fix:优化当前的项目

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

View File

@@ -75,27 +75,87 @@ class Settings(BaseSettings):
auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY")
vip_trial_tokens: int = Field(
default=20000,
default=500,
alias="VIP_TRIAL_TOKENS",
description="新用户试用赠送 token",
description="新用户试用赠送 Credits",
)
credits_seat_monthly_quota: int = Field(
default=1500,
alias="CREDITS_SEAT_MONTHLY_QUOTA",
description="标准坐席每月 Credits 额度",
)
credits_standard_seat_price_cny: float = Field(
default=198.0,
alias="CREDITS_STANDARD_SEAT_PRICE_CNY",
description="标准坐席月费(人民币)",
)
vip_rewrite_token_per_1k_chars: int = Field(
default=1200,
default=1500,
alias="VIP_REWRITE_TOKEN_PER_1K_CHARS",
description="改写按千字计费 token 单价",
description="兼容字段:改写计费参数(建议使用 Credits 规则字段)",
)
vip_image_token_per_image: int = Field(
default=1800,
default=1500,
alias="VIP_IMAGE_TOKEN_PER_IMAGE",
description="文生图单张扣减 token",
description="兼容字段:生图计费参数(建议使用 Credits 规则字段)",
)
credits_per_million_tokens: int = Field(
default=1500,
alias="CREDITS_PER_MILLION_TOKENS",
description="兼容字段100万 token 对应的 Credits 抵扣(建议使用人民币折算字段)",
)
credits_per_120_images: int = Field(
default=1500,
alias="CREDITS_PER_120_IMAGES",
description="兼容字段120 张图片对应的 Credits 抵扣(建议使用人民币折算字段)",
)
credits_token_price_per_million_cny: float = Field(
default=7.9,
alias="CREDITS_TOKEN_PRICE_PER_MILLION_CNY",
description="文本计费100万 token 的人民币价格",
)
credits_image_price_per_image_cny: float = Field(
default=0.04,
alias="CREDITS_IMAGE_PRICE_PER_IMAGE_CNY",
description="兼容字段:生图计费单张价格(建议使用整包折算字段)",
)
credits_image_price_package_cny: float = Field(
default=0.75,
alias="CREDITS_IMAGE_PRICE_PACKAGE_CNY",
description="生图计费:图片整包人民币价格",
)
credits_image_price_package_images: int = Field(
default=160,
alias="CREDITS_IMAGE_PRICE_PACKAGE_IMAGES",
description="生图计费:整包包含图片张数",
)
credits_recharge_package_amount: float = Field(
default=19.9,
alias="CREDITS_RECHARGE_PACKAGE_AMOUNT",
description="标准加油包价格(元)",
)
credits_recharge_package_credits: int = Field(
default=1500,
alias="CREDITS_RECHARGE_PACKAGE_CREDITS",
description="标准加油包 Credits 数量",
)
platform_openai_api_key: str | None = Field(default=None, alias="PLATFORM_OPENAI_API_KEY")
platform_openai_base_url: str | None = Field(default=None, alias="PLATFORM_OPENAI_BASE_URL")
platform_openai_model: str = Field(default="gpt-4.1-mini", alias="PLATFORM_OPENAI_MODEL")
platform_openai_image_model: str = Field(default="gpt-image-1", alias="PLATFORM_OPENAI_IMAGE_MODEL")
platform_openai_model: str = Field(default="qwen-plus", alias="PLATFORM_OPENAI_MODEL")
platform_openai_image_model: str = Field(default="wanx2.0-t2i-turbo", alias="PLATFORM_OPENAI_IMAGE_MODEL")
platform_openai_text_model_options: str = Field(
default="gpt-4.1-mini,gpt-4.1,gpt-4o-mini,qwen-plus,qwen-max",
alias="PLATFORM_OPENAI_TEXT_MODEL_OPTIONS",
)
platform_openai_image_model_options: str = Field(
default="wanx2.0-t2i-turbo,wanx2.1-t2i-plus,wanx2.1-t2i-turbo,gpt-image-1,dall-e-3",
alias="PLATFORM_OPENAI_IMAGE_MODEL_OPTIONS",
)
platform_openai_timeout: float = Field(default=120.0, alias="PLATFORM_OPENAI_TIMEOUT")
platform_openai_max_output_tokens: int = Field(default=8192, alias="PLATFORM_OPENAI_MAX_OUTPUT_TOKENS")
platform_openai_max_retries: int = Field(default=0, alias="PLATFORM_OPENAI_MAX_RETRIES")
shop_backend_create_order_url: str | None = Field(default=None, alias="SHOP_BACKEND_CREATE_ORDER_URL")
shop_backend_callback_token: str = Field(default="", alias="SHOP_BACKEND_CALLBACK_TOKEN")
settings = Settings()

View File

