This commit is contained in:
Daniel
2026-04-28 11:50:55 +08:00
parent 1bbabc2a78
commit 2724e69b4f
20 changed files with 3881 additions and 554 deletions

View File

@@ -1,26 +1,28 @@
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
# 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。
# 若 errcode=40164 invalid ip把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。
WECHAT_APPID=
WECHAT_SECRET=
WECHAT_AUTHOR=AI 编辑部
# WECHAT_APPID=
# WECHAT_SECRET=
# WECHAT_AUTHOR=AI 编辑部
# 封面(图文草稿必填,否则 errcode=40007任选其一
# ① 填永久素材 IDWECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id
# ② 填容器内图片路径由服务自动上传WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg

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

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

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,
@@ -44,6 +49,21 @@ class Settings(BaseSettings):
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")

View File

@@ -1,10 +1,11 @@
from __future__ import annotations
import hmac
import logging
from pathlib import Path
from urllib.parse import urlparse
from fastapi import FastAPI, File, Request, Response, UploadFile
import httpx
from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile
from fastapi.responses import HTMLResponse, RedirectResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
@@ -13,17 +14,26 @@ from app.config import settings
from app.logging_setup import configure_logging
from app.middleware import RequestContextMiddleware
from app.schemas import (
AIModelCreateRequest,
AIModelDeleteRequest,
AIModelSwitchRequest,
AuthCredentialRequest,
ChangePasswordRequest,
DeleteAccountRequest,
ForgotPasswordResetRequest,
IMPublishRequest,
PosterGenerateRequest,
RewriteRequest,
WechatCoverUploadByUrlRequest,
WechatCoverGenerateRequest,
WechatDeleteRequest,
WechatBindingRequest,
WechatPublishRequest,
WechatSwitchRequest,
)
from app.services.ai_rewriter import AIRewriter
from app.services.im import IMPublisher
from app.services.poster_material import PosterMaterialService
from app.services.user_store import UserStore
from app.services.wechat import WechatPublisher
@@ -36,9 +46,9 @@ app = FastAPI(title=settings.app_name)
@app.on_event("startup")
async def _log_startup() -> None:
logger.info(
"app_start name=%s openai_configured=%s ai_soft_accept=%s",
"app_start name=%s user_model_required=%s ai_soft_accept=%s",
settings.app_name,
bool(settings.openai_api_key),
True,
settings.ai_soft_accept,
)
@@ -49,6 +59,7 @@ templates = Jinja2Templates(directory="app/templates")
rewriter = AIRewriter()
wechat = WechatPublisher()
poster_material = PosterMaterialService(wechat)
im = IMPublisher()
users = UserStore(settings.auth_db_path)
@@ -96,25 +107,38 @@ async def settings_page(request: Request):
return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name})
@app.get("/guide", response_class=HTMLResponse)
async def guide_page(request: Request):
if not _current_user(request):
return RedirectResponse(url="/auth?next=/guide", status_code=302)
return templates.TemplateResponse("guide.html", {"request": request, "app_name": settings.app_name})
@app.get("/favicon.ico", include_in_schema=False)
async def favicon():
# 浏览器通常请求 /favicon.ico统一跳转到静态图标
return RedirectResponse(url="/static/favicon.svg?v=20260406a")
return RedirectResponse(url="/static/favicon.svg?v=20260428h")
@app.get("/api/config")
async def api_config():
async def api_config(request: Request):
"""供页面展示:当前是否接入模型、模型名、提供方(不含密钥)。"""
base = settings.openai_base_url or ""
user = _current_user(request)
model_cfg = users.get_active_ai_model(user["id"]) if user else None
base = (model_cfg or {}).get("base_url") or ""
provider = "dashscope" if "dashscope.aliyuncs.com" in base else "openai_compatible"
host = urlparse(base).netloc if base else ""
model_name = (model_cfg or {}).get("model") or None
timeout_sec = (model_cfg or {}).get("timeout_sec") or None
max_output_tokens = (model_cfg or {}).get("max_output_tokens") or None
key_configured = bool((model_cfg or {}).get("api_key"))
return {
"openai_configured": bool(settings.openai_api_key),
"openai_model": settings.openai_model,
"openai_configured": key_configured,
"openai_model": model_name,
"provider": provider,
"base_url_host": host or None,
"openai_timeout_sec": settings.openai_timeout,
"openai_max_output_tokens": settings.openai_max_output_tokens,
"openai_timeout_sec": timeout_sec,
"openai_max_output_tokens": max_output_tokens,
}
@@ -132,6 +156,8 @@ async def auth_me(request: Request):
"wechat_bound": bool(binding and binding.get("appid") and binding.get("secret")),
"active_wechat_account": binding,
"wechat_accounts": bindings,
"active_ai_model": users.get_active_ai_model(user["id"]),
"ai_models": users.list_ai_models(user["id"]),
}
@@ -160,7 +186,12 @@ async def auth_register(req: AuthCredentialRequest, response: Response):
max_age=ttl,
path="/",
)
return {"ok": True, "detail": "注册并登录成功", "user": user}
return {
"ok": True,
"detail": "注册并登录成功,请保存重置码",
"user": {"id": user["id"], "username": user["username"]},
"reset_code": user.get("reset_code", ""),
}
@app.post("/api/auth/login")
@@ -201,23 +232,17 @@ async def forgot_password_page(request: Request):
@app.post("/api/auth/password/forgot")
async def auth_forgot_password_reset(req: ForgotPasswordResetRequest):
reset_key = (req.reset_key or "").strip()
expected_key = (settings.auth_password_reset_key or "x2ws-reset-2026").strip()
username = (req.username or "").strip()
new_password = req.new_password or ""
if not expected_key:
return {"ok": False, "detail": "系统未启用忘记密码重置功能,请联系管理员"}
if len(username) < 2:
return {"ok": False, "detail": "请输入正确的用户名"}
if len(new_password) < 6:
return {"ok": False, "detail": "新密码至少 6 个字符"}
if not hmac.compare_digest(reset_key, expected_key):
return {"ok": False, "detail": "重置码错误"}
ok = users.reset_password_by_username(username, new_password)
ok = users.reset_password_by_username(username, req.reset_key, new_password)
if not ok:
return {"ok": False, "detail": "用户不存在,无法重置"}
return {"ok": False, "detail": "用户名或重置码错误,无法重置"}
return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"}
@@ -254,6 +279,25 @@ async def auth_change_password(req: ChangePasswordRequest, request: Request, res
return {"ok": True, "detail": "密码修改成功,已刷新登录状态"}
@app.post("/api/auth/account/delete")
async def auth_delete_account(req: DeleteAccountRequest, request: Request, response: Response):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
if len((req.password or "").strip()) < 1:
return {"ok": False, "detail": "请输入当前密码"}
if len((req.reset_key or "").strip()) < 4:
return {"ok": False, "detail": "请输入重置码"}
ok = users.delete_user_logically(user["id"], req.password, req.reset_key)
if not ok:
return {"ok": False, "detail": "密码或重置码错误,注销失败"}
token = request.cookies.get(settings.auth_cookie_name, "")
if token:
users.delete_session(token)
response.delete_cookie(settings.auth_cookie_name, path="/")
return {"ok": True, "detail": "账号已注销,关联数据已清空"}
@app.post("/api/auth/wechat/bind")
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
user = _require_user(request)
@@ -286,6 +330,63 @@ async def auth_wechat_switch(req: WechatSwitchRequest, request: Request):
return {"ok": True, "detail": "已切换当前公众号账号"}
@app.post("/api/auth/wechat/delete")
async def auth_wechat_delete(req: WechatDeleteRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
ok = users.delete_wechat_binding(user["id"], int(req.account_id))
if not ok:
return {"ok": False, "detail": "删除失败:账号不存在或无权限"}
return {"ok": True, "detail": "公众号账号已删除"}
@app.post("/api/auth/ai-models/add")
async def auth_ai_model_add(req: AIModelCreateRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
api_key = (req.api_key or "").strip()
model = (req.model or "").strip()
if not api_key:
return {"ok": False, "detail": "API Key 不能为空"}
if not model:
return {"ok": False, "detail": "模型名不能为空"}
created = users.add_ai_model(
user_id=user["id"],
model_name=(req.model_name or "").strip(),
api_key=api_key,
base_url=(req.base_url or "").strip(),
model=model,
timeout_sec=max(10.0, float(req.timeout_sec or 120.0)),
max_output_tokens=max(256, int(req.max_output_tokens or 8192)),
max_retries=max(0, int(req.max_retries or 0)),
)
return {"ok": True, "detail": "模型配置已保存并设为当前", "model_config": created}
@app.post("/api/auth/ai-models/switch")
async def auth_ai_model_switch(req: AIModelSwitchRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
ok = users.switch_active_ai_model(user["id"], int(req.model_id))
if not ok:
return {"ok": False, "detail": "切换失败:模型不存在或无权限"}
return {"ok": True, "detail": "已切换当前模型"}
@app.post("/api/auth/ai-models/delete")
async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
ok = users.delete_ai_model(user["id"], int(req.model_id))
if not ok:
return {"ok": False, "detail": "删除失败:模型不存在或无权限"}
return {"ok": True, "detail": "模型配置已删除"}
@app.post("/api/rewrite")
async def rewrite(req: RewriteRequest, request: Request):
rid = getattr(request.state, "request_id", "")
@@ -302,7 +403,35 @@ async def rewrite(req: RewriteRequest, request: Request):
len(req.avoid_words or ""),
int(req.target_body_chars or 500),
)
result = rewriter.rewrite(req, request_id=rid)
user = _require_user(request)
if not user:
raise HTTPException(status_code=401, detail="请先登录")
model_cfg = users.get_active_ai_model(user["id"])
if not model_cfg:
raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型")
backup = {
"openai_api_key": settings.openai_api_key,
"openai_base_url": settings.openai_base_url,
"openai_model": settings.openai_model,
"openai_timeout": settings.openai_timeout,
"openai_max_output_tokens": settings.openai_max_output_tokens,
"openai_max_retries": settings.openai_max_retries,
}
try:
settings.openai_api_key = model_cfg.get("api_key") or ""
settings.openai_base_url = model_cfg.get("base_url") or ""
settings.openai_model = model_cfg.get("model") or ""
settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0)
settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192)
settings.openai_max_retries = int(model_cfg.get("max_retries") or 0)
result = AIRewriter().rewrite(req, request_id=rid)
finally:
settings.openai_api_key = backup["openai_api_key"]
settings.openai_base_url = backup["openai_base_url"]
settings.openai_model = backup["openai_model"]
settings.openai_timeout = backup["openai_timeout"]
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
settings.openai_max_retries = backup["openai_max_retries"]
tr = result.trace or {}
logger.info(
"api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s",
@@ -367,6 +496,112 @@ async def upload_wechat_cover(request: Request, file: UploadFile = File(...)):
return out
@app.post("/api/wechat/cover/upload-by-url")
async def upload_wechat_cover_by_url(req: WechatCoverUploadByUrlRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
binding = users.get_active_wechat_binding(user["id"])
if not binding:
return {"ok": False, "detail": "当前账号未绑定公众号 token请先在页面绑定"}
rid = getattr(request.state, "request_id", "")
image_url = (req.image_url or "").strip()
if not image_url:
return {"ok": False, "detail": "图片 URL 不能为空"}
parsed = urlparse(image_url)
if parsed.scheme not in {"http", "https"}:
return {"ok": False, "detail": "仅支持 http/https 图片地址"}
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(image_url)
except Exception as exc:
logger.warning("api_wechat_cover_url_fetch_fail rid=%s url=%s err=%s", rid, image_url, exc)
return {"ok": False, "detail": "图片下载失败,请检查 URL 是否可访问"}
if resp.status_code >= 400:
return {"ok": False, "detail": f"图片下载失败HTTP {resp.status_code}"}
content_type = (resp.headers.get("content-type") or "").lower()
if not content_type.startswith("image/"):
return {"ok": False, "detail": "URL 指向的内容不是图片"}
content = resp.content or b""
if not content:
return {"ok": False, "detail": "图片内容为空"}
if len(content) > 10 * 1024 * 1024:
return {"ok": False, "detail": "图片过大,请使用 10MB 以内的图片"}
ext = Path(parsed.path or "").suffix.lower()
if ext not in {".jpg", ".jpeg", ".png", ".webp"}:
ext = ".png" if "png" in content_type else ".jpg"
fn = f"cover_from_url{ext}"
logger.info("api_wechat_cover_upload_url_in rid=%s url=%s bytes=%d", rid, image_url, len(content))
out = await wechat.upload_cover(fn, content, request_id=rid, account=binding)
logger.info(
"api_wechat_cover_upload_url_out rid=%s ok=%s detail=%s",
rid,
out.ok,
(out.detail or "")[:160],
)
return out
@app.post("/api/wechat/cover/generate")
async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
binding = users.get_active_wechat_binding(user["id"]) if req.upload_to_wechat else None
if req.upload_to_wechat and not binding:
return {"ok": False, "detail": "当前账号未绑定公众号 token请先在页面绑定"}
rid = getattr(request.state, "request_id", "")
logger.info(
"api_wechat_cover_generate_in rid=%s title_chars=%d summary_chars=%d upload_to_wechat=%s",
rid,
len(req.title or ""),
len(req.summary or ""),
req.upload_to_wechat,
)
model_cfg = users.get_active_ai_model(user["id"])
backup = {
"openai_api_key": settings.openai_api_key,
"openai_base_url": settings.openai_base_url,
"openai_model": settings.openai_model,
"openai_timeout": settings.openai_timeout,
"openai_max_output_tokens": settings.openai_max_output_tokens,
"openai_max_retries": settings.openai_max_retries,
}
try:
if model_cfg:
settings.openai_api_key = model_cfg.get("api_key") or ""
settings.openai_base_url = model_cfg.get("base_url") or ""
settings.openai_model = model_cfg.get("model") or ""
settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0)
settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192)
settings.openai_max_retries = int(model_cfg.get("max_retries") or 0)
else:
settings.openai_api_key = ""
settings.openai_base_url = ""
settings.openai_model = ""
settings.openai_timeout = 120.0
settings.openai_max_output_tokens = 8192
settings.openai_max_retries = 0
out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding)
finally:
settings.openai_api_key = backup["openai_api_key"]
settings.openai_base_url = backup["openai_base_url"]
settings.openai_model = backup["openai_model"]
settings.openai_timeout = backup["openai_timeout"]
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
settings.openai_max_retries = backup["openai_max_retries"]
logger.info(
"api_wechat_cover_generate_out rid=%s ok=%s thumb=%s note=%s warnings=%d",
rid,
out.ok,
bool(out.thumb_media_id),
out.note,
len(out.warnings),
)
return out
@app.post("/api/wechat/material/upload")
async def upload_wechat_material(request: Request, file: UploadFile = File(...)):
user = _require_user(request)
@@ -389,6 +624,64 @@ async def upload_wechat_material(request: Request, file: UploadFile = File(...))
return out
@app.post("/api/material/posters/generate")
async def generate_posters(req: PosterGenerateRequest, request: Request):
user = _require_user(request)
if not user:
return {"ok": False, "detail": "请先登录"}
binding = users.get_active_wechat_binding(user["id"]) if req.upload_to_wechat else None
if req.upload_to_wechat and not binding:
return {"ok": False, "detail": "当前账号未绑定公众号 token请先在页面绑定"}
rid = getattr(request.state, "request_id", "")
logger.info(
"api_poster_generate_in rid=%s body_chars=%d upload_to_wechat=%s max_images=%d",
rid,
len(req.body_markdown or ""),
req.upload_to_wechat,
int(req.max_images or 0),
)
model_cfg = users.get_active_ai_model(user["id"])
backup = {
"openai_api_key": settings.openai_api_key,
"openai_base_url": settings.openai_base_url,
"openai_model": settings.openai_model,
"openai_timeout": settings.openai_timeout,
"openai_max_output_tokens": settings.openai_max_output_tokens,
"openai_max_retries": settings.openai_max_retries,
}
try:
if model_cfg:
settings.openai_api_key = model_cfg.get("api_key") or ""
settings.openai_base_url = model_cfg.get("base_url") or ""
settings.openai_model = model_cfg.get("model") or ""
settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0)
settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192)
settings.openai_max_retries = int(model_cfg.get("max_retries") or 0)
else:
settings.openai_api_key = ""
settings.openai_base_url = ""
settings.openai_model = ""
settings.openai_timeout = 120.0
settings.openai_max_output_tokens = 8192
settings.openai_max_retries = 0
out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding)
finally:
settings.openai_api_key = backup["openai_api_key"]
settings.openai_base_url = backup["openai_base_url"]
settings.openai_model = backup["openai_model"]
settings.openai_timeout = backup["openai_timeout"]
settings.openai_max_output_tokens = backup["openai_max_output_tokens"]
settings.openai_max_retries = backup["openai_max_retries"]
logger.info(
"api_poster_generate_out rid=%s ok=%s posters=%d warnings=%d",
rid,
out.ok,
len(out.posters),
len(out.warnings),
)
return out
@app.post("/api/publish/im")
async def publish_im(req: IMPublishRequest, request: Request):
rid = getattr(request.state, "request_id", "")

