Compare commits
22 Commits
124a5f0192
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0134a5ef64 | ||
|
|
c234fe64d6 | ||
|
|
6de7e782fc | ||
|
|
f47453a656 | ||
|
|
04f26bdaaf | ||
|
|
2dc7f2e19c | ||
|
|
975a9e88f6 | ||
|
|
4ee41e3534 | ||
|
|
2724e69b4f | ||
|
|
1bbabc2a78 | ||
|
|
d76c7fa25a | ||
|
|
e69666dbb3 | ||
|
|
5b4bee1939 | ||
|
|
17591de58f | ||
|
|
222bf2e70d | ||
|
|
869e3b5976 | ||
|
|
780871e93c | ||
|
|
9070dfba35 | ||
|
|
005a25b77a | ||
|
|
b342a90f9d | ||
|
|
1d389767e6 | ||
|
|
babf24a0b0 |
82
.env.example
82
.env.example
@@ -1,12 +1,82 @@
|
|||||||
OPENAI_API_KEY=
|
APP_NAME=AI发糕
|
||||||
OPENAI_BASE_URL=
|
# 注意:AI 模型、公众号 AppID/Secret 为用户级配置,请在页面「账号与模型」中填写。
|
||||||
OPENAI_MODEL=gpt-4.1-mini
|
# —— 通义千问(推荐):阿里云 DashScope 的 OpenAI 兼容地址 + 模型名 + API Key
|
||||||
|
# OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
# OPENAI_API_KEY=sk-你的DashScopeKey
|
||||||
|
# OPENAI_MODEL=qwen3.5-plus
|
||||||
|
# OPENAI_API_KEY=
|
||||||
|
# OPENAI_BASE_URL=
|
||||||
|
# OPENAI_MODEL=gpt-4.1-mini
|
||||||
|
# 通义长文 JSON 常需 60~120s+,过短会 APITimeout 后走兜底
|
||||||
|
# OPENAI_TIMEOUT=120
|
||||||
|
# SDK 自动重试次数。设为 0 可避免单次请求被隐式重试拖长(例如 30s 变 60s+)
|
||||||
|
# OPENAI_MAX_RETRIES=0
|
||||||
|
# 长文 JSON 建议 8192;通义等若正文仍偏短可适当再加大
|
||||||
|
# OPENAI_MAX_OUTPUT_TOKENS=8192
|
||||||
|
# OPENAI_SOURCE_MAX_CHARS=5000
|
||||||
|
# 质检未通过时仍返回模型洗稿正文(quality_notes 记录问题);设为 false 则严格退回保底稿
|
||||||
|
# AI_SOFT_ACCEPT=true
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
WECHAT_APPID=
|
# 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。
|
||||||
WECHAT_SECRET=
|
# 若 errcode=40164 invalid ip:把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。
|
||||||
WECHAT_AUTHOR=AI 编辑部
|
# WECHAT_APPID=
|
||||||
|
# WECHAT_SECRET=
|
||||||
|
# WECHAT_AUTHOR=AI 编辑部
|
||||||
|
# 封面(图文草稿必填,否则 errcode=40007):任选其一
|
||||||
|
# ① 填永久素材 ID:WECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id)
|
||||||
|
# ② 填容器内图片路径,由服务自动上传:WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg
|
||||||
|
# ③ 两项都不填:服务会用内置默认图自动上传(需 material 接口权限)
|
||||||
|
# WECHAT_THUMB_MEDIA_ID=
|
||||||
|
# WECHAT_THUMB_IMAGE_PATH=
|
||||||
|
|
||||||
# 可填飞书/Slack/企微等 webhook
|
# 可填飞书/Slack/企微等 webhook
|
||||||
IM_WEBHOOK_URL=
|
IM_WEBHOOK_URL=
|
||||||
# 若 webhook 需要签名可填
|
# 若 webhook 需要签名可填
|
||||||
IM_SECRET=
|
IM_SECRET=
|
||||||
|
|
||||||
|
# 账号数据 SQLite 文件(建议放在容器挂载目录,如 /app/data/app.db)
|
||||||
|
AUTH_DB_PATH=./data/app.db
|
||||||
|
# 普通登录有效期(秒),默认 1 天
|
||||||
|
AUTH_SESSION_TTL_SEC=86400
|
||||||
|
# 勾选“限时免登”后的有效期(秒),默认 7 天
|
||||||
|
AUTH_REMEMBER_SESSION_TTL_SEC=604800
|
||||||
|
# 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026)
|
||||||
|
AUTH_PASSWORD_RESET_KEY=x2ws-reset-2026
|
||||||
|
|
||||||
|
# --- VIP 平台模型配置(用户开启 VIP 后优先使用)---
|
||||||
|
# 平台文本模型
|
||||||
|
PLATFORM_OPENAI_API_KEY=
|
||||||
|
# PLATFORM_OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
|
PLATFORM_OPENAI_MODEL=qwen-plus
|
||||||
|
# 平台生图模型
|
||||||
|
PLATFORM_OPENAI_IMAGE_MODEL=wanx2.0-t2i-turbo
|
||||||
|
PLATFORM_OPENAI_TIMEOUT=120
|
||||||
|
PLATFORM_OPENAI_MAX_OUTPUT_TOKENS=8192
|
||||||
|
PLATFORM_OPENAI_MAX_RETRIES=0
|
||||||
|
# 新用户免费试用 Credits
|
||||||
|
VIP_TRIAL_TOKENS=500
|
||||||
|
# 标准坐席每月额度
|
||||||
|
CREDITS_SEAT_MONTHLY_QUOTA=1500
|
||||||
|
# 文本计费:100万 token 的人民币价格
|
||||||
|
CREDITS_TOKEN_PRICE_PER_MILLION_CNY=7.9
|
||||||
|
# 图片计费:160 张图 = 0.75 元
|
||||||
|
CREDITS_IMAGE_PRICE_PACKAGE_CNY=0.75
|
||||||
|
CREDITS_IMAGE_PRICE_PACKAGE_IMAGES=160
|
||||||
|
# 兼容字段(旧版):单张图人民币价格
|
||||||
|
CREDITS_IMAGE_PRICE_PER_IMAGE_CNY=0.04
|
||||||
|
# 兼容字段(旧版):可保留默认,不再作为首选换算规则
|
||||||
|
CREDITS_PER_MILLION_TOKENS=1500
|
||||||
|
CREDITS_PER_120_IMAGES=1500
|
||||||
|
# 标准加油包:19.9 元 = 1500 Credits
|
||||||
|
CREDITS_RECHARGE_PACKAGE_AMOUNT=19.9
|
||||||
|
CREDITS_RECHARGE_PACKAGE_CREDITS=1500
|
||||||
|
|
||||||
|
# 购物系统打通(可选)
|
||||||
|
# SHOP_BACKEND_CREATE_ORDER_URL=https://shop.example.com/api/order/create
|
||||||
|
# 微信支付回调路径(本项目已提供):
|
||||||
|
# 下单入口:POST https://你的域名/api/pay/wechat/
|
||||||
|
# 回调入口:POST https://你的域名/api/pay/wechat/backcall
|
||||||
|
SHOP_BACKEND_CALLBACK_TOKEN=
|
||||||
|
# 超级管理后台口令(访问 /admin?token=你的口令)
|
||||||
|
SUPER_ADMIN_TOKEN=
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,3 +2,5 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
data/*
|
||||||
|
!data/.gitkeep
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"postman.settings.dotenv-detection-notification-visibility": false
|
||||||
|
}
|
||||||
38
Dockerfile
38
Dockerfile
@@ -1,11 +1,45 @@
|
|||||||
FROM python:3.11-slim
|
# syntax=docker/dockerfile:1
|
||||||
|
# 国内拉基础镜像慢时:docker compose build --build-arg PY_BASE=docker.m.daocloud.io/library/python:3.11-slim
|
||||||
|
ARG PY_BASE=python:3.11-slim
|
||||||
|
FROM ${PY_BASE}
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
|
# 默认清华 PyPI;海外可:docker compose build --build-arg PIP_INDEX_URL=https://pypi.org/simple
|
||||||
|
ARG PIP_INDEX_URL=https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
ENV PIP_INDEX_URL=${PIP_INDEX_URL} \
|
||||||
|
PIP_DISABLE_PIP_VERSION_CHECK=1 \
|
||||||
|
PIP_DEFAULT_TIMEOUT=120
|
||||||
|
|
||||||
COPY requirements.txt ./
|
COPY requirements.txt ./
|
||||||
RUN pip install --no-cache-dir -r requirements.txt
|
# BuildKit 缓存加速重复构建;需 Docker 20.10+(compose 默认开 BuildKit)
|
||||||
|
RUN --mount=type=cache,target=/root/.cache/pip \
|
||||||
|
pip install -r requirements.txt
|
||||||
|
|
||||||
COPY . .
|
COPY . .
|
||||||
|
|
||||||
|
# 可选:构建时自动拉取远端仓库最新代码覆盖工作目录(默认关闭)。
|
||||||
|
# 放在 COPY 之后,避免影响依赖层缓存;仅在你显式开启时才会触发网络更新。
|
||||||
|
# 用法示例:
|
||||||
|
# docker compose build \
|
||||||
|
# --build-arg GIT_AUTO_UPDATE=1 \
|
||||||
|
# --build-arg GIT_REMOTE_URL=https://github.com/you/repo.git \
|
||||||
|
# --build-arg GIT_REF=main
|
||||||
|
ARG GIT_AUTO_UPDATE=0
|
||||||
|
ARG GIT_REMOTE_URL=
|
||||||
|
ARG GIT_REF=main
|
||||||
|
RUN if [ "$GIT_AUTO_UPDATE" = "1" ] && [ -n "$GIT_REMOTE_URL" ]; then \
|
||||||
|
set -eux; \
|
||||||
|
apt-get update; \
|
||||||
|
apt-get install -y --no-install-recommends git ca-certificates; \
|
||||||
|
rm -rf /var/lib/apt/lists/*; \
|
||||||
|
tmpdir="$(mktemp -d)"; \
|
||||||
|
git clone --depth=1 --branch "$GIT_REF" "$GIT_REMOTE_URL" "$tmpdir/repo"; \
|
||||||
|
cp -a "$tmpdir/repo/." /app/; \
|
||||||
|
rm -rf "$tmpdir"; \
|
||||||
|
else \
|
||||||
|
echo "Skip remote code update (GIT_AUTO_UPDATE=0 or GIT_REMOTE_URL empty)"; \
|
||||||
|
fi
|
||||||
|
|
||||||
EXPOSE 8000
|
EXPOSE 8000
|
||||||
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
|
|||||||
143
app/config.py
143
app/config.py
@@ -5,17 +5,158 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
app_name: str = "X2WeChat Studio"
|
app_name: str = "AI发糕"
|
||||||
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
|
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
|
||||||
openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
|
openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
|
||||||
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
|
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
|
||||||
|
openai_timeout: float = Field(
|
||||||
|
default=120.0,
|
||||||
|
alias="OPENAI_TIMEOUT",
|
||||||
|
description="HTTP 等待模型单轮响应的最长时间(秒)。通义长文 JSON 建议 120~180",
|
||||||
|
)
|
||||||
|
openai_max_retries: int = Field(
|
||||||
|
default=0,
|
||||||
|
alias="OPENAI_MAX_RETRIES",
|
||||||
|
description="OpenAI SDK 自动重试次数。为避免单次请求被重试拉长,默认 0。",
|
||||||
|
)
|
||||||
|
openai_max_output_tokens: int = Field(
|
||||||
|
default=8192,
|
||||||
|
alias="OPENAI_MAX_OUTPUT_TOKENS",
|
||||||
|
description="单次模型输出 token 上限;通义等长文 JSON 需足够大",
|
||||||
|
)
|
||||||
|
openai_image_model: str = Field(
|
||||||
|
default="gpt-image-1",
|
||||||
|
alias="OPENAI_IMAGE_MODEL",
|
||||||
|
description="用于海报生成的图像模型",
|
||||||
|
)
|
||||||
|
openai_source_max_chars: int = Field(default=5000, alias="OPENAI_SOURCE_MAX_CHARS")
|
||||||
|
ai_soft_accept: bool = Field(
|
||||||
|
default=True,
|
||||||
|
alias="AI_SOFT_ACCEPT",
|
||||||
|
description="质检未通过时仍输出模型洗稿正文(mode=ai,quality_notes 记录问题),仅模型完全失败时用保底稿",
|
||||||
|
)
|
||||||
|
|
||||||
wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID")
|
wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID")
|
||||||
wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET")
|
wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET")
|
||||||
wechat_author: str = Field(default="AI 编辑部", alias="WECHAT_AUTHOR")
|
wechat_author: str = Field(default="AI 编辑部", alias="WECHAT_AUTHOR")
|
||||||
|
wechat_thumb_media_id: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="WECHAT_THUMB_MEDIA_ID",
|
||||||
|
description="草稿图文封面:永久素材 media_id(素材库或 add_material)。与 WECHAT_THUMB_IMAGE_PATH 二选一即可",
|
||||||
|
)
|
||||||
|
wechat_thumb_image_path: str | None = Field(
|
||||||
|
default=None,
|
||||||
|
alias="WECHAT_THUMB_IMAGE_PATH",
|
||||||
|
description="本地封面图路径(容器内),将自动上传为永久素材;不配则使用内置灰底图上传",
|
||||||
|
)
|
||||||
|
poster_image_size: str = Field(
|
||||||
|
default="1024x1536",
|
||||||
|
alias="POSTER_IMAGE_SIZE",
|
||||||
|
description="AI 海报生成尺寸(OpenAI images.generate size)",
|
||||||
|
)
|
||||||
|
poster_max_images: int = Field(
|
||||||
|
default=6,
|
||||||
|
alias="POSTER_MAX_IMAGES",
|
||||||
|
description="单次自动生成海报上限(首段跳过后生效)",
|
||||||
|
)
|
||||||
|
poster_upload_max_bytes: int = Field(
|
||||||
|
default=950000,
|
||||||
|
alias="POSTER_UPLOAD_MAX_BYTES",
|
||||||
|
description="上传微信 uploadimg 前压缩目标字节数,预留余量避免超 1MB 限制",
|
||||||
|
)
|
||||||
|
|
||||||
im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
|
im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
|
||||||
im_secret: str | None = Field(default=None, alias="IM_SECRET")
|
im_secret: str | None = Field(default=None, alias="IM_SECRET")
|
||||||
|
|
||||||
|
auth_db_path: str = Field(default="./data/app.db", alias="AUTH_DB_PATH")
|
||||||
|
auth_cookie_name: str = Field(default="x2ws_session", alias="AUTH_COOKIE_NAME")
|
||||||
|
auth_session_ttl_sec: int = Field(default=86400, alias="AUTH_SESSION_TTL_SEC")
|
||||||
|
auth_remember_session_ttl_sec: int = Field(default=604800, alias="AUTH_REMEMBER_SESSION_TTL_SEC")
|
||||||
|
auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY")
|
||||||
|
|
||||||
|
vip_trial_tokens: int = Field(
|
||||||
|
default=500,
|
||||||
|
alias="VIP_TRIAL_TOKENS",
|
||||||
|
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=1500,
|
||||||
|
alias="VIP_REWRITE_TOKEN_PER_1K_CHARS",
|
||||||
|
description="兼容字段:改写计费参数(建议使用 Credits 规则字段)",
|
||||||
|
)
|
||||||
|
vip_image_token_per_image: int = Field(
|
||||||
|
default=1500,
|
||||||
|
alias="VIP_IMAGE_TOKEN_PER_IMAGE",
|
||||||
|
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="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")
|
||||||
|
super_admin_token: str = Field(default="", alias="SUPER_ADMIN_TOKEN")
|
||||||
|
|
||||||
|
|
||||||
settings = Settings()
|
settings = Settings()
|
||||||
|
|||||||
25
app/logging_setup.py
Normal file
25
app/logging_setup.py
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
"""统一日志格式,便于 grep / 日志平台解析。"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import os
|
||||||
|
|
||||||
|
|
||||||
|
def configure_logging() -> None:
|
||||||
|
level_name = os.getenv("LOG_LEVEL", "INFO").upper()
|
||||||
|
level = getattr(logging, level_name, logging.INFO)
|
||||||
|
|
||||||
|
fmt = "%(asctime)s | %(levelname)s | %(name)s | %(message)s"
|
||||||
|
datefmt = "%Y-%m-%d %H:%M:%S"
|
||||||
|
|
||||||
|
root = logging.getLogger()
|
||||||
|
if not root.handlers:
|
||||||
|
handler = logging.StreamHandler()
|
||||||
|
handler.setFormatter(logging.Formatter(fmt=fmt, datefmt=datefmt))
|
||||||
|
root.addHandler(handler)
|
||||||
|
root.setLevel(level)
|
||||||
|
|
||||||
|
# 降噪:第三方库默认 WARNING
|
||||||
|
logging.getLogger("httpx").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("httpcore").setLevel(logging.WARNING)
|
||||||
|
logging.getLogger("openai").setLevel(logging.WARNING)
|
||||||
1579
app/main.py
1579
app/main.py
File diff suppressed because it is too large
Load Diff
61
app/middleware.py
Normal file
61
app/middleware.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import time
|
||||||
|
import uuid
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
logger = logging.getLogger("app.http")
|
||||||
|
|
||||||
|
|
||||||
|
class RequestContextMiddleware(BaseHTTPMiddleware):
|
||||||
|
"""注入 request_id,记录访问日志与耗时。"""
|
||||||
|
|
||||||
|
async def dispatch(self, request: Request, call_next) -> Response:
|
||||||
|
rid = request.headers.get("X-Request-ID") or str(uuid.uuid4())
|
||||||
|
request.state.request_id = rid
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
if path.startswith("/static"):
|
||||||
|
response = await call_next(request)
|
||||||
|
response.headers["X-Request-ID"] = rid
|
||||||
|
return response
|
||||||
|
|
||||||
|
client = request.client.host if request.client else "-"
|
||||||
|
if path.startswith("/api"):
|
||||||
|
logger.info(
|
||||||
|
"http_in method=%s path=%s rid=%s client=%s",
|
||||||
|
request.method,
|
||||||
|
path,
|
||||||
|
rid,
|
||||||
|
client,
|
||||||
|
)
|
||||||
|
|
||||||
|
started = time.perf_counter()
|
||||||
|
try:
|
||||||
|
response = await call_next(request)
|
||||||
|
except Exception:
|
||||||
|
duration_ms = (time.perf_counter() - started) * 1000
|
||||||
|
logger.exception(
|
||||||
|
"http_error method=%s path=%s duration_ms=%.1f rid=%s",
|
||||||
|
request.method,
|
||||||
|
path,
|
||||||
|
duration_ms,
|
||||||
|
rid,
|
||||||
|
)
|
||||||
|
raise
|
||||||
|
|
||||||
|
duration_ms = (time.perf_counter() - started) * 1000
|
||||||
|
response.headers["X-Request-ID"] = rid
|
||||||
|
logger.info(
|
||||||
|
"http_out method=%s path=%s status=%s duration_ms=%.1f rid=%s",
|
||||||
|
request.method,
|
||||||
|
path,
|
||||||
|
response.status_code,
|
||||||
|
duration_ms,
|
||||||
|
rid,
|
||||||
|
)
|
||||||
|
return response
|
||||||
171
app/schemas.py
171
app/schemas.py
@@ -1,19 +1,34 @@
|
|||||||
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
class RewriteRequest(BaseModel):
|
class RewriteRequest(BaseModel):
|
||||||
source_text: str = Field(..., min_length=20)
|
source_text: str = Field(..., min_length=20)
|
||||||
title_hint: str = ""
|
title_hint: str = ""
|
||||||
|
writing_style: str = "科普解读"
|
||||||
tone: str = "专业、可信、可读性强"
|
tone: str = "专业、可信、可读性强"
|
||||||
audience: str = "公众号读者"
|
audience: str = "公众号读者"
|
||||||
keep_points: str = ""
|
keep_points: str = ""
|
||||||
avoid_words: str = ""
|
avoid_words: str = ""
|
||||||
|
target_body_chars: int = Field(
|
||||||
|
default=500,
|
||||||
|
ge=180,
|
||||||
|
le=2200,
|
||||||
|
description="目标改写正文字数,前端可调(中文字符数近似)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class RewriteResponse(BaseModel):
|
class RewriteResponse(BaseModel):
|
||||||
title: str
|
title: str
|
||||||
summary: str
|
summary: str
|
||||||
body_markdown: str
|
body_markdown: str
|
||||||
|
mode: str = "ai"
|
||||||
|
quality_notes: list[str] = []
|
||||||
|
trace: dict[str, Any] | None = Field(
|
||||||
|
default=None,
|
||||||
|
description="改写链路追踪:请求 ID、耗时、模型、质检与降级原因,便于监测与回溯",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class WechatPublishRequest(BaseModel):
|
class WechatPublishRequest(BaseModel):
|
||||||
@@ -21,6 +36,7 @@ class WechatPublishRequest(BaseModel):
|
|||||||
summary: str = ""
|
summary: str = ""
|
||||||
body_markdown: str
|
body_markdown: str
|
||||||
author: str = ""
|
author: str = ""
|
||||||
|
thumb_media_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
class IMPublishRequest(BaseModel):
|
class IMPublishRequest(BaseModel):
|
||||||
@@ -32,3 +48,158 @@ class PublishResponse(BaseModel):
|
|||||||
ok: bool
|
ok: bool
|
||||||
detail: str
|
detail: str
|
||||||
data: dict | None = None
|
data: dict | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuthCredentialRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
password: str
|
||||||
|
remember_me: bool = False
|
||||||
|
challenge_id: str = ""
|
||||||
|
challenge_answer: str = ""
|
||||||
|
honeypot: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ChangePasswordRequest(BaseModel):
|
||||||
|
old_password: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class ForgotPasswordResetRequest(BaseModel):
|
||||||
|
username: str
|
||||||
|
reset_key: str
|
||||||
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccountRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
reset_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class WechatBindingRequest(BaseModel):
|
||||||
|
account_name: str = ""
|
||||||
|
appid: str
|
||||||
|
secret: str
|
||||||
|
author: str = ""
|
||||||
|
thumb_media_id: str = ""
|
||||||
|
thumb_image_path: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class WechatSwitchRequest(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class WechatDeleteRequest(BaseModel):
|
||||||
|
account_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class WechatCoverUploadByUrlRequest(BaseModel):
|
||||||
|
image_url: str
|
||||||
|
|
||||||
|
|
||||||
|
class WechatCoverGenerateRequest(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
summary: str = ""
|
||||||
|
style_hint: str = ""
|
||||||
|
image_model: str = ""
|
||||||
|
upload_to_wechat: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelCreateRequest(BaseModel):
|
||||||
|
model_name: str
|
||||||
|
api_key: str
|
||||||
|
base_url: str = ""
|
||||||
|
model: str
|
||||||
|
image_model: str = ""
|
||||||
|
timeout_sec: float = 120.0
|
||||||
|
max_output_tokens: int = 8192
|
||||||
|
max_retries: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class AIModelSwitchRequest(BaseModel):
|
||||||
|
model_id: int
|
||||||
|
|
||||||
|
|
||||||
|
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=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 ResetCodeRegenerateRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
|
||||||
|
|
||||||
|
class PosterGenerateRequest(BaseModel):
|
||||||
|
title: str = ""
|
||||||
|
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)
|
||||||
|
|
||||||
|
|
||||||
|
class PosterPreviewItem(BaseModel):
|
||||||
|
paragraph_index: int
|
||||||
|
paragraph_excerpt: str = ""
|
||||||
|
prompt: str = ""
|
||||||
|
preview_data_url: str
|
||||||
|
wechat_url: str = ""
|
||||||
|
uploaded: bool = False
|
||||||
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class PosterGenerateResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
detail: str
|
||||||
|
skipped_first_paragraph: bool = True
|
||||||
|
posters: list[PosterPreviewItem] = Field(default_factory=list)
|
||||||
|
body_markdown_with_posters: str = ""
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
class CoverGenerateResponse(BaseModel):
|
||||||
|
ok: bool
|
||||||
|
detail: str
|
||||||
|
preview_data_url: str = ""
|
||||||
|
thumb_media_id: str = ""
|
||||||
|
width: int = 900
|
||||||
|
height: int = 383
|
||||||
|
note: str = ""
|
||||||
|
warnings: list[str] = Field(default_factory=list)
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -3,20 +3,36 @@ from __future__ import annotations
|
|||||||
import hashlib
|
import hashlib
|
||||||
import hmac
|
import hmac
|
||||||
import base64
|
import base64
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote_plus
|
from urllib.parse import quote_plus
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.schemas import IMPublishRequest, PublishResponse
|
from app.schemas import IMPublishRequest, PublishResponse
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class IMPublisher:
|
class IMPublisher:
|
||||||
async def publish(self, req: IMPublishRequest) -> PublishResponse:
|
async def publish(self, req: IMPublishRequest, request_id: str = "") -> PublishResponse:
|
||||||
|
rid = request_id or "-"
|
||||||
if not settings.im_webhook_url:
|
if not settings.im_webhook_url:
|
||||||
|
logger.warning("im_skipped rid=%s reason=empty_webhook_url", rid)
|
||||||
return PublishResponse(ok=False, detail="缺少 IM_WEBHOOK_URL 配置")
|
return PublishResponse(ok=False, detail="缺少 IM_WEBHOOK_URL 配置")
|
||||||
|
|
||||||
|
parsed = urlparse(settings.im_webhook_url)
|
||||||
|
host = parsed.netloc or "(invalid_url)"
|
||||||
|
logger.info(
|
||||||
|
"im_publish_start rid=%s webhook_host=%s sign_enabled=%s title_chars=%d body_truncated_to=3800",
|
||||||
|
rid,
|
||||||
|
host,
|
||||||
|
bool(settings.im_secret),
|
||||||
|
len(req.title or ""),
|
||||||
|
)
|
||||||
|
|
||||||
webhook = self._with_signature(settings.im_webhook_url, settings.im_secret)
|
webhook = self._with_signature(settings.im_webhook_url, settings.im_secret)
|
||||||
payload = {
|
payload = {
|
||||||
"msg_type": "post",
|
"msg_type": "post",
|
||||||
@@ -31,15 +47,25 @@ class IMPublisher:
|
|||||||
}
|
}
|
||||||
|
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
|
logger.info("im_http_post rid=%s method=POST timeout_s=20", rid)
|
||||||
r = await client.post(webhook, json=payload)
|
r = await client.post(webhook, json=payload)
|
||||||
try:
|
try:
|
||||||
data = r.json()
|
data = r.json()
|
||||||
except Exception:
|
except Exception:
|
||||||
data = {"status_code": r.status_code, "text": r.text}
|
data = {"status_code": r.status_code, "text": r.text}
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"im_http_response rid=%s status=%s body_preview=%s",
|
||||||
|
rid,
|
||||||
|
r.status_code,
|
||||||
|
str(data)[:500],
|
||||||
|
)
|
||||||
|
|
||||||
if r.status_code >= 400:
|
if r.status_code >= 400:
|
||||||
|
logger.warning("im_push_failed rid=%s http_status=%s", rid, r.status_code)
|
||||||
return PublishResponse(ok=False, detail=f"IM 推送失败: {data}", data=data)
|
return PublishResponse(ok=False, detail=f"IM 推送失败: {data}", data=data)
|
||||||
|
|
||||||
|
logger.info("im_push_ok rid=%s", rid)
|
||||||
return PublishResponse(ok=True, detail="IM 推送成功", data=data)
|
return PublishResponse(ok=True, detail="IM 推送成功", data=data)
|
||||||
|
|
||||||
def _with_signature(self, webhook: str, secret: str | None) -> str:
|
def _with_signature(self, webhook: str, secret: str | None) -> str:
|
||||||
|
|||||||
473
app/services/poster_material.py
Normal file
473
app/services/poster_material.py
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from openai import OpenAI
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.schemas import (
|
||||||
|
CoverGenerateResponse,
|
||||||
|
PosterGenerateRequest,
|
||||||
|
PosterGenerateResponse,
|
||||||
|
PosterPreviewItem,
|
||||||
|
WechatCoverGenerateRequest,
|
||||||
|
)
|
||||||
|
from app.services.wechat import WechatPublisher
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_FONT_CANDIDATES = [
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_paragraphs(body_markdown: str) -> list[str]:
|
||||||
|
raw = (body_markdown or "").replace("\r\n", "\n").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [p.strip() for p in re.split(r"\n\s*\n+", raw) if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_font(size: int) -> ImageFont.ImageFont:
|
||||||
|
for p in _FONT_CANDIDATES:
|
||||||
|
if Path(p).is_file():
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(p, size=size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_jpeg_under_limit(content: bytes, max_bytes: int) -> bytes:
|
||||||
|
im = Image.open(BytesIO(content)).convert("RGB")
|
||||||
|
widths = [1080, 1024, 960, 900, 840, 780, 720, 660]
|
||||||
|
qualities = [88, 82, 76, 70, 64, 58, 52]
|
||||||
|
|
||||||
|
for w in widths:
|
||||||
|
if im.width > w:
|
||||||
|
h = max(1, int(im.height * (w / im.width)))
|
||||||
|
cur = im.resize((w, h), Image.Resampling.LANCZOS)
|
||||||
|
else:
|
||||||
|
cur = im
|
||||||
|
for q in qualities:
|
||||||
|
buf = BytesIO()
|
||||||
|
cur.save(buf, format="JPEG", quality=q, optimize=True)
|
||||||
|
out = buf.getvalue()
|
||||||
|
if len(out) <= max_bytes:
|
||||||
|
return out
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
h = max(1, int(im.height * (640 / im.width)))
|
||||||
|
im.resize((640, h), Image.Resampling.LANCZOS).save(buf, format="JPEG", quality=48, optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_to_jpeg(
|
||||||
|
content: bytes,
|
||||||
|
max_bytes: int,
|
||||||
|
size: tuple[int, int] = (900, 383),
|
||||||
|
title: str = "",
|
||||||
|
summary: str = "",
|
||||||
|
overlay_title: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
im = Image.open(BytesIO(content)).convert("RGB")
|
||||||
|
target_w, target_h = size
|
||||||
|
src_ratio = im.width / max(1, im.height)
|
||||||
|
dst_ratio = target_w / target_h
|
||||||
|
if src_ratio > dst_ratio:
|
||||||
|
new_w = int(im.height * dst_ratio)
|
||||||
|
x0 = max(0, (im.width - new_w) // 2)
|
||||||
|
im = im.crop((x0, 0, x0 + new_w, im.height))
|
||||||
|
elif src_ratio < dst_ratio:
|
||||||
|
new_h = int(im.width / dst_ratio)
|
||||||
|
y0 = max(0, (im.height - new_h) // 2)
|
||||||
|
im = im.crop((0, y0, im.width, y0 + new_h))
|
||||||
|
im = im.resize(size, Image.Resampling.LANCZOS)
|
||||||
|
if overlay_title:
|
||||||
|
im = _draw_cover_text_overlay(im, title, summary)
|
||||||
|
|
||||||
|
for q in [92, 88, 84, 80, 76, 72, 68, 62]:
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="JPEG", quality=q, optimize=True, progressive=True)
|
||||||
|
out = buf.getvalue()
|
||||||
|
if len(out) <= max_bytes:
|
||||||
|
return out
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="JPEG", quality=58, optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_cover_text_overlay(im: Image.Image, title: str, summary: str) -> Image.Image:
|
||||||
|
im = im.convert("RGBA")
|
||||||
|
overlay = Image.new("RGBA", im.size, (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(overlay)
|
||||||
|
draw.rounded_rectangle([42, 46, 572, 337], radius=30, fill=(255, 255, 255, 228))
|
||||||
|
draw.rounded_rectangle([70, 74, 222, 119], radius=18, fill=(31, 111, 91, 245))
|
||||||
|
|
||||||
|
tag_font = _pick_font(26)
|
||||||
|
title_font = _pick_font(46)
|
||||||
|
summary_font = _pick_font(24)
|
||||||
|
draw.text((94, 83), "公众号封面", font=tag_font, fill=(255, 255, 255, 255))
|
||||||
|
|
||||||
|
clean_title = re.sub(r"\s+", "", title or "公众号文章")
|
||||||
|
title_lines = textwrap.wrap(clean_title, width=12)[:2]
|
||||||
|
y = 146
|
||||||
|
for line in title_lines:
|
||||||
|
draw.text((72, y), line, font=title_font, fill=(23, 32, 51, 255))
|
||||||
|
y += 56
|
||||||
|
|
||||||
|
clean_summary = re.sub(r"\s+", "", summary or "一眼看懂主题,明确文章价值。")
|
||||||
|
summary_lines = textwrap.wrap(clean_summary, width=23)[:2]
|
||||||
|
sy = max(y + 10, 270)
|
||||||
|
for line in summary_lines:
|
||||||
|
draw.text((74, sy), line, font=summary_font, fill=(104, 115, 133, 255))
|
||||||
|
sy += 30
|
||||||
|
|
||||||
|
return Image.alpha_composite(im, overlay).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
class PosterMaterialService:
|
||||||
|
def __init__(self, wechat: WechatPublisher) -> None:
|
||||||
|
self._wechat = wechat
|
||||||
|
self._image_client = None
|
||||||
|
if settings.openai_api_key:
|
||||||
|
self._image_client = OpenAI(
|
||||||
|
api_key=settings.openai_api_key,
|
||||||
|
base_url=settings.openai_base_url,
|
||||||
|
timeout=settings.openai_timeout,
|
||||||
|
max_retries=max(0, int(settings.openai_max_retries)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
req: PosterGenerateRequest,
|
||||||
|
request_id: str = "",
|
||||||
|
account: dict | None = None,
|
||||||
|
) -> PosterGenerateResponse:
|
||||||
|
rid = request_id or "-"
|
||||||
|
paragraphs = _split_paragraphs(req.body_markdown)
|
||||||
|
if len(paragraphs) <= 1:
|
||||||
|
return PosterGenerateResponse(
|
||||||
|
ok=True,
|
||||||
|
detail="正文不足两段:按规则首段不生成图片,因此无需海报。",
|
||||||
|
posters=[],
|
||||||
|
body_markdown_with_posters=req.body_markdown,
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
max_images = max(1, min(int(req.max_images or settings.poster_max_images), 12))
|
||||||
|
posters: list[PosterPreviewItem] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
wechat_urls_by_para: dict[int, str] = {}
|
||||||
|
|
||||||
|
for idx, paragraph in enumerate(paragraphs):
|
||||||
|
if idx == 0:
|
||||||
|
continue
|
||||||
|
if len(posters) >= max_images:
|
||||||
|
break
|
||||||
|
|
||||||
|
prompt = self._build_prompt(req, paragraph, idx, len(paragraphs))
|
||||||
|
jpeg_bytes, note = await asyncio.to_thread(self._create_poster_jpeg, prompt, paragraph, idx)
|
||||||
|
preview_data_url = "data:image/jpeg;base64," + base64.b64encode(jpeg_bytes).decode("ascii")
|
||||||
|
|
||||||
|
wechat_url = ""
|
||||||
|
uploaded = False
|
||||||
|
if req.upload_to_wechat:
|
||||||
|
if not account:
|
||||||
|
warnings.append("未绑定公众号:已生成海报预览,但未上传微信素材 URL。")
|
||||||
|
else:
|
||||||
|
filename = f"poster_p{idx + 1}.jpg"
|
||||||
|
out = await self._wechat.upload_article_image(
|
||||||
|
filename,
|
||||||
|
jpeg_bytes,
|
||||||
|
request_id=rid,
|
||||||
|
account=account,
|
||||||
|
)
|
||||||
|
if out.ok:
|
||||||
|
wechat_url = ((out.data or {}).get("url") or "").strip()
|
||||||
|
uploaded = bool(wechat_url)
|
||||||
|
if uploaded:
|
||||||
|
wechat_urls_by_para[idx] = wechat_url
|
||||||
|
else:
|
||||||
|
warnings.append(f"第 {idx + 1} 段海报上传失败:{out.detail}")
|
||||||
|
|
||||||
|
posters.append(
|
||||||
|
PosterPreviewItem(
|
||||||
|
paragraph_index=idx,
|
||||||
|
paragraph_excerpt=textwrap.shorten(paragraph.replace("\n", " "), width=80, placeholder="…"),
|
||||||
|
prompt=prompt,
|
||||||
|
preview_data_url=preview_data_url,
|
||||||
|
wechat_url=wechat_url,
|
||||||
|
uploaded=uploaded,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = req.body_markdown
|
||||||
|
if wechat_urls_by_para:
|
||||||
|
merged = self._merge_body_with_posters(paragraphs, wechat_urls_by_para)
|
||||||
|
detail = f"已生成 {len(posters)} 张段落海报(首段跳过)"
|
||||||
|
if req.upload_to_wechat:
|
||||||
|
detail += f",成功上传 {sum(1 for p in posters if p.uploaded)} 张"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"poster_generate rid=%s posters=%d upload_to_wechat=%s uploaded=%d warnings=%d",
|
||||||
|
rid,
|
||||||
|
len(posters),
|
||||||
|
req.upload_to_wechat,
|
||||||
|
sum(1 for p in posters if p.uploaded),
|
||||||
|
len(warnings),
|
||||||
|
)
|
||||||
|
return PosterGenerateResponse(
|
||||||
|
ok=True,
|
||||||
|
detail=detail,
|
||||||
|
posters=posters,
|
||||||
|
body_markdown_with_posters=merged,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_cover(
|
||||||
|
self,
|
||||||
|
req: WechatCoverGenerateRequest,
|
||||||
|
request_id: str = "",
|
||||||
|
account: dict | None = None,
|
||||||
|
) -> CoverGenerateResponse:
|
||||||
|
rid = request_id or "-"
|
||||||
|
title = (req.title or "").strip()
|
||||||
|
summary = (req.summary or "").strip()
|
||||||
|
if not title:
|
||||||
|
return CoverGenerateResponse(ok=False, detail="请先填写标题,或先完成改写生成标题")
|
||||||
|
|
||||||
|
prompt = self._build_cover_prompt(req)
|
||||||
|
jpeg_bytes, note = await asyncio.to_thread(self._create_cover_jpeg, prompt, title, summary)
|
||||||
|
preview_data_url = "data:image/jpeg;base64," + base64.b64encode(jpeg_bytes).decode("ascii")
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
thumb_media_id = ""
|
||||||
|
if req.upload_to_wechat:
|
||||||
|
if not account:
|
||||||
|
warnings.append("未绑定公众号:已生成封面预览,但未上传为微信封面素材。")
|
||||||
|
else:
|
||||||
|
out = await self._wechat.upload_cover("wechat_cover_900x383.jpg", jpeg_bytes, request_id=rid, account=account)
|
||||||
|
if out.ok:
|
||||||
|
thumb_media_id = ((out.data or {}).get("thumb_media_id") or "").strip()
|
||||||
|
else:
|
||||||
|
warnings.append(f"封面上传失败:{out.detail}")
|
||||||
|
|
||||||
|
detail = "已生成公众号封面(900×383)"
|
||||||
|
if thumb_media_id:
|
||||||
|
detail += ",并已绑定 thumb_media_id"
|
||||||
|
elif warnings:
|
||||||
|
detail += ",但未完成微信绑定"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"cover_generate rid=%s title_chars=%d upload_to_wechat=%s uploaded=%s note=%s warnings=%d",
|
||||||
|
rid,
|
||||||
|
len(title),
|
||||||
|
req.upload_to_wechat,
|
||||||
|
bool(thumb_media_id),
|
||||||
|
note,
|
||||||
|
len(warnings),
|
||||||
|
)
|
||||||
|
return CoverGenerateResponse(
|
||||||
|
ok=True,
|
||||||
|
detail=detail,
|
||||||
|
preview_data_url=preview_data_url,
|
||||||
|
thumb_media_id=thumb_media_id,
|
||||||
|
width=900,
|
||||||
|
height=383,
|
||||||
|
note=note,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_cover_prompt(self, req: WechatCoverGenerateRequest) -> str:
|
||||||
|
title = (req.title or "公众号文章").strip()
|
||||||
|
summary = (req.summary or "").strip()
|
||||||
|
style_hint = (req.style_hint or "").strip() or "成熟公众号封面,清晰、克制、信息强,适合作为文章列表首图"
|
||||||
|
return (
|
||||||
|
"生成一张微信公众号文章封面图,最终会裁切为 900x383 横版比例。"
|
||||||
|
f"封面主标题:{title}。"
|
||||||
|
f"文章摘要:{summary}。"
|
||||||
|
f"风格要求:{style_hint}。"
|
||||||
|
"画面要突出封面的点击引导作用:主题明确、视觉焦点强、留出标题安全区、中文字少且清晰。"
|
||||||
|
"不要出现二维码、水印、品牌 logo、真人肖像、杂乱小字和侵权素材。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_prompt(self, req: PosterGenerateRequest, paragraph: str, idx: int, total: int) -> str:
|
||||||
|
title = (req.title or "公众号内容").strip()
|
||||||
|
summary = (req.summary or "").strip()
|
||||||
|
style_hint = (req.style_hint or "").strip() or "现代、干净、中文可读、公众号海报风格"
|
||||||
|
para = paragraph.strip()
|
||||||
|
return (
|
||||||
|
"请生成一张中文竖版海报,适合公众号正文插图。"
|
||||||
|
f"主题标题:{title}。"
|
||||||
|
f"这是第 {idx + 1}/{total} 段对应海报(首段不配图)。"
|
||||||
|
f"段落核心内容:{para}。"
|
||||||
|
f"摘要参考:{summary}。"
|
||||||
|
f"风格要求:{style_hint}。"
|
||||||
|
"画面需信息聚焦、可读性强,不要出现水印、二维码、logo、真人肖像。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_poster_jpeg(self, prompt: str, paragraph: str, idx: int) -> tuple[bytes, str]:
|
||||||
|
max_bytes = max(300_000, int(settings.poster_upload_max_bytes or 950_000))
|
||||||
|
|
||||||
|
if self._image_client:
|
||||||
|
try:
|
||||||
|
raw = self._generate_with_model(prompt)
|
||||||
|
if raw:
|
||||||
|
return _to_jpeg_under_limit(raw, max_bytes), "ai"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("poster_ai_failed detail=%s", str(exc)[:240])
|
||||||
|
|
||||||
|
fallback = self._generate_fallback_poster(paragraph, idx)
|
||||||
|
return _to_jpeg_under_limit(fallback, max_bytes), "fallback"
|
||||||
|
|
||||||
|
def _create_cover_jpeg(self, prompt: str, title: str, summary: str) -> tuple[bytes, str]:
|
||||||
|
max_bytes = max(300_000, int(settings.poster_upload_max_bytes or 950_000))
|
||||||
|
|
||||||
|
if self._image_client:
|
||||||
|
try:
|
||||||
|
raw = self._generate_with_model(prompt)
|
||||||
|
if raw:
|
||||||
|
return _cover_to_jpeg(raw, max_bytes, title=title, summary=summary, overlay_title=True), "ai_900x383"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("cover_ai_failed detail=%s", str(exc)[:240])
|
||||||
|
|
||||||
|
fallback = self._generate_fallback_cover(title, summary)
|
||||||
|
return _cover_to_jpeg(fallback, max_bytes), "fallback_900x383"
|
||||||
|
|
||||||
|
def _generate_with_model(self, prompt: str) -> bytes | None:
|
||||||
|
rsp = self._image_client.images.generate(
|
||||||
|
model=settings.openai_image_model,
|
||||||
|
prompt=prompt,
|
||||||
|
size=settings.poster_image_size,
|
||||||
|
)
|
||||||
|
data = getattr(rsp, "data", None) or []
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first = data[0]
|
||||||
|
b64 = ""
|
||||||
|
image_url = ""
|
||||||
|
if isinstance(first, dict):
|
||||||
|
b64 = (first.get("b64_json") or "").strip()
|
||||||
|
image_url = (first.get("url") or "").strip()
|
||||||
|
else:
|
||||||
|
b64 = (getattr(first, "b64_json", "") or "").strip()
|
||||||
|
image_url = (getattr(first, "url", "") or "").strip()
|
||||||
|
|
||||||
|
if b64:
|
||||||
|
return base64.b64decode(b64)
|
||||||
|
if image_url:
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
r = client.get(image_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _generate_fallback_poster(self, paragraph: str, idx: int) -> bytes:
|
||||||
|
w, h = 1080, 1520
|
||||||
|
im = Image.new("RGB", (w, h), (240, 246, 255))
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
c = int(240 - (y / h) * 36)
|
||||||
|
draw.line([(0, y), (w, y)], fill=(c, c + 6, 255), width=1)
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
x0 = int(w * 0.08) + i * 54
|
||||||
|
y0 = int(h * 0.66) + i * 22
|
||||||
|
x1 = x0 + 260
|
||||||
|
y1 = y0 + 100
|
||||||
|
color = (160 - i * 8, 190 - i * 9, 230 - i * 8)
|
||||||
|
draw.rounded_rectangle([x0, y0, x1, y1], radius=24, outline=color, width=2)
|
||||||
|
|
||||||
|
tag_font = _pick_font(36)
|
||||||
|
title_font = _pick_font(58)
|
||||||
|
body_font = _pick_font(42)
|
||||||
|
|
||||||
|
draw.rounded_rectangle([70, 70, 340, 142], radius=20, fill=(31, 77, 185))
|
||||||
|
draw.text((102, 90), f"段落 {idx + 1}", font=tag_font, fill=(255, 255, 255))
|
||||||
|
draw.text((70, 190), "AI 图文海报", font=title_font, fill=(16, 42, 102))
|
||||||
|
|
||||||
|
words = re.sub(r"\s+", "", paragraph)
|
||||||
|
if len(words) > 120:
|
||||||
|
words = words[:120] + "…"
|
||||||
|
wrapped = textwrap.fill(words, width=19)
|
||||||
|
draw.multiline_text(
|
||||||
|
(72, 330),
|
||||||
|
wrapped,
|
||||||
|
font=body_font,
|
||||||
|
fill=(35, 54, 92),
|
||||||
|
spacing=14,
|
||||||
|
align="left",
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def _generate_fallback_cover(self, title: str, summary: str) -> bytes:
|
||||||
|
w, h = 900, 383
|
||||||
|
im = Image.new("RGB", (w, h), (247, 249, 252))
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
t = y / h
|
||||||
|
r = int(252 - t * 28)
|
||||||
|
g = int(250 - t * 18)
|
||||||
|
b = int(241 - t * 8)
|
||||||
|
draw.line([(0, y), (w, y)], fill=(r, g, b), width=1)
|
||||||
|
|
||||||
|
draw.rounded_rectangle([36, 34, 864, 349], radius=34, fill=(255, 255, 255), outline=(223, 229, 238), width=2)
|
||||||
|
draw.rounded_rectangle([604, 60, 830, 290], radius=34, fill=(238, 244, 241))
|
||||||
|
draw.ellipse([660, 95, 810, 245], fill=(229, 196, 122))
|
||||||
|
draw.ellipse([690, 126, 780, 216], fill=(255, 250, 229))
|
||||||
|
draw.arc([684, 118, 784, 226], start=20, end=168, fill=(199, 159, 81), width=5)
|
||||||
|
|
||||||
|
tag_font = _pick_font(28)
|
||||||
|
title_font = _pick_font(48)
|
||||||
|
summary_font = _pick_font(24)
|
||||||
|
small_font = _pick_font(20)
|
||||||
|
|
||||||
|
draw.rounded_rectangle([72, 70, 226, 118], radius=18, fill=(31, 111, 91))
|
||||||
|
draw.text((96, 80), "公众号封面", font=tag_font, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
clean_title = re.sub(r"\s+", "", title or "公众号文章")
|
||||||
|
title_lines = textwrap.wrap(clean_title, width=12)[:2]
|
||||||
|
y = 146
|
||||||
|
for line in title_lines:
|
||||||
|
draw.text((72, y), line, font=title_font, fill=(23, 32, 51))
|
||||||
|
y += 58
|
||||||
|
|
||||||
|
clean_summary = re.sub(r"\s+", "", summary or "清晰表达主题,让读者一眼知道文章价值。")
|
||||||
|
summary_lines = textwrap.wrap(clean_summary, width=24)[:2]
|
||||||
|
sy = max(y + 12, 268)
|
||||||
|
for line in summary_lines:
|
||||||
|
draw.text((74, sy), line, font=summary_font, fill=(104, 115, 133))
|
||||||
|
sy += 32
|
||||||
|
|
||||||
|
draw.text((72, 320), "900 x 383", font=small_font, fill=(140, 150, 166))
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def _merge_body_with_posters(self, paragraphs: list[str], wechat_urls_by_para: dict[int, str]) -> str:
|
||||||
|
merged: list[str] = []
|
||||||
|
for idx, para in enumerate(paragraphs):
|
||||||
|
if idx > 0:
|
||||||
|
url = (wechat_urls_by_para.get(idx) or "").strip()
|
||||||
|
if url:
|
||||||
|
merged.append(f"")
|
||||||
|
merged.append(para)
|
||||||
|
return "\n\n".join(merged)
|
||||||
1535
app/services/user_store.py
Normal file
1535
app/services/user_store.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,9 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
import time
|
import time
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
import httpx
|
import httpx
|
||||||
import markdown2
|
import markdown2
|
||||||
@@ -8,65 +11,375 @@ import markdown2
|
|||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.schemas import PublishResponse, WechatPublishRequest
|
from app.schemas import PublishResponse, WechatPublishRequest
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_default_cover_jpeg_bytes: bytes | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def _build_default_cover_jpeg() -> tuple[bytes, str]:
|
||||||
|
"""生成简单灰底封面(360×200),满足微信对缩略图尺寸的常规要求。"""
|
||||||
|
global _default_cover_jpeg_bytes
|
||||||
|
if _default_cover_jpeg_bytes is None:
|
||||||
|
from PIL import Image
|
||||||
|
|
||||||
|
im = Image.new("RGB", (360, 200), (236, 240, 241))
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="JPEG", quality=88)
|
||||||
|
_default_cover_jpeg_bytes = buf.getvalue()
|
||||||
|
return _default_cover_jpeg_bytes, "cover_default.jpg"
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_for_token_error(data: dict | None) -> str:
|
||||||
|
"""把微信返回的 errcode 转成可操作的说明。"""
|
||||||
|
if not data:
|
||||||
|
return "获取微信 access_token 失败(无返回内容)"
|
||||||
|
code = data.get("errcode")
|
||||||
|
msg = (data.get("errmsg") or "").strip()
|
||||||
|
if code == 40164:
|
||||||
|
return (
|
||||||
|
"微信 errcode=40164:当前请求使用的出口 IP 未在公众号「IP 白名单」中。"
|
||||||
|
"请到 微信公众平台 → 设置与开发 → 基本配置 → IP 白名单,添加本服务对外的公网 IP"
|
||||||
|
"(日志里 invalid ip 后面的地址)。若在本地/Docker 调试,出口 IP 常会变,需填当前出口或改用固定出口的服务器。"
|
||||||
|
f" 微信原文:{msg}"
|
||||||
|
)
|
||||||
|
if code == 40013:
|
||||||
|
return f"微信 errcode=40013:AppSecret 无效或已重置,请检查 WECHAT_SECRET。{msg}"
|
||||||
|
if code == 40125:
|
||||||
|
return f"微信 errcode=40125:AppSecret 配置错误。{msg}"
|
||||||
|
return f"获取微信 access_token 失败:errcode={code} errmsg={msg}"
|
||||||
|
|
||||||
|
|
||||||
|
def _detail_for_draft_error(data: dict) -> str:
|
||||||
|
code = data.get("errcode")
|
||||||
|
msg = (data.get("errmsg") or "").strip()
|
||||||
|
if code == 40007:
|
||||||
|
return (
|
||||||
|
"微信 errcode=40007(invalid media_id):thumb_media_id 缺失、不是「永久图片素材」、或已失效。"
|
||||||
|
"请核对 WECHAT_THUMB_MEDIA_ID 是否从素材管理里复制的永久素材;若不确定,可删掉该变量,"
|
||||||
|
"由服务自动上传封面(WECHAT_THUMB_IMAGE_PATH 或内置默认图)。"
|
||||||
|
f" 微信原文:{msg}"
|
||||||
|
)
|
||||||
|
return f"微信草稿失败:errcode={code} errmsg={msg}"
|
||||||
|
|
||||||
|
|
||||||
class WechatPublisher:
|
class WechatPublisher:
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self._access_token = None
|
self._token_cache: dict[str, dict[str, int | str]] = {}
|
||||||
self._expires_at = 0
|
self._runtime_thumb_media_id: str | None = None
|
||||||
|
|
||||||
async def publish_draft(self, req: WechatPublishRequest) -> PublishResponse:
|
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
|
||||||
if not settings.wechat_appid or not settings.wechat_secret:
|
src = account or {}
|
||||||
|
appid = (src.get("appid") or "").strip()
|
||||||
|
secret = (src.get("secret") or "").strip()
|
||||||
|
author = (src.get("author") or "").strip()
|
||||||
|
thumb_media_id = (src.get("thumb_media_id") or "").strip()
|
||||||
|
thumb_image_path = (src.get("thumb_image_path") or "").strip()
|
||||||
|
return {
|
||||||
|
"appid": appid,
|
||||||
|
"secret": secret,
|
||||||
|
"author": author,
|
||||||
|
"thumb_media_id": thumb_media_id,
|
||||||
|
"thumb_image_path": thumb_image_path,
|
||||||
|
}
|
||||||
|
|
||||||
|
async def publish_draft(
|
||||||
|
self, req: WechatPublishRequest, request_id: str = "", account: dict | None = None
|
||||||
|
) -> PublishResponse:
|
||||||
|
rid = request_id or "-"
|
||||||
|
acct = self._resolve_account(account)
|
||||||
|
if not acct["appid"] or not acct["secret"]:
|
||||||
|
logger.warning("wechat skipped rid=%s reason=missing_appid_or_secret", rid)
|
||||||
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
||||||
|
|
||||||
token = await self._get_access_token()
|
token, token_from_cache, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
|
||||||
if not token:
|
if not token:
|
||||||
return PublishResponse(ok=False, detail="获取微信 access_token 失败")
|
detail = _detail_for_token_error(token_err_body)
|
||||||
|
logger.error("wechat access_token_unavailable rid=%s detail=%s", rid, detail[:200])
|
||||||
|
return PublishResponse(ok=False, detail=detail, data=token_err_body)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"wechat_token rid=%s cache_hit=%s",
|
||||||
|
rid,
|
||||||
|
token_from_cache,
|
||||||
|
)
|
||||||
|
|
||||||
|
req_thumb = (req.thumb_media_id or "").strip()
|
||||||
|
if req_thumb:
|
||||||
|
logger.info("wechat_thumb rid=%s source=request_media_id", rid)
|
||||||
|
thumb_id = req_thumb
|
||||||
|
else:
|
||||||
|
thumb_id = await self._resolve_thumb_media_id(token, rid, account=acct)
|
||||||
|
if not thumb_id:
|
||||||
|
return PublishResponse(
|
||||||
|
ok=False,
|
||||||
|
detail=(
|
||||||
|
"无法上传封面素材(material/add_material 失败)。"
|
||||||
|
"请检查公众号是否开通素材接口权限,或手动在素材库上传后配置 WECHAT_THUMB_MEDIA_ID。"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
html = markdown2.markdown(req.body_markdown)
|
html = markdown2.markdown(req.body_markdown)
|
||||||
|
logger.info(
|
||||||
|
"wechat_draft_build rid=%s title_chars=%d digest_chars=%d html_chars=%d",
|
||||||
|
rid,
|
||||||
|
len(req.title or ""),
|
||||||
|
len(req.summary or ""),
|
||||||
|
len(html or ""),
|
||||||
|
)
|
||||||
|
# 图文 news:thumb_media_id 为必填(永久素材),否则 errcode=40007
|
||||||
payload = {
|
payload = {
|
||||||
"articles": [
|
"articles": [
|
||||||
{
|
{
|
||||||
"title": req.title,
|
"article_type": "news",
|
||||||
"author": req.author or settings.wechat_author,
|
"title": req.title[:32] if len(req.title) > 32 else req.title,
|
||||||
"digest": req.summary,
|
"author": (req.author or acct["author"] or "AI发糕")[:16],
|
||||||
|
"digest": (req.summary or "")[:128],
|
||||||
"content": html,
|
"content": html,
|
||||||
"content_source_url": "",
|
"content_source_url": "",
|
||||||
|
"thumb_media_id": thumb_id,
|
||||||
"need_open_comment": 0,
|
"need_open_comment": 0,
|
||||||
"only_fans_can_comment": 0,
|
"only_fans_can_comment": 0,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
explicit_used = bool((acct.get("thumb_media_id") or "").strip())
|
||||||
|
draft_url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
|
||||||
async with httpx.AsyncClient(timeout=25) as client:
|
async with httpx.AsyncClient(timeout=25) as client:
|
||||||
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
|
logger.info(
|
||||||
r = await client.post(url, json=payload)
|
"wechat_http_post rid=%s endpoint=cgi-bin/draft/add http_timeout_s=25",
|
||||||
|
rid,
|
||||||
|
)
|
||||||
|
r = await client.post(draft_url, json=payload)
|
||||||
|
data = r.json()
|
||||||
|
|
||||||
|
if data.get("errcode") == 40007 and explicit_used:
|
||||||
|
logger.warning(
|
||||||
|
"wechat_draft_40007_retry rid=%s hint=config_media_id_invalid_try_auto_upload",
|
||||||
|
rid,
|
||||||
|
)
|
||||||
|
self._runtime_thumb_media_id = None
|
||||||
|
thumb_alt = await self._resolve_thumb_media_id(
|
||||||
|
token, rid, force_skip_explicit=True, account=acct
|
||||||
|
)
|
||||||
|
if thumb_alt:
|
||||||
|
payload["articles"][0]["thumb_media_id"] = thumb_alt
|
||||||
|
async with httpx.AsyncClient(timeout=25) as client:
|
||||||
|
r = await client.post(draft_url, json=payload)
|
||||||
data = r.json()
|
data = r.json()
|
||||||
|
|
||||||
if data.get("errcode", 0) != 0:
|
if data.get("errcode", 0) != 0:
|
||||||
return PublishResponse(ok=False, detail=f"微信发布失败: {data}", data=data)
|
logger.warning(
|
||||||
|
"wechat_draft_failed rid=%s errcode=%s errmsg=%s raw=%s",
|
||||||
|
rid,
|
||||||
|
data.get("errcode"),
|
||||||
|
data.get("errmsg"),
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
detail = _detail_for_draft_error(data) if isinstance(data, dict) else f"微信发布失败: {data}"
|
||||||
|
return PublishResponse(ok=False, detail=detail, data=data)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"wechat_draft_ok rid=%s media_id=%s",
|
||||||
|
rid,
|
||||||
|
data.get("media_id", data),
|
||||||
|
)
|
||||||
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
|
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
|
||||||
|
|
||||||
async def _get_access_token(self) -> str | None:
|
async def upload_cover(
|
||||||
now = int(time.time())
|
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
|
||||||
if self._access_token and now < self._expires_at - 60:
|
) -> PublishResponse:
|
||||||
return self._access_token
|
"""上传封面到微信永久素材,返回 thumb_media_id。"""
|
||||||
|
rid = request_id or "-"
|
||||||
|
acct = self._resolve_account(account)
|
||||||
|
if not acct["appid"] or not acct["secret"]:
|
||||||
|
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
||||||
|
if not content:
|
||||||
|
return PublishResponse(ok=False, detail="封面文件为空")
|
||||||
|
|
||||||
|
token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
|
||||||
|
if not token:
|
||||||
|
return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
material = await self._upload_permanent_image(client, token, content, filename)
|
||||||
|
if not material:
|
||||||
|
return PublishResponse(
|
||||||
|
ok=False,
|
||||||
|
detail="封面上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
mid = material["media_id"]
|
||||||
|
self._runtime_thumb_media_id = mid
|
||||||
|
logger.info("wechat_cover_upload_ok rid=%s filename=%s media_id=%s", rid, filename, mid)
|
||||||
|
return PublishResponse(ok=True, detail="封面上传成功", data={"thumb_media_id": mid, "filename": filename})
|
||||||
|
|
||||||
|
async def upload_body_material(
|
||||||
|
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
|
||||||
|
) -> PublishResponse:
|
||||||
|
"""上传正文图片到微信永久素材库,返回 media_id 与可插入正文的 URL。"""
|
||||||
|
rid = request_id or "-"
|
||||||
|
acct = self._resolve_account(account)
|
||||||
|
if not acct["appid"] or not acct["secret"]:
|
||||||
|
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
||||||
|
if not content:
|
||||||
|
return PublishResponse(ok=False, detail="素材文件为空")
|
||||||
|
|
||||||
|
token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
|
||||||
|
if not token:
|
||||||
|
return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
material = await self._upload_permanent_image(client, token, content, filename)
|
||||||
|
if not material:
|
||||||
|
return PublishResponse(
|
||||||
|
ok=False,
|
||||||
|
detail="素材上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"wechat_body_material_upload_ok rid=%s filename=%s media_id=%s url=%s",
|
||||||
|
rid,
|
||||||
|
filename,
|
||||||
|
material.get("media_id"),
|
||||||
|
material.get("url"),
|
||||||
|
)
|
||||||
|
return PublishResponse(ok=True, detail="素材上传成功", data=material)
|
||||||
|
|
||||||
|
async def upload_article_image(
|
||||||
|
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
|
||||||
|
) -> PublishResponse:
|
||||||
|
"""上传图文正文图片(uploadimg),返回可直接插入正文 HTML/Markdown 的 URL。"""
|
||||||
|
rid = request_id or "-"
|
||||||
|
acct = self._resolve_account(account)
|
||||||
|
if not acct["appid"] or not acct["secret"]:
|
||||||
|
return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置")
|
||||||
|
if not content:
|
||||||
|
return PublishResponse(ok=False, detail="素材文件为空")
|
||||||
|
|
||||||
|
token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
|
||||||
|
if not token:
|
||||||
|
return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body)
|
||||||
|
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
out = await self._upload_article_image_url(client, token, content, filename)
|
||||||
|
if not out:
|
||||||
|
return PublishResponse(
|
||||||
|
ok=False,
|
||||||
|
detail="正文配图上传失败:请检查图片格式与大小(jpg/png,建议小于 1MB),或查看日志 wechat_uploadimg_failed",
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"wechat_uploadimg_ok rid=%s filename=%s url=%s",
|
||||||
|
rid,
|
||||||
|
filename,
|
||||||
|
out.get("url"),
|
||||||
|
)
|
||||||
|
return PublishResponse(ok=True, detail="正文配图上传成功", data=out)
|
||||||
|
|
||||||
|
async def _upload_permanent_image(
|
||||||
|
self, client: httpx.AsyncClient, token: str, content: bytes, filename: str
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image"
|
||||||
|
ctype = "image/png" if filename.lower().endswith(".png") else "image/jpeg"
|
||||||
|
files = {"media": (filename, content, ctype)}
|
||||||
|
r = await client.post(url, files=files)
|
||||||
|
data = r.json()
|
||||||
|
if data.get("errcode"):
|
||||||
|
logger.warning("wechat_material_add_failed body=%s", data)
|
||||||
|
return None
|
||||||
|
mid = data.get("media_id")
|
||||||
|
if not mid:
|
||||||
|
logger.warning("wechat_material_add_no_media_id body=%s", data)
|
||||||
|
return None
|
||||||
|
return {"media_id": mid, "url": data.get("url") or ""}
|
||||||
|
|
||||||
|
async def _upload_article_image_url(
|
||||||
|
self, client: httpx.AsyncClient, token: str, content: bytes, filename: str
|
||||||
|
) -> dict[str, str] | None:
|
||||||
|
url = f"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token={token}"
|
||||||
|
ctype = "image/png" if filename.lower().endswith(".png") else "image/jpeg"
|
||||||
|
files = {"media": (filename, content, ctype)}
|
||||||
|
r = await client.post(url, files=files)
|
||||||
|
data = r.json() if r.content else {}
|
||||||
|
if isinstance(data, dict) and data.get("errcode"):
|
||||||
|
logger.warning("wechat_uploadimg_failed body=%s", data)
|
||||||
|
return None
|
||||||
|
image_url = (data.get("url") if isinstance(data, dict) else "") or ""
|
||||||
|
if not image_url:
|
||||||
|
logger.warning("wechat_uploadimg_no_url body=%s", data)
|
||||||
|
return None
|
||||||
|
return {"url": image_url}
|
||||||
|
|
||||||
|
async def _resolve_thumb_media_id(
|
||||||
|
self, token: str, rid: str, *, force_skip_explicit: bool = False, account: dict | None = None
|
||||||
|
) -> str | None:
|
||||||
|
"""draft/add 要求 thumb_media_id 为永久图片素材;优先用配置,否则上传文件或内置图。"""
|
||||||
|
acct = self._resolve_account(account)
|
||||||
|
explicit = (acct.get("thumb_media_id") or "").strip()
|
||||||
|
if explicit and not force_skip_explicit:
|
||||||
|
logger.info("wechat_thumb rid=%s source=config_media_id", rid)
|
||||||
|
return explicit
|
||||||
|
|
||||||
|
if self._runtime_thumb_media_id and not force_skip_explicit:
|
||||||
|
logger.info("wechat_thumb rid=%s source=runtime_cache", rid)
|
||||||
|
return self._runtime_thumb_media_id
|
||||||
|
|
||||||
|
path = (acct.get("thumb_image_path") or "").strip()
|
||||||
|
async with httpx.AsyncClient(timeout=60) as client:
|
||||||
|
if path:
|
||||||
|
p = Path(path)
|
||||||
|
if p.is_file():
|
||||||
|
content = p.read_bytes()
|
||||||
|
material = await self._upload_permanent_image(client, token, content, p.name)
|
||||||
|
if material:
|
||||||
|
self._runtime_thumb_media_id = material["media_id"]
|
||||||
|
logger.info("wechat_thumb rid=%s source=path_upload ok=1", rid)
|
||||||
|
return material["media_id"]
|
||||||
|
logger.warning("wechat_thumb rid=%s source=path_upload ok=0 path=%s", rid, path)
|
||||||
|
else:
|
||||||
|
logger.warning("wechat_thumb rid=%s path_not_found=%s", rid, path)
|
||||||
|
|
||||||
|
content, fname = _build_default_cover_jpeg()
|
||||||
|
material = await self._upload_permanent_image(client, token, content, fname)
|
||||||
|
if material:
|
||||||
|
self._runtime_thumb_media_id = material["media_id"]
|
||||||
|
logger.info("wechat_thumb rid=%s source=default_jpeg_upload ok=1", rid)
|
||||||
|
return material["media_id"]
|
||||||
|
logger.error("wechat_thumb rid=%s source=default_jpeg_upload ok=0", rid)
|
||||||
|
return None
|
||||||
|
|
||||||
|
async def _get_access_token(self, appid: str, secret: str) -> tuple[str | None, bool, dict | None]:
|
||||||
|
"""成功时第三项为 None;失败时为微信返回的 JSON(含 errcode/errmsg)。"""
|
||||||
|
now = int(time.time())
|
||||||
|
key = appid.strip()
|
||||||
|
cached = self._token_cache.get(key)
|
||||||
|
if cached:
|
||||||
|
token = str(cached.get("token") or "")
|
||||||
|
exp = int(cached.get("expires_at") or 0)
|
||||||
|
if token and now < exp - 60:
|
||||||
|
return token, True, None
|
||||||
|
|
||||||
|
logger.info("wechat_http_get endpoint=cgi-bin/token reason=refresh_access_token")
|
||||||
async with httpx.AsyncClient(timeout=20) as client:
|
async with httpx.AsyncClient(timeout=20) as client:
|
||||||
r = await client.get(
|
r = await client.get(
|
||||||
"https://api.weixin.qq.com/cgi-bin/token",
|
"https://api.weixin.qq.com/cgi-bin/token",
|
||||||
params={
|
params={
|
||||||
"grant_type": "client_credential",
|
"grant_type": "client_credential",
|
||||||
"appid": settings.wechat_appid,
|
"appid": appid,
|
||||||
"secret": settings.wechat_secret,
|
"secret": secret,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
data = r.json()
|
data = r.json() if r.content else {}
|
||||||
|
|
||||||
token = data.get("access_token")
|
token = data.get("access_token")
|
||||||
if not token:
|
if not token:
|
||||||
return None
|
logger.warning(
|
||||||
|
"wechat_token_refresh_failed http_status=%s body=%s",
|
||||||
|
r.status_code,
|
||||||
|
data,
|
||||||
|
)
|
||||||
|
return None, False, data if isinstance(data, dict) else None
|
||||||
|
|
||||||
self._access_token = token
|
self._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))}
|
||||||
self._expires_at = now + int(data.get("expires_in", 7200))
|
return token, False, None
|
||||||
return token
|
|
||||||
|
|||||||
248
app/static/admin.js
Normal file
248
app/static/admin.js
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
(() => {
|
||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
const token = new URLSearchParams(window.location.search).get("token") || "";
|
||||||
|
let currentOffset = 0;
|
||||||
|
let currentTotal = 0;
|
||||||
|
function fmtTime(ts) {
|
||||||
|
const n = Number(ts || 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");
|
||||||
|
const hh = String(d.getHours()).padStart(2, "0");
|
||||||
|
const mm = String(d.getMinutes()).padStart(2, "0");
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, "0");
|
||||||
|
return `${y}-${m}-${day} ${hh}:${mm}:${ss}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(msg, isError = false) {
|
||||||
|
const el = $("adminStatus");
|
||||||
|
if (!el) return;
|
||||||
|
el.textContent = msg || "";
|
||||||
|
el.style.color = isError ? "#b91c1c" : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getJSON(url) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
headers: {
|
||||||
|
"X-Admin-Token": token,
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
});
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url, payload) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
"X-Admin-Token": token,
|
||||||
|
},
|
||||||
|
credentials: "same-origin",
|
||||||
|
body: JSON.stringify(payload || {}),
|
||||||
|
});
|
||||||
|
let data = {};
|
||||||
|
try {
|
||||||
|
data = await res.json();
|
||||||
|
} catch (_) {
|
||||||
|
data = {};
|
||||||
|
}
|
||||||
|
if (!res.ok || data.ok === false) {
|
||||||
|
throw new Error(data.detail || `HTTP ${res.status}`);
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fillOptions(id, items) {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.innerHTML = "";
|
||||||
|
for (const it of items || []) {
|
||||||
|
const op = document.createElement("option");
|
||||||
|
op.value = String(it || "");
|
||||||
|
el.appendChild(op);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadPlatformModel() {
|
||||||
|
const data = await getJSON("/api/admin/platform-model");
|
||||||
|
const cfg = data.config || {};
|
||||||
|
if ($("platformApiKey")) $("platformApiKey").value = cfg.api_key || "";
|
||||||
|
if ($("platformBaseUrl")) $("platformBaseUrl").value = cfg.base_url || "";
|
||||||
|
if ($("platformTextModel")) $("platformTextModel").value = cfg.model || "";
|
||||||
|
if ($("platformImageModel")) $("platformImageModel").value = cfg.image_model || "";
|
||||||
|
if ($("platformTimeoutSec")) $("platformTimeoutSec").value = Number(cfg.timeout_sec || 120);
|
||||||
|
if ($("platformMaxOutputTokens")) $("platformMaxOutputTokens").value = Number(cfg.max_output_tokens || 8192);
|
||||||
|
fillOptions("platformTextModelOptions", data.text_model_options || []);
|
||||||
|
fillOptions("platformImageModelOptions", data.image_model_options || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadUserOverview() {
|
||||||
|
const data = await getJSON("/api/admin/users/overview?limit=30");
|
||||||
|
const stats = data.stats || {};
|
||||||
|
if ($("adminTotalUsers")) $("adminTotalUsers").value = String(stats.total_users || 0);
|
||||||
|
if ($("adminActiveUsers")) $("adminActiveUsers").value = String(stats.active_users || 0);
|
||||||
|
if ($("adminTodayUsers")) $("adminTodayUsers").value = String(stats.today_new_users || 0);
|
||||||
|
if ($("adminDeletedUsers")) $("adminDeletedUsers").value = String(stats.deleted_users || 0);
|
||||||
|
|
||||||
|
const tbody = $("adminRecentUsersRows");
|
||||||
|
if (!tbody) return;
|
||||||
|
const rows = Array.isArray(data.recent_users) ? data.recent_users : [];
|
||||||
|
if (!rows.length) {
|
||||||
|
tbody.innerHTML = '<tr><td colspan="4" class="muted">暂无记录</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
tbody.innerHTML = rows
|
||||||
|
.map((r) => {
|
||||||
|
const status = Number(r.deleted_at || 0) > 0 ? "已注销" : "正常";
|
||||||
|
return `<tr>
|
||||||
|
<td>${Number(r.id || 0)}</td>
|
||||||
|
<td>${String(r.username || "")}</td>
|
||||||
|
<td>${fmtTime(r.created_at)}</td>
|
||||||
|
<td>${status}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePlatformModel() {
|
||||||
|
await postJSON("/api/admin/platform-model", {
|
||||||
|
api_key: ($("platformApiKey")?.value || "").trim(),
|
||||||
|
base_url: ($("platformBaseUrl")?.value || "").trim(),
|
||||||
|
model: ($("platformTextModel")?.value || "").trim(),
|
||||||
|
image_model: ($("platformImageModel")?.value || "").trim(),
|
||||||
|
timeout_sec: Number($("platformTimeoutSec")?.value || 120),
|
||||||
|
max_output_tokens: Number($("platformMaxOutputTokens")?.value || 8192),
|
||||||
|
max_retries: 0,
|
||||||
|
});
|
||||||
|
setStatus("平台模型配置已保存");
|
||||||
|
if (window.uiAlert) window.uiAlert("平台模型配置已保存并立即生效");
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderTable(rows, columns) {
|
||||||
|
const head = $("adminHeadRow");
|
||||||
|
const body = $("adminBodyRows");
|
||||||
|
if (!head || !body) return;
|
||||||
|
head.innerHTML = "";
|
||||||
|
body.innerHTML = "";
|
||||||
|
if (!columns || !columns.length) {
|
||||||
|
head.innerHTML = "<th>无列信息</th>";
|
||||||
|
body.innerHTML = '<tr><td class="muted">当前页没有数据</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const col of columns) {
|
||||||
|
const th = document.createElement("th");
|
||||||
|
th.textContent = col;
|
||||||
|
head.appendChild(th);
|
||||||
|
}
|
||||||
|
if (!rows || !rows.length) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
const td = document.createElement("td");
|
||||||
|
td.colSpan = columns.length;
|
||||||
|
td.className = "muted";
|
||||||
|
td.textContent = "当前页没有数据";
|
||||||
|
tr.appendChild(td);
|
||||||
|
body.appendChild(tr);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const row of rows) {
|
||||||
|
const tr = document.createElement("tr");
|
||||||
|
for (const col of columns) {
|
||||||
|
const td = document.createElement("td");
|
||||||
|
const val = row[col];
|
||||||
|
td.textContent = val == null ? "" : typeof val === "object" ? JSON.stringify(val) : String(val);
|
||||||
|
tr.appendChild(td);
|
||||||
|
}
|
||||||
|
body.appendChild(tr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadTables() {
|
||||||
|
const data = await getJSON("/api/admin/tables");
|
||||||
|
const sel = $("adminTableSelect");
|
||||||
|
if (!sel) return;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
const tables = Array.isArray(data.tables) ? data.tables : [];
|
||||||
|
for (const t of tables) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = t;
|
||||||
|
opt.textContent = t;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
}
|
||||||
|
if (!tables.length) {
|
||||||
|
setStatus("数据库没有可展示的数据表");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await loadRows(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadRows(resetOffset = false) {
|
||||||
|
const sel = $("adminTableSelect");
|
||||||
|
const limitInput = $("adminLimit");
|
||||||
|
if (!sel || !limitInput) return;
|
||||||
|
const table = (sel.value || "").trim();
|
||||||
|
if (!table) return;
|
||||||
|
if (resetOffset) currentOffset = 0;
|
||||||
|
const limit = Math.max(1, Math.min(500, Number(limitInput.value) || 100));
|
||||||
|
const data = await getJSON(
|
||||||
|
`/api/admin/table/${encodeURIComponent(table)}?limit=${limit}&offset=${currentOffset}`
|
||||||
|
);
|
||||||
|
if (!data.ok) {
|
||||||
|
throw new Error(data.detail || "加载失败");
|
||||||
|
}
|
||||||
|
currentTotal = Number(data.total || 0);
|
||||||
|
renderTable(data.rows || [], data.columns || []);
|
||||||
|
const start = currentOffset + 1;
|
||||||
|
const end = Math.min(currentOffset + limit, currentTotal);
|
||||||
|
setStatus(`表 ${table}:第 ${start}-${end > 0 ? end : 0} 条 / 共 ${currentTotal} 条`);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function safeRun(fn) {
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err && err.message ? err.message : "请求失败";
|
||||||
|
setStatus(msg, true);
|
||||||
|
if (window.uiAlert) window.uiAlert(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$("adminRefreshBtn")?.addEventListener("click", () => safeRun(() => loadRows(false)));
|
||||||
|
$("adminTableSelect")?.addEventListener("change", () => safeRun(() => loadRows(true)));
|
||||||
|
$("platformReloadBtn")?.addEventListener("click", () => safeRun(loadPlatformModel));
|
||||||
|
$("platformSaveBtn")?.addEventListener("click", () => safeRun(savePlatformModel));
|
||||||
|
$("adminUserRefreshBtn")?.addEventListener("click", () => safeRun(loadUserOverview));
|
||||||
|
$("adminPrevBtn")?.addEventListener("click", () =>
|
||||||
|
safeRun(async () => {
|
||||||
|
const limit = Math.max(1, Math.min(500, Number($("adminLimit")?.value) || 100));
|
||||||
|
currentOffset = Math.max(0, currentOffset - limit);
|
||||||
|
await loadRows(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
$("adminNextBtn")?.addEventListener("click", () =>
|
||||||
|
safeRun(async () => {
|
||||||
|
const limit = Math.max(1, Math.min(500, Number($("adminLimit")?.value) || 100));
|
||||||
|
if (currentOffset + limit < currentTotal) {
|
||||||
|
currentOffset += limit;
|
||||||
|
}
|
||||||
|
await loadRows(false);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
safeRun(async () => {
|
||||||
|
await loadUserOverview();
|
||||||
|
await loadPlatformModel();
|
||||||
|
await loadTables();
|
||||||
|
});
|
||||||
|
})();
|
||||||
@@ -1,12 +1,313 @@
|
|||||||
const $ = (id) => document.getElementById(id);
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function renderBodyPreview() {
|
||||||
|
const raw = ($("body") && $("body").value) || "";
|
||||||
|
const el = $("bodyPreview");
|
||||||
|
if (!el) return;
|
||||||
|
if (typeof marked !== "undefined" && marked.parse) {
|
||||||
|
el.innerHTML = marked.parse(raw, { breaks: true });
|
||||||
|
} else {
|
||||||
|
el.textContent = raw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const statusEl = $("status");
|
const statusEl = $("status");
|
||||||
|
const rewriteBtn = $("rewriteBtn");
|
||||||
|
const wechatBtn = $("wechatBtn");
|
||||||
|
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");
|
||||||
|
const coverAiSection = $("coverAiSection");
|
||||||
|
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: "",
|
||||||
|
bodyMarkdownWithPosters: "",
|
||||||
|
posters: [],
|
||||||
|
};
|
||||||
|
let coverMode = "manual";
|
||||||
|
|
||||||
|
function setCoverMode(mode) {
|
||||||
|
coverMode = mode === "ai" ? "ai" : "manual";
|
||||||
|
if (coverModeManualBtn) coverModeManualBtn.classList.toggle("is-active", coverMode === "manual");
|
||||||
|
if (coverModeAiBtn) coverModeAiBtn.classList.toggle("is-active", coverMode === "ai");
|
||||||
|
if (coverManualSection) {
|
||||||
|
const hideManual = coverMode !== "manual";
|
||||||
|
coverManualSection.hidden = hideManual;
|
||||||
|
coverManualSection.style.display = hideManual ? "none" : "";
|
||||||
|
}
|
||||||
|
if (coverAiSection) {
|
||||||
|
const hideAi = coverMode !== "ai";
|
||||||
|
coverAiSection.hidden = hideAi;
|
||||||
|
coverAiSection.style.display = hideAi ? "none" : "";
|
||||||
|
}
|
||||||
|
const hint = $("coverHint");
|
||||||
|
if (hint) {
|
||||||
|
hint.textContent =
|
||||||
|
coverMode === "manual"
|
||||||
|
? "当前为手动上传模式,可切换到 AI 自动生成。"
|
||||||
|
: "当前为 AI 生成模式,也可切换回手动上传。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncTargetCharChips() {
|
||||||
|
const val = Number((targetBodyCharsInput && targetBodyCharsInput.value) || 0);
|
||||||
|
document.querySelectorAll(".target-char-chip").forEach((btn) => {
|
||||||
|
const n = Number(btn.getAttribute("data-target-chars") || 0);
|
||||||
|
btn.classList.toggle("is-active", Number.isFinite(val) && val === n);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function countText(v) {
|
||||||
|
return (v || "").trim().length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 多选子项用顿号拼接,可选补充用分号接在末尾 */
|
||||||
|
function buildMultiPrompt(nameAttr, extraId) {
|
||||||
|
const boxes = document.querySelectorAll(`input[name="${nameAttr}"]:checked`);
|
||||||
|
const parts = Array.from(boxes).map((b) => (b.value || "").trim()).filter(Boolean);
|
||||||
|
const extraEl = extraId ? $(extraId) : null;
|
||||||
|
const extra = extraEl ? (extraEl.value || "").trim() : "";
|
||||||
|
let s = parts.join("、");
|
||||||
|
if (extra) s = s ? `${s};${extra}` : extra;
|
||||||
|
return s;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateMultiDropdownSummary(nameAttr, summaryId, emptyHint) {
|
||||||
|
const el = $(summaryId);
|
||||||
|
if (!el) return;
|
||||||
|
const parts = Array.from(document.querySelectorAll(`input[name="${nameAttr}"]:checked`)).map((b) =>
|
||||||
|
(b.value || "").trim(),
|
||||||
|
);
|
||||||
|
el.textContent = parts.length ? parts.join("、") : emptyHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initMultiDropdowns() {
|
||||||
|
const pairs = [
|
||||||
|
{ name: "audienceChip", summary: "audienceSummary", empty: "点击展开,选择目标读者…" },
|
||||||
|
{ name: "toneChip", summary: "toneSummary", empty: "点击展开,选择语气风格…" },
|
||||||
|
];
|
||||||
|
pairs.forEach(({ name, summary, empty }) => {
|
||||||
|
updateMultiDropdownSummary(name, summary, empty);
|
||||||
|
document.querySelectorAll(`input[name="${name}"]`).forEach((cb) => {
|
||||||
|
cb.addEventListener("change", () => updateMultiDropdownSummary(name, summary, empty));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateCounters() {
|
||||||
|
$("sourceCount").textContent = `${countText($("sourceText").value)} 字`;
|
||||||
|
$("summaryCount").textContent = `${countText($("summary").value)} 字`;
|
||||||
|
$("bodyCount").textContent = `${countText($("body").value)} 字`;
|
||||||
|
renderBodyPreview();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(button, loading, idleText, loadingText) {
|
||||||
|
if (!button) return;
|
||||||
|
button.disabled = loading;
|
||||||
|
button.textContent = loading ? loadingText : idleText;
|
||||||
|
}
|
||||||
|
|
||||||
function setStatus(msg, danger = false) {
|
function setStatus(msg, danger = false) {
|
||||||
statusEl.style.color = danger ? "#b42318" : "#0f5f3d";
|
statusEl.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||||
statusEl.textContent = msg;
|
statusEl.textContent = msg;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function saveDraftState() {
|
||||||
|
try {
|
||||||
|
const data = {
|
||||||
|
sourceText: ($("sourceText") && $("sourceText").value) || "",
|
||||||
|
titleHint: ($("titleHint") && $("titleHint").value) || "",
|
||||||
|
audienceExtra: ($("audienceExtra") && $("audienceExtra").value) || "",
|
||||||
|
toneExtra: ($("toneExtra") && $("toneExtra").value) || "",
|
||||||
|
avoidWords: ($("avoidWords") && $("avoidWords").value) || "",
|
||||||
|
keepPoints: ($("keepPoints") && $("keepPoints").value) || "",
|
||||||
|
targetBodyChars: ($("targetBodyChars") && $("targetBodyChars").value) || "500",
|
||||||
|
title: ($("title") && $("title").value) || "",
|
||||||
|
summary: ($("summary") && $("summary").value) || "",
|
||||||
|
body: ($("body") && $("body").value) || "",
|
||||||
|
thumbMediaId: ($("thumbMediaId") && $("thumbMediaId").value) || "",
|
||||||
|
coverStyleHint: ($("coverStyleHint") && $("coverStyleHint").value) || "",
|
||||||
|
coverImageModel: (coverImageModelInput && coverImageModelInput.value) || "",
|
||||||
|
coverAutoAfterRewrite: Boolean(coverAutoAfterRewrite && coverAutoAfterRewrite.checked),
|
||||||
|
posterAutoInclude: Boolean(posterAutoInclude && posterAutoInclude.checked),
|
||||||
|
audienceChipValues: Array.from(document.querySelectorAll('input[name="audienceChip"]:checked')).map((n) => n.value),
|
||||||
|
toneChipValues: Array.from(document.querySelectorAll('input[name="toneChip"]:checked')).map((n) => n.value),
|
||||||
|
};
|
||||||
|
window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data));
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreDraftState() {
|
||||||
|
try {
|
||||||
|
const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY);
|
||||||
|
if (!raw) return;
|
||||||
|
const data = JSON.parse(raw);
|
||||||
|
if (!data || typeof data !== "object") return;
|
||||||
|
const setVal = (id, val) => {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el || typeof val !== "string") return;
|
||||||
|
el.value = val;
|
||||||
|
};
|
||||||
|
setVal("sourceText", data.sourceText || "");
|
||||||
|
setVal("titleHint", data.titleHint || "");
|
||||||
|
setVal("audienceExtra", data.audienceExtra || "");
|
||||||
|
setVal("toneExtra", data.toneExtra || "");
|
||||||
|
setVal("avoidWords", data.avoidWords || "");
|
||||||
|
setVal("keepPoints", data.keepPoints || "");
|
||||||
|
setVal("targetBodyChars", String(data.targetBodyChars || "500"));
|
||||||
|
setVal("title", data.title || "");
|
||||||
|
setVal("summary", data.summary || "");
|
||||||
|
setVal("body", data.body || "");
|
||||||
|
setVal("thumbMediaId", data.thumbMediaId || "");
|
||||||
|
setVal("coverStyleHint", data.coverStyleHint || "");
|
||||||
|
if (coverImageModelInput && typeof data.coverImageModel === "string" && data.coverImageModel.trim()) {
|
||||||
|
coverImageModelInput.value = data.coverImageModel;
|
||||||
|
}
|
||||||
|
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = Boolean(data.coverAutoAfterRewrite);
|
||||||
|
if (posterAutoInclude) posterAutoInclude.checked = Boolean(data.posterAutoInclude);
|
||||||
|
const audienceSet = new Set(Array.isArray(data.audienceChipValues) ? data.audienceChipValues : []);
|
||||||
|
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
|
||||||
|
el.checked = audienceSet.size ? audienceSet.has(el.value) : el.checked;
|
||||||
|
});
|
||||||
|
const toneSet = new Set(Array.isArray(data.toneChipValues) ? data.toneChipValues : []);
|
||||||
|
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
|
||||||
|
el.checked = toneSet.size ? toneSet.has(el.value) : el.checked;
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearDraftState() {
|
||||||
|
try {
|
||||||
|
window.localStorage.removeItem(DRAFT_STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
const clearIds = [
|
||||||
|
"sourceText",
|
||||||
|
"titleHint",
|
||||||
|
"audienceExtra",
|
||||||
|
"toneExtra",
|
||||||
|
"avoidWords",
|
||||||
|
"keepPoints",
|
||||||
|
"title",
|
||||||
|
"summary",
|
||||||
|
"body",
|
||||||
|
"thumbMediaId",
|
||||||
|
"coverStyleHint",
|
||||||
|
"coverUrl",
|
||||||
|
];
|
||||||
|
clearIds.forEach((id) => {
|
||||||
|
const el = $(id);
|
||||||
|
if (!el) return;
|
||||||
|
el.value = "";
|
||||||
|
});
|
||||||
|
if ($("targetBodyChars")) $("targetBodyChars").value = "500";
|
||||||
|
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = false;
|
||||||
|
if (posterAutoInclude) posterAutoInclude.checked = false;
|
||||||
|
if (coverImageModelInput) coverImageModelInput.value = "";
|
||||||
|
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
|
||||||
|
el.checked = false;
|
||||||
|
});
|
||||||
|
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
|
||||||
|
el.checked = false;
|
||||||
|
});
|
||||||
|
if (coverPreviewWrap) coverPreviewWrap.hidden = true;
|
||||||
|
if (coverPreview) coverPreview.src = "";
|
||||||
|
if ($("coverFile")) $("coverFile").value = "";
|
||||||
|
|
||||||
|
posterState = { signature: "", bodyMarkdownWithPosters: "", posters: [] };
|
||||||
|
renderPosterPreview([]);
|
||||||
|
updateCounters();
|
||||||
|
syncTargetCharChips();
|
||||||
|
initMultiDropdowns();
|
||||||
|
initImageModelStatus();
|
||||||
|
if (posterHint) posterHint.textContent = "默认不生成海报,点击“生成段落海报”后再插入发布。";
|
||||||
|
setStatus("草稿已清除。");
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPosterSignature() {
|
||||||
|
const title = ($("title") && $("title").value.trim()) || "";
|
||||||
|
const summary = ($("summary") && $("summary").value.trim()) || "";
|
||||||
|
const body = ($("body") && $("body").value.trim()) || "";
|
||||||
|
return `${title}\n||\n${summary}\n||\n${body}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderPosterPreview(posters) {
|
||||||
|
if (!posterPreviewList) return;
|
||||||
|
posterPreviewList.innerHTML = "";
|
||||||
|
const list = Array.isArray(posters) ? posters : [];
|
||||||
|
if (!list.length) {
|
||||||
|
const empty = document.createElement("p");
|
||||||
|
empty.className = "muted small";
|
||||||
|
empty.textContent = "暂无段落海报预览。";
|
||||||
|
posterPreviewList.appendChild(empty);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach((item) => {
|
||||||
|
const card = document.createElement("article");
|
||||||
|
card.className = "poster-card";
|
||||||
|
|
||||||
|
const img = document.createElement("img");
|
||||||
|
img.className = "poster-thumb";
|
||||||
|
img.alt = `段落 ${Number(item.paragraph_index || 0) + 1} 海报`;
|
||||||
|
img.src = item.preview_data_url || "";
|
||||||
|
card.appendChild(img);
|
||||||
|
|
||||||
|
const meta = document.createElement("div");
|
||||||
|
meta.className = "poster-meta";
|
||||||
|
const top = document.createElement("div");
|
||||||
|
top.className = "poster-topline";
|
||||||
|
top.textContent = `段落 ${Number(item.paragraph_index || 0) + 1} · ${item.note || "ai"}`;
|
||||||
|
meta.appendChild(top);
|
||||||
|
|
||||||
|
const excerpt = document.createElement("p");
|
||||||
|
excerpt.className = "poster-excerpt";
|
||||||
|
excerpt.textContent = item.paragraph_excerpt || "";
|
||||||
|
meta.appendChild(excerpt);
|
||||||
|
|
||||||
|
if (item.wechat_url) {
|
||||||
|
const link = document.createElement("a");
|
||||||
|
link.className = "poster-link";
|
||||||
|
link.href = item.wechat_url;
|
||||||
|
link.target = "_blank";
|
||||||
|
link.rel = "noreferrer noopener";
|
||||||
|
link.textContent = "微信素材 URL";
|
||||||
|
meta.appendChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
card.appendChild(meta);
|
||||||
|
posterPreviewList.appendChild(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function markPosterStaleIfNeeded() {
|
||||||
|
if (!posterState.signature || !posterHint) return;
|
||||||
|
if (posterState.signature !== buildPosterSignature()) {
|
||||||
|
posterHint.textContent = "正文已修改,如需海报请手动点击“生成段落海报”。";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function postJSON(url, body) {
|
async function postJSON(url, body) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -14,53 +315,473 @@ async function postJSON(url, body) {
|
|||||||
body: JSON.stringify(body),
|
body: JSON.stringify(body),
|
||||||
});
|
});
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (!res.ok) throw new Error(data.detail || "请求失败");
|
if (!res.ok) {
|
||||||
|
const err = new Error(data.detail || "请求失败");
|
||||||
|
err.payload = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
data._requestId = res.headers.get("X-Request-ID") || "";
|
||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleUpgradeRequired(err) {
|
||||||
|
const data = (err && err.payload) || {};
|
||||||
|
const msg = (err && err.message) || data.detail || "";
|
||||||
|
if (!data.upgrade_required && !msg.includes("免费额度已用完") && !msg.includes("余额不足")) return false;
|
||||||
|
setStatus("免费额度已用完,请前往升级页充值或升级 VIP 用户。", true);
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.href = "/upgrade";
|
||||||
|
}, 800);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generatePosterMaterials({ silent = false } = {}) {
|
||||||
|
const bodyMarkdown = (($("body") && $("body").value) || "").trim();
|
||||||
|
if (bodyMarkdown.length < 20) {
|
||||||
|
throw new Error("正文太短,暂无法生成段落海报");
|
||||||
|
}
|
||||||
|
if (!silent) setStatus("正在生成段落海报...");
|
||||||
|
if (posterHint) posterHint.textContent = "正在生成并上传段落海报...";
|
||||||
|
setLoading(posterGenerateBtn, true, "生成段落海报", "生成中...");
|
||||||
|
try {
|
||||||
|
const data = await postJSON("/api/material/posters/generate", {
|
||||||
|
title: $("title").value,
|
||||||
|
summary: $("summary").value,
|
||||||
|
body_markdown: $("body").value,
|
||||||
|
image_model: (coverImageModelInput && coverImageModelInput.value.trim()) || "",
|
||||||
|
upload_to_wechat: true,
|
||||||
|
});
|
||||||
|
if (!data.ok) {
|
||||||
|
const err = new Error(data.detail || "海报生成失败");
|
||||||
|
err.payload = data;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
posterState = {
|
||||||
|
signature: buildPosterSignature(),
|
||||||
|
bodyMarkdownWithPosters: data.body_markdown_with_posters || $("body").value,
|
||||||
|
posters: Array.isArray(data.posters) ? data.posters : [],
|
||||||
|
};
|
||||||
|
renderPosterPreview(posterState.posters);
|
||||||
|
|
||||||
|
const warnText = Array.isArray(data.warnings) && data.warnings.length ? `(提示:${data.warnings.join(";")})` : "";
|
||||||
|
if (posterHint) posterHint.textContent = `${data.detail || "海报生成完成"}${warnText}`;
|
||||||
|
if (!silent) setStatus(`${data.detail || "海报生成完成"}${warnText}`);
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
setLoading(posterGenerateBtn, false, "生成段落海报", "生成中...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function coverTitleForGeneration() {
|
||||||
|
const generatedTitle = (($("title") && $("title").value) || "").trim();
|
||||||
|
const titleHint = (($("titleHint") && $("titleHint").value) || "").trim();
|
||||||
|
return generatedTitle || titleHint;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateWechatCover({ silent = false } = {}) {
|
||||||
|
const title = coverTitleForGeneration();
|
||||||
|
if (!title) {
|
||||||
|
throw new Error("请先填写标题提示,或先改写生成标题");
|
||||||
|
}
|
||||||
|
if (!silent) setStatus("正在按标题生成公众号封面...");
|
||||||
|
const hint = $("coverHint");
|
||||||
|
if (hint) hint.textContent = "正在生成 900×383 公众号封面并上传...";
|
||||||
|
setLoading(coverGenerateBtn, true, "按标题生成封面", "生成中...");
|
||||||
|
try {
|
||||||
|
const data = await postJSON("/api/wechat/cover/generate", {
|
||||||
|
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) {
|
||||||
|
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) {
|
||||||
|
coverPreview.src = data.preview_data_url;
|
||||||
|
coverPreviewWrap.hidden = false;
|
||||||
|
}
|
||||||
|
const warnText = Array.isArray(data.warnings) && data.warnings.length ? `(提示:${data.warnings.join(";")})` : "";
|
||||||
|
const detail = data.detail || "封面生成完成";
|
||||||
|
if (hint) hint.textContent = `${detail}${warnText}`;
|
||||||
|
if (!silent) setStatus(`${detail}${warnText}`);
|
||||||
|
return data;
|
||||||
|
} finally {
|
||||||
|
setLoading(coverGenerateBtn, false, "按标题生成封面", "生成中...");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchAuthMe() {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.ok || !data.logged_in) {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWechatAccountSelect(me) {
|
||||||
|
const sel = $("wechatAccountSelect");
|
||||||
|
const hint = $("wechatAccountStatus");
|
||||||
|
if (!sel) return;
|
||||||
|
const list = Array.isArray(me.wechat_accounts) ? me.wechat_accounts : [];
|
||||||
|
const activeId =
|
||||||
|
me.active_wechat_account && me.active_wechat_account.id
|
||||||
|
? Number(me.active_wechat_account.id)
|
||||||
|
: 0;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (!list.length) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "";
|
||||||
|
opt.textContent = "未绑定公众号";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
sel.disabled = true;
|
||||||
|
if (hint) hint.textContent = "请先绑定公众号";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
sel.disabled = false;
|
||||||
|
list.forEach((a) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = String(a.id);
|
||||||
|
const name = (a.account_name || "未命名").trim();
|
||||||
|
const appid = (a.appid || "").trim();
|
||||||
|
opt.textContent = appid ? `${name} (${appid})` : name;
|
||||||
|
if ((activeId && Number(a.id) === activeId) || a.active) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function flashWechatAccountHint(msg, clearMs = 2600) {
|
||||||
|
const hint = $("wechatAccountStatus");
|
||||||
|
if (!hint) return;
|
||||||
|
hint.textContent = msg;
|
||||||
|
if (clearMs > 0) {
|
||||||
|
window.clearTimeout(flashWechatAccountHint._t);
|
||||||
|
flashWechatAccountHint._t = window.setTimeout(() => {
|
||||||
|
hint.textContent = "";
|
||||||
|
}, clearMs);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const wechatAccountSelect = $("wechatAccountSelect");
|
||||||
|
if (wechatAccountSelect) {
|
||||||
|
wechatAccountSelect.addEventListener("change", async () => {
|
||||||
|
const id = Number(wechatAccountSelect.value || 0);
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/wechat/switch", { account_id: id });
|
||||||
|
if (!out.ok) {
|
||||||
|
flashWechatAccountHint(out.detail || "切换失败", 4000);
|
||||||
|
const me = await fetchAuthMe();
|
||||||
|
if (me) renderWechatAccountSelect(me);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const me = await fetchAuthMe();
|
||||||
|
if (me) renderWechatAccountSelect(me);
|
||||||
|
flashWechatAccountHint("已切换");
|
||||||
|
} catch (e) {
|
||||||
|
flashWechatAccountHint(e.message || "切换失败", 4000);
|
||||||
|
const me = await fetchAuthMe();
|
||||||
|
if (me) renderWechatAccountSelect(me);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function initWechatAccountSwitch() {
|
||||||
|
const me = await fetchAuthMe();
|
||||||
|
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", {});
|
||||||
|
} catch {
|
||||||
|
// 忽略退出接口异常,直接跳转认证页
|
||||||
|
}
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(logoutBtn, true, "退出登录", "退出中...");
|
||||||
|
await logoutAndGoAuth();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clearDraftBtn) {
|
||||||
|
clearDraftBtn.addEventListener("click", () => {
|
||||||
|
clearDraftState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverModeManualBtn) {
|
||||||
|
coverModeManualBtn.addEventListener("click", () => setCoverMode("manual"));
|
||||||
|
}
|
||||||
|
if (coverModeAiBtn) {
|
||||||
|
coverModeAiBtn.addEventListener("click", () => setCoverMode("ai"));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll(".target-char-chip").forEach((btn) => {
|
||||||
|
btn.addEventListener("click", () => {
|
||||||
|
const n = Number(btn.getAttribute("data-target-chars") || 0);
|
||||||
|
if (!targetBodyCharsInput || !Number.isFinite(n) || n < 1) return;
|
||||||
|
targetBodyCharsInput.value = String(n);
|
||||||
|
syncTargetCharChips();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if (targetBodyCharsInput) {
|
||||||
|
targetBodyCharsInput.addEventListener("input", syncTargetCharChips);
|
||||||
|
}
|
||||||
|
|
||||||
$("rewriteBtn").addEventListener("click", async () => {
|
$("rewriteBtn").addEventListener("click", async () => {
|
||||||
const sourceText = $("sourceText").value.trim();
|
const sourceText = $("sourceText").value.trim();
|
||||||
|
const targetBodyChars = Number(($("targetBodyChars") && $("targetBodyChars").value) || 500);
|
||||||
if (sourceText.length < 20) {
|
if (sourceText.length < 20) {
|
||||||
setStatus("原始内容太短,至少 20 个字符", true);
|
setStatus("原始内容太短,至少 20 个字符", true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (!Number.isFinite(targetBodyChars) || targetBodyChars < 180 || targetBodyChars > 2200) {
|
||||||
|
setStatus("改写目标字数需在 180~2200 之间", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
setStatus("AI 改写中...");
|
setStatus("正在改写...");
|
||||||
|
setLoading(rewriteBtn, true, "改写并排版", "改写中...");
|
||||||
try {
|
try {
|
||||||
|
const audience = buildMultiPrompt("audienceChip", "audienceExtra") || "公众号读者";
|
||||||
|
const tone = buildMultiPrompt("toneChip", "toneExtra") || "专业、可信、可读性强";
|
||||||
const data = await postJSON("/api/rewrite", {
|
const data = await postJSON("/api/rewrite", {
|
||||||
source_text: sourceText,
|
source_text: sourceText,
|
||||||
title_hint: $("titleHint").value,
|
title_hint: $("titleHint").value,
|
||||||
tone: $("tone").value,
|
tone,
|
||||||
audience: $("audience").value,
|
audience,
|
||||||
keep_points: $("keepPoints").value,
|
keep_points: $("keepPoints").value,
|
||||||
avoid_words: $("avoidWords").value,
|
avoid_words: $("avoidWords").value,
|
||||||
|
target_body_chars: Math.round(targetBodyChars),
|
||||||
});
|
});
|
||||||
$("title").value = data.title || "";
|
$("title").value = data.title || "";
|
||||||
$("summary").value = data.summary || "";
|
$("summary").value = data.summary || "";
|
||||||
$("body").value = data.body_markdown || "";
|
$("body").value = data.body_markdown || "";
|
||||||
setStatus("改写完成,可直接发布。");
|
updateCounters();
|
||||||
|
saveDraftState();
|
||||||
|
const tr = data.trace || {};
|
||||||
|
if (data.mode === "fallback") {
|
||||||
|
const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
|
||||||
|
setStatus(`改写完成(保底模式):${note}`, true);
|
||||||
|
} else if (tr.quality_soft_accept) {
|
||||||
|
setStatus(`改写完成(有提示):${(data.quality_notes || []).join(";") || "请检查正文"}`);
|
||||||
|
statusEl.style.color = "#9a3412";
|
||||||
|
} else {
|
||||||
|
setStatus("改写完成。");
|
||||||
|
}
|
||||||
|
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) {
|
} catch (e) {
|
||||||
|
if (handleUpgradeRequired(e)) return;
|
||||||
setStatus(`改写失败: ${e.message}`, true);
|
setStatus(`改写失败: ${e.message}`, true);
|
||||||
|
} finally {
|
||||||
|
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("wechatBtn").addEventListener("click", async () => {
|
$("wechatBtn").addEventListener("click", async () => {
|
||||||
setStatus("正在发布到公众号草稿箱...");
|
setStatus("正在发布到公众号草稿箱...");
|
||||||
|
setLoading(wechatBtn, true, "发布到公众号草稿箱", "发布中...");
|
||||||
try {
|
try {
|
||||||
|
let bodyForPublish = $("body").value;
|
||||||
|
const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked);
|
||||||
|
if (autoInclude) {
|
||||||
|
const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters;
|
||||||
|
if (!stale && posterState.bodyMarkdownWithPosters) {
|
||||||
|
bodyForPublish = posterState.bodyMarkdownWithPosters;
|
||||||
|
} else {
|
||||||
|
setStatus("未检测到可用海报,本次仅发布文字;如需海报请先手动生成。", true);
|
||||||
|
}
|
||||||
|
}
|
||||||
const data = await postJSON("/api/publish/wechat", {
|
const data = await postJSON("/api/publish/wechat", {
|
||||||
title: $("title").value,
|
title: $("title").value,
|
||||||
summary: $("summary").value,
|
summary: $("summary").value,
|
||||||
body_markdown: $("body").value,
|
body_markdown: bodyForPublish,
|
||||||
|
thumb_media_id: $("thumbMediaId") ? $("thumbMediaId").value.trim() : "",
|
||||||
});
|
});
|
||||||
if (!data.ok) throw new Error(data.detail);
|
if (!data.ok) throw new Error(data.detail);
|
||||||
setStatus("公众号草稿发布成功");
|
setStatus("公众号草稿发布成功");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`公众号发布失败: ${e.message}`, true);
|
setStatus(`公众号发布失败: ${e.message}`, true);
|
||||||
|
if ((e.message || "").includes("请先登录")) {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
} else if ((e.message || "").includes("未绑定公众号")) {
|
||||||
|
window.location.href = "/settings";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(wechatBtn, false, "发布到公众号草稿箱", "发布中...");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
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);
|
||||||
|
if ((e.message || "").includes("请先登录")) {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
} else if ((e.message || "").includes("未绑定公众号")) {
|
||||||
|
window.location.href = "/settings";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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");
|
||||||
|
const hint = $("coverHint");
|
||||||
|
const file = fileInput && fileInput.files && fileInput.files[0];
|
||||||
|
if (!file) {
|
||||||
|
setStatus("请先选择封面图片再上传", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hint) hint.textContent = "正在上传封面...";
|
||||||
|
setLoading(coverUploadBtn, true, "上传封面并绑定", "上传中...");
|
||||||
|
try {
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.append("file", file);
|
||||||
|
const res = await fetch("/api/wechat/cover/upload", { method: "POST", body: fd });
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.ok) throw new Error(data.detail || "封面上传失败");
|
||||||
|
const mid = data.data && data.data.thumb_media_id ? data.data.thumb_media_id : "";
|
||||||
|
if ($("thumbMediaId")) $("thumbMediaId").value = mid;
|
||||||
|
if (hint) hint.textContent = `封面上传成功,已绑定 media_id:${mid}`;
|
||||||
|
setStatus("封面上传成功,发布时将优先使用该封面。");
|
||||||
|
saveDraftState();
|
||||||
|
} catch (e) {
|
||||||
|
if (hint) hint.textContent = "封面上传失败,请看状态提示。";
|
||||||
|
setStatus(`封面上传失败: ${e.message}`, true);
|
||||||
|
if ((e.message || "").includes("请先登录")) {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
} else if ((e.message || "").includes("未绑定公众号")) {
|
||||||
|
window.location.href = "/settings";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(coverUploadBtn, false, "上传封面并绑定", "上传中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (coverUrlUploadBtn) {
|
||||||
|
coverUrlUploadBtn.addEventListener("click", async () => {
|
||||||
|
const hint = $("coverHint");
|
||||||
|
const imageUrl = (($("coverUrl") && $("coverUrl").value) || "").trim();
|
||||||
|
if (!imageUrl) {
|
||||||
|
setStatus("请先粘贴图片 URL", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (hint) hint.textContent = "正在下载并上传 URL 图片...";
|
||||||
|
setLoading(coverUrlUploadBtn, true, "URL 上传并绑定", "上传中...");
|
||||||
|
try {
|
||||||
|
const data = await postJSON("/api/wechat/cover/upload-by-url", { image_url: imageUrl });
|
||||||
|
if (!data.ok) throw new Error(data.detail || "URL 封面上传失败");
|
||||||
|
const mid = data.data && data.data.thumb_media_id ? data.data.thumb_media_id : "";
|
||||||
|
if ($("thumbMediaId")) $("thumbMediaId").value = mid;
|
||||||
|
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);
|
||||||
|
if ((e.message || "").includes("请先登录")) {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
} else if ((e.message || "").includes("未绑定公众号")) {
|
||||||
|
window.location.href = "/settings";
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
setLoading(coverUrlUploadBtn, false, "URL 上传并绑定", "上传中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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("请先登录")) {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
} else if ((e.message || "").includes("未绑定公众号")) {
|
||||||
|
window.location.href = "/settings";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
$("imBtn").addEventListener("click", async () => {
|
$("imBtn").addEventListener("click", async () => {
|
||||||
setStatus("正在发送到 IM...");
|
setStatus("正在发送到 IM...");
|
||||||
|
setLoading(imBtn, true, "发送到 IM", "发送中...");
|
||||||
try {
|
try {
|
||||||
const data = await postJSON("/api/publish/im", {
|
const data = await postJSON("/api/publish/im", {
|
||||||
title: $("title").value,
|
title: $("title").value,
|
||||||
@@ -70,5 +791,37 @@ $("imBtn").addEventListener("click", async () => {
|
|||||||
setStatus("IM 发送成功");
|
setStatus("IM 发送成功");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setStatus(`IM 发送失败: ${e.message}`, true);
|
setStatus(`IM 发送失败: ${e.message}`, true);
|
||||||
|
} finally {
|
||||||
|
setLoading(imBtn, false, "发送到 IM", "发送中...");
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
["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"));
|
||||||
|
|||||||
134
app/static/auth.js
Normal file
134
app/static/auth.js
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
let challengeId = "";
|
||||||
|
|
||||||
|
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 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 "/";
|
||||||
|
return nxt;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fields() {
|
||||||
|
return {
|
||||||
|
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) || "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authAction(url, button, idleText, loadingText, okMessage) {
|
||||||
|
setLoading(button, true, idleText, loadingText);
|
||||||
|
try {
|
||||||
|
const data = await postJSON(url, fields());
|
||||||
|
if (!data.ok) {
|
||||||
|
setStatus(data.detail || "操作失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(okMessage);
|
||||||
|
window.location.href = nextPath();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "请求异常", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(button, false, idleText, loadingText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loginBtn = $("loginBtn");
|
||||||
|
const registerBtn = $("registerBtn");
|
||||||
|
const refreshChallengeBtn = $("refreshChallengeBtn");
|
||||||
|
|
||||||
|
if (loginBtn) {
|
||||||
|
loginBtn.addEventListener("click", async () => {
|
||||||
|
await authAction("/api/auth/login", loginBtn, "登录", "登录中...", "登录成功,正在跳转...");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (registerBtn) {
|
||||||
|
registerBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(registerBtn, true, "注册", "注册中...");
|
||||||
|
try {
|
||||||
|
const data = await postJSON("/api/auth/register", fields());
|
||||||
|
if (!data.ok) {
|
||||||
|
setStatus(data.detail || "注册失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = (data.reset_code || "").trim();
|
||||||
|
if (code) {
|
||||||
|
const msg =
|
||||||
|
`注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` +
|
||||||
|
"请立即复制并妥善保管,点击“确定”后继续进入系统。";
|
||||||
|
await window.uiAlert(msg, "注册成功");
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// 忽略复制失败
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setStatus("注册成功,正在跳转...");
|
||||||
|
const redirectTo = (data.redirect_to || "").trim();
|
||||||
|
if (redirectTo && redirectTo.startsWith("/")) {
|
||||||
|
window.location.href = redirectTo;
|
||||||
|
} else {
|
||||||
|
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
340
app/static/billing.js
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function setStatus(msg, danger = false) {
|
||||||
|
const el = $("status");
|
||||||
|
if (!el) return;
|
||||||
|
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||||
|
el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(button, loading, idleText, loadingText) {
|
||||||
|
if (!button) return;
|
||||||
|
button.disabled = loading;
|
||||||
|
button.textContent = loading ? loadingText : idleText;
|
||||||
|
}
|
||||||
|
|
||||||
|
function importantNotice(message, title = "提示") {
|
||||||
|
if (typeof window.uiAlert === "function") {
|
||||||
|
void window.uiAlert(message, title);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus(message, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openPayLink(url) {
|
||||||
|
const payUrl = String(url || "").trim();
|
||||||
|
if (!payUrl) return false;
|
||||||
|
const tab = window.open(payUrl, "_blank", "noopener");
|
||||||
|
if (tab) return true;
|
||||||
|
window.location.href = payUrl;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url, body) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authMe() {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.logged_in) {
|
||||||
|
window.location.href = "/auth?next=/billing";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function fmtTime(ts) {
|
||||||
|
const n = Number(ts || 0);
|
||||||
|
if (!n) return "-";
|
||||||
|
return new Date(n * 1000).toLocaleString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapOrderStatus(status) {
|
||||||
|
const s = String(status || "").toLowerCase();
|
||||||
|
if (s === "paid" || s === "success") return "已支付";
|
||||||
|
if (s === "pending") return "待支付";
|
||||||
|
if (s === "failed") return "支付失败";
|
||||||
|
if (s === "cancelled") return "已取消";
|
||||||
|
if (s === "closed") return "已关闭";
|
||||||
|
return status || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapChannel(channel) {
|
||||||
|
const c = String(channel || "").toLowerCase();
|
||||||
|
if (c === "wechat") return "微信支付";
|
||||||
|
if (c === "alipay") return "支付宝";
|
||||||
|
if (c === "stripe") return "Stripe";
|
||||||
|
return channel || "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDetail(detail) {
|
||||||
|
if (!detail || typeof detail !== "object") return "-";
|
||||||
|
const rows = [];
|
||||||
|
if (detail.source_chars) rows.push(`原文${detail.source_chars}字`);
|
||||||
|
if (detail.body_chars) rows.push(`正文${detail.body_chars}字`);
|
||||||
|
if (detail.title_chars) rows.push(`标题${detail.title_chars}字`);
|
||||||
|
if (detail.summary_chars) rows.push(`摘要${detail.summary_chars}字`);
|
||||||
|
if (detail.image_count) rows.push(`图片${detail.image_count}张`);
|
||||||
|
if (detail.prompt_tokens) rows.push(`输入Token:${detail.prompt_tokens}`);
|
||||||
|
if (detail.completion_tokens) rows.push(`输出Token:${detail.completion_tokens}`);
|
||||||
|
if (detail.total_tokens) rows.push(`总Token:${detail.total_tokens}`);
|
||||||
|
if (detail.model) rows.push(`模型:${detail.model}`);
|
||||||
|
if (detail.image_model) rows.push(`生图模型:${detail.image_model}`);
|
||||||
|
if (detail.image_price_package_images && detail.image_price_package_cny) {
|
||||||
|
rows.push(`图片计价:${detail.image_price_package_images}张=${Number(detail.image_price_package_cny).toFixed(2)}元`);
|
||||||
|
}
|
||||||
|
if (detail.credits_rule) rows.push(`规则:${detail.credits_rule}`);
|
||||||
|
if (detail.billed_basis === "usage_tokens") rows.push("按真实Token计费");
|
||||||
|
if (detail.billed_basis === "char_estimate") rows.push("按字符估算计费");
|
||||||
|
if (detail.paid_amount_cny) rows.push(`实付¥${Number(detail.paid_amount_cny).toFixed(2)}`);
|
||||||
|
if (detail.external_txn_id) rows.push(`交易号:${detail.external_txn_id}`);
|
||||||
|
if (detail.credit_source && typeof detail.credit_source === "object") {
|
||||||
|
const seat = Number(detail.credit_source.seat || 0);
|
||||||
|
const shared = Number(detail.credit_source.shared || 0);
|
||||||
|
rows.push(`抵扣来源:席位${seat}/共享${shared}`);
|
||||||
|
}
|
||||||
|
return rows.length ? rows.join(" | ") : "-";
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderRechargeRecords(records) {
|
||||||
|
const el = $("rechargeRecords");
|
||||||
|
if (!el) return;
|
||||||
|
const list = Array.isArray(records) ? records : [];
|
||||||
|
if (!list.length) {
|
||||||
|
el.innerHTML = '<p class="muted small">暂无充值记录</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const rows = list
|
||||||
|
.map((r) => {
|
||||||
|
const statusText = mapOrderStatus(r.status);
|
||||||
|
const statusClass =
|
||||||
|
statusText === "已支付"
|
||||||
|
? "paid"
|
||||||
|
: statusText === "待支付"
|
||||||
|
? "pending"
|
||||||
|
: statusText === "支付失败"
|
||||||
|
? "failed"
|
||||||
|
: "closed";
|
||||||
|
return `<tr>
|
||||||
|
<td class="mono">${r.order_no || "-"}</td>
|
||||||
|
<td><span class="billing-badge ${statusClass}">${statusText}</span></td>
|
||||||
|
<td>${mapChannel(r.channel)}</td>
|
||||||
|
<td>${Number(r.token_amount || 0)}</td>
|
||||||
|
<td>¥${Number(r.amount_cny || 0).toFixed(2)}</td>
|
||||||
|
<td>${fmtTime(r.created_at)}</td>
|
||||||
|
<td>${r.paid_at ? fmtTime(r.paid_at) : "-"}</td>
|
||||||
|
<td>${
|
||||||
|
statusText === "待支付"
|
||||||
|
? `<button class="primary pay-now-btn" data-order-no="${r.order_no || ""}" type="button">立即支付</button>`
|
||||||
|
: "-"
|
||||||
|
}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
el.innerHTML = `<table class="billing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>订单号</th>
|
||||||
|
<th>状态</th>
|
||||||
|
<th>渠道</th>
|
||||||
|
<th>Credits</th>
|
||||||
|
<th>金额</th>
|
||||||
|
<th>创建时间</th>
|
||||||
|
<th>支付时间</th>
|
||||||
|
<th>操作</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderConsumeRecords(records) {
|
||||||
|
const el = $("consumeRecords");
|
||||||
|
if (!el) return;
|
||||||
|
const list = Array.isArray(records) ? records : [];
|
||||||
|
if (!list.length) {
|
||||||
|
el.innerHTML = '<p class="muted small">暂无消费记录</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const kindTextMap = {
|
||||||
|
trial_grant: "试用赠送",
|
||||||
|
paid_recharge: "充值到账",
|
||||||
|
manual_recharge: "手动充值",
|
||||||
|
rewrite: "AI改写",
|
||||||
|
cover_generate: "封面生成",
|
||||||
|
poster_generate: "段落海报",
|
||||||
|
usage: "模型调用",
|
||||||
|
};
|
||||||
|
const rows = list
|
||||||
|
.map((r) => {
|
||||||
|
const kindText = kindTextMap[r.kind] || r.kind || "-";
|
||||||
|
const delta = `${r.direction === "out" ? "-" : "+"}${Number(r.token_change || 0)}`;
|
||||||
|
const ref = `${r.ref_type || "-"} ${r.ref_id || ""}`.trim();
|
||||||
|
const detail = formatDetail(r.detail);
|
||||||
|
return `<tr>
|
||||||
|
<td>${kindText}</td>
|
||||||
|
<td class="${r.direction === "out" ? "out" : "in"}">${delta}</td>
|
||||||
|
<td>${Number(r.balance_after || 0)}</td>
|
||||||
|
<td>${ref}</td>
|
||||||
|
<td>${detail}</td>
|
||||||
|
<td>${fmtTime(r.created_at)}</td>
|
||||||
|
</tr>`;
|
||||||
|
})
|
||||||
|
.join("");
|
||||||
|
el.innerHTML = `<table class="billing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>类型</th>
|
||||||
|
<th>Credits变动</th>
|
||||||
|
<th>余额</th>
|
||||||
|
<th>关联</th>
|
||||||
|
<th>明细</th>
|
||||||
|
<th>时间</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>${rows}</tbody>
|
||||||
|
</table>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshBilling() {
|
||||||
|
const data = await fetch("/api/billing/overview").then((r) => r.json());
|
||||||
|
if (!data.ok) throw new Error(data.detail || "账单加载失败");
|
||||||
|
renderRechargeRecords(data.recharge_records || []);
|
||||||
|
renderConsumeRecords(data.consume_records || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const createRechargeOrderBtn = $("createRechargeOrderBtn");
|
||||||
|
const refreshBillingBtn = $("refreshBillingBtn");
|
||||||
|
const logoutBtn = $("logoutBtn");
|
||||||
|
const billingRechargeTokensInput = $("billingRechargeTokens");
|
||||||
|
const billingRechargeAmountInput = $("billingRechargeAmount");
|
||||||
|
|
||||||
|
function packageRate() {
|
||||||
|
const credits = Number((billingRechargeTokensInput && billingRechargeTokensInput.dataset.packageCredits) || "1500");
|
||||||
|
const amount = Number((billingRechargeAmountInput && billingRechargeAmountInput.dataset.packageAmount) || "19.9");
|
||||||
|
return {
|
||||||
|
packageCredits: Number.isFinite(credits) && credits > 0 ? credits : 1500,
|
||||||
|
packageAmount: Number.isFinite(amount) && amount > 0 ? amount : 19.9,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncBillingAmount() {
|
||||||
|
if (!billingRechargeTokensInput || !billingRechargeAmountInput) return;
|
||||||
|
const credits = Number((billingRechargeTokensInput.value || "0").trim());
|
||||||
|
if (!Number.isFinite(credits) || credits <= 0) return;
|
||||||
|
const { packageCredits, packageAmount } = packageRate();
|
||||||
|
const amount = (credits / packageCredits) * packageAmount;
|
||||||
|
billingRechargeAmountInput.value = amount.toFixed(2);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (billingRechargeTokensInput) billingRechargeTokensInput.addEventListener("input", syncBillingAmount);
|
||||||
|
if (billingRechargeAmountInput) billingRechargeAmountInput.readOnly = true;
|
||||||
|
|
||||||
|
if (createRechargeOrderBtn) {
|
||||||
|
createRechargeOrderBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(createRechargeOrderBtn, true, "创建充值订单", "创建中...");
|
||||||
|
try {
|
||||||
|
const tokens = Number((($("billingRechargeTokens") && $("billingRechargeTokens").value) || "0").trim());
|
||||||
|
const amount = Number((($("billingRechargeAmount") && $("billingRechargeAmount").value) || "0").trim());
|
||||||
|
if (!Number.isFinite(tokens) || tokens <= 0) {
|
||||||
|
setStatus("请输入正确的充值 Credits 数量", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!Number.isFinite(amount) || amount <= 0) {
|
||||||
|
setStatus("请输入正确的支付金额", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const out = await postJSON("/api/pay/wechat/", {
|
||||||
|
tokens: Math.round(tokens),
|
||||||
|
amount_cny: Number(amount.toFixed(2)),
|
||||||
|
channel: "wechat",
|
||||||
|
});
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "创建订单失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const orderNo = out.order && out.order.order_no ? out.order.order_no : "";
|
||||||
|
if (out.pay_url) {
|
||||||
|
openPayLink(out.pay_url);
|
||||||
|
} else {
|
||||||
|
importantNotice("订单已创建,但未获取到支付链接,请检查支付网关配置。", "支付链接缺失");
|
||||||
|
}
|
||||||
|
setStatus(`订单已创建:${orderNo}`);
|
||||||
|
await refreshBilling();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "创建订单失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(createRechargeOrderBtn, false, "创建充值订单", "创建中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const rechargeRecordsWrap = $("rechargeRecords");
|
||||||
|
if (rechargeRecordsWrap) {
|
||||||
|
rechargeRecordsWrap.addEventListener("click", async (evt) => {
|
||||||
|
const btn = evt.target && evt.target.closest ? evt.target.closest(".pay-now-btn") : null;
|
||||||
|
if (!btn) return;
|
||||||
|
const orderNo = (btn.getAttribute("data-order-no") || "").trim();
|
||||||
|
if (!orderNo) return;
|
||||||
|
setLoading(btn, true, "立即支付", "拉起中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/billing/recharge/pay-now", { order_no: orderNo });
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "拉起支付失败", true);
|
||||||
|
await refreshBilling();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (out.pay_url) {
|
||||||
|
openPayLink(out.pay_url);
|
||||||
|
} else {
|
||||||
|
importantNotice("未获取到支付链接,请检查支付网关配置。", "立即支付失败");
|
||||||
|
}
|
||||||
|
setStatus(`订单 ${orderNo} 已拉起支付。`);
|
||||||
|
await refreshBilling();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "拉起支付失败", true);
|
||||||
|
await refreshBilling();
|
||||||
|
} finally {
|
||||||
|
setLoading(btn, false, "立即支付", "拉起中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (refreshBillingBtn) {
|
||||||
|
refreshBillingBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(refreshBillingBtn, true, "刷新账单记录", "刷新中...");
|
||||||
|
try {
|
||||||
|
await refreshBilling();
|
||||||
|
setStatus("账单已刷新。");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "账单刷新失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(refreshBillingBtn, false, "刷新账单记录", "刷新中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/logout", {});
|
||||||
|
} finally {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const me = await authMe();
|
||||||
|
if (!me) return;
|
||||||
|
syncBillingAmount();
|
||||||
|
await refreshBilling();
|
||||||
|
})();
|
||||||
32
app/static/favicon.svg
Normal file
32
app/static/favicon.svg
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AI发糕">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="appBg" x1="10" y1="8" x2="55" y2="58" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFB23F"/>
|
||||||
|
<stop offset="0.54" stop-color="#FF8428"/>
|
||||||
|
<stop offset="1" stop-color="#FF5A16"/>
|
||||||
|
</linearGradient>
|
||||||
|
<radialGradient id="cakeTop" cx="42%" cy="28%" r="72%">
|
||||||
|
<stop stop-color="#FFFDF2"/>
|
||||||
|
<stop offset="0.68" stop-color="#FFF0C6"/>
|
||||||
|
<stop offset="1" stop-color="#F6D48F"/>
|
||||||
|
</radialGradient>
|
||||||
|
<linearGradient id="cakeSide" x1="19" y1="31" x2="47" y2="54" gradientUnits="userSpaceOnUse">
|
||||||
|
<stop stop-color="#FFF0BF"/>
|
||||||
|
<stop offset="1" stop-color="#F0B85B"/>
|
||||||
|
</linearGradient>
|
||||||
|
<filter id="softShadow" x="-20%" y="-20%" width="140%" height="150%">
|
||||||
|
<feDropShadow dx="0" dy="5" stdDeviation="3" flood-color="#9A3F0B" flood-opacity="0.28"/>
|
||||||
|
</filter>
|
||||||
|
</defs>
|
||||||
|
<rect x="5" y="5" width="54" height="54" rx="13" fill="url(#appBg)"/>
|
||||||
|
<g filter="url(#softShadow)">
|
||||||
|
<path d="M16.5 30.5c0-8 6.5-14.2 15.5-14.2s15.5 6.2 15.5 14.2v14.2c0 5.1-6.4 8.7-15.5 8.7s-15.5-3.6-15.5-8.7V30.5z" fill="url(#cakeSide)"/>
|
||||||
|
<path d="M13.3 29.5c0-7.9 7.7-14.3 18.7-14.3s18.7 6.4 18.7 14.3c0 6.3-7.7 11.3-18.7 11.3s-18.7-5-18.7-11.3z" fill="url(#cakeTop)"/>
|
||||||
|
<path d="M18.2 33.4c3 3.3 7.8 5.1 13.8 5.1s10.8-1.8 13.8-5.1" fill="none" stroke="#E1B260" stroke-width="2.2" stroke-linecap="round"/>
|
||||||
|
<ellipse cx="24.3" cy="27" rx="1.9" ry="3.2" transform="rotate(-38 24.3 27)" fill="#E33D18"/>
|
||||||
|
<ellipse cx="31.9" cy="25.7" rx="1.9" ry="3.3" transform="rotate(-61 31.9 25.7)" fill="#F05321"/>
|
||||||
|
<ellipse cx="39.7" cy="27.1" rx="1.9" ry="3.2" transform="rotate(41 39.7 27.1)" fill="#D93616"/>
|
||||||
|
<ellipse cx="28.4" cy="20.6" rx="1.6" ry="2.8" transform="rotate(47 28.4 20.6)" fill="#F15A24"/>
|
||||||
|
<ellipse cx="36.6" cy="20.8" rx="1.6" ry="2.8" transform="rotate(-45 36.6 20.8)" fill="#E7471C"/>
|
||||||
|
</g>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 2.0 KiB |
52
app/static/forgot_password.js
Normal file
52
app/static/forgot_password.js
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBtn = $("resetBtn");
|
||||||
|
|
||||||
|
if (resetBtn) {
|
||||||
|
resetBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(resetBtn, true, "重置密码", "提交中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/password/forgot", {
|
||||||
|
username: ($("username") && $("username").value.trim()) || "",
|
||||||
|
reset_key: ($("resetKey") && $("resetKey").value.trim()) || "",
|
||||||
|
new_password: ($("newPassword") && $("newPassword").value) || "",
|
||||||
|
});
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "重置失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("密码重置成功,2 秒后跳转登录页。");
|
||||||
|
setTimeout(() => {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
}, 2000);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "重置失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(resetBtn, false, "重置密码", "提交中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
36
app/static/guide.js
Normal file
36
app/static/guide.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
38
app/static/mode-hint.js
Normal file
38
app/static/mode-hint.js
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
(() => {
|
||||||
|
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;
|
||||||
|
const activeModel = data.active_ai_model || {};
|
||||||
|
const hasSelfModel =
|
||||||
|
Boolean(String(activeModel.api_key || "").trim()) &&
|
||||||
|
Boolean(String(activeModel.model || "").trim());
|
||||||
|
const hasTrialCredits = Number(vip.total_available_credits || 0) > 0;
|
||||||
|
if (enabled && hasActiveSubscription) {
|
||||||
|
setText("当前模型模式:平台模型");
|
||||||
|
} else if (enabled && !hasSelfModel && hasTrialCredits) {
|
||||||
|
setText("当前模型模式:平台模型(体验版)");
|
||||||
|
} else {
|
||||||
|
setText("当前模型模式:自由模型");
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
})();
|
||||||
97
app/static/profile.js
Normal file
97
app/static/profile.js
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
const $ = (id) => document.getElementById(id);
|
||||||
|
|
||||||
|
function setStatus(msg, danger = false) {
|
||||||
|
const el = $("status");
|
||||||
|
if (!el) return;
|
||||||
|
el.style.color = danger ? "#b42318" : "#0f5f3d";
|
||||||
|
el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setLoading(button, loading, idleText, loadingText) {
|
||||||
|
if (!button) return;
|
||||||
|
button.disabled = loading;
|
||||||
|
button.textContent = loading ? loadingText : idleText;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJSON(url, body) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.detail || "请求失败");
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function authMe() {
|
||||||
|
const res = await fetch("/api/auth/me");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!data.logged_in) {
|
||||||
|
window.location.href = "/auth?next=/profile";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadProfile() {
|
||||||
|
const res = await fetch("/api/profile");
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok || !data.ok) throw new Error(data.detail || "个人信息加载失败");
|
||||||
|
const profile = data.profile || {};
|
||||||
|
if ($("profileSubscriberName")) $("profileSubscriberName").value = profile.subscriber_name || "";
|
||||||
|
if ($("profileSubscriberPhone")) $("profileSubscriberPhone").value = profile.subscriber_phone || "";
|
||||||
|
if ($("profileShippingAddress")) $("profileShippingAddress").value = profile.shipping_address || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
const saveProfileBtn = $("saveProfileBtn");
|
||||||
|
const logoutBtn = $("logoutBtn");
|
||||||
|
|
||||||
|
if (saveProfileBtn) {
|
||||||
|
saveProfileBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(saveProfileBtn, true, "保存个人信息", "保存中...");
|
||||||
|
try {
|
||||||
|
const subscriberName = (($("profileSubscriberName") && $("profileSubscriberName").value) || "").trim();
|
||||||
|
const subscriberPhone = (($("profileSubscriberPhone") && $("profileSubscriberPhone").value) || "").trim();
|
||||||
|
const shippingAddress = (($("profileShippingAddress") && $("profileShippingAddress").value) || "").trim();
|
||||||
|
if (!subscriberName) {
|
||||||
|
setStatus("请填写订阅人姓名", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!shippingAddress) {
|
||||||
|
setStatus("请填写收货地址", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const out = await postJSON("/api/profile", {
|
||||||
|
subscriber_name: subscriberName,
|
||||||
|
subscriber_phone: subscriberPhone,
|
||||||
|
shipping_address: shippingAddress,
|
||||||
|
});
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "保存失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("个人信息已保存。");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "保存失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(saveProfileBtn, false, "保存个人信息", "保存中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (logoutBtn) {
|
||||||
|
logoutBtn.addEventListener("click", async () => {
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/logout", {});
|
||||||
|
} finally {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
const me = await authMe();
|
||||||
|
if (!me) return;
|
||||||
|
await loadProfile();
|
||||||
|
})();
|
||||||
433
app/static/settings.js
Normal file
433
app/static/settings.js
Normal file
@@ -0,0 +1,433 @@
|
|||||||
|
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=/settings";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderAccounts(me) {
|
||||||
|
const sel = $("accountSelect");
|
||||||
|
if (!sel) return;
|
||||||
|
const list = Array.isArray(me.wechat_accounts) ? me.wechat_accounts : [];
|
||||||
|
const active = me.active_wechat_account && me.active_wechat_account.id ? Number(me.active_wechat_account.id) : 0;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (!list.length) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "";
|
||||||
|
opt.textContent = "未绑定公众号";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach((a) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = String(a.id);
|
||||||
|
opt.textContent = `${a.account_name} (${a.appid})`;
|
||||||
|
if ((active && a.id === active) || a.active) opt.selected = true;
|
||||||
|
sel.appendChild(opt);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderModels(me) {
|
||||||
|
const sel = $("modelSelect");
|
||||||
|
if (!sel) return;
|
||||||
|
const list = Array.isArray(me.ai_models) ? me.ai_models : [];
|
||||||
|
const active = me.active_ai_model && me.active_ai_model.id ? Number(me.active_ai_model.id) : 0;
|
||||||
|
sel.innerHTML = "";
|
||||||
|
if (!list.length) {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = "";
|
||||||
|
opt.textContent = "暂无模型配置,请先新增";
|
||||||
|
sel.appendChild(opt);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
list.forEach((m) => {
|
||||||
|
const opt = document.createElement("option");
|
||||||
|
opt.value = String(m.id);
|
||||||
|
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");
|
||||||
|
const bindBtn = $("bindBtn");
|
||||||
|
const deleteWechatBtn = $("deleteWechatBtn");
|
||||||
|
const logoutBtn = $("logoutBtn");
|
||||||
|
const changePwdBtn = $("changePwdBtn");
|
||||||
|
const regenResetCodeBtn = $("regenResetCodeBtn");
|
||||||
|
const deleteAccountBtn = $("deleteAccountBtn");
|
||||||
|
const modelSelect = $("modelSelect");
|
||||||
|
const saveModelBtn = $("saveModelBtn");
|
||||||
|
const deleteModelBtn = $("deleteModelBtn");
|
||||||
|
const saveVipBtn = $("saveVipBtn");
|
||||||
|
const vipRechargeBtn = $("vipRechargeBtn");
|
||||||
|
|
||||||
|
if (accountSelect) {
|
||||||
|
accountSelect.addEventListener("change", async () => {
|
||||||
|
const id = Number(accountSelect.value || 0);
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/wechat/switch", { account_id: id });
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "切换失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("已切换当前公众号。");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "切换失败", true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteWechatBtn) {
|
||||||
|
deleteWechatBtn.addEventListener("click", async () => {
|
||||||
|
const id = Number((accountSelect && accountSelect.value) || 0);
|
||||||
|
if (!id) {
|
||||||
|
setStatus("请先选择要删除的公众号", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sure = await window.uiConfirm("确定删除当前公众号绑定吗?删除后不可恢复。", "删除公众号");
|
||||||
|
if (!sure) return;
|
||||||
|
setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/wechat/delete", { account_id: id });
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "删除失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("公众号账号已删除。");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "删除失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(deleteWechatBtn, false, "删除当前公众号", "删除中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (bindBtn) {
|
||||||
|
bindBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(bindBtn, true, "绑定并设为当前账号", "绑定中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/wechat/bind", {
|
||||||
|
account_name: ($("accountName") && $("accountName").value.trim()) || "",
|
||||||
|
appid: ($("appid") && $("appid").value.trim()) || "",
|
||||||
|
secret: ($("secret") && $("secret").value.trim()) || "",
|
||||||
|
author: "",
|
||||||
|
thumb_media_id: "",
|
||||||
|
thumb_image_path: "",
|
||||||
|
});
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "绑定失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("公众号绑定成功,已切换为当前账号。");
|
||||||
|
if ($("appid")) $("appid").value = "";
|
||||||
|
if ($("secret")) $("secret").value = "";
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "绑定失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(bindBtn, false, "绑定并设为当前账号", "绑定中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modelSelect) {
|
||||||
|
modelSelect.addEventListener("change", async () => {
|
||||||
|
const id = Number(modelSelect.value || 0);
|
||||||
|
if (!id) return;
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/ai-models/switch", { model_id: id });
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "模型切换失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("已切换当前模型。");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "模型切换失败", true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (saveModelBtn) {
|
||||||
|
saveModelBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(saveModelBtn, true, "保存并设为当前模型", "保存中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/ai-models/add", {
|
||||||
|
model_name: ($("modelName") && $("modelName").value.trim()) || "",
|
||||||
|
api_key: ($("apiKey") && $("apiKey").value.trim()) || "",
|
||||||
|
base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "",
|
||||||
|
model: ($("modelValue") && $("modelValue").value.trim()) || "",
|
||||||
|
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()),
|
||||||
|
});
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "模型保存失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("模型配置已保存并设为当前。");
|
||||||
|
if ($("apiKey")) $("apiKey").value = "";
|
||||||
|
if ($("modelName")) $("modelName").value = "";
|
||||||
|
if ($("imageModelValue")) $("imageModelValue").value = "";
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "模型保存失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(saveModelBtn, false, "保存并设为当前模型", "保存中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteModelBtn) {
|
||||||
|
deleteModelBtn.addEventListener("click", async () => {
|
||||||
|
const id = Number((modelSelect && modelSelect.value) || 0);
|
||||||
|
if (!id) {
|
||||||
|
setStatus("请先选择要删除的模型", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sure = await window.uiConfirm("确定删除当前模型配置吗?删除后不可恢复。", "删除模型");
|
||||||
|
if (!sure) return;
|
||||||
|
setLoading(deleteModelBtn, true, "删除当前模型", "删除中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/ai-models/delete", { model_id: id });
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "模型删除失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("模型配置已删除。");
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "模型删除失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(deleteModelBtn, false, "删除当前模型", "删除中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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, "退出登录", "退出中...");
|
||||||
|
try {
|
||||||
|
await postJSON("/api/auth/logout", {});
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "退出失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(logoutBtn, false, "退出登录", "退出中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changePwdBtn) {
|
||||||
|
changePwdBtn.addEventListener("click", async () => {
|
||||||
|
setLoading(changePwdBtn, true, "修改密码", "提交中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/password/change", {
|
||||||
|
old_password: ($("oldPassword") && $("oldPassword").value) || "",
|
||||||
|
new_password: ($("newPassword") && $("newPassword").value) || "",
|
||||||
|
});
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "修改密码失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("密码修改成功。");
|
||||||
|
if ($("oldPassword")) $("oldPassword").value = "";
|
||||||
|
if ($("newPassword")) $("newPassword").value = "";
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "修改密码失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(changePwdBtn, false, "修改密码", "提交中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regenResetCodeBtn) {
|
||||||
|
regenResetCodeBtn.addEventListener("click", async () => {
|
||||||
|
const pwd = await window.uiPrompt("请输入当前登录密码,用于生成新的重置码:", "重新生成重置码", "", "请输入当前密码");
|
||||||
|
if (!pwd) return;
|
||||||
|
setLoading(regenResetCodeBtn, true, "重新生成重置码", "生成中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/reset-code/regenerate", { password: pwd });
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "生成失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const code = String(out.reset_code || "").trim();
|
||||||
|
if (!code) {
|
||||||
|
setStatus("生成失败,请稍后再试", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await window.uiAlert(
|
||||||
|
`新的重置码如下(仅展示这一次):\n\n${code}\n\n请立即复制并妥善保存,旧重置码已失效。`,
|
||||||
|
"重置码已更新"
|
||||||
|
);
|
||||||
|
try {
|
||||||
|
if (navigator.clipboard && navigator.clipboard.writeText) {
|
||||||
|
await navigator.clipboard.writeText(code);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore clipboard failure
|
||||||
|
}
|
||||||
|
setStatus("新重置码已生成并展示一次,请妥善保存。");
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "生成失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(regenResetCodeBtn, false, "重新生成重置码", "生成中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleteAccountBtn) {
|
||||||
|
deleteAccountBtn.addEventListener("click", async () => {
|
||||||
|
const pwd = ($("deletePassword") && $("deletePassword").value) || "";
|
||||||
|
const rkey = ($("deleteResetKey") && $("deleteResetKey").value.trim()) || "";
|
||||||
|
if (!pwd) {
|
||||||
|
setStatus("请输入注销校验密码", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!rkey) {
|
||||||
|
setStatus("请输入注销校验重置码", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const sure = await window.uiConfirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。", "注销账户");
|
||||||
|
if (!sure) return;
|
||||||
|
const confirmText = await window.uiPrompt(
|
||||||
|
"为防止误删,请输入「注销账户」后确认:",
|
||||||
|
"二次确认",
|
||||||
|
"",
|
||||||
|
"请输入:注销账户",
|
||||||
|
);
|
||||||
|
if ((confirmText || "").trim() !== "注销账户") {
|
||||||
|
setStatus("二次确认未通过,已取消注销。", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setLoading(deleteAccountBtn, true, "注销账户", "注销中...");
|
||||||
|
try {
|
||||||
|
const out = await postJSON("/api/auth/account/delete", {
|
||||||
|
password: pwd,
|
||||||
|
reset_key: rkey,
|
||||||
|
});
|
||||||
|
if (!out.ok) {
|
||||||
|
setStatus(out.detail || "注销失败", true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setStatus("账号已注销,正在返回登录页。");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
window.location.href = "/auth?next=/";
|
||||||
|
}, 900);
|
||||||
|
} catch (e) {
|
||||||
|
setStatus(e.message || "注销失败", true);
|
||||||
|
} finally {
|
||||||
|
setLoading(deleteAccountBtn, false, "注销账户", "注销中...");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
refresh();
|
||||||
2610
app/static/style.css
2610
app/static/style.css
File diff suppressed because it is too large
Load Diff
101
app/static/ui-dialog.js
Normal file
101
app/static/ui-dialog.js
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
(() => {
|
||||||
|
function ensureRoot() {
|
||||||
|
let root = document.getElementById("uiDialogRoot");
|
||||||
|
if (root) return root;
|
||||||
|
root = document.createElement("div");
|
||||||
|
root.id = "uiDialogRoot";
|
||||||
|
root.className = "ui-dialog-root";
|
||||||
|
document.body.appendChild(root);
|
||||||
|
return root;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeDialog(resolve, value, overlay) {
|
||||||
|
if (overlay && overlay.parentNode) overlay.parentNode.removeChild(overlay);
|
||||||
|
resolve(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function createDialog({ title, message, mode = "alert", confirmText = "确定", cancelText = "取消", placeholder = "", defaultValue = "" }) {
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const root = ensureRoot();
|
||||||
|
const overlay = document.createElement("div");
|
||||||
|
overlay.className = "ui-dialog-overlay";
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="ui-dialog" role="dialog" aria-modal="true">
|
||||||
|
<h3 class="ui-dialog-title"></h3>
|
||||||
|
<div class="ui-dialog-message"></div>
|
||||||
|
<input class="ui-dialog-input" type="text" />
|
||||||
|
<div class="ui-dialog-actions">
|
||||||
|
<button class="ui-dialog-btn secondary ui-dialog-cancel" type="button">${cancelText}</button>
|
||||||
|
<button class="ui-dialog-btn primary ui-dialog-confirm" type="button">${confirmText}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
root.appendChild(overlay);
|
||||||
|
|
||||||
|
const titleEl = overlay.querySelector(".ui-dialog-title");
|
||||||
|
const msgEl = overlay.querySelector(".ui-dialog-message");
|
||||||
|
const inputEl = overlay.querySelector(".ui-dialog-input");
|
||||||
|
const cancelBtn = overlay.querySelector(".ui-dialog-cancel");
|
||||||
|
const okBtn = overlay.querySelector(".ui-dialog-confirm");
|
||||||
|
if (titleEl) titleEl.textContent = title || "提示";
|
||||||
|
if (msgEl) msgEl.textContent = message || "";
|
||||||
|
if (inputEl) {
|
||||||
|
inputEl.placeholder = placeholder || "";
|
||||||
|
inputEl.value = defaultValue || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "alert") {
|
||||||
|
if (cancelBtn) cancelBtn.style.display = "none";
|
||||||
|
} else if (mode === "confirm") {
|
||||||
|
if (inputEl) inputEl.style.display = "none";
|
||||||
|
} else if (mode === "prompt") {
|
||||||
|
if (inputEl) inputEl.style.display = "block";
|
||||||
|
}
|
||||||
|
|
||||||
|
const onEsc = (e) => {
|
||||||
|
if (e.key === "Escape") {
|
||||||
|
document.removeEventListener("keydown", onEsc);
|
||||||
|
if (mode === "alert") closeDialog(resolve, true, overlay);
|
||||||
|
else closeDialog(resolve, null, overlay);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener("keydown", onEsc);
|
||||||
|
|
||||||
|
if (cancelBtn) {
|
||||||
|
cancelBtn.addEventListener("click", () => {
|
||||||
|
document.removeEventListener("keydown", onEsc);
|
||||||
|
if (mode === "confirm") closeDialog(resolve, false, overlay);
|
||||||
|
else closeDialog(resolve, null, overlay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (okBtn) {
|
||||||
|
okBtn.addEventListener("click", () => {
|
||||||
|
document.removeEventListener("keydown", onEsc);
|
||||||
|
if (mode === "prompt") closeDialog(resolve, inputEl ? inputEl.value : "", overlay);
|
||||||
|
else if (mode === "confirm") closeDialog(resolve, true, overlay);
|
||||||
|
else closeDialog(resolve, true, overlay);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "prompt" && inputEl) {
|
||||||
|
window.setTimeout(() => inputEl.focus(), 0);
|
||||||
|
} else if (okBtn) {
|
||||||
|
window.setTimeout(() => okBtn.focus(), 0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.uiAlert = async (message, title = "提示") => createDialog({ title, message, mode: "alert", confirmText: "我知道了" });
|
||||||
|
window.uiConfirm = async (message, title = "请确认") =>
|
||||||
|
createDialog({ title, message, mode: "confirm", confirmText: "确认", cancelText: "取消" });
|
||||||
|
window.uiPrompt = async (message, title = "请输入", defaultValue = "", placeholder = "") =>
|
||||||
|
createDialog({
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
mode: "prompt",
|
||||||
|
confirmText: "确认",
|
||||||
|
cancelText: "取消",
|
||||||
|
defaultValue,
|
||||||
|
placeholder,
|
||||||
|
});
|
||||||
|
})();
|
||||||
303
app/static/upgrade.js
Normal file
303
app/static/upgrade.js
Normal file
@@ -0,0 +1,303 @@
|
|||||||
|
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);
|
||||||
|
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);
|
||||||
|
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 ($("vipModeHint")) {
|
||||||
|
if (enabled && hasActiveSubscription) {
|
||||||
|
$("vipModeHint").textContent = "当前模型模式:平台模型(订阅有效)";
|
||||||
|
} else if (enabled && !hasActiveSubscription) {
|
||||||
|
$("vipModeHint").textContent = "当前模型模式:自由模型(未开通订阅)";
|
||||||
|
} else {
|
||||||
|
$("vipModeHint").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);
|
||||||
151
app/templates/admin.html
Normal file
151
app/templates/admin.html
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
<!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="stylesheet" href="/static/style.css?v=20260428z" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main class="app-shell">
|
||||||
|
<aside class="side-nav">
|
||||||
|
<div class="side-brand">{{ app_name }}</div>
|
||||||
|
<nav>
|
||||||
|
<a href="/">内容生产</a>
|
||||||
|
<a href="/settings">账号与模型</a>
|
||||||
|
<a href="/billing">账单中心</a>
|
||||||
|
<a href="/upgrade">升级订阅</a>
|
||||||
|
<a href="/profile">个人中心</a>
|
||||||
|
<a href="/guide">新手指引</a>
|
||||||
|
<a class="active" href="/admin?token={{ request.query_params.get('token','') }}">超级管理</a>
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<section class="main-wrap">
|
||||||
|
<header class="topbar topbar-compact">
|
||||||
|
<h1>超级管理后台</h1>
|
||||||
|
<span class="global-mode-hint mode-badge">平台模型与数据库管理</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section class="panel-scroll">
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>用户注册总览</h3>
|
||||||
|
<div class="grid-2" style="margin-top: 12px;">
|
||||||
|
<label>
|
||||||
|
总用户数
|
||||||
|
<input id="adminTotalUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
当前有效用户
|
||||||
|
<input id="adminActiveUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
今日新增
|
||||||
|
<input id="adminTodayUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
已注销用户
|
||||||
|
<input id="adminDeletedUsers" type="text" value="0" readonly />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button id="adminUserRefreshBtn" class="button secondary">刷新用户统计</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>最近注册记录</h3>
|
||||||
|
<div class="billing-table-wrap" style="margin-top: 12px;">
|
||||||
|
<table class="billing-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>用户名</th>
|
||||||
|
<th>注册时间</th>
|
||||||
|
<th>状态</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="adminRecentUsersRows">
|
||||||
|
<tr><td colspan="4" class="muted">暂无记录</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>平台模型配置</h3>
|
||||||
|
<div class="grid-2" style="margin-top: 12px;">
|
||||||
|
<label>
|
||||||
|
API Key
|
||||||
|
<input id="platformApiKey" type="password" placeholder="sk-..." />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
Base URL
|
||||||
|
<input id="platformBaseUrl" type="text" placeholder="https://dashscope.aliyuncs.com/compatible-mode/v1" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
文本模型
|
||||||
|
<input id="platformTextModel" type="text" placeholder="qwen-plus" list="platformTextModelOptions" />
|
||||||
|
<datalist id="platformTextModelOptions"></datalist>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
生图模型
|
||||||
|
<input id="platformImageModel" type="text" placeholder="wanx2.0-t2i-turbo" list="platformImageModelOptions" />
|
||||||
|
<datalist id="platformImageModelOptions"></datalist>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
超时(秒)
|
||||||
|
<input id="platformTimeoutSec" type="number" min="5" max="600" value="120" />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
最大输出 tokens
|
||||||
|
<input id="platformMaxOutputTokens" type="number" min="256" max="65535" value="8192" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button id="platformSaveBtn" class="button">保存平台模型配置</button>
|
||||||
|
<button id="platformReloadBtn" class="button secondary">重新加载</button>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>数据库总览</h3>
|
||||||
|
<div class="grid-2" style="margin-top: 12px;">
|
||||||
|
<label>
|
||||||
|
选择数据表
|
||||||
|
<select id="adminTableSelect" class="ui-select"></select>
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
每页条数
|
||||||
|
<input id="adminLimit" type="number" min="1" max="500" value="100" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div style="margin-top: 12px; display: flex; gap: 8px; flex-wrap: wrap;">
|
||||||
|
<button id="adminPrevBtn" class="button secondary">上一页</button>
|
||||||
|
<button id="adminNextBtn" class="button secondary">下一页</button>
|
||||||
|
<button id="adminRefreshBtn" class="button">刷新</button>
|
||||||
|
</div>
|
||||||
|
<p id="adminStatus" class="muted" style="margin-top: 10px;">准备就绪</p>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="settings-card">
|
||||||
|
<h3>表数据</h3>
|
||||||
|
<div class="billing-table-wrap" style="margin-top: 12px;">
|
||||||
|
<table class="billing-table" id="adminTableData">
|
||||||
|
<thead>
|
||||||
|
<tr id="adminHeadRow"><th>暂无数据</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="adminBodyRows">
|
||||||
|
<tr><td class="muted">请选择一个表后加载数据</td></tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script src="/static/ui-dialog.js?v=20260428p"></script>
|
||||||
|
<script src="/static/admin.js?v=20260428b"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
108
app/templates/auth.html
Normal file
108
app/templates/auth.html
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
<!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 class="simple-page auth-page">
|
||||||
|
<main class="auth-shell">
|
||||||
|
<section class="auth-showcase" aria-label="产品介绍">
|
||||||
|
<div class="brand-lockup auth-brand-lockup">
|
||||||
|
<img class="logo-mark auth-logo" src="/static/favicon.svg?v=20260428h" alt="" />
|
||||||
|
<div>
|
||||||
|
<h1>{{ app_name }}</h1>
|
||||||
|
<p>公众号内容工作台</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-hero-copy">
|
||||||
|
<p class="auth-kicker">AI Content Studio</p>
|
||||||
|
<h2>从原文到草稿,一气呵成</h2>
|
||||||
|
<p>改写、封面、发布,集中完成。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-preview-card">
|
||||||
|
<div class="auth-preview-body">
|
||||||
|
<div class="auth-preview-cover">
|
||||||
|
<img src="/static/favicon.svg?v=20260428h" alt="" />
|
||||||
|
<div>
|
||||||
|
<strong>今日选题</strong>
|
||||||
|
<p>自动生成公众号封面</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="auth-preview-steps">
|
||||||
|
<span>改写</span>
|
||||||
|
<span>封面</span>
|
||||||
|
<span>发布</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-feature-row">
|
||||||
|
<div>
|
||||||
|
<strong>AI 改写</strong>
|
||||||
|
<span>标题、摘要、正文一次成稿</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>封面生成</strong>
|
||||||
|
<span>按标题自动生成头图</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<strong>草稿发布</strong>
|
||||||
|
<span>直达公众号草稿箱</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="auth-panel" aria-label="登录注册">
|
||||||
|
<div class="auth-panel-head">
|
||||||
|
<h2>欢迎回来</h2>
|
||||||
|
<p>登录后继续创作。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-form">
|
||||||
|
<div>
|
||||||
|
<label>用户名</label>
|
||||||
|
<input id="username" type="text" placeholder="4-24 位,字母数字下划线" autocomplete="username" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>密码</label>
|
||||||
|
<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">
|
||||||
|
<input id="rememberMe" type="checkbox" checked />
|
||||||
|
<span>7 天内免登录</span>
|
||||||
|
</label>
|
||||||
|
<a class="auth-link" href="/auth/forgot">忘记密码?</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="loginBtn" class="primary" type="button">登录工作台</button>
|
||||||
|
<button id="registerBtn" class="secondary" type="button">注册账号</button>
|
||||||
|
</div>
|
||||||
|
<p id="status" class="status"></p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="auth-footnote">首次使用建议先完成公众号与模型配置。</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.__NEXT_PATH__ = {{ next|tojson }};
|
||||||
|
</script>
|
||||||
|
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
||||||
|
<script src="/static/auth.js?v=20260428l"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
102
app/templates/billing.html
Normal file
102
app/templates/billing.html
Normal file
@@ -0,0 +1,102 @@
|
|||||||
|
<!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">
|
||||||
|
<span class="mode-badge global-mode-hint">当前模型模式:自由模型</span>
|
||||||
|
<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/mode-hint.js?v=20260428a"></script>
|
||||||
|
<script src="/static/billing.js?v=20260428w"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
50
app/templates/forgot_password.html
Normal file
50
app/templates/forgot_password.html
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
<!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 class="simple-page">
|
||||||
|
<main class="auth-card">
|
||||||
|
<section class="panel simple-panel">
|
||||||
|
<div class="simple-head">
|
||||||
|
<div class="brand-lockup simple-brand-lockup">
|
||||||
|
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
|
||||||
|
<h1>重置密码</h1>
|
||||||
|
</div>
|
||||||
|
<p class="muted small">使用注册时保存的个人重置码恢复账号访问。</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="simple-body">
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>用户名</label>
|
||||||
|
<input id="username" type="text" placeholder="请输入账号用户名" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>重置码</label>
|
||||||
|
<input id="resetKey" type="password" placeholder="请输入你保存的个人重置码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>新密码</label>
|
||||||
|
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
|
||||||
|
</div>
|
||||||
|
<p class="muted small">重置码仅在注册时展示一次,请妥善保存。</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="resetBtn" class="primary" type="button">重置密码</button>
|
||||||
|
</div>
|
||||||
|
<p id="status" class="status"></p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="subtle-link" href="/auth?next=/">返回登录页</a>
|
||||||
|
<a class="subtle-link" href="/settings">去设置页</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
<script src="/static/forgot_password.js?v=20260410b"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
154
app/templates/guide.html
Normal file
154
app/templates/guide.html
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
<!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" href="/profile">个人中心</a>
|
||||||
|
<a class="nav-item is-active" href="/guide">新手引导</a>
|
||||||
|
</nav>
|
||||||
|
<div class="side-footer">首次配置 · 三分钟跑通</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<header class="topbar topbar-compact">
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="mode-badge global-mode-hint">当前模型模式:自由模型</span>
|
||||||
|
<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>
|
||||||
|
|
||||||
|
<main class="layout guide-layout">
|
||||||
|
<section class="panel guide-panel">
|
||||||
|
<div class="panel-scroll guide-scroll">
|
||||||
|
<section class="guide-hero">
|
||||||
|
<div>
|
||||||
|
<p class="guide-eyebrow">新手引导</p>
|
||||||
|
<h2>从空账号到第一篇公众号草稿</h2>
|
||||||
|
<p class="muted">按下面顺序完成配置、生成、检查和发布。每一步都对应当前项目里的真实页面和按钮。</p>
|
||||||
|
</div>
|
||||||
|
<div class="guide-hero-actions">
|
||||||
|
<a class="subtle-link" href="/settings">先去配置</a>
|
||||||
|
<a class="subtle-link" href="/">开始写作</a>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-grid">
|
||||||
|
<article class="guide-card">
|
||||||
|
<div class="guide-step">01</div>
|
||||||
|
<h3>准备发布账号</h3>
|
||||||
|
<p>进入账号与模型设置,绑定公众号 AppID 和 Secret。草稿发布、封面上传、段落海报素材都会使用当前选中的发表主体。</p>
|
||||||
|
<p class="muted small">
|
||||||
|
不知道哪里查看 ID?可先阅读微信文档:
|
||||||
|
<a href="https://developers.weixin.qq.com/doc/oplatform/developers/product/subscription_service/appid.html" target="_blank" rel="noopener noreferrer">查看 AppID / AppSecret 获取说明</a>
|
||||||
|
</p>
|
||||||
|
<a href="/settings" class="guide-link">打开账号设置</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="guide-card">
|
||||||
|
<div class="guide-step">02</div>
|
||||||
|
<h3>配置 API IP 白名单</h3>
|
||||||
|
<p>若第三方模型平台开启了 API IP 白名单,请先把当前服务器 IP 加入白名单,避免接口请求被拒绝。</p>
|
||||||
|
<button id="getServerIpBtn" class="guide-link" type="button">获取服务器IP</button>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="guide-card">
|
||||||
|
<div class="guide-step">03</div>
|
||||||
|
<h3>配置 AI 模型</h3>
|
||||||
|
<p>保存模型名称、API Key、Base URL、超时秒数和输出 token 上限。未配置模型时将无法进行 AI 改写,请先完成模型配置。</p>
|
||||||
|
<a href="/settings#model-settings" class="guide-link">打开模型配置</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="guide-card">
|
||||||
|
<div class="guide-step">04</div>
|
||||||
|
<h3>输入原文与策略</h3>
|
||||||
|
<p>在内容生产页粘贴原文,补充标题提示、目标读者、语气风格、必须保留观点和避免词汇。目标字数建议先从 500 或 800 开始。</p>
|
||||||
|
<a href="/" class="guide-link">进入写作输入</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="guide-card">
|
||||||
|
<div class="guide-step">05</div>
|
||||||
|
<h3>生成并人工复核</h3>
|
||||||
|
<p>点击“改写并排版”后,检查标题、摘要、正文结构和排版预览。涉及事实、数据、引用和品牌表达时,发布前务必人工确认。</p>
|
||||||
|
<a href="/" class="guide-link">查看发布内容</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="guide-card">
|
||||||
|
<div class="guide-step">06</div>
|
||||||
|
<h3>补齐封面和海报</h3>
|
||||||
|
<p>可按输出标题自动生成 900×383 公众号封面并绑定 thumb_media_id,也可以生成段落海报。勾选自动插入后,发布草稿时会把正文和海报一起编排。</p>
|
||||||
|
<a href="/" class="guide-link">处理内容素材</a>
|
||||||
|
</article>
|
||||||
|
|
||||||
|
<article class="guide-card">
|
||||||
|
<div class="guide-step">07</div>
|
||||||
|
<h3>发布到草稿箱</h3>
|
||||||
|
<p>确认发表主体无误后,点击“发布到公众号草稿箱”。需要团队同步时,再点击“发送到 IM”。草稿发布后仍建议在公众号后台最终预览。</p>
|
||||||
|
<a href="/" class="guide-link">回到发布动作</a>
|
||||||
|
</article>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-checklist">
|
||||||
|
<div class="guide-section-head">
|
||||||
|
<h3>发布前检查</h3>
|
||||||
|
<p class="muted small">适合每次出稿前快速扫一遍。</p>
|
||||||
|
</div>
|
||||||
|
<div class="checklist-grid">
|
||||||
|
<label class="check-label"><input type="checkbox" />发表主体是目标公众号</label>
|
||||||
|
<label class="check-label"><input type="checkbox" />标题没有夸大或误导</label>
|
||||||
|
<label class="check-label"><input type="checkbox" />摘要能独立说明文章价值</label>
|
||||||
|
<label class="check-label"><input type="checkbox" />正文事实、数据、引用已核对</label>
|
||||||
|
<label class="check-label"><input type="checkbox" />封面或默认封面策略可接受</label>
|
||||||
|
<label class="check-label"><input type="checkbox" />段落海报插入位置符合预期</label>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="guide-faq">
|
||||||
|
<div class="guide-section-head">
|
||||||
|
<h3>常见问题</h3>
|
||||||
|
<p class="muted small">这些是新账号最常遇到的卡点。</p>
|
||||||
|
</div>
|
||||||
|
<details>
|
||||||
|
<summary>提示未绑定公众号怎么办?</summary>
|
||||||
|
<p>进入账号与模型设置,新增公众号并设为当前账号。绑定后回到内容生产页,顶部发表主体会显示当前账号。</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>模型不可用或生成失败怎么办?</summary>
|
||||||
|
<p>检查 API Key、Base URL、模型名、超时秒数和输出 token 上限。第三方兼容接口通常需要填写完整 Base URL。</p>
|
||||||
|
</details>
|
||||||
|
<details>
|
||||||
|
<summary>发布成功后在哪里继续编辑?</summary>
|
||||||
|
<p>内容会进入公众号草稿箱。最终标题、封面、排版和群发前预览,建议在公众号后台完成最后确认。</p>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/ui-dialog.js?v=20260428a"></script>
|
||||||
|
<script src="/static/mode-hint.js?v=20260428a"></script>
|
||||||
|
<script src="/static/guide.js?v=20260428a"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -4,65 +4,253 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ app_name }}</title>
|
<title>{{ app_name }}</title>
|
||||||
<link rel="stylesheet" href="/static/style.css" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||||
|
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<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 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>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<header class="topbar topbar-compact">
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="mode-badge global-mode-hint">当前模型模式:自由模型</span>
|
||||||
|
<div class="wechat-account-switch" title="发布将使用当前账号">
|
||||||
|
<label for="wechatAccountSelect" class="wechat-account-label">发表主体</label>
|
||||||
|
<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>
|
||||||
|
|
||||||
<main class="layout">
|
<main class="layout">
|
||||||
<section class="panel input-panel">
|
<section class="panel input-panel">
|
||||||
<h1>{{ app_name }}</h1>
|
<div class="panel-head">
|
||||||
<p class="muted">粘贴 X 上的优质内容,生成公众号可发布版本,并支持同步到 IM。</p>
|
<h2>写作输入</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label>原始内容</label>
|
<div class="panel-scroll">
|
||||||
<textarea id="sourceText" rows="14" placeholder="粘贴 X 长文/线程内容..."></textarea>
|
<section class="form-section">
|
||||||
|
<div class="field-head">
|
||||||
|
<label>内容</label>
|
||||||
|
<span id="sourceCount" class="meta">0 字</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="sourceText" rows="5" placeholder="粘贴原文内容"></textarea>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div>
|
<div>
|
||||||
<label>标题提示</label>
|
<label>标题提示</label>
|
||||||
<input id="titleHint" type="text" placeholder="如:AI Agent 商业化路径" />
|
<input id="titleHint" type="text" placeholder="可选:标题方向" />
|
||||||
|
</div>
|
||||||
|
<div class="multi-field">
|
||||||
|
<div class="field-head">
|
||||||
|
<label>目标读者</label>
|
||||||
|
<span class="meta">下拉多选</span>
|
||||||
|
</div>
|
||||||
|
<details class="multi-dropdown" id="audienceDetails">
|
||||||
|
<summary>
|
||||||
|
<span class="multi-dropdown-text" id="audienceSummary"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="multi-dropdown-body" role="group" aria-label="目标读者选项">
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="公众号运营者" checked />公众号运营者</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="产品经理" checked />产品经理</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="技术开发者" />技术开发者</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="创业者" />创业者</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="学生与研究者" />学生与研究者</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="普通读者" />普通读者</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<input id="audienceExtra" type="text" class="multi-extra" placeholder="可选补充" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="grid2">
|
||||||
|
<div class="multi-field">
|
||||||
|
<div class="field-head">
|
||||||
|
<label>语气风格</label>
|
||||||
|
<span class="meta">下拉多选</span>
|
||||||
|
</div>
|
||||||
|
<details class="multi-dropdown" id="toneDetails">
|
||||||
|
<summary>
|
||||||
|
<span class="multi-dropdown-text" id="toneSummary"></span>
|
||||||
|
</summary>
|
||||||
|
<div class="multi-dropdown-body" role="group" aria-label="语气风格选项">
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="专业严谨" checked />专业严谨</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="有观点" checked />有观点</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="口语自然" checked />口语自然</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="轻松幽默" />轻松幽默</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="故事化叙事" />故事化叙事</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="科普解读" />科普解读</label>
|
||||||
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="理性克制" />理性克制</label>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<input id="toneExtra" type="text" class="multi-extra" placeholder="可选补充" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>目标读者</label>
|
<label>避免词汇</label>
|
||||||
<input id="audience" type="text" value="公众号运营者/产品经理" />
|
<input id="avoidWords" type="text" placeholder="可选:避免词汇" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div>
|
<div>
|
||||||
<label>语气风格</label>
|
|
||||||
<input id="tone" type="text" value="专业、有观点、口语自然" />
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>避免词汇</label>
|
|
||||||
<input id="avoidWords" type="text" placeholder="如:颠覆、闭环、赋能" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<label>必须保留观点</label>
|
<label>必须保留观点</label>
|
||||||
<input id="keepPoints" type="text" placeholder="逗号分隔" />
|
<input id="keepPoints" type="text" placeholder="可选:保留观点(逗号分隔)" />
|
||||||
|
</div>
|
||||||
|
<div class="target-chars-block">
|
||||||
|
<label>改写目标字数</label>
|
||||||
|
<div class="target-chars-inline">
|
||||||
|
<input id="targetBodyChars" type="number" min="180" max="2200" step="10" value="500" placeholder="目标字数" />
|
||||||
|
<div class="target-chars-quick" aria-label="快捷字数">
|
||||||
|
<button type="button" class="target-char-chip" data-target-chars="300">300</button>
|
||||||
|
<button type="button" class="target-char-chip is-active" data-target-chars="500">500</button>
|
||||||
|
<button type="button" class="target-char-chip" data-target-chars="800">800</button>
|
||||||
|
<button type="button" class="target-char-chip" data-target-chars="1200">1200</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<button id="rewriteBtn" class="primary">AI 改写</button>
|
<section class="form-section">
|
||||||
|
<button id="rewriteBtn" class="primary">改写并排版</button>
|
||||||
<p id="status" class="status"></p>
|
<p id="status" class="status"></p>
|
||||||
</section>
|
</section>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section class="panel output-panel">
|
<section class="panel output-panel">
|
||||||
|
<div class="panel-head">
|
||||||
<h2>发布内容</h2>
|
<h2>发布内容</h2>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-scroll">
|
||||||
|
<section class="form-section">
|
||||||
<label>标题</label>
|
<label>标题</label>
|
||||||
<input id="title" type="text" />
|
<input id="title" type="text" />
|
||||||
|
|
||||||
|
<div class="field-head">
|
||||||
<label>摘要</label>
|
<label>摘要</label>
|
||||||
<textarea id="summary" rows="3"></textarea>
|
<span id="summaryCount" class="meta">0 字</span>
|
||||||
|
</div>
|
||||||
|
<textarea id="summary" rows="2"></textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
<label>Markdown 正文</label>
|
<section class="form-section">
|
||||||
<textarea id="body" rows="16"></textarea>
|
<div class="field-head">
|
||||||
|
<label>公众号封面</label>
|
||||||
|
<span class="meta">900×383 横版头图</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-mode-switch" role="tablist" aria-label="封面模式切换">
|
||||||
|
<button id="coverModeManualBtn" class="cover-mode-btn is-active" type="button">手动上传封面</button>
|
||||||
|
<button id="coverModeAiBtn" class="cover-mode-btn" type="button">AI 自动生成封面</button>
|
||||||
|
</div>
|
||||||
|
<div id="coverAiSection" class="cover-ai-box" hidden>
|
||||||
|
<div class="cover-ai-copy">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
<label class="check-label cover-auto-check"
|
||||||
|
><input id="coverAutoAfterRewrite" type="checkbox" />改写后自动按输出标题生成封面</label
|
||||||
|
>
|
||||||
|
<div id="coverPreviewWrap" class="cover-preview-wrap" hidden>
|
||||||
|
<img id="coverPreview" class="cover-preview" alt="公众号封面预览" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="coverManualSection">
|
||||||
|
<label>手动上传封面</label>
|
||||||
|
<div class="cover-tools">
|
||||||
|
<input id="coverFile" type="file" accept="image/png,image/jpeg,image/jpg,image/webp" />
|
||||||
|
<button id="coverUploadBtn" class="subtle-btn" type="button">上传封面并绑定</button>
|
||||||
|
</div>
|
||||||
|
<div class="cover-tools">
|
||||||
|
<input id="coverUrl" type="url" placeholder="图片 URL(http/https)" />
|
||||||
|
<button id="coverUrlUploadBtn" class="subtle-btn" type="button">URL 上传并绑定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input id="thumbMediaId" type="text" placeholder="thumb_media_id(可选)" />
|
||||||
|
<p id="coverHint" class="muted small">当前为手动上传模式。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
<div class="actions">
|
<section class="form-section">
|
||||||
<button id="wechatBtn">发布到公众号草稿箱</button>
|
<div class="field-head">
|
||||||
<button id="imBtn">发送到 IM</button>
|
<label>正文</label>
|
||||||
|
<span id="bodyCount" class="meta">0 字</span>
|
||||||
|
</div>
|
||||||
|
<div class="body-split">
|
||||||
|
<textarea id="body" rows="6" placeholder="可直接编辑正文"></textarea>
|
||||||
|
<div class="preview-panel">
|
||||||
|
<div class="field-head">
|
||||||
|
<label>排版预览</label>
|
||||||
|
<span class="meta">实时</span>
|
||||||
|
</div>
|
||||||
|
<div id="bodyPreview" class="markdown-preview"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="poster-tools">
|
||||||
|
<div class="field-head">
|
||||||
|
<label>段落海报(首段不生成)</label>
|
||||||
|
<span class="meta">自动上传</span>
|
||||||
|
</div>
|
||||||
|
<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" />发布时自动插入海报</label
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
<p id="posterHint" class="muted small">默认不生成海报,点击“生成段落海报”后再插入发布。</p>
|
||||||
|
<div id="posterPreviewList" class="poster-preview-list"></div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="actions publish-actions">
|
||||||
|
<button id="wechatBtn" class="primary">发布到公众号草稿箱</button>
|
||||||
|
<button id="imBtn" class="secondary">发送到 IM</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/static/app.js"></script>
|
<script src="/static/mode-hint.js?v=20260428a"></script>
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||||
|
<script src="/static/app.js?v=20260428s"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
77
app/templates/profile.html
Normal file
77
app/templates/profile.html
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<!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">
|
||||||
|
<span class="mode-badge global-mode-hint">当前模型模式:自由模型</span>
|
||||||
|
<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/mode-hint.js?v=20260428a"></script>
|
||||||
|
<script src="/static/profile.js?v=20260428a"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
166
app/templates/settings.html
Normal file
166
app/templates/settings.html
Normal file
@@ -0,0 +1,166 @@
|
|||||||
|
<!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 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>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<div class="workspace">
|
||||||
|
<header class="topbar topbar-compact">
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<span class="mode-badge global-mode-hint">当前模型模式:自由模型</span>
|
||||||
|
<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>
|
||||||
|
</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">
|
||||||
|
<div>
|
||||||
|
<label>当前账号</label>
|
||||||
|
<select id="accountSelect" class="ui-select"></select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="section-title">新增公众号</h3>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>账号名</label>
|
||||||
|
<input id="accountName" type="text" placeholder="如:公司主号 / 客户A号" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>AppID</label>
|
||||||
|
<input id="appid" type="text" placeholder="请输入公众号 AppID" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>Secret</label>
|
||||||
|
<input id="secret" type="password" placeholder="请输入公众号 Secret" />
|
||||||
|
</div>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="bindBtn" class="primary" type="button">绑定并设为当前账号</button>
|
||||||
|
<button id="deleteWechatBtn" class="secondary" type="button">删除当前公众号</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="model-settings" class="settings-section settings-card">
|
||||||
|
<h3 class="section-title">AI 模型配置</h3>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>当前模型</label>
|
||||||
|
<select id="modelSelect" class="ui-select"></select>
|
||||||
|
</div>
|
||||||
|
<div class="actions-inline">
|
||||||
|
<button id="deleteModelBtn" class="secondary topbar-btn" type="button">删除当前模型</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>配置名称</label>
|
||||||
|
<input id="modelName" type="text" placeholder="如:OpenAI 生产 / 阿里云通义" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>文本模型名</label>
|
||||||
|
<input id="modelValue" type="text" placeholder="如:gpt-4.1-mini / qwen-max" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>生图模型名</label>
|
||||||
|
<input id="imageModelValue" type="text" placeholder="如:wanx2.0-t2i-turbo / gpt-image-1" />
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>Base URL(可选)</label>
|
||||||
|
<input id="baseUrl" type="text" placeholder="如:https://dashscope.aliyuncs.com/compatible-mode/v1" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>API Key</label>
|
||||||
|
<input id="apiKey" type="password" placeholder="请输入该模型的 API Key" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>超时秒数</label>
|
||||||
|
<input id="timeoutSec" type="number" min="10" max="600" step="1" value="120" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>输出 token 上限</label>
|
||||||
|
<input id="maxOutputTokens" type="number" min="256" max="65536" step="1" value="8192" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>自动重试次数</label>
|
||||||
|
<input id="maxRetries" type="number" min="0" max="5" step="1" value="0" />
|
||||||
|
</div>
|
||||||
|
<button id="saveModelBtn" class="primary" type="button">保存并设为当前模型</button>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section id="security-settings" class="settings-section settings-card">
|
||||||
|
<h3 class="section-title">账号安全</h3>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>当前密码</label>
|
||||||
|
<input id="oldPassword" type="password" placeholder="请输入当前密码" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>新密码</label>
|
||||||
|
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-inline">
|
||||||
|
<a class="subtle-link" href="/auth/forgot">忘记密码提示</a>
|
||||||
|
<button id="regenResetCodeBtn" class="secondary topbar-btn" type="button">重新生成重置码</button>
|
||||||
|
<button id="changePwdBtn" class="secondary topbar-btn" type="button">修改密码</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>注销校验密码</label>
|
||||||
|
<input id="deletePassword" type="password" placeholder="请输入当前登录密码" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>注销校验重置码</label>
|
||||||
|
<input id="deleteResetKey" type="password" placeholder="请输入你的重置码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="deleteAccountBtn" class="danger" type="button">注销账户</button>
|
||||||
|
<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/mode-hint.js?v=20260428a"></script>
|
||||||
|
<script src="/static/settings.js?v=20260428s"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
182
app/templates/upgrade.html
Normal file
182
app/templates/upgrade.html
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
<!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=20260428zd" />
|
||||||
|
</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 upgrade-topbar">
|
||||||
|
<div class="topbar-spacer" aria-hidden="true"></div>
|
||||||
|
<div class="topbar-center">
|
||||||
|
<span id="vipModeHint" class="mode-badge global-mode-hint">当前模型模式:自由模型</span>
|
||||||
|
</div>
|
||||||
|
<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/mode-hint.js?v=20260428a"></script>
|
||||||
|
<script src="/static/upgrade.js?v=20260428ag"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1
data/.gitkeep
Normal file
1
data/.gitkeep
Normal file
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
103
deploy.prod.sh
Executable file
103
deploy.prod.sh
Executable file
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -Eeuo pipefail
|
||||||
|
|
||||||
|
# Pure production deploy:
|
||||||
|
# - Always uses remote branch as source of truth
|
||||||
|
# - Discards local changes on server
|
||||||
|
# - Non-interactive, fail-fast
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
BRANCH_INPUT="${1:-}"
|
||||||
|
REMOTE="${2:-origin}"
|
||||||
|
|
||||||
|
echo "==> [prod] Project: $PROJECT_ROOT"
|
||||||
|
echo "==> [prod] Remote: $REMOTE"
|
||||||
|
echo "==> [prod] Branch: ${BRANCH_INPUT:-<auto>}"
|
||||||
|
|
||||||
|
if [[ ! -d ".git" ]]; then
|
||||||
|
echo "Error: current directory is not a git repository."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if ! git remote get-url "$REMOTE" >/dev/null 2>&1; then
|
||||||
|
echo "Error: git remote '$REMOTE' does not exist."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> [prod] Fetching latest code"
|
||||||
|
git fetch "$REMOTE" --prune
|
||||||
|
|
||||||
|
resolve_default_branch() {
|
||||||
|
local remote="$1"
|
||||||
|
local head_ref
|
||||||
|
head_ref="$(git symbolic-ref -q --short "refs/remotes/$remote/HEAD" 2>/dev/null || true)"
|
||||||
|
if [[ -n "$head_ref" ]]; then
|
||||||
|
echo "${head_ref#${remote}/}"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
head_ref="$(git remote show "$remote" 2>/dev/null | awk '/HEAD branch/ {print $NF; exit}')"
|
||||||
|
if [[ -n "$head_ref" ]]; then
|
||||||
|
echo "$head_ref"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -n "$BRANCH_INPUT" ]]; then
|
||||||
|
BRANCH="$BRANCH_INPUT"
|
||||||
|
else
|
||||||
|
BRANCH="$(resolve_default_branch "$REMOTE" || true)"
|
||||||
|
if [[ -z "$BRANCH" ]]; then
|
||||||
|
BRANCH="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
|
||||||
|
fi
|
||||||
|
if [[ -z "$BRANCH" || "$BRANCH" == "HEAD" ]]; then
|
||||||
|
echo "Error: could not determine deploy branch. Please pass one explicitly:"
|
||||||
|
echo " sh deploy.prod.sh <branch> [remote]"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> [prod] Using branch: $BRANCH"
|
||||||
|
|
||||||
|
if ! git show-ref --verify --quiet "refs/remotes/$REMOTE/$BRANCH"; then
|
||||||
|
echo "Error: remote branch '$REMOTE/$BRANCH' not found."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
PREV_COMMIT="$(git rev-parse --short HEAD 2>/dev/null || echo unknown)"
|
||||||
|
echo "==> [prod] Current commit: $PREV_COMMIT"
|
||||||
|
|
||||||
|
echo "==> [prod] Force sync to $REMOTE/$BRANCH (discard local changes)"
|
||||||
|
git checkout -B "$BRANCH" "$REMOTE/$BRANCH"
|
||||||
|
git reset --hard "$REMOTE/$BRANCH"
|
||||||
|
git clean -fd
|
||||||
|
|
||||||
|
NEW_COMMIT="$(git rev-parse --short HEAD)"
|
||||||
|
echo "==> [prod] New commit: $NEW_COMMIT"
|
||||||
|
|
||||||
|
if [[ ! -f ".env" && -f ".env.example" ]]; then
|
||||||
|
cp ".env.example" ".env"
|
||||||
|
echo "==> [prod] Created .env from .env.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE_CMD="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE_CMD="docker-compose"
|
||||||
|
else
|
||||||
|
COMPOSE_CMD=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$COMPOSE_CMD" ]]; then
|
||||||
|
echo "==> [prod] Restarting services with $COMPOSE_CMD"
|
||||||
|
$COMPOSE_CMD down
|
||||||
|
$COMPOSE_CMD up -d --build --remove-orphans
|
||||||
|
echo "==> [prod] Deploy success: http://localhost:18000"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> [prod] docker compose not found, fallback to ./start.sh"
|
||||||
|
chmod +x ./start.sh
|
||||||
|
./start.sh
|
||||||
67
deploy.sh
Executable file
67
deploy.sh
Executable file
@@ -0,0 +1,67 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
|
cd "$PROJECT_ROOT"
|
||||||
|
|
||||||
|
resolve_default_branch() {
|
||||||
|
local current
|
||||||
|
current="$(git branch --show-current 2>/dev/null || true)"
|
||||||
|
if [[ -n "$current" ]]; then
|
||||||
|
echo "$current"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
local remote_head
|
||||||
|
remote_head="$(git symbolic-ref --short refs/remotes/origin/HEAD 2>/dev/null || true)"
|
||||||
|
if [[ -n "$remote_head" ]]; then
|
||||||
|
echo "${remote_head#origin/}"
|
||||||
|
return
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "main"
|
||||||
|
}
|
||||||
|
|
||||||
|
TARGET_BRANCH="${1:-$(resolve_default_branch)}"
|
||||||
|
|
||||||
|
echo "==> Deploy directory: $PROJECT_ROOT"
|
||||||
|
echo "==> Target branch: $TARGET_BRANCH"
|
||||||
|
|
||||||
|
if [[ -n "$(git status --porcelain)" ]]; then
|
||||||
|
STASH_NAME="auto-deploy-$(date +%Y%m%d-%H%M%S)"
|
||||||
|
echo "==> Working tree is not clean, auto stashing: $STASH_NAME"
|
||||||
|
git stash push -u -m "$STASH_NAME" >/dev/null
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "==> Fetching remote updates..."
|
||||||
|
git fetch --all --prune
|
||||||
|
|
||||||
|
echo "==> Checking out branch: $TARGET_BRANCH"
|
||||||
|
git checkout "$TARGET_BRANCH"
|
||||||
|
|
||||||
|
echo "==> Pulling latest code from origin/$TARGET_BRANCH"
|
||||||
|
git pull --ff-only origin "$TARGET_BRANCH"
|
||||||
|
|
||||||
|
if [[ ! -f ".env" && -f ".env.example" ]]; then
|
||||||
|
cp ".env.example" ".env"
|
||||||
|
echo "==> Created .env from .env.example"
|
||||||
|
fi
|
||||||
|
|
||||||
|
if docker compose version >/dev/null 2>&1; then
|
||||||
|
COMPOSE_CMD="docker compose"
|
||||||
|
elif command -v docker-compose >/dev/null 2>&1; then
|
||||||
|
COMPOSE_CMD="docker-compose"
|
||||||
|
else
|
||||||
|
COMPOSE_CMD=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$COMPOSE_CMD" ]]; then
|
||||||
|
echo "==> Restarting service with $COMPOSE_CMD"
|
||||||
|
$COMPOSE_CMD down
|
||||||
|
$COMPOSE_CMD up -d --build
|
||||||
|
echo "==> Deployment finished. Service: http://localhost:18000"
|
||||||
|
else
|
||||||
|
echo "==> Docker Compose not found, fallback to ./start.sh"
|
||||||
|
chmod +x ./start.sh
|
||||||
|
./start.sh
|
||||||
|
fi
|
||||||
@@ -1,9 +1,19 @@
|
|||||||
services:
|
services:
|
||||||
x2wechat:
|
x2wechat:
|
||||||
build: .
|
build:
|
||||||
|
context: .
|
||||||
|
args:
|
||||||
|
# 海外 PyPI 可改为 https://pypi.org/simple
|
||||||
|
PIP_INDEX_URL: https://pypi.tuna.tsinghua.edu.cn/simple
|
||||||
|
# 拉 python 镜像慢时取消下一行注释(DaoCloud 同步 Docker Hub)
|
||||||
|
# PY_BASE: docker.m.daocloud.io/library/python:3.11-slim
|
||||||
container_name: x2wechat-studio
|
container_name: x2wechat-studio
|
||||||
ports:
|
ports:
|
||||||
- "18000:8000"
|
- "18000:8000"
|
||||||
env_file:
|
env_file:
|
||||||
- .env
|
- .env
|
||||||
|
environment:
|
||||||
|
AUTH_DB_PATH: /app/data/app.db
|
||||||
|
volumes:
|
||||||
|
- ./data:/app/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|||||||
38
readme.md
38
readme.md
@@ -1,4 +1,4 @@
|
|||||||
# X2WeChat Studio
|
# AI发糕
|
||||||
|
|
||||||
把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。
|
把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。
|
||||||
|
|
||||||
@@ -6,12 +6,12 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 填写 .env 中的 OPENAI / 微信 / IM 参数
|
|
||||||
|
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问:`http://localhost:8000`
|
启动后访问:`http://localhost:18000`
|
||||||
|
|
||||||
|
容器默认将数据库挂载到宿主机目录 `./data`(`AUTH_DB_PATH=/app/data/app.db`),更新容器镜像不会清空历史账号和会话数据。
|
||||||
|
|
||||||
## 2. 使用流程
|
## 2. 使用流程
|
||||||
|
|
||||||
@@ -20,17 +20,33 @@ docker compose up --build
|
|||||||
3. 点击 `发布到公众号草稿箱`。
|
3. 点击 `发布到公众号草稿箱`。
|
||||||
4. 可选点击 `发送到 IM` 同步到团队群。
|
4. 可选点击 `发送到 IM` 同步到团队群。
|
||||||
|
|
||||||
## 3. 环境变量说明
|
## 3. 配置说明
|
||||||
|
|
||||||
- `OPENAI_API_KEY`:AI 改写能力。
|
- `AI 模型配置`、`公众号 AppID/Secret`:由用户在“账号与模型”页面录入,不再依赖 `.env`。
|
||||||
- `OPENAI_BASE_URL`:可选,兼容第三方网关。
|
|
||||||
- `OPENAI_MODEL`:默认 `gpt-4.1-mini`。
|
|
||||||
- `WECHAT_APPID` / `WECHAT_SECRET`:公众号发布必填。
|
|
||||||
- `WECHAT_AUTHOR`:草稿默认作者名。
|
|
||||||
- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。
|
- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。
|
||||||
- `IM_SECRET`:可选签名。
|
- `IM_SECRET`:可选签名。
|
||||||
|
- `AUTH_DB_PATH`:账号数据库文件路径(SQLite)。
|
||||||
|
- `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。
|
||||||
|
- `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。
|
||||||
|
|
||||||
## 4. 说明
|
## 4. 说明
|
||||||
|
|
||||||
- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程。
|
- 未配置用户级 AI 模型时,改写接口会提示先去“账号与模型”页面完成配置。
|
||||||
- 建议发布前人工复核事实与引用,避免版权和失真风险。
|
- 建议发布前人工复核事实与引用,避免版权和失真风险。
|
||||||
|
- 登录页支持“限时免登”,设置页支持修改密码;忘记密码页支持通过“用户名 + 重置码”重置密码。
|
||||||
|
|
||||||
|
## 5. 数据备份与恢复
|
||||||
|
|
||||||
|
数据库文件默认在 `./data/app.db`,可直接备份该文件:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp ./data/app.db ./data/app.db.bak.$(date +%Y%m%d_%H%M%S)
|
||||||
|
```
|
||||||
|
|
||||||
|
恢复时停止服务后覆盖回去:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose down
|
||||||
|
cp ./data/app.db.bak.YYYYMMDD_HHMMSS ./data/app.db
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ httpx==0.28.1
|
|||||||
openai==1.108.2
|
openai==1.108.2
|
||||||
markdown2==2.5.4
|
markdown2==2.5.4
|
||||||
python-multipart==0.0.20
|
python-multipart==0.0.20
|
||||||
|
Pillow>=10.0.0
|
||||||
|
|||||||
3
start.sh
3
start.sh
@@ -24,7 +24,7 @@ else
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Starting X2WeChat Studio..."
|
echo "Starting AI发糕..."
|
||||||
$COMPOSE_CMD up --build -d
|
$COMPOSE_CMD up --build -d
|
||||||
|
|
||||||
echo "Service is starting in background."
|
echo "Service is starting in background."
|
||||||
@@ -33,3 +33,4 @@ echo
|
|||||||
echo "Useful commands:"
|
echo "Useful commands:"
|
||||||
echo " $COMPOSE_CMD logs -f"
|
echo " $COMPOSE_CMD logs -f"
|
||||||
echo " $COMPOSE_CMD down"
|
echo " $COMPOSE_CMD down"
|
||||||
|
$COMPOSE_CMD logs -f
|
||||||
|
|||||||
Reference in New Issue
Block a user