@@ -1,6 +1,11 @@
from __future__ import annotations
import logging
import math
import re
import secrets
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
@@ -16,8 +21,12 @@ from app.middleware import RequestContextMiddleware
from app.schemas import (
AIModelCreateRequest,
AIModelDeleteRequest,
AIImageModelUpdateRequest,
AIModelSwitchRequest,
AuthCredentialRequest,
BillingRechargeCreateRequest,
BillingPayNowRequest,
BillingRechargeNotifyRequest,
ChangePasswordRequest,
DeleteAccountRequest,
ForgotPasswordResetRequest,
@@ -30,6 +39,7 @@ from app.schemas import (
WechatBindingRequest,
WechatPublishRequest,
WechatSwitchRequest,
UserProfileUpdateRequest,
VipRechargeRequest,
VipToggleRequest,
)
@@ -64,6 +74,11 @@ wechat = WechatPublisher()
poster_material = PosterMaterialService(wechat)
im = IMPublisher()
users = UserStore(settings.auth_db_path)
_register_rate: dict[str, list[float]] = {}
_login_rate: dict[str, list[float]] = {}
_challenge_pool: dict[str, dict] = {}
USERNAME_RE = re.compile(r"^[A-Za-z0-9_]{4,24}$")
PASSWORD_STRONG_RE = re.compile(r"^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[^A-Za-z0-9])\S{10,64}$")
def _session_ttl(remember_me: bool) -> int:
@@ -84,6 +99,67 @@ def _require_user(request: Request) -> dict | None:
return u
def _client_ip(request: Request) -> str:
xff = (request.headers.get("x-forwarded-for") or "").strip()
if xff:
return xff.split(",")[0].strip()
return (request.client.host if request.client else "") or "unknown"
def _hit_limit(bucket: dict[str, list[float]], key: str, limit: int, window_sec: int) -> bool:
now = time.time()
points = bucket.get(key, [])
points = [p for p in points if now - p <= window_sec]
points.append(now)
bucket[key] = points
return len(points) > limit
def _create_challenge() -> dict:
a = secrets.randbelow(8) + 2
b = secrets.randbelow(9) + 1
op = "+" if secrets.randbelow(2) == 0 else "-"
if op == "-":
a, b = max(a, b) + 5, min(a, b)
answer = str(a + b) if op == "+" else str(a - b)
cid = secrets.token_urlsafe(16)
_challenge_pool[cid] = {"answer": answer, "created_at": time.time(), "used": False}
return {"challenge_id": cid, "question": f"{a} {op} {b} = ?"}
def _verify_challenge(req: AuthCredentialRequest) -> tuple[bool, str]:
cid = (req.challenge_id or "").strip()
ans = (req.challenge_answer or "").strip()
if req.honeypot:
return False, "请求被拒绝"
if not cid or not ans:
return False, "请先完成人机校验"
item = _challenge_pool.get(cid)
if not item:
return False, "校验已失效,请刷新后重试"
if item.get("used"):
return False, "校验已使用,请刷新后重试"
age = time.time() - float(item.get("created_at") or 0)
if age < 2:
return False, "提交过快,请稍后重试"
if age > 300:
return False, "校验已过期,请刷新后重试"
if ans != str(item.get("answer") or ""):
return False, "人机校验答案错误"
item["used"] = True
return True, ""
def _validate_username_password(username: str, password: str) -> tuple[bool, str]:
if not USERNAME_RE.match(username):
return False, "用户名需为 4-24 位,仅支持字母/数字/下划线"
if not PASSWORD_STRONG_RE.match(password):
return False, "密码需 10-64 位,且包含大小写字母、数字和特殊字符"
if username.lower() in password.lower():
return False, "密码不能包含用户名"
return True, ""
def _platform_model_cfg() -> dict:
return {
"api_key": settings.platform_openai_api_key or "",
@@ -99,7 +175,9 @@ def _platform_model_cfg() -> dict:
def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | None, str]:
vip = users.get_vip_status(user_id)
if prefer_vip and vip.get("vip_enabled") and int(vip.get("token_balance") or 0) > 0:
if prefer_vip and vip.get("vip_enabled"):
if int(vip.get("total_available_credits") or 0) <= 0:
return None, "vip_empty"
cfg = _platform_model_cfg()
if cfg.get("api_key"):
return cfg, "vip"
@@ -107,12 +185,114 @@ def _select_model_cfg(user_id: int, prefer_vip: bool = True) -> tuple[dict | Non
return cfg, "user"
def _quota_detail() -> str:
return "Credits 额度已用完(席位额度+共享加油包),请充值或等待下个计费周期"
def _credits_from_cny(amount_cny: float) -> int:
package_credits = max(1, int(settings.credits_recharge_package_credits))
package_amount = max(0.01, float(settings.credits_recharge_package_amount))
cny = max(0.0, float(amount_cny))
if cny <= 0:
return 0
return max(1, int(math.ceil((cny * package_credits) / package_amount)))
def _estimate_rewrite_cost(req: RewriteRequest, result) -> int:
trace = getattr(result, "trace", None) or {}
usage = trace.get("usage") if isinstance(trace, dict) else {}
usage_total_tokens = int((usage or {}).get("total_tokens") or 0)
if usage_total_tokens > 0:
token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny))
return _credits_from_cny((usage_total_tokens / 1_000_000.0) * token_price_cny)
src_chars = len((req.source_text or "").strip())
out_chars = len((result.body_markdown or "").strip()) + len((result.title or "").strip()) + len((result.summary or "").strip())
total_chars = max(1, src_chars + out_chars)
blocks = (total_chars + 999) // 1000
return int(blocks * max(1, int(settings.vip_rewrite_token_per_1k_chars)))
estimated_tokens = int(total_chars * 1.8)
token_price_cny = max(0.0, float(settings.credits_token_price_per_million_cny))
return _credits_from_cny((max(1, estimated_tokens) / 1_000_000.0) * token_price_cny)
def _estimate_image_cost(image_count: int) -> int:
cnt = max(0, int(image_count))
if cnt <= 0:
return 0
pkg_images = max(1, int(settings.credits_image_price_package_images or 160))
pkg_cny = max(0.0, float(settings.credits_image_price_package_cny or 0.75))
return _credits_from_cny((cnt / pkg_images) * pkg_cny)
def _new_order_no() -> str:
return f"RC{time.strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}"
async def _create_recharge_order_with_pay_url(
user_id: int,
username: str,
credits: int,
callback_url: str,
order_meta: dict | None = None,
channel: str = "wechat",
) -> tuple[dict, str]:
package_credits = max(1, int(settings.credits_recharge_package_credits))
package_amount = float(settings.credits_recharge_package_amount)
amount_cny = round((int(credits) / package_credits) * package_amount, 2)
order_no = _new_order_no()
order = users.create_recharge_order(
user_id=user_id,
order_no=order_no,
channel=(channel or "wechat").strip() or "wechat",
token_amount=int(credits),
amount_cny=amount_cny,
meta={"username": username, **(order_meta or {})},
)
pay_payload = {
"order_no": order_no,
"user_id": int(user_id),
"username": username,
"callback_url": callback_url,
**order,
}
pay_url = ""
if settings.shop_backend_create_order_url:
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(
settings.shop_backend_create_order_url,
json=pay_payload,
headers={"X-Shop-Token": settings.shop_backend_callback_token or ""},
)
body = r.json() if r.content else {}
if r.status_code < 400 and isinstance(body, dict):
pay_url = str(body.get("pay_url") or "")
except Exception as exc:
logger.warning("shop_create_order_failed order=%s err=%s", order_no, exc)
return order, pay_url
async def _fetch_pay_url_for_order(order: dict, user_id: int, username: str, callback_url: str) -> str:
pay_payload = {
"order_no": order.get("order_no", ""),
"user_id": int(user_id),
"username": username,
"callback_url": callback_url,
**order,
}
pay_url = ""
if settings.shop_backend_create_order_url:
try:
async with httpx.AsyncClient(timeout=15) as client:
r = await client.post(
settings.shop_backend_create_order_url,
json=pay_payload,
headers={"X-Shop-Token": settings.shop_backend_callback_token or ""},
)
body = r.json() if r.content else {}
if r.status_code < 400 and isinstance(body, dict):
pay_url = str(body.get("pay_url") or "")
except Exception as exc:
logger.warning("shop_repay_order_failed order=%s err=%s", order.get("order_no", ""), exc)
return pay_url
@app.get("/", response_class=HTMLResponse)
@@ -140,6 +320,48 @@ async def settings_page(request: Request):
return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name})
@app.get("/billing", response_class=HTMLResponse)
async def billing_page(request: Request):
if not _current_user(request):
return RedirectResponse(url="/auth?next=/billing", status_code=302)
return templates.TemplateResponse(
"billing.html",
{
"request": request,
"app_name": settings.app_name,
"package_amount": settings.credits_recharge_package_amount,
"package_credits": settings.credits_recharge_package_credits,
},
)
@app.get("/upgrade", response_class=HTMLResponse)
async def upgrade_page(request: Request):
if not _current_user(request):
return RedirectResponse(url="/auth?next=/upgrade", status_code=302)
return templates.TemplateResponse(
"upgrade.html",
{
"request": request,
"app_name": settings.app_name,
"trial_tokens": settings.vip_trial_tokens,
"rewrite_cost": settings.credits_per_million_tokens,
"image_cost": settings.credits_per_120_images,
"seat_price": settings.credits_standard_seat_price_cny,
"seat_quota": settings.credits_seat_monthly_quota,
"package_amount": settings.credits_recharge_package_amount,
"package_credits": settings.credits_recharge_package_credits,
},
)
@app.get("/profile", response_class=HTMLResponse)
async def profile_page(request: Request):
if not _current_user(request):
return RedirectResponse(url="/auth?next=/profile", status_code=302)
return templates.TemplateResponse("profile.html", {"request": request, "app_name": settings.app_name})
@app.get("/guide", response_class=HTMLResponse)
async def guide_page(request: Request):
if not _current_user(request):
@@ -197,14 +419,48 @@ async def auth_me(request: Request):
}
@app.get("/api/profile")
async def api_profile(request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
return {"ok": True, "profile": users.get_user_profile(user["id"])}
@app.post("/api/profile")
async def api_profile_update(req: UserProfileUpdateRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
profile = users.save_user_profile(
user["id"],
subscriber_name=(req.subscriber_name or "").strip(),
subscriber_phone=(req.subscriber_phone or "").strip(),
shipping_address=(req.shipping_address or "").strip(),
)
return {"ok": True, "detail": "个人信息已保存", "profile": profile}
@app.get("/api/auth/challenge")
async def auth_challenge():
return {"ok": True, **_create_challenge()}
@app.post("/api/auth/register")
async def auth_register(req: AuthCredentialRequest, response: Response):
async def auth_register(req: AuthCredentialRequest, request: Request, response: Response):
ip = _client_ip(request)
if _hit_limit(_register_rate, f"ip:{ip}", limit=8, window_sec=600):
return {"ok": False, "detail": "请求过于频繁,请稍后再试"}
if _hit_limit(_register_rate, f"user:{(req.username or '').strip().lower()}", limit=6, window_sec=600):
return {"ok": False, "detail": "该用户名操作过于频繁,请稍后再试"}
username = (req.username or "").strip()
password = req.password or ""
if len(username) < 2:
return {"ok": False, "detail": "用户名至少 2 个字符"}
if len(password) < 6:
return {"ok": False, "detail": "密码至少 6 个字符"}
ok, msg = _validate_username_password(username, password)
if not ok:
return {"ok": False, "detail": msg}
ok_challenge, challenge_msg = _verify_challenge(req)
if not ok_challenge:
return {"ok": False, "detail": challenge_msg}
try:
user = users.create_user(username, password)
except Exception as exc:
@@ -225,14 +481,19 @@ async def auth_register(req: AuthCredentialRequest, response: Response):
)
return {
"ok": True,
"detail": "注册并登录成功,已赠送试用 token,请保存重置码",
"detail": "注册并登录成功,已赠送试用 Credits,请保存重置码",
"user": {"id": user["id"], "username": user["username"]},
"reset_code": user.get("reset_code", ""),
}
@app.post("/api/auth/login")
async def auth_login(req: AuthCredentialRequest, response: Response):
async def auth_login(req: AuthCredentialRequest, request: Request, response: Response):
ip = _client_ip(request)
if _hit_limit(_login_rate, f"ip:{ip}", limit=20, window_sec=600):
return {"ok": False, "detail": "登录过于频繁,请稍后再试"}
if _hit_limit(_login_rate, f"user:{(req.username or '').strip().lower()}", limit=12, window_sec=600):
return {"ok": False, "detail": "该账户登录尝试过多,请稍后再试"}
try:
user = users.verify_user((req.username or "").strip(), req.password or "")
except Exception as exc:
@@ -335,6 +596,217 @@ async def auth_delete_account(req: DeleteAccountRequest, request: Request, respo
return {"ok": True, "detail": "账号已注销,关联数据已清空"}
@app.get("/api/auth/vip/status")
async def auth_vip_status(request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
return {"ok": True, "vip": users.get_vip_status(user["id"])}
@app.post("/api/auth/vip/toggle")
async def auth_vip_toggle(req: VipToggleRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
vip = users.set_vip_enabled(user["id"], bool(req.enabled))
return {"ok": True, "detail": "VIP 模式已更新", "vip": vip}
@app.post("/api/auth/vip/recharge")
async def auth_vip_recharge(req: VipRechargeRequest, request: Request):
token = (request.headers.get("X-Shop-Token") or "").strip()
if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token:
raise HTTPException(status_code=403, detail="直充接口已禁用,请走支付订单接口")
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
vip = users.recharge_tokens(
user["id"],
int(req.tokens),
kind="admin_direct_recharge",
ref_type="admin",
ref_id="",
detail={"source": "legacy_api"},
)
return {"ok": True, "detail": "充值成功", "vip": vip}
@app.get("/api/billing/overview")
async def billing_overview(request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
return {
"ok": True,
"vip": users.get_vip_status(user["id"]),
"recharge_records": users.list_recharge_orders(user["id"], limit=30),
"consume_records": users.list_token_ledger(user["id"], limit=100),
}
@app.post("/api/billing/recharge/create")
async def billing_recharge_create(req: BillingRechargeCreateRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
order, pay_url = await _create_recharge_order_with_pay_url(
user_id=user["id"],
username=user["username"],
credits=int(req.tokens),
callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall",
order_meta={
"subscriber_name": (req.subscriber_name or "").strip(),
"subscriber_phone": (req.subscriber_phone or "").strip(),
"shipping_address": (req.shipping_address or "").strip(),
},
channel=(req.channel or "wechat").strip() or "wechat",
)
return {"ok": True, "detail": "充值订单已创建", "order": order, "pay_url": pay_url}
@app.post("/api/billing/recharge/pay-now")
async def billing_recharge_pay_now(req: BillingPayNowRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
order_no = (req.order_no or "").strip()
if not order_no:
return {"ok": False, "detail": "订单号不能为空"}
order = users.get_recharge_order(user["id"], order_no)
if not order:
return {"ok": False, "detail": "订单不存在"}
status = (order.get("status") or "").lower()
if status in {"paid", "success"}:
return {"ok": False, "detail": "订单已支付,无需重复支付", "order": order}
if status in {"cancelled", "closed"}:
return {"ok": False, "detail": "订单已取消,请重新创建订单", "order": order}
pay_url = await _fetch_pay_url_for_order(
order=order,
user_id=user["id"],
username=user["username"],
callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall",
)
return {"ok": True, "detail": "支付链接已生成", "order": order, "pay_url": pay_url}
@app.get("/api/pay/wechat/")
async def pay_wechat_ready():
return {"ok": True, "detail": "wechat pay api ready"}
@app.post("/api/pay/wechat/")
async def pay_wechat_create(req: BillingRechargeCreateRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
order, pay_url = await _create_recharge_order_with_pay_url(
user_id=user["id"],
username=user["username"],
credits=int(req.tokens),
callback_url=f"{str(request.base_url).rstrip('/')}/api/pay/wechat/backcall",
order_meta={
"subscriber_name": (req.subscriber_name or "").strip(),
"subscriber_phone": (req.subscriber_phone or "").strip(),
"shipping_address": (req.shipping_address or "").strip(),
},
channel=(req.channel or "wechat").strip() or "wechat",
)
return {"ok": True, "detail": "支付订单已创建", "order": order, "pay_url": pay_url}
@app.get("/api/pay/address/suggest")
async def pay_address_suggest(request: Request):
ip = _client_ip(request)
if not ip or ip in {"unknown", "127.0.0.1", "::1"}:
return {"ok": True, "detail": "本地环境,建议手动填写地址", "ip": ip or "unknown", "address": ""}
url = f"https://ip-api.com/json/{ip}?lang=zh-CN"
try:
async with httpx.AsyncClient(timeout=5) as client:
r = await client.get(url)
body = r.json() if r.content else {}
if r.status_code >= 400 or not isinstance(body, dict):
return {"ok": True, "detail": "IP地址解析失败已填入IP信息可手动修改", "ip": ip, "address": f"IP:{ip}"}
if body.get("status") != "success":
return {"ok": True, "detail": "IP地址解析失败已填入IP信息可手动修改", "ip": ip, "address": f"IP:{ip}"}
country = str(body.get("country") or "").strip()
region = str(body.get("regionName") or "").strip()
city = str(body.get("city") or "").strip()
isp = str(body.get("isp") or "").strip()
addr = "".join([x for x in [country, region, city] if x])
if isp:
addr = f"{addr}{isp}" if addr else isp
if not addr:
addr = f"IP:{ip}"
return {"ok": True, "detail": "已根据IP自动识别地址并填充可手动修改", "ip": ip, "address": addr}
except Exception:
return {"ok": True, "detail": "IP地址解析失败已填入IP信息可手动修改", "ip": ip, "address": f"IP:{ip}"}
@app.post("/api/billing/recharge/notify")
async def billing_recharge_notify(req: BillingRechargeNotifyRequest, request: Request):
token = (request.headers.get("X-Shop-Token") or "").strip()
if not settings.shop_backend_callback_token or token != settings.shop_backend_callback_token:
raise HTTPException(status_code=403, detail="forbidden")
user = _require_user(request)
uid = user["id"] if user else 0
# 回调优先根据登录态用户确认;若无登录态,可通过订单所属用户在 DB 内判断
if uid <= 0:
# 无用户态时,先查订单归属(通过 mark 接口会二次校验)
uid = -1
if (req.status or "").lower() not in {"paid", "success"}:
return {"ok": True, "detail": "ignored"}
if uid > 0:
ok, msg = users.mark_recharge_order_paid(
uid,
req.order_no,
paid_amount_cny=float(req.paid_amount_cny or 0.0),
external_txn_id=(req.external_txn_id or "").strip(),
meta={"status": req.status},
)
if not ok:
return {"ok": False, "detail": msg}
return {"ok": True, "detail": msg}
# no session callback path: iterate known users is unavailable; use direct sql in store by probing order owner
# fallback: let store resolve user by order internally via new helper-like behavior
owner_id = users.get_recharge_order_user_id(req.order_no)
if not owner_id:
return {"ok": False, "detail": "订单不存在"}
ok, msg = users.mark_recharge_order_paid(
int(owner_id),
req.order_no,
paid_amount_cny=float(req.paid_amount_cny or 0.0),
external_txn_id=(req.external_txn_id or "").strip(),
meta={"status": req.status},
)
if not ok:
return {"ok": False, "detail": msg}
return {"ok": True, "detail": msg}
@app.post("/api/pay/wechat/backcall")
async def pay_wechat_backcall(req: BillingRechargeNotifyRequest, request: Request):
configured = (settings.shop_backend_callback_token or "").strip()
provided = (request.headers.get("X-Shop-Token") or request.query_params.get("token") or "").strip()
if configured and provided != configured:
raise HTTPException(status_code=403, detail="forbidden")
if (req.status or "").lower() not in {"paid", "success"}:
return {"ok": True, "detail": "ignored"}
owner_id = users.get_recharge_order_user_id(req.order_no)
if not owner_id:
return {"ok": False, "detail": "订单不存在"}
ok, msg = users.mark_recharge_order_paid(
int(owner_id),
req.order_no,
paid_amount_cny=float(req.paid_amount_cny or 0.0),
external_txn_id=(req.external_txn_id or "").strip(),
meta={"status": req.status, "source": "wechat_backcall"},
)
if not ok:
return {"ok": False, "detail": msg}
return {"ok": True, "detail": msg}
@app.post("/api/auth/wechat/bind")
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
user = _require_user(request)
@@ -425,6 +897,20 @@ async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request):
return {"ok": True, "detail": "模型配置已删除"}
@app.post("/api/auth/ai-models/image-model/update")
async def auth_ai_image_model_update(req: AIImageModelUpdateRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
image_model = (req.image_model or "").strip()
if not image_model:
return {"ok": False, "detail": "文生图模型不能为空"}
ok = users.update_active_ai_image_model(user["id"], image_model)
if not ok:
return {"ok": False, "detail": "保存失败:请先配置文本模型"}
return {"ok": True, "detail": "文生图模型已更新"}
@app.post("/api/rewrite")
async def rewrite(req: RewriteRequest, request: Request):
rid = getattr(request.state, "request_id", "")
@@ -444,7 +930,9 @@ async def rewrite(req: RewriteRequest, request: Request):
user = _require_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
model_cfg = users.get_active_ai_model(user["id"])
model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True)
if model_source == "vip_empty":
raise HTTPException(status_code=402, detail=_quota_detail())
if not model_cfg:
raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型")
backup = {
@@ -472,6 +960,50 @@ async def rewrite(req: RewriteRequest, request: Request):
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
settings.openai_max_retries = backup["openai_max_retries"]
settings.openai_image_model = backup["openai_image_model"]
usage = ((result.trace or {}).get("usage") or {}) if isinstance(result.trace, dict) else {}
prompt_tokens = int(usage.get("prompt_tokens") or 0)
completion_tokens = int(usage.get("completion_tokens") or 0)
total_tokens = int(usage.get("total_tokens") or 0)
billed_basis = "usage_tokens" if total_tokens > 0 else "char_estimate"
token_cost = _estimate_rewrite_cost(req, result)
vip_status = users.get_vip_status(user["id"])
should_consume = bool(vip_status.get("vip_enabled"))
if should_consume:
ok_cost, balance = users.consume_tokens(
user["id"],
token_cost,
kind="rewrite",
ref_type="request",
ref_id=rid,
detail={
"source_chars": len((req.source_text or "").strip()),
"target_body_chars": int(req.target_body_chars or 0),
"title_chars": len((result.title or "").strip()),
"summary_chars": len((result.summary or "").strip()),
"body_chars": len((result.body_markdown or "").strip()),
"model": model_cfg.get("model") if model_cfg else "",
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"credits_rule": (
f"1000000 tokens={float(settings.credits_token_price_per_million_cny):.2f}元,"
f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元,"
f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}"
),
"billed_basis": billed_basis,
},
)
if not ok_cost:
raise HTTPException(status_code=402, detail=_quota_detail())
if result.trace is None:
result.trace = {}
result.trace["vip"] = {
"model_source": "platform" if model_source == "vip" else "user",
"credits_cost": token_cost,
"credits_balance": balance,
"billed_basis": billed_basis,
"total_tokens": total_tokens,
}
tr = result.trace or {}
logger.info(
"api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s",
@@ -599,7 +1131,10 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques
len(req.summary or ""),
req.upload_to_wechat,
)
model_cfg = users.get_active_ai_model(user["id"])
model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True)
if model_source == "vip_empty":
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
image_model_override = (req.image_model or "").strip()
backup = {
"openai_api_key": settings.openai_api_key,
"openai_base_url": settings.openai_base_url,
@@ -626,6 +1161,8 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques
settings.openai_max_output_tokens = 8192
settings.openai_max_retries = 0
settings.openai_image_model = backup["openai_image_model"]
if image_model_override:
settings.openai_image_model = image_model_override
out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding)
finally:
settings.openai_api_key = backup["openai_api_key"]
@@ -643,6 +1180,31 @@ async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Reques
out.note,
len(out.warnings),
)
if out.ok and model_source == "vip":
token_cost = _estimate_image_cost(1)
ok_cost, balance = users.consume_tokens(
user["id"],
token_cost,
kind="cover_generate",
ref_type="request",
ref_id=rid,
detail={
"title": (req.title or "")[:120],
"style_hint": (req.style_hint or "")[:120],
"image_count": 1,
"image_model": image_model_override or settings.openai_image_model,
"image_price_package_cny": float(settings.credits_image_price_package_cny),
"image_price_package_images": int(settings.credits_image_price_package_images),
"credits_rule": (
f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元,"
f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}"
),
},
)
if not ok_cost:
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
out.warnings = list(out.warnings or [])
out.warnings.append(f"已扣减 {token_cost} Credits可用余额 {balance}")
return out
@@ -684,7 +1246,10 @@ async def generate_posters(req: PosterGenerateRequest, request: Request):
req.upload_to_wechat,
int(req.max_images or 0),
)
model_cfg = users.get_active_ai_model(user["id"])
model_cfg, model_source = _select_model_cfg(user["id"], prefer_vip=True)
if model_source == "vip_empty":
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
image_model_override = (req.image_model or "").strip()
backup = {
"openai_api_key": settings.openai_api_key,
"openai_base_url": settings.openai_base_url,
@@ -711,6 +1276,8 @@ async def generate_posters(req: PosterGenerateRequest, request: Request):
settings.openai_max_output_tokens = 8192
settings.openai_max_retries = 0
settings.openai_image_model = backup["openai_image_model"]
if image_model_override:
settings.openai_image_model = image_model_override
out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding)
finally:
settings.openai_api_key = backup["openai_api_key"]
@@ -727,6 +1294,32 @@ async def generate_posters(req: PosterGenerateRequest, request: Request):
len(out.posters),
len(out.warnings),
)
if out.ok and model_source == "vip":
image_count = max(0, len(out.posters or []))
token_cost = _estimate_image_cost(image_count)
ok_cost, balance = users.consume_tokens(
user["id"],
token_cost,
kind="poster_generate",
ref_type="request",
ref_id=rid,
detail={
"image_count": image_count,
"body_chars": len((req.body_markdown or "").strip()),
"max_images": int(req.max_images or 0),
"image_model": image_model_override or settings.openai_image_model,
"image_price_package_cny": float(settings.credits_image_price_package_cny),
"image_price_package_images": int(settings.credits_image_price_package_images),
"credits_rule": (
f"图片{int(settings.credits_image_price_package_images)}张={float(settings.credits_image_price_package_cny):.2f}元,"
f"{int(settings.credits_recharge_package_credits)}Credits={float(settings.credits_recharge_package_amount):.2f}"
),
},
)
if not ok_cost:
return {"ok": False, "detail": _quota_detail(), "upgrade_required": True}
out.warnings = list(out.warnings or [])
out.warnings.append(f"已扣减 {token_cost} Credits可用余额 {balance}")
return out

View File

@@ -54,6 +54,9 @@ class AuthCredentialRequest(BaseModel):
username: str
password: str
remember_me: bool = False
challenge_id: str = ""
challenge_answer: str = ""
honeypot: str = ""
class ChangePasswordRequest(BaseModel):
@@ -97,6 +100,7 @@ class WechatCoverGenerateRequest(BaseModel):
title: str = ""
summary: str = ""
style_hint: str = ""
image_model: str = ""
upload_to_wechat: bool = True
@@ -119,12 +123,42 @@ class AIModelDeleteRequest(BaseModel):
model_id: int
class AIImageModelUpdateRequest(BaseModel):
image_model: str
class VipToggleRequest(BaseModel):
enabled: bool = True
class VipRechargeRequest(BaseModel):
tokens: int = Field(default=10000, ge=1, le=10_000_000)
tokens: int = Field(default=1500, ge=1, le=10_000_000)
class BillingRechargeCreateRequest(BaseModel):
tokens: int = Field(default=1500, ge=1, le=10_000_000)
amount_cny: float = Field(default=19.9, ge=0.01, le=999999)
channel: str = "wechat"
subscriber_name: str = ""
subscriber_phone: str = ""
shipping_address: str = ""
class BillingRechargeNotifyRequest(BaseModel):
order_no: str
paid_amount_cny: float = Field(default=0.0, ge=0.0)
external_txn_id: str = ""
status: str = "paid"
class BillingPayNowRequest(BaseModel):
order_no: str
class UserProfileUpdateRequest(BaseModel):
subscriber_name: str = ""
subscriber_phone: str = ""
shipping_address: str = ""
class PosterGenerateRequest(BaseModel):
@@ -132,6 +166,7 @@ class PosterGenerateRequest(BaseModel):
summary: str = ""
body_markdown: str = Field(..., min_length=20)
style_hint: str = ""
image_model: str = ""
upload_to_wechat: bool = True
max_images: int = Field(default=6, ge=1, le=12)

View File

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

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import hmac
import json
import secrets
import sqlite3
import time
@@ -69,6 +70,12 @@ class UserStore:
pref_cols = self._table_columns(c, "user_prefs")
if "active_ai_model_id" not in pref_cols:
c.execute("ALTER TABLE user_prefs ADD COLUMN active_ai_model_id INTEGER")
if "subscriber_name" not in pref_cols:
c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_name TEXT NOT NULL DEFAULT ''")
if "subscriber_phone" not in pref_cols:
c.execute("ALTER TABLE user_prefs ADD COLUMN subscriber_phone TEXT NOT NULL DEFAULT ''")
if "shipping_address" not in pref_cols:
c.execute("ALTER TABLE user_prefs ADD COLUMN shipping_address TEXT NOT NULL DEFAULT ''")
c.execute(
"""
CREATE TABLE IF NOT EXISTS ai_models (
@@ -95,11 +102,62 @@ class UserStore:
vip_enabled INTEGER NOT NULL DEFAULT 0,
token_balance INTEGER NOT NULL DEFAULT 0,
total_consumed_tokens INTEGER NOT NULL DEFAULT 0,
seat_quota_credits INTEGER NOT NULL DEFAULT 1500,
seat_used_credits INTEGER NOT NULL DEFAULT 0,
seat_cycle TEXT NOT NULL DEFAULT '',
cycle_started_at INTEGER NOT NULL DEFAULT 0,
cycle_expires_at INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
wallet_cols = self._table_columns(c, "user_wallets")
if "seat_quota_credits" not in wallet_cols:
c.execute("ALTER TABLE user_wallets ADD COLUMN seat_quota_credits INTEGER NOT NULL DEFAULT 25000")
if "seat_used_credits" not in wallet_cols:
c.execute("ALTER TABLE user_wallets ADD COLUMN seat_used_credits INTEGER NOT NULL DEFAULT 0")
if "seat_cycle" not in wallet_cols:
c.execute("ALTER TABLE user_wallets ADD COLUMN seat_cycle TEXT NOT NULL DEFAULT ''")
if "cycle_started_at" not in wallet_cols:
c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_started_at INTEGER NOT NULL DEFAULT 0")
if "cycle_expires_at" not in wallet_cols:
c.execute("ALTER TABLE user_wallets ADD COLUMN cycle_expires_at INTEGER NOT NULL DEFAULT 0")
c.execute(
"""
CREATE TABLE IF NOT EXISTS recharge_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
order_no TEXT NOT NULL UNIQUE,
channel TEXT NOT NULL DEFAULT '',
token_amount INTEGER NOT NULL DEFAULT 0,
amount_cny REAL NOT NULL DEFAULT 0.0,
status TEXT NOT NULL DEFAULT 'pending',
external_txn_id TEXT NOT NULL DEFAULT '',
meta_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
paid_at INTEGER,
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
c.execute(
"""
CREATE TABLE IF NOT EXISTS token_ledger (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
direction TEXT NOT NULL,
token_change INTEGER NOT NULL,
balance_after INTEGER NOT NULL,
kind TEXT NOT NULL DEFAULT '',
ref_type TEXT NOT NULL DEFAULT '',
ref_id TEXT NOT NULL DEFAULT '',
detail_json TEXT NOT NULL DEFAULT '{}',
created_at INTEGER NOT NULL,
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
ai_cols = self._table_columns(c, "ai_models")
if "image_model" not in ai_cols:
c.execute("ALTER TABLE ai_models ADD COLUMN image_model TEXT NOT NULL DEFAULT ''")
@@ -438,6 +496,57 @@ class UserStore:
return None
return {"id": int(row["id"]), "username": row["username"]}
def get_user_profile(self, user_id: int) -> dict:
with self._conn() as c:
row = c.execute(
"""
SELECT subscriber_name, subscriber_phone, shipping_address
FROM user_prefs
WHERE user_id=?
LIMIT 1
""",
(user_id,),
).fetchone()
if not row:
return {"subscriber_name": "", "subscriber_phone": "", "shipping_address": ""}
return {
"subscriber_name": (row["subscriber_name"] or "").strip(),
"subscriber_phone": (row["subscriber_phone"] or "").strip(),
"shipping_address": (row["shipping_address"] or "").strip(),
}
def save_user_profile(
self,
user_id: int,
*,
subscriber_name: str,
subscriber_phone: str,
shipping_address: str,
) -> dict:
now = int(time.time())
with self._conn() as c:
c.execute(
"""
INSERT INTO user_prefs(
user_id, active_wechat_account_id, active_ai_model_id,
subscriber_name, subscriber_phone, shipping_address, updated_at
) VALUES (?, NULL, NULL, ?, ?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
subscriber_name=excluded.subscriber_name,
subscriber_phone=excluded.subscriber_phone,
shipping_address=excluded.shipping_address,
updated_at=excluded.updated_at
""",
(
user_id,
(subscriber_name or "").strip(),
(subscriber_phone or "").strip(),
(shipping_address or "").strip(),
now,
),
)
return self.get_user_profile(user_id)
def delete_user_logically(self, user_id: int, password: str, reset_code: str) -> bool:
now = int(time.time())
with self._conn() as c:
@@ -464,6 +573,8 @@ class UserStore:
c.execute("DELETE FROM user_prefs WHERE user_id=?", (user_id,))
c.execute("DELETE FROM wechat_bindings WHERE user_id=?", (user_id,))
c.execute("DELETE FROM user_wallets WHERE user_id=?", (user_id,))
c.execute("DELETE FROM recharge_orders WHERE user_id=?", (user_id,))
c.execute("DELETE FROM token_ledger WHERE user_id=?", (user_id,))
c.execute(
"UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?",
(now, str(now), user_id),
@@ -472,14 +583,81 @@ class UserStore:
def _ensure_wallet_row(self, c: sqlite3.Connection, user_id: int) -> None:
now = int(time.time())
cycle = time.strftime("%Y-%m", time.localtime(now))
c.execute(
"""
INSERT OR IGNORE INTO user_wallets(user_id, vip_enabled, token_balance, total_consumed_tokens, updated_at)
VALUES (?, 0, 0, 0, ?)
INSERT OR IGNORE INTO user_wallets(
user_id, vip_enabled, token_balance, total_consumed_tokens,
seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at
)
VALUES (?, 0, 0, 0, 1500, 0, ?, 0, 0, ?)
""",
(user_id, now),
(user_id, cycle, now),
)
def _refresh_billing_cycle(self, c: sqlite3.Connection, user_id: int) -> None:
now = int(time.time())
row = c.execute(
"SELECT seat_cycle, cycle_expires_at, token_balance, seat_used_credits FROM user_wallets WHERE user_id=?",
(user_id,),
).fetchone()
current_cycle = (row["seat_cycle"] or "") if row else ""
expires_at = int(row["cycle_expires_at"] or 0) if row else 0
if expires_at > 0 and now >= expires_at:
c.execute(
"""
UPDATE user_wallets
SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=?
WHERE user_id=?
""",
(now, user_id),
)
return
# 兼容历史按自然月 seat_cycle 的老数据:若没有新周期字段,保留原行为
if expires_at <= 0:
paid = c.execute(
"""
SELECT paid_at
FROM recharge_orders
WHERE user_id=? AND status='paid' AND paid_at IS NOT NULL
ORDER BY paid_at DESC, id DESC
LIMIT 1
""",
(user_id,),
).fetchone()
if paid and int(paid["paid_at"] or 0) > 0:
start_at = int(paid["paid_at"])
new_expires = start_at + 30 * 24 * 3600
if now >= new_expires:
c.execute(
"""
UPDATE user_wallets
SET seat_used_credits=0, token_balance=0, seat_cycle='', cycle_started_at=0, cycle_expires_at=0, updated_at=?
WHERE user_id=?
""",
(now, user_id),
)
else:
c.execute(
"""
UPDATE user_wallets
SET seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=?
WHERE user_id=?
""",
(time.strftime("%Y-%m", time.localtime(start_at)), start_at, new_expires, now, user_id),
)
return
cycle = time.strftime("%Y-%m", time.localtime(now))
if current_cycle != cycle:
c.execute(
"""
UPDATE user_wallets
SET seat_used_credits=0, seat_cycle=?, updated_at=?
WHERE user_id=?
""",
(cycle, now, user_id),
)
def ensure_trial_tokens(self, user_id: int, trial_tokens: int) -> dict:
amount = max(0, int(trial_tokens))
now = int(time.time())
@@ -491,31 +669,63 @@ class UserStore:
).fetchone()
current = int(row["token_balance"] or 0) if row else 0
if current <= 0 and amount > 0:
new_balance = amount
c.execute(
"""
UPDATE user_wallets
SET vip_enabled=1, token_balance=?, updated_at=?
WHERE user_id=?
""",
(amount, now, user_id),
(new_balance, now, user_id),
)
c.execute(
"""
INSERT INTO token_ledger(
user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
) VALUES (?, 'in', ?, ?, 'trial_grant', 'system', '', '{}', ?)
""",
(user_id, amount, new_balance, now),
)
return self.get_vip_status(user_id)
def get_vip_status(self, user_id: int) -> dict:
with self._conn() as c:
self._ensure_wallet_row(c, user_id)
self._refresh_billing_cycle(c, user_id)
row = c.execute(
"""
SELECT vip_enabled, token_balance, total_consumed_tokens, updated_at
SELECT
vip_enabled, token_balance, total_consumed_tokens,
seat_quota_credits, seat_used_credits, seat_cycle, cycle_started_at, cycle_expires_at, updated_at
FROM user_wallets
WHERE user_id=?
""",
(user_id,),
).fetchone()
seat_quota = int(row["seat_quota_credits"] or 0) if row else 0
seat_used = int(row["seat_used_credits"] or 0) if row else 0
seat_remaining = max(0, seat_quota - seat_used)
shared_credits = int(row["token_balance"] or 0) if row else 0
cycle_started_at = int(row["cycle_started_at"] or 0) if row else 0
cycle_expires_at = int(row["cycle_expires_at"] or 0) if row else 0
now = int(time.time())
cycle_active = cycle_expires_at > now if cycle_expires_at > 0 else True
if not cycle_active:
seat_remaining = 0
shared_credits = 0
return {
"vip_enabled": bool(int(row["vip_enabled"] or 0)) if row else False,
"token_balance": int(row["token_balance"] or 0) if row else 0,
"token_balance": shared_credits,
"total_consumed_tokens": int(row["total_consumed_tokens"] or 0) if row else 0,
"seat_quota_credits": seat_quota,
"seat_used_credits": seat_used,
"seat_remaining_credits": seat_remaining,
"shared_credits": shared_credits,
"total_available_credits": seat_remaining + shared_credits,
"seat_cycle": (row["seat_cycle"] or "") if row else "",
"cycle_started_at": cycle_started_at,
"cycle_expires_at": cycle_expires_at,
"cycle_active": cycle_active,
"updated_at": int(row["updated_at"] or 0) if row else 0,
}
@@ -529,45 +739,328 @@ class UserStore:
)
return self.get_vip_status(user_id)
def recharge_tokens(self, user_id: int, tokens: int) -> dict:
def recharge_tokens(
self,
user_id: int,
tokens: int,
*,
kind: str = "manual_recharge",
ref_type: str = "",
ref_id: str = "",
detail: dict | None = None,
cycle_start_at: int | None = None,
cycle_days: int = 30,
) -> dict:
add = max(0, int(tokens))
now = int(time.time())
with self._conn() as c:
self._ensure_wallet_row(c, user_id)
self._refresh_billing_cycle(c, user_id)
row = c.execute("SELECT token_balance FROM user_wallets WHERE user_id=?", (user_id,)).fetchone()
prev = int(row["token_balance"] or 0) if row else 0
new_balance = prev + add
start_at = int(cycle_start_at or 0)
if start_at > 0:
expires_at = start_at + max(1, int(cycle_days)) * 24 * 3600
c.execute(
"""
UPDATE user_wallets
SET token_balance=token_balance + ?, vip_enabled=1, seat_used_credits=0, seat_cycle=?, cycle_started_at=?, cycle_expires_at=?, updated_at=?
WHERE user_id=?
""",
(add, time.strftime("%Y-%m", time.localtime(start_at)), start_at, expires_at, now, user_id),
)
else:
c.execute(
"""
UPDATE user_wallets
SET token_balance=token_balance + ?, vip_enabled=1, updated_at=?
WHERE user_id=?
""",
(add, now, user_id),
)
c.execute(
"""
UPDATE user_wallets
SET token_balance=token_balance + ?, vip_enabled=1, updated_at=?
WHERE user_id=?
INSERT INTO token_ledger(
user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
) VALUES (?, 'in', ?, ?, ?, ?, ?, ?, ?)
""",
(add, now, user_id),
(
user_id,
add,
new_balance,
kind,
ref_type,
ref_id or "",
json.dumps(detail or {}, ensure_ascii=True),
now,
),
)
return self.get_vip_status(user_id)
def consume_tokens(self, user_id: int, tokens: int) -> tuple[bool, int]:
def consume_tokens(
self,
user_id: int,
tokens: int,
*,
kind: str = "usage",
ref_type: str = "",
ref_id: str = "",
detail: dict | None = None,
) -> tuple[bool, int]:
cost = max(0, int(tokens))
now = int(time.time())
with self._conn() as c:
self._ensure_wallet_row(c, user_id)
self._refresh_billing_cycle(c, user_id)
row = c.execute(
"SELECT token_balance FROM user_wallets WHERE user_id=?",
"SELECT token_balance, seat_quota_credits, seat_used_credits FROM user_wallets WHERE user_id=?",
(user_id,),
).fetchone()
balance = int(row["token_balance"] or 0) if row else 0
shared_balance = int(row["token_balance"] or 0) if row else 0
seat_quota = int(row["seat_quota_credits"] or 0) if row else 0
seat_used = int(row["seat_used_credits"] or 0) if row else 0
seat_remaining = max(0, seat_quota - seat_used)
if cost <= 0:
return True, balance
if balance < cost:
return False, balance
new_balance = balance - cost
return True, seat_remaining + shared_balance
use_from_seat = min(seat_remaining, cost)
need_shared = cost - use_from_seat
if shared_balance < need_shared:
return False, seat_remaining + shared_balance
new_shared = shared_balance - need_shared
new_seat_used = seat_used + use_from_seat
c.execute(
"""
UPDATE user_wallets
SET token_balance=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=?
SET token_balance=?, seat_used_credits=?, total_consumed_tokens=total_consumed_tokens + ?, updated_at=?
WHERE user_id=?
""",
(new_balance, cost, now, user_id),
(new_shared, new_seat_used, cost, now, user_id),
)
return True, new_balance
c.execute(
"""
INSERT INTO token_ledger(
user_id, direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
) VALUES (?, 'out', ?, ?, ?, ?, ?, ?, ?)
""",
(
user_id,
cost,
max(0, seat_quota - new_seat_used) + new_shared,
kind,
ref_type,
ref_id or "",
json.dumps(
{
**(detail or {}),
"credit_source": {"seat": use_from_seat, "shared": need_shared},
},
ensure_ascii=True,
),
now,
),
)
return True, max(0, seat_quota - new_seat_used) + new_shared
def create_recharge_order(
self,
user_id: int,
order_no: str,
channel: str,
token_amount: int,
amount_cny: float,
meta: dict | None = None,
) -> dict:
now = int(time.time())
with self._conn() as c:
c.execute(
"""
INSERT INTO recharge_orders(
user_id, order_no, channel, token_amount, amount_cny, status, external_txn_id, meta_json, created_at, paid_at
) VALUES (?, ?, ?, ?, ?, 'pending', '', ?, ?, NULL)
""",
(
user_id,
order_no,
channel or "",
int(token_amount),
float(amount_cny),
json.dumps(meta or {}, ensure_ascii=True),
now,
),
)
return {
"order_no": order_no,
"channel": channel,
"token_amount": int(token_amount),
"amount_cny": float(amount_cny),
"status": "pending",
"created_at": now,
}
def mark_recharge_order_paid(
self,
user_id: int,
order_no: str,
paid_amount_cny: float,
external_txn_id: str = "",
meta: dict | None = None,
) -> tuple[bool, str]:
now = int(time.time())
with self._conn() as c:
row = c.execute(
"""
SELECT user_id, token_amount, amount_cny, status
FROM recharge_orders
WHERE order_no=?
""",
(order_no,),
).fetchone()
if not row:
return False, "订单不存在"
if int(row["user_id"]) != int(user_id):
return False, "订单无权限"
if (row["status"] or "") == "paid":
return True, "already_paid"
if float(paid_amount_cny or 0.0) + 1e-9 < float(row["amount_cny"] or 0.0):
return False, "支付金额不足"
c.execute(
"""
UPDATE recharge_orders
SET status='paid', external_txn_id=?, paid_at=?, meta_json=?
WHERE order_no=?
""",
(
external_txn_id or "",
now,
json.dumps(meta or {}, ensure_ascii=True),
order_no,
),
)
self.recharge_tokens(
user_id,
int(row["token_amount"] or 0),
kind="paid_recharge",
ref_type="order",
ref_id=order_no,
detail={"paid_amount_cny": float(paid_amount_cny or 0.0), "external_txn_id": external_txn_id or ""},
cycle_start_at=now,
cycle_days=30,
)
return True, "ok"
def list_recharge_orders(self, user_id: int, limit: int = 50) -> list[dict]:
with self._conn() as c:
now = int(time.time())
expire_before = now - 15 * 60
c.execute(
"""
UPDATE recharge_orders
SET status='cancelled'
WHERE user_id=? AND status='pending' AND created_at<=?
""",
(user_id, expire_before),
)
rows = c.execute(
"""
SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json
FROM recharge_orders
WHERE user_id=?
ORDER BY id DESC
LIMIT ?
""",
(user_id, max(1, min(int(limit), 200))),
).fetchall()
return [
{
"order_no": r["order_no"] or "",
"channel": r["channel"] or "",
"token_amount": int(r["token_amount"] or 0),
"amount_cny": float(r["amount_cny"] or 0.0),
"status": r["status"] or "",
"external_txn_id": r["external_txn_id"] or "",
"created_at": int(r["created_at"] or 0),
"paid_at": int(r["paid_at"] or 0) if r["paid_at"] else None,
"meta": json.loads(r["meta_json"] or "{}"),
}
for r in rows
]
def get_recharge_order(self, user_id: int, order_no: str) -> dict | None:
now = int(time.time())
with self._conn() as c:
expire_before = now - 15 * 60
c.execute(
"""
UPDATE recharge_orders
SET status='cancelled'
WHERE user_id=? AND status='pending' AND created_at<=?
""",
(user_id, expire_before),
)
row = c.execute(
"""
SELECT order_no, channel, token_amount, amount_cny, status, external_txn_id, created_at, paid_at, meta_json
FROM recharge_orders
WHERE user_id=? AND order_no=?
LIMIT 1
""",
(user_id, order_no),
).fetchone()
if not row:
return None
try:
meta = json.loads(row["meta_json"] or "{}")
except Exception:
meta = {}
return {
"order_no": row["order_no"] or "",
"channel": row["channel"] or "",
"token_amount": int(row["token_amount"] or 0),
"amount_cny": float(row["amount_cny"] or 0.0),
"status": row["status"] or "",
"external_txn_id": row["external_txn_id"] or "",
"created_at": int(row["created_at"] or 0),
"paid_at": int(row["paid_at"] or 0) if row["paid_at"] else None,
"meta": meta,
}
def get_recharge_order_user_id(self, order_no: str) -> int | None:
with self._conn() as c:
row = c.execute("SELECT user_id FROM recharge_orders WHERE order_no=?", (order_no,)).fetchone()
return int(row["user_id"]) if row and row["user_id"] else None
def list_token_ledger(self, user_id: int, limit: int = 100) -> list[dict]:
with self._conn() as c:
rows = c.execute(
"""
SELECT direction, token_change, balance_after, kind, ref_type, ref_id, detail_json, created_at
FROM token_ledger
WHERE user_id=?
ORDER BY id DESC
LIMIT ?
""",
(user_id, max(1, min(int(limit), 500))),
).fetchall()
out: list[dict] = []
for r in rows:
try:
detail = json.loads(r["detail_json"] or "{}")
except Exception:
detail = {}
out.append(
{
"direction": r["direction"] or "",
"token_change": int(r["token_change"] or 0),
"balance_after": int(r["balance_after"] or 0),
"kind": r["kind"] or "",
"ref_type": r["ref_type"] or "",
"ref_id": r["ref_id"] or "",
"detail": detail,
"created_at": int(r["created_at"] or 0),
}
)
return out
def save_wechat_binding(
self,
@@ -887,6 +1380,41 @@ class UserStore:
)
return True
def update_active_ai_image_model(self, user_id: int, image_model: str) -> bool:
now = int(time.time())
name = (image_model or "").strip()
with self._conn() as c:
pref = c.execute(
"SELECT active_ai_model_id FROM user_prefs WHERE user_id=?",
(user_id,),
).fetchone()
aid = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None
if not aid:
row = c.execute(
"SELECT id FROM ai_models WHERE user_id=? ORDER BY updated_at DESC, id DESC LIMIT 1",
(user_id,),
).fetchone()
aid = int(row["id"]) if row else None
if not aid:
return False
c.execute(
"UPDATE ai_models SET image_model=?, updated_at=? WHERE id=? AND user_id=?",
(name, now, aid, user_id),
)
if c.total_changes <= 0:
return False
c.execute(
"""
INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_ai_model_id=excluded.active_ai_model_id,
updated_at=excluded.updated_at
""",
(user_id, aid, now),
)
return True
def get_active_ai_model(self, user_id: int) -> dict | None:
with self._conn() as c:
pref = c.execute(

View File

@@ -18,6 +18,8 @@ const imBtn = $("imBtn");
const coverUploadBtn = $("coverUploadBtn");
const coverUrlUploadBtn = $("coverUrlUploadBtn");
const coverGenerateBtn = $("coverGenerateBtn");
const saveCoverImageModelBtn = $("saveCoverImageModelBtn");
const coverImageModelInput = $("coverImageModel");
const coverModeManualBtn = $("coverModeManualBtn");
const coverModeAiBtn = $("coverModeAiBtn");
const coverManualSection = $("coverManualSection");
@@ -26,11 +28,13 @@ const coverAutoAfterRewrite = $("coverAutoAfterRewrite");
const coverPreview = $("coverPreview");
const coverPreviewWrap = $("coverPreviewWrap");
const logoutBtn = $("logoutBtn");
const clearDraftBtn = $("clearDraftBtn");
const targetBodyCharsInput = $("targetBodyChars");
const posterGenerateBtn = $("posterGenerateBtn");
const posterPreviewList = $("posterPreviewList");
const posterHint = $("posterHint");
const posterAutoInclude = $("posterAutoInclude");
const DRAFT_STORAGE_KEY = "aifagao:index:draft:v1";
let posterState = {
signature: "",
@@ -125,6 +129,123 @@ function setStatus(msg, danger = false) {
statusEl.textContent = msg;
}
function saveDraftState() {
try {
const data = {
sourceText: ($("sourceText") && $("sourceText").value) || "",
titleHint: ($("titleHint") && $("titleHint").value) || "",
audienceExtra: ($("audienceExtra") && $("audienceExtra").value) || "",
toneExtra: ($("toneExtra") && $("toneExtra").value) || "",
avoidWords: ($("avoidWords") && $("avoidWords").value) || "",
keepPoints: ($("keepPoints") && $("keepPoints").value) || "",
targetBodyChars: ($("targetBodyChars") && $("targetBodyChars").value) || "500",
title: ($("title") && $("title").value) || "",
summary: ($("summary") && $("summary").value) || "",
body: ($("body") && $("body").value) || "",
thumbMediaId: ($("thumbMediaId") && $("thumbMediaId").value) || "",
coverStyleHint: ($("coverStyleHint") && $("coverStyleHint").value) || "",
coverImageModel: (coverImageModelInput && coverImageModelInput.value) || "",
coverAutoAfterRewrite: Boolean(coverAutoAfterRewrite && coverAutoAfterRewrite.checked),
posterAutoInclude: Boolean(posterAutoInclude && posterAutoInclude.checked),
audienceChipValues: Array.from(document.querySelectorAll('input[name="audienceChip"]:checked')).map((n) => n.value),
toneChipValues: Array.from(document.querySelectorAll('input[name="toneChip"]:checked')).map((n) => n.value),
};
window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data));
} catch {
// ignore
}
}
function restoreDraftState() {
try {
const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (!data || typeof data !== "object") return;
const setVal = (id, val) => {
const el = $(id);
if (!el || typeof val !== "string") return;
el.value = val;
};
setVal("sourceText", data.sourceText || "");
setVal("titleHint", data.titleHint || "");
setVal("audienceExtra", data.audienceExtra || "");
setVal("toneExtra", data.toneExtra || "");
setVal("avoidWords", data.avoidWords || "");
setVal("keepPoints", data.keepPoints || "");
setVal("targetBodyChars", String(data.targetBodyChars || "500"));
setVal("title", data.title || "");
setVal("summary", data.summary || "");
setVal("body", data.body || "");
setVal("thumbMediaId", data.thumbMediaId || "");
setVal("coverStyleHint", data.coverStyleHint || "");
if (coverImageModelInput && typeof data.coverImageModel === "string" && data.coverImageModel.trim()) {
coverImageModelInput.value = data.coverImageModel;
}
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = Boolean(data.coverAutoAfterRewrite);
if (posterAutoInclude) posterAutoInclude.checked = Boolean(data.posterAutoInclude);
const audienceSet = new Set(Array.isArray(data.audienceChipValues) ? data.audienceChipValues : []);
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
el.checked = audienceSet.size ? audienceSet.has(el.value) : el.checked;
});
const toneSet = new Set(Array.isArray(data.toneChipValues) ? data.toneChipValues : []);
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
el.checked = toneSet.size ? toneSet.has(el.value) : el.checked;
});
} catch {
// ignore
}
}
function clearDraftState() {
try {
window.localStorage.removeItem(DRAFT_STORAGE_KEY);
} catch {
// ignore
}
const clearIds = [
"sourceText",
"titleHint",
"audienceExtra",
"toneExtra",
"avoidWords",
"keepPoints",
"title",
"summary",
"body",
"thumbMediaId",
"coverStyleHint",
"coverUrl",
];
clearIds.forEach((id) => {
const el = $(id);
if (!el) return;
el.value = "";
});
if ($("targetBodyChars")) $("targetBodyChars").value = "500";
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = false;
if (posterAutoInclude) posterAutoInclude.checked = false;
if (coverImageModelInput) coverImageModelInput.value = "";
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
el.checked = false;
});
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
el.checked = false;
});
if (coverPreviewWrap) coverPreviewWrap.hidden = true;
if (coverPreview) coverPreview.src = "";
if ($("coverFile")) $("coverFile").value = "";
posterState = { signature: "", bodyMarkdownWithPosters: "", posters: [] };
renderPosterPreview([]);
updateCounters();
syncTargetCharChips();
initMultiDropdowns();
initImageModelStatus();
if (posterHint) posterHint.textContent = "默认不生成海报,点击“生成段落海报”后再插入发布。";
setStatus("草稿已清除。");
}
function buildPosterSignature() {
const title = ($("title") && $("title").value.trim()) || "";
const summary = ($("summary") && $("summary").value.trim()) || "";
@@ -183,7 +304,7 @@ function renderPosterPreview(posters) {
function markPosterStaleIfNeeded() {
if (!posterState.signature || !posterHint) return;
if (posterState.signature !== buildPosterSignature()) {
posterHint.textContent = "正文已修改,发布前会自动重建段落海报。";
posterHint.textContent = "正文已修改,如需海报请手动点击“生成段落海报。";
}
}
@@ -194,11 +315,26 @@ async function postJSON(url, body) {
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
if (!res.ok) {
const err = new Error(data.detail || "请求失败");
err.payload = data;
throw err;
}
data._requestId = res.headers.get("X-Request-ID") || "";
return data;
}
function handleUpgradeRequired(err) {
const data = (err && err.payload) || {};
const msg = (err && err.message) || data.detail || "";
if (!data.upgrade_required && !msg.includes("免费额度已用完") && !msg.includes("余额不足")) return false;
setStatus("免费额度已用完,请前往升级页充值或升级 VIP 用户。", true);
window.setTimeout(() => {
window.location.href = "/upgrade";
}, 800);
return true;
}
async function generatePosterMaterials({ silent = false } = {}) {
const bodyMarkdown = (($("body") && $("body").value) || "").trim();
if (bodyMarkdown.length < 20) {
@@ -212,9 +348,14 @@ async function generatePosterMaterials({ silent = false } = {}) {
title: $("title").value,
summary: $("summary").value,
body_markdown: $("body").value,
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
upload_to_wechat: true,
});
if (!data.ok) throw new Error(data.detail || "海报生成失败");
if (!data.ok) {
const err = new Error(data.detail || "海报生成失败");
err.payload = data;
throw err;
}
posterState = {
signature: buildPosterSignature(),
@@ -252,9 +393,14 @@ async function generateWechatCover({ silent = false } = {}) {
title,
summary: (($("summary") && $("summary").value) || "").trim(),
style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(),
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
upload_to_wechat: true,
});
if (!data.ok) throw new Error(data.detail || "封面生成失败");
if (!data.ok) {
const err = new Error(data.detail || "封面生成失败");
err.payload = data;
throw err;
}
const mid = data.thumb_media_id || "";
if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid;
if (data.preview_data_url && coverPreview && coverPreviewWrap) {
@@ -353,6 +499,20 @@ async function initWechatAccountSwitch() {
if (me) renderWechatAccountSelect(me);
}
async function initImageModelStatus() {
try {
const me = await fetch("/api/auth/me").then((r) => r.json());
const active = me && me.active_ai_model ? me.active_ai_model : null;
const imageModel = active && active.image_model ? String(active.image_model).trim() : "";
if (coverImageModelInput) {
const current = (coverImageModelInput.value || "").trim();
if (!current) coverImageModelInput.value = imageModel || "wanx2.0-t2i-turbo";
}
} catch {
if (coverImageModelInput && !(coverImageModelInput.value || "").trim()) coverImageModelInput.value = "wanx2.0-t2i-turbo";
}
}
async function logoutAndGoAuth() {
try {
await postJSON("/api/auth/logout", {});
@@ -369,6 +529,12 @@ if (logoutBtn) {
});
}
if (clearDraftBtn) {
clearDraftBtn.addEventListener("click", () => {
clearDraftState();
});
}
if (coverModeManualBtn) {
coverModeManualBtn.addEventListener("click", () => setCoverMode("manual"));
}
@@ -419,6 +585,7 @@ $("rewriteBtn").addEventListener("click", async () => {
$("summary").value = data.summary || "";
$("body").value = data.body_markdown || "";
updateCounters();
saveDraftState();
const tr = data.trace || {};
if (data.mode === "fallback") {
const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
@@ -429,23 +596,19 @@ $("rewriteBtn").addEventListener("click", async () => {
} else {
setStatus("改写完成。");
}
try {
setStatus("改写完成,正在生成段落海报...");
await generatePosterMaterials({ silent: true });
setStatus("改写与段落海报生成完成。");
} catch (posterErr) {
setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true);
}
if (posterHint) posterHint.textContent = "改写完成。默认不自动生成海报,可手动点击“生成段落海报”。";
if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) {
try {
setStatus("改写完成,正在按输出标题生成封面...");
await generateWechatCover({ silent: true });
setStatus("改写、封面与段落海报生成完成。");
} catch (coverErr) {
if (handleUpgradeRequired(coverErr)) return;
setStatus(`改写完成,封面未生成:${coverErr.message}`, true);
}
}
} catch (e) {
if (handleUpgradeRequired(e)) return;
setStatus(`改写失败: ${e.message}`, true);
} finally {
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
@@ -460,15 +623,10 @@ $("wechatBtn").addEventListener("click", async () => {
const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked);
if (autoInclude) {
const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters;
if (stale) {
try {
await generatePosterMaterials({ silent: true });
} catch (posterErr) {
setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true);
}
}
if (posterState.bodyMarkdownWithPosters) {
if (!stale && posterState.bodyMarkdownWithPosters) {
bodyForPublish = posterState.bodyMarkdownWithPosters;
} else {
setStatus("未检测到可用海报,本次仅发布文字;如需海报请先手动生成。", true);
}
}
const data = await postJSON("/api/publish/wechat", {
@@ -495,7 +653,9 @@ if (coverGenerateBtn) {
coverGenerateBtn.addEventListener("click", async () => {
try {
await generateWechatCover({ silent: false });
saveDraftState();
} catch (e) {
if (handleUpgradeRequired(e)) return;
const hint = $("coverHint");
if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。";
setStatus(`封面生成失败: ${e.message}`, true);
@@ -508,6 +668,30 @@ if (coverGenerateBtn) {
});
}
if (saveCoverImageModelBtn) {
saveCoverImageModelBtn.addEventListener("click", async () => {
const value = (coverImageModelInput && coverImageModelInput.value.trim()) || "";
if (!value) {
setStatus("请先填写文生图模型", true);
return;
}
setLoading(saveCoverImageModelBtn, true, "保存模型", "保存中...");
try {
const out = await postJSON("/api/auth/ai-models/image-model/update", { image_model: value });
if (!out.ok) {
setStatus(out.detail || "保存失败", true);
return;
}
setStatus("文生图模型已保存。");
saveDraftState();
} catch (e) {
setStatus(e.message || "保存失败", true);
} finally {
setLoading(saveCoverImageModelBtn, false, "保存模型", "保存中...");
}
});
}
if (coverUploadBtn) {
coverUploadBtn.addEventListener("click", async () => {
const fileInput = $("coverFile");
@@ -529,6 +713,7 @@ if (coverUploadBtn) {
if ($("thumbMediaId")) $("thumbMediaId").value = mid;
if (hint) hint.textContent = `封面上传成功,已绑定 media_id${mid}`;
setStatus("封面上传成功,发布时将优先使用该封面。");
saveDraftState();
} catch (e) {
if (hint) hint.textContent = "封面上传失败,请看状态提示。";
setStatus(`封面上传失败: ${e.message}`, true);
@@ -561,6 +746,7 @@ if (coverUrlUploadBtn) {
if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id${mid}`;
setStatus("URL 封面上传成功,发布时将优先使用该封面。");
if ($("coverUrl")) $("coverUrl").value = "";
saveDraftState();
} catch (e) {
if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。";
setStatus(`URL 封面上传失败: ${e.message}`, true);
@@ -579,7 +765,9 @@ if (posterGenerateBtn) {
posterGenerateBtn.addEventListener("click", async () => {
try {
await generatePosterMaterials({ silent: false });
saveDraftState();
} catch (e) {
if (handleUpgradeRequired(e)) return;
setStatus(`海报生成失败: ${e.message}`, true);
if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。";
if ((e.message || "").includes("请先登录")) {
@@ -610,13 +798,30 @@ $("imBtn").addEventListener("click", async () => {
["sourceText", "title", "summary", "body"].forEach((id) => {
$(id).addEventListener("input", updateCounters);
$(id).addEventListener("input", saveDraftState);
if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded);
});
["titleHint", "audienceExtra", "toneExtra", "avoidWords", "keepPoints", "targetBodyChars", "thumbMediaId", "coverStyleHint"].forEach(
(id) => {
const el = $(id);
if (el) el.addEventListener("input", saveDraftState);
},
);
document.querySelectorAll('input[name="audienceChip"],input[name="toneChip"]').forEach((el) => {
el.addEventListener("change", saveDraftState);
});
if (coverAutoAfterRewrite) coverAutoAfterRewrite.addEventListener("change", saveDraftState);
if (posterAutoInclude) posterAutoInclude.addEventListener("change", saveDraftState);
if (coverImageModelInput) coverImageModelInput.addEventListener("input", saveDraftState);
restoreDraftState();
updateCounters();
initMultiDropdowns();
initWechatAccountSwitch();
syncTargetCharChips();
renderPosterPreview([]);
setCoverMode("manual");
initImageModelStatus();
window.addEventListener("beforeunload", saveDraftState);
window.addEventListener("load", () => setCoverMode("manual"));

View File

@@ -1,4 +1,5 @@
const $ = (id) => document.getElementById(id);
let challengeId = "";
function setStatus(msg, danger = false) {
const el = $("status");
@@ -24,6 +25,21 @@ async function postJSON(url, body) {
return data;
}
async function refreshChallenge() {
try {
const res = await fetch("/api/auth/challenge");
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.detail || "校验题加载失败");
challengeId = (data.challenge_id || "").trim();
const label = $("challengeLabel");
if (label) label.textContent = `人机校验:${data.question || ""}`;
const ans = $("challengeAnswer");
if (ans) ans.value = "";
} catch {
setStatus("校验题加载失败,请刷新页面重试", true);
}
}
function nextPath() {
const nxt = (window.__NEXT_PATH__ || "/").trim();
if (!nxt.startsWith("/")) return "/";
@@ -35,6 +51,9 @@ function fields() {
username: ($("username") && $("username").value.trim()) || "",
password: ($("password") && $("password").value) || "",
remember_me: Boolean($("rememberMe") && $("rememberMe").checked),
challenge_id: challengeId,
challenge_answer: ($("challengeAnswer") && $("challengeAnswer").value.trim()) || "",
honeypot: ($("botTrap") && $("botTrap").value) || "",
};
}
@@ -57,6 +76,7 @@ async function authAction(url, button, idleText, loadingText, okMessage) {
const loginBtn = $("loginBtn");
const registerBtn = $("registerBtn");
const refreshChallengeBtn = $("refreshChallengeBtn");
if (loginBtn) {
loginBtn.addEventListener("click", async () => {
@@ -78,7 +98,7 @@ if (registerBtn) {
const msg =
`注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` +
"请立即复制并妥善保管,点击“确定”后继续进入系统。";
window.alert(msg);
await window.uiAlert(msg, "注册成功");
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(code);
@@ -91,8 +111,19 @@ if (registerBtn) {
window.location.href = nextPath();
} catch (e) {
setStatus(e.message || "请求异常", true);
await refreshChallenge();
} finally {
setLoading(registerBtn, false, "注册", "注册中...");
}
});
}
if (refreshChallengeBtn) {
refreshChallengeBtn.addEventListener("click", async () => {
setLoading(refreshChallengeBtn, true, "刷新题目", "刷新中...");
await refreshChallenge();
setLoading(refreshChallengeBtn, false, "刷新题目", "刷新中...");
});
}
refreshChallenge();

340
app/static/billing.js Normal file
View File

@@ -0,0 +1,340 @@
const $ = (id) => document.getElementById(id);
function setStatus(msg, danger = false) {
const el = $("status");
if (!el) return;
el.style.color = danger ? "#b42318" : "#0f5f3d";
el.textContent = msg;
}
function setLoading(button, loading, idleText, loadingText) {
if (!button) return;
button.disabled = loading;
button.textContent = loading ? loadingText : idleText;
}
function importantNotice(message, title = "提示") {
if (typeof window.uiAlert === "function") {
void window.uiAlert(message, title);
return;
}
setStatus(message, true);
}
function openPayLink(url) {
const payUrl = String(url || "").trim();
if (!payUrl) return false;
const tab = window.open(payUrl, "_blank", "noopener");
if (tab) return true;
window.location.href = payUrl;
return true;
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
return data;
}
async function authMe() {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!data.logged_in) {
window.location.href = "/auth?next=/billing";
return null;
}
return data;
}
function fmtTime(ts) {
const n = Number(ts || 0);
if (!n) return "-";
return new Date(n * 1000).toLocaleString();
}
function mapOrderStatus(status) {
const s = String(status || "").toLowerCase();
if (s === "paid" || s === "success") return "已支付";
if (s === "pending") return "待支付";
if (s === "failed") return "支付失败";
if (s === "cancelled") return "已取消";
if (s === "closed") return "已关闭";
return status || "-";
}
function mapChannel(channel) {
const c = String(channel || "").toLowerCase();
if (c === "wechat") return "微信支付";
if (c === "alipay") return "支付宝";
if (c === "stripe") return "Stripe";
return channel || "-";
}
function formatDetail(detail) {
if (!detail || typeof detail !== "object") return "-";
const rows = [];
if (detail.source_chars) rows.push(`原文${detail.source_chars}`);
if (detail.body_chars) rows.push(`正文${detail.body_chars}`);
if (detail.title_chars) rows.push(`标题${detail.title_chars}`);
if (detail.summary_chars) rows.push(`摘要${detail.summary_chars}`);
if (detail.image_count) rows.push(`图片${detail.image_count}`);
if (detail.prompt_tokens) rows.push(`输入Token:${detail.prompt_tokens}`);
if (detail.completion_tokens) rows.push(`输出Token:${detail.completion_tokens}`);
if (detail.total_tokens) rows.push(`总Token:${detail.total_tokens}`);
if (detail.model) rows.push(`模型:${detail.model}`);
if (detail.image_model) rows.push(`生图模型:${detail.image_model}`);
if (detail.image_price_package_images && detail.image_price_package_cny) {
rows.push(`图片计价:${detail.image_price_package_images}张=${Number(detail.image_price_package_cny).toFixed(2)}`);
}
if (detail.credits_rule) rows.push(`规则:${detail.credits_rule}`);
if (detail.billed_basis === "usage_tokens") rows.push("按真实Token计费");
if (detail.billed_basis === "char_estimate") rows.push("按字符估算计费");
if (detail.paid_amount_cny) rows.push(`实付¥${Number(detail.paid_amount_cny).toFixed(2)}`);
if (detail.external_txn_id) rows.push(`交易号:${detail.external_txn_id}`);
if (detail.credit_source && typeof detail.credit_source === "object") {
const seat = Number(detail.credit_source.seat || 0);
const shared = Number(detail.credit_source.shared || 0);
rows.push(`抵扣来源:席位${seat}/共享${shared}`);
}
return rows.length ? rows.join(" ") : "-";
}
function renderRechargeRecords(records) {
const el = $("rechargeRecords");
if (!el) return;
const list = Array.isArray(records) ? records : [];
if (!list.length) {
el.innerHTML = '<p class="muted small">暂无充值记录</p>';
return;
}
const rows = list
.map((r) => {
const statusText = mapOrderStatus(r.status);
const statusClass =
statusText === "已支付"
? "paid"
: statusText === "待支付"
? "pending"
: statusText === "支付失败"
? "failed"
: "closed";
return `<tr>
<td class="mono">${r.order_no || "-"}</td>
<td><span class="billing-badge ${statusClass}">${statusText}</span></td>
<td>${mapChannel(r.channel)}</td>
<td>${Number(r.token_amount || 0)}</td>
<td>¥${Number(r.amount_cny || 0).toFixed(2)}</td>
<td>${fmtTime(r.created_at)}</td>
<td>${r.paid_at ? fmtTime(r.paid_at) : "-"}</td>
<td>${
statusText === "待支付"
? `<button class="primary pay-now-btn" data-order-no="${r.order_no || ""}" type="button">立即支付</button>`
: "-"
}</td>
</tr>`;
})
.join("");
el.innerHTML = `<table class="billing-table">
<thead>
<tr>
<th>订单号</th>
<th>状态</th>
<th>渠道</th>
<th>Credits</th>
<th>金额</th>
<th>创建时间</th>
<th>支付时间</th>
<th>操作</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
function renderConsumeRecords(records) {
const el = $("consumeRecords");
if (!el) return;
const list = Array.isArray(records) ? records : [];
if (!list.length) {
el.innerHTML = '<p class="muted small">暂无消费记录</p>';
return;
}
const kindTextMap = {
trial_grant: "试用赠送",
paid_recharge: "充值到账",
manual_recharge: "手动充值",
rewrite: "AI改写",
cover_generate: "封面生成",
poster_generate: "段落海报",
usage: "模型调用",
};
const rows = list
.map((r) => {
const kindText = kindTextMap[r.kind] || r.kind || "-";
const delta = `${r.direction === "out" ? "-" : "+"}${Number(r.token_change || 0)}`;
const ref = `${r.ref_type || "-"} ${r.ref_id || ""}`.trim();
const detail = formatDetail(r.detail);
return `<tr>
<td>${kindText}</td>
<td class="${r.direction === "out" ? "out" : "in"}">${delta}</td>
<td>${Number(r.balance_after || 0)}</td>
<td>${ref}</td>
<td>${detail}</td>
<td>${fmtTime(r.created_at)}</td>
</tr>`;
})
.join("");
el.innerHTML = `<table class="billing-table">
<thead>
<tr>
<th>类型</th>
<th>Credits变动</th>
<th>余额</th>
<th>关联</th>
<th>明细</th>
<th>时间</th>
</tr>
</thead>
<tbody>${rows}</tbody>
</table>`;
}
async function refreshBilling() {
const data = await fetch("/api/billing/overview").then((r) => r.json());
if (!data.ok) throw new Error(data.detail || "账单加载失败");
renderRechargeRecords(data.recharge_records || []);
renderConsumeRecords(data.consume_records || []);
}
const createRechargeOrderBtn = $("createRechargeOrderBtn");
const refreshBillingBtn = $("refreshBillingBtn");
const logoutBtn = $("logoutBtn");
const billingRechargeTokensInput = $("billingRechargeTokens");
const billingRechargeAmountInput = $("billingRechargeAmount");
function packageRate() {
const credits = Number((billingRechargeTokensInput && billingRechargeTokensInput.dataset.packageCredits) || "1500");
const amount = Number((billingRechargeAmountInput && billingRechargeAmountInput.dataset.packageAmount) || "19.9");
return {
packageCredits: Number.isFinite(credits) && credits > 0 ? credits : 1500,
packageAmount: Number.isFinite(amount) && amount > 0 ? amount : 19.9,
};
}
function syncBillingAmount() {
if (!billingRechargeTokensInput || !billingRechargeAmountInput) return;
const credits = Number((billingRechargeTokensInput.value || "0").trim());
if (!Number.isFinite(credits) || credits <= 0) return;
const { packageCredits, packageAmount } = packageRate();
const amount = (credits / packageCredits) * packageAmount;
billingRechargeAmountInput.value = amount.toFixed(2);
}
if (billingRechargeTokensInput) billingRechargeTokensInput.addEventListener("input", syncBillingAmount);
if (billingRechargeAmountInput) billingRechargeAmountInput.readOnly = true;
if (createRechargeOrderBtn) {
createRechargeOrderBtn.addEventListener("click", async () => {
setLoading(createRechargeOrderBtn, true, "创建充值订单", "创建中...");
try {
const tokens = Number((($("billingRechargeTokens") && $("billingRechargeTokens").value) || "0").trim());
const amount = Number((($("billingRechargeAmount") && $("billingRechargeAmount").value) || "0").trim());
if (!Number.isFinite(tokens) || tokens <= 0) {
setStatus("请输入正确的充值 Credits 数量", true);
return;
}
if (!Number.isFinite(amount) || amount <= 0) {
setStatus("请输入正确的支付金额", true);
return;
}
const out = await postJSON("/api/pay/wechat/", {
tokens: Math.round(tokens),
amount_cny: Number(amount.toFixed(2)),
channel: "wechat",
});
if (!out.ok) {
setStatus(out.detail || "创建订单失败", true);
return;
}
const orderNo = out.order && out.order.order_no ? out.order.order_no : "";
if (out.pay_url) {
openPayLink(out.pay_url);
} else {
importantNotice("订单已创建,但未获取到支付链接,请检查支付网关配置。", "支付链接缺失");
}
setStatus(`订单已创建:${orderNo}`);
await refreshBilling();
} catch (e) {
setStatus(e.message || "创建订单失败", true);
} finally {
setLoading(createRechargeOrderBtn, false, "创建充值订单", "创建中...");
}
});
}
const rechargeRecordsWrap = $("rechargeRecords");
if (rechargeRecordsWrap) {
rechargeRecordsWrap.addEventListener("click", async (evt) => {
const btn = evt.target && evt.target.closest ? evt.target.closest(".pay-now-btn") : null;
if (!btn) return;
const orderNo = (btn.getAttribute("data-order-no") || "").trim();
if (!orderNo) return;
setLoading(btn, true, "立即支付", "拉起中...");
try {
const out = await postJSON("/api/billing/recharge/pay-now", { order_no: orderNo });
if (!out.ok) {
setStatus(out.detail || "拉起支付失败", true);
await refreshBilling();
return;
}
if (out.pay_url) {
openPayLink(out.pay_url);
} else {
importantNotice("未获取到支付链接,请检查支付网关配置。", "立即支付失败");
}
setStatus(`订单 ${orderNo} 已拉起支付。`);
await refreshBilling();
} catch (e) {
setStatus(e.message || "拉起支付失败", true);
await refreshBilling();
} finally {
setLoading(btn, false, "立即支付", "拉起中...");
}
});
}
if (refreshBillingBtn) {
refreshBillingBtn.addEventListener("click", async () => {
setLoading(refreshBillingBtn, true, "刷新账单记录", "刷新中...");
try {
await refreshBilling();
setStatus("账单已刷新。");
} catch (e) {
setStatus(e.message || "账单刷新失败", true);
} finally {
setLoading(refreshBillingBtn, false, "刷新账单记录", "刷新中...");
}
});
}
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
try {
await postJSON("/api/auth/logout", {});
} finally {
window.location.href = "/auth?next=/";
}
});
}
(async () => {
const me = await authMe();
if (!me) return;
syncBillingAmount();
await refreshBilling();
})();

97
app/static/profile.js Normal file
View File

@@ -0,0 +1,97 @@
const $ = (id) => document.getElementById(id);
function setStatus(msg, danger = false) {
const el = $("status");
if (!el) return;
el.style.color = danger ? "#b42318" : "#0f5f3d";
el.textContent = msg;
}
function setLoading(button, loading, idleText, loadingText) {
if (!button) return;
button.disabled = loading;
button.textContent = loading ? loadingText : idleText;
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
return data;
}
async function authMe() {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!data.logged_in) {
window.location.href = "/auth?next=/profile";
return null;
}
return data;
}
async function loadProfile() {
const res = await fetch("/api/profile");
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.detail || "个人信息加载失败");
const profile = data.profile || {};
if ($("profileSubscriberName")) $("profileSubscriberName").value = profile.subscriber_name || "";
if ($("profileSubscriberPhone")) $("profileSubscriberPhone").value = profile.subscriber_phone || "";
if ($("profileShippingAddress")) $("profileShippingAddress").value = profile.shipping_address || "";
}
const saveProfileBtn = $("saveProfileBtn");
const logoutBtn = $("logoutBtn");
if (saveProfileBtn) {
saveProfileBtn.addEventListener("click", async () => {
setLoading(saveProfileBtn, true, "保存个人信息", "保存中...");
try {
const subscriberName = (($("profileSubscriberName") && $("profileSubscriberName").value) || "").trim();
const subscriberPhone = (($("profileSubscriberPhone") && $("profileSubscriberPhone").value) || "").trim();
const shippingAddress = (($("profileShippingAddress") && $("profileShippingAddress").value) || "").trim();
if (!subscriberName) {
setStatus("请填写订阅人姓名", true);
return;
}
if (!shippingAddress) {
setStatus("请填写收货地址", true);
return;
}
const out = await postJSON("/api/profile", {
subscriber_name: subscriberName,
subscriber_phone: subscriberPhone,
shipping_address: shippingAddress,
});
if (!out.ok) {
setStatus(out.detail || "保存失败", true);
return;
}
setStatus("个人信息已保存。");
} catch (e) {
setStatus(e.message || "保存失败", true);
} finally {
setLoading(saveProfileBtn, false, "保存个人信息", "保存中...");
}
});
}
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
try {
await postJSON("/api/auth/logout", {});
} finally {
window.location.href = "/auth?next=/";
}
});
}
(async () => {
const me = await authMe();
if (!me) return;
await loadProfile();
})();

View File

@@ -72,18 +72,28 @@ function renderModels(me) {
list.forEach((m) => {
const opt = document.createElement("option");
opt.value = String(m.id);
const imageModel = (m.image_model || "").trim();
opt.textContent = imageModel ? `${m.model_name} (${m.model} / 图:${imageModel})` : `${m.model_name} (${m.model})`;
opt.textContent = `${m.model_name} (${m.model})`;
if ((active && m.id === active) || m.active) opt.selected = true;
sel.appendChild(opt);
});
}
function renderVip(me) {
const vip = me && me.vip ? me.vip : {};
const enabledSelect = $("vipEnabledSelect");
const tokenBalance = $("vipTokenBalance");
const totalConsumed = $("vipTotalConsumed");
if (enabledSelect) enabledSelect.value = vip.vip_enabled ? "1" : "0";
if (tokenBalance) tokenBalance.value = String(Number(vip.token_balance || 0));
if (totalConsumed) totalConsumed.value = String(Number(vip.total_consumed_tokens || 0));
}
async function refresh() {
const me = await authMe();
if (!me) return;
renderAccounts(me);
renderModels(me);
renderVip(me);
}
const accountSelect = $("accountSelect");
@@ -95,6 +105,8 @@ const deleteAccountBtn = $("deleteAccountBtn");
const modelSelect = $("modelSelect");
const saveModelBtn = $("saveModelBtn");
const deleteModelBtn = $("deleteModelBtn");
const saveVipBtn = $("saveVipBtn");
const vipRechargeBtn = $("vipRechargeBtn");
if (accountSelect) {
accountSelect.addEventListener("change", async () => {
@@ -121,7 +133,7 @@ if (deleteWechatBtn) {
setStatus("请先选择要删除的公众号", true);
return;
}
const sure = window.confirm("确定删除当前公众号绑定吗?删除后不可恢复。");
const sure = await window.uiConfirm("确定删除当前公众号绑定吗?删除后不可恢复。", "删除公众号");
if (!sure) return;
setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中...");
try {
@@ -195,7 +207,7 @@ if (saveModelBtn) {
api_key: ($("apiKey") && $("apiKey").value.trim()) || "",
base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "",
model: ($("modelValue") && $("modelValue").value.trim()) || "",
image_model: ($("imageModelValue") && $("imageModelValue").value.trim()) || "",
image_model: "",
timeout_sec: Number((($("timeoutSec") && $("timeoutSec").value) || "120").trim()),
max_output_tokens: Number((($("maxOutputTokens") && $("maxOutputTokens").value) || "8192").trim()),
max_retries: Number((($("maxRetries") && $("maxRetries").value) || "0").trim()),
@@ -207,7 +219,6 @@ if (saveModelBtn) {
setStatus("模型配置已保存并设为当前。");
if ($("apiKey")) $("apiKey").value = "";
if ($("modelName")) $("modelName").value = "";
if ($("imageModelValue")) $("imageModelValue").value = "";
await refresh();
} catch (e) {
setStatus(e.message || "模型保存失败", true);
@@ -224,7 +235,7 @@ if (deleteModelBtn) {
setStatus("请先选择要删除的模型", true);
return;
}
const sure = window.confirm("确定删除当前模型配置吗?删除后不可恢复。");
const sure = await window.uiConfirm("确定删除当前模型配置吗?删除后不可恢复。", "删除模型");
if (!sure) return;
setLoading(deleteModelBtn, true, "删除当前模型", "删除中...");
try {
@@ -243,6 +254,61 @@ if (deleteModelBtn) {
});
}
if (saveVipBtn) {
saveVipBtn.addEventListener("click", async () => {
setLoading(saveVipBtn, true, "保存 VIP 设置", "保存中...");
try {
const enabled = (($("vipEnabledSelect") && $("vipEnabledSelect").value) || "0") === "1";
const out = await postJSON("/api/auth/vip/toggle", { enabled });
if (!out.ok) {
setStatus(out.detail || "VIP 设置保存失败", true);
return;
}
setStatus("VIP 设置已更新。");
await refresh();
} catch (e) {
setStatus(e.message || "VIP 设置保存失败", true);
} finally {
setLoading(saveVipBtn, false, "保存 VIP 设置", "保存中...");
}
});
}
if (vipRechargeBtn) {
vipRechargeBtn.addEventListener("click", async () => {
setLoading(vipRechargeBtn, true, "充值 Token", "创建订单中...");
try {
const tokens = Number((($("vipRechargeTokens") && $("vipRechargeTokens").value) || "0").trim());
if (!Number.isFinite(tokens) || tokens <= 0) {
setStatus("请输入正确的充值数量", true);
return;
}
const out = await postJSON("/api/pay/wechat/", {
tokens: Math.round(tokens),
amount_cny: Number((((Number(tokens) / 10000) * 9.9) || 9.9).toFixed(2)),
channel: "wechat",
});
if (!out.ok) {
setStatus(out.detail || "充值失败", true);
return;
}
if (out.pay_url) {
window.open(out.pay_url, "_blank", "noopener");
setStatus("订单已创建,请在新窗口完成支付。");
} else {
setStatus("订单已创建,但未获取到支付链接,请联系管理员配置购物系统。", true);
}
window.setTimeout(() => {
window.location.href = "/billing";
}, 400);
} catch (e) {
setStatus(e.message || "充值失败", true);
} finally {
setLoading(vipRechargeBtn, false, "充值 Token", "创建订单中...");
}
});
}
if (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
setLoading(logoutBtn, true, "退出登录", "退出中...");
@@ -292,9 +358,14 @@ if (deleteAccountBtn) {
setStatus("请输入注销校验重置码", true);
return;
}
const sure = window.confirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。");
const sure = await window.uiConfirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。", "注销账户");
if (!sure) return;
const confirmText = window.prompt("为防止误删,请输入「注销账户」后确认:", "");
const confirmText = await window.uiPrompt(
"为防止误删,请输入「注销账户」后确认:",
"二次确认",
"",
"请输入:注销账户",
);
if ((confirmText || "").trim() !== "注销账户") {
setStatus("二次确认未通过,已取消注销。", true);
return;

View File

@@ -257,6 +257,17 @@ a {
background: transparent;
font-size: 13px;
font-weight: 750;
padding-right: 20px;
background-image:
linear-gradient(45deg, transparent 50%, #8a6f58 50%),
linear-gradient(135deg, #8a6f58 50%, transparent 50%);
background-position:
calc(100% - 12px) calc(50% - 2px),
calc(100% - 7px) calc(50% - 2px);
background-size:
5px 5px,
5px 5px;
background-repeat: no-repeat;
}
.topbar-select:focus {
@@ -441,6 +452,45 @@ button {
transform 0.18s ease;
}
select {
appearance: none;
-webkit-appearance: none;
-moz-appearance: none;
padding-right: 34px;
background-image:
linear-gradient(45deg, transparent 50%, #8a6f58 50%),
linear-gradient(135deg, #8a6f58 50%, transparent 50%),
linear-gradient(180deg, #ffffff, #ffffff);
background-position:
calc(100% - 16px) calc(50% - 2px),
calc(100% - 11px) calc(50% - 2px),
0 0;
background-size:
6px 6px,
6px 6px,
100% 100%;
background-repeat: no-repeat;
}
select:hover {
border-color: var(--accent);
}
select:disabled {
color: var(--faint);
border-color: var(--line);
background-image:
linear-gradient(45deg, transparent 50%, #c4af98 50%),
linear-gradient(135deg, #c4af98 50%, transparent 50%),
linear-gradient(180deg, #f8f3ec, #f8f3ec);
cursor: not-allowed;
}
.ui-select {
background-color: #fffdf8;
font-weight: 700;
}
textarea {
resize: vertical;
line-height: 1.6;
@@ -770,6 +820,49 @@ button.target-char-chip {
align-items: center;
}
.image-model-banner {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
margin: 8px 0;
padding: 10px 12px;
border: 1px solid rgba(255, 122, 26, 0.24);
border-radius: var(--radius);
background: linear-gradient(135deg, #fff8ed, #fff0dc);
}
.image-model-banner div {
display: grid;
gap: 2px;
min-width: 0;
}
.image-model-banner strong {
color: var(--text);
font-size: 13px;
}
.image-model-banner span {
overflow: hidden;
color: var(--muted);
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
}
.image-model-banner a {
flex: 0 0 auto;
color: var(--accent-2);
text-decoration: none;
font-size: 12px;
font-weight: 850;
}
.image-model-banner a:hover {
text-decoration: underline;
}
.cover-mode-switch {
display: grid;
grid-template-columns: 1fr 1fr;
@@ -929,6 +1022,92 @@ button.target-char-chip {
padding-right: 2px;
}
.billing-list {
max-height: 260px;
}
.billing-table-wrap {
overflow: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
}
.billing-table {
width: 100%;
border-collapse: collapse;
min-width: 760px;
font-size: 13px;
}
.billing-table th,
.billing-table td {
padding: 10px 12px;
border-bottom: 1px solid var(--line);
text-align: left;
vertical-align: middle;
}
.billing-table thead th {
position: sticky;
top: 0;
z-index: 1;
background: #fff8ed;
color: var(--muted);
font-weight: 800;
font-size: 12px;
}
.billing-table tbody tr:hover {
background: var(--surface-2);
}
.billing-table .mono {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 12px;
}
.billing-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 64px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 800;
}
.billing-badge.pending {
color: #b54708;
background: #fffaeb;
}
.billing-badge.paid {
color: #027a48;
background: #ecfdf3;
}
.billing-badge.failed {
color: #b42318;
background: #fef3f2;
}
.billing-badge.closed {
color: #475467;
background: #f2f4f7;
}
.billing-table td.in {
color: #027a48;
font-weight: 700;
}
.billing-table td.out {
color: #b42318;
font-weight: 700;
}
.poster-card {
border: 1px solid var(--line);
border-radius: var(--radius);
@@ -1093,6 +1272,33 @@ button.target-char-chip {
box-shadow: var(--shadow-soft);
}
.model-image-config {
display: grid;
gap: 8px;
margin-bottom: 12px;
padding: 12px;
border: 1px solid rgba(255, 122, 26, 0.28);
border-radius: var(--radius);
background: linear-gradient(135deg, #fffaf3, #fff0dc);
}
.model-image-config strong,
.model-image-config span {
display: block;
}
.model-image-config strong {
color: var(--text);
font-size: 14px;
}
.model-image-config span {
margin-top: 4px;
color: var(--muted);
font-size: 12px;
line-height: 1.5;
}
.settings-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
@@ -1318,6 +1524,525 @@ button.target-char-chip {
line-height: 1.65;
}
.upgrade-layout {
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.upgrade-panel {
min-height: 0;
}
.upgrade-scroll {
padding: 10px;
}
.upgrade-hero {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 14px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: linear-gradient(135deg, #fff, #fff0dc);
box-shadow: var(--shadow-soft);
}
.upgrade-hero h2 {
margin: 0;
font-size: 22px;
}
.upgrade-hero p {
max-width: 620px;
margin: 6px 0 0;
line-height: 1.55;
font-size: 13px;
}
.upgrade-balance-card {
min-width: 160px;
padding: 12px;
border-radius: 12px;
background: linear-gradient(135deg, #ffb23f, #ff6b16);
color: #fff;
box-shadow: 0 18px 40px rgba(255, 107, 22, 0.22);
}
.upgrade-balance-card span,
.upgrade-balance-card small {
display: block;
color: rgba(255, 255, 255, 0.82);
font-size: 12px;
}
.upgrade-balance-card strong {
display: block;
margin-top: 4px;
font-size: 28px;
line-height: 1;
}
.upgrade-grid {
margin-top: 10px;
display: grid;
grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr);
gap: 10px;
}
.upgrade-plans-stack {
display: grid;
gap: 10px;
}
.upgrade-plan-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
align-items: stretch;
}
.upgrade-tabbar {
display: inline-flex;
gap: 6px;
padding: 2px;
border: 1px solid var(--line);
border-radius: 8px;
background: #fffdf9;
width: fit-content;
}
.upgrade-tab {
display: inline-flex;
align-items: center;
justify-content: center;
margin: 0;
min-height: 24px;
padding: 0 10px;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--muted);
font-size: 11px;
font-weight: 800;
line-height: 1;
white-space: nowrap;
}
.upgrade-tab.is-active {
border-color: #ffca97;
background: #fff0de;
color: #9b3f00;
}
.upgrade-plan {
height: 100%;
padding: 12px;
border: 1px solid rgba(255, 122, 26, 0.42);
border-radius: var(--radius);
background: linear-gradient(180deg, #fff, #fff8ed);
box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18);
display: grid;
align-content: start;
gap: 8px;
}
.upgrade-plan.is-highlighted {
border-color: #4a2f1f;
background: linear-gradient(180deg, #3c2619, #2b1a12);
box-shadow: 0 14px 30px rgba(38, 20, 12, 0.3);
}
.upgrade-plan.is-highlighted .plan-head h3,
.upgrade-plan.is-highlighted p,
.upgrade-plan.is-highlighted ul {
color: #f8e9da;
}
.upgrade-plan.is-highlighted .plan-head span {
background: rgba(255, 173, 66, 0.2);
color: #ffd39f;
}
.plan-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.plan-head h3 {
margin: 0;
font-size: 16px;
}
.plan-head span {
display: inline-flex;
width: fit-content;
flex: 0 0 auto;
padding: 5px 10px;
border-radius: 999px;
background: var(--accent-soft);
color: var(--accent-2);
font-size: 12px;
font-weight: 850;
white-space: nowrap;
}
.upgrade-plan p {
margin: 0;
color: var(--muted);
line-height: 1.45;
font-size: 13px;
}
.plan-price {
margin: 0;
display: inline-flex;
justify-self: start;
width: fit-content;
max-width: 100%;
align-items: baseline;
padding: 4px 10px;
border-radius: 999px;
background: linear-gradient(180deg, #fff2de, #ffe2bf);
color: #b43f00;
font-size: 28px;
font-weight: 900;
letter-spacing: 0.2px;
box-shadow: 0 10px 24px rgba(255, 107, 22, 0.18);
}
.upgrade-plan.is-highlighted .plan-price {
display: inline-flex;
align-items: baseline;
padding: 4px 10px;
border-radius: 999px;
background: linear-gradient(180deg, #ffcf90, #ffb766);
color: #5a2a05;
font-size: 28px;
letter-spacing: 0.2px;
box-shadow: 0 10px 24px rgba(0, 0, 0, 0.24);
}
.upgrade-plan.is-highlighted .upgrade-toggle-row label {
color: #f8e9da;
}
.upgrade-plan.is-highlighted .ui-select {
border-color: #7a553c;
background-color: #f8e9da;
color: #2d1f17;
}
.upgrade-plan ul {
margin: 0 0 0 16px;
padding: 0;
color: var(--muted);
line-height: 1.55;
font-size: 12px;
}
.upgrade-toggle-row {
display: grid;
grid-template-columns: auto minmax(0, 1fr);
gap: 12px;
align-items: center;
margin-bottom: 10px;
}
.upgrade-toggle-row label {
margin: 0;
}
.upgrade-wallet {
margin-top: 10px;
}
.upgrade-stats {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
.upgrade-stats div {
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
}
.upgrade-stats span,
.upgrade-stats strong {
display: block;
}
.upgrade-stats span {
color: var(--muted);
font-size: 12px;
}
.upgrade-stats strong {
margin-top: 4px;
color: var(--text);
font-size: 17px;
}
.upgrade-recharge {
margin-top: 8px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.upgrade-recharge button {
width: auto;
margin-top: 0;
white-space: nowrap;
min-height: 36px;
}
.upgrade-plan button.secondary,
.upgrade-recharge button.secondary {
border-color: #ffb57a;
background: #fff6ec;
color: #9b3f00;
font-weight: 900;
}
.upgrade-plan button.secondary:hover,
.upgrade-recharge button.secondary:hover {
background: #ffe9d3;
}
.upgrade-purchase-card {
padding: 0;
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
box-shadow: var(--shadow-soft);
align-self: start;
position: sticky;
top: 8px;
overflow: hidden;
}
.upgrade-purchase-card > *:not(.purchase-head) {
margin-left: 12px;
margin-right: 12px;
}
.purchase-section {
margin-top: 10px;
}
.purchase-head {
padding: 12px;
border-bottom: 1px solid var(--line);
background: var(--surface-2);
display: grid;
gap: 4px;
}
.upgrade-purchase-card h3 {
margin: 0;
font-size: 16px;
}
.upgrade-purchase-card .muted.small {
margin: 0;
line-height: 1.45;
}
.purchase-row {
display: grid;
gap: 4px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
}
.purchase-row span {
color: var(--muted);
font-size: 12px;
}
.purchase-row strong {
color: var(--text);
font-size: 15px;
line-height: 1.3;
}
.purchase-meta-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.purchase-qty {
display: grid;
gap: 6px;
}
.purchase-qty > span {
color: var(--muted);
font-size: 12px;
font-weight: 750;
}
.purchase-qty .tiny {
margin: 0;
}
.purchase-info {
display: grid;
gap: 3px;
margin-top: 8px;
}
.purchase-static-text {
margin: 0;
color: var(--text);
font-size: 12px;
line-height: 1.45;
word-break: break-word;
}
.purchase-stepper {
display: grid;
grid-template-columns: 42px minmax(0, 1fr) 42px;
gap: 8px;
align-items: center;
}
.purchase-stepper button {
margin-top: 0;
min-height: 36px;
padding: 0;
}
.purchase-stepper input {
text-align: center;
font-weight: 800;
}
.pay-channel-group {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.pay-channel-option {
margin: 0;
min-height: 34px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
color: var(--muted);
font-weight: 800;
}
.pay-channel-option[data-channel="wechat"] {
border-color: #b7e8c9;
color: #1f9d55;
background: #f4fdf7;
}
.pay-channel-option[data-channel="alipay"] {
border-color: #b8d8ff;
color: #1677ff;
background: #f3f8ff;
}
.pay-channel-option[data-channel="wechat"].is-active {
border-color: #07c160;
color: #056a35;
background: #eafaf1;
box-shadow: 0 8px 18px rgba(7, 193, 96, 0.2);
}
.pay-channel-option[data-channel="alipay"].is-active {
border-color: #1677ff;
color: #0d4fb8;
background: #ebf3ff;
box-shadow: 0 8px 18px rgba(22, 119, 255, 0.2);
}
.purchase-summary {
display: grid;
gap: 8px;
}
.purchase-summary-row {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 10px;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--surface-2);
}
.purchase-summary span {
color: var(--muted);
font-size: 12px;
}
.purchase-summary strong {
color: var(--accent-2);
font-size: 18px;
font-weight: 900;
}
.purchase-action {
margin-bottom: 12px;
}
.upgrade-purchase-card #vipRechargeBtn {
margin-top: 0;
margin-bottom: 0;
min-height: 40px;
border-radius: 10px;
}
@media (max-width: 860px) {
.upgrade-hero {
align-items: flex-start;
flex-direction: column;
}
.upgrade-balance-card {
width: 100%;
}
.upgrade-grid,
.upgrade-stats,
.upgrade-recharge {
grid-template-columns: 1fr;
}
.upgrade-plan-grid,
.pay-channel-group {
grid-template-columns: 1fr;
}
.purchase-meta-grid {
grid-template-columns: 1fr;
}
.upgrade-purchase-card {
position: static;
}
.upgrade-recharge button {
width: 100%;
}
}
.section-title {
margin: 0 0 12px;
font-size: 16px;
@@ -1804,3 +2529,60 @@ button.target-char-chip {
grid-template-columns: 1fr;
}
}
.ui-dialog-root {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
}
.ui-dialog-overlay {
position: fixed;
inset: 0;
display: grid;
place-items: center;
padding: 16px;
background: rgba(17, 24, 39, 0.45);
pointer-events: auto;
}
.ui-dialog {
width: min(420px, calc(100vw - 32px));
border: 1px solid var(--line);
border-radius: var(--radius);
background: #fff;
box-shadow: var(--shadow);
padding: 14px;
}
.ui-dialog-title {
margin: 0;
font-size: 16px;
}
.ui-dialog-message {
margin-top: 8px;
color: var(--muted);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.ui-dialog-input {
margin-top: 10px;
display: none;
}
.ui-dialog-actions {
margin-top: 12px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.ui-dialog-btn {
width: auto;
min-width: 92px;
margin-top: 0;
}

101
app/static/ui-dialog.js Normal file
View File

@@ -0,0 +1,101 @@
(() => {
function ensureRoot() {
let root = document.getElementById("uiDialogRoot");
if (root) return root;
root = document.createElement("div");
root.id = "uiDialogRoot";
root.className = "ui-dialog-root";
document.body.appendChild(root);
return root;
}
function closeDialog(resolve, value, overlay) {
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
resolve(value);
}
function createDialog({ title, message, mode = "alert", confirmText = "确定", cancelText = "取消", placeholder = "", defaultValue = "" }) {
return new Promise((resolve) => {
const root = ensureRoot();
const overlay = document.createElement("div");
overlay.className = "ui-dialog-overlay";
overlay.innerHTML = `
<div class="ui-dialog" role="dialog" aria-modal="true">
<h3 class="ui-dialog-title"></h3>
<div class="ui-dialog-message"></div>
<input class="ui-dialog-input" type="text" />
<div class="ui-dialog-actions">
<button class="ui-dialog-btn secondary ui-dialog-cancel" type="button">${cancelText}</button>
<button class="ui-dialog-btn primary ui-dialog-confirm" type="button">${confirmText}</button>
</div>
</div>
`;
root.appendChild(overlay);
const titleEl = overlay.querySelector(".ui-dialog-title");
const msgEl = overlay.querySelector(".ui-dialog-message");
const inputEl = overlay.querySelector(".ui-dialog-input");
const cancelBtn = overlay.querySelector(".ui-dialog-cancel");
const okBtn = overlay.querySelector(".ui-dialog-confirm");
if (titleEl) titleEl.textContent = title || "提示";
if (msgEl) msgEl.textContent = message || "";
if (inputEl) {
inputEl.placeholder = placeholder || "";
inputEl.value = defaultValue || "";
}
if (mode === "alert") {
if (cancelBtn) cancelBtn.style.display = "none";
} else if (mode === "confirm") {
if (inputEl) inputEl.style.display = "none";
} else if (mode === "prompt") {
if (inputEl) inputEl.style.display = "block";
}
const onEsc = (e) => {
if (e.key === "Escape") {
document.removeEventListener("keydown", onEsc);
if (mode === "alert") closeDialog(resolve, true, overlay);
else closeDialog(resolve, null, overlay);
}
};
document.addEventListener("keydown", onEsc);
if (cancelBtn) {
cancelBtn.addEventListener("click", () => {
document.removeEventListener("keydown", onEsc);
if (mode === "confirm") closeDialog(resolve, false, overlay);
else closeDialog(resolve, null, overlay);
});
}
if (okBtn) {
okBtn.addEventListener("click", () => {
document.removeEventListener("keydown", onEsc);
if (mode === "prompt") closeDialog(resolve, inputEl ? inputEl.value : "", overlay);
else if (mode === "confirm") closeDialog(resolve, true, overlay);
else closeDialog(resolve, true, overlay);
});
}
if (mode === "prompt" && inputEl) {
window.setTimeout(() => inputEl.focus(), 0);
} else if (okBtn) {
window.setTimeout(() => okBtn.focus(), 0);
}
});
}
window.uiAlert = async (message, title = "提示") => createDialog({ title, message, mode: "alert", confirmText: "我知道了" });
window.uiConfirm = async (message, title = "请确认") =>
createDialog({ title, message, mode: "confirm", confirmText: "确认", cancelText: "取消" });
window.uiPrompt = async (message, title = "请输入", defaultValue = "", placeholder = "") =>
createDialog({
title,
message,
mode: "prompt",
confirmText: "确认",
cancelText: "取消",
defaultValue,
placeholder,
});
})();

292
app/static/upgrade.js Normal file
View File

@@ -0,0 +1,292 @@
const $ = (id) => document.getElementById(id);
let pendingOrderNo = "";
let pendingPollTimer = null;
let pendingPollCount = 0;
function setStatus(msg, danger = false) {
const el = $("status");
if (!el) return;
el.style.color = danger ? "#b42318" : "#0f5f3d";
el.textContent = msg;
}
function setLoading(button, loading, idleText, loadingText) {
if (!button) return;
button.disabled = loading;
button.textContent = loading ? loadingText : idleText;
}
function importantNotice(message, title = "提示") {
if (typeof window.uiAlert === "function") {
void window.uiAlert(message, title);
return;
}
setStatus(message);
}
function openPayLink(url) {
const payUrl = String(url || "").trim();
if (!payUrl) return false;
const tab = window.open(payUrl, "_blank", "noopener");
if (tab) return true;
window.location.href = payUrl;
return true;
}
async function postJSON(url, body) {
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
return data;
}
async function fetchMe() {
const res = await fetch("/api/auth/me");
const data = await res.json();
if (!data.logged_in) {
window.location.href = "/auth?next=/upgrade";
return null;
}
return data;
}
function formatDateTime(tsSec) {
const n = Number(tsSec || 0);
if (!n) return "-";
return new Date(n * 1000).toLocaleString();
}
function formatDate(tsSec) {
const n = Number(tsSec || 0);
if (!n) return "-";
const d = new Date(n * 1000);
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, "0");
const day = String(d.getDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function refreshPurchaseCycleText() {
const el = $("purchaseCycleText");
if (!el) return;
const start = Math.floor(Date.now() / 1000);
const end = start + 30 * 24 * 3600;
el.textContent = `时长1个月到期${formatDate(end)}`;
}
function renderVip(vip) {
const shared = Number(vip.shared_credits || vip.token_balance || 0);
const seatRemaining = Number(vip.seat_remaining_credits || 0);
const totalAvailable = Number(vip.total_available_credits || seatRemaining + shared);
const enabled = Boolean(vip.vip_enabled);
const cycleStart = Number(vip.cycle_started_at || 0);
const cycleEnd = Number(vip.cycle_expires_at || 0);
if ($("upgradeTokenBalance")) $("upgradeTokenBalance").textContent = String(totalAvailable);
if ($("upgradeTokenBalanceHero")) $("upgradeTokenBalanceHero").textContent = String(totalAvailable);
if ($("vipTokenBalance")) $("vipTokenBalance").textContent = String(shared);
if ($("vipSeatRemaining")) $("vipSeatRemaining").textContent = String(seatRemaining);
if ($("vipTotalConsumed")) $("vipTotalConsumed").textContent = String(Number(vip.total_consumed_tokens || 0));
if ($("vipEnabledSelect")) $("vipEnabledSelect").value = enabled ? "1" : "0";
if ($("vipStateText")) {
$("vipStateText").textContent = totalAvailable <= 0 ? "额度已用完" : enabled ? "平台模型已开启" : "平台模型已关闭";
}
if ($("vipCycleHint")) {
if (cycleStart > 0 && cycleEnd > 0) {
const startText = formatDateTime(cycleStart);
const endText = formatDateTime(cycleEnd);
$("vipCycleHint").textContent = `当前周期:${startText} - ${endText}(到期自动清零)`;
} else {
$("vipCycleHint").textContent = "当前未开始月周期,首次支付成功后开始计时。";
}
}
if (totalAvailable <= 0) {
setStatus("Credits 额度已用完,请充值共享加油包或等待下个计费周期。", true);
}
}
async function refresh() {
const me = await fetchMe();
if (me && me.vip) renderVip(me.vip);
}
async function fetchBillingOverview() {
const res = await fetch("/api/billing/overview");
const data = await res.json();
if (!res.ok || !data.ok) throw new Error(data.detail || "账单读取失败");
return data;
}
function stopPendingPoll() {
if (pendingPollTimer) {
window.clearInterval(pendingPollTimer);
pendingPollTimer = null;
}
pendingPollCount = 0;
}
function startPendingPoll(orderNo, showCreatedNotice = true) {
if (!orderNo) return;
pendingOrderNo = orderNo;
stopPendingPoll();
const msg = `订单 ${orderNo} 已创建,请完成支付,系统将自动刷新余额。`;
setStatus(msg);
if (showCreatedNotice) {
importantNotice(msg, "订单已创建");
}
pendingPollTimer = window.setInterval(async () => {
pendingPollCount += 1;
try {
const data = await fetchBillingOverview();
const records = Array.isArray(data.recharge_records) ? data.recharge_records : [];
const current = records.find((r) => (r.order_no || "") === pendingOrderNo);
if (current && (current.status || "") === "paid") {
stopPendingPoll();
pendingOrderNo = "";
setStatus("支付成功,余额已自动刷新。");
await refresh();
return;
}
if (current && ["cancelled", "closed"].includes(String(current.status || "").toLowerCase())) {
stopPendingPoll();
pendingOrderNo = "";
setStatus("订单超过15分钟未支付已自动取消。", true);
await refresh();
return;
}
if (pendingPollCount >= 120) {
stopPendingPoll();
setStatus("订单仍未支付,可支付后点击刷新查看余额。", true);
}
} catch {
if (pendingPollCount >= 120) {
stopPendingPoll();
}
}
}, 3000);
}
const saveVipBtn = $("saveVipBtn");
const vipRechargeBtn = $("vipRechargeBtn");
const vipRechargeTokensInput = $("vipRechargeTokens");
const vipRechargeAmountInput = $("vipRechargeAmount");
const payChannelOptions = Array.from(document.querySelectorAll(".pay-channel-option"));
let selectedPayChannel = "wechat";
function bindPayChannelOptions() {
if (!payChannelOptions.length) return;
payChannelOptions.forEach((btn) => {
btn.addEventListener("click", () => {
selectedPayChannel = (btn.dataset.channel || "wechat").trim() || "wechat";
payChannelOptions.forEach((item) => {
const active = item === btn;
item.classList.toggle("is-active", active);
item.setAttribute("aria-pressed", active ? "true" : "false");
});
});
});
}
function packageRate() {
const credits = Number((vipRechargeTokensInput && vipRechargeTokensInput.dataset.packageCredits) || "1500");
const amount = Number((vipRechargeAmountInput && vipRechargeAmountInput.dataset.packageAmount) || "19.9");
return {
packageCredits: Number.isFinite(credits) && credits > 0 ? credits : 1500,
packageAmount: Number.isFinite(amount) && amount > 0 ? amount : 19.9,
};
}
function syncRechargeAmount() {
const { packageCredits, packageAmount } = packageRate();
const credits = packageCredits;
const amount = packageAmount;
if (vipRechargeTokensInput) vipRechargeTokensInput.value = String(credits);
vipRechargeAmountInput.value = amount.toFixed(2);
if ($("purchaseCredits")) $("purchaseCredits").textContent = String(credits);
if ($("purchaseAmount")) $("purchaseAmount").textContent = `¥${amount.toFixed(2)}`;
}
if (vipRechargeAmountInput) vipRechargeAmountInput.readOnly = true;
syncRechargeAmount();
refreshPurchaseCycleText();
bindPayChannelOptions();
if (saveVipBtn) {
saveVipBtn.addEventListener("click", async () => {
setLoading(saveVipBtn, true, "保存升级设置", "保存中...");
try {
const enabled = (($("vipEnabledSelect") && $("vipEnabledSelect").value) || "0") === "1";
const out = await postJSON("/api/auth/vip/toggle", { enabled });
if (!out.ok) {
setStatus(out.detail || "VIP 设置保存失败", true);
return;
}
setStatus("升级设置已保存。");
await refresh();
} catch (e) {
setStatus(e.message || "VIP 设置保存失败", true);
} finally {
setLoading(saveVipBtn, false, "保存升级设置", "保存中...");
}
});
}
if (vipRechargeBtn) {
vipRechargeBtn.addEventListener("click", async () => {
setLoading(vipRechargeBtn, true, "订阅", "创建订单中...");
try {
const tokens = Number((($("vipRechargeTokens") && $("vipRechargeTokens").value) || "0").trim());
const amount = Number((($("vipRechargeAmount") && $("vipRechargeAmount").value) || "0").trim());
if (!Number.isFinite(tokens) || tokens <= 0) {
setStatus("请输入正确的 Credits 数量", true);
return;
}
if (!Number.isFinite(amount) || amount <= 0) {
setStatus("请输入正确的支付金额", true);
return;
}
const out = await postJSON("/api/pay/wechat/", {
tokens: Math.round(tokens),
amount_cny: Number(amount.toFixed(2)),
channel: selectedPayChannel || "wechat",
subscriber_name: "",
subscriber_phone: "",
shipping_address: "",
});
if (!out.ok) {
setStatus(out.detail || "充值失败", true);
return;
}
const orderNo = out.order && out.order.order_no ? out.order.order_no : "";
if (orderNo) startPendingPoll(orderNo, true);
if (out.pay_url) {
openPayLink(out.pay_url);
return;
}
const tip = "订单已创建,但未获取到支付链接,请检查支付网关配置。";
setStatus(tip, true);
importantNotice(tip, "支付链接缺失");
} catch (e) {
setStatus(e.message || "充值失败", true);
} finally {
setLoading(vipRechargeBtn, false, "订阅", "创建订单中...");
}
});
}
refresh();
(async () => {
await fetchMe();
try {
const data = await fetchBillingOverview();
const records = Array.isArray(data.recharge_records) ? data.recharge_records : [];
const pending = records.find((r) => (r.status || "") === "pending");
if (pending && pending.order_no) startPendingPoll(pending.order_no, false);
} catch {
// ignore
}
})();
window.setInterval(refreshPurchaseCycleText, 60000);

View File

@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 登录注册</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
</head>
<body class="simple-page auth-page">
<main class="auth-shell">
@@ -66,11 +66,19 @@
<div class="auth-form">
<div>
<label>用户名</label>
<input id="username" type="text" placeholder="请输入用户名" autocomplete="username" />
<input id="username" type="text" placeholder="4-24 位,字母数字下划线" autocomplete="username" />
</div>
<div>
<label>密码</label>
<input id="password" type="password" placeholder="请输入密码(至少 6 位)" autocomplete="current-password" />
<input id="password" type="password" placeholder="10-64 位,含大小写/数字/特殊字符" autocomplete="current-password" />
</div>
<div>
<label id="challengeLabel">人机校验</label>
<div class="cover-tools">
<input id="challengeAnswer" type="text" placeholder="请输入答案" autocomplete="off" />
<button id="refreshChallengeBtn" class="subtle-btn" type="button">刷新题目</button>
</div>
<input id="botTrap" type="text" tabindex="-1" autocomplete="off" style="position:absolute;left:-9999px;opacity:0;" />
</div>
<div class="check-row">
<label class="check-label">
@@ -94,6 +102,7 @@
<script>
window.__NEXT_PATH__ = {{ next|tojson }};
</script>
<script src="/static/auth.js?v=20260410a"></script>
<script src="/static/ui-dialog.js?v=20260428a"></script>
<script src="/static/auth.js?v=20260428l"></script>
</body>
</html>

100
app/templates/billing.html Normal file
View File

@@ -0,0 +1,100 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 账单中心</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
</head>
<body>
<div class="product-shell">
<aside class="side-nav" aria-label="主导航">
<div class="side-brand">
<div class="brand-lockup">
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
<h1>{{ app_name }}</h1>
</div>
</div>
<nav class="nav-group">
<div class="nav-label">工作台</div>
<a class="nav-item" href="/">内容生产</a>
<a class="nav-item" href="/settings">账号与模型</a>
<a class="nav-item is-active" href="/billing">账单中心</a>
<a class="nav-item" href="/upgrade">升级</a>
<a class="nav-item" href="/profile">个人中心</a>
<a class="nav-item" href="/guide">新手引导</a>
</nav>
<div class="side-footer">充值订单 · Credits 明细</div>
</aside>
<div class="workspace">
<header class="topbar topbar-compact">
<div class="topbar-actions">
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台"></a>
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置"></a>
<a class="icon-btn" href="/upgrade" aria-label="升级" title="升级"></a>
<a class="icon-btn" href="/profile" aria-label="个人中心" title="个人中心"></a>
<button id="logoutBtn" class="icon-btn topbar-btn" type="button" aria-label="退出登录" title="退出登录"></button>
</div>
</header>
<main class="layout settings-layout">
<section class="panel settings-panel">
<div class="panel-scroll settings-panel-scroll">
<div class="settings-content">
<section class="settings-section settings-card">
<h3 class="section-title">创建充值订单</h3>
<p class="muted small">
统一 Credits 计费:免费 500 Credits标准加油包按比例换算¥{{ package_amount }} / {{ package_credits }} Credits
</p>
<div class="grid2">
<div>
<label>充值 Credits 数量</label>
<input
id="billingRechargeTokens"
type="number"
min="1"
step="1"
value="{{ package_credits }}"
data-package-credits="{{ package_credits }}"
/>
</div>
<div>
<label>支付金额(元)</label>
<input
id="billingRechargeAmount"
type="number"
min="0.01"
step="0.01"
value="{{ package_amount }}"
data-package-amount="{{ package_amount }}"
/>
</div>
</div>
<div class="actions">
<button id="createRechargeOrderBtn" class="primary" type="button">创建充值订单</button>
<button id="refreshBillingBtn" class="secondary" type="button">刷新账单记录</button>
</div>
</section>
<section class="settings-section settings-card">
<h3 class="section-title">充值订单记录</h3>
<div id="rechargeRecords" class="billing-table-wrap"></div>
</section>
<section class="settings-section settings-card">
<h3 class="section-title">Credits 消费明细</h3>
<div id="consumeRecords" class="billing-table-wrap"></div>
<p id="status" class="status"></p>
</section>
</div>
</div>
</section>
</main>
</div>
</div>
<script src="/static/ui-dialog.js?v=20260428a"></script>
<script src="/static/billing.js?v=20260428w"></script>
</body>
</html>

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,75 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 个人中心</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
</head>
<body>
<div class="product-shell">
<aside class="side-nav" aria-label="主导航">
<div class="side-brand">
<div class="brand-lockup">
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
<h1>{{ app_name }}</h1>
</div>
</div>
<nav class="nav-group">
<div class="nav-label">工作台</div>
<a class="nav-item" href="/">内容生产</a>
<a class="nav-item" href="/settings">账号与模型</a>
<a class="nav-item" href="/billing">账单中心</a>
<a class="nav-item" href="/upgrade">升级</a>
<a class="nav-item is-active" href="/profile">个人中心</a>
<a class="nav-item" href="/guide">新手引导</a>
</nav>
<div class="side-footer">订阅信息 · 地址管理</div>
</aside>
<div class="workspace">
<header class="topbar topbar-compact">
<div class="topbar-actions">
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台"></a>
<a class="icon-btn" href="/upgrade" aria-label="升级" title="升级"></a>
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置"></a>
<button id="logoutBtn" class="icon-btn topbar-btn" type="button" aria-label="退出登录" title="退出登录"></button>
</div>
</header>
<main class="layout settings-layout">
<section class="panel settings-panel">
<div class="panel-scroll settings-panel-scroll">
<div class="settings-content">
<section class="settings-section settings-card">
<h3 class="section-title">个人中心</h3>
<p class="muted small">这里保存默认订阅人信息,升级页会自动带入,避免重复输入。</p>
<div class="grid2">
<div>
<label>订阅人姓名</label>
<input id="profileSubscriberName" type="text" placeholder="请输入姓名" />
</div>
<div>
<label>手机号(可选)</label>
<input id="profileSubscriberPhone" type="text" placeholder="请输入手机号" />
</div>
</div>
<div>
<label>收货地址</label>
<textarea id="profileShippingAddress" rows="3" placeholder="请输入详细地址"></textarea>
</div>
<div class="actions">
<button id="saveProfileBtn" class="primary" type="button">保存个人信息</button>
</div>
<p id="status" class="status"></p>
</section>
</div>
</div>
</section>
</main>
</div>
</div>
<script src="/static/profile.js?v=20260428a"></script>
</body>
</html>

View File

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

177
app/templates/upgrade.html Normal file
View File

@@ -0,0 +1,177 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>{{ app_name }} - 升级</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428za" />
</head>
<body>
<div class="product-shell">
<aside class="side-nav" aria-label="主导航">
<div class="side-brand">
<div class="brand-lockup">
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
<h1>{{ app_name }}</h1>
</div>
</div>
<nav class="nav-group">
<div class="nav-label">工作台</div>
<a class="nav-item" href="/">内容生产</a>
<a class="nav-item" href="/settings">账号与模型</a>
<a class="nav-item" href="/billing">账单中心</a>
<a class="nav-item is-active" href="/upgrade">升级</a>
<a class="nav-item" href="/profile">个人中心</a>
<a class="nav-item" href="/guide">新手引导</a>
</nav>
<div class="side-footer">免费额度 · 平台模型</div>
</aside>
<div class="workspace">
<header class="topbar topbar-compact">
<div class="topbar-actions">
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台"></a>
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置"></a>
<a class="icon-btn" href="/profile" aria-label="个人中心" title="个人中心"></a>
</div>
</header>
<main class="layout upgrade-layout">
<section class="panel upgrade-panel">
<div class="panel-scroll upgrade-scroll">
<section class="upgrade-hero">
<div>
<p class="guide-eyebrow">VIP 平台模型</p>
<h2>Token Plan 订阅</h2>
<p class="muted">新用户免费 {{ trial_tokens }} Credits。按支付成功时间起算月周期到期席位与加油包额度清零。</p>
</div>
<div class="upgrade-balance-card">
<span>当前可用</span>
<strong id="upgradeTokenBalanceHero">0</strong>
<small>Credits</small>
</div>
</section>
<section class="upgrade-grid">
<div class="upgrade-plans-stack">
<div class="upgrade-plan-grid">
<article class="upgrade-plan is-highlighted">
<div class="plan-head">
<span>推荐</span>
<h3>标准坐席</h3>
</div>
<p>适合轻度使用 AI 辅助写作与生图,采用 Credits 统一抵扣。</p>
<div class="plan-price">19.9/月</div>
<ul>
<li>{{ seat_quota }} Credits /月(优先抵扣)</li>
<li>席位额度用尽后,从共享加油包继续抵扣</li>
</ul>
<div class="upgrade-toggle-row">
<label>平台模型</label>
<select id="vipEnabledSelect" class="ui-select">
<option value="1">开启</option>
<option value="0">关闭</option>
</select>
</div>
<button id="saveVipBtn" class="secondary" type="button">保存升级设置</button>
</article>
<article class="upgrade-plan">
<div class="plan-head">
<span>自定义</span>
<h3>自备模型</h3>
</div>
<p>继续使用你在设置页配置的文本模型和文生图模型。</p>
<div class="plan-price">自有额度</div>
<ul>
<li>适合已有 API Key 的团队</li>
<li>模型、Base URL 可自行维护</li>
<li>平台 Credits 不参与扣减</li>
</ul>
<a class="subtle-link" href="/settings#model-settings">配置自定义模型</a>
</article>
</div>
</div>
<aside class="upgrade-purchase-card">
<div class="purchase-head">
<h3>标准坐席</h3>
<p class="muted small">席位1 席</p>
<p id="purchaseCycleText" class="muted small">时长:按支付成功时间起算 30 天(到期清零)</p>
</div>
<div class="purchase-section purchase-meta-grid">
<div class="purchase-row">
<span>套餐单价</span>
<strong>¥{{ package_amount }} / {{ package_credits }} Credits</strong>
</div>
<div class="purchase-row">
<span>座位数量</span>
<strong>1</strong>
</div>
</div>
<div class="purchase-section purchase-summary">
<div class="purchase-summary-row"><span>合计 Credits</span><strong id="purchaseCredits">0</strong></div>
<div class="purchase-summary-row"><span>应付金额</span><strong id="purchaseAmount">¥0.00</strong></div>
</div>
<div class="purchase-section purchase-qty">
<p class="muted tiny">支付方式</p>
<div class="pay-channel-group" role="radiogroup" aria-label="支付方式">
<button class="pay-channel-option is-active" type="button" data-channel="wechat" aria-pressed="true">微信支付</button>
<button class="pay-channel-option" type="button" data-channel="alipay" aria-pressed="false">支付宝</button>
</div>
</div>
<input
id="vipRechargeTokens"
type="number"
min="1"
step="1"
value="{{ package_credits }}"
data-package-credits="{{ package_credits }}"
hidden
/>
<input
id="vipRechargeAmount"
type="number"
min="0.01"
step="0.01"
value="{{ package_amount }}"
data-package-amount="{{ package_amount }}"
hidden
/>
<div class="purchase-section purchase-action">
<button id="vipRechargeBtn" class="primary" type="button">订阅</button>
</div>
</aside>
</section>
<section class="settings-card upgrade-wallet">
<div class="guide-section-head">
<h3>额度与充值</h3>
</div>
<div class="upgrade-stats">
<div>
<span>席位剩余额度</span>
<strong id="vipSeatRemaining">0</strong>
</div>
<div>
<span>共享加油包</span>
<strong id="vipTokenBalance">0</strong>
</div>
<div>
<span>总可用 Credits</span>
<strong id="upgradeTokenBalance">0</strong>
</div>
</div>
<p id="vipCycleHint" class="muted small">当前未开始月周期。</p>
<p id="status" class="status"></p>
</section>
</div>
</section>
</main>
</div>
</div>
<script src="/static/ui-dialog.js?v=20260428a"></script>
<script src="/static/upgrade.js?v=20260428ae"></script>
</body>
</html>