diff --git a/app/main.py b/app/main.py index 4eb9f0e..cecf53e 100644 --- a/app/main.py +++ b/app/main.py @@ -4,6 +4,7 @@ import logging import math import re import secrets +import socket import time import uuid from pathlib import Path @@ -175,18 +176,31 @@ 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) + now = int(time.time()) + active_user_model = users.get_active_ai_model(user_id) + has_user_model = bool( + active_user_model + and (active_user_model.get("api_key") or "").strip() + and (active_user_model.get("model") or "").strip() + ) + purchased_cycle_active = bool(int(vip.get("cycle_started_at") or 0) > 0 and int(vip.get("cycle_expires_at") or 0) > now) if prefer_vip and vip.get("vip_enabled"): - if int(vip.get("total_available_credits") or 0) <= 0: + total_available = int(vip.get("total_available_credits") or 0) + if total_available < _min_platform_credits_required(): return None, "vip_empty" - cfg = _platform_model_cfg() - if cfg.get("api_key"): - return cfg, "vip" - cfg = users.get_active_ai_model(user_id) + # 已购买订阅:优先平台模型 + # 未购买订阅:若未配置自有模型,则默认走平台模型(可使用赠送额度) + if purchased_cycle_active or not has_user_model: + cfg = _platform_model_cfg() + if cfg.get("api_key"): + return cfg, "vip" + cfg = active_user_model return cfg, "user" def _quota_detail() -> str: - return "Credits 额度已用完(席位额度+共享加油包),请充值或等待下个计费周期" + min_required = _min_platform_credits_required() + return f"Credits 额度不足,平台模型至少需要 {min_required} Credits(1张图片或10000Token门槛),请订阅或充值" def _credits_from_cny(amount_cny: float) -> int: @@ -222,6 +236,13 @@ def _estimate_image_cost(image_count: int) -> int: return _credits_from_cny((cnt / pkg_images) * pkg_cny) +def _min_platform_credits_required() -> int: + one_image_credits = _estimate_image_cost(1) + token_10k_cny = (10_000.0 / 1_000_000.0) * max(0.0, float(settings.credits_token_price_per_million_cny)) + token_10k_credits = _credits_from_cny(token_10k_cny) + return max(1, one_image_credits, token_10k_credits) + + def _new_order_no() -> str: return f"RC{time.strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:10].upper()}" @@ -743,6 +764,31 @@ async def pay_address_suggest(request: Request): return {"ok": True, "detail": "IP地址解析失败,已填入IP信息,可手动修改", "ip": ip, "address": f"IP:{ip}"} +@app.get("/api/tools/server-ip") +async def tools_server_ip(request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + public_ip = "" + try: + async with httpx.AsyncClient(timeout=5) as client: + resp = await client.get("https://api64.ipify.org?format=json") + body = resp.json() if resp.content else {} + if resp.status_code < 400 and isinstance(body, dict): + public_ip = str(body.get("ip") or "").strip() + except Exception: + public_ip = "" + private_ip = "" + try: + private_ip = socket.gethostbyname(socket.gethostname()) + except Exception: + private_ip = "" + ip = public_ip or private_ip + if not ip: + return {"ok": False, "detail": "无法识别服务器IP,请稍后重试"} + return {"ok": True, "ip": ip, "public_ip": public_ip, "private_ip": private_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() @@ -967,7 +1013,7 @@ async def rewrite(req: RewriteRequest, request: Request): 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")) + should_consume = model_source == "vip" if should_consume: ok_cost, balance = users.consume_tokens( user["id"], diff --git a/app/services/user_store.py b/app/services/user_store.py index 589fba7..e906a5c 100644 --- a/app/services/user_store.py +++ b/app/services/user_store.py @@ -709,10 +709,10 @@ class UserStore: 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 + # 仅当已开启有效计费周期时,席位额度才可用;新用户未购买时不应显示席位 1500 + cycle_active = cycle_started_at > 0 and cycle_expires_at > now 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": shared_credits, diff --git a/app/static/guide.js b/app/static/guide.js new file mode 100644 index 0000000..6759b62 --- /dev/null +++ b/app/static/guide.js @@ -0,0 +1,36 @@ +const guideGetServerIpBtn = document.getElementById("getServerIpBtn"); + +async function showGuideAlert(message, title = "提示") { + if (typeof window.uiAlert === "function") { + await window.uiAlert(message, title); + return; + } + window.alert(message); +} + +if (guideGetServerIpBtn) { + guideGetServerIpBtn.addEventListener("click", async () => { + const idleText = "获取服务器IP"; + guideGetServerIpBtn.disabled = true; + guideGetServerIpBtn.textContent = "获取中..."; + try { + const res = await fetch("/api/tools/server-ip"); + const data = await res.json(); + if (!res.ok || !data.ok) { + await showGuideAlert((data && data.detail) || "获取服务器IP失败,请稍后重试", "获取失败"); + return; + } + const ip = String(data.ip || "").trim(); + if (!ip) { + await showGuideAlert("服务器IP为空,请稍后重试", "获取失败"); + return; + } + await showGuideAlert(`当前服务器IP:${ip}`, "API IP白名单"); + } catch { + await showGuideAlert("获取服务器IP失败,请稍后重试", "获取失败"); + } finally { + guideGetServerIpBtn.disabled = false; + guideGetServerIpBtn.textContent = idleText; + } + }); +} diff --git a/app/static/mode-hint.js b/app/static/mode-hint.js new file mode 100644 index 0000000..ffd2e90 --- /dev/null +++ b/app/static/mode-hint.js @@ -0,0 +1,27 @@ +(() => { + const badges = Array.from(document.querySelectorAll(".global-mode-hint")); + if (!badges.length) return; + + function setText(text) { + badges.forEach((el) => { + el.textContent = text; + }); + } + + async function run() { + try { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (!res.ok || !data || !data.logged_in) return; + const vip = data.vip || {}; + const now = Math.floor(Date.now() / 1000); + const enabled = Boolean(vip.vip_enabled); + const hasActiveSubscription = Number(vip.cycle_started_at || 0) > 0 && Number(vip.cycle_expires_at || 0) > now; + setText(enabled && hasActiveSubscription ? "当前模型模式:平台模型" : "当前模型模式:自由模型"); + } catch { + // ignore + } + } + + run(); +})(); diff --git a/app/static/settings.js b/app/static/settings.js index c722bf7..ca5bacd 100644 --- a/app/static/settings.js +++ b/app/static/settings.js @@ -207,7 +207,7 @@ if (saveModelBtn) { api_key: ($("apiKey") && $("apiKey").value.trim()) || "", base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "", model: ($("modelValue") && $("modelValue").value.trim()) || "", - image_model: "", + image_model: ($("imageModelValue") && $("imageModelValue").value.trim()) || "", 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()), @@ -219,6 +219,7 @@ if (saveModelBtn) { setStatus("模型配置已保存并设为当前。"); if ($("apiKey")) $("apiKey").value = ""; if ($("modelName")) $("modelName").value = ""; + if ($("imageModelValue")) $("imageModelValue").value = ""; await refresh(); } catch (e) { setStatus(e.message || "模型保存失败", true); diff --git a/app/static/style.css b/app/static/style.css index 52dd750..baad390 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -215,6 +215,41 @@ a { flex-wrap: wrap; } +.upgrade-topbar { + display: grid; + grid-template-columns: 1fr auto 1fr; + align-items: center; +} + +.topbar-spacer { + min-height: 1px; +} + +.topbar-center { + display: flex; + justify-content: center; +} + +.upgrade-topbar .topbar-actions { + justify-self: end; + flex-wrap: nowrap; +} + +.mode-badge { + display: inline-flex; + align-items: center; + min-height: var(--control-h); + padding: 0 12px; + border-radius: var(--radius); + border: 1px solid var(--accent); + background: linear-gradient(180deg, #ff922e, #ff6b16); + color: #fff; + font-size: 12px; + font-weight: 850; + white-space: nowrap; + box-shadow: 0 10px 20px rgba(255, 107, 22, 0.2); +} + .wechat-account-switch { min-width: 250px; display: grid; @@ -333,12 +368,14 @@ a { .panel-scroll { min-height: 0; - overflow: auto; + overflow-y: auto; + overflow-x: hidden; padding: 10px; } .input-panel .panel-scroll { - overflow: visible; + overflow-y: visible; + overflow-x: hidden; padding: 8px; } @@ -783,27 +820,29 @@ button.secondary:hover, } .target-chars-quick { - display: flex; - align-items: center; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + align-items: stretch; gap: 6px; + width: 100%; min-width: 0; - flex-wrap: wrap; - overflow: visible; } .target-char-chip, button.target-char-chip { - width: auto; - min-width: 54px; + width: 100%; + min-width: 0; + min-height: 30px; margin-top: 0; - padding: 5px 8px; - flex: 0 0 auto; - border-radius: 999px; + padding: 5px 6px; + border-radius: 8px; border-color: var(--line-strong); color: #344054; background: #fff; font-size: 12px; - line-height: 1.2; + line-height: 1.1; + text-align: center; + white-space: nowrap; box-shadow: none; } @@ -1587,7 +1626,7 @@ button.target-char-chip { .upgrade-grid { margin-top: 10px; display: grid; - grid-template-columns: minmax(0, 1.4fr) minmax(320px, 0.8fr); + grid-template-columns: minmax(0, 1fr) minmax(300px, 380px); gap: 10px; } @@ -2009,6 +2048,12 @@ button.target-char-chip { border-radius: 10px; } +@media (max-width: 1100px) { + .upgrade-grid { + grid-template-columns: 1fr; + } +} + @media (max-width: 860px) { .upgrade-hero { align-items: flex-start; diff --git a/app/static/upgrade.js b/app/static/upgrade.js index 54dc861..0b9dc38 100644 --- a/app/static/upgrade.js +++ b/app/static/upgrade.js @@ -85,6 +85,8 @@ function renderVip(vip) { const enabled = Boolean(vip.vip_enabled); const cycleStart = Number(vip.cycle_started_at || 0); const cycleEnd = Number(vip.cycle_expires_at || 0); + const now = Math.floor(Date.now() / 1000); + const hasActiveSubscription = cycleStart > 0 && cycleEnd > now; if ($("upgradeTokenBalance")) $("upgradeTokenBalance").textContent = String(totalAvailable); if ($("upgradeTokenBalanceHero")) $("upgradeTokenBalanceHero").textContent = String(totalAvailable); if ($("vipTokenBalance")) $("vipTokenBalance").textContent = String(shared); @@ -103,6 +105,15 @@ function renderVip(vip) { $("vipCycleHint").textContent = "当前未开始月周期,首次支付成功后开始计时。"; } } + if ($("vipModeHint")) { + if (enabled && hasActiveSubscription) { + $("vipModeHint").textContent = "当前模型模式:平台模型(订阅有效)"; + } else if (enabled && !hasActiveSubscription) { + $("vipModeHint").textContent = "当前模型模式:自由模型(未开通订阅)"; + } else { + $("vipModeHint").textContent = "当前模型模式:自由模型"; + } + } if (totalAvailable <= 0) { setStatus("Credits 额度已用完,请充值共享加油包或等待下个计费周期。", true); } diff --git a/app/templates/billing.html b/app/templates/billing.html index 2fe4375..a7352ac 100644 --- a/app/templates/billing.html +++ b/app/templates/billing.html @@ -31,6 +31,7 @@
+ 当前模型模式:自由模型 @@ -95,6 +96,7 @@
+ diff --git a/app/templates/guide.html b/app/templates/guide.html index 0699696..00bc7cc 100644 --- a/app/templates/guide.html +++ b/app/templates/guide.html @@ -31,6 +31,7 @@
+ 当前模型模式:自由模型 @@ -58,39 +59,50 @@
01

