Compare commits

..

20 Commits

Author SHA1 Message Date
Daniel
0134a5ef64 fix 修复bug 2026-04-28 19:40:02 +08:00
Daniel
c234fe64d6 fix: bug 2026-04-28 19:16:27 +08:00
Daniel
6de7e782fc fix:优化生产部署脚本 2026-04-28 18:39:55 +08:00
Daniel
f47453a656 fix:优化当前的项目 2026-04-28 18:36:38 +08:00
Daniel
04f26bdaaf feat: 纯生产脚本更新 2026-04-28 12:10:27 +08:00
Daniel
2dc7f2e19c feat: 纯生产脚本更新 2026-04-28 11:56:03 +08:00
Daniel
975a9e88f6 feat: 新增生产部署脚本 2026-04-28 11:55:04 +08:00
Daniel
4ee41e3534 feat: 新增生产部署脚本 2026-04-28 11:53:47 +08:00
Daniel
2724e69b4f fix: bug 2026-04-28 11:50:55 +08:00
Daniel
1bbabc2a78 fix: 优化字数更改设置 2026-04-21 13:41:35 +08:00
Daniel
d76c7fa25a fix:优化工作面板,支持动态字数 2026-04-21 13:14:53 +08:00
Daniel
e69666dbb3 fix: 更新当前界面,支持多公帐号切换 2026-04-10 12:47:03 +08:00
Daniel
5b4bee1939 fix: 修复生成报错 2026-04-08 19:12:59 +08:00
Daniel
17591de58f fix:优化输出内容 2026-04-08 11:39:17 +08:00
Daniel
222bf2e70d fix: 优化排版内容 2026-04-08 10:48:42 +08:00
Daniel
869e3b5976 fix: 修复样式问题 2026-04-07 19:09:09 +08:00
Daniel
780871e93c 修复本地样式 2026-04-07 18:55:50 +08:00
Daniel
9070dfba35 fix: 强制更新容器样式 2026-04-06 18:17:42 +08:00
Daniel
005a25b77a fix: 优化界面 2026-04-06 18:07:32 +08:00
Daniel
b342a90f9d fix: 修复报错内容 2026-04-06 15:28:15 +08:00
40 changed files with 10753 additions and 359 deletions

View File

@@ -1,26 +1,82 @@
APP_NAME=AI发糕
# 注意AI 模型、公众号 AppID/Secret 为用户级配置,请在页面「账号与模型」中填写。
# —— 通义千问(推荐):阿里云 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
# OPENAI_API_KEY=
# OPENAI_BASE_URL=
# OPENAI_MODEL=gpt-4.1-mini
# 通义长文 JSON 常需 60~120s+,过短会 APITimeout 后走兜底
OPENAI_TIMEOUT=120
# OPENAI_TIMEOUT=120
# SDK 自动重试次数。设为 0 可避免单次请求被隐式重试拖长(例如 30s 变 60s+
OPENAI_MAX_RETRIES=0
# OPENAI_MAX_RETRIES=0
# 长文 JSON 建议 8192通义等若正文仍偏短可适当再加大
OPENAI_MAX_OUTPUT_TOKENS=8192
OPENAI_SOURCE_MAX_CHARS=5000
# OPENAI_MAX_OUTPUT_TOKENS=8192
# OPENAI_SOURCE_MAX_CHARS=5000
# 质检未通过时仍返回模型洗稿正文quality_notes 记录问题);设为 false 则严格退回保底稿
AI_SOFT_ACCEPT=true
# AI_SOFT_ACCEPT=true
LOG_LEVEL=INFO
WECHAT_APPID=
WECHAT_SECRET=
WECHAT_AUTHOR=AI 编辑部
# 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。
# 若 errcode=40164 invalid ip把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。
# WECHAT_APPID=
# WECHAT_SECRET=
# WECHAT_AUTHOR=AI 编辑部
# 封面(图文草稿必填,否则 errcode=40007任选其一
# ① 填永久素材 IDWECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id
# ② 填容器内图片路径由服务自动上传WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg
# ③ 两项都不填:服务会用内置默认图自动上传(需 material 接口权限)
# WECHAT_THUMB_MEDIA_ID=
# WECHAT_THUMB_IMAGE_PATH=
# 可填飞书/Slack/企微等 webhook
IM_WEBHOOK_URL=
# 若 webhook 需要签名可填
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
View File

