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