准备发布账号

进入账号与模型设置,绑定公众号 AppID 和 Secret。草稿发布、封面上传、段落海报素材都会使用当前选中的发表主体。

+

+ 不知道哪里查看 ID?可先阅读微信文档: + 查看 AppID / AppSecret 获取说明 +

打开账号设置
02
+

配置 API IP 白名单

+

若第三方模型平台开启了 API IP 白名单,请先把当前服务器 IP 加入白名单,避免接口请求被拒绝。

+ +
+ +
+
03

配置 AI 模型

保存模型名称、API Key、Base URL、超时秒数和输出 token 上限。未配置模型时将无法进行 AI 改写,请先完成模型配置。

打开模型配置
-
03
+
04

输入原文与策略

在内容生产页粘贴原文,补充标题提示、目标读者、语气风格、必须保留观点和避免词汇。目标字数建议先从 500 或 800 开始。

进入写作输入
-
04
+
05

生成并人工复核

点击“改写并排版”后,检查标题、摘要、正文结构和排版预览。涉及事实、数据、引用和品牌表达时,发布前务必人工确认。

查看发布内容
-
05
+
06

补齐封面和海报

可按输出标题自动生成 900×383 公众号封面并绑定 thumb_media_id,也可以生成段落海报。勾选自动插入后,发布草稿时会把正文和海报一起编排。