View File

@@ -67,6 +67,11 @@ class ForgotPasswordResetRequest(BaseModel):
new_password: str
class DeleteAccountRequest(BaseModel):
password: str
reset_key: str
class WechatBindingRequest(BaseModel):
account_name: str = ""
appid: str
@@ -78,3 +83,75 @@ class WechatBindingRequest(BaseModel):
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 = ""
upload_to_wechat: bool = True
class AIModelCreateRequest(BaseModel):
model_name: str
api_key: str
base_url: str = ""
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 PosterGenerateRequest(BaseModel):
title: str = ""
summary: str = ""
body_markdown: str = Field(..., min_length=20)
style_hint: 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

@@ -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)

View File

@@ -66,6 +66,27 @@ class UserStore:
)
"""
)
pref_cols = self._table_columns(c, "user_prefs")
if "active_ai_model_id" not in pref_cols:
c.execute("ALTER TABLE user_prefs ADD COLUMN active_ai_model_id INTEGER")
c.execute(
"""
CREATE TABLE IF NOT EXISTS ai_models (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL,
model_name TEXT NOT NULL,
api_key TEXT NOT NULL,
base_url TEXT NOT NULL DEFAULT '',
model TEXT NOT NULL,
timeout_sec REAL NOT NULL DEFAULT 120.0,
max_output_tokens INTEGER NOT NULL DEFAULT 8192,
max_retries INTEGER NOT NULL DEFAULT 0,
updated_at INTEGER NOT NULL,
UNIQUE(user_id, model_name),
FOREIGN KEY(user_id) REFERENCES users(id)
)
"""
)
# 兼容历史单绑定结构,自动迁移为默认账号
rows = c.execute(
"SELECT user_id, appid, secret, author, thumb_media_id, thumb_image_path, updated_at FROM wechat_bindings"
@@ -111,7 +132,16 @@ class UserStore:
return {str(r["name"]) for r in rows}
def _ensure_users_table(self, c: sqlite3.Connection) -> None:
required = {"id", "username", "password_hash", "password_salt", "created_at"}
required = {
"id",
"username",
"password_hash",
"password_salt",
"reset_code_hash",
"reset_code_salt",
"created_at",
"deleted_at",
}
c.execute(
"""
CREATE TABLE IF NOT EXISTS users (
@@ -119,7 +149,10 @@ class UserStore:
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at INTEGER NOT NULL
reset_code_hash TEXT NOT NULL DEFAULT '',
reset_code_salt TEXT NOT NULL DEFAULT '',
created_at INTEGER NOT NULL,
deleted_at INTEGER
)
"""
)
@@ -137,29 +170,44 @@ class UserStore:
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
password_salt TEXT NOT NULL,
created_at INTEGER NOT NULL
reset_code_hash TEXT NOT NULL,
reset_code_salt TEXT NOT NULL,
created_at INTEGER NOT NULL,
deleted_at INTEGER
)
"""
)
if {"username", "password_hash", "password_salt"}.issubset(cols):
if "created_at" in cols:
rows = c.execute("SELECT * FROM users").fetchall()
for r in rows:
username = (r["username"] or "").strip()
if not username:
continue
reset_salt = r["reset_code_salt"] if "reset_code_salt" in cols else secrets.token_hex(8)
reset_hash = r["reset_code_hash"] if "reset_code_hash" in cols else ""
if not reset_hash:
legacy_code = self._generate_reset_code()
reset_hash = self._hash_reset_code(legacy_code, reset_salt)
created_at = int(r["created_at"]) if "created_at" in cols and r["created_at"] else now
deleted_at = int(r["deleted_at"]) if "deleted_at" in cols and r["deleted_at"] else None
c.execute(
"""
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
SELECT id, username, password_hash, password_salt, COALESCE(created_at, ?)
FROM users
""",
(now,),
INSERT OR IGNORE INTO users_new(
id, username, password_hash, password_salt, reset_code_hash, reset_code_salt, created_at, deleted_at
)
else:
c.execute(
"""
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
SELECT id, username, password_hash, password_salt, ?
FROM users
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""",
(now,),
(
int(r["id"]),
username,
r["password_hash"],
r["password_salt"],
reset_hash,
reset_salt,
created_at,
deleted_at,
),
)
elif {"username", "password"}.issubset(cols):
if "created_at" in cols:
@@ -173,13 +221,18 @@ class UserStore:
continue
salt = secrets.token_hex(16)
pwd_hash = self._hash_password(raw_pwd, salt)
reset_code = self._generate_reset_code()
reset_salt = secrets.token_hex(8)
reset_hash = self._hash_reset_code(reset_code, reset_salt)
created_at = int(r["created_at"]) if "created_at" in r.keys() and r["created_at"] else now
c.execute(
"""
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
VALUES (?, ?, ?, ?, ?)
INSERT OR IGNORE INTO users_new(
id, username, password_hash, password_salt, reset_code_hash, reset_code_salt, created_at, deleted_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, NULL)
""",
(int(r["id"]), username, pwd_hash, salt, created_at),
(int(r["id"]), username, pwd_hash, salt, reset_hash, reset_salt, created_at),
)
c.execute("DROP TABLE users")
@@ -222,18 +275,31 @@ class UserStore:
def _hash_token(self, token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()
def _generate_reset_code(self) -> str:
return secrets.token_urlsafe(9).replace("-", "").replace("_", "")[:12]
def _hash_reset_code(self, reset_code: str, salt: str) -> str:
return hashlib.sha256(f"{salt}:{reset_code}".encode("utf-8")).hexdigest()
def create_user(self, username: str, password: str) -> dict | None:
now = int(time.time())
salt = secrets.token_hex(16)
pwd_hash = self._hash_password(password, salt)
reset_code = self._generate_reset_code()
reset_salt = secrets.token_hex(8)
reset_hash = self._hash_reset_code(reset_code, reset_salt)
try:
with self._conn() as c:
cur = c.execute(
"INSERT INTO users(username, password_hash, password_salt, created_at) VALUES (?, ?, ?, ?)",
(username, pwd_hash, salt, now),
"""
INSERT INTO users(
username, password_hash, password_salt, reset_code_hash, reset_code_salt, created_at, deleted_at
) VALUES (?, ?, ?, ?, ?, ?, NULL)
""",
(username, pwd_hash, salt, reset_hash, reset_salt, now),
)
uid = int(cur.lastrowid)
return {"id": uid, "username": username}
return {"id": uid, "username": username, "reset_code": reset_code}
except sqlite3.IntegrityError:
return None
except sqlite3.Error as exc:
@@ -243,7 +309,11 @@ class UserStore:
try:
with self._conn() as c:
row = c.execute(
"SELECT id, username, password_hash, password_salt FROM users WHERE username=?",
"""
SELECT id, username, password_hash, password_salt
FROM users
WHERE username=? AND deleted_at IS NULL
""",
(username,),
).fetchone()
except sqlite3.Error as exc:
@@ -274,14 +344,27 @@ class UserStore:
)
return True
def reset_password_by_username(self, username: str, new_password: str) -> bool:
def reset_password_by_username(self, username: str, reset_code: str, new_password: str) -> bool:
uname = (username or "").strip()
rcode = (reset_code or "").strip()
if not uname:
return False
with self._conn() as c:
row = c.execute("SELECT id FROM users WHERE username=?", (uname,)).fetchone()
row = c.execute(
"""
SELECT id, reset_code_hash, reset_code_salt
FROM users
WHERE username=? AND deleted_at IS NULL
""",
(uname,),
).fetchone()
if not row:
return False
if not rcode:
return False
calc = self._hash_reset_code(rcode, row["reset_code_salt"] or "")
if not hmac.compare_digest(calc, row["reset_code_hash"] or ""):
return False
new_salt = secrets.token_hex(16)
new_hash = self._hash_password(new_password, new_salt)
c.execute(
@@ -324,7 +407,7 @@ class UserStore:
SELECT u.id, u.username
FROM sessions s
JOIN users u ON u.id=s.user_id
WHERE s.token_hash=? AND s.expires_at>=?
WHERE s.token_hash=? AND s.expires_at>=? AND u.deleted_at IS NULL
""",
(th, now),
).fetchone()
@@ -332,6 +415,37 @@ class UserStore:
return None
return {"id": int(row["id"]), "username": row["username"]}
def delete_user_logically(self, user_id: int, password: str, reset_code: str) -> bool:
now = int(time.time())
with self._conn() as c:
row = c.execute(
"""
SELECT id, password_hash, password_salt, reset_code_hash, reset_code_salt
FROM users
WHERE id=? AND deleted_at IS NULL
""",
(user_id,),
).fetchone()
if not row:
return False
calc_pwd = self._hash_password(password or "", row["password_salt"] or "")
if not hmac.compare_digest(calc_pwd, row["password_hash"] or ""):
return False
calc_reset = self._hash_reset_code(reset_code or "", row["reset_code_salt"] or "")
if not hmac.compare_digest(calc_reset, row["reset_code_hash"] or ""):
return False
c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,))
c.execute("DELETE FROM wechat_accounts WHERE user_id=?", (user_id,))
c.execute("DELETE FROM ai_models WHERE user_id=?", (user_id,))
c.execute("DELETE FROM user_prefs WHERE user_id=?", (user_id,))
c.execute("DELETE FROM wechat_bindings WHERE user_id=?", (user_id,))
c.execute(
"UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?",
(now, str(now), user_id),
)
return True
def save_wechat_binding(
self,
user_id: int,
@@ -510,3 +624,230 @@ class UserStore:
"thumb_image_path": row["thumb_image_path"] or "",
"updated_at": int(row["updated_at"] or 0),
}
def list_ai_models(self, user_id: int) -> list[dict]:
with self._conn() as c:
rows = c.execute(
"""
SELECT id, model_name, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at
FROM ai_models
WHERE user_id=?
ORDER BY updated_at DESC, id DESC
""",
(user_id,),
).fetchall()
pref = c.execute(
"SELECT active_ai_model_id FROM user_prefs WHERE user_id=?",
(user_id,),
).fetchone()
active_id = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None
out: list[dict] = []
for r in rows:
out.append(
{
"id": int(r["id"]),
"model_name": r["model_name"] or "",
"base_url": r["base_url"] or "",
"model": r["model"] or "",
"timeout_sec": float(r["timeout_sec"] or 120.0),
"max_output_tokens": int(r["max_output_tokens"] or 8192),
"max_retries": int(r["max_retries"] or 0),
"updated_at": int(r["updated_at"] or 0),
"active": int(r["id"]) == active_id,
}
)
return out
def add_ai_model(
self,
user_id: int,
model_name: str,
api_key: str,
base_url: str,
model: str,
timeout_sec: float = 120.0,
max_output_tokens: int = 8192,
max_retries: int = 0,
) -> dict:
now = int(time.time())
name = model_name.strip() or f"模型{now % 10000}"
with self._conn() as c:
try:
cur = c.execute(
"""
INSERT INTO ai_models
(user_id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(user_id, name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, now),
)
except sqlite3.IntegrityError:
name = f"{name}-{now % 1000}"
cur = c.execute(
"""
INSERT INTO ai_models
(user_id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
""",
(user_id, name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, now),
)
aid = int(cur.lastrowid)
c.execute(
"""
INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_ai_model_id=excluded.active_ai_model_id,
updated_at=excluded.updated_at
""",
(user_id, aid, now),
)
return {"id": aid, "model_name": name}
def switch_active_ai_model(self, user_id: int, model_id: int) -> bool:
now = int(time.time())
with self._conn() as c:
row = c.execute(
"SELECT id FROM ai_models WHERE id=? AND user_id=?",
(model_id, user_id),
).fetchone()
if not row:
return False
c.execute(
"""
INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_ai_model_id=excluded.active_ai_model_id,
updated_at=excluded.updated_at
""",
(user_id, model_id, now),
)
return True
def delete_ai_model(self, user_id: int, model_id: int) -> bool:
now = int(time.time())
with self._conn() as c:
row = c.execute(
"SELECT id FROM ai_models WHERE id=? AND user_id=?",
(model_id, user_id),
).fetchone()
if not row:
return False
c.execute("DELETE FROM ai_models WHERE id=? AND user_id=?", (model_id, user_id))
pref = c.execute(
"SELECT active_ai_model_id FROM user_prefs WHERE user_id=?",
(user_id,),
).fetchone()
active_id = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None
if active_id == model_id:
replacement = c.execute(
"""
SELECT id FROM ai_models WHERE user_id=?
ORDER BY updated_at DESC, id DESC
LIMIT 1
""",
(user_id,),
).fetchone()
replacement_id = int(replacement["id"]) if replacement else None
c.execute(
"""
INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_ai_model_id=excluded.active_ai_model_id,
updated_at=excluded.updated_at
""",
(user_id, replacement_id, now),
)
return True
def get_active_ai_model(self, user_id: int) -> dict | None:
with self._conn() as c:
pref = c.execute(
"SELECT active_ai_model_id FROM user_prefs WHERE user_id=?",
(user_id,),
).fetchone()
aid = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None
row = None
if aid:
row = c.execute(
"""
SELECT id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at
FROM ai_models
WHERE id=? AND user_id=?
""",
(aid, user_id),
).fetchone()
if not row:
row = c.execute(
"""
SELECT id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at
FROM ai_models
WHERE user_id=?
ORDER BY updated_at DESC, id DESC
LIMIT 1
""",
(user_id,),
).fetchone()
if row:
c.execute(
"""
INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_ai_model_id=excluded.active_ai_model_id,
updated_at=excluded.updated_at
""",
(user_id, int(row["id"]), int(time.time())),
)
if not row:
return None
return {
"id": int(row["id"]),
"model_name": row["model_name"] or "",
"api_key": row["api_key"] or "",
"base_url": row["base_url"] or "",
"model": row["model"] or "",
"timeout_sec": float(row["timeout_sec"] or 120.0),
"max_output_tokens": int(row["max_output_tokens"] or 8192),
"max_retries": int(row["max_retries"] or 0),
"updated_at": int(row["updated_at"] or 0),
}
def delete_wechat_binding(self, user_id: int, account_id: int) -> bool:
now = int(time.time())
with self._conn() as c:
row = c.execute(
"SELECT id FROM wechat_accounts WHERE id=? AND user_id=?",
(account_id, user_id),
).fetchone()
if not row:
return False
c.execute("DELETE FROM wechat_accounts WHERE id=? AND user_id=?", (account_id, user_id))
pref = c.execute(
"SELECT active_wechat_account_id FROM user_prefs WHERE user_id=?",
(user_id,),
).fetchone()
active_id = int(pref["active_wechat_account_id"]) if pref and pref["active_wechat_account_id"] else None
if active_id == account_id:
replacement = c.execute(
"""
SELECT id FROM wechat_accounts WHERE user_id=?
ORDER BY updated_at DESC, id DESC
LIMIT 1
""",
(user_id,),
).fetchone()
replacement_id = int(replacement["id"]) if replacement else None
c.execute(
"""
INSERT INTO user_prefs(user_id, active_wechat_account_id, updated_at)
VALUES (?, ?, ?)
ON CONFLICT(user_id) DO UPDATE SET
active_wechat_account_id=excluded.active_wechat_account_id,
updated_at=excluded.updated_at
""",
(user_id, replacement_id, now),
)
return True

View File

@@ -69,11 +69,11 @@ class WechatPublisher:
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
src = account or {}
appid = (src.get("appid") or settings.wechat_appid or "").strip()
secret = (src.get("secret") or settings.wechat_secret or "").strip()
author = (src.get("author") or settings.wechat_author or "").strip()
thumb_media_id = (src.get("thumb_media_id") or settings.wechat_thumb_media_id or "").strip()
thumb_image_path = (src.get("thumb_image_path") or settings.wechat_thumb_image_path or "").strip()
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,
@@ -132,7 +132,7 @@ class WechatPublisher:
{
"article_type": "news",
"title": req.title[:32] if len(req.title) > 32 else req.title,
"author": (req.author or acct["author"] or settings.wechat_author)[:16],
"author": (req.author or acct["author"] or "AI发糕")[:16],
"digest": (req.summary or "")[:128],
"content": html,
"content_source_url": "",
@@ -246,6 +246,37 @@ class WechatPublisher:
)
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:
@@ -263,6 +294,23 @@ class WechatPublisher:
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:

View File

@@ -16,8 +16,51 @@ const rewriteBtn = $("rewriteBtn");
const wechatBtn = $("wechatBtn");
const imBtn = $("imBtn");
const coverUploadBtn = $("coverUploadBtn");
const coverUrlUploadBtn = $("coverUrlUploadBtn");
const coverGenerateBtn = $("coverGenerateBtn");
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 targetBodyCharsInput = $("targetBodyChars");
const posterGenerateBtn = $("posterGenerateBtn");
const posterPreviewList = $("posterPreviewList");
const posterHint = $("posterHint");
const posterAutoInclude = $("posterAutoInclude");
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);
@@ -82,6 +125,68 @@ function setStatus(msg, danger = false) {
statusEl.textContent = msg;
}
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",
@@ -94,6 +199,78 @@ async function postJSON(url, body) {
return data;
}
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,
upload_to_wechat: true,
});
if (!data.ok) throw new Error(data.detail || "海报生成失败");
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(),
upload_to_wechat: true,
});
if (!data.ok) throw new Error(data.detail || "封面生成失败");
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();
@@ -117,10 +294,10 @@ function renderWechatAccountSelect(me) {
if (!list.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "暂无公众号";
opt.textContent = "未绑定公众号";
sel.appendChild(opt);
sel.disabled = true;
if (hint) hint.textContent = "请先在「公众号设置」绑定";
if (hint) hint.textContent = "请先绑定公众号";
return;
}
sel.disabled = false;
@@ -192,6 +369,13 @@ if (logoutBtn) {
});
}
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);
@@ -245,6 +429,22 @@ $("rewriteBtn").addEventListener("click", async () => {
} else {
setStatus("改写完成。");
}
try {
setStatus("改写完成,正在生成段落海报...");
await generatePosterMaterials({ silent: true });
setStatus("改写与段落海报生成完成。");
} catch (posterErr) {
setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true);
}
if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) {
try {
setStatus("改写完成,正在按输出标题生成封面...");
await generateWechatCover({ silent: true });
setStatus("改写、封面与段落海报生成完成。");
} catch (coverErr) {
setStatus(`改写完成,封面未生成:${coverErr.message}`, true);
}
}
} catch (e) {
setStatus(`改写失败: ${e.message}`, true);
} finally {
@@ -256,10 +456,25 @@ $("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) {
try {
await generatePosterMaterials({ silent: true });
} catch (posterErr) {
setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true);
}
}
if (posterState.bodyMarkdownWithPosters) {
bodyForPublish = posterState.bodyMarkdownWithPosters;
}
}
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);
@@ -276,6 +491,23 @@ $("wechatBtn").addEventListener("click", async () => {
}
});
if (coverGenerateBtn) {
coverGenerateBtn.addEventListener("click", async () => {
try {
await generateWechatCover({ silent: false });
} catch (e) {
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 (coverUploadBtn) {
coverUploadBtn.addEventListener("click", async () => {
const fileInput = $("coverFile");
@@ -311,6 +543,54 @@ if (coverUploadBtn) {
});
}
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 = "";
} 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 });
} catch (e) {
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", "发送中...");
@@ -328,11 +608,15 @@ $("imBtn").addEventListener("click", async () => {
}
});
["sourceText", "summary", "body"].forEach((id) => {
["sourceText", "title", "summary", "body"].forEach((id) => {
$(id).addEventListener("input", updateCounters);
if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded);
});
updateCounters();
initMultiDropdowns();
initWechatAccountSwitch();
syncTargetCharChips();
renderPosterPreview([]);
setCoverMode("manual");
window.addEventListener("load", () => setCoverMode("manual"));

View File

@@ -66,6 +66,33 @@ if (loginBtn) {
if (registerBtn) {
registerBtn.addEventListener("click", async () => {
await authAction("/api/auth/register", registerBtn, "注册", "注册中...", "注册成功,正在跳转...");
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` +
"请立即复制并妥善保管,点击“确定”后继续进入系统。";
window.alert(msg);
try {
if (navigator.clipboard && navigator.clipboard.writeText) {
await navigator.clipboard.writeText(code);
}
} catch {
// 忽略复制失败
}
}
setStatus("注册成功,正在跳转...");
window.location.href = nextPath();
} catch (e) {
setStatus(e.message || "请求异常", true);
} finally {
setLoading(registerBtn, false, "注册", "注册中...");
}
});
}

View File

@@ -1,4 +1,32 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AIcreat">
<rect width="64" height="64" rx="14" fill="#0891b2"/>
<path d="M20 46l10-28h4l10 28h-5l-2-6H27l-2 6h-5zm9-10h14l-7-20-7 20z" fill="#fff"/>
<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>

Before

Width:  |  Height:  |  Size: 243 B

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@@ -43,7 +43,7 @@ function renderAccounts(me) {
if (!list.length) {
const opt = document.createElement("option");
opt.value = "";
opt.textContent = "暂无公众号,请先绑定";
opt.textContent = "未绑定公众号";
sel.appendChild(opt);
return;
}
@@ -56,16 +56,44 @@ function renderAccounts(me) {
});
}
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);
});
}
async function refresh() {
const me = await authMe();
if (!me) return;
renderAccounts(me);
renderModels(me);
}
const accountSelect = $("accountSelect");
const bindBtn = $("bindBtn");
const deleteWechatBtn = $("deleteWechatBtn");
const logoutBtn = $("logoutBtn");
const changePwdBtn = $("changePwdBtn");
const deleteAccountBtn = $("deleteAccountBtn");
const modelSelect = $("modelSelect");
const saveModelBtn = $("saveModelBtn");
const deleteModelBtn = $("deleteModelBtn");
if (accountSelect) {
accountSelect.addEventListener("change", async () => {
@@ -85,6 +113,32 @@ if (accountSelect) {
});
}
if (deleteWechatBtn) {
deleteWechatBtn.addEventListener("click", async () => {
const id = Number((accountSelect && accountSelect.value) || 0);
if (!id) {
setStatus("请先选择要删除的公众号", true);
return;
}
const sure = window.confirm("确定删除当前公众号绑定吗?删除后不可恢复。");
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, "绑定并设为当前账号", "绑定中...");
@@ -113,6 +167,79 @@ if (bindBtn) {
});
}
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()) || "",
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 = "";
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 = window.confirm("确定删除当前模型配置吗?删除后不可恢复。");
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 (logoutBtn) {
logoutBtn.addEventListener("click", async () => {
setLoading(logoutBtn, true, "退出登录", "退出中...");
@@ -150,4 +277,45 @@ if (changePwdBtn) {
});
}
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 = window.confirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。");
if (!sure) return;
const confirmText = window.prompt("为防止误删,请输入「注销账户」后确认:", "");
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

View File

@@ -4,38 +4,90 @@
<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=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
</head>
<body class="simple-page">
<main class="simple-wrap">
<section class="panel simple-panel">
<h2>登录 / 注册</h2>
<p class="muted small">登录后将跳转到编辑主页。</p>
<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="grid2">
<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="请输入用户名" />
<input id="username" type="text" placeholder="请输入用户名" autocomplete="username" />
</div>
<div>
<label>密码</label>
<input id="password" type="password" placeholder="请输入密码(至少 6 位)" />
</div>
<input id="password" type="password" placeholder="请输入密码(至少 6 位)" autocomplete="current-password" />
</div>
<div class="check-row">
<label class="check-label">
<input id="rememberMe" type="checkbox" checked />
<span>7 天内免登录(限时)</span>
<span>7 天内免登录</span>
</label>
<a class="subtle-link" href="/auth/forgot">忘记密码?</a>
<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>
<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>

View File

@@ -4,14 +4,21 @@
<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=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
</head>
<body class="simple-page">
<main class="simple-wrap">
<main class="auth-card">
<section class="panel simple-panel">
<h2>忘记密码</h2>
<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>
@@ -19,14 +26,14 @@
</div>
<div>
<label>重置码</label>
<input id="resetKey" type="password" placeholder="请输入管理员提供的重置码" />
<input id="resetKey" type="password" placeholder="请输入你保存的个人重置码" />
</div>
</div>
<div>
<label>新密码</label>
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
</div>
<p class="muted small">请向管理员获取重置码。若未改配置,默认重置码为 x2ws-reset-2026建议尽快修改</p>
<p class="muted small">重置码仅在注册时展示一次,请妥善保存</p>
<div class="actions">
<button id="resetBtn" class="primary" type="button">重置密码</button>
</div>
@@ -35,6 +42,7 @@
<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>

134
app/templates/guide.html Normal file
View File

@@ -0,0 +1,134 @@
<!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=20260428h" />
</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="/guide">新手引导</a>
</nav>
<div class="side-footer">首次配置 · 三分钟跑通</div>
</aside>
<div class="workspace">
<header class="topbar topbar-compact">
<div class="topbar-actions">
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台"></a>
<a class="icon-btn" href="/settings" aria-label="账号与模型设置" title="账号与模型设置"></a>
</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>
<a href="/settings" class="guide-link">打开账号设置</a>
</article>
<article class="guide-card">
<div class="guide-step">02</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">03</div>
<h3>输入原文与策略</h3>
<p>在内容生产页粘贴原文,补充标题提示、目标读者、语气风格、必须保留观点和避免词汇。目标字数建议先从 500 或 800 开始。</p>
<a href="/" class="guide-link">进入写作输入</a>
</article>
<article class="guide-card">
<div class="guide-step">04</div>
<h3>生成并人工复核</h3>
<p>点击“改写并排版”后,检查标题、摘要、正文结构和排版预览。涉及事实、数据、引用和品牌表达时,发布前务必人工确认。</p>
<a href="/" class="guide-link">查看发布内容</a>
</article>
<article class="guide-card">
<div class="guide-step">05</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">06</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>
</body>
</html>

View File

@@ -4,42 +4,59 @@
<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=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260421a" />
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
</head>
<body>
<header class="topbar">
<div class="brand">
<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>
<p class="muted">从原文到公众号草稿,一页完成编辑、封面和发布。</p>
</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="/guide">新手引导</a>
</nav>
<div class="side-footer">生产环境 · 内容工作流</div>
</aside>
<div class="workspace">
<header class="topbar topbar-compact">
<div class="topbar-actions">
<div class="wechat-account-switch" title="草稿发布、封面上传均使用此处选中的公众号">
<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="subtle-link" href="/settings">公众号设置</a>
<button id="logoutBtn" class="subtle-btn topbar-btn" type="button">退出登录</button>
<a class="icon-btn" href="/guide" 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">
<section class="panel input-panel">
<div class="panel-head">
<h2>内容输入</h2>
<h2>写作输入</h2>
</div>
<div class="panel-scroll">
<section class="form-section">
<div class="field-head">
<label>内容</label>
<span id="sourceCount" class="meta">0 字</span>
</div>
<textarea id="sourceText" rows="9" 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">
@@ -59,10 +76,12 @@
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="普通读者" />普通读者</label>
</div>
</details>
<input id="audienceExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
<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">
@@ -83,23 +102,23 @@
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="理性克制" />理性克制</label>
</div>
</details>
<input id="toneExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
<input id="toneExtra" type="text" class="multi-extra" placeholder="可选补充" />
</div>
<div>
<label>避免词汇</label>
<input id="avoidWords" type="text" placeholder="如:颠覆、闭环、赋能" />
<input id="avoidWords" type="text" placeholder="可选:避免词汇" />
</div>
</div>
<div class="grid2">
<div>
<label>必须保留观点</label>
<input id="keepPoints" type="text" placeholder="逗号分隔" />
<input id="keepPoints" type="text" placeholder="可选:保留观点(逗号分隔" />
</div>
<div class="target-chars-block">
<label>改写目标字数</label>
<div class="target-chars-inline">
<input id="targetBodyChars" type="number" min="180" max="2200" step="10" value="500" placeholder="500" />
<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>
@@ -109,16 +128,22 @@
</div>
</div>
</div>
</section>
<section class="form-section">
<button id="rewriteBtn" class="primary">改写并排版</button>
<p id="status" class="status"></p>
</section>
</div>
</section>
<section class="panel output-panel">
<div class="panel-head">
<h2>发布内容</h2>
</div>
<div class="panel-scroll">
<section class="form-section">
<label>标题</label>
<input id="title" type="text" />
@@ -127,38 +152,93 @@
<span id="summaryCount" class="meta">0 字</span>
</div>
<textarea id="summary" rows="2"></textarea>
</section>
<label>公众号封面(可选上传)</label>
<section class="form-section">
<div class="field-head">
<label>公众号封面</label>
<span class="meta">900×383 横版头图</span>
</div>
<div class="cover-mode-switch" role="tablist" aria-label="封面模式切换">
<button id="coverModeManualBtn" class="cover-mode-btn is-active" type="button">手动上传封面</button>
<button id="coverModeAiBtn" class="cover-mode-btn" type="button">AI 自动生成封面</button>
</div>
<div id="coverAiSection" class="cover-ai-box" hidden>
<div class="cover-ai-copy">
<strong>AI 自动生成封面</strong>
<span>按标题生成并自动上传绑定。</span>
</div>
<div class="cover-tools">
<input id="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>
<input id="thumbMediaId" type="text" placeholder="thumb_media_id上传后自动填充也可手动粘贴" />
<p id="coverHint" class="muted small">未上传时将使用后端默认封面策略。</p>
<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>正文4~6 自然段,字数由左侧配置)</label>
<label>正文</label>
<span id="bodyCount" class="meta">0 字</span>
</div>
<div class="body-split">
<textarea id="body" rows="7" placeholder="五段之间空一行;无需 # 标题"></textarea>
<textarea id="body" rows="6" placeholder="可直接编辑正文"></textarea>
<div class="preview-panel">
<div class="field-head">
<label>排版预览</label>
<span class="meta">实时同步</span>
<span class="meta">实时</span>
</div>
<div id="bodyPreview" class="markdown-preview"></div>
</div>
</div>
</section>
<div class="actions">
<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" checked />发布时自动插入海报</label
>
</div>
<p id="posterHint" class="muted small">改写后可生成段落海报。</p>
<div id="posterPreviewList" class="poster-preview-list"></div>
</div>
</section>
<div class="actions publish-actions">
<button id="wechatBtn" class="primary">发布到公众号草稿箱</button>
<button id="imBtn" class="secondary">发送到 IM</button>
</div>
</div>
</section>
</main>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<script src="/static/app.js?v=20260421a"></script>
<script src="/static/app.js?v=20260428h"></script>
</body>
</html>

View File

@@ -3,28 +3,46 @@
<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=20260406a" />
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
<title>{{ app_name }} - 账号与模型设置</title>
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
</head>
<body class="simple-page">
<main class="simple-wrap">
<section class="panel simple-panel">
<div class="panel-head">
<h2>公众号设置</h2>
<p class="muted small">支持绑定多个公众号并切换当前发布账号。</p>
<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="/guide">新手引导</a>
</nav>
<div class="side-footer">生产环境 · 内容工作流</div>
</aside>
<div class="grid2">
<div class="workspace">
<header class="topbar topbar-compact">
<div class="topbar-actions">
<a class="icon-btn" href="/" aria-label="返回工作台" title="返回工作台"></a>
<a class="icon-btn" href="/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"></select>
</div>
<div class="actions-inline">
<a class="subtle-link" href="/">返回主页</a>
<button id="logoutBtn" class="subtle-btn topbar-btn" type="button">退出登录</button>
</div>
</div>
<h3 class="section-title">新增公众号</h3>
<div class="grid2">
@@ -41,8 +59,61 @@
<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"></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 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>
@@ -58,10 +129,25 @@
<a class="subtle-link" href="/auth/forgot">忘记密码提示</a>
<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>
<script src="/static/settings.js?v=20260410a"></script>
</div>
</div>
<script src="/static/settings.js?v=20260428a"></script>
</body>
</html>

View File

@@ -1,4 +1,4 @@
# X2WeChat Studio
# AI发糕
把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。
@@ -6,8 +6,6 @@
```bash
cp .env.example .env
# 填写 .env 中的 OPENAI / 微信 / IM 参数
docker compose up --build
```
@@ -22,23 +20,18 @@ 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`:勾选“限时免登”时的会话时长(秒)。
- `AUTH_PASSWORD_RESET_KEY`:忘记密码重置码(用于“用户名+重置码”找回,默认 `x2ws-reset-2026`,建议改掉)。
## 4. 说明
- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程
- 未配置用户级 AI 模型时,改写接口会提示先去“账号与模型”页面完成配置
- 建议发布前人工复核事实与引用,避免版权和失真风险。
- 登录页支持“限时免登”,设置页支持修改密码;忘记密码页支持通过“用户名 + 重置码”重置密码。

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."