@@ -2,3 +2,5 @@
__pycache__/
*.pyc
.pytest_cache/
data/*
!data/.gitkeep

3
.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,3 @@
{
"postman.settings.dotenv-detection-notification-visibility": false
}

View File

@@ -18,5 +18,28 @@ RUN --mount=type=cache,target=/root/.cache/pip \
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
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]

View File

@@ -5,7 +5,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
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_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
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,
@@ -34,9 +39,124 @@ class Settings(BaseSettings):
wechat_appid: str | None = Field(default=None, alias="WECHAT_APPID")
wechat_secret: str | None = Field(default=None, alias="WECHAT_SECRET")
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_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()

File diff suppressed because it is too large Load Diff

View File

@@ -6,10 +6,17 @@ from pydantic import BaseModel, Field
class RewriteRequest(BaseModel):
source_text: str = Field(..., min_length=20)
title_hint: str = ""
writing_style: str = "科普解读"
tone: str = "专业、可信、可读性强"
audience: str = "公众号读者"
keep_points: str = ""
avoid_words: str = ""
target_body_chars: int = Field(
default=500,
ge=180,
le=2200,
description="目标改写正文字数,前端可调(中文字符数近似)",
)
class RewriteResponse(BaseModel):
@@ -29,6 +36,7 @@ class WechatPublishRequest(BaseModel):
summary: str = ""
body_markdown: str
author: str = ""
thumb_media_id: str = ""
class IMPublishRequest(BaseModel):
@@ -40,3 +48,158 @@ class PublishResponse(BaseModel):
ok: bool
detail: str
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)

View File

@@ -34,9 +34,9 @@ def _is_likely_timeout_error(exc: BaseException) -> bool:
return "timed out" in s or "timeout" in s
# 短文洗稿:5 个自然段、正文总字数上限(含标点)
MAX_BODY_CHARS = 500
# 短文洗稿:默认目标约 500 字,支持前端按需配置
MIN_BODY_CHARS = 80
DEFAULT_TARGET_BODY_CHARS = 500
def _preview_for_log(text: str, limit: int = 400) -> str:
@@ -46,7 +46,7 @@ def _preview_for_log(text: str, limit: int = 400) -> str:
return t[: limit - 1] + ""
SYSTEM_PROMPT = """
SYSTEM_PROMPT_TEMPLATE = """
你是资深中文科普类公众号编辑,擅长把长文、线程贴改写成**极短、好读**的推送。
目标:在**不偏离原意**的前提下,用最少字数讲清一件事;不要写成技术方案、长文大纲或带很多小标题的文章。
@@ -54,42 +54,64 @@ SYSTEM_PROMPT = """
1) **忠实原意**:只概括、转述原文已有信息,不编造事实,不偷换主题;
2) 语气通俗、干脆,避免套话堆砌;
3) 只输出合法 JSONtitle, summary, body_markdown
4) **body_markdown 约束**恰好 **5 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;全文(正文)总字数 **不超过 500 字**(含标点)
4) **body_markdown 约束**按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 {target_chars} 字**为目标,优先完整表达并避免冗长重复
5) title、summary 也要短:标题约 818 字;摘要约 4080 字;
6) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "
6) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语;
7) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "
""".strip()
REWRITE_SCHEMA_HINT = """
REWRITE_SCHEMA_HINT_TEMPLATE = """
请输出 JSON勿包在 ``` 里),例如:
{
{{
"title": "短标题,点明主题",
"summary": "一句话到两句话摘要",
"body_markdown": "第一段内容…\\n\\n第二段…\\n\\n第三段…\\n\\n第四段…\\n\\n第五段…"
}
}}
body_markdown 写法:
- 必须且只能有 **5 段**:每段若干完整句子,段之间 **\\n\\n**(空一行);
- 使用 **4~6 段**:每段若干完整句子,段之间 **\\n\\n**(空一行);
- **禁止** markdown 标题(不要用 #
- 正文总长 **≤500 字**,宁可短而清楚,不要写满废话
- 内容顺序建议:第 1 段交代在说什么;中间 3 段展开关键信息;最后 1 段收束或提醒(均须紧扣原文,勿乱发挥)。
- 正文目标约 **{target_chars} 字**(可上下浮动),以信息完整为先,避免冗长和重复
- 请用 `**...**` 加粗 2~4 个关键观点词;
- 内容顺序建议:首段交代在说什么;中间段展开关键信息;末段收束或提醒(均须紧扣原文,勿乱发挥)。
""".strip()
# 通义等模型若首次过短/结构不对,再要一次
_JSON_BODY_TOO_SHORT_RETRY = """
_JSON_BODY_TOO_SHORT_RETRY_TEMPLATE = """
【系统复检】上一次 body_markdown 不符合要求。请重输出**完整** JSON
- 正文必须 **恰好 5 个自然段**(仅 \\n\\n 分段),无 # 标题,总字数 **≤500 字**
- 正文必须使用 **4~6 个自然段**(仅 \\n\\n 分段),无 # 标题;篇幅尽量收敛到约 {target_chars} 字,同时保持信息完整
- 忠实原稿、简短高效;
- 引号只用「」『』;
- 只输出 JSON。
""".strip()
def _target_chars(req: RewriteRequest) -> int:
return max(180, min(2200, int(req.target_body_chars or DEFAULT_TARGET_BODY_CHARS)))
def _system_prompt(target_chars: int) -> str:
return SYSTEM_PROMPT_TEMPLATE.format(target_chars=target_chars)
def _rewrite_schema_hint(target_chars: int) -> str:
return REWRITE_SCHEMA_HINT_TEMPLATE.format(target_chars=target_chars)
def _retry_hint(target_chars: int) -> str:
return _JSON_BODY_TOO_SHORT_RETRY_TEMPLATE.format(target_chars=target_chars)
class AIRewriter:
def __init__(self) -> None:
self._client = None
self._prefer_chat_first = False
self._usage_prompt_tokens = 0
self._usage_completion_tokens = 0
self._usage_total_tokens = 0
self._usage_calls = 0
if settings.openai_api_key:
base_url = settings.openai_base_url or ""
self._prefer_chat_first = "dashscope.aliyuncs.com" in base_url
@@ -110,6 +132,22 @@ class AIRewriter:
else:
logger.warning("AIRewriter_init openai_key_missing=1 rewrite_will_use_fallback_only=1")
def _accumulate_usage(self, usage: Any) -> None:
if usage is None:
return
data = usage.model_dump() if hasattr(usage, "model_dump") else usage
if not isinstance(data, dict):
return
prompt = int(data.get("prompt_tokens") or data.get("input_tokens") or 0)
completion = int(data.get("completion_tokens") or data.get("output_tokens") or 0)
total = int(data.get("total_tokens") or 0)
if total <= 0:
total = max(0, prompt + completion)
self._usage_prompt_tokens += max(0, prompt)
self._usage_completion_tokens += max(0, completion)
self._usage_total_tokens += max(0, total)
self._usage_calls += 1
def rewrite(self, req: RewriteRequest, request_id: str = "") -> RewriteResponse:
cleaned_source = self._clean_source(req.source_text)
started = time.monotonic()
@@ -119,6 +157,7 @@ class AIRewriter:
"provider": "dashscope" if self._prefer_chat_first else "openai_compatible",
"source_chars_in": len(req.source_text or ""),
"cleaned_chars": len(cleaned_source),
"target_body_chars": _target_chars(req),
"openai_timeout_env_sec": settings.openai_timeout,
"steps": [],
}
@@ -237,6 +276,12 @@ class AIRewriter:
)
trace["quality_issues_final"] = final_issues
if not final_issues:
trace["usage"] = {
"prompt_tokens": int(self._usage_prompt_tokens),
"completion_tokens": int(self._usage_completion_tokens),
"total_tokens": int(self._usage_total_tokens),
"model_calls": int(self._usage_calls),
}
trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
trace["mode"] = "ai"
logger.info(
@@ -247,6 +292,12 @@ class AIRewriter:
return RewriteResponse(**normalized, mode="ai", quality_notes=[], trace=trace)
# 模型已返回有效 JSON默认「软接受」——仍视为 AI 洗稿,质检问题写入 quality_notes避免误用模板稿
if settings.ai_soft_accept and self._model_output_usable(normalized):
trace["usage"] = {
"prompt_tokens": int(self._usage_prompt_tokens),
"completion_tokens": int(self._usage_completion_tokens),
"total_tokens": int(self._usage_total_tokens),
"model_calls": int(self._usage_calls),
}
trace["duration_ms"] = round((time.monotonic() - started) * 1000, 1)
trace["mode"] = "ai"
trace["quality_soft_accept"] = True
@@ -287,8 +338,15 @@ class AIRewriter:
def _model_rewrite(
self, req: RewriteRequest, cleaned_source: str, timeout_sec: float, request_id: str = ""
) -> dict | None:
user_prompt = self._build_user_prompt(req, cleaned_source)
return self._call_model_json(user_prompt, timeout_sec=timeout_sec, request_id=request_id)
target_chars = _target_chars(req)
user_prompt = self._build_user_prompt(req, cleaned_source, target_chars)
return self._call_model_json(
user_prompt,
system_prompt=_system_prompt(target_chars),
target_chars=target_chars,
timeout_sec=timeout_sec,
request_id=request_id,
)
def _model_polish(
self,
@@ -299,6 +357,7 @@ class AIRewriter:
timeout_sec: float,
request_id: str = "",
) -> dict | None:
target_chars = _target_chars(req)
issue_text = "\n".join([f"- {i}" for i in issues])
user_prompt = f"""
你上一次的改写稿未通过质检,请针对下列问题重写;体裁仍为**科普介绍类公众号****忠实原稿**,不要写成技术方案或内部汇报。
@@ -315,35 +374,44 @@ class AIRewriter:
用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'}
- 写作风格:{req.writing_style}
- 语气风格:{req.tone}
- 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''}
- 避免词汇:{req.avoid_words or ''}
请输出一版全新稿件。{REWRITE_SCHEMA_HINT}
请输出一版全新稿件。{_rewrite_schema_hint(target_chars)}
""".strip()
return self._call_model_json(user_prompt, timeout_sec=timeout_sec, request_id=request_id)
return self._call_model_json(
user_prompt,
system_prompt=_system_prompt(target_chars),
target_chars=target_chars,
timeout_sec=timeout_sec,
request_id=request_id,
)
def _build_user_prompt(self, req: RewriteRequest, cleaned_source: str) -> str:
def _build_user_prompt(self, req: RewriteRequest, cleaned_source: str, target_chars: int) -> str:
return f"""
原始内容(已清洗):
{cleaned_source}
用户改写偏好:
- 标题参考:{req.title_hint or '自动生成'}
- 写作风格:{req.writing_style}
- 语气风格:{req.tone}
- 目标读者:{req.audience}
- 必须保留观点:{req.keep_points or ''}
- 避免词汇:{req.avoid_words or ''}
任务:在**不偏离原帖主题与事实**的前提下,改写成科普介绍风格的公众号正文(好读、讲清楚,而非技术实施方案)。{REWRITE_SCHEMA_HINT}
任务:在**不偏离原帖主题与事实**的前提下,改写成科普介绍风格的公众号正文(好读、讲清楚,而非技术实施方案)。{_rewrite_schema_hint(target_chars)}
""".strip()
def _fallback_rewrite(
self, req: RewriteRequest, cleaned_source: str, reason: str, trace: dict[str, Any] | None = None
) -> RewriteResponse:
sentences = self._extract_sentences(cleaned_source)
points = self._pick_key_points(sentences, limit=5)
para_count = self._fallback_para_count(cleaned_source)
points = self._pick_key_points(sentences, limit=max(5, para_count))
title = req.title_hint.strip() or self._build_fallback_title(sentences)
summary = self._build_fallback_summary(points, cleaned_source)
@@ -354,17 +422,14 @@ class AIRewriter:
t = re.sub(r"\s+", " ", (s or "").strip())
return t if len(t) <= n else t[: n - 1] + ""
paras = [
_one_line(self._build_intro(points, cleaned_source), 105),
_one_line(analysis["cause"], 105),
_one_line(analysis["impact"], 105),
_one_line(analysis["risk"], 105),
_one_line(conclusion, 105),
]
paras = [_one_line(self._build_intro(points, cleaned_source), 105)]
if para_count >= 4:
paras.append(_one_line(analysis["cause"], 105))
paras.append(_one_line(analysis["impact"], 105))
if para_count >= 5:
paras.append(_one_line(analysis["risk"], 105))
paras.append(_one_line(conclusion, 105))
body = "\n\n".join(paras)
if len(body) > MAX_BODY_CHARS:
body = body[: MAX_BODY_CHARS - 1] + ""
normalized = {
"title": title,
"summary": summary,
@@ -425,6 +490,14 @@ class AIRewriter:
),
}
def _fallback_para_count(self, source: str) -> int:
length = len((source or "").strip())
if length < 240:
return 4
if length > 1200:
return 6
return 5
def _clean_source(self, text: str) -> str:
src = (text or "").replace("\r\n", "\n").strip()
src = re.sub(r"https?://\S+", "", src)
@@ -474,6 +547,10 @@ class AIRewriter:
return json.loads(raw)
except json.JSONDecodeError:
pass
try:
return json.loads(self._escape_control_chars_in_json_string(raw))
except json.JSONDecodeError:
pass
fenced = re.sub(r"^```(?:json)?\s*|\s*```$", "", raw, flags=re.IGNORECASE).strip()
if fenced != raw:
@@ -481,22 +558,80 @@ class AIRewriter:
return json.loads(fenced)
except json.JSONDecodeError:
pass
try:
return json.loads(self._escape_control_chars_in_json_string(fenced))
except json.JSONDecodeError:
pass
start = raw.find("{")
end = raw.rfind("}")
if start != -1 and end != -1 and end > start:
return json.loads(raw[start : end + 1])
sliced = raw[start : end + 1]
try:
return json.loads(sliced)
except json.JSONDecodeError:
return json.loads(self._escape_control_chars_in_json_string(sliced))
raise ValueError("model output is not valid JSON")
def _chat_completions_json(self, user_prompt: str, timeout_sec: float, request_id: str) -> dict | None:
def _escape_control_chars_in_json_string(self, s: str) -> str:
"""
修复“近似 JSON”中字符串里的裸控制字符尤其是换行
避免 `Invalid control character` 导致误判为无效 JSON。
"""
out: list[str] = []
in_string = False
escaped = False
for ch in s:
if in_string:
if escaped:
out.append(ch)
escaped = False
continue
if ch == "\\":
out.append(ch)
escaped = True
continue
if ch == '"':
out.append(ch)
in_string = False
continue
if ch == "\n":
out.append("\\n")
continue
if ch == "\r":
out.append("\\r")
continue
if ch == "\t":
out.append("\\t")
continue
if ord(ch) < 0x20:
out.append(f"\\u{ord(ch):04x}")
continue
out.append(ch)
continue
else:
out.append(ch)
if ch == '"':
in_string = True
escaped = False
return "".join(out)
def _chat_completions_json(
self,
user_prompt: str,
system_prompt: str,
target_chars: int,
timeout_sec: float,
request_id: str,
) -> dict | None:
"""chat.completions通义兼容层在 json_object 下易产出极短 JSON故 DashScope 不传 response_format并支持短文自动重试。"""
max_attempts = 2 if self._prefer_chat_first else 1
deadline = time.monotonic() + max(0.0, timeout_sec)
pe = user_prompt
for attempt in range(max_attempts):
if attempt == 1:
pe = user_prompt + _JSON_BODY_TOO_SHORT_RETRY
pe = user_prompt + _retry_hint(target_chars)
remaining = deadline - time.monotonic()
if remaining <= 0:
logger.warning(
@@ -520,7 +655,7 @@ class AIRewriter:
create_kwargs: dict[str, Any] = {
"model": settings.openai_model,
"messages": [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "system", "content": system_prompt},
{"role": "user", "content": pe},
],
"max_tokens": settings.openai_max_output_tokens,
@@ -566,6 +701,7 @@ class AIRewriter:
msg = (choice.message.content if choice else "") or ""
fr = getattr(choice, "finish_reason", None) if choice else None
usage = getattr(completion, "usage", None)
self._accumulate_usage(usage)
udump = (
usage.model_dump()
if usage is not None and hasattr(usage, "model_dump")
@@ -619,7 +755,14 @@ class AIRewriter:
return parsed
return None
def _call_model_json(self, user_prompt: str, timeout_sec: float, request_id: str = "") -> dict | None:
def _call_model_json(
self,
user_prompt: str,
system_prompt: str,
target_chars: int,
timeout_sec: float,
request_id: str = "",
) -> dict | None:
methods = ["chat", "responses"] if self._prefer_chat_first else ["responses", "chat"]
logger.info(
"model_call_begin rid=%s model=%s timeout_s=%.1f prefer_chat_first=%s prompt_chars=%d "
@@ -639,12 +782,13 @@ class AIRewriter:
completion = self._client.responses.create(
model=settings.openai_model,
input=[
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "system", "content": system_prompt},
{"role": "user", "content": user_prompt},
],
text={"format": {"type": "json_object"}},
timeout=timeout_sec,
)
self._accumulate_usage(getattr(completion, "usage", None))
output_text = completion.output_text or ""
ms = (time.monotonic() - t0) * 1000
logger.info(
@@ -669,7 +813,13 @@ class AIRewriter:
if method == "chat":
try:
t_chat = time.monotonic()
out = self._chat_completions_json(user_prompt, timeout_sec, request_id)
out = self._chat_completions_json(
user_prompt,
system_prompt=system_prompt,
target_chars=target_chars,
timeout_sec=timeout_sec,
request_id=request_id,
)
if out is not None:
return out
if self._prefer_chat_first:
@@ -721,8 +871,6 @@ class AIRewriter:
text = (body or "").strip()
if not text:
text = "(正文生成失败,请重试。)"
if len(text) > MAX_BODY_CHARS:
text = text[: MAX_BODY_CHARS - 1] + ""
return text
def _quality_issues(
@@ -745,20 +893,28 @@ class AIRewriter:
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
pc = len(paragraphs)
need_p = 4 if lenient else 5
if pc < need_p:
issues.append(f"正文需约 5 个自然段、空行分隔(当前 {pc} 段)")
elif not lenient and pc > 6:
issues.append(f"正文段落多(当前 {pc} 段),请合并为 5 段左右")
min_p, max_p = (3, 6) if lenient else (4, 6)
if pc < min_p:
issues.append(f"正文段落偏少(当前 {pc} 段),建议 {min_p}-{max_p}")
elif pc > max_p:
issues.append(f"正文段落多(当前 {pc} 段),建议控制在 {min_p}-{max_p}")
if len(body) > MAX_BODY_CHARS:
issues.append(f"正文超过 {MAX_BODY_CHARS} 字(当前 {len(body)} 字),请压缩")
elif len(body) < MIN_BODY_CHARS:
issues.append(f"正文过短(当前阈值 ≥{MIN_BODY_CHARS} 字)")
target_chars = _target_chars(req)
min_body_chars = max(MIN_BODY_CHARS, int(target_chars * 0.45))
max_body_chars = int(target_chars * 1.7)
if len(body) < min_body_chars:
issues.append(f"正文过短(当前阈值 ≥{min_body_chars} 字)")
elif len(body) > max_body_chars:
issues.append(
f"正文偏长(当前 {len(body)} 字),建议收敛到约 {target_chars} 字(可上下浮动)"
)
if re.search(r"(?m)^#+\s", body):
issues.append("正文请勿使用 # 标题符号,只用自然段")
if "**" not in body:
issues.append("关键观点未加粗(建议 2~4 处)")
if self._looks_like_raw_copy(source, body, lenient=lenient):
issues.append("改写与原文相似度过高,疑似未充分重写")
@@ -792,11 +948,35 @@ class AIRewriter:
title = (normalized.get("title") or "").strip()
if len(title) < 4 or len(body) < 40:
return False
if len(body) > MAX_BODY_CHARS + 80:
return False
return True
def _format_markdown(self, text: str) -> str:
body = text.replace("\r\n", "\n").strip()
body = re.sub(r"\n{3,}", "\n\n", body)
return body.strip() + "\n"
paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()]
if not paragraphs:
return body.strip() + "\n"
# 若模型未加粗,兜底给第一段的核心短语加粗一次
merged = "\n\n".join(paragraphs)
if "**" not in merged:
first = paragraphs[0]
first_plain = first.lstrip("  ").strip()
phrase = re.split(r"[,。;:,:]", first_plain, maxsplit=1)[0].strip()
phrase = phrase[:14]
if len(phrase) >= 4 and phrase in first:
paragraphs[0] = first.replace(phrase, f"**{phrase}**", 1)
# 段首全角缩进:保持阅读习惯,避免顶格
out: list[str] = []
for p in paragraphs:
seg = p.strip()
if not seg:
continue
if seg.startswith("  "):
out.append(seg)
else:
out.append("  " + seg.lstrip())
# 不能用 .strip(),否则会把首段的全角缩进「  」去掉
return "\n\n".join(out).rstrip() + "\n"

View 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"![段落配图 {idx + 1}]({url})")
merged.append(para)
return "\n\n".join(merged)

1535
app/services/user_store.py Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,8 @@ from __future__ import annotations
import logging
import time
from io import BytesIO
from pathlib import Path
import httpx
import markdown2
@@ -11,6 +13,21 @@ 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 转成可操作的说明。"""
@@ -32,18 +49,49 @@ def _detail_for_token_error(data: dict | None) -> str:
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=40007invalid media_idthumb_media_id 缺失、不是「永久图片素材」、或已失效。"
"请核对 WECHAT_THUMB_MEDIA_ID 是否从素材管理里复制的永久素材;若不确定,可删掉该变量,"
"由服务自动上传封面WECHAT_THUMB_IMAGE_PATH 或内置默认图)。"
f" 微信原文:{msg}"
)
return f"微信草稿失败errcode={code} errmsg={msg}"
class WechatPublisher:
def __init__(self) -> None:
self._access_token = None
self._expires_at = 0
self._token_cache: dict[str, dict[str, int | str]] = {}
self._runtime_thumb_media_id: str | None = None
async def publish_draft(self, req: WechatPublishRequest, request_id: str = "") -> PublishResponse:
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
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 "-"
if not settings.wechat_appid or not settings.wechat_secret:
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 配置")
token, token_from_cache, token_err_body = await self._get_access_token()
token, token_from_cache, token_err_body = await self._get_access_token(acct["appid"], acct["secret"])
if not token:
detail = _detail_for_token_error(token_err_body)
logger.error("wechat access_token_unavailable rid=%s detail=%s", rid, detail[:200])
@@ -55,6 +103,21 @@ class WechatPublisher:
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)
logger.info(
"wechat_draft_build rid=%s title_chars=%d digest_chars=%d html_chars=%d",
@@ -63,29 +126,48 @@ class WechatPublisher:
len(req.summary or ""),
len(html or ""),
)
# 图文 newsthumb_media_id 为必填(永久素材),否则 errcode=40007
payload = {
"articles": [
{
"title": req.title,
"author": req.author or settings.wechat_author,
"digest": req.summary,
"article_type": "news",
"title": req.title[:32] if len(req.title) > 32 else req.title,
"author": (req.author or acct["author"] or "AI发糕")[:16],
"digest": (req.summary or "")[:128],
"content": html,
"content_source_url": "",
"thumb_media_id": thumb_id,
"need_open_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:
url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}"
logger.info(
"wechat_http_post rid=%s endpoint=cgi-bin/draft/add http_timeout_s=25",
rid,
)
r = await client.post(url, json=payload)
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()
if data.get("errcode", 0) != 0:
logger.warning(
"wechat_draft_failed rid=%s errcode=%s errmsg=%s raw=%s",
@@ -94,7 +176,8 @@ class WechatPublisher:
data.get("errmsg"),
data,
)
return PublishResponse(ok=False, detail=f"微信发布失败: {data}", data=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",
@@ -103,11 +186,179 @@ class WechatPublisher:
)
return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data)
async def _get_access_token(self) -> tuple[str | None, bool, dict | None]:
async def upload_cover(
self, filename: str, content: bytes, request_id: str = "", account: dict | None = None
) -> PublishResponse:
"""上传封面到微信永久素材,返回 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())
if self._access_token and now < self._expires_at - 60:
return self._access_token, True, None
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:
@@ -115,8 +366,8 @@ class WechatPublisher:
"https://api.weixin.qq.com/cgi-bin/token",
params={
"grant_type": "client_credential",
"appid": settings.wechat_appid,
"secret": settings.wechat_secret,
"appid": appid,
"secret": secret,
},
)
data = r.json() if r.content else {}
@@ -130,6 +381,5 @@ class WechatPublisher:
)
return None, False, data if isinstance(data, dict) else None
self._access_token = token
self._expires_at = now + int(data.get("expires_in", 7200))
self._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))}
return token, False, None

248
app/static/admin.js Normal file
View 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();
});
})();

View File

@@ -15,11 +15,102 @@ 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)}`;
@@ -38,6 +129,185 @@ function setStatus(msg, danger = false) {
statusEl.textContent = msg;
}
function saveDraftState() {
try {
const data = {
sourceText: ($("sourceText") && $("sourceText").value) || "",
titleHint: ($("titleHint") && $("titleHint").value) || "",
audienceExtra: ($("audienceExtra") && $("audienceExtra").value) || "",
toneExtra: ($("toneExtra") && $("toneExtra").value) || "",
avoidWords: ($("avoidWords") && $("avoidWords").value) || "",
keepPoints: ($("keepPoints") && $("keepPoints").value) || "",
targetBodyChars: ($("targetBodyChars") && $("targetBodyChars").value) || "500",
title: ($("title") && $("title").value) || "",
summary: ($("summary") && $("summary").value) || "",
body: ($("body") && $("body").value) || "",
thumbMediaId: ($("thumbMediaId") && $("thumbMediaId").value) || "",
coverStyleHint: ($("coverStyleHint") && $("coverStyleHint").value) || "",
coverImageModel: (coverImageModelInput && coverImageModelInput.value) || "",
coverAutoAfterRewrite: Boolean(coverAutoAfterRewrite && coverAutoAfterRewrite.checked),
posterAutoInclude: Boolean(posterAutoInclude && posterAutoInclude.checked),
audienceChipValues: Array.from(document.querySelectorAll('input[name="audienceChip"]:checked')).map((n) => n.value),
toneChipValues: Array.from(document.querySelectorAll('input[name="toneChip"]:checked')).map((n) => n.value),
};
window.localStorage.setItem(DRAFT_STORAGE_KEY, JSON.stringify(data));
} catch {
// ignore
}
}
function restoreDraftState() {
try {
const raw = window.localStorage.getItem(DRAFT_STORAGE_KEY);
if (!raw) return;
const data = JSON.parse(raw);
if (!data || typeof data !== "object") return;
const setVal = (id, val) => {
const el = $(id);
if (!el || typeof val !== "string") return;
el.value = val;
};
setVal("sourceText", data.sourceText || "");
setVal("titleHint", data.titleHint || "");
setVal("audienceExtra", data.audienceExtra || "");
setVal("toneExtra", data.toneExtra || "");
setVal("avoidWords", data.avoidWords || "");
setVal("keepPoints", data.keepPoints || "");
setVal("targetBodyChars", String(data.targetBodyChars || "500"));
setVal("title", data.title || "");
setVal("summary", data.summary || "");
setVal("body", data.body || "");
setVal("thumbMediaId", data.thumbMediaId || "");
setVal("coverStyleHint", data.coverStyleHint || "");
if (coverImageModelInput && typeof data.coverImageModel === "string" && data.coverImageModel.trim()) {
coverImageModelInput.value = data.coverImageModel;
}
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = Boolean(data.coverAutoAfterRewrite);
if (posterAutoInclude) posterAutoInclude.checked = Boolean(data.posterAutoInclude);
const audienceSet = new Set(Array.isArray(data.audienceChipValues) ? data.audienceChipValues : []);
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
el.checked = audienceSet.size ? audienceSet.has(el.value) : el.checked;
});
const toneSet = new Set(Array.isArray(data.toneChipValues) ? data.toneChipValues : []);
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
el.checked = toneSet.size ? toneSet.has(el.value) : el.checked;
});
} catch {
// ignore
}
}
function clearDraftState() {
try {
window.localStorage.removeItem(DRAFT_STORAGE_KEY);
} catch {
// ignore
}
const clearIds = [
"sourceText",
"titleHint",
"audienceExtra",
"toneExtra",
"avoidWords",
"keepPoints",
"title",
"summary",
"body",
"thumbMediaId",
"coverStyleHint",
"coverUrl",
];
clearIds.forEach((id) => {
const el = $(id);
if (!el) return;
el.value = "";
});
if ($("targetBodyChars")) $("targetBodyChars").value = "500";
if (coverAutoAfterRewrite) coverAutoAfterRewrite.checked = false;
if (posterAutoInclude) posterAutoInclude.checked = false;
if (coverImageModelInput) coverImageModelInput.value = "";
document.querySelectorAll('input[name="audienceChip"]').forEach((el) => {
el.checked = false;
});
document.querySelectorAll('input[name="toneChip"]').forEach((el) => {
el.checked = false;
});
if (coverPreviewWrap) coverPreviewWrap.hidden = true;
if (coverPreview) coverPreview.src = "";
if ($("coverFile")) $("coverFile").value = "";
posterState = { signature: "", bodyMarkdownWithPosters: "", posters: [] };
renderPosterPreview([]);
updateCounters();
syncTargetCharChips();
initMultiDropdowns();
initImageModelStatus();
if (posterHint) posterHint.textContent = "默认不生成海报,点击“生成段落海报”后再插入发布。";
setStatus("草稿已清除。");
}
function buildPosterSignature() {
const title = ($("title") && $("title").value.trim()) || "";
const summary = ($("summary") && $("summary").value.trim()) || "";
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) {
const res = await fetch(url, {
method: "POST",
@@ -45,80 +315,303 @@ async function postJSON(url, body) {
body: JSON.stringify(body),
});
const data = await res.json();
if (!res.ok) throw new Error(data.detail || "请求失败");
if (!res.ok) {
const err = new Error(data.detail || "请求失败");
err.payload = data;
throw err;
}
data._requestId = res.headers.get("X-Request-ID") || "";
return data;
}
function renderTrace(trace, headerRid) {
const wrap = $("traceWrap");
const pre = $("traceJson");
const badge = $("traceBadge");
if (!pre || !wrap) return;
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;
}
if (!trace || Object.keys(trace).length === 0) {
pre.textContent = headerRid
? JSON.stringify({ request_id: headerRid, note: "响应中无 trace 字段" }, null, 2)
: "尚无数据完成一次「AI 改写」后,这里会显示请求 ID、耗时、质检与降级原因。";
if (badge) badge.textContent = "";
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);
});
}
const merged = { ...trace };
if (headerRid && !merged.request_id) merged.request_id = headerRid;
pre.textContent = JSON.stringify(merged, null, 2);
const mode = merged.mode || "";
if (badge) {
badge.textContent = mode === "ai" ? "AI" : mode === "fallback" ? "保底" : "";
badge.className = "trace-badge " + (mode === "ai" ? "is-ai" : mode === "fallback" ? "is-fallback" : "");
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);
}
wrap.open = true;
}
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 () => {
const sourceText = $("sourceText").value.trim();
const targetBodyChars = Number(($("targetBodyChars") && $("targetBodyChars").value) || 500);
if (sourceText.length < 20) {
setStatus("原始内容太短,至少 20 个字符", true);
return;
}
if (!Number.isFinite(targetBodyChars) || targetBodyChars < 180 || targetBodyChars > 2200) {
setStatus("改写目标字数需在 180~2200 之间", true);
return;
}
setStatus("AI 改写...");
setLoading(rewriteBtn, true, "AI 改写并排版", "AI 改写中...");
setStatus("正在改写...");
setLoading(rewriteBtn, true, "改写并排版", "改写中...");
try {
const audience = buildMultiPrompt("audienceChip", "audienceExtra") || "公众号读者";
const tone = buildMultiPrompt("toneChip", "toneExtra") || "专业、可信、可读性强";
const data = await postJSON("/api/rewrite", {
source_text: sourceText,
title_hint: $("titleHint").value,
tone: $("tone").value,
audience: $("audience").value,
tone,
audience,
keep_points: $("keepPoints").value,
avoid_words: $("avoidWords").value,
target_body_chars: Math.round(targetBodyChars),
});
$("title").value = data.title || "";
$("summary").value = data.summary || "";
$("body").value = data.body_markdown || "";
updateCounters();
renderTrace(data.trace, data._requestId);
saveDraftState();
const tr = data.trace || {};
const modelLine = tr.model ? `模型 ${tr.model}` : "";
if (data.mode === "fallback") {
const note = (data.quality_notes || [])[0] || "当前为保底改写稿";
setStatus(
`改写完成(保底模式,未使用或未通过千问长文):${note}${modelLine ? ` · ${modelLine}` : ""}`,
true
);
setStatus(`改写完成(保底模式):${note}`, true);
} else if (tr.quality_soft_accept) {
setStatus(
`改写完成AI质检提示${(data.quality_notes || []).join("") || "见 quality_notes"} · ${modelLine || "AI"}`
);
setStatus(`改写完成(有提示):${(data.quality_notes || []).join("") || "请检查正文"}`);
statusEl.style.color = "#9a3412";
} else {
setStatus(`改写完成AI 洗稿)${modelLine ? ` · ${modelLine}` : ""}`);
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) {
if (handleUpgradeRequired(e)) return;
setStatus(`改写失败: ${e.message}`, true);
} finally {
setLoading(rewriteBtn, false, "AI 改写并排版", "AI 改写中...");
setLoading(rewriteBtn, false, "改写并排版", "改写中...");
}
});
@@ -126,20 +619,166 @@ $("wechatBtn").addEventListener("click", async () => {
setStatus("正在发布到公众号草稿箱...");
setLoading(wechatBtn, true, "发布到公众号草稿箱", "发布中...");
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", {
title: $("title").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);
setStatus("公众号草稿发布成功");
} catch (e) {
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 () => {
setStatus("正在发送到 IM...");
setLoading(imBtn, true, "发送到 IM", "发送中...");
@@ -157,36 +796,32 @@ $("imBtn").addEventListener("click", async () => {
}
});
["sourceText", "summary", "body"].forEach((id) => {
["sourceText", "title", "summary", "body"].forEach((id) => {
$(id).addEventListener("input", updateCounters);
$(id).addEventListener("input", saveDraftState);
if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded);
});
async function loadBackendConfig() {
const el = $("backendConfig");
if (!el) return;
try {
const res = await fetch("/api/config");
const c = await res.json();
if (!c.openai_configured) {
el.textContent =
"后端未配置 OPENAI_API_KEY改写将使用本地保底稿千问不会参与。请在 .env 中配置并重启容器。";
el.style.color = "#b42318";
return;
}
const name =
c.provider === "dashscope"
? "通义千问DashScope 兼容接口)"
: "OpenAI 兼容接口";
const host = c.base_url_host ? ` · ${c.base_url_host}` : "";
const to = c.openai_timeout_sec != null ? ` · 单轮最长等待 ${c.openai_timeout_sec}s` : "";
el.textContent = `已接入:${c.openai_model} · ${name}${host}${to}`;
el.style.color = "";
} catch (e) {
el.textContent = "无法读取 /api/config请确认服务已启动";
el.style.color = "#b42318";
}
}
["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);
loadBackendConfig();
restoreDraftState();
updateCounters();
renderTrace(null, "");
initMultiDropdowns();
initWechatAccountSwitch();
syncTargetCharChips();
renderPosterPreview([]);
setCoverMode("manual");
initImageModelStatus();
window.addEventListener("beforeunload", saveDraftState);
window.addEventListener("load", () => setCoverMode("manual"));

134
app/static/auth.js Normal file
View 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
View File

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

32
app/static/favicon.svg Normal file
View 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

View 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
View 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
View 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
View File

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

433
app/static/settings.js Normal file
View 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();

File diff suppressed because it is too large Load Diff

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

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

303
app/static/upgrade.js Normal file
View 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
View 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
View 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
View 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>

View 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
View 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>

View File

@@ -4,67 +4,153 @@
<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" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428o" />
</head>
<body>
<header class="topbar">
<div class="brand">
<h1>{{ app_name }}</h1>
<p class="muted">粘贴原文 → 洗成约 <strong>5 段、500 字内</strong> 的短文(无小标题)→ 右侧预览 → 满意后发布。</p>
<p id="backendConfig" class="backend-config muted small" aria-live="polite"></p>
</div>
<div class="badge">Beta</div>
</header>
<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">
<section class="panel input-panel">
<h2>输入与改写策略</h2>
<ol class="flow-hint muted">
<li>粘贴原文并设置语气/读者</li>
<li>点击改写 → 右侧为短标题、摘要与<strong>五段正文</strong>(段落间空一行)</li>
<li>看「运行追踪」:<strong>模式为 AI</strong> 且模型名正确,即千问/接口已生效</li>
<li>人工改好后 →「发布到公众号草稿箱」(需配置 WECHAT_*</li>
</ol>
<div class="panel-head">
<h2>写作输入</h2>
</div>
<div class="panel-scroll">
<section class="form-section">
<div class="field-head">
<label>原始内容</label>
<label>内容</label>
<span id="sourceCount" class="meta">0 字</span>
</div>
<textarea id="sourceText" rows="14" placeholder="粘贴原文(长帖、线程、摘录均可),洗稿会围绕原文主题展开…"></textarea>
<textarea id="sourceText" rows="5" placeholder="粘贴原文内容"></textarea>
<div class="grid2">
<div>
<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>
<label>目标读者</label>
<input id="audience" type="text" value="公众号运营者/产品经理" />
<label>避免词汇</label>
<input id="avoidWords" type="text" placeholder="可选:避免词汇" />
</div>
</div>
<div class="grid2">
<div>
<label>语气风格</label>
<input id="tone" type="text" value="专业、有观点、口语自然" />
<label>必须保留观点</label>
<input id="keepPoints" type="text" placeholder="可选:保留观点(逗号分隔)" />
</div>
<div>
<label>避免词汇</label>
<input id="avoidWords" type="text" placeholder="如:颠覆、闭环、赋能" />
<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>
<label>必须保留观点</label>
<input id="keepPoints" type="text" placeholder="逗号分隔" />
<button id="rewriteBtn" class="primary">AI 改写并排版</button>
<section class="form-section">
<button id="rewriteBtn" class="primary">改写并排版</button>
<p id="status" class="status"></p>
</section>
</div>
</section>
<section class="panel output-panel">
<h2>发布内容</h2>
<p class="muted small">下方「运行追踪」会显示本次请求 ID、耗时、质检项与是否降级便于与容器日志对照。</p>
<div class="panel-head">
<h2>发布内容</h2>
</div>
<div class="panel-scroll">
<section class="form-section">
<label>标题</label>
<input id="title" type="text" />
@@ -72,36 +158,99 @@
<label>摘要</label>
<span id="summaryCount" class="meta">0 字</span>
</div>
<textarea id="summary" rows="3"></textarea>
<textarea id="summary" rows="2"></textarea>
</section>
<section class="form-section">
<div class="field-head">
<label>正文5 自然段,建议 ≤500 字)</label>
<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="图片 URLhttp/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>
<section class="form-section">
<div class="field-head">
<label>正文</label>
<span id="bodyCount" class="meta">0 字</span>
</div>
<div class="body-split">
<textarea id="body" rows="10" placeholder="五段之间空一行;无需 # 标题"></textarea>
<textarea id="body" rows="6" placeholder="可直接编辑正文"></textarea>
<div class="preview-panel">
<div class="field-head">
<label>排版预览</label>
<span class="meta">与公众号 HTML 渲染接近</span>
<span class="meta">实时</span>
</div>
<div id="bodyPreview" class="markdown-preview"></div>
</div>
</div>
</section>
<details id="traceWrap" class="trace-wrap">
<summary>运行追踪 <span id="traceBadge" class="trace-badge"></span></summary>
<pre id="traceJson" class="trace-json"></pre>
</details>
<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">
<button id="wechatBtn">发布到公众号草稿箱</button>
<button id="imBtn">发送到 IM</button>
<div class="actions publish-actions">
<button id="wechatBtn" class="primary">发布到公众号草稿箱</button>
<button id="imBtn" class="secondary">发送到 IM</button>
</div>
</div>
</section>
</main>
</div>
</div>
<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"></script>
<script src="/static/app.js?v=20260428s"></script>
</body>
</html>

View 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
View 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
View 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
View File

@@ -0,0 +1 @@

103
deploy.prod.sh Executable file
View 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
View 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

View File

@@ -12,4 +12,8 @@ services:
- "18000:8000"
env_file:
- .env
environment:
AUTH_DB_PATH: /app/data/app.db
volumes:
- ./data:/app/data
restart: unless-stopped

View File

@@ -1,4 +1,4 @@
# X2WeChat Studio
# AI发糕
把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。
@@ -6,12 +6,12 @@
```bash
cp .env.example .env
# 填写 .env 中的 OPENAI / 微信 / IM 参数
docker compose up --build
```
启动后访问:`http://localhost:8000`
启动后访问:`http://localhost:18000`
容器默认将数据库挂载到宿主机目录 `./data``AUTH_DB_PATH=/app/data/app.db`),更新容器镜像不会清空历史账号和会话数据。
## 2. 使用流程
@@ -20,17 +20,33 @@ docker compose up --build
3. 点击 `发布到公众号草稿箱`
4. 可选点击 `发送到 IM` 同步到团队群。
## 3. 环境变量说明
## 3. 配置说明
- `OPENAI_API_KEY`AI 改写能力
- `OPENAI_BASE_URL`:可选,兼容第三方网关。
- `OPENAI_MODEL`:默认 `gpt-4.1-mini`
- `WECHAT_APPID` / `WECHAT_SECRET`:公众号发布必填。
- `WECHAT_AUTHOR`:草稿默认作者名。
- `AI 模型配置``公众号 AppID/Secret`:由用户在“账号与模型”页面录入,不再依赖 `.env`
- `IM_WEBHOOK_URL`IM 推送地址(飞书/Slack/企微等)。
- `IM_SECRET`:可选签名。
- `AUTH_DB_PATH`账号数据库文件路径SQLite
- `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。
- `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。
## 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
```

View File

@@ -6,3 +6,4 @@ httpx==0.28.1
openai==1.108.2
markdown2==2.5.4
python-multipart==0.0.20
Pillow>=10.0.0

View File

@@ -24,7 +24,7 @@ else
exit 1
fi
echo "Starting X2WeChat Studio..."
echo "Starting AI发糕..."
$COMPOSE_CMD up --build -d
echo "Service is starting in background."