fix: bug
This commit is contained in:
24
.env.example
24
.env.example
@@ -1,26 +1,28 @@
|
|||||||
|
APP_NAME=AI发糕
|
||||||
|
# 注意:AI 模型、公众号 AppID/Secret 为用户级配置,请在页面「账号与模型」中填写。
|
||||||
# —— 通义千问(推荐):阿里云 DashScope 的 OpenAI 兼容地址 + 模型名 + API Key
|
# —— 通义千问(推荐):阿里云 DashScope 的 OpenAI 兼容地址 + 模型名 + API Key
|
||||||
# OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
# OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
|
||||||
# OPENAI_API_KEY=sk-你的DashScopeKey
|
# OPENAI_API_KEY=sk-你的DashScopeKey
|
||||||
# OPENAI_MODEL=qwen3.5-plus
|
# OPENAI_MODEL=qwen3.5-plus
|
||||||
OPENAI_API_KEY=
|
# OPENAI_API_KEY=
|
||||||
OPENAI_BASE_URL=
|
# OPENAI_BASE_URL=
|
||||||
OPENAI_MODEL=gpt-4.1-mini
|
# OPENAI_MODEL=gpt-4.1-mini
|
||||||
# 通义长文 JSON 常需 60~120s+,过短会 APITimeout 后走兜底
|
# 通义长文 JSON 常需 60~120s+,过短会 APITimeout 后走兜底
|
||||||
OPENAI_TIMEOUT=120
|
# OPENAI_TIMEOUT=120
|
||||||
# SDK 自动重试次数。设为 0 可避免单次请求被隐式重试拖长(例如 30s 变 60s+)
|
# SDK 自动重试次数。设为 0 可避免单次请求被隐式重试拖长(例如 30s 变 60s+)
|
||||||
OPENAI_MAX_RETRIES=0
|
# OPENAI_MAX_RETRIES=0
|
||||||
# 长文 JSON 建议 8192;通义等若正文仍偏短可适当再加大
|
# 长文 JSON 建议 8192;通义等若正文仍偏短可适当再加大
|
||||||
OPENAI_MAX_OUTPUT_TOKENS=8192
|
# OPENAI_MAX_OUTPUT_TOKENS=8192
|
||||||
OPENAI_SOURCE_MAX_CHARS=5000
|
# OPENAI_SOURCE_MAX_CHARS=5000
|
||||||
# 质检未通过时仍返回模型洗稿正文(quality_notes 记录问题);设为 false 则严格退回保底稿
|
# 质检未通过时仍返回模型洗稿正文(quality_notes 记录问题);设为 false 则严格退回保底稿
|
||||||
AI_SOFT_ACCEPT=true
|
# AI_SOFT_ACCEPT=true
|
||||||
LOG_LEVEL=INFO
|
LOG_LEVEL=INFO
|
||||||
|
|
||||||
# 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。
|
# 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。
|
||||||
# 若 errcode=40164 invalid ip:把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。
|
# 若 errcode=40164 invalid ip:把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。
|
||||||
WECHAT_APPID=
|
# WECHAT_APPID=
|
||||||
WECHAT_SECRET=
|
# WECHAT_SECRET=
|
||||||
WECHAT_AUTHOR=AI 编辑部
|
# WECHAT_AUTHOR=AI 编辑部
|
||||||
# 封面(图文草稿必填,否则 errcode=40007):任选其一
|
# 封面(图文草稿必填,否则 errcode=40007):任选其一
|
||||||
# ① 填永久素材 ID:WECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id)
|
# ① 填永久素材 ID:WECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id)
|
||||||
# ② 填容器内图片路径,由服务自动上传:WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg
|
# ② 填容器内图片路径,由服务自动上传:WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg
|
||||||
|
|||||||
3
.vscode/settings.json
vendored
Normal file
3
.vscode/settings.json
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"postman.settings.dotenv-detection-notification-visibility": false
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict
|
|||||||
class Settings(BaseSettings):
|
class Settings(BaseSettings):
|
||||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||||
|
|
||||||
app_name: str = "X2WeChat Studio"
|
app_name: str = "AI发糕"
|
||||||
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
|
openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY")
|
||||||
openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
|
openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL")
|
||||||
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
|
openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL")
|
||||||
@@ -24,6 +24,11 @@ class Settings(BaseSettings):
|
|||||||
alias="OPENAI_MAX_OUTPUT_TOKENS",
|
alias="OPENAI_MAX_OUTPUT_TOKENS",
|
||||||
description="单次模型输出 token 上限;通义等长文 JSON 需足够大",
|
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")
|
openai_source_max_chars: int = Field(default=5000, alias="OPENAI_SOURCE_MAX_CHARS")
|
||||||
ai_soft_accept: bool = Field(
|
ai_soft_accept: bool = Field(
|
||||||
default=True,
|
default=True,
|
||||||
@@ -44,6 +49,21 @@ class Settings(BaseSettings):
|
|||||||
alias="WECHAT_THUMB_IMAGE_PATH",
|
alias="WECHAT_THUMB_IMAGE_PATH",
|
||||||
description="本地封面图路径(容器内),将自动上传为永久素材;不配则使用内置灰底图上传",
|
description="本地封面图路径(容器内),将自动上传为永久素材;不配则使用内置灰底图上传",
|
||||||
)
|
)
|
||||||
|
poster_image_size: str = Field(
|
||||||
|
default="1024x1536",
|
||||||
|
alias="POSTER_IMAGE_SIZE",
|
||||||
|
description="AI 海报生成尺寸(OpenAI images.generate size)",
|
||||||
|
)
|
||||||
|
poster_max_images: int = Field(
|
||||||
|
default=6,
|
||||||
|
alias="POSTER_MAX_IMAGES",
|
||||||
|
description="单次自动生成海报上限(首段跳过后生效)",
|
||||||
|
)
|
||||||
|
poster_upload_max_bytes: int = Field(
|
||||||
|
default=950000,
|
||||||
|
alias="POSTER_UPLOAD_MAX_BYTES",
|
||||||
|
description="上传微信 uploadimg 前压缩目标字节数,预留余量避免超 1MB 限制",
|
||||||
|
)
|
||||||
|
|
||||||
im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
|
im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL")
|
||||||
im_secret: str | None = Field(default=None, alias="IM_SECRET")
|
im_secret: str | None = Field(default=None, alias="IM_SECRET")
|
||||||
|
|||||||
335
app/main.py
335
app/main.py
@@ -1,10 +1,11 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import hmac
|
|
||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
from urllib.parse import urlparse
|
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.responses import HTMLResponse, RedirectResponse
|
||||||
from fastapi.staticfiles import StaticFiles
|
from fastapi.staticfiles import StaticFiles
|
||||||
from fastapi.templating import Jinja2Templates
|
from fastapi.templating import Jinja2Templates
|
||||||
@@ -13,17 +14,26 @@ from app.config import settings
|
|||||||
from app.logging_setup import configure_logging
|
from app.logging_setup import configure_logging
|
||||||
from app.middleware import RequestContextMiddleware
|
from app.middleware import RequestContextMiddleware
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
|
AIModelCreateRequest,
|
||||||
|
AIModelDeleteRequest,
|
||||||
|
AIModelSwitchRequest,
|
||||||
AuthCredentialRequest,
|
AuthCredentialRequest,
|
||||||
ChangePasswordRequest,
|
ChangePasswordRequest,
|
||||||
|
DeleteAccountRequest,
|
||||||
ForgotPasswordResetRequest,
|
ForgotPasswordResetRequest,
|
||||||
IMPublishRequest,
|
IMPublishRequest,
|
||||||
|
PosterGenerateRequest,
|
||||||
RewriteRequest,
|
RewriteRequest,
|
||||||
|
WechatCoverUploadByUrlRequest,
|
||||||
|
WechatCoverGenerateRequest,
|
||||||
|
WechatDeleteRequest,
|
||||||
WechatBindingRequest,
|
WechatBindingRequest,
|
||||||
WechatPublishRequest,
|
WechatPublishRequest,
|
||||||
WechatSwitchRequest,
|
WechatSwitchRequest,
|
||||||
)
|
)
|
||||||
from app.services.ai_rewriter import AIRewriter
|
from app.services.ai_rewriter import AIRewriter
|
||||||
from app.services.im import IMPublisher
|
from app.services.im import IMPublisher
|
||||||
|
from app.services.poster_material import PosterMaterialService
|
||||||
from app.services.user_store import UserStore
|
from app.services.user_store import UserStore
|
||||||
from app.services.wechat import WechatPublisher
|
from app.services.wechat import WechatPublisher
|
||||||
|
|
||||||
@@ -36,9 +46,9 @@ app = FastAPI(title=settings.app_name)
|
|||||||
@app.on_event("startup")
|
@app.on_event("startup")
|
||||||
async def _log_startup() -> None:
|
async def _log_startup() -> None:
|
||||||
logger.info(
|
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,
|
settings.app_name,
|
||||||
bool(settings.openai_api_key),
|
True,
|
||||||
settings.ai_soft_accept,
|
settings.ai_soft_accept,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -49,6 +59,7 @@ templates = Jinja2Templates(directory="app/templates")
|
|||||||
|
|
||||||
rewriter = AIRewriter()
|
rewriter = AIRewriter()
|
||||||
wechat = WechatPublisher()
|
wechat = WechatPublisher()
|
||||||
|
poster_material = PosterMaterialService(wechat)
|
||||||
im = IMPublisher()
|
im = IMPublisher()
|
||||||
users = UserStore(settings.auth_db_path)
|
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})
|
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)
|
@app.get("/favicon.ico", include_in_schema=False)
|
||||||
async def favicon():
|
async def favicon():
|
||||||
# 浏览器通常请求 /favicon.ico,统一跳转到静态图标
|
# 浏览器通常请求 /favicon.ico,统一跳转到静态图标
|
||||||
return RedirectResponse(url="/static/favicon.svg?v=20260406a")
|
return RedirectResponse(url="/static/favicon.svg?v=20260428h")
|
||||||
|
|
||||||
|
|
||||||
@app.get("/api/config")
|
@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"
|
provider = "dashscope" if "dashscope.aliyuncs.com" in base else "openai_compatible"
|
||||||
host = urlparse(base).netloc if base else ""
|
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 {
|
return {
|
||||||
"openai_configured": bool(settings.openai_api_key),
|
"openai_configured": key_configured,
|
||||||
"openai_model": settings.openai_model,
|
"openai_model": model_name,
|
||||||
"provider": provider,
|
"provider": provider,
|
||||||
"base_url_host": host or None,
|
"base_url_host": host or None,
|
||||||
"openai_timeout_sec": settings.openai_timeout,
|
"openai_timeout_sec": timeout_sec,
|
||||||
"openai_max_output_tokens": settings.openai_max_output_tokens,
|
"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")),
|
"wechat_bound": bool(binding and binding.get("appid") and binding.get("secret")),
|
||||||
"active_wechat_account": binding,
|
"active_wechat_account": binding,
|
||||||
"wechat_accounts": bindings,
|
"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,
|
max_age=ttl,
|
||||||
path="/",
|
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")
|
@app.post("/api/auth/login")
|
||||||
@@ -201,23 +232,17 @@ async def forgot_password_page(request: Request):
|
|||||||
|
|
||||||
@app.post("/api/auth/password/forgot")
|
@app.post("/api/auth/password/forgot")
|
||||||
async def auth_forgot_password_reset(req: ForgotPasswordResetRequest):
|
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()
|
username = (req.username or "").strip()
|
||||||
new_password = req.new_password or ""
|
new_password = req.new_password or ""
|
||||||
|
|
||||||
if not expected_key:
|
|
||||||
return {"ok": False, "detail": "系统未启用忘记密码重置功能,请联系管理员"}
|
|
||||||
if len(username) < 2:
|
if len(username) < 2:
|
||||||
return {"ok": False, "detail": "请输入正确的用户名"}
|
return {"ok": False, "detail": "请输入正确的用户名"}
|
||||||
if len(new_password) < 6:
|
if len(new_password) < 6:
|
||||||
return {"ok": False, "detail": "新密码至少 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:
|
if not ok:
|
||||||
return {"ok": False, "detail": "用户不存在,无法重置"}
|
return {"ok": False, "detail": "用户名或重置码错误,无法重置"}
|
||||||
return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"}
|
return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"}
|
||||||
|
|
||||||
|
|
||||||
@@ -254,6 +279,25 @@ async def auth_change_password(req: ChangePasswordRequest, request: Request, res
|
|||||||
return {"ok": True, "detail": "密码修改成功,已刷新登录状态"}
|
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")
|
@app.post("/api/auth/wechat/bind")
|
||||||
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
|
async def auth_wechat_bind(req: WechatBindingRequest, request: Request):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
@@ -286,6 +330,63 @@ async def auth_wechat_switch(req: WechatSwitchRequest, request: Request):
|
|||||||
return {"ok": True, "detail": "已切换当前公众号账号"}
|
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")
|
@app.post("/api/rewrite")
|
||||||
async def rewrite(req: RewriteRequest, request: Request):
|
async def rewrite(req: RewriteRequest, request: Request):
|
||||||
rid = getattr(request.state, "request_id", "")
|
rid = getattr(request.state, "request_id", "")
|
||||||
@@ -302,7 +403,35 @@ async def rewrite(req: RewriteRequest, request: Request):
|
|||||||
len(req.avoid_words or ""),
|
len(req.avoid_words or ""),
|
||||||
int(req.target_body_chars or 500),
|
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 {}
|
tr = result.trace or {}
|
||||||
logger.info(
|
logger.info(
|
||||||
"api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s",
|
"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
|
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")
|
@app.post("/api/wechat/material/upload")
|
||||||
async def upload_wechat_material(request: Request, file: UploadFile = File(...)):
|
async def upload_wechat_material(request: Request, file: UploadFile = File(...)):
|
||||||
user = _require_user(request)
|
user = _require_user(request)
|
||||||
@@ -389,6 +624,64 @@ async def upload_wechat_material(request: Request, file: UploadFile = File(...))
|
|||||||
return out
|
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")
|
@app.post("/api/publish/im")
|
||||||
async def publish_im(req: IMPublishRequest, request: Request):
|
async def publish_im(req: IMPublishRequest, request: Request):
|
||||||
rid = getattr(request.state, "request_id", "")
|
rid = getattr(request.state, "request_id", "")
|
||||||
|
|||||||
@@ -67,6 +67,11 @@ class ForgotPasswordResetRequest(BaseModel):
|
|||||||
new_password: str
|
new_password: str
|
||||||
|
|
||||||
|
|
||||||
|
class DeleteAccountRequest(BaseModel):
|
||||||
|
password: str
|
||||||
|
reset_key: str
|
||||||
|
|
||||||
|
|
||||||
class WechatBindingRequest(BaseModel):
|
class WechatBindingRequest(BaseModel):
|
||||||
account_name: str = ""
|
account_name: str = ""
|
||||||
appid: str
|
appid: str
|
||||||
@@ -78,3 +83,75 @@ class WechatBindingRequest(BaseModel):
|
|||||||
|
|
||||||
class WechatSwitchRequest(BaseModel):
|
class WechatSwitchRequest(BaseModel):
|
||||||
account_id: int
|
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)
|
||||||
|
|||||||
473
app/services/poster_material.py
Normal file
473
app/services/poster_material.py
Normal file
@@ -0,0 +1,473 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import base64
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import textwrap
|
||||||
|
from io import BytesIO
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
from openai import OpenAI
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
|
||||||
|
from app.config import settings
|
||||||
|
from app.schemas import (
|
||||||
|
CoverGenerateResponse,
|
||||||
|
PosterGenerateRequest,
|
||||||
|
PosterGenerateResponse,
|
||||||
|
PosterPreviewItem,
|
||||||
|
WechatCoverGenerateRequest,
|
||||||
|
)
|
||||||
|
from app.services.wechat import WechatPublisher
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_FONT_CANDIDATES = [
|
||||||
|
"/System/Library/Fonts/PingFang.ttc",
|
||||||
|
"/System/Library/Fonts/Hiragino Sans GB.ttc",
|
||||||
|
"/Library/Fonts/Arial Unicode.ttf",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _split_paragraphs(body_markdown: str) -> list[str]:
|
||||||
|
raw = (body_markdown or "").replace("\r\n", "\n").strip()
|
||||||
|
if not raw:
|
||||||
|
return []
|
||||||
|
return [p.strip() for p in re.split(r"\n\s*\n+", raw) if p.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
def _pick_font(size: int) -> ImageFont.ImageFont:
|
||||||
|
for p in _FONT_CANDIDATES:
|
||||||
|
if Path(p).is_file():
|
||||||
|
try:
|
||||||
|
return ImageFont.truetype(p, size=size)
|
||||||
|
except Exception:
|
||||||
|
continue
|
||||||
|
return ImageFont.load_default()
|
||||||
|
|
||||||
|
|
||||||
|
def _to_jpeg_under_limit(content: bytes, max_bytes: int) -> bytes:
|
||||||
|
im = Image.open(BytesIO(content)).convert("RGB")
|
||||||
|
widths = [1080, 1024, 960, 900, 840, 780, 720, 660]
|
||||||
|
qualities = [88, 82, 76, 70, 64, 58, 52]
|
||||||
|
|
||||||
|
for w in widths:
|
||||||
|
if im.width > w:
|
||||||
|
h = max(1, int(im.height * (w / im.width)))
|
||||||
|
cur = im.resize((w, h), Image.Resampling.LANCZOS)
|
||||||
|
else:
|
||||||
|
cur = im
|
||||||
|
for q in qualities:
|
||||||
|
buf = BytesIO()
|
||||||
|
cur.save(buf, format="JPEG", quality=q, optimize=True)
|
||||||
|
out = buf.getvalue()
|
||||||
|
if len(out) <= max_bytes:
|
||||||
|
return out
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
h = max(1, int(im.height * (640 / im.width)))
|
||||||
|
im.resize((640, h), Image.Resampling.LANCZOS).save(buf, format="JPEG", quality=48, optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _cover_to_jpeg(
|
||||||
|
content: bytes,
|
||||||
|
max_bytes: int,
|
||||||
|
size: tuple[int, int] = (900, 383),
|
||||||
|
title: str = "",
|
||||||
|
summary: str = "",
|
||||||
|
overlay_title: bool = False,
|
||||||
|
) -> bytes:
|
||||||
|
im = Image.open(BytesIO(content)).convert("RGB")
|
||||||
|
target_w, target_h = size
|
||||||
|
src_ratio = im.width / max(1, im.height)
|
||||||
|
dst_ratio = target_w / target_h
|
||||||
|
if src_ratio > dst_ratio:
|
||||||
|
new_w = int(im.height * dst_ratio)
|
||||||
|
x0 = max(0, (im.width - new_w) // 2)
|
||||||
|
im = im.crop((x0, 0, x0 + new_w, im.height))
|
||||||
|
elif src_ratio < dst_ratio:
|
||||||
|
new_h = int(im.width / dst_ratio)
|
||||||
|
y0 = max(0, (im.height - new_h) // 2)
|
||||||
|
im = im.crop((0, y0, im.width, y0 + new_h))
|
||||||
|
im = im.resize(size, Image.Resampling.LANCZOS)
|
||||||
|
if overlay_title:
|
||||||
|
im = _draw_cover_text_overlay(im, title, summary)
|
||||||
|
|
||||||
|
for q in [92, 88, 84, 80, 76, 72, 68, 62]:
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="JPEG", quality=q, optimize=True, progressive=True)
|
||||||
|
out = buf.getvalue()
|
||||||
|
if len(out) <= max_bytes:
|
||||||
|
return out
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="JPEG", quality=58, optimize=True)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def _draw_cover_text_overlay(im: Image.Image, title: str, summary: str) -> Image.Image:
|
||||||
|
im = im.convert("RGBA")
|
||||||
|
overlay = Image.new("RGBA", im.size, (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(overlay)
|
||||||
|
draw.rounded_rectangle([42, 46, 572, 337], radius=30, fill=(255, 255, 255, 228))
|
||||||
|
draw.rounded_rectangle([70, 74, 222, 119], radius=18, fill=(31, 111, 91, 245))
|
||||||
|
|
||||||
|
tag_font = _pick_font(26)
|
||||||
|
title_font = _pick_font(46)
|
||||||
|
summary_font = _pick_font(24)
|
||||||
|
draw.text((94, 83), "公众号封面", font=tag_font, fill=(255, 255, 255, 255))
|
||||||
|
|
||||||
|
clean_title = re.sub(r"\s+", "", title or "公众号文章")
|
||||||
|
title_lines = textwrap.wrap(clean_title, width=12)[:2]
|
||||||
|
y = 146
|
||||||
|
for line in title_lines:
|
||||||
|
draw.text((72, y), line, font=title_font, fill=(23, 32, 51, 255))
|
||||||
|
y += 56
|
||||||
|
|
||||||
|
clean_summary = re.sub(r"\s+", "", summary or "一眼看懂主题,明确文章价值。")
|
||||||
|
summary_lines = textwrap.wrap(clean_summary, width=23)[:2]
|
||||||
|
sy = max(y + 10, 270)
|
||||||
|
for line in summary_lines:
|
||||||
|
draw.text((74, sy), line, font=summary_font, fill=(104, 115, 133, 255))
|
||||||
|
sy += 30
|
||||||
|
|
||||||
|
return Image.alpha_composite(im, overlay).convert("RGB")
|
||||||
|
|
||||||
|
|
||||||
|
class PosterMaterialService:
|
||||||
|
def __init__(self, wechat: WechatPublisher) -> None:
|
||||||
|
self._wechat = wechat
|
||||||
|
self._image_client = None
|
||||||
|
if settings.openai_api_key:
|
||||||
|
self._image_client = OpenAI(
|
||||||
|
api_key=settings.openai_api_key,
|
||||||
|
base_url=settings.openai_base_url,
|
||||||
|
timeout=settings.openai_timeout,
|
||||||
|
max_retries=max(0, int(settings.openai_max_retries)),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate(
|
||||||
|
self,
|
||||||
|
req: PosterGenerateRequest,
|
||||||
|
request_id: str = "",
|
||||||
|
account: dict | None = None,
|
||||||
|
) -> PosterGenerateResponse:
|
||||||
|
rid = request_id or "-"
|
||||||
|
paragraphs = _split_paragraphs(req.body_markdown)
|
||||||
|
if len(paragraphs) <= 1:
|
||||||
|
return PosterGenerateResponse(
|
||||||
|
ok=True,
|
||||||
|
detail="正文不足两段:按规则首段不生成图片,因此无需海报。",
|
||||||
|
posters=[],
|
||||||
|
body_markdown_with_posters=req.body_markdown,
|
||||||
|
warnings=[],
|
||||||
|
)
|
||||||
|
|
||||||
|
max_images = max(1, min(int(req.max_images or settings.poster_max_images), 12))
|
||||||
|
posters: list[PosterPreviewItem] = []
|
||||||
|
warnings: list[str] = []
|
||||||
|
wechat_urls_by_para: dict[int, str] = {}
|
||||||
|
|
||||||
|
for idx, paragraph in enumerate(paragraphs):
|
||||||
|
if idx == 0:
|
||||||
|
continue
|
||||||
|
if len(posters) >= max_images:
|
||||||
|
break
|
||||||
|
|
||||||
|
prompt = self._build_prompt(req, paragraph, idx, len(paragraphs))
|
||||||
|
jpeg_bytes, note = await asyncio.to_thread(self._create_poster_jpeg, prompt, paragraph, idx)
|
||||||
|
preview_data_url = "data:image/jpeg;base64," + base64.b64encode(jpeg_bytes).decode("ascii")
|
||||||
|
|
||||||
|
wechat_url = ""
|
||||||
|
uploaded = False
|
||||||
|
if req.upload_to_wechat:
|
||||||
|
if not account:
|
||||||
|
warnings.append("未绑定公众号:已生成海报预览,但未上传微信素材 URL。")
|
||||||
|
else:
|
||||||
|
filename = f"poster_p{idx + 1}.jpg"
|
||||||
|
out = await self._wechat.upload_article_image(
|
||||||
|
filename,
|
||||||
|
jpeg_bytes,
|
||||||
|
request_id=rid,
|
||||||
|
account=account,
|
||||||
|
)
|
||||||
|
if out.ok:
|
||||||
|
wechat_url = ((out.data or {}).get("url") or "").strip()
|
||||||
|
uploaded = bool(wechat_url)
|
||||||
|
if uploaded:
|
||||||
|
wechat_urls_by_para[idx] = wechat_url
|
||||||
|
else:
|
||||||
|
warnings.append(f"第 {idx + 1} 段海报上传失败:{out.detail}")
|
||||||
|
|
||||||
|
posters.append(
|
||||||
|
PosterPreviewItem(
|
||||||
|
paragraph_index=idx,
|
||||||
|
paragraph_excerpt=textwrap.shorten(paragraph.replace("\n", " "), width=80, placeholder="…"),
|
||||||
|
prompt=prompt,
|
||||||
|
preview_data_url=preview_data_url,
|
||||||
|
wechat_url=wechat_url,
|
||||||
|
uploaded=uploaded,
|
||||||
|
note=note,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
merged = req.body_markdown
|
||||||
|
if wechat_urls_by_para:
|
||||||
|
merged = self._merge_body_with_posters(paragraphs, wechat_urls_by_para)
|
||||||
|
detail = f"已生成 {len(posters)} 张段落海报(首段跳过)"
|
||||||
|
if req.upload_to_wechat:
|
||||||
|
detail += f",成功上传 {sum(1 for p in posters if p.uploaded)} 张"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"poster_generate rid=%s posters=%d upload_to_wechat=%s uploaded=%d warnings=%d",
|
||||||
|
rid,
|
||||||
|
len(posters),
|
||||||
|
req.upload_to_wechat,
|
||||||
|
sum(1 for p in posters if p.uploaded),
|
||||||
|
len(warnings),
|
||||||
|
)
|
||||||
|
return PosterGenerateResponse(
|
||||||
|
ok=True,
|
||||||
|
detail=detail,
|
||||||
|
posters=posters,
|
||||||
|
body_markdown_with_posters=merged,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def generate_cover(
|
||||||
|
self,
|
||||||
|
req: WechatCoverGenerateRequest,
|
||||||
|
request_id: str = "",
|
||||||
|
account: dict | None = None,
|
||||||
|
) -> CoverGenerateResponse:
|
||||||
|
rid = request_id or "-"
|
||||||
|
title = (req.title or "").strip()
|
||||||
|
summary = (req.summary or "").strip()
|
||||||
|
if not title:
|
||||||
|
return CoverGenerateResponse(ok=False, detail="请先填写标题,或先完成改写生成标题")
|
||||||
|
|
||||||
|
prompt = self._build_cover_prompt(req)
|
||||||
|
jpeg_bytes, note = await asyncio.to_thread(self._create_cover_jpeg, prompt, title, summary)
|
||||||
|
preview_data_url = "data:image/jpeg;base64," + base64.b64encode(jpeg_bytes).decode("ascii")
|
||||||
|
|
||||||
|
warnings: list[str] = []
|
||||||
|
thumb_media_id = ""
|
||||||
|
if req.upload_to_wechat:
|
||||||
|
if not account:
|
||||||
|
warnings.append("未绑定公众号:已生成封面预览,但未上传为微信封面素材。")
|
||||||
|
else:
|
||||||
|
out = await self._wechat.upload_cover("wechat_cover_900x383.jpg", jpeg_bytes, request_id=rid, account=account)
|
||||||
|
if out.ok:
|
||||||
|
thumb_media_id = ((out.data or {}).get("thumb_media_id") or "").strip()
|
||||||
|
else:
|
||||||
|
warnings.append(f"封面上传失败:{out.detail}")
|
||||||
|
|
||||||
|
detail = "已生成公众号封面(900×383)"
|
||||||
|
if thumb_media_id:
|
||||||
|
detail += ",并已绑定 thumb_media_id"
|
||||||
|
elif warnings:
|
||||||
|
detail += ",但未完成微信绑定"
|
||||||
|
|
||||||
|
logger.info(
|
||||||
|
"cover_generate rid=%s title_chars=%d upload_to_wechat=%s uploaded=%s note=%s warnings=%d",
|
||||||
|
rid,
|
||||||
|
len(title),
|
||||||
|
req.upload_to_wechat,
|
||||||
|
bool(thumb_media_id),
|
||||||
|
note,
|
||||||
|
len(warnings),
|
||||||
|
)
|
||||||
|
return CoverGenerateResponse(
|
||||||
|
ok=True,
|
||||||
|
detail=detail,
|
||||||
|
preview_data_url=preview_data_url,
|
||||||
|
thumb_media_id=thumb_media_id,
|
||||||
|
width=900,
|
||||||
|
height=383,
|
||||||
|
note=note,
|
||||||
|
warnings=warnings,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_cover_prompt(self, req: WechatCoverGenerateRequest) -> str:
|
||||||
|
title = (req.title or "公众号文章").strip()
|
||||||
|
summary = (req.summary or "").strip()
|
||||||
|
style_hint = (req.style_hint or "").strip() or "成熟公众号封面,清晰、克制、信息强,适合作为文章列表首图"
|
||||||
|
return (
|
||||||
|
"生成一张微信公众号文章封面图,最终会裁切为 900x383 横版比例。"
|
||||||
|
f"封面主标题:{title}。"
|
||||||
|
f"文章摘要:{summary}。"
|
||||||
|
f"风格要求:{style_hint}。"
|
||||||
|
"画面要突出封面的点击引导作用:主题明确、视觉焦点强、留出标题安全区、中文字少且清晰。"
|
||||||
|
"不要出现二维码、水印、品牌 logo、真人肖像、杂乱小字和侵权素材。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _build_prompt(self, req: PosterGenerateRequest, paragraph: str, idx: int, total: int) -> str:
|
||||||
|
title = (req.title or "公众号内容").strip()
|
||||||
|
summary = (req.summary or "").strip()
|
||||||
|
style_hint = (req.style_hint or "").strip() or "现代、干净、中文可读、公众号海报风格"
|
||||||
|
para = paragraph.strip()
|
||||||
|
return (
|
||||||
|
"请生成一张中文竖版海报,适合公众号正文插图。"
|
||||||
|
f"主题标题:{title}。"
|
||||||
|
f"这是第 {idx + 1}/{total} 段对应海报(首段不配图)。"
|
||||||
|
f"段落核心内容:{para}。"
|
||||||
|
f"摘要参考:{summary}。"
|
||||||
|
f"风格要求:{style_hint}。"
|
||||||
|
"画面需信息聚焦、可读性强,不要出现水印、二维码、logo、真人肖像。"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _create_poster_jpeg(self, prompt: str, paragraph: str, idx: int) -> tuple[bytes, str]:
|
||||||
|
max_bytes = max(300_000, int(settings.poster_upload_max_bytes or 950_000))
|
||||||
|
|
||||||
|
if self._image_client:
|
||||||
|
try:
|
||||||
|
raw = self._generate_with_model(prompt)
|
||||||
|
if raw:
|
||||||
|
return _to_jpeg_under_limit(raw, max_bytes), "ai"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("poster_ai_failed detail=%s", str(exc)[:240])
|
||||||
|
|
||||||
|
fallback = self._generate_fallback_poster(paragraph, idx)
|
||||||
|
return _to_jpeg_under_limit(fallback, max_bytes), "fallback"
|
||||||
|
|
||||||
|
def _create_cover_jpeg(self, prompt: str, title: str, summary: str) -> tuple[bytes, str]:
|
||||||
|
max_bytes = max(300_000, int(settings.poster_upload_max_bytes or 950_000))
|
||||||
|
|
||||||
|
if self._image_client:
|
||||||
|
try:
|
||||||
|
raw = self._generate_with_model(prompt)
|
||||||
|
if raw:
|
||||||
|
return _cover_to_jpeg(raw, max_bytes, title=title, summary=summary, overlay_title=True), "ai_900x383"
|
||||||
|
except Exception as exc:
|
||||||
|
logger.warning("cover_ai_failed detail=%s", str(exc)[:240])
|
||||||
|
|
||||||
|
fallback = self._generate_fallback_cover(title, summary)
|
||||||
|
return _cover_to_jpeg(fallback, max_bytes), "fallback_900x383"
|
||||||
|
|
||||||
|
def _generate_with_model(self, prompt: str) -> bytes | None:
|
||||||
|
rsp = self._image_client.images.generate(
|
||||||
|
model=settings.openai_image_model,
|
||||||
|
prompt=prompt,
|
||||||
|
size=settings.poster_image_size,
|
||||||
|
)
|
||||||
|
data = getattr(rsp, "data", None) or []
|
||||||
|
if not data:
|
||||||
|
return None
|
||||||
|
|
||||||
|
first = data[0]
|
||||||
|
b64 = ""
|
||||||
|
image_url = ""
|
||||||
|
if isinstance(first, dict):
|
||||||
|
b64 = (first.get("b64_json") or "").strip()
|
||||||
|
image_url = (first.get("url") or "").strip()
|
||||||
|
else:
|
||||||
|
b64 = (getattr(first, "b64_json", "") or "").strip()
|
||||||
|
image_url = (getattr(first, "url", "") or "").strip()
|
||||||
|
|
||||||
|
if b64:
|
||||||
|
return base64.b64decode(b64)
|
||||||
|
if image_url:
|
||||||
|
with httpx.Client(timeout=30) as client:
|
||||||
|
r = client.get(image_url)
|
||||||
|
r.raise_for_status()
|
||||||
|
return r.content
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _generate_fallback_poster(self, paragraph: str, idx: int) -> bytes:
|
||||||
|
w, h = 1080, 1520
|
||||||
|
im = Image.new("RGB", (w, h), (240, 246, 255))
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
c = int(240 - (y / h) * 36)
|
||||||
|
draw.line([(0, y), (w, y)], fill=(c, c + 6, 255), width=1)
|
||||||
|
|
||||||
|
for i in range(8):
|
||||||
|
x0 = int(w * 0.08) + i * 54
|
||||||
|
y0 = int(h * 0.66) + i * 22
|
||||||
|
x1 = x0 + 260
|
||||||
|
y1 = y0 + 100
|
||||||
|
color = (160 - i * 8, 190 - i * 9, 230 - i * 8)
|
||||||
|
draw.rounded_rectangle([x0, y0, x1, y1], radius=24, outline=color, width=2)
|
||||||
|
|
||||||
|
tag_font = _pick_font(36)
|
||||||
|
title_font = _pick_font(58)
|
||||||
|
body_font = _pick_font(42)
|
||||||
|
|
||||||
|
draw.rounded_rectangle([70, 70, 340, 142], radius=20, fill=(31, 77, 185))
|
||||||
|
draw.text((102, 90), f"段落 {idx + 1}", font=tag_font, fill=(255, 255, 255))
|
||||||
|
draw.text((70, 190), "AI 图文海报", font=title_font, fill=(16, 42, 102))
|
||||||
|
|
||||||
|
words = re.sub(r"\s+", "", paragraph)
|
||||||
|
if len(words) > 120:
|
||||||
|
words = words[:120] + "…"
|
||||||
|
wrapped = textwrap.fill(words, width=19)
|
||||||
|
draw.multiline_text(
|
||||||
|
(72, 330),
|
||||||
|
wrapped,
|
||||||
|
font=body_font,
|
||||||
|
fill=(35, 54, 92),
|
||||||
|
spacing=14,
|
||||||
|
align="left",
|
||||||
|
)
|
||||||
|
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def _generate_fallback_cover(self, title: str, summary: str) -> bytes:
|
||||||
|
w, h = 900, 383
|
||||||
|
im = Image.new("RGB", (w, h), (247, 249, 252))
|
||||||
|
draw = ImageDraw.Draw(im)
|
||||||
|
|
||||||
|
for y in range(h):
|
||||||
|
t = y / h
|
||||||
|
r = int(252 - t * 28)
|
||||||
|
g = int(250 - t * 18)
|
||||||
|
b = int(241 - t * 8)
|
||||||
|
draw.line([(0, y), (w, y)], fill=(r, g, b), width=1)
|
||||||
|
|
||||||
|
draw.rounded_rectangle([36, 34, 864, 349], radius=34, fill=(255, 255, 255), outline=(223, 229, 238), width=2)
|
||||||
|
draw.rounded_rectangle([604, 60, 830, 290], radius=34, fill=(238, 244, 241))
|
||||||
|
draw.ellipse([660, 95, 810, 245], fill=(229, 196, 122))
|
||||||
|
draw.ellipse([690, 126, 780, 216], fill=(255, 250, 229))
|
||||||
|
draw.arc([684, 118, 784, 226], start=20, end=168, fill=(199, 159, 81), width=5)
|
||||||
|
|
||||||
|
tag_font = _pick_font(28)
|
||||||
|
title_font = _pick_font(48)
|
||||||
|
summary_font = _pick_font(24)
|
||||||
|
small_font = _pick_font(20)
|
||||||
|
|
||||||
|
draw.rounded_rectangle([72, 70, 226, 118], radius=18, fill=(31, 111, 91))
|
||||||
|
draw.text((96, 80), "公众号封面", font=tag_font, fill=(255, 255, 255))
|
||||||
|
|
||||||
|
clean_title = re.sub(r"\s+", "", title or "公众号文章")
|
||||||
|
title_lines = textwrap.wrap(clean_title, width=12)[:2]
|
||||||
|
y = 146
|
||||||
|
for line in title_lines:
|
||||||
|
draw.text((72, y), line, font=title_font, fill=(23, 32, 51))
|
||||||
|
y += 58
|
||||||
|
|
||||||
|
clean_summary = re.sub(r"\s+", "", summary or "清晰表达主题,让读者一眼知道文章价值。")
|
||||||
|
summary_lines = textwrap.wrap(clean_summary, width=24)[:2]
|
||||||
|
sy = max(y + 12, 268)
|
||||||
|
for line in summary_lines:
|
||||||
|
draw.text((74, sy), line, font=summary_font, fill=(104, 115, 133))
|
||||||
|
sy += 32
|
||||||
|
|
||||||
|
draw.text((72, 320), "900 x 383", font=small_font, fill=(140, 150, 166))
|
||||||
|
buf = BytesIO()
|
||||||
|
im.save(buf, format="PNG")
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
def _merge_body_with_posters(self, paragraphs: list[str], wechat_urls_by_para: dict[int, str]) -> str:
|
||||||
|
merged: list[str] = []
|
||||||
|
for idx, para in enumerate(paragraphs):
|
||||||
|
if idx > 0:
|
||||||
|
url = (wechat_urls_by_para.get(idx) or "").strip()
|
||||||
|
if url:
|
||||||
|
merged.append(f"")
|
||||||
|
merged.append(para)
|
||||||
|
return "\n\n".join(merged)
|
||||||
@@ -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(
|
rows = c.execute(
|
||||||
"SELECT user_id, appid, secret, author, thumb_media_id, thumb_image_path, updated_at FROM wechat_bindings"
|
"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}
|
return {str(r["name"]) for r in rows}
|
||||||
|
|
||||||
def _ensure_users_table(self, c: sqlite3.Connection) -> None:
|
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(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
CREATE TABLE IF NOT EXISTS users (
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
@@ -119,7 +149,10 @@ class UserStore:
|
|||||||
username TEXT NOT NULL UNIQUE,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
password_salt 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,
|
username TEXT NOT NULL UNIQUE,
|
||||||
password_hash TEXT NOT NULL,
|
password_hash TEXT NOT NULL,
|
||||||
password_salt 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 {"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(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
|
INSERT OR IGNORE INTO users_new(
|
||||||
SELECT id, username, password_hash, password_salt, COALESCE(created_at, ?)
|
id, username, password_hash, password_salt, reset_code_hash, reset_code_salt, created_at, deleted_at
|
||||||
FROM users
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
""",
|
""",
|
||||||
(now,),
|
(
|
||||||
)
|
int(r["id"]),
|
||||||
else:
|
username,
|
||||||
c.execute(
|
r["password_hash"],
|
||||||
"""
|
r["password_salt"],
|
||||||
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
|
reset_hash,
|
||||||
SELECT id, username, password_hash, password_salt, ?
|
reset_salt,
|
||||||
FROM users
|
created_at,
|
||||||
""",
|
deleted_at,
|
||||||
(now,),
|
),
|
||||||
)
|
)
|
||||||
elif {"username", "password"}.issubset(cols):
|
elif {"username", "password"}.issubset(cols):
|
||||||
if "created_at" in cols:
|
if "created_at" in cols:
|
||||||
@@ -173,13 +221,18 @@ class UserStore:
|
|||||||
continue
|
continue
|
||||||
salt = secrets.token_hex(16)
|
salt = secrets.token_hex(16)
|
||||||
pwd_hash = self._hash_password(raw_pwd, salt)
|
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
|
created_at = int(r["created_at"]) if "created_at" in r.keys() and r["created_at"] else now
|
||||||
c.execute(
|
c.execute(
|
||||||
"""
|
"""
|
||||||
INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at)
|
INSERT OR IGNORE INTO users_new(
|
||||||
VALUES (?, ?, ?, ?, ?)
|
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")
|
c.execute("DROP TABLE users")
|
||||||
@@ -222,18 +275,31 @@ class UserStore:
|
|||||||
def _hash_token(self, token: str) -> str:
|
def _hash_token(self, token: str) -> str:
|
||||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
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:
|
def create_user(self, username: str, password: str) -> dict | None:
|
||||||
now = int(time.time())
|
now = int(time.time())
|
||||||
salt = secrets.token_hex(16)
|
salt = secrets.token_hex(16)
|
||||||
pwd_hash = self._hash_password(password, salt)
|
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:
|
try:
|
||||||
with self._conn() as c:
|
with self._conn() as c:
|
||||||
cur = c.execute(
|
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)
|
uid = int(cur.lastrowid)
|
||||||
return {"id": uid, "username": username}
|
return {"id": uid, "username": username, "reset_code": reset_code}
|
||||||
except sqlite3.IntegrityError:
|
except sqlite3.IntegrityError:
|
||||||
return None
|
return None
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
@@ -243,7 +309,11 @@ class UserStore:
|
|||||||
try:
|
try:
|
||||||
with self._conn() as c:
|
with self._conn() as c:
|
||||||
row = c.execute(
|
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,),
|
(username,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
except sqlite3.Error as exc:
|
except sqlite3.Error as exc:
|
||||||
@@ -274,14 +344,27 @@ class UserStore:
|
|||||||
)
|
)
|
||||||
return True
|
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()
|
uname = (username or "").strip()
|
||||||
|
rcode = (reset_code or "").strip()
|
||||||
if not uname:
|
if not uname:
|
||||||
return False
|
return False
|
||||||
with self._conn() as c:
|
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:
|
if not row:
|
||||||
return False
|
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_salt = secrets.token_hex(16)
|
||||||
new_hash = self._hash_password(new_password, new_salt)
|
new_hash = self._hash_password(new_password, new_salt)
|
||||||
c.execute(
|
c.execute(
|
||||||
@@ -324,7 +407,7 @@ class UserStore:
|
|||||||
SELECT u.id, u.username
|
SELECT u.id, u.username
|
||||||
FROM sessions s
|
FROM sessions s
|
||||||
JOIN users u ON u.id=s.user_id
|
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),
|
(th, now),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
@@ -332,6 +415,37 @@ class UserStore:
|
|||||||
return None
|
return None
|
||||||
return {"id": int(row["id"]), "username": row["username"]}
|
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(
|
def save_wechat_binding(
|
||||||
self,
|
self,
|
||||||
user_id: int,
|
user_id: int,
|
||||||
@@ -510,3 +624,230 @@ class UserStore:
|
|||||||
"thumb_image_path": row["thumb_image_path"] or "",
|
"thumb_image_path": row["thumb_image_path"] or "",
|
||||||
"updated_at": int(row["updated_at"] or 0),
|
"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
|
||||||
|
|||||||
@@ -69,11 +69,11 @@ class WechatPublisher:
|
|||||||
|
|
||||||
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
|
def _resolve_account(self, account: dict | None = None) -> dict[str, str]:
|
||||||
src = account or {}
|
src = account or {}
|
||||||
appid = (src.get("appid") or settings.wechat_appid or "").strip()
|
appid = (src.get("appid") or "").strip()
|
||||||
secret = (src.get("secret") or settings.wechat_secret or "").strip()
|
secret = (src.get("secret") or "").strip()
|
||||||
author = (src.get("author") or settings.wechat_author or "").strip()
|
author = (src.get("author") or "").strip()
|
||||||
thumb_media_id = (src.get("thumb_media_id") or settings.wechat_thumb_media_id or "").strip()
|
thumb_media_id = (src.get("thumb_media_id") or "").strip()
|
||||||
thumb_image_path = (src.get("thumb_image_path") or settings.wechat_thumb_image_path or "").strip()
|
thumb_image_path = (src.get("thumb_image_path") or "").strip()
|
||||||
return {
|
return {
|
||||||
"appid": appid,
|
"appid": appid,
|
||||||
"secret": secret,
|
"secret": secret,
|
||||||
@@ -132,7 +132,7 @@ class WechatPublisher:
|
|||||||
{
|
{
|
||||||
"article_type": "news",
|
"article_type": "news",
|
||||||
"title": req.title[:32] if len(req.title) > 32 else req.title,
|
"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],
|
"digest": (req.summary or "")[:128],
|
||||||
"content": html,
|
"content": html,
|
||||||
"content_source_url": "",
|
"content_source_url": "",
|
||||||
@@ -246,6 +246,37 @@ class WechatPublisher:
|
|||||||
)
|
)
|
||||||
return PublishResponse(ok=True, detail="素材上传成功", data=material)
|
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(
|
async def _upload_permanent_image(
|
||||||
self, client: httpx.AsyncClient, token: str, content: bytes, filename: str
|
self, client: httpx.AsyncClient, token: str, content: bytes, filename: str
|
||||||
) -> dict[str, str] | None:
|
) -> dict[str, str] | None:
|
||||||
@@ -263,6 +294,23 @@ class WechatPublisher:
|
|||||||
return None
|
return None
|
||||||
return {"media_id": mid, "url": data.get("url") or ""}
|
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(
|
async def _resolve_thumb_media_id(
|
||||||
self, token: str, rid: str, *, force_skip_explicit: bool = False, account: dict | None = None
|
self, token: str, rid: str, *, force_skip_explicit: bool = False, account: dict | None = None
|
||||||
) -> str | None:
|
) -> str | None:
|
||||||
|
|||||||
@@ -16,8 +16,51 @@ const rewriteBtn = $("rewriteBtn");
|
|||||||
const wechatBtn = $("wechatBtn");
|
const wechatBtn = $("wechatBtn");
|
||||||
const imBtn = $("imBtn");
|
const imBtn = $("imBtn");
|
||||||
const coverUploadBtn = $("coverUploadBtn");
|
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 logoutBtn = $("logoutBtn");
|
||||||
const targetBodyCharsInput = $("targetBodyChars");
|
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() {
|
function syncTargetCharChips() {
|
||||||
const val = Number((targetBodyCharsInput && targetBodyCharsInput.value) || 0);
|
const val = Number((targetBodyCharsInput && targetBodyCharsInput.value) || 0);
|
||||||
@@ -82,6 +125,68 @@ function setStatus(msg, danger = false) {
|
|||||||
statusEl.textContent = msg;
|
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) {
|
async function postJSON(url, body) {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
@@ -94,6 +199,78 @@ async function postJSON(url, body) {
|
|||||||
return data;
|
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() {
|
async function fetchAuthMe() {
|
||||||
const res = await fetch("/api/auth/me");
|
const res = await fetch("/api/auth/me");
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
@@ -117,10 +294,10 @@ function renderWechatAccountSelect(me) {
|
|||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = "";
|
opt.value = "";
|
||||||
opt.textContent = "暂无公众号";
|
opt.textContent = "未绑定公众号";
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
sel.disabled = true;
|
sel.disabled = true;
|
||||||
if (hint) hint.textContent = "请先在「公众号设置」绑定";
|
if (hint) hint.textContent = "请先绑定公众号";
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
sel.disabled = false;
|
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) => {
|
document.querySelectorAll(".target-char-chip").forEach((btn) => {
|
||||||
btn.addEventListener("click", () => {
|
btn.addEventListener("click", () => {
|
||||||
const n = Number(btn.getAttribute("data-target-chars") || 0);
|
const n = Number(btn.getAttribute("data-target-chars") || 0);
|
||||||
@@ -245,6 +429,22 @@ $("rewriteBtn").addEventListener("click", async () => {
|
|||||||
} else {
|
} else {
|
||||||
setStatus("改写完成。");
|
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) {
|
} catch (e) {
|
||||||
setStatus(`改写失败: ${e.message}`, true);
|
setStatus(`改写失败: ${e.message}`, true);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -256,10 +456,25 @@ $("wechatBtn").addEventListener("click", async () => {
|
|||||||
setStatus("正在发布到公众号草稿箱...");
|
setStatus("正在发布到公众号草稿箱...");
|
||||||
setLoading(wechatBtn, true, "发布到公众号草稿箱", "发布中...");
|
setLoading(wechatBtn, true, "发布到公众号草稿箱", "发布中...");
|
||||||
try {
|
try {
|
||||||
|
let bodyForPublish = $("body").value;
|
||||||
|
const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked);
|
||||||
|
if (autoInclude) {
|
||||||
|
const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters;
|
||||||
|
if (stale) {
|
||||||
|
try {
|
||||||
|
await generatePosterMaterials({ silent: true });
|
||||||
|
} catch (posterErr) {
|
||||||
|
setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (posterState.bodyMarkdownWithPosters) {
|
||||||
|
bodyForPublish = posterState.bodyMarkdownWithPosters;
|
||||||
|
}
|
||||||
|
}
|
||||||
const data = await postJSON("/api/publish/wechat", {
|
const data = await postJSON("/api/publish/wechat", {
|
||||||
title: $("title").value,
|
title: $("title").value,
|
||||||
summary: $("summary").value,
|
summary: $("summary").value,
|
||||||
body_markdown: $("body").value,
|
body_markdown: bodyForPublish,
|
||||||
thumb_media_id: $("thumbMediaId") ? $("thumbMediaId").value.trim() : "",
|
thumb_media_id: $("thumbMediaId") ? $("thumbMediaId").value.trim() : "",
|
||||||
});
|
});
|
||||||
if (!data.ok) throw new Error(data.detail);
|
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) {
|
if (coverUploadBtn) {
|
||||||
coverUploadBtn.addEventListener("click", async () => {
|
coverUploadBtn.addEventListener("click", async () => {
|
||||||
const fileInput = $("coverFile");
|
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 () => {
|
$("imBtn").addEventListener("click", async () => {
|
||||||
setStatus("正在发送到 IM...");
|
setStatus("正在发送到 IM...");
|
||||||
setLoading(imBtn, true, "发送到 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);
|
$(id).addEventListener("input", updateCounters);
|
||||||
|
if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded);
|
||||||
});
|
});
|
||||||
|
|
||||||
updateCounters();
|
updateCounters();
|
||||||
initMultiDropdowns();
|
initMultiDropdowns();
|
||||||
initWechatAccountSwitch();
|
initWechatAccountSwitch();
|
||||||
syncTargetCharChips();
|
syncTargetCharChips();
|
||||||
|
renderPosterPreview([]);
|
||||||
|
setCoverMode("manual");
|
||||||
|
window.addEventListener("load", () => setCoverMode("manual"));
|
||||||
|
|||||||
@@ -66,6 +66,33 @@ if (loginBtn) {
|
|||||||
|
|
||||||
if (registerBtn) {
|
if (registerBtn) {
|
||||||
registerBtn.addEventListener("click", async () => {
|
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, "注册", "注册中...");
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,32 @@
|
|||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AIcreat">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="AI发糕">
|
||||||
<rect width="64" height="64" rx="14" fill="#0891b2"/>
|
<defs>
|
||||||
<path d="M20 46l10-28h4l10 28h-5l-2-6H27l-2 6h-5zm9-10h14l-7-20-7 20z" fill="#fff"/>
|
<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>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 243 B After Width: | Height: | Size: 2.0 KiB |
@@ -43,7 +43,7 @@ function renderAccounts(me) {
|
|||||||
if (!list.length) {
|
if (!list.length) {
|
||||||
const opt = document.createElement("option");
|
const opt = document.createElement("option");
|
||||||
opt.value = "";
|
opt.value = "";
|
||||||
opt.textContent = "暂无公众号,请先绑定";
|
opt.textContent = "未绑定公众号";
|
||||||
sel.appendChild(opt);
|
sel.appendChild(opt);
|
||||||
return;
|
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() {
|
async function refresh() {
|
||||||
const me = await authMe();
|
const me = await authMe();
|
||||||
if (!me) return;
|
if (!me) return;
|
||||||
renderAccounts(me);
|
renderAccounts(me);
|
||||||
|
renderModels(me);
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountSelect = $("accountSelect");
|
const accountSelect = $("accountSelect");
|
||||||
const bindBtn = $("bindBtn");
|
const bindBtn = $("bindBtn");
|
||||||
|
const deleteWechatBtn = $("deleteWechatBtn");
|
||||||
const logoutBtn = $("logoutBtn");
|
const logoutBtn = $("logoutBtn");
|
||||||
const changePwdBtn = $("changePwdBtn");
|
const changePwdBtn = $("changePwdBtn");
|
||||||
|
const deleteAccountBtn = $("deleteAccountBtn");
|
||||||
|
const modelSelect = $("modelSelect");
|
||||||
|
const saveModelBtn = $("saveModelBtn");
|
||||||
|
const deleteModelBtn = $("deleteModelBtn");
|
||||||
|
|
||||||
if (accountSelect) {
|
if (accountSelect) {
|
||||||
accountSelect.addEventListener("change", async () => {
|
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) {
|
if (bindBtn) {
|
||||||
bindBtn.addEventListener("click", async () => {
|
bindBtn.addEventListener("click", async () => {
|
||||||
setLoading(bindBtn, true, "绑定并设为当前账号", "绑定中...");
|
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) {
|
if (logoutBtn) {
|
||||||
logoutBtn.addEventListener("click", async () => {
|
logoutBtn.addEventListener("click", async () => {
|
||||||
setLoading(logoutBtn, true, "退出登录", "退出中...");
|
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();
|
refresh();
|
||||||
|
|||||||
1874
app/static/style.css
1874
app/static/style.css
File diff suppressed because it is too large
Load Diff
@@ -4,38 +4,90 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ app_name }} - 登录注册</title>
|
<title>{{ app_name }} - 登录注册</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
|
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||||
</head>
|
</head>
|
||||||
<body class="simple-page">
|
<body class="simple-page auth-page">
|
||||||
<main class="simple-wrap">
|
<main class="auth-shell">
|
||||||
<section class="panel simple-panel">
|
<section class="auth-showcase" aria-label="产品介绍">
|
||||||
<h2>登录 / 注册</h2>
|
<div class="brand-lockup auth-brand-lockup">
|
||||||
<p class="muted small">登录后将跳转到编辑主页。</p>
|
<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>
|
<div>
|
||||||
<label>用户名</label>
|
<label>用户名</label>
|
||||||
<input id="username" type="text" placeholder="请输入用户名" />
|
<input id="username" type="text" placeholder="请输入用户名" autocomplete="username" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>密码</label>
|
<label>密码</label>
|
||||||
<input id="password" type="password" placeholder="请输入密码(至少 6 位)" />
|
<input id="password" type="password" placeholder="请输入密码(至少 6 位)" autocomplete="current-password" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="check-row">
|
||||||
<div class="check-row">
|
<label class="check-label">
|
||||||
<label class="check-label">
|
<input id="rememberMe" type="checkbox" checked />
|
||||||
<input id="rememberMe" type="checkbox" checked />
|
<span>7 天内免登录</span>
|
||||||
<span>7 天内免登录(限时)</span>
|
</label>
|
||||||
</label>
|
<a class="auth-link" href="/auth/forgot">忘记密码?</a>
|
||||||
<a class="subtle-link" href="/auth/forgot">忘记密码?</a>
|
</div>
|
||||||
|
|
||||||
|
<div class="actions">
|
||||||
|
<button id="loginBtn" class="primary" type="button">登录工作台</button>
|
||||||
|
<button id="registerBtn" class="secondary" type="button">注册账号</button>
|
||||||
|
</div>
|
||||||
|
<p id="status" class="status"></p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="actions">
|
<div class="auth-footnote">首次使用建议先完成公众号与模型配置。</div>
|
||||||
<button id="loginBtn" class="primary" type="button">登录</button>
|
|
||||||
<button id="registerBtn" class="secondary" type="button">注册</button>
|
|
||||||
</div>
|
|
||||||
<p id="status" class="status"></p>
|
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
|
|||||||
@@ -4,36 +4,44 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ app_name }} - 忘记密码</title>
|
<title>{{ app_name }} - 忘记密码</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
|
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||||
</head>
|
</head>
|
||||||
<body class="simple-page">
|
<body class="simple-page">
|
||||||
<main class="simple-wrap">
|
<main class="auth-card">
|
||||||
<section class="panel simple-panel">
|
<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="grid2">
|
<div class="simple-body">
|
||||||
<div>
|
<div class="grid2">
|
||||||
<label>用户名</label>
|
<div>
|
||||||
<input id="username" type="text" placeholder="请输入账号用户名" />
|
<label>用户名</label>
|
||||||
|
<input id="username" type="text" placeholder="请输入账号用户名" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>重置码</label>
|
||||||
|
<input id="resetKey" type="password" placeholder="请输入你保存的个人重置码" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>重置码</label>
|
<label>新密码</label>
|
||||||
<input id="resetKey" type="password" placeholder="请输入管理员提供的重置码" />
|
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
|
||||||
|
</div>
|
||||||
|
<p class="muted small">重置码仅在注册时展示一次,请妥善保存。</p>
|
||||||
|
<div class="actions">
|
||||||
|
<button id="resetBtn" class="primary" type="button">重置密码</button>
|
||||||
|
</div>
|
||||||
|
<p id="status" class="status"></p>
|
||||||
|
<div class="actions">
|
||||||
|
<a class="subtle-link" href="/auth?next=/">返回登录页</a>
|
||||||
|
<a class="subtle-link" href="/settings">去设置页</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>新密码</label>
|
|
||||||
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
|
|
||||||
</div>
|
|
||||||
<p class="muted small">请向管理员获取重置码。若未改配置,默认重置码为 x2ws-reset-2026(建议尽快修改)。</p>
|
|
||||||
<div class="actions">
|
|
||||||
<button id="resetBtn" class="primary" type="button">重置密码</button>
|
|
||||||
</div>
|
|
||||||
<p id="status" class="status"></p>
|
|
||||||
<div class="actions">
|
|
||||||
<a class="subtle-link" href="/auth?next=/">返回登录页</a>
|
|
||||||
<a class="subtle-link" href="/settings">去设置页</a>
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
134
app/templates/guide.html
Normal file
134
app/templates/guide.html
Normal 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>
|
||||||
@@ -4,42 +4,59 @@
|
|||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ app_name }}</title>
|
<title>{{ app_name }}</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260421a" />
|
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<header class="topbar">
|
<div class="product-shell">
|
||||||
<div class="brand">
|
<aside class="side-nav" aria-label="主导航">
|
||||||
<h1>{{ app_name }}</h1>
|
<div class="side-brand">
|
||||||
<p class="muted">从原文到公众号草稿,一页完成编辑、封面和发布。</p>
|
<div class="brand-lockup">
|
||||||
</div>
|
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
|
||||||
<div class="topbar-actions">
|
<h1>{{ app_name }}</h1>
|
||||||
<div class="wechat-account-switch" title="草稿发布、封面上传均使用此处选中的公众号">
|
</div>
|
||||||
<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>
|
</div>
|
||||||
<a class="subtle-link" href="/settings">公众号设置</a>
|
<nav class="nav-group">
|
||||||
<button id="logoutBtn" class="subtle-btn topbar-btn" type="button">退出登录</button>
|
<div class="nav-label">工作台</div>
|
||||||
</div>
|
<a class="nav-item is-active" href="/">内容生产</a>
|
||||||
</header>
|
<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="发布将使用当前账号">
|
||||||
|
<label for="wechatAccountSelect" class="wechat-account-label">发表主体</label>
|
||||||
|
<select id="wechatAccountSelect" class="topbar-select" aria-label="切换公众号"></select>
|
||||||
|
<span id="wechatAccountStatus" class="muted small wechat-account-status" aria-live="polite"></span>
|
||||||
|
</div>
|
||||||
|
<a class="icon-btn" href="/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">
|
<main class="layout">
|
||||||
<section class="panel input-panel">
|
<section class="panel input-panel">
|
||||||
<div class="panel-head">
|
<div class="panel-head">
|
||||||
<h2>内容输入</h2>
|
<h2>写作输入</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-scroll">
|
||||||
|
<section class="form-section">
|
||||||
<div class="field-head">
|
<div class="field-head">
|
||||||
<label>内容</label>
|
<label>内容</label>
|
||||||
<span id="sourceCount" class="meta">0 字</span>
|
<span id="sourceCount" class="meta">0 字</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="sourceText" rows="9" placeholder="粘贴原文(长帖、线程、摘录均可),洗稿会围绕原文主题展开…"></textarea>
|
<textarea id="sourceText" rows="5" placeholder="粘贴原文内容"></textarea>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div>
|
<div>
|
||||||
<label>标题提示</label>
|
<label>标题提示</label>
|
||||||
<input id="titleHint" type="text" placeholder="如:AI Agent 商业化路径" />
|
<input id="titleHint" type="text" placeholder="可选:标题方向" />
|
||||||
</div>
|
</div>
|
||||||
<div class="multi-field">
|
<div class="multi-field">
|
||||||
<div class="field-head">
|
<div class="field-head">
|
||||||
@@ -59,10 +76,12 @@
|
|||||||
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="普通读者" />普通读者</label>
|
<label class="multi-dropdown-option"><input type="checkbox" name="audienceChip" value="普通读者" />普通读者</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<input id="audienceExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
|
<input id="audienceExtra" type="text" class="multi-extra" placeholder="可选补充" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div class="multi-field">
|
<div class="multi-field">
|
||||||
<div class="field-head">
|
<div class="field-head">
|
||||||
@@ -83,23 +102,23 @@
|
|||||||
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="理性克制" />理性克制</label>
|
<label class="multi-dropdown-option"><input type="checkbox" name="toneChip" value="理性克制" />理性克制</label>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</details>
|
||||||
<input id="toneExtra" type="text" class="multi-extra" placeholder="其他补充(可选)" />
|
<input id="toneExtra" type="text" class="multi-extra" placeholder="可选补充" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>避免词汇</label>
|
<label>避免词汇</label>
|
||||||
<input id="avoidWords" type="text" placeholder="如:颠覆、闭环、赋能" />
|
<input id="avoidWords" type="text" placeholder="可选:避免词汇" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div>
|
<div>
|
||||||
<label>必须保留观点</label>
|
<label>必须保留观点</label>
|
||||||
<input id="keepPoints" type="text" placeholder="逗号分隔" />
|
<input id="keepPoints" type="text" placeholder="可选:保留观点(逗号分隔)" />
|
||||||
</div>
|
</div>
|
||||||
<div class="target-chars-block">
|
<div class="target-chars-block">
|
||||||
<label>改写目标字数</label>
|
<label>改写目标字数</label>
|
||||||
<div class="target-chars-inline">
|
<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="快捷字数">
|
<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" data-target-chars="300">300</button>
|
||||||
<button type="button" class="target-char-chip is-active" data-target-chars="500">500</button>
|
<button type="button" class="target-char-chip is-active" data-target-chars="500">500</button>
|
||||||
@@ -109,9 +128,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
<button id="rewriteBtn" class="primary">改写并排版</button>
|
<button id="rewriteBtn" class="primary">改写并排版</button>
|
||||||
<p id="status" class="status"></p>
|
<p id="status" class="status"></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="panel output-panel">
|
<section class="panel output-panel">
|
||||||
@@ -119,6 +142,8 @@
|
|||||||
<h2>发布内容</h2>
|
<h2>发布内容</h2>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div class="panel-scroll">
|
||||||
|
<section class="form-section">
|
||||||
<label>标题</label>
|
<label>标题</label>
|
||||||
<input id="title" type="text" />
|
<input id="title" type="text" />
|
||||||
|
|
||||||
@@ -127,38 +152,93 @@
|
|||||||
<span id="summaryCount" class="meta">0 字</span>
|
<span id="summaryCount" class="meta">0 字</span>
|
||||||
</div>
|
</div>
|
||||||
<textarea id="summary" rows="2"></textarea>
|
<textarea id="summary" rows="2"></textarea>
|
||||||
|
</section>
|
||||||
|
|
||||||
<label>公众号封面(可选上传)</label>
|
<section class="form-section">
|
||||||
<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="field-head">
|
<div class="field-head">
|
||||||
<label>正文(4~6 自然段,字数由左侧配置)</label>
|
<label>公众号封面</label>
|
||||||
|
<span class="meta">900×383 横版头图</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-mode-switch" role="tablist" aria-label="封面模式切换">
|
||||||
|
<button id="coverModeManualBtn" class="cover-mode-btn is-active" type="button">手动上传封面</button>
|
||||||
|
<button id="coverModeAiBtn" class="cover-mode-btn" type="button">AI 自动生成封面</button>
|
||||||
|
</div>
|
||||||
|
<div id="coverAiSection" class="cover-ai-box" hidden>
|
||||||
|
<div class="cover-ai-copy">
|
||||||
|
<strong>AI 自动生成封面</strong>
|
||||||
|
<span>按标题生成并自动上传绑定。</span>
|
||||||
|
</div>
|
||||||
|
<div class="cover-tools">
|
||||||
|
<input id="coverStyleHint" type="text" placeholder="可选:封面风格" />
|
||||||
|
<button id="coverGenerateBtn" class="primary" type="button">按标题生成封面</button>
|
||||||
|
</div>
|
||||||
|
<label class="check-label cover-auto-check"
|
||||||
|
><input id="coverAutoAfterRewrite" type="checkbox" />改写后自动按输出标题生成封面</label
|
||||||
|
>
|
||||||
|
<div id="coverPreviewWrap" class="cover-preview-wrap" hidden>
|
||||||
|
<img id="coverPreview" class="cover-preview" alt="公众号封面预览" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="coverManualSection">
|
||||||
|
<label>手动上传封面</label>
|
||||||
|
<div class="cover-tools">
|
||||||
|
<input id="coverFile" type="file" accept="image/png,image/jpeg,image/jpg,image/webp" />
|
||||||
|
<button id="coverUploadBtn" class="subtle-btn" type="button">上传封面并绑定</button>
|
||||||
|
</div>
|
||||||
|
<div class="cover-tools">
|
||||||
|
<input id="coverUrl" type="url" placeholder="图片 URL(http/https)" />
|
||||||
|
<button id="coverUrlUploadBtn" class="subtle-btn" type="button">URL 上传并绑定</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<input id="thumbMediaId" type="text" placeholder="thumb_media_id(可选)" />
|
||||||
|
<p id="coverHint" class="muted small">当前为手动上传模式。</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="form-section">
|
||||||
|
<div class="field-head">
|
||||||
|
<label>正文</label>
|
||||||
<span id="bodyCount" class="meta">0 字</span>
|
<span id="bodyCount" class="meta">0 字</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="body-split">
|
<div class="body-split">
|
||||||
<textarea id="body" rows="7" placeholder="五段之间空一行;无需 # 标题"></textarea>
|
<textarea id="body" rows="6" placeholder="可直接编辑正文"></textarea>
|
||||||
<div class="preview-panel">
|
<div class="preview-panel">
|
||||||
<div class="field-head">
|
<div class="field-head">
|
||||||
<label>排版预览</label>
|
<label>排版预览</label>
|
||||||
<span class="meta">实时同步</span>
|
<span class="meta">实时</span>
|
||||||
</div>
|
</div>
|
||||||
<div id="bodyPreview" class="markdown-preview"></div>
|
<div id="bodyPreview" class="markdown-preview"></div>
|
||||||
</div>
|
</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="wechatBtn" class="primary">发布到公众号草稿箱</button>
|
||||||
<button id="imBtn" class="secondary">发送到 IM</button>
|
<button id="imBtn" class="secondary">发送到 IM</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</section>
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
<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>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
@@ -3,65 +3,151 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>{{ app_name }} - 公众号设置</title>
|
<title>{{ app_name }} - 账号与模型设置</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260406a" />
|
<link rel="icon" type="image/svg+xml" href="/static/favicon.svg?v=20260428h" />
|
||||||
<link rel="stylesheet" href="/static/style.css?v=20260410a" />
|
<link rel="stylesheet" href="/static/style.css?v=20260428h" />
|
||||||
</head>
|
</head>
|
||||||
<body class="simple-page">
|
<body>
|
||||||
<main class="simple-wrap">
|
<div class="product-shell">
|
||||||
<section class="panel simple-panel">
|
<aside class="side-nav" aria-label="主导航">
|
||||||
<div class="panel-head">
|
<div class="side-brand">
|
||||||
<h2>公众号设置</h2>
|
<div class="brand-lockup">
|
||||||
<p class="muted small">支持绑定多个公众号并切换当前发布账号。</p>
|
<img class="logo-mark" src="/static/favicon.svg?v=20260428h" alt="" />
|
||||||
|
<h1>{{ app_name }}</h1>
|
||||||
|
</div>
|
||||||
</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">
|
||||||
<div>
|
<header class="topbar topbar-compact">
|
||||||
<label>当前账号</label>
|
<div class="topbar-actions">
|
||||||
<select id="accountSelect"></select>
|
<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>
|
</div>
|
||||||
<div class="actions-inline">
|
</header>
|
||||||
<a class="subtle-link" href="/">返回主页</a>
|
|
||||||
<button id="logoutBtn" class="subtle-btn topbar-btn" type="button">退出登录</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<h3 class="section-title">新增公众号</h3>
|
<main class="layout settings-layout">
|
||||||
<div class="grid2">
|
<section class="panel settings-panel">
|
||||||
<div>
|
<div class="panel-scroll settings-panel-scroll">
|
||||||
<label>账号名</label>
|
<div class="settings-content">
|
||||||
<input id="accountName" type="text" placeholder="如:公司主号 / 客户A号" />
|
<section class="settings-section settings-card">
|
||||||
</div>
|
<div>
|
||||||
<div>
|
<label>当前账号</label>
|
||||||
<label>AppID</label>
|
<select id="accountSelect"></select>
|
||||||
<input id="appid" type="text" placeholder="请输入公众号 AppID" />
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label>Secret</label>
|
|
||||||
<input id="secret" type="password" placeholder="请输入公众号 Secret" />
|
|
||||||
</div>
|
|
||||||
<button id="bindBtn" class="primary" type="button">绑定并设为当前账号</button>
|
|
||||||
|
|
||||||
<h3 class="section-title">账号安全</h3>
|
<h3 class="section-title">新增公众号</h3>
|
||||||
<div class="grid2">
|
<div class="grid2">
|
||||||
<div>
|
<div>
|
||||||
<label>当前密码</label>
|
<label>账号名</label>
|
||||||
<input id="oldPassword" type="password" placeholder="请输入当前密码" />
|
<input id="accountName" type="text" placeholder="如:公司主号 / 客户A号" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label>新密码</label>
|
<label>AppID</label>
|
||||||
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
|
<input id="appid" type="text" placeholder="请输入公众号 AppID" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="actions-inline">
|
<div>
|
||||||
<a class="subtle-link" href="/auth/forgot">忘记密码提示</a>
|
<label>Secret</label>
|
||||||
<button id="changePwdBtn" class="secondary topbar-btn" type="button">修改密码</button>
|
<input id="secret" type="password" placeholder="请输入公众号 Secret" />
|
||||||
</div>
|
</div>
|
||||||
<p id="status" class="status"></p>
|
<div class="actions">
|
||||||
</section>
|
<button id="bindBtn" class="primary" type="button">绑定并设为当前账号</button>
|
||||||
</main>
|
<button id="deleteWechatBtn" class="secondary" type="button">删除当前公众号</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
<script src="/static/settings.js?v=20260410a"></script>
|
<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>
|
||||||
|
<label>当前密码</label>
|
||||||
|
<input id="oldPassword" type="password" placeholder="请输入当前密码" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>新密码</label>
|
||||||
|
<input id="newPassword" type="password" placeholder="请输入新密码(至少 6 位)" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="actions-inline">
|
||||||
|
<a class="subtle-link" href="/auth/forgot">忘记密码提示</a>
|
||||||
|
<button id="changePwdBtn" class="secondary topbar-btn" type="button">修改密码</button>
|
||||||
|
</div>
|
||||||
|
<div class="grid2">
|
||||||
|
<div>
|
||||||
|
<label>注销校验密码</label>
|
||||||
|
<input id="deletePassword" type="password" placeholder="请输入当前登录密码" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label>注销校验重置码</label>
|
||||||
|
<input id="deleteResetKey" type="password" placeholder="请输入你的重置码" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button id="deleteAccountBtn" class="danger" type="button">注销账户</button>
|
||||||
|
<p id="status" class="status"></p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<script src="/static/settings.js?v=20260428a"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
15
readme.md
15
readme.md
@@ -1,4 +1,4 @@
|
|||||||
# X2WeChat Studio
|
# AI发糕
|
||||||
|
|
||||||
把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。
|
把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。
|
||||||
|
|
||||||
@@ -6,8 +6,6 @@
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
cp .env.example .env
|
cp .env.example .env
|
||||||
# 填写 .env 中的 OPENAI / 微信 / IM 参数
|
|
||||||
|
|
||||||
docker compose up --build
|
docker compose up --build
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -22,23 +20,18 @@ docker compose up --build
|
|||||||
3. 点击 `发布到公众号草稿箱`。
|
3. 点击 `发布到公众号草稿箱`。
|
||||||
4. 可选点击 `发送到 IM` 同步到团队群。
|
4. 可选点击 `发送到 IM` 同步到团队群。
|
||||||
|
|
||||||
## 3. 环境变量说明
|
## 3. 配置说明
|
||||||
|
|
||||||
- `OPENAI_API_KEY`:AI 改写能力。
|
- `AI 模型配置`、`公众号 AppID/Secret`:由用户在“账号与模型”页面录入,不再依赖 `.env`。
|
||||||
- `OPENAI_BASE_URL`:可选,兼容第三方网关。
|
|
||||||
- `OPENAI_MODEL`:默认 `gpt-4.1-mini`。
|
|
||||||
- `WECHAT_APPID` / `WECHAT_SECRET`:公众号发布必填。
|
|
||||||
- `WECHAT_AUTHOR`:草稿默认作者名。
|
|
||||||
- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。
|
- `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。
|
||||||
- `IM_SECRET`:可选签名。
|
- `IM_SECRET`:可选签名。
|
||||||
- `AUTH_DB_PATH`:账号数据库文件路径(SQLite)。
|
- `AUTH_DB_PATH`:账号数据库文件路径(SQLite)。
|
||||||
- `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。
|
- `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。
|
||||||
- `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。
|
- `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。
|
||||||
- `AUTH_PASSWORD_RESET_KEY`:忘记密码重置码(用于“用户名+重置码”找回,默认 `x2ws-reset-2026`,建议改掉)。
|
|
||||||
|
|
||||||
## 4. 说明
|
## 4. 说明
|
||||||
|
|
||||||
- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程。
|
- 未配置用户级 AI 模型时,改写接口会提示先去“账号与模型”页面完成配置。
|
||||||
- 建议发布前人工复核事实与引用,避免版权和失真风险。
|
- 建议发布前人工复核事实与引用,避免版权和失真风险。
|
||||||
- 登录页支持“限时免登”,设置页支持修改密码;忘记密码页支持通过“用户名 + 重置码”重置密码。
|
- 登录页支持“限时免登”,设置页支持修改密码;忘记密码页支持通过“用户名 + 重置码”重置密码。
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user