From f47453a65622af01eb22a88fe0ac8968326bca53 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 28 Apr 2026 18:36:38 +0800 Subject: [PATCH] =?UTF-8?q?fix:=E4=BC=98=E5=8C=96=E5=BD=93=E5=89=8D?= =?UTF-8?q?=E7=9A=84=E9=A1=B9=E7=9B=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 35 ++ app/config.py | 76 ++- app/main.py | 619 ++++++++++++++++++++++- app/schemas.py | 37 +- app/services/ai_rewriter.py | 34 ++ app/services/user_store.py | 570 ++++++++++++++++++++- app/static/app.js | 243 ++++++++- app/static/auth.js | 33 +- app/static/billing.js | 340 +++++++++++++ app/static/profile.js | 97 ++++ app/static/settings.js | 87 +++- app/static/style.css | 782 +++++++++++++++++++++++++++++ app/static/ui-dialog.js | 101 ++++ app/static/upgrade.js | 292 +++++++++++ app/templates/auth.html | 17 +- app/templates/billing.html | 100 ++++ app/templates/forgot_password.html | 2 +- app/templates/guide.html | 7 +- app/templates/index.html | 18 +- app/templates/profile.html | 75 +++ app/templates/settings.html | 18 +- app/templates/upgrade.html | 177 +++++++ 22 files changed, 3671 insertions(+), 89 deletions(-) create mode 100644 app/static/billing.js create mode 100644 app/static/profile.js create mode 100644 app/static/ui-dialog.js create mode 100644 app/static/upgrade.js create mode 100644 app/templates/billing.html create mode 100644 app/templates/profile.html create mode 100644 app/templates/upgrade.html diff --git a/.env.example b/.env.example index 7b3d4a6..3df8d85 100644 --- a/.env.example +++ b/.env.example @@ -43,3 +43,38 @@ AUTH_SESSION_TTL_SEC=86400 AUTH_REMEMBER_SESSION_TTL_SEC=604800 # 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026) AUTH_PASSWORD_RESET_KEY=x2ws-reset-2026 + +# --- VIP 平台模型配置(用户开启 VIP 后优先使用)--- +# 平台文本模型 +PLATFORM_OPENAI_API_KEY= +# PLATFORM_OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 +PLATFORM_OPENAI_MODEL=qwen-plus +# 平台生图模型 +PLATFORM_OPENAI_IMAGE_MODEL=wanx2.0-t2i-turbo +PLATFORM_OPENAI_TIMEOUT=120 +PLATFORM_OPENAI_MAX_OUTPUT_TOKENS=8192 +PLATFORM_OPENAI_MAX_RETRIES=0 +# 新用户免费试用 Credits +VIP_TRIAL_TOKENS=500 +# 标准坐席每月额度 +CREDITS_SEAT_MONTHLY_QUOTA=1500 +# 文本计费:100万 token 的人民币价格 +CREDITS_TOKEN_PRICE_PER_MILLION_CNY=7.9 +# 图片计费:160 张图 = 0.75 元 +CREDITS_IMAGE_PRICE_PACKAGE_CNY=0.75 +CREDITS_IMAGE_PRICE_PACKAGE_IMAGES=160 +# 兼容字段(旧版):单张图人民币价格 +CREDITS_IMAGE_PRICE_PER_IMAGE_CNY=0.04 +# 兼容字段(旧版):可保留默认,不再作为首选换算规则 +CREDITS_PER_MILLION_TOKENS=1500 +CREDITS_PER_120_IMAGES=1500 +# 标准加油包:19.9 元 = 1500 Credits +CREDITS_RECHARGE_PACKAGE_AMOUNT=19.9 +CREDITS_RECHARGE_PACKAGE_CREDITS=1500 + +# 购物系统打通(可选) +# SHOP_BACKEND_CREATE_ORDER_URL=https://shop.example.com/api/order/create +# 微信支付回调路径(本项目已提供): +# 下单入口:POST https://你的域名/api/pay/wechat/ +# 回调入口:POST https://你的域名/api/pay/wechat/backcall +SHOP_BACKEND_CALLBACK_TOKEN= diff --git a/app/config.py b/app/config.py index fe7b690..61ae5fe 100644 --- a/app/config.py +++ b/app/config.py @@ -75,27 +75,87 @@ class Settings(BaseSettings): auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY") vip_trial_tokens: int = Field( - default=20000, + default=500, alias="VIP_TRIAL_TOKENS", - description="新用户试用赠送 token", + description="新用户试用赠送 Credits", + ) + credits_seat_monthly_quota: int = Field( + default=1500, + alias="CREDITS_SEAT_MONTHLY_QUOTA", + description="标准坐席每月 Credits 额度", + ) + credits_standard_seat_price_cny: float = Field( + default=198.0, + alias="CREDITS_STANDARD_SEAT_PRICE_CNY", + description="标准坐席月费(人民币)", ) vip_rewrite_token_per_1k_chars: int = Field( - default=1200, + default=1500, alias="VIP_REWRITE_TOKEN_PER_1K_CHARS", - description="改写按千字计费 token 单价", + description="兼容字段:改写计费参数(建议使用 Credits 规则字段)", ) vip_image_token_per_image: int = Field( - default=1800, + default=1500, alias="VIP_IMAGE_TOKEN_PER_IMAGE", - description="文生图单张扣减 token", + description="兼容字段:生图计费参数(建议使用 Credits 规则字段)", + ) + credits_per_million_tokens: int = Field( + default=1500, + alias="CREDITS_PER_MILLION_TOKENS", + description="兼容字段:100万 token 对应的 Credits 抵扣(建议使用人民币折算字段)", + ) + credits_per_120_images: int = Field( + default=1500, + alias="CREDITS_PER_120_IMAGES", + description="兼容字段:120 张图片对应的 Credits 抵扣(建议使用人民币折算字段)", + ) + credits_token_price_per_million_cny: float = Field( + default=7.9, + alias="CREDITS_TOKEN_PRICE_PER_MILLION_CNY", + description="文本计费:100万 token 的人民币价格", + ) + credits_image_price_per_image_cny: float = Field( + default=0.04, + alias="CREDITS_IMAGE_PRICE_PER_IMAGE_CNY", + description="兼容字段:生图计费单张价格(建议使用整包折算字段)", + ) + credits_image_price_package_cny: float = Field( + default=0.75, + alias="CREDITS_IMAGE_PRICE_PACKAGE_CNY", + description="生图计费:图片整包人民币价格", + ) + credits_image_price_package_images: int = Field( + default=160, + alias="CREDITS_IMAGE_PRICE_PACKAGE_IMAGES", + description="生图计费:整包包含图片张数", + ) + credits_recharge_package_amount: float = Field( + default=19.9, + alias="CREDITS_RECHARGE_PACKAGE_AMOUNT", + description="标准加油包价格(元)", + ) + credits_recharge_package_credits: int = Field( + default=1500, + alias="CREDITS_RECHARGE_PACKAGE_CREDITS", + description="标准加油包 Credits 数量", ) platform_openai_api_key: str | None = Field(default=None, alias="PLATFORM_OPENAI_API_KEY") platform_openai_base_url: str | None = Field(default=None, alias="PLATFORM_OPENAI_BASE_URL") - platform_openai_model: str = Field(default="gpt-4.1-mini", alias="PLATFORM_OPENAI_MODEL") - platform_openai_image_model: str = Field(default="gpt-image-1", alias="PLATFORM_OPENAI_IMAGE_MODEL") + platform_openai_model: str = Field(default="qwen-plus", alias="PLATFORM_OPENAI_MODEL") + platform_openai_image_model: str = Field(default="wanx2.0-t2i-turbo", alias="PLATFORM_OPENAI_IMAGE_MODEL") + platform_openai_text_model_options: str = Field( + default="gpt-4.1-mini,gpt-4.1,gpt-4o-mini,qwen-plus,qwen-max", + alias="PLATFORM_OPENAI_TEXT_MODEL_OPTIONS", + ) + platform_openai_image_model_options: str = Field( + default="wanx2.0-t2i-turbo,wanx2.1-t2i-plus,wanx2.1-t2i-turbo,gpt-image-1,dall-e-3", + alias="PLATFORM_OPENAI_IMAGE_MODEL_OPTIONS", + ) platform_openai_timeout: float = Field(default=120.0, alias="PLATFORM_OPENAI_TIMEOUT") platform_openai_max_output_tokens: int = Field(default=8192, alias="PLATFORM_OPENAI_MAX_OUTPUT_TOKENS") platform_openai_max_retries: int = Field(default=0, alias="PLATFORM_OPENAI_MAX_RETRIES") + shop_backend_create_order_url: str | None = Field(default=None, alias="SHOP_BACKEND_CREATE_ORDER_URL") + shop_backend_callback_token: str = Field(default="", alias="SHOP_BACKEND_CALLBACK_TOKEN") settings = Settings() diff --git a/app/main.py b/app/main.py index 6445f1f..4eb9f0e 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,11 @@ from __future__ import annotations import logging +import math +import re +import secrets +import time +import uuid from pathlib import Path from urllib.parse import urlparse @@ -16,8 +21,12 @@ from app.middleware import RequestContextMiddleware from app.schemas import ( AIModelCreateRequest, AIModelDeleteRequest, + AIImageModelUpdateRequest, AIModelSwitchRequest, AuthCredentialRequest, + BillingRechargeCreateRequest, + BillingPayNowRequest, + BillingRechargeNotifyRequest, ChangePasswordRequest, DeleteAccountRequest, ForgotPasswordResetRequest, @@ -30,6 +39,7 @@ from app.schemas import ( WechatBindingRequest, WechatPublishRequest, WechatSwitchRequest, + UserProfileUpdateRequest, VipRechargeRequest, VipToggleRequest, ) @@ -64,6 +74,11 @@ wechat = WechatPublisher() poster_material = PosterMaterialService(wechat) im = IMPublisher() users = UserStore(settings.auth_db_path) +_register_rate: dict[str, list[float]] = {} +_login_rate: dict[str, list[float]] = {} +_challenge_pool: dict[str, dict] = {} +USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{4,24}$") +PASSWORD_STRONG_RE = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{10,64}$") def _session_ttl(remember_me: bool) -> int: @@ -84,6 +99,67 @@ def _require_user(request: Request) -> dict | None: return u +def _client_ip(request: Request) -> str: + xff = (request.headers.get("x-forwarded-for") or "").strip() + if xff: + return xff.split(",")[0].strip() + return (request.client.host if request.client else "") or "unknown" + + +def _hit_limit(bucket: dict[str, list[float]], key: str, limit: int, window_sec: int) -> bool: + now = time.time() + points = bucket.get(key, []) + points = [p for p in points if now - p <= window_sec] + points.append(now) + bucket[key] = points + return len(points) > limit + + +def _create_challenge() -> dict: + a = secrets.randbelow(8) + 2 + b = secrets.randbelow(9) + 1 + op = "+" if secrets.randbelow(2) == 0 else "-" + if op == "-": + a, b = max(a, b) + 5, min(a, b) + answer = str(a + b) if op == "+" else str(a - b) + cid = secrets.token_urlsafe(16) + _challenge_pool[cid] = {"answer": answer, "created_at": time.time(), "used": False} + return {"challenge_id": cid, "question": f"{a} {op} {b} = ?"} + + +def _verify_challenge(req: AuthCredentialRequest) -> tuple[bool, str]: + cid = (req.challenge_id or "").strip() + ans = (req.challenge_answer or "").strip() + if req.honeypot: + return False, "请求被拒绝" + if not cid or not ans: + return False, "请先完成人机校验" + item = _challenge_pool.get(cid) + if not item: + return False, "校验已失效,请刷新后重试" + if item.get("used"): + return False, "校验已使用,请刷新后重试" + age = time.time() - float(item.get("created_at") or 0) + if age < 2: + return False, "提交过快,请稍后重试" + if age > 300: + return False, "校验已过期,请刷新后重试" + if ans != str(item.get("answer") or ""): + return False, "人机校验答案错误" + item["used"] = True + return True, "" + + +def _validate_username_password(username: str, password: str) -> tuple[bool, str]: + if not USERNAME_RE.match(username): + return False, "用户名需为 4-24 位,仅支持字母/数字/下划线" + if not PASSWORD_STRONG_RE.match(password): + return False, "密码需 10-64 位,且包含大小写字母、数字和特殊字符" + if username.lower() in password.lower(): + return False, "密码不能包含用户名" + return True, "" + + def _platform_model_cfg() -> dict: return { "api_key": settings.platform_openai_api_key or "", @@ -99,7 +175,9 @@ def _platform_model_cfg() -> dict: def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | None, str]: vip = users.get_vip_status(user_id) - if prefer_vip and vip.get("vip_enabled") and int(vip.get("token_balance") or 0) > 0: + if prefer_vip and vip.get("vip_enabled"): + if int(vip.get("total_available_credits") or 0) <= 0: + return None, "vip_empty" cfg = _platform_model_cfg() if cfg.get("api_key"): return cfg, "vip" @@ -107,12 +185,114 @@ def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | Non return cfg, "user" +def _quota_detail() -> str: + return "Credits 额度已用完(席位额度+共享加油包),请充值或等待下个计费周期" + + +def _credits_from_cny(amount_cny: float) -> int: + package_credits = max(1, int(settings.credits_recharge_package_credits)) + package_amount = max(0.01, float(settings.credits_recharge_package_amount)) + cny = max(0.0, float(amount_cny)) + if cny <= 0: + return 0 + return max(1, int(math.ceil((cny * package_credits) / package_amount))) + + def _estimate_rewrite_cost(req: RewriteRequest, result) -> int: + trace = getattr(result, "trace", None) or {} + usage = trace.get("usage") if isinstance(trace, dict) else {} + usage_total_tokens = int((usage or {}).get("total_tokens") or 0) + if usage_total_tokens > 0: + token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny)) + return _credits_from_cny((usage_total_tokens / 1_000_000.0) * token_price_cny) src_chars = len((req.source_text or "").strip()) out_chars = len((result.body_markdown or "").strip()) + len((result.title or "").strip()) + len((result.summary or "").strip()) total_chars = max(1, src_chars + out_chars) - blocks = (total_chars + 999) // 1000 - return int(blocks * max(1, int(settings.vip_rewrite_token_per_1k_chars))) + estimated_tokens = int(total_chars * 1.8) + token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny)) + return _credits_from_cny((max(1, estimated_tokens) / 1_000_000.0) * token_price_cny) + + +def _estimate_image_cost(image_count: int) -> int: + cnt = max(0, int(image_count)) + if cnt <= 0: + return 0 + pkg_images = max(1, int(settings.credits_image_price_package_images or 160)) + pkg_cny = max(0.0, float(settings.credits_image_price_package_cny or 0.75)) + return _credits_from_cny((cnt / pkg_images) * pkg_cny) + + +def _new_order_no() -> str: + return f"RC{time.strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}" + + +async def _create_recharge_order_with_pay_url( + user_id: int, + username: str, + credits: int, + callback_url: str, + order_meta: dict | None = None, + channel: str = "wechat", +) -> tuple[dict, str]: + package_credits = max(1, int(settings.credits_recharge_package_credits)) + package_amount = float(settings.credits_recharge_package_amount) + amount_cny = round((int(credits) / package_credits) * package_amount, 2) + order_no = _new_order_no() + order = users.create_recharge_order( + user_id=user_id, + order_no=order_no, + channel=(channel or "wechat").strip() or "wechat", + token_amount=int(credits), + amount_cny=amount_cny, + meta={"username": username, **(order_meta or {})}, + ) + pay_payload = { + "order_no": order_no, + "user_id": int(user_id), + "username": username, + "callback_url": callback_url, + **order, + } + pay_url = "" + if settings.shop_backend_create_order_url: + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post( + settings.shop_backend_create_order_url, + json=pay_payload, + headers={"X-Shop-Token": settings.shop_backend_callback_token or ""}, + ) + body = r.json() if r.content else {} + if r.status_code < 400 and isinstance(body, dict): + pay_url = str(body.get("pay_url") or "") + except Exception as exc: + logger.warning("shop_create_order_failed order=%s err=%s", order_no, exc) + return order, pay_url + + +async def _fetch_pay_url_for_order(order: dict, user_id: int, username: str, callback_url: str) -> str: + pay_payload = { + "order_no": order.get("order_no", ""), + "user_id": int(user_id), + "username": username, + "callback_url": callback_url, + **order, + } + pay_url = "" + if settings.shop_backend_create_order_url: + try: + async with httpx.AsyncClient(timeout=15) as client: + r = await client.post( + settings.shop_backend_create_order_url, + json=pay_payload, + headers={"X-Shop-Token": settings.shop_backend_callback_token or ""}, + ) + body = r.json() if r.content else {} + if r.status_code < 400 and isinstance(body, dict): + pay_url = str(body.get("pay_url") or "") + except Exception as exc: + logger.warning("shop_repay_order_failed order=%s err=%s", order.get("order_no", ""), exc) + return pay_url @app.get("/", response_class=HTMLResponse) @@ -140,6 +320,48 @@ async def settings_page(request: Request): return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name}) +@app.get("/billing", response_class=HTMLResponse) +async def billing_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/billing", status_code=302) + return templates.TemplateResponse( + "billing.html", + { + "request": request, + "app_name": settings.app_name, + "package_amount": settings.credits_recharge_package_amount, + "package_credits": settings.credits_recharge_package_credits, + }, + ) + + +@app.get("/upgrade", response_class=HTMLResponse) +async def upgrade_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/upgrade", status_code=302) + return templates.TemplateResponse( + "upgrade.html", + { + "request": request, + "app_name": settings.app_name, + "trial_tokens": settings.vip_trial_tokens, + "rewrite_cost": settings.credits_per_million_tokens, + "image_cost": settings.credits_per_120_images, + "seat_price": settings.credits_standard_seat_price_cny, + "seat_quota": settings.credits_seat_monthly_quota, + "package_amount": settings.credits_recharge_package_amount, + "package_credits": settings.credits_recharge_package_credits, + }, + ) + + +@app.get("/profile", response_class=HTMLResponse) +async def profile_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/profile", status_code=302) + return templates.TemplateResponse("profile.html", {"request": request, "app_name": settings.app_name}) + + @app.get("/guide", response_class=HTMLResponse) async def guide_page(request: Request): if not _current_user(request): @@ -197,14 +419,48 @@ async def auth_me(request: Request): } +@app.get("/api/profile") +async def api_profile(request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + return {"ok": True, "profile": users.get_user_profile(user["id"])} + + +@app.post("/api/profile") +async def api_profile_update(req: UserProfileUpdateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + profile = users.save_user_profile( + user["id"], + subscriber_name=(req.subscriber_name or "").strip(), + subscriber_phone=(req.subscriber_phone or "").strip(), + shipping_address=(req.shipping_address or "").strip(), + ) + return {"ok": True, "detail": "个人信息已保存", "profile": profile} + + +@app.get("/api/auth/challenge") +async def auth_challenge(): + return {"ok": True, **_create_challenge()} + + @app.post("/api/auth/register") -async def auth_register(req: AuthCredentialRequest, response: Response): +async def auth_register(req: AuthCredentialRequest, request: Request, response: Response): + ip = _client_ip(request) + if _hit_limit(_register_rate, f"ip:{ip}", limit=8, window_sec=600): + return {"ok": False, "detail": "请求过于频繁,请稍后再试"} + if _hit_limit(_register_rate, f"user:{(req.username or '').strip().lower()}", limit=6, window_sec=600): + return {"ok": False, "detail": "该用户名操作过于频繁,请稍后再试"} username = (req.username or "").strip() password = req.password or "" - if len(username) < 2: - return {"ok": False, "detail": "用户名至少 2 个字符"} - if len(password) < 6: - return {"ok": False, "detail": "密码至少 6 个字符"} + ok, msg = _validate_username_password(username, password) + if not ok: + return {"ok": False, "detail": msg} + ok_challenge, challenge_msg = _verify_challenge(req) + if not ok_challenge: + return {"ok": False, "detail": challenge_msg} try: user = users.create_user(username, password) except Exception as exc: @@ -225,14 +481,19 @@ async def auth_register(req: AuthCredentialRequest, response: Response): ) return { "ok": True, - "detail": "注册并登录成功,已赠送试用 token,请保存重置码", + "detail": "注册并登录成功,已赠送试用 Credits,请保存重置码", "user": {"id": user["id"], "username": user["username"]}, "reset_code": user.get("reset_code", ""), } @app.post("/api/auth/login") -async def auth_login(req: AuthCredentialRequest, response: Response): +async def auth_login(req: AuthCredentialRequest, request: Request, response: Response): + ip = _client_ip(request) + if _hit_limit(_login_rate, f"ip:{ip}", limit=20, window_sec=600): + return {"ok": False, "detail": "登录过于频繁,请稍后再试"} + if _hit_limit(_login_rate, f"user:{(req.username or '').strip().lower()}", limit=12, window_sec=600): + return {"ok": False, "detail": "该账户登录尝试过多,请稍后再试"} try: user = users.verify_user((req.username or "").strip(), req.password or "") except Exception as exc: @@ -335,6 +596,217 @@ async def auth_delete_account(req: DeleteAccountRequest, request: Request, respo return {"ok": True, "detail": "账号已注销,关联数据已清空"} +@app.get("/api/auth/vip/status") +async def auth_vip_status(request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + return {"ok": True, "vip": users.get_vip_status(user["id"])} + + +@app.post("/api/auth/vip/toggle") +async def auth_vip_toggle(req: VipToggleRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + vip = users.set_vip_enabled(user["id"], bool(req.enabled)) + return {"ok": True, "detail": "VIP 模式已更新", "vip": vip} + + +@app.post("/api/auth/vip/recharge") +async def auth_vip_recharge(req: VipRechargeRequest, request: Request): + token = (request.headers.get("X-Shop-Token") or "").strip() + if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token: + raise HTTPException(status_code=403, detail="直充接口已禁用,请走支付订单接口") + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + vip = users.recharge_tokens( + user["id"], + int(req.tokens), + kind="admin_direct_recharge", + ref_type="admin", + ref_id="", + detail={"source": "legacy_api"}, + ) + return {"ok": True, "detail": "充值成功", "vip": vip} + + +@app.get("/api/billing/overview") +async def billing_overview(request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + return { + "ok": True, + "vip": users.get_vip_status(user["id"]), + "recharge_records": users.list_recharge_orders(user["id"], limit=30), + "consume_records": users.list_token_ledger(user["id"], limit=100), + } + + +@app.post("/api/billing/recharge/create") +async def billing_recharge_create(req: BillingRechargeCreateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + order, pay_url = await _create_recharge_order_with_pay_url( + user_id=user["id"], + username=user["username"], + credits=int(req.tokens), + callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall", + order_meta={ + "subscriber_name": (req.subscriber_name or "").strip(), + "subscriber_phone": (req.subscriber_phone or "").strip(), + "shipping_address": (req.shipping_address or "").strip(), + }, + channel=(req.channel or "wechat").strip() or "wechat", + ) + return {"ok": True, "detail": "充值订单已创建", "order": order, "pay_url": pay_url} + + +@app.post("/api/billing/recharge/pay-now") +async def billing_recharge_pay_now(req: BillingPayNowRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + order_no = (req.order_no or "").strip() + if not order_no: + return {"ok": False, "detail": "订单号不能为空"} + order = users.get_recharge_order(user["id"], order_no) + if not order: + return {"ok": False, "detail": "订单不存在"} + status = (order.get("status") or "").lower() + if status in {"paid", "success"}: + return {"ok": False, "detail": "订单已支付,无需重复支付", "order": order} + if status in {"cancelled", "closed"}: + return {"ok": False, "detail": "订单已取消,请重新创建订单", "order": order} + pay_url = await _fetch_pay_url_for_order( + order=order, + user_id=user["id"], + username=user["username"], + callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall", + ) + return {"ok": True, "detail": "支付链接已生成", "order": order, "pay_url": pay_url} + + +@app.get("/api/pay/wechat/") +async def pay_wechat_ready(): + return {"ok": True, "detail": "wechat pay api ready"} + + +@app.post("/api/pay/wechat/") +async def pay_wechat_create(req: BillingRechargeCreateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + order, pay_url = await _create_recharge_order_with_pay_url( + user_id=user["id"], + username=user["username"], + credits=int(req.tokens), + callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall", + order_meta={ + "subscriber_name": (req.subscriber_name or "").strip(), + "subscriber_phone": (req.subscriber_phone or "").strip(), + "shipping_address": (req.shipping_address or "").strip(), + }, + channel=(req.channel or "wechat").strip() or "wechat", + ) + return {"ok": True, "detail": "支付订单已创建", "order": order, "pay_url": pay_url} + + +@app.get("/api/pay/address/suggest") +async def pay_address_suggest(request: Request): + ip = _client_ip(request) + if not ip or ip in {"unknown", "127.0.0.1", "::1"}: + return {"ok": True, "detail": "本地环境,建议手动填写地址", "ip": ip or "unknown", "address": ""} + url = f"https://ip-api.com/json/{ip}?lang=zh-CN" + try: + async with httpx.AsyncClient(timeout=5) as client: + r = await client.get(url) + body = r.json() if r.content else {} + if r.status_code >= 400 or not isinstance(body, dict): + return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"} + if body.get("status") != "success": + return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"} + country = str(body.get("country") or "").strip() + region = str(body.get("regionName") or "").strip() + city = str(body.get("city") or "").strip() + isp = str(body.get("isp") or "").strip() + addr = "".join([x for x in [country, region, city] if x]) + if isp: + addr = f"{addr}({isp})" if addr else isp + if not addr: + addr = f"IP:{ip}" + return {"ok": True, "detail": "已根据IP自动识别地址并填充,可手动修改", "ip": ip, "address": addr} + except Exception: + return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"} + + +@app.post("/api/billing/recharge/notify") +async def billing_recharge_notify(req: BillingRechargeNotifyRequest, request: Request): + token = (request.headers.get("X-Shop-Token") or "").strip() + if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token: + raise HTTPException(status_code=403, detail="forbidden") + user = _require_user(request) + uid = user["id"] if user else 0 + # 回调优先根据登录态用户确认;若无登录态,可通过订单所属用户在 DB 内判断 + if uid <= 0: + # 无用户态时,先查订单归属(通过 mark 接口会二次校验) + uid = -1 + if (req.status or "").lower() not in {"paid", "success"}: + return {"ok": True, "detail": "ignored"} + if uid > 0: + ok, msg = users.mark_recharge_order_paid( + uid, + req.order_no, + paid_amount_cny=float(req.paid_amount_cny or 0.0), + external_txn_id=(req.external_txn_id or "").strip(), + meta={"status": req.status}, + ) + if not ok: + return {"ok": False, "detail": msg} + return {"ok": True, "detail": msg} + # no session callback path: iterate known users is unavailable; use direct sql in store by probing order owner + # fallback: let store resolve user by order internally via new helper-like behavior + owner_id = users.get_recharge_order_user_id(req.order_no) + if not owner_id: + return {"ok": False, "detail": "订单不存在"} + ok, msg = users.mark_recharge_order_paid( + int(owner_id), + req.order_no, + paid_amount_cny=float(req.paid_amount_cny or 0.0), + external_txn_id=(req.external_txn_id or "").strip(), + meta={"status": req.status}, + ) + if not ok: + return {"ok": False, "detail": msg} + return {"ok": True, "detail": msg} + + +@app.post("/api/pay/wechat/backcall") +async def pay_wechat_backcall(req: BillingRechargeNotifyRequest, request: Request): + configured = (settings.shop_backend_callback_token or "").strip() + provided = (request.headers.get("X-Shop-Token") or request.query_params.get("token") or "").strip() + if configured and provided != configured: + raise HTTPException(status_code=403, detail="forbidden") + if (req.status or "").lower() not in {"paid", "success"}: + return {"ok": True, "detail": "ignored"} + owner_id = users.get_recharge_order_user_id(req.order_no) + if not owner_id: + return {"ok": False, "detail": "订单不存在"} + ok, msg = users.mark_recharge_order_paid( + int(owner_id), + req.order_no, + paid_amount_cny=float(req.paid_amount_cny or 0.0), + external_txn_id=(req.external_txn_id or "").strip(), + meta={"status": req.status, "source": "wechat_backcall"}, + ) + if not ok: + return {"ok": False, "detail": msg} + return {"ok": True, "detail": msg} + + @app.post("/api/auth/wechat/bind") async def auth_wechat_bind(req: WechatBindingRequest, request: Request): user = _require_user(request) @@ -425,6 +897,20 @@ async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request): return {"ok": True, "detail": "模型配置已删除"} +@app.post("/api/auth/ai-models/image-model/update") +async def auth_ai_image_model_update(req: AIImageModelUpdateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + image_model = (req.image_model or "").strip() + if not image_model: + return {"ok": False, "detail": "文生图模型不能为空"} + ok = users.update_active_ai_image_model(user["id"], image_model) + if not ok: + return {"ok": False, "detail": "保存失败:请先配置文本模型"} + return {"ok": True, "detail": "文生图模型已更新"} + + @app.post("/api/rewrite") async def rewrite(req: RewriteRequest, request: Request): rid = getattr(request.state, "request_id", "") @@ -444,7 +930,9 @@ async def rewrite(req: RewriteRequest, request: Request): user = _require_user(request) if not user: raise HTTPException(status_code=401, detail="请先登录") - model_cfg = users.get_active_ai_model(user["id"]) + model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True) + if model_source == "vip_empty": + raise HTTPException(status_code=402, detail=_quota_detail()) if not model_cfg: raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型") backup = { @@ -472,6 +960,50 @@ async def rewrite(req: RewriteRequest, request: Request): settings.openai_max_output_tokens = backup["openai_max_output_tokens"] settings.openai_max_retries = backup["openai_max_retries"] settings.openai_image_model = backup["openai_image_model"] + usage = ((result.trace or {}).get("usage") or {}) if isinstance(result.trace, dict) else {} + prompt_tokens = int(usage.get("prompt_tokens") or 0) + completion_tokens = int(usage.get("completion_tokens") or 0) + total_tokens = int(usage.get("total_tokens") or 0) + billed_basis = "usage_tokens" if total_tokens > 0 else "char_estimate" + token_cost = _estimate_rewrite_cost(req, result) + vip_status = users.get_vip_status(user["id"]) + should_consume = bool(vip_status.get("vip_enabled")) + if should_consume: + ok_cost, balance = users.consume_tokens( + user["id"], + token_cost, + kind="rewrite", + ref_type="request", + ref_id=rid, + detail={ + "source_chars": len((req.source_text or "").strip()), + "target_body_chars": int(req.target_body_chars or 0), + "title_chars": len((result.title or "").strip()), + "summary_chars": len((result.summary or "").strip()), + "body_chars": len((result.body_markdown or "").strip()), + "model": model_cfg.get("model") if model_cfg else "", + "prompt_tokens": prompt_tokens, + "completion_tokens": completion_tokens, + "total_tokens": total_tokens, + "credits_rule": ( + f"1000000 tokens={float(settings.credits_token_price_per_million_cny):.2f}元," + f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元," + f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元" + ), + "billed_basis": billed_basis, + }, + ) + if not ok_cost: + raise HTTPException(status_code=402, detail=_quota_detail()) + if result.trace is None: + result.trace = {} + result.trace["vip"] = { + "model_source": "platform" if model_source == "vip" else "user", + "credits_cost": token_cost, + "credits_balance": balance, + "billed_basis": billed_basis, + "total_tokens": total_tokens, + } tr = result.trace or {} logger.info( "api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s", @@ -599,7 +1131,10 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques len(req.summary or ""), req.upload_to_wechat, ) - model_cfg = users.get_active_ai_model(user["id"]) + model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True) + if model_source == "vip_empty": + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + image_model_override = (req.image_model or "").strip() backup = { "openai_api_key": settings.openai_api_key, "openai_base_url": settings.openai_base_url, @@ -626,6 +1161,8 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques settings.openai_max_output_tokens = 8192 settings.openai_max_retries = 0 settings.openai_image_model = backup["openai_image_model"] + if image_model_override: + settings.openai_image_model = image_model_override out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding) finally: settings.openai_api_key = backup["openai_api_key"] @@ -643,6 +1180,31 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques out.note, len(out.warnings), ) + if out.ok and model_source == "vip": + token_cost = _estimate_image_cost(1) + ok_cost, balance = users.consume_tokens( + user["id"], + token_cost, + kind="cover_generate", + ref_type="request", + ref_id=rid, + detail={ + "title": (req.title or "")[:120], + "style_hint": (req.style_hint or "")[:120], + "image_count": 1, + "image_model": image_model_override or settings.openai_image_model, + "image_price_package_cny": float(settings.credits_image_price_package_cny), + "image_price_package_images": int(settings.credits_image_price_package_images), + "credits_rule": ( + f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元," + f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元" + ), + }, + ) + if not ok_cost: + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + out.warnings = list(out.warnings or []) + out.warnings.append(f"已扣减 {token_cost} Credits,可用余额 {balance}") return out @@ -684,7 +1246,10 @@ async def generate_posters(req: PosterGenerateRequest, request: Request): req.upload_to_wechat, int(req.max_images or 0), ) - model_cfg = users.get_active_ai_model(user["id"]) + model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True) + if model_source == "vip_empty": + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + image_model_override = (req.image_model or "").strip() backup = { "openai_api_key": settings.openai_api_key, "openai_base_url": settings.openai_base_url, @@ -711,6 +1276,8 @@ async def generate_posters(req: PosterGenerateRequest, request: Request): settings.openai_max_output_tokens = 8192 settings.openai_max_retries = 0 settings.openai_image_model = backup["openai_image_model"] + if image_model_override: + settings.openai_image_model = image_model_override out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding) finally: settings.openai_api_key = backup["openai_api_key"] @@ -727,6 +1294,32 @@ async def generate_posters(req: PosterGenerateRequest, request: Request): len(out.posters), len(out.warnings), ) + if out.ok and model_source == "vip": + image_count = max(0, len(out.posters or [])) + token_cost = _estimate_image_cost(image_count) + ok_cost, balance = users.consume_tokens( + user["id"], + token_cost, + kind="poster_generate", + ref_type="request", + ref_id=rid, + detail={ + "image_count": image_count, + "body_chars": len((req.body_markdown or "").strip()), + "max_images": int(req.max_images or 0), + "image_model": image_model_override or settings.openai_image_model, + "image_price_package_cny": float(settings.credits_image_price_package_cny), + "image_price_package_images": int(settings.credits_image_price_package_images), + "credits_rule": ( + f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元," + f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}元" + ), + }, + ) + if not ok_cost: + return {"ok": False, "detail": _quota_detail(), "upgrade_required": True} + out.warnings = list(out.warnings or []) + out.warnings.append(f"已扣减 {token_cost} Credits,可用余额 {balance}") return out diff --git a/app/schemas.py b/app/schemas.py index 2ca6ba4..68382c9 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -54,6 +54,9 @@ class AuthCredentialRequest(BaseModel): username: str password: str remember_me: bool = False + challenge_id: str = "" + challenge_answer: str = "" + honeypot: str = "" class ChangePasswordRequest(BaseModel): @@ -97,6 +100,7 @@ class WechatCoverGenerateRequest(BaseModel): title: str = "" summary: str = "" style_hint: str = "" + image_model: str = "" upload_to_wechat: bool = True @@ -119,12 +123,42 @@ class AIModelDeleteRequest(BaseModel): model_id: int +class AIImageModelUpdateRequest(BaseModel): + image_model: str + + class VipToggleRequest(BaseModel): enabled: bool = True class VipRechargeRequest(BaseModel): - tokens: int = Field(default=10000, ge=1, le=10_000_000) + tokens: int = Field(default=1500, ge=1, le=10_000_000) + + +class BillingRechargeCreateRequest(BaseModel): + tokens: int = Field(default=1500, ge=1, le=10_000_000) + amount_cny: float = Field(default=19.9, ge=0.01, le=999999) + channel: str = "wechat" + subscriber_name: str = "" + subscriber_phone: str = "" + shipping_address: str = "" + + +class BillingRechargeNotifyRequest(BaseModel): + order_no: str + paid_amount_cny: float = Field(default=0.0, ge=0.0) + external_txn_id: str = "" + status: str = "paid" + + +class BillingPayNowRequest(BaseModel): + order_no: str + + +class UserProfileUpdateRequest(BaseModel): + subscriber_name: str = "" + subscriber_phone: str = "" + shipping_address: str = "" class PosterGenerateRequest(BaseModel): @@ -132,6 +166,7 @@ class PosterGenerateRequest(BaseModel): summary: str = "" body_markdown: str = Field(..., min_length=20) style_hint: str = "" + image_model: str = "" upload_to_wechat: bool = True max_images: int = Field(default=6, ge=1, le=12) diff --git a/app/services/ai_rewriter.py b/app/services/ai_rewriter.py index 18dd5f8..8ddb12f 100644 --- a/app/services/ai_rewriter.py +++ b/app/services/ai_rewriter.py @@ -108,6 +108,10 @@ class AIRewriter: def __init__(self) -> None: self._client = None self._prefer_chat_first = False + self._usage_prompt_tokens = 0 + self._usage_completion_tokens = 0 + self._usage_total_tokens = 0 + self._usage_calls = 0 if settings.openai_api_key: base_url = settings.openai_base_url or "" self._prefer_chat_first = "dashscope.aliyuncs.com" in base_url @@ -128,6 +132,22 @@ class AIRewriter: else: logger.warning("AIRewriter_init openai_key_missing=1 rewrite_will_use_fallback_only=1") + def _accumulate_usage(self, usage: Any) -> None: + if usage is None: + return + data = usage.model_dump() if hasattr(usage, "model_dump") else usage + if not isinstance(data, dict): + return + prompt = int(data.get("prompt_tokens") or data.get("input_tokens") or 0) + completion = int(data.get("completion_tokens") or data.get("output_tokens") or 0) + total = int(data.get("total_tokens") or 0) + if total <= 0: + total = max(0, prompt + completion) + self._usage_prompt_tokens += max(0, prompt) + self._usage_completion_tokens += max(0, completion) + self._usage_total_tokens += max(0, total) + self._usage_calls += 1 + def rewrite(self, req: RewriteRequest, request_id: str = "") -> RewriteResponse: cleaned_source = self._clean_source(req.source_text) started = time.monotonic() @@ -256,6 +276,12 @@ class AIRewriter: ) trace["quality_issues_final"] = final_issues if not final_issues: + trace["usage"] = { + "prompt_tokens": int(self._usage_prompt_tokens), + "completion_tokens": int(self._usage_completion_tokens), + "total_tokens": int(self._usage_total_tokens), + "model_calls": int(self._usage_calls), + } trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1) trace["mode"] = "ai" logger.info( @@ -266,6 +292,12 @@ class AIRewriter: return RewriteResponse(**normalized, mode="ai", quality_notes=[], trace=trace) # 模型已返回有效 JSON:默认「软接受」——仍视为 AI 洗稿,质检问题写入 quality_notes,避免误用模板稿 if settings.ai_soft_accept and self._model_output_usable(normalized): + trace["usage"] = { + "prompt_tokens": int(self._usage_prompt_tokens), + "completion_tokens": int(self._usage_completion_tokens), + "total_tokens": int(self._usage_total_tokens), + "model_calls": int(self._usage_calls), + } trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1) trace["mode"] = "ai" trace["quality_soft_accept"] = True @@ -669,6 +701,7 @@ class AIRewriter: msg = (choice.message.content if choice else "") or "" fr = getattr(choice, "finish_reason", None) if choice else None usage = getattr(completion, "usage", None) + self._accumulate_usage(usage) udump = ( usage.model_dump() if usage is not None and hasattr(usage, "model_dump") @@ -755,6 +788,7 @@ class AIRewriter: text={"format": {"type": "json_object"}}, timeout=timeout_sec, ) + self._accumulate_usage(getattr(completion, "usage", None)) output_text = completion.output_text or "" ms = (time.monotonic() - t0) * 1000 logger.info( diff --git a/app/services/user_store.py b/app/services/user_store.py index 3fe6551..589fba7 100644 --- a/app/services/user_store.py +++ b/app/services/user_store.py @@ -2,6 +2,7 @@ from __future__ import annotations import hashlib import hmac +import json import secrets import sqlite3 import time @@ -69,6 +70,12 @@ class UserStore: pref_cols = self._table_columns(c, "user_prefs") if "active_ai_model_id" not in pref_cols: c.execute("ALTER TABLE user_prefs ADD COLUMN active_ai_model_id INTEGER") + if "subscriber_name" not in pref_cols: + c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_name TEXT NOT NULL DEFAULT ''") + if "subscriber_phone" not in pref_cols: + c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_phone TEXT NOT NULL DEFAULT ''") + if "shipping_address" not in pref_cols: + c.execute("ALTER TABLE user_prefs ADD COLUMN shipping_address TEXT NOT NULL DEFAULT ''") c.execute( """ CREATE TABLE IF NOT EXISTS ai_models ( @@ -95,11 +102,62 @@ class UserStore: vip_enabled INTEGER NOT NULL DEFAULT 0, token_balance INTEGER NOT NULL DEFAULT 0, total_consumed_tokens INTEGER NOT NULL DEFAULT 0, + seat_quota_credits INTEGER NOT NULL DEFAULT 1500, + seat_used_credits INTEGER NOT NULL DEFAULT 0, + seat_cycle TEXT NOT NULL DEFAULT '', + cycle_started_at INTEGER NOT NULL DEFAULT 0, + cycle_expires_at INTEGER NOT NULL DEFAULT 0, updated_at INTEGER NOT NULL, FOREIGN KEY(user_id) REFERENCES users(id) ) """ ) + wallet_cols = self._table_columns(c, "user_wallets") + if "seat_quota_credits" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN seat_quota_credits INTEGER NOT NULL DEFAULT 25000") + if "seat_used_credits" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN seat_used_credits INTEGER NOT NULL DEFAULT 0") + if "seat_cycle" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN seat_cycle TEXT NOT NULL DEFAULT ''") + if "cycle_started_at" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_started_at INTEGER NOT NULL DEFAULT 0") + if "cycle_expires_at" not in wallet_cols: + c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_expires_at INTEGER NOT NULL DEFAULT 0") + c.execute( + """ + CREATE TABLE IF NOT EXISTS recharge_orders ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + order_no TEXT NOT NULL UNIQUE, + channel TEXT NOT NULL DEFAULT '', + token_amount INTEGER NOT NULL DEFAULT 0, + amount_cny REAL NOT NULL DEFAULT 0.0, + status TEXT NOT NULL DEFAULT 'pending', + external_txn_id TEXT NOT NULL DEFAULT '', + meta_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + paid_at INTEGER, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) + c.execute( + """ + CREATE TABLE IF NOT EXISTS token_ledger ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + direction TEXT NOT NULL, + token_change INTEGER NOT NULL, + balance_after INTEGER NOT NULL, + kind TEXT NOT NULL DEFAULT '', + ref_type TEXT NOT NULL DEFAULT '', + ref_id TEXT NOT NULL DEFAULT '', + detail_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) ai_cols = self._table_columns(c, "ai_models") if "image_model" not in ai_cols: c.execute("ALTER TABLE ai_models ADD COLUMN image_model TEXT NOT NULL DEFAULT ''") @@ -438,6 +496,57 @@ class UserStore: return None return {"id": int(row["id"]), "username": row["username"]} + def get_user_profile(self, user_id: int) -> dict: + with self._conn() as c: + row = c.execute( + """ + SELECT subscriber_name, subscriber_phone, shipping_address + FROM user_prefs + WHERE user_id=? + LIMIT 1 + """, + (user_id,), + ).fetchone() + if not row: + return {"subscriber_name": "", "subscriber_phone": "", "shipping_address": ""} + return { + "subscriber_name": (row["subscriber_name"] or "").strip(), + "subscriber_phone": (row["subscriber_phone"] or "").strip(), + "shipping_address": (row["shipping_address"] or "").strip(), + } + + def save_user_profile( + self, + user_id: int, + *, + subscriber_name: str, + subscriber_phone: str, + shipping_address: str, + ) -> dict: + now = int(time.time()) + with self._conn() as c: + c.execute( + """ + INSERT INTO user_prefs( + user_id, active_wechat_account_id, active_ai_model_id, + subscriber_name, subscriber_phone, shipping_address, updated_at + ) VALUES (?, NULL, NULL, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + subscriber_name=excluded.subscriber_name, + subscriber_phone=excluded.subscriber_phone, + shipping_address=excluded.shipping_address, + updated_at=excluded.updated_at + """, + ( + user_id, + (subscriber_name or "").strip(), + (subscriber_phone or "").strip(), + (shipping_address or "").strip(), + now, + ), + ) + return self.get_user_profile(user_id) + def delete_user_logically(self, user_id: int, password: str, reset_code: str) -> bool: now = int(time.time()) with self._conn() as c: @@ -464,6 +573,8 @@ class UserStore: c.execute("DELETE FROM user_prefs WHERE user_id=?", (user_id,)) c.execute("DELETE FROM wechat_bindings WHERE user_id=?", (user_id,)) c.execute("DELETE FROM user_wallets WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM recharge_orders WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM token_ledger WHERE user_id=?", (user_id,)) c.execute( "UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?", (now, str(now), user_id), @@ -472,14 +583,81 @@ class UserStore: def _ensure_wallet_row(self, c: sqlite3.Connection, user_id: int) -> None: now = int(time.time()) + cycle = time.strftime("%Y-%m", time.localtime(now)) c.execute( """ - INSERT OR IGNORE INTO user_wallets(user_id, vip_enabled, token_balance, total_consumed_tokens, updated_at) - VALUES (?, 0, 0, 0, ?) + INSERT OR IGNORE INTO user_wallets( + user_id, vip_enabled, token_balance, total_consumed_tokens, + seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at + ) + VALUES (?, 0, 0, 0, 1500, 0, ?, 0, 0, ?) """, - (user_id, now), + (user_id, cycle, now), ) + def _refresh_billing_cycle(self, c: sqlite3.Connection, user_id: int) -> None: + now = int(time.time()) + row = c.execute( + "SELECT seat_cycle, cycle_expires_at, token_balance, seat_used_credits FROM user_wallets WHERE user_id=?", + (user_id,), + ).fetchone() + current_cycle = (row["seat_cycle"] or "") if row else "" + expires_at = int(row["cycle_expires_at"] or 0) if row else 0 + if expires_at > 0 and now >= expires_at: + c.execute( + """ + UPDATE user_wallets + SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=? + WHERE user_id=? + """, + (now, user_id), + ) + return + # 兼容历史按自然月 seat_cycle 的老数据:若没有新周期字段,保留原行为 + if expires_at <= 0: + paid = c.execute( + """ + SELECT paid_at + FROM recharge_orders + WHERE user_id=? AND status='paid' AND paid_at IS NOT NULL + ORDER BY paid_at DESC, id DESC + LIMIT 1 + """, + (user_id,), + ).fetchone() + if paid and int(paid["paid_at"] or 0) > 0: + start_at = int(paid["paid_at"]) + new_expires = start_at + 30 * 24 * 3600 + if now >= new_expires: + c.execute( + """ + UPDATE user_wallets + SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=? + WHERE user_id=? + """, + (now, user_id), + ) + else: + c.execute( + """ + UPDATE user_wallets + SET seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=? + WHERE user_id=? + """, + (time.strftime("%Y-%m", time.localtime(start_at)), start_at, new_expires, now, user_id), + ) + return + cycle = time.strftime("%Y-%m", time.localtime(now)) + if current_cycle != cycle: + c.execute( + """ + UPDATE user_wallets + SET seat_used_credits=0, seat_cycle=?, updated_at=? + WHERE user_id=? + """, + (cycle, now, user_id), + ) + def ensure_trial_tokens(self, user_id: int, trial_tokens: int) -> dict: amount = max(0, int(trial_tokens)) now = int(time.time()) @@ -491,31 +669,63 @@ class UserStore: ).fetchone() current = int(row["token_balance"] or 0) if row else 0 if current <= 0 and amount > 0: + new_balance = amount c.execute( """ UPDATE user_wallets SET vip_enabled=1, token_balance=?, updated_at=? WHERE user_id=? """, - (amount, now, user_id), + (new_balance, now, user_id), + ) + c.execute( + """ + INSERT INTO token_ledger( + user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + ) VALUES (?, 'in', ?, ?, 'trial_grant', 'system', '', '{}', ?) + """, + (user_id, amount, new_balance, now), ) return self.get_vip_status(user_id) def get_vip_status(self, user_id: int) -> dict: with self._conn() as c: self._ensure_wallet_row(c, user_id) + self._refresh_billing_cycle(c, user_id) row = c.execute( """ - SELECT vip_enabled, token_balance, total_consumed_tokens, updated_at + SELECT + vip_enabled, token_balance, total_consumed_tokens, + seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at FROM user_wallets WHERE user_id=? """, (user_id,), ).fetchone() + seat_quota = int(row["seat_quota_credits"] or 0) if row else 0 + seat_used = int(row["seat_used_credits"] or 0) if row else 0 + seat_remaining = max(0, seat_quota - seat_used) + shared_credits = int(row["token_balance"] or 0) if row else 0 + cycle_started_at = int(row["cycle_started_at"] or 0) if row else 0 + cycle_expires_at = int(row["cycle_expires_at"] or 0) if row else 0 + now = int(time.time()) + cycle_active = cycle_expires_at > now if cycle_expires_at > 0 else True + if not cycle_active: + seat_remaining = 0 + shared_credits = 0 return { "vip_enabled": bool(int(row["vip_enabled"] or 0)) if row else False, - "token_balance": int(row["token_balance"] or 0) if row else 0, + "token_balance": shared_credits, "total_consumed_tokens": int(row["total_consumed_tokens"] or 0) if row else 0, + "seat_quota_credits": seat_quota, + "seat_used_credits": seat_used, + "seat_remaining_credits": seat_remaining, + "shared_credits": shared_credits, + "total_available_credits": seat_remaining + shared_credits, + "seat_cycle": (row["seat_cycle"] or "") if row else "", + "cycle_started_at": cycle_started_at, + "cycle_expires_at": cycle_expires_at, + "cycle_active": cycle_active, "updated_at": int(row["updated_at"] or 0) if row else 0, } @@ -529,45 +739,328 @@ class UserStore: ) return self.get_vip_status(user_id) - def recharge_tokens(self, user_id: int, tokens: int) -> dict: + def recharge_tokens( + self, + user_id: int, + tokens: int, + *, + kind: str = "manual_recharge", + ref_type: str = "", + ref_id: str = "", + detail: dict | None = None, + cycle_start_at: int | None = None, + cycle_days: int = 30, + ) -> dict: add = max(0, int(tokens)) now = int(time.time()) with self._conn() as c: self._ensure_wallet_row(c, user_id) + self._refresh_billing_cycle(c, user_id) + row = c.execute("SELECT token_balance FROM user_wallets WHERE user_id=?", (user_id,)).fetchone() + prev = int(row["token_balance"] or 0) if row else 0 + new_balance = prev + add + start_at = int(cycle_start_at or 0) + if start_at > 0: + expires_at = start_at + max(1, int(cycle_days)) * 24 * 3600 + c.execute( + """ + UPDATE user_wallets + SET token_balance=token_balance + ?, vip_enabled=1, seat_used_credits=0, seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=? + WHERE user_id=? + """, + (add, time.strftime("%Y-%m", time.localtime(start_at)), start_at, expires_at, now, user_id), + ) + else: + c.execute( + """ + UPDATE user_wallets + SET token_balance=token_balance + ?, vip_enabled=1, updated_at=? + WHERE user_id=? + """, + (add, now, user_id), + ) c.execute( """ - UPDATE user_wallets - SET token_balance=token_balance + ?, vip_enabled=1, updated_at=? - WHERE user_id=? + INSERT INTO token_ledger( + user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + ) 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) - def consume_tokens(self, user_id: int, tokens: int) -> tuple[bool, int]: + def consume_tokens( + self, + user_id: int, + tokens: int, + *, + kind: str = "usage", + ref_type: str = "", + ref_id: str = "", + detail: dict | None = None, + ) -> tuple[bool, int]: cost = max(0, int(tokens)) now = int(time.time()) with self._conn() as c: self._ensure_wallet_row(c, user_id) + self._refresh_billing_cycle(c, user_id) row = c.execute( - "SELECT token_balance FROM user_wallets WHERE user_id=?", + "SELECT token_balance, seat_quota_credits, seat_used_credits FROM user_wallets WHERE user_id=?", (user_id,), ).fetchone() - balance = int(row["token_balance"] or 0) if row else 0 + shared_balance = int(row["token_balance"] or 0) if row else 0 + seat_quota = int(row["seat_quota_credits"] or 0) if row else 0 + seat_used = int(row["seat_used_credits"] or 0) if row else 0 + seat_remaining = max(0, seat_quota - seat_used) if cost <= 0: - return True, balance - if balance < cost: - return False, balance - new_balance = balance - cost + return True, seat_remaining + shared_balance + use_from_seat = min(seat_remaining, cost) + need_shared = cost - use_from_seat + if shared_balance < need_shared: + return False, seat_remaining + shared_balance + new_shared = shared_balance - need_shared + new_seat_used = seat_used + use_from_seat c.execute( """ UPDATE user_wallets - SET token_balance=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=? + SET token_balance=?, seat_used_credits=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=? WHERE user_id=? """, - (new_balance, cost, now, user_id), + (new_shared, new_seat_used, cost, now, user_id), ) - return True, new_balance + c.execute( + """ + INSERT INTO token_ledger( + user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + ) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?) + """, + ( + user_id, + cost, + max(0, seat_quota - new_seat_used) + new_shared, + kind, + ref_type, + ref_id or "", + json.dumps( + { + **(detail or {}), + "credit_source": {"seat": use_from_seat, "shared": need_shared}, + }, + ensure_ascii=True, + ), + now, + ), + ) + return True, max(0, seat_quota - new_seat_used) + new_shared + + def create_recharge_order( + self, + user_id: int, + order_no: str, + channel: str, + token_amount: int, + amount_cny: float, + meta: dict | None = None, + ) -> dict: + now = int(time.time()) + with self._conn() as c: + c.execute( + """ + INSERT INTO recharge_orders( + user_id, order_no, channel, token_amount, amount_cny, status, external_txn_id, meta_json, created_at, paid_at + ) VALUES (?, ?, ?, ?, ?, 'pending', '', ?, ?, NULL) + """, + ( + user_id, + order_no, + channel or "", + int(token_amount), + float(amount_cny), + json.dumps(meta or {}, ensure_ascii=True), + now, + ), + ) + return { + "order_no": order_no, + "channel": channel, + "token_amount": int(token_amount), + "amount_cny": float(amount_cny), + "status": "pending", + "created_at": now, + } + + def mark_recharge_order_paid( + self, + user_id: int, + order_no: str, + paid_amount_cny: float, + external_txn_id: str = "", + meta: dict | None = None, + ) -> tuple[bool, str]: + now = int(time.time()) + with self._conn() as c: + row = c.execute( + """ + SELECT user_id, token_amount, amount_cny, status + FROM recharge_orders + WHERE order_no=? + """, + (order_no,), + ).fetchone() + if not row: + return False, "订单不存在" + if int(row["user_id"]) != int(user_id): + return False, "订单无权限" + if (row["status"] or "") == "paid": + return True, "already_paid" + if float(paid_amount_cny or 0.0) + 1e-9 < float(row["amount_cny"] or 0.0): + return False, "支付金额不足" + c.execute( + """ + UPDATE recharge_orders + SET status='paid', external_txn_id=?, paid_at=?, meta_json=? + WHERE order_no=? + """, + ( + external_txn_id or "", + now, + json.dumps(meta or {}, ensure_ascii=True), + order_no, + ), + ) + self.recharge_tokens( + user_id, + int(row["token_amount"] or 0), + kind="paid_recharge", + ref_type="order", + ref_id=order_no, + detail={"paid_amount_cny": float(paid_amount_cny or 0.0), "external_txn_id": external_txn_id or ""}, + cycle_start_at=now, + cycle_days=30, + ) + return True, "ok" + + def list_recharge_orders(self, user_id: int, limit: int = 50) -> list[dict]: + with self._conn() as c: + now = int(time.time()) + expire_before = now - 15 * 60 + c.execute( + """ + UPDATE recharge_orders + SET status='cancelled' + WHERE user_id=? AND status='pending' AND created_at<=? + """, + (user_id, expire_before), + ) + rows = c.execute( + """ + SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json + FROM recharge_orders + WHERE user_id=? + ORDER BY id DESC + LIMIT ? + """, + (user_id, max(1, min(int(limit), 200))), + ).fetchall() + return [ + { + "order_no": r["order_no"] or "", + "channel": r["channel"] or "", + "token_amount": int(r["token_amount"] or 0), + "amount_cny": float(r["amount_cny"] or 0.0), + "status": r["status"] or "", + "external_txn_id": r["external_txn_id"] or "", + "created_at": int(r["created_at"] or 0), + "paid_at": int(r["paid_at"] or 0) if r["paid_at"] else None, + "meta": json.loads(r["meta_json"] or "{}"), + } + for r in rows + ] + + def get_recharge_order(self, user_id: int, order_no: str) -> dict | None: + now = int(time.time()) + with self._conn() as c: + expire_before = now - 15 * 60 + c.execute( + """ + UPDATE recharge_orders + SET status='cancelled' + WHERE user_id=? AND status='pending' AND created_at<=? + """, + (user_id, expire_before), + ) + row = c.execute( + """ + SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json + FROM recharge_orders + WHERE user_id=? AND order_no=? + LIMIT 1 + """, + (user_id, order_no), + ).fetchone() + if not row: + return None + try: + meta = json.loads(row["meta_json"] or "{}") + except Exception: + meta = {} + return { + "order_no": row["order_no"] or "", + "channel": row["channel"] or "", + "token_amount": int(row["token_amount"] or 0), + "amount_cny": float(row["amount_cny"] or 0.0), + "status": row["status"] or "", + "external_txn_id": row["external_txn_id"] or "", + "created_at": int(row["created_at"] or 0), + "paid_at": int(row["paid_at"] or 0) if row["paid_at"] else None, + "meta": meta, + } + + def get_recharge_order_user_id(self, order_no: str) -> int | None: + with self._conn() as c: + row = c.execute("SELECT user_id FROM recharge_orders WHERE order_no=?", (order_no,)).fetchone() + return int(row["user_id"]) if row and row["user_id"] else None + + def list_token_ledger(self, user_id: int, limit: int = 100) -> list[dict]: + with self._conn() as c: + rows = c.execute( + """ + SELECT direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at + FROM token_ledger + WHERE user_id=? + ORDER BY id DESC + LIMIT ? + """, + (user_id, max(1, min(int(limit), 500))), + ).fetchall() + out: list[dict] = [] + for r in rows: + try: + detail = json.loads(r["detail_json"] or "{}") + except Exception: + detail = {} + out.append( + { + "direction": r["direction"] or "", + "token_change": int(r["token_change"] or 0), + "balance_after": int(r["balance_after"] or 0), + "kind": r["kind"] or "", + "ref_type": r["ref_type"] or "", + "ref_id": r["ref_id"] or "", + "detail": detail, + "created_at": int(r["created_at"] or 0), + } + ) + return out def save_wechat_binding( self, @@ -887,6 +1380,41 @@ class UserStore: ) return True + def update_active_ai_image_model(self, user_id: int, image_model: str) -> bool: + now = int(time.time()) + name = (image_model or "").strip() + with self._conn() as c: + pref = c.execute( + "SELECT active_ai_model_id FROM user_prefs WHERE user_id=?", + (user_id,), + ).fetchone() + aid = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None + if not aid: + row = c.execute( + "SELECT id FROM ai_models WHERE user_id=? ORDER BY updated_at DESC, id DESC LIMIT 1", + (user_id,), + ).fetchone() + aid = int(row["id"]) if row else None + if not aid: + return False + c.execute( + "UPDATE ai_models SET image_model=?, updated_at=? WHERE id=? AND user_id=?", + (name, now, aid, user_id), + ) + if c.total_changes <= 0: + return False + c.execute( + """ + INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + active_ai_model_id=excluded.active_ai_model_id, + updated_at=excluded.updated_at + """, + (user_id, aid, now), + ) + return True + def get_active_ai_model(self, user_id: int) -> dict | None: with self._conn() as c: pref = c.execute( diff --git a/app/static/app.js b/app/static/app.js index 9906565..c0a8376 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -18,6 +18,8 @@ const imBtn = $("imBtn"); const coverUploadBtn = $("coverUploadBtn"); const coverUrlUploadBtn = $("coverUrlUploadBtn"); const coverGenerateBtn = $("coverGenerateBtn"); +const saveCoverImageModelBtn = $("saveCoverImageModelBtn"); +const coverImageModelInput = $("coverImageModel"); const coverModeManualBtn = $("coverModeManualBtn"); const coverModeAiBtn = $("coverModeAiBtn"); const coverManualSection = $("coverManualSection"); @@ -26,11 +28,13 @@ const coverAutoAfterRewrite = $("coverAutoAfterRewrite"); const coverPreview = $("coverPreview"); const coverPreviewWrap = $("coverPreviewWrap"); const logoutBtn = $("logoutBtn"); +const clearDraftBtn = $("clearDraftBtn"); const targetBodyCharsInput = $("targetBodyChars"); const posterGenerateBtn = $("posterGenerateBtn"); const posterPreviewList = $("posterPreviewList"); const posterHint = $("posterHint"); const posterAutoInclude = $("posterAutoInclude"); +const DRAFT_STORAGE_KEY = "aifagao:index:draft:v1"; let posterState = { signature: "", @@ -125,6 +129,123 @@ function setStatus(msg, danger = false) { statusEl.textContent = msg; } +function saveDraftState() { + try { + const data = { + sourceText: ($("sourceText") && $("sourceText").value) || "", + titleHint: ($("titleHint") && $("titleHint").value) || "", + audienceExtra: ($("audienceExtra") && $("audienceExtra").value) || "", + toneExtra: ($("toneExtra") && $("toneExtra").value) || "", + avoidWords: ($("avoidWords") && $("avoidWords").value) || "", + keepPoints: ($("keepPoints") && $("keepPoints").value) || "", + targetBodyChars: ($("targetBodyChars") && $("targetBodyChars").value) || "500", + title: ($("title") && $("title").value) || "", + summary: ($("summary") && $("summary").value) || "", + body: ($("body") && $("body").value) || "", + thumbMediaId: ($("thumbMediaId") && $("thumbMediaId").value) || "", + coverStyleHint: ($("coverStyleHint") && $("coverStyleHint").value) || "", + coverImageModel: (coverImageModelInput && coverImageModelInput.value) || "", + coverAutoAfterRewrite: Boolean(coverAutoAfterRewrite && coverAutoAfterRewrite.checked), + posterAutoInclude: Boolean(posterAutoInclude && posterAutoInclude.checked), + audienceChipValues: Array.from(document.querySelectorAll('input[name="audienceChip"]:checked')).map((n) => n.value), + toneChipValues: Array.from(document.querySelectorAll('input[name="toneChip"]:checked')).map((n) => n.value), + }; + window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data)); + } catch { + // ignore + } +} + +function restoreDraftState() { + try { + const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY); + if (!raw) return; + const data = JSON.parse(raw); + if (!data || typeof data !== "object") return; + const setVal = (id, val) => { + const el = $(id); + if (!el || typeof val !== "string") return; + el.value = val; + }; + setVal("sourceText", data.sourceText || ""); + setVal("titleHint", data.titleHint || ""); + setVal("audienceExtra", data.audienceExtra || ""); + setVal("toneExtra", data.toneExtra || ""); + setVal("avoidWords", data.avoidWords || ""); + setVal("keepPoints", data.keepPoints || ""); + setVal("targetBodyChars", String(data.targetBodyChars || "500")); + setVal("title", data.title || ""); + setVal("summary", data.summary || ""); + setVal("body", data.body || ""); + setVal("thumbMediaId", data.thumbMediaId || ""); + setVal("coverStyleHint", data.coverStyleHint || ""); + if (coverImageModelInput && typeof data.coverImageModel === "string" && data.coverImageModel.trim()) { + coverImageModelInput.value = data.coverImageModel; + } + if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = Boolean(data.coverAutoAfterRewrite); + if (posterAutoInclude) posterAutoInclude.checked = Boolean(data.posterAutoInclude); + const audienceSet = new Set(Array.isArray(data.audienceChipValues) ? data.audienceChipValues : []); + document.querySelectorAll('input[name="audienceChip"]').forEach((el) => { + el.checked = audienceSet.size ? audienceSet.has(el.value) : el.checked; + }); + const toneSet = new Set(Array.isArray(data.toneChipValues) ? data.toneChipValues : []); + document.querySelectorAll('input[name="toneChip"]').forEach((el) => { + el.checked = toneSet.size ? toneSet.has(el.value) : el.checked; + }); + } catch { + // ignore + } +} + +function clearDraftState() { + try { + window.localStorage.removeItem(DRAFT_STORAGE_KEY); + } catch { + // ignore + } + const clearIds = [ + "sourceText", + "titleHint", + "audienceExtra", + "toneExtra", + "avoidWords", + "keepPoints", + "title", + "summary", + "body", + "thumbMediaId", + "coverStyleHint", + "coverUrl", + ]; + clearIds.forEach((id) => { + const el = $(id); + if (!el) return; + el.value = ""; + }); + if ($("targetBodyChars")) $("targetBodyChars").value = "500"; + if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = false; + if (posterAutoInclude) posterAutoInclude.checked = false; + if (coverImageModelInput) coverImageModelInput.value = ""; + document.querySelectorAll('input[name="audienceChip"]').forEach((el) => { + el.checked = false; + }); + document.querySelectorAll('input[name="toneChip"]').forEach((el) => { + el.checked = false; + }); + if (coverPreviewWrap) coverPreviewWrap.hidden = true; + if (coverPreview) coverPreview.src = ""; + if ($("coverFile")) $("coverFile").value = ""; + + posterState = { signature: "", bodyMarkdownWithPosters: "", posters: [] }; + renderPosterPreview([]); + updateCounters(); + syncTargetCharChips(); + initMultiDropdowns(); + initImageModelStatus(); + if (posterHint) posterHint.textContent = "默认不生成海报,点击“生成段落海报”后再插入发布。"; + setStatus("草稿已清除。"); +} + function buildPosterSignature() { const title = ($("title") && $("title").value.trim()) || ""; const summary = ($("summary") && $("summary").value.trim()) || ""; @@ -183,7 +304,7 @@ function renderPosterPreview(posters) { function markPosterStaleIfNeeded() { if (!posterState.signature || !posterHint) return; if (posterState.signature !== buildPosterSignature()) { - posterHint.textContent = "正文已修改,发布前会自动重建段落海报。"; + posterHint.textContent = "正文已修改,如需海报请手动点击“生成段落海报”。"; } } @@ -194,11 +315,26 @@ async function postJSON(url, body) { body: JSON.stringify(body), }); const data = await res.json(); - if (!res.ok) throw new Error(data.detail || "请求失败"); + if (!res.ok) { + const err = new Error(data.detail || "请求失败"); + err.payload = data; + throw err; + } data._requestId = res.headers.get("X-Request-ID") || ""; return data; } +function handleUpgradeRequired(err) { + const data = (err && err.payload) || {}; + const msg = (err && err.message) || data.detail || ""; + if (!data.upgrade_required && !msg.includes("免费额度已用完") && !msg.includes("余额不足")) return false; + setStatus("免费额度已用完,请前往升级页充值或升级 VIP 用户。", true); + window.setTimeout(() => { + window.location.href = "/upgrade"; + }, 800); + return true; +} + async function generatePosterMaterials({ silent = false } = {}) { const bodyMarkdown = (($("body") && $("body").value) || "").trim(); if (bodyMarkdown.length < 20) { @@ -212,9 +348,14 @@ async function generatePosterMaterials({ silent = false } = {}) { title: $("title").value, summary: $("summary").value, body_markdown: $("body").value, + image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "", upload_to_wechat: true, }); - if (!data.ok) throw new Error(data.detail || "海报生成失败"); + if (!data.ok) { + const err = new Error(data.detail || "海报生成失败"); + err.payload = data; + throw err; + } posterState = { signature: buildPosterSignature(), @@ -252,9 +393,14 @@ async function generateWechatCover({ silent = false } = {}) { title, summary: (($("summary") && $("summary").value) || "").trim(), style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(), + image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "", upload_to_wechat: true, }); - if (!data.ok) throw new Error(data.detail || "封面生成失败"); + if (!data.ok) { + const err = new Error(data.detail || "封面生成失败"); + err.payload = data; + throw err; + } const mid = data.thumb_media_id || ""; if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid; if (data.preview_data_url && coverPreview && coverPreviewWrap) { @@ -353,6 +499,20 @@ async function initWechatAccountSwitch() { if (me) renderWechatAccountSelect(me); } +async function initImageModelStatus() { + try { + const me = await fetch("/api/auth/me").then((r) => r.json()); + const active = me && me.active_ai_model ? me.active_ai_model : null; + const imageModel = active && active.image_model ? String(active.image_model).trim() : ""; + if (coverImageModelInput) { + const current = (coverImageModelInput.value || "").trim(); + if (!current) coverImageModelInput.value = imageModel || "wanx2.0-t2i-turbo"; + } + } catch { + if (coverImageModelInput && !(coverImageModelInput.value || "").trim()) coverImageModelInput.value = "wanx2.0-t2i-turbo"; + } +} + async function logoutAndGoAuth() { try { await postJSON("/api/auth/logout", {}); @@ -369,6 +529,12 @@ if (logoutBtn) { }); } +if (clearDraftBtn) { + clearDraftBtn.addEventListener("click", () => { + clearDraftState(); + }); +} + if (coverModeManualBtn) { coverModeManualBtn.addEventListener("click", () => setCoverMode("manual")); } @@ -419,6 +585,7 @@ $("rewriteBtn").addEventListener("click", async () => { $("summary").value = data.summary || ""; $("body").value = data.body_markdown || ""; updateCounters(); + saveDraftState(); const tr = data.trace || {}; if (data.mode === "fallback") { const note = (data.quality_notes || [])[0] || "当前为保底改写稿"; @@ -429,23 +596,19 @@ $("rewriteBtn").addEventListener("click", async () => { } else { setStatus("改写完成。"); } - try { - setStatus("改写完成,正在生成段落海报..."); - await generatePosterMaterials({ silent: true }); - setStatus("改写与段落海报生成完成。"); - } catch (posterErr) { - setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true); - } + if (posterHint) posterHint.textContent = "改写完成。默认不自动生成海报,可手动点击“生成段落海报”。"; if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) { try { setStatus("改写完成,正在按输出标题生成封面..."); await generateWechatCover({ silent: true }); setStatus("改写、封面与段落海报生成完成。"); } catch (coverErr) { + if (handleUpgradeRequired(coverErr)) return; setStatus(`改写完成,封面未生成:${coverErr.message}`, true); } } } catch (e) { + if (handleUpgradeRequired(e)) return; setStatus(`改写失败: ${e.message}`, true); } finally { setLoading(rewriteBtn, false, "改写并排版", "改写中..."); @@ -460,15 +623,10 @@ $("wechatBtn").addEventListener("click", async () => { const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked); if (autoInclude) { const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters; - if (stale) { - try { - await generatePosterMaterials({ silent: true }); - } catch (posterErr) { - setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true); - } - } - if (posterState.bodyMarkdownWithPosters) { + if (!stale && posterState.bodyMarkdownWithPosters) { bodyForPublish = posterState.bodyMarkdownWithPosters; + } else { + setStatus("未检测到可用海报,本次仅发布文字;如需海报请先手动生成。", true); } } const data = await postJSON("/api/publish/wechat", { @@ -495,7 +653,9 @@ if (coverGenerateBtn) { coverGenerateBtn.addEventListener("click", async () => { try { await generateWechatCover({ silent: false }); + saveDraftState(); } catch (e) { + if (handleUpgradeRequired(e)) return; const hint = $("coverHint"); if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。"; setStatus(`封面生成失败: ${e.message}`, true); @@ -508,6 +668,30 @@ if (coverGenerateBtn) { }); } +if (saveCoverImageModelBtn) { + saveCoverImageModelBtn.addEventListener("click", async () => { + const value = (coverImageModelInput && coverImageModelInput.value.trim()) || ""; + if (!value) { + setStatus("请先填写文生图模型", true); + return; + } + setLoading(saveCoverImageModelBtn, true, "保存模型", "保存中..."); + try { + const out = await postJSON("/api/auth/ai-models/image-model/update", { image_model: value }); + if (!out.ok) { + setStatus(out.detail || "保存失败", true); + return; + } + setStatus("文生图模型已保存。"); + saveDraftState(); + } catch (e) { + setStatus(e.message || "保存失败", true); + } finally { + setLoading(saveCoverImageModelBtn, false, "保存模型", "保存中..."); + } + }); +} + if (coverUploadBtn) { coverUploadBtn.addEventListener("click", async () => { const fileInput = $("coverFile"); @@ -529,6 +713,7 @@ if (coverUploadBtn) { if ($("thumbMediaId")) $("thumbMediaId").value = mid; if (hint) hint.textContent = `封面上传成功,已绑定 media_id:${mid}`; setStatus("封面上传成功,发布时将优先使用该封面。"); + saveDraftState(); } catch (e) { if (hint) hint.textContent = "封面上传失败,请看状态提示。"; setStatus(`封面上传失败: ${e.message}`, true); @@ -561,6 +746,7 @@ if (coverUrlUploadBtn) { if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id:${mid}`; setStatus("URL 封面上传成功,发布时将优先使用该封面。"); if ($("coverUrl")) $("coverUrl").value = ""; + saveDraftState(); } catch (e) { if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。"; setStatus(`URL 封面上传失败: ${e.message}`, true); @@ -579,7 +765,9 @@ if (posterGenerateBtn) { posterGenerateBtn.addEventListener("click", async () => { try { await generatePosterMaterials({ silent: false }); + saveDraftState(); } catch (e) { + if (handleUpgradeRequired(e)) return; setStatus(`海报生成失败: ${e.message}`, true); if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。"; if ((e.message || "").includes("请先登录")) { @@ -610,13 +798,30 @@ $("imBtn").addEventListener("click", async () => { ["sourceText", "title", "summary", "body"].forEach((id) => { $(id).addEventListener("input", updateCounters); + $(id).addEventListener("input", saveDraftState); if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded); }); +["titleHint", "audienceExtra", "toneExtra", "avoidWords", "keepPoints", "targetBodyChars", "thumbMediaId", "coverStyleHint"].forEach( + (id) => { + const el = $(id); + if (el) el.addEventListener("input", saveDraftState); + }, +); +document.querySelectorAll('input[name="audienceChip"],input[name="toneChip"]').forEach((el) => { + el.addEventListener("change", saveDraftState); +}); +if (coverAutoAfterRewrite) coverAutoAfterRewrite.addEventListener("change", saveDraftState); +if (posterAutoInclude) posterAutoInclude.addEventListener("change", saveDraftState); +if (coverImageModelInput) coverImageModelInput.addEventListener("input", saveDraftState); + +restoreDraftState(); updateCounters(); initMultiDropdowns(); initWechatAccountSwitch(); syncTargetCharChips(); renderPosterPreview([]); setCoverMode("manual"); +initImageModelStatus(); +window.addEventListener("beforeunload", saveDraftState); window.addEventListener("load", () => setCoverMode("manual")); diff --git a/app/static/auth.js b/app/static/auth.js index 65ce867..c5573ec 100644 --- a/app/static/auth.js +++ b/app/static/auth.js @@ -1,4 +1,5 @@ const $ = (id) => document.getElementById(id); +let challengeId = ""; function setStatus(msg, danger = false) { const el = $("status"); @@ -24,6 +25,21 @@ async function postJSON(url, body) { return data; } +async function refreshChallenge() { + try { + const res = await fetch("/api/auth/challenge"); + const data = await res.json(); + if (!res.ok || !data.ok) throw new Error(data.detail || "校验题加载失败"); + challengeId = (data.challenge_id || "").trim(); + const label = $("challengeLabel"); + if (label) label.textContent = `人机校验:${data.question || ""}`; + const ans = $("challengeAnswer"); + if (ans) ans.value = ""; + } catch { + setStatus("校验题加载失败,请刷新页面重试", true); + } +} + function nextPath() { const nxt = (window.__NEXT_PATH__ || "/").trim(); if (!nxt.startsWith("/")) return "/"; @@ -35,6 +51,9 @@ function fields() { username: ($("username") && $("username").value.trim()) || "", password: ($("password") && $("password").value) || "", remember_me: Boolean($("rememberMe") && $("rememberMe").checked), + challenge_id: challengeId, + challenge_answer: ($("challengeAnswer") && $("challengeAnswer").value.trim()) || "", + honeypot: ($("botTrap") && $("botTrap").value) || "", }; } @@ -57,6 +76,7 @@ async function authAction(url, button, idleText, loadingText, okMessage) { const loginBtn = $("loginBtn"); const registerBtn = $("registerBtn"); +const refreshChallengeBtn = $("refreshChallengeBtn"); if (loginBtn) { loginBtn.addEventListener("click", async () => { @@ -78,7 +98,7 @@ if (registerBtn) { const msg = `注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` + "请立即复制并妥善保管,点击“确定”后继续进入系统。"; - window.alert(msg); + await window.uiAlert(msg, "注册成功"); try { if (navigator.clipboard && navigator.clipboard.writeText) { await navigator.clipboard.writeText(code); @@ -91,8 +111,19 @@ if (registerBtn) { window.location.href = nextPath(); } catch (e) { setStatus(e.message || "请求异常", true); + await refreshChallenge(); } finally { setLoading(registerBtn, false, "注册", "注册中..."); } }); } + +if (refreshChallengeBtn) { + refreshChallengeBtn.addEventListener("click", async () => { + setLoading(refreshChallengeBtn, true, "刷新题目", "刷新中..."); + await refreshChallenge(); + setLoading(refreshChallengeBtn, false, "刷新题目", "刷新中..."); + }); +} + +refreshChallenge(); diff --git a/app/static/billing.js b/app/static/billing.js new file mode 100644 index 0000000..85e2504 --- /dev/null +++ b/app/static/billing.js @@ -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 = '