处理内容素材
-
06
+
07

发布到草稿箱

确认发表主体无误后,点击“发布到公众号草稿箱”。需要团队同步时,再点击“发送到 IM”。草稿发布后仍建议在公众号后台最终预览。

回到发布动作 @@ -135,5 +147,8 @@
+ + + diff --git a/app/templates/index.html b/app/templates/index.html index e5df8ce..1a671ad 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -31,6 +31,7 @@
+ 当前模型模式:自由模型
+ diff --git a/app/templates/profile.html b/app/templates/profile.html index aa29da6..eae7fde 100644 --- a/app/templates/profile.html +++ b/app/templates/profile.html @@ -31,6 +31,7 @@
+ 当前模型模式:自由模型 @@ -70,6 +71,7 @@
+ diff --git a/app/templates/settings.html b/app/templates/settings.html index 6a2dd80..fd43ea6 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -31,6 +31,7 @@
+ 当前模型模式:自由模型 @@ -91,6 +92,10 @@
+
+ + +
@@ -154,6 +159,7 @@
- + + diff --git a/app/templates/upgrade.html b/app/templates/upgrade.html index 0f46635..4ad37e3 100644 --- a/app/templates/upgrade.html +++ b/app/templates/upgrade.html @@ -5,7 +5,7 @@ {{ app_name }} - 升级 - +
@@ -29,7 +29,11 @@
-
+
+ +
+ 当前模型模式:自由模型 +
@@ -172,6 +176,7 @@
- + +