暂无充值记录

'; + return; + } + const rows = list + .map((r) => { + const statusText = mapOrderStatus(r.status); + const statusClass = + statusText === "已支付" + ? "paid" + : statusText === "待支付" + ? "pending" + : statusText === "支付失败" + ? "failed" + : "closed"; + return ` + ${r.order_no || "-"} + ${statusText} + ${mapChannel(r.channel)} + ${Number(r.token_amount || 0)} + ¥${Number(r.amount_cny || 0).toFixed(2)} + ${fmtTime(r.created_at)} + ${r.paid_at ? fmtTime(r.paid_at) : "-"} + ${ + statusText === "待支付" + ? `` + : "-" + } + `; + }) + .join(""); + el.innerHTML = ` + + + + + + + + + + + + + ${rows} +
订单号状态渠道Credits金额创建时间支付时间操作
`; +} + +function renderConsumeRecords(records) { + const el = $("consumeRecords"); + if (!el) return; + const list = Array.isArray(records) ? records : []; + if (!list.length) { + el.innerHTML = '

暂无消费记录

'; + 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 ` + ${kindText} + ${delta} + ${Number(r.balance_after || 0)} + ${ref} + ${detail} + ${fmtTime(r.created_at)} + `; + }) + .join(""); + el.innerHTML = ` + + + + + + + + + + + ${rows} +
类型Credits变动余额关联明细时间
`; +} + +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(); +})(); diff --git a/app/static/profile.js b/app/static/profile.js new file mode 100644 index 0000000..14152d9 --- /dev/null +++ b/app/static/profile.js @@ -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(); +})(); diff --git a/app/static/settings.js b/app/static/settings.js index 4cf104a..c722bf7 100644 --- a/app/static/settings.js +++ b/app/static/settings.js @@ -72,18 +72,28 @@ function renderModels(me) { list.forEach((m) => { const opt = document.createElement("option"); opt.value = String(m.id); - const imageModel = (m.image_model || "").trim(); - opt.textContent = imageModel ? `${m.model_name} (${m.model} / 图:${imageModel})` : `${m.model_name} (${m.model})`; + opt.textContent = `${m.model_name} (${m.model})`; if ((active && m.id === active) || m.active) opt.selected = true; sel.appendChild(opt); }); } +function renderVip(me) { + const vip = me && me.vip ? me.vip : {}; + const enabledSelect = $("vipEnabledSelect"); + const tokenBalance = $("vipTokenBalance"); + const totalConsumed = $("vipTotalConsumed"); + if (enabledSelect) enabledSelect.value = vip.vip_enabled ? "1" : "0"; + if (tokenBalance) tokenBalance.value = String(Number(vip.token_balance || 0)); + if (totalConsumed) totalConsumed.value = String(Number(vip.total_consumed_tokens || 0)); +} + async function refresh() { const me = await authMe(); if (!me) return; renderAccounts(me); renderModels(me); + renderVip(me); } const accountSelect = $("accountSelect"); @@ -95,6 +105,8 @@ const deleteAccountBtn = $("deleteAccountBtn"); const modelSelect = $("modelSelect"); const saveModelBtn = $("saveModelBtn"); const deleteModelBtn = $("deleteModelBtn"); +const saveVipBtn = $("saveVipBtn"); +const vipRechargeBtn = $("vipRechargeBtn"); if (accountSelect) { accountSelect.addEventListener("change", async () => { @@ -121,7 +133,7 @@ if (deleteWechatBtn) { setStatus("请先选择要删除的公众号", true); return; } - const sure = window.confirm("确定删除当前公众号绑定吗?删除后不可恢复。"); + const sure = await window.uiConfirm("确定删除当前公众号绑定吗?删除后不可恢复。", "删除公众号"); if (!sure) return; setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中..."); try { @@ -195,7 +207,7 @@ if (saveModelBtn) { api_key: ($("apiKey") && $("apiKey").value.trim()) || "", base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "", model: ($("modelValue") && $("modelValue").value.trim()) || "", - image_model: ($("imageModelValue") && $("imageModelValue").value.trim()) || "", + image_model: "", timeout_sec: Number((($("timeoutSec") && $("timeoutSec").value) || "120").trim()), max_output_tokens: Number((($("maxOutputTokens") && $("maxOutputTokens").value) || "8192").trim()), max_retries: Number((($("maxRetries") && $("maxRetries").value) || "0").trim()), @@ -207,7 +219,6 @@ if (saveModelBtn) { setStatus("模型配置已保存并设为当前。"); if ($("apiKey")) $("apiKey").value = ""; if ($("modelName")) $("modelName").value = ""; - if ($("imageModelValue")) $("imageModelValue").value = ""; await refresh(); } catch (e) { setStatus(e.message || "模型保存失败", true); @@ -224,7 +235,7 @@ if (deleteModelBtn) { setStatus("请先选择要删除的模型", true); return; } - const sure = window.confirm("确定删除当前模型配置吗?删除后不可恢复。"); + const sure = await window.uiConfirm("确定删除当前模型配置吗?删除后不可恢复。", "删除模型"); if (!sure) return; setLoading(deleteModelBtn, true, "删除当前模型", "删除中..."); try { @@ -243,6 +254,61 @@ if (deleteModelBtn) { }); } +if (saveVipBtn) { + saveVipBtn.addEventListener("click", async () => { + setLoading(saveVipBtn, true, "保存 VIP 设置", "保存中..."); + try { + const enabled = (($("vipEnabledSelect") && $("vipEnabledSelect").value) || "0") === "1"; + const out = await postJSON("/api/auth/vip/toggle", { enabled }); + if (!out.ok) { + setStatus(out.detail || "VIP 设置保存失败", true); + return; + } + setStatus("VIP 设置已更新。"); + await refresh(); + } catch (e) { + setStatus(e.message || "VIP 设置保存失败", true); + } finally { + setLoading(saveVipBtn, false, "保存 VIP 设置", "保存中..."); + } + }); +} + +if (vipRechargeBtn) { + vipRechargeBtn.addEventListener("click", async () => { + setLoading(vipRechargeBtn, true, "充值 Token", "创建订单中..."); + try { + const tokens = Number((($("vipRechargeTokens") && $("vipRechargeTokens").value) || "0").trim()); + if (!Number.isFinite(tokens) || tokens <= 0) { + setStatus("请输入正确的充值数量", true); + return; + } + const out = await postJSON("/api/pay/wechat/", { + tokens: Math.round(tokens), + amount_cny: Number((((Number(tokens) / 10000) * 9.9) || 9.9).toFixed(2)), + channel: "wechat", + }); + if (!out.ok) { + setStatus(out.detail || "充值失败", true); + return; + } + if (out.pay_url) { + window.open(out.pay_url, "_blank", "noopener"); + setStatus("订单已创建,请在新窗口完成支付。"); + } else { + setStatus("订单已创建,但未获取到支付链接,请联系管理员配置购物系统。", true); + } + window.setTimeout(() => { + window.location.href = "/billing"; + }, 400); + } catch (e) { + setStatus(e.message || "充值失败", true); + } finally { + setLoading(vipRechargeBtn, false, "充值 Token", "创建订单中..."); + } + }); +} + if (logoutBtn) { logoutBtn.addEventListener("click", async () => { setLoading(logoutBtn, true, "退出登录", "退出中..."); @@ -292,9 +358,14 @@ if (deleteAccountBtn) { setStatus("请输入注销校验重置码", true); return; } - const sure = window.confirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。"); + const sure = await window.uiConfirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。", "注销账户"); if (!sure) return; - const confirmText = window.prompt("为防止误删,请输入「注销账户」后确认:", ""); + const confirmText = await window.uiPrompt( + "为防止误删,请输入「注销账户」后确认:", + "二次确认", + "", + "请输入:注销账户", + ); if ((confirmText || "").trim() !== "注销账户") { setStatus("二次确认未通过,已取消注销。", true); return; diff --git a/app/static/style.css b/app/static/style.css index eb0b489..52dd750 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -257,6 +257,17 @@ a { background: transparent; font-size: 13px; font-weight: 750; + padding-right: 20px; + background-image: + linear-gradient(45deg, transparent 50%, #8a6f58 50%), + linear-gradient(135deg, #8a6f58 50%, transparent 50%); + background-position: + calc(100% - 12px) calc(50% - 2px), + calc(100% - 7px) calc(50% - 2px); + background-size: + 5px 5px, + 5px 5px; + background-repeat: no-repeat; } .topbar-select:focus { @@ -441,6 +452,45 @@ button { transform 0.18s ease; } +select { + appearance: none; + -webkit-appearance: none; + -moz-appearance: none; + padding-right: 34px; + background-image: + linear-gradient(45deg, transparent 50%, #8a6f58 50%), + linear-gradient(135deg, #8a6f58 50%, transparent 50%), + linear-gradient(180deg, #ffffff, #ffffff); + background-position: + calc(100% - 16px) calc(50% - 2px), + calc(100% - 11px) calc(50% - 2px), + 0 0; + background-size: + 6px 6px, + 6px 6px, + 100% 100%; + background-repeat: no-repeat; +} + +select:hover { + border-color: var(--accent); +} + +select:disabled { + color: var(--faint); + border-color: var(--line); + background-image: + linear-gradient(45deg, transparent 50%, #c4af98 50%), + linear-gradient(135deg, #c4af98 50%, transparent 50%), + linear-gradient(180deg, #f8f3ec, #f8f3ec); + cursor: not-allowed; +} + +.ui-select { + background-color: #fffdf8; + font-weight: 700; +} + textarea { resize: vertical; line-height: 1.6; @@ -770,6 +820,49 @@ button.target-char-chip { align-items: center; } +.image-model-banner { + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + margin: 8px 0; + padding: 10px 12px; + border: 1px solid rgba(255, 122, 26, 0.24); + border-radius: var(--radius); + background: linear-gradient(135deg, #fff8ed, #fff0dc); +} + +.image-model-banner div { + display: grid; + gap: 2px; + min-width: 0; +} + +.image-model-banner strong { + color: var(--text); + font-size: 13px; +} + +.image-model-banner span { + overflow: hidden; + color: var(--muted); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.image-model-banner a { + flex: 0 0 auto; + color: var(--accent-2); + text-decoration: none; + font-size: 12px; + font-weight: 850; +} + +.image-model-banner a:hover { + text-decoration: underline; +} + .cover-mode-switch { display: grid; grid-template-columns: 1fr 1fr; @@ -929,6 +1022,92 @@ button.target-char-chip { padding-right: 2px; } +.billing-list { + max-height: 260px; +} + +.billing-table-wrap { + overflow: auto; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; +} + +.billing-table { + width: 100%; + border-collapse: collapse; + min-width: 760px; + font-size: 13px; +} + +.billing-table th, +.billing-table td { + padding: 10px 12px; + border-bottom: 1px solid var(--line); + text-align: left; + vertical-align: middle; +} + +.billing-table thead th { + position: sticky; + top: 0; + z-index: 1; + background: #fff8ed; + color: var(--muted); + font-weight: 800; + font-size: 12px; +} + +.billing-table tbody tr:hover { + background: var(--surface-2); +} + +.billing-table .mono { + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; + font-size: 12px; +} + +.billing-badge { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 64px; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + font-weight: 800; +} + +.billing-badge.pending { + color: #b54708; + background: #fffaeb; +} + +.billing-badge.paid { + color: #027a48; + background: #ecfdf3; +} + +.billing-badge.failed { + color: #b42318; + background: #fef3f2; +} + +.billing-badge.closed { + color: #475467; + background: #f2f4f7; +} + +.billing-table td.in { + color: #027a48; + font-weight: 700; +} + +.billing-table td.out { + color: #b42318; + font-weight: 700; +} + .poster-card { border: 1px solid var(--line); border-radius: var(--radius); @@ -1093,6 +1272,33 @@ button.target-char-chip { box-shadow: var(--shadow-soft); } +.model-image-config { + display: grid; + gap: 8px; + margin-bottom: 12px; + padding: 12px; + border: 1px solid rgba(255, 122, 26, 0.28); + border-radius: var(--radius); + background: linear-gradient(135deg, #fffaf3, #fff0dc); +} + +.model-image-config strong, +.model-image-config span { + display: block; +} + +.model-image-config strong { + color: var(--text); + font-size: 14px; +} + +.model-image-config span { + margin-top: 4px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + .settings-layout { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(0, 1fr); @@ -1318,6 +1524,525 @@ button.target-char-chip { line-height: 1.65; } +.upgrade-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); +} + +.upgrade-panel { + min-height: 0; +} + +.upgrade-scroll { + padding: 10px; +} + +.upgrade-hero { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(135deg, #fff, #fff0dc); + box-shadow: var(--shadow-soft); +} + +.upgrade-hero h2 { + margin: 0; + font-size: 22px; +} + +.upgrade-hero p { + max-width: 620px; + margin: 6px 0 0; + line-height: 1.55; + font-size: 13px; +} + +.upgrade-balance-card { + min-width: 160px; + padding: 12px; + border-radius: 12px; + background: linear-gradient(135deg, #ffb23f, #ff6b16); + color: #fff; + box-shadow: 0 18px 40px rgba(255, 107, 22, 0.22); +} + +.upgrade-balance-card span, +.upgrade-balance-card small { + display: block; + color: rgba(255, 255, 255, 0.82); + font-size: 12px; +} + +.upgrade-balance-card strong { + display: block; + margin-top: 4px; + font-size: 28px; + line-height: 1; +} + +.upgrade-grid { + margin-top: 10px; + display: grid; + grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr); + gap: 10px; +} + +.upgrade-plans-stack { + display: grid; + gap: 10px; +} + +.upgrade-plan-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 10px; + align-items: stretch; +} + +.upgrade-tabbar { + display: inline-flex; + gap: 6px; + padding: 2px; + border: 1px solid var(--line); + border-radius: 8px; + background: #fffdf9; + width: fit-content; +} + +.upgrade-tab { + display: inline-flex; + align-items: center; + justify-content: center; + margin: 0; + min-height: 24px; + padding: 0 10px; + border-radius: 6px; + border: 1px solid transparent; + background: transparent; + color: var(--muted); + font-size: 11px; + font-weight: 800; + line-height: 1; + white-space: nowrap; +} + +.upgrade-tab.is-active { + border-color: #ffca97; + background: #fff0de; + color: #9b3f00; +} + +.upgrade-plan { + height: 100%; + padding: 12px; + border: 1px solid rgba(255, 122, 26, 0.42); + border-radius: var(--radius); + background: linear-gradient(180deg, #fff, #fff8ed); + box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18); + display: grid; + align-content: start; + gap: 8px; +} + +.upgrade-plan.is-highlighted { + border-color: #4a2f1f; + background: linear-gradient(180deg, #3c2619, #2b1a12); + box-shadow: 0 14px 30px rgba(38, 20, 12, 0.3); +} + +.upgrade-plan.is-highlighted .plan-head h3, +.upgrade-plan.is-highlighted p, +.upgrade-plan.is-highlighted ul { + color: #f8e9da; +} + +.upgrade-plan.is-highlighted .plan-head span { + background: rgba(255, 173, 66, 0.2); + color: #ffd39f; +} + +.plan-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; +} + +.plan-head h3 { + margin: 0; + font-size: 16px; +} + +.plan-head span { + display: inline-flex; + width: fit-content; + flex: 0 0 auto; + padding: 5px 10px; + border-radius: 999px; + background: var(--accent-soft); + color: var(--accent-2); + font-size: 12px; + font-weight: 850; + white-space: nowrap; +} + +.upgrade-plan p { + margin: 0; + color: var(--muted); + line-height: 1.45; + font-size: 13px; +} + +.plan-price { + margin: 0; + display: inline-flex; + justify-self: start; + width: fit-content; + max-width: 100%; + align-items: baseline; + padding: 4px 10px; + border-radius: 999px; + background: linear-gradient(180deg, #fff2de, #ffe2bf); + color: #b43f00; + font-size: 28px; + font-weight: 900; + letter-spacing: 0.2px; + box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18); +} + +.upgrade-plan.is-highlighted .plan-price { + display: inline-flex; + align-items: baseline; + padding: 4px 10px; + border-radius: 999px; + background: linear-gradient(180deg, #ffcf90, #ffb766); + color: #5a2a05; + font-size: 28px; + letter-spacing: 0.2px; + box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24); +} + +.upgrade-plan.is-highlighted .upgrade-toggle-row label { + color: #f8e9da; +} + +.upgrade-plan.is-highlighted .ui-select { + border-color: #7a553c; + background-color: #f8e9da; + color: #2d1f17; +} + +.upgrade-plan ul { + margin: 0 0 0 16px; + padding: 0; + color: var(--muted); + line-height: 1.55; + font-size: 12px; +} + +.upgrade-toggle-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: center; + margin-bottom: 10px; +} + +.upgrade-toggle-row label { + margin: 0; +} + +.upgrade-wallet { + margin-top: 10px; +} + +.upgrade-stats { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.upgrade-stats div { + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); +} + +.upgrade-stats span, +.upgrade-stats strong { + display: block; +} + +.upgrade-stats span { + color: var(--muted); + font-size: 12px; +} + +.upgrade-stats strong { + margin-top: 4px; + color: var(--text); + font-size: 17px; +} + +.upgrade-recharge { + margin-top: 8px; + display: grid; + grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.upgrade-recharge button { + width: auto; + margin-top: 0; + white-space: nowrap; + min-height: 36px; +} + +.upgrade-plan button.secondary, +.upgrade-recharge button.secondary { + border-color: #ffb57a; + background: #fff6ec; + color: #9b3f00; + font-weight: 900; +} + +.upgrade-plan button.secondary:hover, +.upgrade-recharge button.secondary:hover { + background: #ffe9d3; +} + +.upgrade-purchase-card { + padding: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + box-shadow: var(--shadow-soft); + align-self: start; + position: sticky; + top: 8px; + overflow: hidden; +} + +.upgrade-purchase-card > *:not(.purchase-head) { + margin-left: 12px; + margin-right: 12px; +} + +.purchase-section { + margin-top: 10px; +} + +.purchase-head { + padding: 12px; + border-bottom: 1px solid var(--line); + background: var(--surface-2); + display: grid; + gap: 4px; +} + +.upgrade-purchase-card h3 { + margin: 0; + font-size: 16px; +} + +.upgrade-purchase-card .muted.small { + margin: 0; + line-height: 1.45; +} + +.purchase-row { + display: grid; + gap: 4px; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); +} + +.purchase-row span { + color: var(--muted); + font-size: 12px; +} + +.purchase-row strong { + color: var(--text); + font-size: 15px; + line-height: 1.3; +} + +.purchase-meta-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.purchase-qty { + display: grid; + gap: 6px; +} + +.purchase-qty > span { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.purchase-qty .tiny { + margin: 0; +} + +.purchase-info { + display: grid; + gap: 3px; + margin-top: 8px; +} + +.purchase-static-text { + margin: 0; + color: var(--text); + font-size: 12px; + line-height: 1.45; + word-break: break-word; +} + +.purchase-stepper { + display: grid; + grid-template-columns: 42px minmax(0, 1fr) 42px; + gap: 8px; + align-items: center; +} + +.purchase-stepper button { + margin-top: 0; + min-height: 36px; + padding: 0; +} + +.purchase-stepper input { + text-align: center; + font-weight: 800; +} + +.pay-channel-group { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} + +.pay-channel-option { + margin: 0; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 10px; + background: #fff; + color: var(--muted); + font-weight: 800; +} + +.pay-channel-option[data-channel="wechat"] { + border-color: #b7e8c9; + color: #1f9d55; + background: #f4fdf7; +} + +.pay-channel-option[data-channel="alipay"] { + border-color: #b8d8ff; + color: #1677ff; + background: #f3f8ff; +} + +.pay-channel-option[data-channel="wechat"].is-active { + border-color: #07c160; + color: #056a35; + background: #eafaf1; + box-shadow: 0 8px 18px rgba(7, 193, 96, 0.2); +} + +.pay-channel-option[data-channel="alipay"].is-active { + border-color: #1677ff; + color: #0d4fb8; + background: #ebf3ff; + box-shadow: 0 8px 18px rgba(22, 119, 255, 0.2); +} + +.purchase-summary { + display: grid; + gap: 8px; +} + +.purchase-summary-row { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 10px; + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); +} + +.purchase-summary span { + color: var(--muted); + font-size: 12px; +} + +.purchase-summary strong { + color: var(--accent-2); + font-size: 18px; + font-weight: 900; +} + +.purchase-action { + margin-bottom: 12px; +} + +.upgrade-purchase-card #vipRechargeBtn { + margin-top: 0; + margin-bottom: 0; + min-height: 40px; + border-radius: 10px; +} + +@media (max-width: 860px) { + .upgrade-hero { + align-items: flex-start; + flex-direction: column; + } + + .upgrade-balance-card { + width: 100%; + } + + .upgrade-grid, + .upgrade-stats, + .upgrade-recharge { + grid-template-columns: 1fr; + } + + .upgrade-plan-grid, + .pay-channel-group { + grid-template-columns: 1fr; + } + + .purchase-meta-grid { + grid-template-columns: 1fr; + } + + .upgrade-purchase-card { + position: static; + } + + .upgrade-recharge button { + width: 100%; + } +} + .section-title { margin: 0 0 12px; font-size: 16px; @@ -1804,3 +2529,60 @@ button.target-char-chip { grid-template-columns: 1fr; } } + +.ui-dialog-root { + position: fixed; + inset: 0; + pointer-events: none; + z-index: 9999; +} + +.ui-dialog-overlay { + position: fixed; + inset: 0; + display: grid; + place-items: center; + padding: 16px; + background: rgba(17, 24, 39, 0.45); + pointer-events: auto; +} + +.ui-dialog { + width: min(420px, calc(100vw - 32px)); + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + box-shadow: var(--shadow); + padding: 14px; +} + +.ui-dialog-title { + margin: 0; + font-size: 16px; +} + +.ui-dialog-message { + margin-top: 8px; + color: var(--muted); + font-size: 13px; + line-height: 1.6; + white-space: pre-wrap; +} + +.ui-dialog-input { + margin-top: 10px; + display: none; +} + +.ui-dialog-actions { + margin-top: 12px; + display: flex; + justify-content: flex-end; + gap: 8px; +} + +.ui-dialog-btn { + width: auto; + min-width: 92px; + margin-top: 0; +} diff --git a/app/static/ui-dialog.js b/app/static/ui-dialog.js new file mode 100644 index 0000000..f90577f --- /dev/null +++ b/app/static/ui-dialog.js @@ -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 = ` + + `; + 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, + }); +})(); diff --git a/app/static/upgrade.js b/app/static/upgrade.js new file mode 100644 index 0000000..54dc861 --- /dev/null +++ b/app/static/upgrade.js @@ -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); diff --git a/app/templates/auth.html b/app/templates/auth.html index 72aeaa5..3a4934c 100644 --- a/app/templates/auth.html +++ b/app/templates/auth.html @@ -5,7 +5,7 @@ {{ app_name }} - 登录注册 - +
@@ -66,11 +66,19 @@
- +
- + +
+
+ +
+ + +
+
+
+ + +
@@ -220,10 +230,10 @@
发布时自动插入海报
-

改写后可生成段落海报。

+

默认不生成海报,点击“生成段落海报”后再插入发布。

@@ -239,6 +249,6 @@
- + diff --git a/app/templates/profile.html b/app/templates/profile.html new file mode 100644 index 0000000..aa29da6 --- /dev/null +++ b/app/templates/profile.html @@ -0,0 +1,75 @@ + + + + + + {{ app_name }} - 个人中心 + + + + +
+ + +
+
+
+ + + + +
+
+ +
+
+
+
+
+

个人中心

+

这里保存默认订阅人信息,升级页会自动带入,避免重复输入。

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ +
+

+
+
+
+
+
+
+
+ + + diff --git a/app/templates/settings.html b/app/templates/settings.html index d0bf38a..6a2dd80 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -5,7 +5,7 @@ {{ app_name }} - 账号与模型设置 - +
@@ -20,6 +20,9 @@ 内容生产 账号与模型 + 账单中心 + 升级 + 个人中心 新手引导 @@ -29,6 +32,8 @@
+ + ?
@@ -41,7 +46,7 @@
- +

新增公众号

@@ -70,7 +75,7 @@
- +
@@ -86,10 +91,6 @@
-
- - -
@@ -152,6 +153,7 @@
- + + diff --git a/app/templates/upgrade.html b/app/templates/upgrade.html new file mode 100644 index 0000000..0f46635 --- /dev/null +++ b/app/templates/upgrade.html @@ -0,0 +1,177 @@ + + + + + + {{ app_name }} - 升级 + + + + +
+ + +
+
+
+ + + +
+
+ +
+
+
+
+
+

VIP 平台模型

+

Token Plan 订阅

+

新用户免费 {{ trial_tokens }} Credits。按支付成功时间起算月周期,到期席位与加油包额度清零。

+
+
+ 当前可用 + 0 + Credits +
+
+ +
+
+
+
+
+ 推荐 +

标准坐席

+
+

适合轻度使用 AI 辅助写作与生图,采用 Credits 统一抵扣。

+
19.9/月
+
    +
  • {{ seat_quota }} Credits /月(优先抵扣)
  • +
  • 席位额度用尽后,从共享加油包继续抵扣
  • +
+
+ + +
+ +
+ +
+
+ 自定义 +

自备模型

+
+

继续使用你在设置页配置的文本模型和文生图模型。

+
自有额度
+
    +
  • 适合已有 API Key 的团队
  • +
  • 模型、Base URL 可自行维护
  • +
  • 平台 Credits 不参与扣减
  • +
+ 配置自定义模型 +
+
+
+ + +
+ +
+
+

额度与充值

+
+
+
+ 席位剩余额度 + 0 +
+
+ 共享加油包 + 0 +
+
+ 总可用 Credits + 0 +
+
+

当前未开始月周期。

+

+
+
+
+
+
+
+ + + +