From 2724e69b4f0250cc6f49bed17377363d6f6f3d24 Mon Sep 17 00:00:00 2001 From: Daniel Date: Tue, 28 Apr 2026 11:50:55 +0800 Subject: [PATCH] fix: bug --- .env.example | 24 +- .vscode/settings.json | 3 + app/config.py | 22 +- app/main.py | 335 ++++- app/schemas.py | 77 ++ app/services/poster_material.py | 473 +++++++ app/services/user_store.py | 395 +++++- app/services/wechat.py | 60 +- app/static/app.js | 292 ++++- app/static/auth.js | 29 +- app/static/favicon.svg | 34 +- app/static/settings.js | 170 ++- app/static/style.css | 1874 +++++++++++++++++++++++----- app/templates/auth.html | 96 +- app/templates/forgot_password.html | 54 +- app/templates/guide.html | 134 ++ app/templates/index.html | 154 ++- app/templates/settings.html | 192 ++- readme.md | 15 +- start.sh | 2 +- 20 files changed, 3881 insertions(+), 554 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 app/services/poster_material.py create mode 100644 app/templates/guide.html diff --git a/.env.example b/.env.example index a6d4057..7b3d4a6 100644 --- a/.env.example +++ b/.env.example @@ -1,26 +1,28 @@ +APP_NAME=AI发糕 +# 注意:AI 模型、公众号 AppID/Secret 为用户级配置,请在页面「账号与模型」中填写。 # —— 通义千问(推荐):阿里云 DashScope 的 OpenAI 兼容地址 + 模型名 + API Key # OPENAI_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1 # OPENAI_API_KEY=sk-你的DashScopeKey # OPENAI_MODEL=qwen3.5-plus -OPENAI_API_KEY= -OPENAI_BASE_URL= -OPENAI_MODEL=gpt-4.1-mini +# OPENAI_API_KEY= +# OPENAI_BASE_URL= +# OPENAI_MODEL=gpt-4.1-mini # 通义长文 JSON 常需 60~120s+,过短会 APITimeout 后走兜底 -OPENAI_TIMEOUT=120 +# OPENAI_TIMEOUT=120 # SDK 自动重试次数。设为 0 可避免单次请求被隐式重试拖长(例如 30s 变 60s+) -OPENAI_MAX_RETRIES=0 +# OPENAI_MAX_RETRIES=0 # 长文 JSON 建议 8192;通义等若正文仍偏短可适当再加大 -OPENAI_MAX_OUTPUT_TOKENS=8192 -OPENAI_SOURCE_MAX_CHARS=5000 +# OPENAI_MAX_OUTPUT_TOKENS=8192 +# OPENAI_SOURCE_MAX_CHARS=5000 # 质检未通过时仍返回模型洗稿正文(quality_notes 记录问题);设为 false 则严格退回保底稿 -AI_SOFT_ACCEPT=true +# AI_SOFT_ACCEPT=true LOG_LEVEL=INFO # 发布到公众号需:公众平台 → 基本配置 → IP 白名单,加入「本服务访问 api.weixin.qq.com 的出口公网 IP」。 # 若 errcode=40164 invalid ip:把日志里的 IP 加入白名单;本地/Docker 出口 IP 常变,建议用固定 IP 服务器部署。 -WECHAT_APPID= -WECHAT_SECRET= -WECHAT_AUTHOR=AI 编辑部 +# WECHAT_APPID= +# WECHAT_SECRET= +# WECHAT_AUTHOR=AI 编辑部 # 封面(图文草稿必填,否则 errcode=40007):任选其一 # ① 填永久素材 ID:WECHAT_THUMB_MEDIA_ID=(素材库 → 图片 → 复制 media_id) # ② 填容器内图片路径,由服务自动上传:WECHAT_THUMB_IMAGE_PATH=/app/cover.jpg diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..6b0e5ab --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "postman.settings.dotenv-detection-notification-visibility": false +} \ No newline at end of file diff --git a/app/config.py b/app/config.py index b7bee0e..20c9594 100644 --- a/app/config.py +++ b/app/config.py @@ -5,7 +5,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict class Settings(BaseSettings): model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") - app_name: str = "X2WeChat Studio" + app_name: str = "AI发糕" openai_api_key: str | None = Field(default=None, alias="OPENAI_API_KEY") openai_base_url: str | None = Field(default=None, alias="OPENAI_BASE_URL") openai_model: str = Field(default="gpt-4.1-mini", alias="OPENAI_MODEL") @@ -24,6 +24,11 @@ class Settings(BaseSettings): alias="OPENAI_MAX_OUTPUT_TOKENS", description="单次模型输出 token 上限;通义等长文 JSON 需足够大", ) + openai_image_model: str = Field( + default="gpt-image-1", + alias="OPENAI_IMAGE_MODEL", + description="用于海报生成的图像模型", + ) openai_source_max_chars: int = Field(default=5000, alias="OPENAI_SOURCE_MAX_CHARS") ai_soft_accept: bool = Field( default=True, @@ -44,6 +49,21 @@ class Settings(BaseSettings): alias="WECHAT_THUMB_IMAGE_PATH", description="本地封面图路径(容器内),将自动上传为永久素材;不配则使用内置灰底图上传", ) + poster_image_size: str = Field( + default="1024x1536", + alias="POSTER_IMAGE_SIZE", + description="AI 海报生成尺寸(OpenAI images.generate size)", + ) + poster_max_images: int = Field( + default=6, + alias="POSTER_MAX_IMAGES", + description="单次自动生成海报上限(首段跳过后生效)", + ) + poster_upload_max_bytes: int = Field( + default=950000, + alias="POSTER_UPLOAD_MAX_BYTES", + description="上传微信 uploadimg 前压缩目标字节数,预留余量避免超 1MB 限制", + ) im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL") im_secret: str | None = Field(default=None, alias="IM_SECRET") diff --git a/app/main.py b/app/main.py index 69339ef..d2f41cd 100644 --- a/app/main.py +++ b/app/main.py @@ -1,10 +1,11 @@ from __future__ import annotations -import hmac import logging +from pathlib import Path from urllib.parse import urlparse -from fastapi import FastAPI, File, Request, Response, UploadFile +import httpx +from fastapi import FastAPI, File, HTTPException, Request, Response, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -13,17 +14,26 @@ from app.config import settings from app.logging_setup import configure_logging from app.middleware import RequestContextMiddleware from app.schemas import ( + AIModelCreateRequest, + AIModelDeleteRequest, + AIModelSwitchRequest, AuthCredentialRequest, ChangePasswordRequest, + DeleteAccountRequest, ForgotPasswordResetRequest, IMPublishRequest, + PosterGenerateRequest, RewriteRequest, + WechatCoverUploadByUrlRequest, + WechatCoverGenerateRequest, + WechatDeleteRequest, WechatBindingRequest, WechatPublishRequest, WechatSwitchRequest, ) from app.services.ai_rewriter import AIRewriter from app.services.im import IMPublisher +from app.services.poster_material import PosterMaterialService from app.services.user_store import UserStore from app.services.wechat import WechatPublisher @@ -36,9 +46,9 @@ app = FastAPI(title=settings.app_name) @app.on_event("startup") async def _log_startup() -> None: logger.info( - "app_start name=%s openai_configured=%s ai_soft_accept=%s", + "app_start name=%s user_model_required=%s ai_soft_accept=%s", settings.app_name, - bool(settings.openai_api_key), + True, settings.ai_soft_accept, ) @@ -49,6 +59,7 @@ templates = Jinja2Templates(directory="app/templates") rewriter = AIRewriter() wechat = WechatPublisher() +poster_material = PosterMaterialService(wechat) im = IMPublisher() users = UserStore(settings.auth_db_path) @@ -96,25 +107,38 @@ async def settings_page(request: Request): return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name}) +@app.get("/guide", response_class=HTMLResponse) +async def guide_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/guide", status_code=302) + return templates.TemplateResponse("guide.html", {"request": request, "app_name": settings.app_name}) + + @app.get("/favicon.ico", include_in_schema=False) async def favicon(): # 浏览器通常请求 /favicon.ico,统一跳转到静态图标 - return RedirectResponse(url="/static/favicon.svg?v=20260406a") + return RedirectResponse(url="/static/favicon.svg?v=20260428h") @app.get("/api/config") -async def api_config(): +async def api_config(request: Request): """供页面展示:当前是否接入模型、模型名、提供方(不含密钥)。""" - base = settings.openai_base_url or "" + user = _current_user(request) + model_cfg = users.get_active_ai_model(user["id"]) if user else None + base = (model_cfg or {}).get("base_url") or "" provider = "dashscope" if "dashscope.aliyuncs.com" in base else "openai_compatible" host = urlparse(base).netloc if base else "" + model_name = (model_cfg or {}).get("model") or None + timeout_sec = (model_cfg or {}).get("timeout_sec") or None + max_output_tokens = (model_cfg or {}).get("max_output_tokens") or None + key_configured = bool((model_cfg or {}).get("api_key")) return { - "openai_configured": bool(settings.openai_api_key), - "openai_model": settings.openai_model, + "openai_configured": key_configured, + "openai_model": model_name, "provider": provider, "base_url_host": host or None, - "openai_timeout_sec": settings.openai_timeout, - "openai_max_output_tokens": settings.openai_max_output_tokens, + "openai_timeout_sec": timeout_sec, + "openai_max_output_tokens": max_output_tokens, } @@ -132,6 +156,8 @@ async def auth_me(request: Request): "wechat_bound": bool(binding and binding.get("appid") and binding.get("secret")), "active_wechat_account": binding, "wechat_accounts": bindings, + "active_ai_model": users.get_active_ai_model(user["id"]), + "ai_models": users.list_ai_models(user["id"]), } @@ -160,7 +186,12 @@ async def auth_register(req: AuthCredentialRequest, response: Response): max_age=ttl, path="/", ) - return {"ok": True, "detail": "注册并登录成功", "user": user} + return { + "ok": True, + "detail": "注册并登录成功,请保存重置码", + "user": {"id": user["id"], "username": user["username"]}, + "reset_code": user.get("reset_code", ""), + } @app.post("/api/auth/login") @@ -201,23 +232,17 @@ async def forgot_password_page(request: Request): @app.post("/api/auth/password/forgot") async def auth_forgot_password_reset(req: ForgotPasswordResetRequest): - reset_key = (req.reset_key or "").strip() - expected_key = (settings.auth_password_reset_key or "x2ws-reset-2026").strip() username = (req.username or "").strip() new_password = req.new_password or "" - if not expected_key: - return {"ok": False, "detail": "系统未启用忘记密码重置功能,请联系管理员"} if len(username) < 2: return {"ok": False, "detail": "请输入正确的用户名"} if len(new_password) < 6: return {"ok": False, "detail": "新密码至少 6 个字符"} - if not hmac.compare_digest(reset_key, expected_key): - return {"ok": False, "detail": "重置码错误"} - ok = users.reset_password_by_username(username, new_password) + ok = users.reset_password_by_username(username, req.reset_key, new_password) if not ok: - return {"ok": False, "detail": "用户不存在,无法重置"} + return {"ok": False, "detail": "用户名或重置码错误,无法重置"} return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"} @@ -254,6 +279,25 @@ async def auth_change_password(req: ChangePasswordRequest, request: Request, res return {"ok": True, "detail": "密码修改成功,已刷新登录状态"} +@app.post("/api/auth/account/delete") +async def auth_delete_account(req: DeleteAccountRequest, request: Request, response: Response): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + if len((req.password or "").strip()) < 1: + return {"ok": False, "detail": "请输入当前密码"} + if len((req.reset_key or "").strip()) < 4: + return {"ok": False, "detail": "请输入重置码"} + ok = users.delete_user_logically(user["id"], req.password, req.reset_key) + if not ok: + return {"ok": False, "detail": "密码或重置码错误,注销失败"} + token = request.cookies.get(settings.auth_cookie_name, "") + if token: + users.delete_session(token) + response.delete_cookie(settings.auth_cookie_name, path="/") + return {"ok": True, "detail": "账号已注销,关联数据已清空"} + + @app.post("/api/auth/wechat/bind") async def auth_wechat_bind(req: WechatBindingRequest, request: Request): user = _require_user(request) @@ -286,6 +330,63 @@ async def auth_wechat_switch(req: WechatSwitchRequest, request: Request): return {"ok": True, "detail": "已切换当前公众号账号"} +@app.post("/api/auth/wechat/delete") +async def auth_wechat_delete(req: WechatDeleteRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + ok = users.delete_wechat_binding(user["id"], int(req.account_id)) + if not ok: + return {"ok": False, "detail": "删除失败:账号不存在或无权限"} + return {"ok": True, "detail": "公众号账号已删除"} + + +@app.post("/api/auth/ai-models/add") +async def auth_ai_model_add(req: AIModelCreateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + api_key = (req.api_key or "").strip() + model = (req.model or "").strip() + if not api_key: + return {"ok": False, "detail": "API Key 不能为空"} + if not model: + return {"ok": False, "detail": "模型名不能为空"} + created = users.add_ai_model( + user_id=user["id"], + model_name=(req.model_name or "").strip(), + api_key=api_key, + base_url=(req.base_url or "").strip(), + model=model, + timeout_sec=max(10.0, float(req.timeout_sec or 120.0)), + max_output_tokens=max(256, int(req.max_output_tokens or 8192)), + max_retries=max(0, int(req.max_retries or 0)), + ) + return {"ok": True, "detail": "模型配置已保存并设为当前", "model_config": created} + + +@app.post("/api/auth/ai-models/switch") +async def auth_ai_model_switch(req: AIModelSwitchRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + ok = users.switch_active_ai_model(user["id"], int(req.model_id)) + if not ok: + return {"ok": False, "detail": "切换失败:模型不存在或无权限"} + return {"ok": True, "detail": "已切换当前模型"} + + +@app.post("/api/auth/ai-models/delete") +async def auth_ai_model_delete(req: AIModelDeleteRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + ok = users.delete_ai_model(user["id"], int(req.model_id)) + if not ok: + return {"ok": False, "detail": "删除失败:模型不存在或无权限"} + return {"ok": True, "detail": "模型配置已删除"} + + @app.post("/api/rewrite") async def rewrite(req: RewriteRequest, request: Request): rid = getattr(request.state, "request_id", "") @@ -302,7 +403,35 @@ async def rewrite(req: RewriteRequest, request: Request): len(req.avoid_words or ""), int(req.target_body_chars or 500), ) - result = rewriter.rewrite(req, request_id=rid) + user = _require_user(request) + if not user: + raise HTTPException(status_code=401, detail="请先登录") + model_cfg = users.get_active_ai_model(user["id"]) + if not model_cfg: + raise HTTPException(status_code=400, detail="请先在设置页配置 AI 模型") + backup = { + "openai_api_key": settings.openai_api_key, + "openai_base_url": settings.openai_base_url, + "openai_model": settings.openai_model, + "openai_timeout": settings.openai_timeout, + "openai_max_output_tokens": settings.openai_max_output_tokens, + "openai_max_retries": settings.openai_max_retries, + } + try: + settings.openai_api_key = model_cfg.get("api_key") or "" + settings.openai_base_url = model_cfg.get("base_url") or "" + settings.openai_model = model_cfg.get("model") or "" + settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0) + settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192) + settings.openai_max_retries = int(model_cfg.get("max_retries") or 0) + result = AIRewriter().rewrite(req, request_id=rid) + finally: + settings.openai_api_key = backup["openai_api_key"] + settings.openai_base_url = backup["openai_base_url"] + settings.openai_model = backup["openai_model"] + settings.openai_timeout = backup["openai_timeout"] + settings.openai_max_output_tokens = backup["openai_max_output_tokens"] + settings.openai_max_retries = backup["openai_max_retries"] tr = result.trace or {} logger.info( "api_rewrite_out rid=%s mode=%s duration_ms=%s quality_notes=%d trace_steps=%s soft_accept=%s", @@ -367,6 +496,112 @@ async def upload_wechat_cover(request: Request, file: UploadFile = File(...)): return out +@app.post("/api/wechat/cover/upload-by-url") +async def upload_wechat_cover_by_url(req: WechatCoverUploadByUrlRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + binding = users.get_active_wechat_binding(user["id"]) + if not binding: + return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"} + rid = getattr(request.state, "request_id", "") + image_url = (req.image_url or "").strip() + if not image_url: + return {"ok": False, "detail": "图片 URL 不能为空"} + parsed = urlparse(image_url) + if parsed.scheme not in {"http", "https"}: + return {"ok": False, "detail": "仅支持 http/https 图片地址"} + try: + async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client: + resp = await client.get(image_url) + except Exception as exc: + logger.warning("api_wechat_cover_url_fetch_fail rid=%s url=%s err=%s", rid, image_url, exc) + return {"ok": False, "detail": "图片下载失败,请检查 URL 是否可访问"} + if resp.status_code >= 400: + return {"ok": False, "detail": f"图片下载失败,HTTP {resp.status_code}"} + content_type = (resp.headers.get("content-type") or "").lower() + if not content_type.startswith("image/"): + return {"ok": False, "detail": "URL 指向的内容不是图片"} + content = resp.content or b"" + if not content: + return {"ok": False, "detail": "图片内容为空"} + if len(content) > 10 * 1024 * 1024: + return {"ok": False, "detail": "图片过大,请使用 10MB 以内的图片"} + + ext = Path(parsed.path or "").suffix.lower() + if ext not in {".jpg", ".jpeg", ".png", ".webp"}: + ext = ".png" if "png" in content_type else ".jpg" + fn = f"cover_from_url{ext}" + logger.info("api_wechat_cover_upload_url_in rid=%s url=%s bytes=%d", rid, image_url, len(content)) + out = await wechat.upload_cover(fn, content, request_id=rid, account=binding) + logger.info( + "api_wechat_cover_upload_url_out rid=%s ok=%s detail=%s", + rid, + out.ok, + (out.detail or "")[:160], + ) + return out + + +@app.post("/api/wechat/cover/generate") +async def generate_wechat_cover(req: WechatCoverGenerateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + binding = users.get_active_wechat_binding(user["id"]) if req.upload_to_wechat else None + if req.upload_to_wechat and not binding: + return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"} + rid = getattr(request.state, "request_id", "") + logger.info( + "api_wechat_cover_generate_in rid=%s title_chars=%d summary_chars=%d upload_to_wechat=%s", + rid, + len(req.title or ""), + len(req.summary or ""), + req.upload_to_wechat, + ) + model_cfg = users.get_active_ai_model(user["id"]) + backup = { + "openai_api_key": settings.openai_api_key, + "openai_base_url": settings.openai_base_url, + "openai_model": settings.openai_model, + "openai_timeout": settings.openai_timeout, + "openai_max_output_tokens": settings.openai_max_output_tokens, + "openai_max_retries": settings.openai_max_retries, + } + try: + if model_cfg: + settings.openai_api_key = model_cfg.get("api_key") or "" + settings.openai_base_url = model_cfg.get("base_url") or "" + settings.openai_model = model_cfg.get("model") or "" + settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0) + settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192) + settings.openai_max_retries = int(model_cfg.get("max_retries") or 0) + else: + settings.openai_api_key = "" + settings.openai_base_url = "" + settings.openai_model = "" + settings.openai_timeout = 120.0 + settings.openai_max_output_tokens = 8192 + settings.openai_max_retries = 0 + out = await PosterMaterialService(wechat).generate_cover(req, request_id=rid, account=binding) + finally: + settings.openai_api_key = backup["openai_api_key"] + settings.openai_base_url = backup["openai_base_url"] + settings.openai_model = backup["openai_model"] + settings.openai_timeout = backup["openai_timeout"] + settings.openai_max_output_tokens = backup["openai_max_output_tokens"] + settings.openai_max_retries = backup["openai_max_retries"] + logger.info( + "api_wechat_cover_generate_out rid=%s ok=%s thumb=%s note=%s warnings=%d", + rid, + out.ok, + bool(out.thumb_media_id), + out.note, + len(out.warnings), + ) + return out + + @app.post("/api/wechat/material/upload") async def upload_wechat_material(request: Request, file: UploadFile = File(...)): user = _require_user(request) @@ -389,6 +624,64 @@ async def upload_wechat_material(request: Request, file: UploadFile = File(...)) return out +@app.post("/api/material/posters/generate") +async def generate_posters(req: PosterGenerateRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + binding = users.get_active_wechat_binding(user["id"]) if req.upload_to_wechat else None + if req.upload_to_wechat and not binding: + return {"ok": False, "detail": "当前账号未绑定公众号 token,请先在页面绑定"} + rid = getattr(request.state, "request_id", "") + logger.info( + "api_poster_generate_in rid=%s body_chars=%d upload_to_wechat=%s max_images=%d", + rid, + len(req.body_markdown or ""), + req.upload_to_wechat, + int(req.max_images or 0), + ) + model_cfg = users.get_active_ai_model(user["id"]) + backup = { + "openai_api_key": settings.openai_api_key, + "openai_base_url": settings.openai_base_url, + "openai_model": settings.openai_model, + "openai_timeout": settings.openai_timeout, + "openai_max_output_tokens": settings.openai_max_output_tokens, + "openai_max_retries": settings.openai_max_retries, + } + try: + if model_cfg: + settings.openai_api_key = model_cfg.get("api_key") or "" + settings.openai_base_url = model_cfg.get("base_url") or "" + settings.openai_model = model_cfg.get("model") or "" + settings.openai_timeout = float(model_cfg.get("timeout_sec") or 120.0) + settings.openai_max_output_tokens = int(model_cfg.get("max_output_tokens") or 8192) + settings.openai_max_retries = int(model_cfg.get("max_retries") or 0) + else: + settings.openai_api_key = "" + settings.openai_base_url = "" + settings.openai_model = "" + settings.openai_timeout = 120.0 + settings.openai_max_output_tokens = 8192 + settings.openai_max_retries = 0 + out = await PosterMaterialService(wechat).generate(req, request_id=rid, account=binding) + finally: + settings.openai_api_key = backup["openai_api_key"] + settings.openai_base_url = backup["openai_base_url"] + settings.openai_model = backup["openai_model"] + settings.openai_timeout = backup["openai_timeout"] + settings.openai_max_output_tokens = backup["openai_max_output_tokens"] + settings.openai_max_retries = backup["openai_max_retries"] + logger.info( + "api_poster_generate_out rid=%s ok=%s posters=%d warnings=%d", + rid, + out.ok, + len(out.posters), + len(out.warnings), + ) + return out + + @app.post("/api/publish/im") async def publish_im(req: IMPublishRequest, request: Request): rid = getattr(request.state, "request_id", "") diff --git a/app/schemas.py b/app/schemas.py index 546a582..370155a 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -67,6 +67,11 @@ class ForgotPasswordResetRequest(BaseModel): new_password: str +class DeleteAccountRequest(BaseModel): + password: str + reset_key: str + + class WechatBindingRequest(BaseModel): account_name: str = "" appid: str @@ -78,3 +83,75 @@ class WechatBindingRequest(BaseModel): class WechatSwitchRequest(BaseModel): account_id: int + + +class WechatDeleteRequest(BaseModel): + account_id: int + + +class WechatCoverUploadByUrlRequest(BaseModel): + image_url: str + + +class WechatCoverGenerateRequest(BaseModel): + title: str = "" + summary: str = "" + style_hint: str = "" + upload_to_wechat: bool = True + + +class AIModelCreateRequest(BaseModel): + model_name: str + api_key: str + base_url: str = "" + model: str + timeout_sec: float = 120.0 + max_output_tokens: int = 8192 + max_retries: int = 0 + + +class AIModelSwitchRequest(BaseModel): + model_id: int + + +class AIModelDeleteRequest(BaseModel): + model_id: int + + +class PosterGenerateRequest(BaseModel): + title: str = "" + summary: str = "" + body_markdown: str = Field(..., min_length=20) + style_hint: str = "" + upload_to_wechat: bool = True + max_images: int = Field(default=6, ge=1, le=12) + + +class PosterPreviewItem(BaseModel): + paragraph_index: int + paragraph_excerpt: str = "" + prompt: str = "" + preview_data_url: str + wechat_url: str = "" + uploaded: bool = False + note: str = "" + + +class PosterGenerateResponse(BaseModel): + ok: bool + detail: str + skipped_first_paragraph: bool = True + posters: list[PosterPreviewItem] = Field(default_factory=list) + body_markdown_with_posters: str = "" + warnings: list[str] = Field(default_factory=list) + + +class CoverGenerateResponse(BaseModel): + ok: bool + detail: str + preview_data_url: str = "" + thumb_media_id: str = "" + width: int = 900 + height: int = 383 + note: str = "" + warnings: list[str] = Field(default_factory=list) diff --git a/app/services/poster_material.py b/app/services/poster_material.py new file mode 100644 index 0000000..b2b0b55 --- /dev/null +++ b/app/services/poster_material.py @@ -0,0 +1,473 @@ +from __future__ import annotations + +import asyncio +import base64 +import logging +import re +import textwrap +from io import BytesIO +from pathlib import Path + +import httpx +from openai import OpenAI +from PIL import Image, ImageDraw, ImageFont + +from app.config import settings +from app.schemas import ( + CoverGenerateResponse, + PosterGenerateRequest, + PosterGenerateResponse, + PosterPreviewItem, + WechatCoverGenerateRequest, +) +from app.services.wechat import WechatPublisher + +logger = logging.getLogger(__name__) + +_FONT_CANDIDATES = [ + "/System/Library/Fonts/PingFang.ttc", + "/System/Library/Fonts/Hiragino Sans GB.ttc", + "/Library/Fonts/Arial Unicode.ttf", +] + + +def _split_paragraphs(body_markdown: str) -> list[str]: + raw = (body_markdown or "").replace("\r\n", "\n").strip() + if not raw: + return [] + return [p.strip() for p in re.split(r"\n\s*\n+", raw) if p.strip()] + + +def _pick_font(size: int) -> ImageFont.ImageFont: + for p in _FONT_CANDIDATES: + if Path(p).is_file(): + try: + return ImageFont.truetype(p, size=size) + except Exception: + continue + return ImageFont.load_default() + + +def _to_jpeg_under_limit(content: bytes, max_bytes: int) -> bytes: + im = Image.open(BytesIO(content)).convert("RGB") + widths = [1080, 1024, 960, 900, 840, 780, 720, 660] + qualities = [88, 82, 76, 70, 64, 58, 52] + + for w in widths: + if im.width > w: + h = max(1, int(im.height * (w / im.width))) + cur = im.resize((w, h), Image.Resampling.LANCZOS) + else: + cur = im + for q in qualities: + buf = BytesIO() + cur.save(buf, format="JPEG", quality=q, optimize=True) + out = buf.getvalue() + if len(out) <= max_bytes: + return out + + buf = BytesIO() + h = max(1, int(im.height * (640 / im.width))) + im.resize((640, h), Image.Resampling.LANCZOS).save(buf, format="JPEG", quality=48, optimize=True) + return buf.getvalue() + + +def _cover_to_jpeg( + content: bytes, + max_bytes: int, + size: tuple[int, int] = (900, 383), + title: str = "", + summary: str = "", + overlay_title: bool = False, +) -> bytes: + im = Image.open(BytesIO(content)).convert("RGB") + target_w, target_h = size + src_ratio = im.width / max(1, im.height) + dst_ratio = target_w / target_h + if src_ratio > dst_ratio: + new_w = int(im.height * dst_ratio) + x0 = max(0, (im.width - new_w) // 2) + im = im.crop((x0, 0, x0 + new_w, im.height)) + elif src_ratio < dst_ratio: + new_h = int(im.width / dst_ratio) + y0 = max(0, (im.height - new_h) // 2) + im = im.crop((0, y0, im.width, y0 + new_h)) + im = im.resize(size, Image.Resampling.LANCZOS) + if overlay_title: + im = _draw_cover_text_overlay(im, title, summary) + + for q in [92, 88, 84, 80, 76, 72, 68, 62]: + buf = BytesIO() + im.save(buf, format="JPEG", quality=q, optimize=True, progressive=True) + out = buf.getvalue() + if len(out) <= max_bytes: + return out + buf = BytesIO() + im.save(buf, format="JPEG", quality=58, optimize=True) + return buf.getvalue() + + +def _draw_cover_text_overlay(im: Image.Image, title: str, summary: str) -> Image.Image: + im = im.convert("RGBA") + overlay = Image.new("RGBA", im.size, (0, 0, 0, 0)) + draw = ImageDraw.Draw(overlay) + draw.rounded_rectangle([42, 46, 572, 337], radius=30, fill=(255, 255, 255, 228)) + draw.rounded_rectangle([70, 74, 222, 119], radius=18, fill=(31, 111, 91, 245)) + + tag_font = _pick_font(26) + title_font = _pick_font(46) + summary_font = _pick_font(24) + draw.text((94, 83), "公众号封面", font=tag_font, fill=(255, 255, 255, 255)) + + clean_title = re.sub(r"\s+", "", title or "公众号文章") + title_lines = textwrap.wrap(clean_title, width=12)[:2] + y = 146 + for line in title_lines: + draw.text((72, y), line, font=title_font, fill=(23, 32, 51, 255)) + y += 56 + + clean_summary = re.sub(r"\s+", "", summary or "一眼看懂主题,明确文章价值。") + summary_lines = textwrap.wrap(clean_summary, width=23)[:2] + sy = max(y + 10, 270) + for line in summary_lines: + draw.text((74, sy), line, font=summary_font, fill=(104, 115, 133, 255)) + sy += 30 + + return Image.alpha_composite(im, overlay).convert("RGB") + + +class PosterMaterialService: + def __init__(self, wechat: WechatPublisher) -> None: + self._wechat = wechat + self._image_client = None + if settings.openai_api_key: + self._image_client = OpenAI( + api_key=settings.openai_api_key, + base_url=settings.openai_base_url, + timeout=settings.openai_timeout, + max_retries=max(0, int(settings.openai_max_retries)), + ) + + async def generate( + self, + req: PosterGenerateRequest, + request_id: str = "", + account: dict | None = None, + ) -> PosterGenerateResponse: + rid = request_id or "-" + paragraphs = _split_paragraphs(req.body_markdown) + if len(paragraphs) <= 1: + return PosterGenerateResponse( + ok=True, + detail="正文不足两段:按规则首段不生成图片,因此无需海报。", + posters=[], + body_markdown_with_posters=req.body_markdown, + warnings=[], + ) + + max_images = max(1, min(int(req.max_images or settings.poster_max_images), 12)) + posters: list[PosterPreviewItem] = [] + warnings: list[str] = [] + wechat_urls_by_para: dict[int, str] = {} + + for idx, paragraph in enumerate(paragraphs): + if idx == 0: + continue + if len(posters) >= max_images: + break + + prompt = self._build_prompt(req, paragraph, idx, len(paragraphs)) + jpeg_bytes, note = await asyncio.to_thread(self._create_poster_jpeg, prompt, paragraph, idx) + preview_data_url = "data:image/jpeg;base64," + base64.b64encode(jpeg_bytes).decode("ascii") + + wechat_url = "" + uploaded = False + if req.upload_to_wechat: + if not account: + warnings.append("未绑定公众号:已生成海报预览,但未上传微信素材 URL。") + else: + filename = f"poster_p{idx + 1}.jpg" + out = await self._wechat.upload_article_image( + filename, + jpeg_bytes, + request_id=rid, + account=account, + ) + if out.ok: + wechat_url = ((out.data or {}).get("url") or "").strip() + uploaded = bool(wechat_url) + if uploaded: + wechat_urls_by_para[idx] = wechat_url + else: + warnings.append(f"第 {idx + 1} 段海报上传失败:{out.detail}") + + posters.append( + PosterPreviewItem( + paragraph_index=idx, + paragraph_excerpt=textwrap.shorten(paragraph.replace("\n", " "), width=80, placeholder="…"), + prompt=prompt, + preview_data_url=preview_data_url, + wechat_url=wechat_url, + uploaded=uploaded, + note=note, + ) + ) + + merged = req.body_markdown + if wechat_urls_by_para: + merged = self._merge_body_with_posters(paragraphs, wechat_urls_by_para) + detail = f"已生成 {len(posters)} 张段落海报(首段跳过)" + if req.upload_to_wechat: + detail += f",成功上传 {sum(1 for p in posters if p.uploaded)} 张" + + logger.info( + "poster_generate rid=%s posters=%d upload_to_wechat=%s uploaded=%d warnings=%d", + rid, + len(posters), + req.upload_to_wechat, + sum(1 for p in posters if p.uploaded), + len(warnings), + ) + return PosterGenerateResponse( + ok=True, + detail=detail, + posters=posters, + body_markdown_with_posters=merged, + warnings=warnings, + ) + + async def generate_cover( + self, + req: WechatCoverGenerateRequest, + request_id: str = "", + account: dict | None = None, + ) -> CoverGenerateResponse: + rid = request_id or "-" + title = (req.title or "").strip() + summary = (req.summary or "").strip() + if not title: + return CoverGenerateResponse(ok=False, detail="请先填写标题,或先完成改写生成标题") + + prompt = self._build_cover_prompt(req) + jpeg_bytes, note = await asyncio.to_thread(self._create_cover_jpeg, prompt, title, summary) + preview_data_url = "data:image/jpeg;base64," + base64.b64encode(jpeg_bytes).decode("ascii") + + warnings: list[str] = [] + thumb_media_id = "" + if req.upload_to_wechat: + if not account: + warnings.append("未绑定公众号:已生成封面预览,但未上传为微信封面素材。") + else: + out = await self._wechat.upload_cover("wechat_cover_900x383.jpg", jpeg_bytes, request_id=rid, account=account) + if out.ok: + thumb_media_id = ((out.data or {}).get("thumb_media_id") or "").strip() + else: + warnings.append(f"封面上传失败:{out.detail}") + + detail = "已生成公众号封面(900×383)" + if thumb_media_id: + detail += ",并已绑定 thumb_media_id" + elif warnings: + detail += ",但未完成微信绑定" + + logger.info( + "cover_generate rid=%s title_chars=%d upload_to_wechat=%s uploaded=%s note=%s warnings=%d", + rid, + len(title), + req.upload_to_wechat, + bool(thumb_media_id), + note, + len(warnings), + ) + return CoverGenerateResponse( + ok=True, + detail=detail, + preview_data_url=preview_data_url, + thumb_media_id=thumb_media_id, + width=900, + height=383, + note=note, + warnings=warnings, + ) + + def _build_cover_prompt(self, req: WechatCoverGenerateRequest) -> str: + title = (req.title or "公众号文章").strip() + summary = (req.summary or "").strip() + style_hint = (req.style_hint or "").strip() or "成熟公众号封面,清晰、克制、信息强,适合作为文章列表首图" + return ( + "生成一张微信公众号文章封面图,最终会裁切为 900x383 横版比例。" + f"封面主标题:{title}。" + f"文章摘要:{summary}。" + f"风格要求:{style_hint}。" + "画面要突出封面的点击引导作用:主题明确、视觉焦点强、留出标题安全区、中文字少且清晰。" + "不要出现二维码、水印、品牌 logo、真人肖像、杂乱小字和侵权素材。" + ) + + def _build_prompt(self, req: PosterGenerateRequest, paragraph: str, idx: int, total: int) -> str: + title = (req.title or "公众号内容").strip() + summary = (req.summary or "").strip() + style_hint = (req.style_hint or "").strip() or "现代、干净、中文可读、公众号海报风格" + para = paragraph.strip() + return ( + "请生成一张中文竖版海报,适合公众号正文插图。" + f"主题标题:{title}。" + f"这是第 {idx + 1}/{total} 段对应海报(首段不配图)。" + f"段落核心内容:{para}。" + f"摘要参考:{summary}。" + f"风格要求:{style_hint}。" + "画面需信息聚焦、可读性强,不要出现水印、二维码、logo、真人肖像。" + ) + + def _create_poster_jpeg(self, prompt: str, paragraph: str, idx: int) -> tuple[bytes, str]: + max_bytes = max(300_000, int(settings.poster_upload_max_bytes or 950_000)) + + if self._image_client: + try: + raw = self._generate_with_model(prompt) + if raw: + return _to_jpeg_under_limit(raw, max_bytes), "ai" + except Exception as exc: + logger.warning("poster_ai_failed detail=%s", str(exc)[:240]) + + fallback = self._generate_fallback_poster(paragraph, idx) + return _to_jpeg_under_limit(fallback, max_bytes), "fallback" + + def _create_cover_jpeg(self, prompt: str, title: str, summary: str) -> tuple[bytes, str]: + max_bytes = max(300_000, int(settings.poster_upload_max_bytes or 950_000)) + + if self._image_client: + try: + raw = self._generate_with_model(prompt) + if raw: + return _cover_to_jpeg(raw, max_bytes, title=title, summary=summary, overlay_title=True), "ai_900x383" + except Exception as exc: + logger.warning("cover_ai_failed detail=%s", str(exc)[:240]) + + fallback = self._generate_fallback_cover(title, summary) + return _cover_to_jpeg(fallback, max_bytes), "fallback_900x383" + + def _generate_with_model(self, prompt: str) -> bytes | None: + rsp = self._image_client.images.generate( + model=settings.openai_image_model, + prompt=prompt, + size=settings.poster_image_size, + ) + data = getattr(rsp, "data", None) or [] + if not data: + return None + + first = data[0] + b64 = "" + image_url = "" + if isinstance(first, dict): + b64 = (first.get("b64_json") or "").strip() + image_url = (first.get("url") or "").strip() + else: + b64 = (getattr(first, "b64_json", "") or "").strip() + image_url = (getattr(first, "url", "") or "").strip() + + if b64: + return base64.b64decode(b64) + if image_url: + with httpx.Client(timeout=30) as client: + r = client.get(image_url) + r.raise_for_status() + return r.content + return None + + def _generate_fallback_poster(self, paragraph: str, idx: int) -> bytes: + w, h = 1080, 1520 + im = Image.new("RGB", (w, h), (240, 246, 255)) + draw = ImageDraw.Draw(im) + + for y in range(h): + c = int(240 - (y / h) * 36) + draw.line([(0, y), (w, y)], fill=(c, c + 6, 255), width=1) + + for i in range(8): + x0 = int(w * 0.08) + i * 54 + y0 = int(h * 0.66) + i * 22 + x1 = x0 + 260 + y1 = y0 + 100 + color = (160 - i * 8, 190 - i * 9, 230 - i * 8) + draw.rounded_rectangle([x0, y0, x1, y1], radius=24, outline=color, width=2) + + tag_font = _pick_font(36) + title_font = _pick_font(58) + body_font = _pick_font(42) + + draw.rounded_rectangle([70, 70, 340, 142], radius=20, fill=(31, 77, 185)) + draw.text((102, 90), f"段落 {idx + 1}", font=tag_font, fill=(255, 255, 255)) + draw.text((70, 190), "AI 图文海报", font=title_font, fill=(16, 42, 102)) + + words = re.sub(r"\s+", "", paragraph) + if len(words) > 120: + words = words[:120] + "…" + wrapped = textwrap.fill(words, width=19) + draw.multiline_text( + (72, 330), + wrapped, + font=body_font, + fill=(35, 54, 92), + spacing=14, + align="left", + ) + + buf = BytesIO() + im.save(buf, format="PNG") + return buf.getvalue() + + def _generate_fallback_cover(self, title: str, summary: str) -> bytes: + w, h = 900, 383 + im = Image.new("RGB", (w, h), (247, 249, 252)) + draw = ImageDraw.Draw(im) + + for y in range(h): + t = y / h + r = int(252 - t * 28) + g = int(250 - t * 18) + b = int(241 - t * 8) + draw.line([(0, y), (w, y)], fill=(r, g, b), width=1) + + draw.rounded_rectangle([36, 34, 864, 349], radius=34, fill=(255, 255, 255), outline=(223, 229, 238), width=2) + draw.rounded_rectangle([604, 60, 830, 290], radius=34, fill=(238, 244, 241)) + draw.ellipse([660, 95, 810, 245], fill=(229, 196, 122)) + draw.ellipse([690, 126, 780, 216], fill=(255, 250, 229)) + draw.arc([684, 118, 784, 226], start=20, end=168, fill=(199, 159, 81), width=5) + + tag_font = _pick_font(28) + title_font = _pick_font(48) + summary_font = _pick_font(24) + small_font = _pick_font(20) + + draw.rounded_rectangle([72, 70, 226, 118], radius=18, fill=(31, 111, 91)) + draw.text((96, 80), "公众号封面", font=tag_font, fill=(255, 255, 255)) + + clean_title = re.sub(r"\s+", "", title or "公众号文章") + title_lines = textwrap.wrap(clean_title, width=12)[:2] + y = 146 + for line in title_lines: + draw.text((72, y), line, font=title_font, fill=(23, 32, 51)) + y += 58 + + clean_summary = re.sub(r"\s+", "", summary or "清晰表达主题,让读者一眼知道文章价值。") + summary_lines = textwrap.wrap(clean_summary, width=24)[:2] + sy = max(y + 12, 268) + for line in summary_lines: + draw.text((74, sy), line, font=summary_font, fill=(104, 115, 133)) + sy += 32 + + draw.text((72, 320), "900 x 383", font=small_font, fill=(140, 150, 166)) + buf = BytesIO() + im.save(buf, format="PNG") + return buf.getvalue() + + def _merge_body_with_posters(self, paragraphs: list[str], wechat_urls_by_para: dict[int, str]) -> str: + merged: list[str] = [] + for idx, para in enumerate(paragraphs): + if idx > 0: + url = (wechat_urls_by_para.get(idx) or "").strip() + if url: + merged.append(f"![段落配图 {idx + 1}]({url})") + merged.append(para) + return "\n\n".join(merged) diff --git a/app/services/user_store.py b/app/services/user_store.py index b65468f..3c1bc9d 100644 --- a/app/services/user_store.py +++ b/app/services/user_store.py @@ -66,6 +66,27 @@ class UserStore: ) """ ) + pref_cols = self._table_columns(c, "user_prefs") + if "active_ai_model_id" not in pref_cols: + c.execute("ALTER TABLE user_prefs ADD COLUMN active_ai_model_id INTEGER") + c.execute( + """ + CREATE TABLE IF NOT EXISTS ai_models ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + model_name TEXT NOT NULL, + api_key TEXT NOT NULL, + base_url TEXT NOT NULL DEFAULT '', + model TEXT NOT NULL, + timeout_sec REAL NOT NULL DEFAULT 120.0, + max_output_tokens INTEGER NOT NULL DEFAULT 8192, + max_retries INTEGER NOT NULL DEFAULT 0, + updated_at INTEGER NOT NULL, + UNIQUE(user_id, model_name), + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) # 兼容历史单绑定结构,自动迁移为默认账号 rows = c.execute( "SELECT user_id, appid, secret, author, thumb_media_id, thumb_image_path, updated_at FROM wechat_bindings" @@ -111,7 +132,16 @@ class UserStore: return {str(r["name"]) for r in rows} def _ensure_users_table(self, c: sqlite3.Connection) -> None: - required = {"id", "username", "password_hash", "password_salt", "created_at"} + required = { + "id", + "username", + "password_hash", + "password_salt", + "reset_code_hash", + "reset_code_salt", + "created_at", + "deleted_at", + } c.execute( """ CREATE TABLE IF NOT EXISTS users ( @@ -119,7 +149,10 @@ class UserStore: username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, - created_at INTEGER NOT NULL + reset_code_hash TEXT NOT NULL DEFAULT '', + reset_code_salt TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL, + deleted_at INTEGER ) """ ) @@ -137,29 +170,44 @@ class UserStore: username TEXT NOT NULL UNIQUE, password_hash TEXT NOT NULL, password_salt TEXT NOT NULL, - created_at INTEGER NOT NULL + reset_code_hash TEXT NOT NULL, + reset_code_salt TEXT NOT NULL, + created_at INTEGER NOT NULL, + deleted_at INTEGER ) """ ) if {"username", "password_hash", "password_salt"}.issubset(cols): - if "created_at" in cols: + rows = c.execute("SELECT * FROM users").fetchall() + for r in rows: + username = (r["username"] or "").strip() + if not username: + continue + reset_salt = r["reset_code_salt"] if "reset_code_salt" in cols else secrets.token_hex(8) + reset_hash = r["reset_code_hash"] if "reset_code_hash" in cols else "" + if not reset_hash: + legacy_code = self._generate_reset_code() + reset_hash = self._hash_reset_code(legacy_code, reset_salt) + created_at = int(r["created_at"]) if "created_at" in cols and r["created_at"] else now + deleted_at = int(r["deleted_at"]) if "deleted_at" in cols and r["deleted_at"] else None c.execute( """ - INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at) - SELECT id, username, password_hash, password_salt, COALESCE(created_at, ?) - FROM users + INSERT OR IGNORE INTO users_new( + id, username, password_hash, password_salt, reset_code_hash, reset_code_salt, created_at, deleted_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) """, - (now,), - ) - else: - c.execute( - """ - INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at) - SELECT id, username, password_hash, password_salt, ? - FROM users - """, - (now,), + ( + int(r["id"]), + username, + r["password_hash"], + r["password_salt"], + reset_hash, + reset_salt, + created_at, + deleted_at, + ), ) elif {"username", "password"}.issubset(cols): if "created_at" in cols: @@ -173,13 +221,18 @@ class UserStore: continue salt = secrets.token_hex(16) pwd_hash = self._hash_password(raw_pwd, salt) + reset_code = self._generate_reset_code() + reset_salt = secrets.token_hex(8) + reset_hash = self._hash_reset_code(reset_code, reset_salt) created_at = int(r["created_at"]) if "created_at" in r.keys() and r["created_at"] else now c.execute( """ - INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at) - VALUES (?, ?, ?, ?, ?) + INSERT OR IGNORE INTO users_new( + id, username, password_hash, password_salt, reset_code_hash, reset_code_salt, created_at, deleted_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, NULL) """, - (int(r["id"]), username, pwd_hash, salt, created_at), + (int(r["id"]), username, pwd_hash, salt, reset_hash, reset_salt, created_at), ) c.execute("DROP TABLE users") @@ -222,18 +275,31 @@ class UserStore: def _hash_token(self, token: str) -> str: return hashlib.sha256(token.encode("utf-8")).hexdigest() + def _generate_reset_code(self) -> str: + return secrets.token_urlsafe(9).replace("-", "").replace("_", "")[:12] + + def _hash_reset_code(self, reset_code: str, salt: str) -> str: + return hashlib.sha256(f"{salt}:{reset_code}".encode("utf-8")).hexdigest() + def create_user(self, username: str, password: str) -> dict | None: now = int(time.time()) salt = secrets.token_hex(16) pwd_hash = self._hash_password(password, salt) + reset_code = self._generate_reset_code() + reset_salt = secrets.token_hex(8) + reset_hash = self._hash_reset_code(reset_code, reset_salt) try: with self._conn() as c: cur = c.execute( - "INSERT INTO users(username, password_hash, password_salt, created_at) VALUES (?, ?, ?, ?)", - (username, pwd_hash, salt, now), + """ + INSERT INTO users( + username, password_hash, password_salt, reset_code_hash, reset_code_salt, created_at, deleted_at + ) VALUES (?, ?, ?, ?, ?, ?, NULL) + """, + (username, pwd_hash, salt, reset_hash, reset_salt, now), ) uid = int(cur.lastrowid) - return {"id": uid, "username": username} + return {"id": uid, "username": username, "reset_code": reset_code} except sqlite3.IntegrityError: return None except sqlite3.Error as exc: @@ -243,7 +309,11 @@ class UserStore: try: with self._conn() as c: row = c.execute( - "SELECT id, username, password_hash, password_salt FROM users WHERE username=?", + """ + SELECT id, username, password_hash, password_salt + FROM users + WHERE username=? AND deleted_at IS NULL + """, (username,), ).fetchone() except sqlite3.Error as exc: @@ -274,14 +344,27 @@ class UserStore: ) return True - def reset_password_by_username(self, username: str, new_password: str) -> bool: + def reset_password_by_username(self, username: str, reset_code: str, new_password: str) -> bool: uname = (username or "").strip() + rcode = (reset_code or "").strip() if not uname: return False with self._conn() as c: - row = c.execute("SELECT id FROM users WHERE username=?", (uname,)).fetchone() + row = c.execute( + """ + SELECT id, reset_code_hash, reset_code_salt + FROM users + WHERE username=? AND deleted_at IS NULL + """, + (uname,), + ).fetchone() if not row: return False + if not rcode: + return False + calc = self._hash_reset_code(rcode, row["reset_code_salt"] or "") + if not hmac.compare_digest(calc, row["reset_code_hash"] or ""): + return False new_salt = secrets.token_hex(16) new_hash = self._hash_password(new_password, new_salt) c.execute( @@ -324,7 +407,7 @@ class UserStore: SELECT u.id, u.username FROM sessions s JOIN users u ON u.id=s.user_id - WHERE s.token_hash=? AND s.expires_at>=? + WHERE s.token_hash=? AND s.expires_at>=? AND u.deleted_at IS NULL """, (th, now), ).fetchone() @@ -332,6 +415,37 @@ class UserStore: return None return {"id": int(row["id"]), "username": row["username"]} + def delete_user_logically(self, user_id: int, password: str, reset_code: str) -> bool: + now = int(time.time()) + with self._conn() as c: + row = c.execute( + """ + SELECT id, password_hash, password_salt, reset_code_hash, reset_code_salt + FROM users + WHERE id=? AND deleted_at IS NULL + """, + (user_id,), + ).fetchone() + if not row: + return False + calc_pwd = self._hash_password(password or "", row["password_salt"] or "") + if not hmac.compare_digest(calc_pwd, row["password_hash"] or ""): + return False + calc_reset = self._hash_reset_code(reset_code or "", row["reset_code_salt"] or "") + if not hmac.compare_digest(calc_reset, row["reset_code_hash"] or ""): + return False + + c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM wechat_accounts WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM ai_models WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM user_prefs WHERE user_id=?", (user_id,)) + c.execute("DELETE FROM wechat_bindings WHERE user_id=?", (user_id,)) + c.execute( + "UPDATE users SET deleted_at=?, username=username || '#deleted' || ? WHERE id=?", + (now, str(now), user_id), + ) + return True + def save_wechat_binding( self, user_id: int, @@ -510,3 +624,230 @@ class UserStore: "thumb_image_path": row["thumb_image_path"] or "", "updated_at": int(row["updated_at"] or 0), } + + def list_ai_models(self, user_id: int) -> list[dict]: + with self._conn() as c: + rows = c.execute( + """ + SELECT id, model_name, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at + FROM ai_models + WHERE user_id=? + ORDER BY updated_at DESC, id DESC + """, + (user_id,), + ).fetchall() + pref = c.execute( + "SELECT active_ai_model_id FROM user_prefs WHERE user_id=?", + (user_id,), + ).fetchone() + active_id = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None + out: list[dict] = [] + for r in rows: + out.append( + { + "id": int(r["id"]), + "model_name": r["model_name"] or "", + "base_url": r["base_url"] or "", + "model": r["model"] or "", + "timeout_sec": float(r["timeout_sec"] or 120.0), + "max_output_tokens": int(r["max_output_tokens"] or 8192), + "max_retries": int(r["max_retries"] or 0), + "updated_at": int(r["updated_at"] or 0), + "active": int(r["id"]) == active_id, + } + ) + return out + + def add_ai_model( + self, + user_id: int, + model_name: str, + api_key: str, + base_url: str, + model: str, + timeout_sec: float = 120.0, + max_output_tokens: int = 8192, + max_retries: int = 0, + ) -> dict: + now = int(time.time()) + name = model_name.strip() or f"模型{now % 10000}" + with self._conn() as c: + try: + cur = c.execute( + """ + INSERT INTO ai_models + (user_id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, now), + ) + except sqlite3.IntegrityError: + name = f"{name}-{now % 1000}" + cur = c.execute( + """ + INSERT INTO ai_models + (user_id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, now), + ) + aid = int(cur.lastrowid) + c.execute( + """ + INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + active_ai_model_id=excluded.active_ai_model_id, + updated_at=excluded.updated_at + """, + (user_id, aid, now), + ) + return {"id": aid, "model_name": name} + + def switch_active_ai_model(self, user_id: int, model_id: int) -> bool: + now = int(time.time()) + with self._conn() as c: + row = c.execute( + "SELECT id FROM ai_models WHERE id=? AND user_id=?", + (model_id, user_id), + ).fetchone() + if not row: + return False + c.execute( + """ + INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + active_ai_model_id=excluded.active_ai_model_id, + updated_at=excluded.updated_at + """, + (user_id, model_id, now), + ) + return True + + def delete_ai_model(self, user_id: int, model_id: int) -> bool: + now = int(time.time()) + with self._conn() as c: + row = c.execute( + "SELECT id FROM ai_models WHERE id=? AND user_id=?", + (model_id, user_id), + ).fetchone() + if not row: + return False + c.execute("DELETE FROM ai_models WHERE id=? AND user_id=?", (model_id, user_id)) + pref = c.execute( + "SELECT active_ai_model_id FROM user_prefs WHERE user_id=?", + (user_id,), + ).fetchone() + active_id = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None + if active_id == model_id: + replacement = c.execute( + """ + SELECT id FROM ai_models WHERE user_id=? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + """, + (user_id,), + ).fetchone() + replacement_id = int(replacement["id"]) if replacement else None + c.execute( + """ + INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + active_ai_model_id=excluded.active_ai_model_id, + updated_at=excluded.updated_at + """, + (user_id, replacement_id, now), + ) + return True + + def get_active_ai_model(self, user_id: int) -> dict | None: + with self._conn() as c: + pref = c.execute( + "SELECT active_ai_model_id FROM user_prefs WHERE user_id=?", + (user_id,), + ).fetchone() + aid = int(pref["active_ai_model_id"]) if pref and pref["active_ai_model_id"] else None + row = None + if aid: + row = c.execute( + """ + SELECT id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at + FROM ai_models + WHERE id=? AND user_id=? + """, + (aid, user_id), + ).fetchone() + if not row: + row = c.execute( + """ + SELECT id, model_name, api_key, base_url, model, timeout_sec, max_output_tokens, max_retries, updated_at + FROM ai_models + WHERE user_id=? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + """, + (user_id,), + ).fetchone() + if row: + c.execute( + """ + INSERT INTO user_prefs(user_id, active_ai_model_id, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + active_ai_model_id=excluded.active_ai_model_id, + updated_at=excluded.updated_at + """, + (user_id, int(row["id"]), int(time.time())), + ) + if not row: + return None + return { + "id": int(row["id"]), + "model_name": row["model_name"] or "", + "api_key": row["api_key"] or "", + "base_url": row["base_url"] or "", + "model": row["model"] or "", + "timeout_sec": float(row["timeout_sec"] or 120.0), + "max_output_tokens": int(row["max_output_tokens"] or 8192), + "max_retries": int(row["max_retries"] or 0), + "updated_at": int(row["updated_at"] or 0), + } + + def delete_wechat_binding(self, user_id: int, account_id: int) -> bool: + now = int(time.time()) + with self._conn() as c: + row = c.execute( + "SELECT id FROM wechat_accounts WHERE id=? AND user_id=?", + (account_id, user_id), + ).fetchone() + if not row: + return False + c.execute("DELETE FROM wechat_accounts WHERE id=? AND user_id=?", (account_id, user_id)) + pref = c.execute( + "SELECT active_wechat_account_id FROM user_prefs WHERE user_id=?", + (user_id,), + ).fetchone() + active_id = int(pref["active_wechat_account_id"]) if pref and pref["active_wechat_account_id"] else None + if active_id == account_id: + replacement = c.execute( + """ + SELECT id FROM wechat_accounts WHERE user_id=? + ORDER BY updated_at DESC, id DESC + LIMIT 1 + """, + (user_id,), + ).fetchone() + replacement_id = int(replacement["id"]) if replacement else None + c.execute( + """ + INSERT INTO user_prefs(user_id, active_wechat_account_id, updated_at) + VALUES (?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + active_wechat_account_id=excluded.active_wechat_account_id, + updated_at=excluded.updated_at + """, + (user_id, replacement_id, now), + ) + return True diff --git a/app/services/wechat.py b/app/services/wechat.py index 5325929..011bac7 100644 --- a/app/services/wechat.py +++ b/app/services/wechat.py @@ -69,11 +69,11 @@ class WechatPublisher: def _resolve_account(self, account: dict | None = None) -> dict[str, str]: src = account or {} - appid = (src.get("appid") or settings.wechat_appid or "").strip() - secret = (src.get("secret") or settings.wechat_secret or "").strip() - author = (src.get("author") or settings.wechat_author or "").strip() - thumb_media_id = (src.get("thumb_media_id") or settings.wechat_thumb_media_id or "").strip() - thumb_image_path = (src.get("thumb_image_path") or settings.wechat_thumb_image_path or "").strip() + appid = (src.get("appid") or "").strip() + secret = (src.get("secret") or "").strip() + author = (src.get("author") or "").strip() + thumb_media_id = (src.get("thumb_media_id") or "").strip() + thumb_image_path = (src.get("thumb_image_path") or "").strip() return { "appid": appid, "secret": secret, @@ -132,7 +132,7 @@ class WechatPublisher: { "article_type": "news", "title": req.title[:32] if len(req.title) > 32 else req.title, - "author": (req.author or acct["author"] or settings.wechat_author)[:16], + "author": (req.author or acct["author"] or "AI发糕")[:16], "digest": (req.summary or "")[:128], "content": html, "content_source_url": "", @@ -246,6 +246,37 @@ class WechatPublisher: ) return PublishResponse(ok=True, detail="素材上传成功", data=material) + async def upload_article_image( + self, filename: str, content: bytes, request_id: str = "", account: dict | None = None + ) -> PublishResponse: + """上传图文正文图片(uploadimg),返回可直接插入正文 HTML/Markdown 的 URL。""" + rid = request_id or "-" + acct = self._resolve_account(account) + if not acct["appid"] or not acct["secret"]: + return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置") + if not content: + return PublishResponse(ok=False, detail="素材文件为空") + + token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"]) + if not token: + return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body) + + async with httpx.AsyncClient(timeout=60) as client: + out = await self._upload_article_image_url(client, token, content, filename) + if not out: + return PublishResponse( + ok=False, + detail="正文配图上传失败:请检查图片格式与大小(jpg/png,建议小于 1MB),或查看日志 wechat_uploadimg_failed", + ) + + logger.info( + "wechat_uploadimg_ok rid=%s filename=%s url=%s", + rid, + filename, + out.get("url"), + ) + return PublishResponse(ok=True, detail="正文配图上传成功", data=out) + async def _upload_permanent_image( self, client: httpx.AsyncClient, token: str, content: bytes, filename: str ) -> dict[str, str] | None: @@ -263,6 +294,23 @@ class WechatPublisher: return None return {"media_id": mid, "url": data.get("url") or ""} + async def _upload_article_image_url( + self, client: httpx.AsyncClient, token: str, content: bytes, filename: str + ) -> dict[str, str] | None: + url = f"https://api.weixin.qq.com/cgi-bin/media/uploadimg?access_token={token}" + ctype = "image/png" if filename.lower().endswith(".png") else "image/jpeg" + files = {"media": (filename, content, ctype)} + r = await client.post(url, files=files) + data = r.json() if r.content else {} + if isinstance(data, dict) and data.get("errcode"): + logger.warning("wechat_uploadimg_failed body=%s", data) + return None + image_url = (data.get("url") if isinstance(data, dict) else "") or "" + if not image_url: + logger.warning("wechat_uploadimg_no_url body=%s", data) + return None + return {"url": image_url} + async def _resolve_thumb_media_id( self, token: str, rid: str, *, force_skip_explicit: bool = False, account: dict | None = None ) -> str | None: diff --git a/app/static/app.js b/app/static/app.js index f452603..9906565 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -16,8 +16,51 @@ const rewriteBtn = $("rewriteBtn"); const wechatBtn = $("wechatBtn"); const imBtn = $("imBtn"); const coverUploadBtn = $("coverUploadBtn"); +const coverUrlUploadBtn = $("coverUrlUploadBtn"); +const coverGenerateBtn = $("coverGenerateBtn"); +const coverModeManualBtn = $("coverModeManualBtn"); +const coverModeAiBtn = $("coverModeAiBtn"); +const coverManualSection = $("coverManualSection"); +const coverAiSection = $("coverAiSection"); +const coverAutoAfterRewrite = $("coverAutoAfterRewrite"); +const coverPreview = $("coverPreview"); +const coverPreviewWrap = $("coverPreviewWrap"); const logoutBtn = $("logoutBtn"); const targetBodyCharsInput = $("targetBodyChars"); +const posterGenerateBtn = $("posterGenerateBtn"); +const posterPreviewList = $("posterPreviewList"); +const posterHint = $("posterHint"); +const posterAutoInclude = $("posterAutoInclude"); + +let posterState = { + signature: "", + bodyMarkdownWithPosters: "", + posters: [], +}; +let coverMode = "manual"; + +function setCoverMode(mode) { + coverMode = mode === "ai" ? "ai" : "manual"; + if (coverModeManualBtn) coverModeManualBtn.classList.toggle("is-active", coverMode === "manual"); + if (coverModeAiBtn) coverModeAiBtn.classList.toggle("is-active", coverMode === "ai"); + if (coverManualSection) { + const hideManual = coverMode !== "manual"; + coverManualSection.hidden = hideManual; + coverManualSection.style.display = hideManual ? "none" : ""; + } + if (coverAiSection) { + const hideAi = coverMode !== "ai"; + coverAiSection.hidden = hideAi; + coverAiSection.style.display = hideAi ? "none" : ""; + } + const hint = $("coverHint"); + if (hint) { + hint.textContent = + coverMode === "manual" + ? "当前为手动上传模式,可切换到 AI 自动生成。" + : "当前为 AI 生成模式,也可切换回手动上传。"; + } +} function syncTargetCharChips() { const val = Number((targetBodyCharsInput && targetBodyCharsInput.value) || 0); @@ -82,6 +125,68 @@ function setStatus(msg, danger = false) { statusEl.textContent = msg; } +function buildPosterSignature() { + const title = ($("title") && $("title").value.trim()) || ""; + const summary = ($("summary") && $("summary").value.trim()) || ""; + const body = ($("body") && $("body").value.trim()) || ""; + return `${title}\n||\n${summary}\n||\n${body}`; +} + +function renderPosterPreview(posters) { + if (!posterPreviewList) return; + posterPreviewList.innerHTML = ""; + const list = Array.isArray(posters) ? posters : []; + if (!list.length) { + const empty = document.createElement("p"); + empty.className = "muted small"; + empty.textContent = "暂无段落海报预览。"; + posterPreviewList.appendChild(empty); + return; + } + list.forEach((item) => { + const card = document.createElement("article"); + card.className = "poster-card"; + + const img = document.createElement("img"); + img.className = "poster-thumb"; + img.alt = `段落 ${Number(item.paragraph_index || 0) + 1} 海报`; + img.src = item.preview_data_url || ""; + card.appendChild(img); + + const meta = document.createElement("div"); + meta.className = "poster-meta"; + const top = document.createElement("div"); + top.className = "poster-topline"; + top.textContent = `段落 ${Number(item.paragraph_index || 0) + 1} · ${item.note || "ai"}`; + meta.appendChild(top); + + const excerpt = document.createElement("p"); + excerpt.className = "poster-excerpt"; + excerpt.textContent = item.paragraph_excerpt || ""; + meta.appendChild(excerpt); + + if (item.wechat_url) { + const link = document.createElement("a"); + link.className = "poster-link"; + link.href = item.wechat_url; + link.target = "_blank"; + link.rel = "noreferrer noopener"; + link.textContent = "微信素材 URL"; + meta.appendChild(link); + } + + card.appendChild(meta); + posterPreviewList.appendChild(card); + }); +} + +function markPosterStaleIfNeeded() { + if (!posterState.signature || !posterHint) return; + if (posterState.signature !== buildPosterSignature()) { + posterHint.textContent = "正文已修改,发布前会自动重建段落海报。"; + } +} + async function postJSON(url, body) { const res = await fetch(url, { method: "POST", @@ -94,6 +199,78 @@ async function postJSON(url, body) { return data; } +async function generatePosterMaterials({ silent = false } = {}) { + const bodyMarkdown = (($("body") && $("body").value) || "").trim(); + if (bodyMarkdown.length < 20) { + throw new Error("正文太短,暂无法生成段落海报"); + } + if (!silent) setStatus("正在生成段落海报..."); + if (posterHint) posterHint.textContent = "正在生成并上传段落海报..."; + setLoading(posterGenerateBtn, true, "生成段落海报", "生成中..."); + try { + const data = await postJSON("/api/material/posters/generate", { + title: $("title").value, + summary: $("summary").value, + body_markdown: $("body").value, + upload_to_wechat: true, + }); + if (!data.ok) throw new Error(data.detail || "海报生成失败"); + + posterState = { + signature: buildPosterSignature(), + bodyMarkdownWithPosters: data.body_markdown_with_posters || $("body").value, + posters: Array.isArray(data.posters) ? data.posters : [], + }; + renderPosterPreview(posterState.posters); + + const warnText = Array.isArray(data.warnings) && data.warnings.length ? `(提示:${data.warnings.join(";")})` : ""; + if (posterHint) posterHint.textContent = `${data.detail || "海报生成完成"}${warnText}`; + if (!silent) setStatus(`${data.detail || "海报生成完成"}${warnText}`); + return data; + } finally { + setLoading(posterGenerateBtn, false, "生成段落海报", "生成中..."); + } +} + +function coverTitleForGeneration() { + const generatedTitle = (($("title") && $("title").value) || "").trim(); + const titleHint = (($("titleHint") && $("titleHint").value) || "").trim(); + return generatedTitle || titleHint; +} + +async function generateWechatCover({ silent = false } = {}) { + const title = coverTitleForGeneration(); + if (!title) { + throw new Error("请先填写标题提示,或先改写生成标题"); + } + if (!silent) setStatus("正在按标题生成公众号封面..."); + const hint = $("coverHint"); + if (hint) hint.textContent = "正在生成 900×383 公众号封面并上传..."; + setLoading(coverGenerateBtn, true, "按标题生成封面", "生成中..."); + try { + const data = await postJSON("/api/wechat/cover/generate", { + title, + summary: (($("summary") && $("summary").value) || "").trim(), + style_hint: (($("coverStyleHint") && $("coverStyleHint").value) || "").trim(), + upload_to_wechat: true, + }); + if (!data.ok) throw new Error(data.detail || "封面生成失败"); + const mid = data.thumb_media_id || ""; + if (mid && $("thumbMediaId")) $("thumbMediaId").value = mid; + if (data.preview_data_url && coverPreview && coverPreviewWrap) { + coverPreview.src = data.preview_data_url; + coverPreviewWrap.hidden = false; + } + const warnText = Array.isArray(data.warnings) && data.warnings.length ? `(提示:${data.warnings.join(";")})` : ""; + const detail = data.detail || "封面生成完成"; + if (hint) hint.textContent = `${detail}${warnText}`; + if (!silent) setStatus(`${detail}${warnText}`); + return data; + } finally { + setLoading(coverGenerateBtn, false, "按标题生成封面", "生成中..."); + } +} + async function fetchAuthMe() { const res = await fetch("/api/auth/me"); const data = await res.json(); @@ -117,10 +294,10 @@ function renderWechatAccountSelect(me) { if (!list.length) { const opt = document.createElement("option"); opt.value = ""; - opt.textContent = "暂无公众号"; + opt.textContent = "未绑定公众号"; sel.appendChild(opt); sel.disabled = true; - if (hint) hint.textContent = "请先在「公众号设置」绑定"; + if (hint) hint.textContent = "请先绑定公众号"; return; } sel.disabled = false; @@ -192,6 +369,13 @@ if (logoutBtn) { }); } +if (coverModeManualBtn) { + coverModeManualBtn.addEventListener("click", () => setCoverMode("manual")); +} +if (coverModeAiBtn) { + coverModeAiBtn.addEventListener("click", () => setCoverMode("ai")); +} + document.querySelectorAll(".target-char-chip").forEach((btn) => { btn.addEventListener("click", () => { const n = Number(btn.getAttribute("data-target-chars") || 0); @@ -245,6 +429,22 @@ $("rewriteBtn").addEventListener("click", async () => { } else { setStatus("改写完成。"); } + try { + setStatus("改写完成,正在生成段落海报..."); + await generatePosterMaterials({ silent: true }); + setStatus("改写与段落海报生成完成。"); + } catch (posterErr) { + setStatus(`改写完成,段落海报未生成:${posterErr.message}`, true); + } + if (coverAutoAfterRewrite && coverAutoAfterRewrite.checked) { + try { + setStatus("改写完成,正在按输出标题生成封面..."); + await generateWechatCover({ silent: true }); + setStatus("改写、封面与段落海报生成完成。"); + } catch (coverErr) { + setStatus(`改写完成,封面未生成:${coverErr.message}`, true); + } + } } catch (e) { setStatus(`改写失败: ${e.message}`, true); } finally { @@ -256,10 +456,25 @@ $("wechatBtn").addEventListener("click", async () => { setStatus("正在发布到公众号草稿箱..."); setLoading(wechatBtn, true, "发布到公众号草稿箱", "发布中..."); try { + let bodyForPublish = $("body").value; + const autoInclude = Boolean(posterAutoInclude && posterAutoInclude.checked); + if (autoInclude) { + const stale = posterState.signature !== buildPosterSignature() || !posterState.bodyMarkdownWithPosters; + if (stale) { + try { + await generatePosterMaterials({ silent: true }); + } catch (posterErr) { + setStatus(`海报生成失败,本次仅发布文字:${posterErr.message}`, true); + } + } + if (posterState.bodyMarkdownWithPosters) { + bodyForPublish = posterState.bodyMarkdownWithPosters; + } + } const data = await postJSON("/api/publish/wechat", { title: $("title").value, summary: $("summary").value, - body_markdown: $("body").value, + body_markdown: bodyForPublish, thumb_media_id: $("thumbMediaId") ? $("thumbMediaId").value.trim() : "", }); if (!data.ok) throw new Error(data.detail); @@ -276,6 +491,23 @@ $("wechatBtn").addEventListener("click", async () => { } }); +if (coverGenerateBtn) { + coverGenerateBtn.addEventListener("click", async () => { + try { + await generateWechatCover({ silent: false }); + } catch (e) { + const hint = $("coverHint"); + if (hint) hint.textContent = "AI 封面生成失败,请检查标题、模型或公众号配置。"; + setStatus(`封面生成失败: ${e.message}`, true); + if ((e.message || "").includes("请先登录")) { + window.location.href = "/auth?next=/"; + } else if ((e.message || "").includes("未绑定公众号")) { + window.location.href = "/settings"; + } + } + }); +} + if (coverUploadBtn) { coverUploadBtn.addEventListener("click", async () => { const fileInput = $("coverFile"); @@ -311,6 +543,54 @@ if (coverUploadBtn) { }); } +if (coverUrlUploadBtn) { + coverUrlUploadBtn.addEventListener("click", async () => { + const hint = $("coverHint"); + const imageUrl = (($("coverUrl") && $("coverUrl").value) || "").trim(); + if (!imageUrl) { + setStatus("请先粘贴图片 URL", true); + return; + } + if (hint) hint.textContent = "正在下载并上传 URL 图片..."; + setLoading(coverUrlUploadBtn, true, "URL 上传并绑定", "上传中..."); + try { + const data = await postJSON("/api/wechat/cover/upload-by-url", { image_url: imageUrl }); + if (!data.ok) throw new Error(data.detail || "URL 封面上传失败"); + const mid = data.data && data.data.thumb_media_id ? data.data.thumb_media_id : ""; + if ($("thumbMediaId")) $("thumbMediaId").value = mid; + if (hint) hint.textContent = `URL 封面上传成功,已绑定 media_id:${mid}`; + setStatus("URL 封面上传成功,发布时将优先使用该封面。"); + if ($("coverUrl")) $("coverUrl").value = ""; + } catch (e) { + if (hint) hint.textContent = "URL 封面上传失败,请看状态提示。"; + setStatus(`URL 封面上传失败: ${e.message}`, true); + if ((e.message || "").includes("请先登录")) { + window.location.href = "/auth?next=/"; + } else if ((e.message || "").includes("未绑定公众号")) { + window.location.href = "/settings"; + } + } finally { + setLoading(coverUrlUploadBtn, false, "URL 上传并绑定", "上传中..."); + } + }); +} + +if (posterGenerateBtn) { + posterGenerateBtn.addEventListener("click", async () => { + try { + await generatePosterMaterials({ silent: false }); + } catch (e) { + setStatus(`海报生成失败: ${e.message}`, true); + if (posterHint) posterHint.textContent = "海报生成失败,请检查配置后重试。"; + if ((e.message || "").includes("请先登录")) { + window.location.href = "/auth?next=/"; + } else if ((e.message || "").includes("未绑定公众号")) { + window.location.href = "/settings"; + } + } + }); +} + $("imBtn").addEventListener("click", async () => { setStatus("正在发送到 IM..."); setLoading(imBtn, true, "发送到 IM", "发送中..."); @@ -328,11 +608,15 @@ $("imBtn").addEventListener("click", async () => { } }); -["sourceText", "summary", "body"].forEach((id) => { +["sourceText", "title", "summary", "body"].forEach((id) => { $(id).addEventListener("input", updateCounters); + if (id !== "sourceText") $(id).addEventListener("input", markPosterStaleIfNeeded); }); updateCounters(); initMultiDropdowns(); initWechatAccountSwitch(); syncTargetCharChips(); +renderPosterPreview([]); +setCoverMode("manual"); +window.addEventListener("load", () => setCoverMode("manual")); diff --git a/app/static/auth.js b/app/static/auth.js index 970d520..65ce867 100644 --- a/app/static/auth.js +++ b/app/static/auth.js @@ -66,6 +66,33 @@ if (loginBtn) { if (registerBtn) { registerBtn.addEventListener("click", async () => { - await authAction("/api/auth/register", registerBtn, "注册", "注册中...", "注册成功,正在跳转..."); + setLoading(registerBtn, true, "注册", "注册中..."); + try { + const data = await postJSON("/api/auth/register", fields()); + if (!data.ok) { + setStatus(data.detail || "注册失败", true); + return; + } + const code = (data.reset_code || "").trim(); + if (code) { + const msg = + `注册成功!请务必保存你的重置码(找回密码唯一凭证):\n\n${code}\n\n` + + "请立即复制并妥善保管,点击“确定”后继续进入系统。"; + window.alert(msg); + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(code); + } + } catch { + // 忽略复制失败 + } + } + setStatus("注册成功,正在跳转..."); + window.location.href = nextPath(); + } catch (e) { + setStatus(e.message || "请求异常", true); + } finally { + setLoading(registerBtn, false, "注册", "注册中..."); + } }); } diff --git a/app/static/favicon.svg b/app/static/favicon.svg index 853cef5..749494f 100644 --- a/app/static/favicon.svg +++ b/app/static/favicon.svg @@ -1,4 +1,32 @@ - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/static/settings.js b/app/static/settings.js index 0c1fae6..f1c23a7 100644 --- a/app/static/settings.js +++ b/app/static/settings.js @@ -43,7 +43,7 @@ function renderAccounts(me) { if (!list.length) { const opt = document.createElement("option"); opt.value = ""; - opt.textContent = "暂无公众号,请先绑定"; + opt.textContent = "未绑定公众号"; sel.appendChild(opt); return; } @@ -56,16 +56,44 @@ function renderAccounts(me) { }); } +function renderModels(me) { + const sel = $("modelSelect"); + if (!sel) return; + const list = Array.isArray(me.ai_models) ? me.ai_models : []; + const active = me.active_ai_model && me.active_ai_model.id ? Number(me.active_ai_model.id) : 0; + sel.innerHTML = ""; + if (!list.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "暂无模型配置,请先新增"; + sel.appendChild(opt); + return; + } + list.forEach((m) => { + const opt = document.createElement("option"); + opt.value = String(m.id); + opt.textContent = `${m.model_name} (${m.model})`; + if ((active && m.id === active) || m.active) opt.selected = true; + sel.appendChild(opt); + }); +} + async function refresh() { const me = await authMe(); if (!me) return; renderAccounts(me); + renderModels(me); } const accountSelect = $("accountSelect"); const bindBtn = $("bindBtn"); +const deleteWechatBtn = $("deleteWechatBtn"); const logoutBtn = $("logoutBtn"); const changePwdBtn = $("changePwdBtn"); +const deleteAccountBtn = $("deleteAccountBtn"); +const modelSelect = $("modelSelect"); +const saveModelBtn = $("saveModelBtn"); +const deleteModelBtn = $("deleteModelBtn"); if (accountSelect) { accountSelect.addEventListener("change", async () => { @@ -85,6 +113,32 @@ if (accountSelect) { }); } +if (deleteWechatBtn) { + deleteWechatBtn.addEventListener("click", async () => { + const id = Number((accountSelect && accountSelect.value) || 0); + if (!id) { + setStatus("请先选择要删除的公众号", true); + return; + } + const sure = window.confirm("确定删除当前公众号绑定吗?删除后不可恢复。"); + if (!sure) return; + setLoading(deleteWechatBtn, true, "删除当前公众号", "删除中..."); + try { + const out = await postJSON("/api/auth/wechat/delete", { account_id: id }); + if (!out.ok) { + setStatus(out.detail || "删除失败", true); + return; + } + setStatus("公众号账号已删除。"); + await refresh(); + } catch (e) { + setStatus(e.message || "删除失败", true); + } finally { + setLoading(deleteWechatBtn, false, "删除当前公众号", "删除中..."); + } + }); +} + if (bindBtn) { bindBtn.addEventListener("click", async () => { setLoading(bindBtn, true, "绑定并设为当前账号", "绑定中..."); @@ -113,6 +167,79 @@ if (bindBtn) { }); } +if (modelSelect) { + modelSelect.addEventListener("change", async () => { + const id = Number(modelSelect.value || 0); + if (!id) return; + try { + const out = await postJSON("/api/auth/ai-models/switch", { model_id: id }); + if (!out.ok) { + setStatus(out.detail || "模型切换失败", true); + return; + } + setStatus("已切换当前模型。"); + await refresh(); + } catch (e) { + setStatus(e.message || "模型切换失败", true); + } + }); +} + +if (saveModelBtn) { + saveModelBtn.addEventListener("click", async () => { + setLoading(saveModelBtn, true, "保存并设为当前模型", "保存中..."); + try { + const out = await postJSON("/api/auth/ai-models/add", { + model_name: ($("modelName") && $("modelName").value.trim()) || "", + api_key: ($("apiKey") && $("apiKey").value.trim()) || "", + base_url: ($("baseUrl") && $("baseUrl").value.trim()) || "", + model: ($("modelValue") && $("modelValue").value.trim()) || "", + timeout_sec: Number((($("timeoutSec") && $("timeoutSec").value) || "120").trim()), + max_output_tokens: Number((($("maxOutputTokens") && $("maxOutputTokens").value) || "8192").trim()), + max_retries: Number((($("maxRetries") && $("maxRetries").value) || "0").trim()), + }); + if (!out.ok) { + setStatus(out.detail || "模型保存失败", true); + return; + } + setStatus("模型配置已保存并设为当前。"); + if ($("apiKey")) $("apiKey").value = ""; + if ($("modelName")) $("modelName").value = ""; + await refresh(); + } catch (e) { + setStatus(e.message || "模型保存失败", true); + } finally { + setLoading(saveModelBtn, false, "保存并设为当前模型", "保存中..."); + } + }); +} + +if (deleteModelBtn) { + deleteModelBtn.addEventListener("click", async () => { + const id = Number((modelSelect && modelSelect.value) || 0); + if (!id) { + setStatus("请先选择要删除的模型", true); + return; + } + const sure = window.confirm("确定删除当前模型配置吗?删除后不可恢复。"); + if (!sure) return; + setLoading(deleteModelBtn, true, "删除当前模型", "删除中..."); + try { + const out = await postJSON("/api/auth/ai-models/delete", { model_id: id }); + if (!out.ok) { + setStatus(out.detail || "模型删除失败", true); + return; + } + setStatus("模型配置已删除。"); + await refresh(); + } catch (e) { + setStatus(e.message || "模型删除失败", true); + } finally { + setLoading(deleteModelBtn, false, "删除当前模型", "删除中..."); + } + }); +} + if (logoutBtn) { logoutBtn.addEventListener("click", async () => { setLoading(logoutBtn, true, "退出登录", "退出中..."); @@ -150,4 +277,45 @@ if (changePwdBtn) { }); } +if (deleteAccountBtn) { + deleteAccountBtn.addEventListener("click", async () => { + const pwd = ($("deletePassword") && $("deletePassword").value) || ""; + const rkey = ($("deleteResetKey") && $("deleteResetKey").value.trim()) || ""; + if (!pwd) { + setStatus("请输入注销校验密码", true); + return; + } + if (!rkey) { + setStatus("请输入注销校验重置码", true); + return; + } + const sure = window.confirm("确定注销账户吗?将清空此账号所有业务数据,操作不可恢复。"); + if (!sure) return; + const confirmText = window.prompt("为防止误删,请输入「注销账户」后确认:", ""); + if ((confirmText || "").trim() !== "注销账户") { + setStatus("二次确认未通过,已取消注销。", true); + return; + } + setLoading(deleteAccountBtn, true, "注销账户", "注销中..."); + try { + const out = await postJSON("/api/auth/account/delete", { + password: pwd, + reset_key: rkey, + }); + if (!out.ok) { + setStatus(out.detail || "注销失败", true); + return; + } + setStatus("账号已注销,正在返回登录页。"); + window.setTimeout(() => { + window.location.href = "/auth?next=/"; + }, 900); + } catch (e) { + setStatus(e.message || "注销失败", true); + } finally { + setLoading(deleteAccountBtn, false, "注销账户", "注销中..."); + } + }); +} + refresh(); diff --git a/app/static/style.css b/app/static/style.css index b2c65e0..eb0b489 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -1,155 +1,604 @@ :root { - --bg: #f8fafc; - --panel: #ffffff; - --line: #e2e8f0; - --text: #1e293b; - --muted: #64748b; - --accent: #2563eb; - --accent-2: #1d4ed8; - --accent-soft: #eff6ff; + color-scheme: light; + --bg: #fff7ed; + --surface: #ffffff; + --surface-2: #fffaf3; + --surface-3: #fff1dc; + --line: #ead7bf; + --line-strong: #dfbf98; + --text: #2d1f17; + --muted: #7d6552; + --faint: #a38b76; + --accent: #ff7a1a; + --accent-2: #d9560b; + --accent-soft: #fff0dc; + --warn: #b45309; + --danger: #b42318; + --shadow: 0 22px 60px rgba(144, 73, 15, 0.13); + --shadow-soft: 0 10px 28px rgba(144, 73, 15, 0.09); + --radius: 8px; + --control-h: 36px; } * { box-sizing: border-box; } +[hidden] { + display: none !important; +} + +html { + min-width: 320px; +} + body { margin: 0; - background: var(--bg); + background: + radial-gradient(circle at 18% 8%, rgba(255, 203, 128, 0.32), transparent 260px), + radial-gradient(circle at 85% 18%, rgba(255, 122, 26, 0.12), transparent 320px), + linear-gradient(180deg, rgba(255, 255, 255, 0.78) 0, rgba(255, 247, 237, 0) 280px), + var(--bg); color: var(--text); font-family: Inter, "PingFang SC", "Noto Sans SC", "Microsoft YaHei", sans-serif; + min-height: 100vh; + letter-spacing: 0; +} + +body:not(.simple-page) { height: 100vh; overflow: hidden; } -body.simple-page { - height: auto; +button, +input, +select, +textarea { + font: inherit; +} + +a { + color: inherit; +} + +.product-shell { min-height: 100vh; - overflow: auto; + display: grid; + grid-template-columns: 232px minmax(0, 1fr); +} + +.side-nav { + background: + radial-gradient(circle at 50% 0%, rgba(255, 210, 143, 0.24), transparent 180px), + linear-gradient(180deg, #3b2417 0%, #25160f 100%); + color: #fffaf0; + padding: 14px 12px; + display: flex; + flex-direction: column; + gap: 18px; + min-height: 0; +} + +.side-brand { + padding: 4px 4px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.12); +} + +.brand-lockup { + display: flex; + align-items: center; + gap: 11px; + min-width: 0; +} + +.logo-mark { + width: 38px; + height: 38px; + flex: 0 0 auto; + border-radius: 10px; + box-shadow: 0 12px 26px rgba(133, 55, 10, 0.24); +} + +.side-brand h1 { + margin: 0; + font-size: 21px; + line-height: 1.2; + font-weight: 800; +} + +.side-brand p { + margin: 8px 0 0; + color: #aeb8c7; + font-size: 12px; + line-height: 1.55; +} + +.nav-group { + display: grid; + gap: 6px; +} + +.nav-label { + padding: 0 10px; + color: #c9a98a; + font-size: 11px; + font-weight: 800; + text-transform: uppercase; +} + +.nav-item { + display: flex; + align-items: center; + gap: 10px; + padding: 10px; + border-radius: var(--radius); + color: #f0d9bf; + text-decoration: none; + font-size: 14px; + font-weight: 700; +} + +.nav-item::before { + content: ""; + width: 8px; + height: 8px; + border-radius: 50%; + background: #9f7b5e; +} + +.nav-item.is-active { + color: #fff8ec; + background: rgba(255, 122, 26, 0.18); +} + +.nav-item.is-active::before { + background: #ffad42; + box-shadow: 0 0 0 4px rgba(255, 173, 66, 0.2); +} + +.side-footer { + margin-top: auto; + padding: 12px; + border-radius: var(--radius); + background: rgba(255, 222, 176, 0.12); + color: #ead0b2; + font-size: 12px; + line-height: 1.55; +} + +.workspace { + min-width: 0; + height: 100vh; + overflow: hidden; + display: grid; + grid-template-rows: auto minmax(0, 1fr); } .topbar { - max-width: 1240px; - height: 72px; - margin: 0 auto; - padding: 0 20px; + min-height: 62px; + padding: 10px 16px; display: flex; justify-content: space-between; align-items: center; + gap: 20px; + border-bottom: 1px solid var(--line); + background: rgba(255, 252, 247, 0.88); + backdrop-filter: blur(14px); } +.topbar-compact { + min-height: 44px; + padding-top: 4px; + padding-bottom: 4px; + justify-content: flex-end; +} + +.page-title h2, .brand h1 { margin: 0; - font-size: 32px; - letter-spacing: -0.02em; + font-size: 20px; + line-height: 1.2; + font-weight: 850; } +.page-title p, .brand .muted { - margin: 6px 0 0; -} - -.badge { + margin: 2px 0 0; font-size: 12px; - font-weight: 700; - color: #fafafa; - background: #334155; - border: 1px solid #334155; - padding: 5px 10px; - border-radius: 999px; } .topbar-actions { display: flex; align-items: center; - gap: 8px; - flex-wrap: wrap; justify-content: flex-end; + gap: 10px; + flex-wrap: wrap; } .wechat-account-switch { - display: flex; + min-width: 250px; + display: grid; + grid-template-columns: auto minmax(150px, 1fr); align-items: center; - gap: 8px; - flex-wrap: wrap; - max-width: min(420px, 100%); + gap: 4px 8px; + padding: 4px 8px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.86); } .wechat-account-label { - font-size: 13px; - font-weight: 600; + margin: 0; color: var(--muted); + font-size: 12px; + font-weight: 750; white-space: nowrap; } -.topbar-select { - min-width: 160px; - max-width: 260px; - flex: 1 1 auto; - font: inherit; - font-size: 13px; - font-weight: 600; - color: var(--text); - border: 1px solid #cbd5e1; - border-radius: 10px; - padding: 8px 10px; - background: #fff; - cursor: pointer; -} - -.topbar-select:disabled { - opacity: 0.65; - cursor: not-allowed; -} - .wechat-account-status { - max-width: 140px; + grid-column: 1 / -1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + min-height: 14px; +} + +.wechat-account-status:empty { + display: none; +} + +.topbar-select { + min-width: 0; + width: 100%; + padding: 0; + border: 0; + border-radius: 0; + color: var(--text); + background: transparent; + font-size: 13px; + font-weight: 750; +} + +.topbar-select:focus { + box-shadow: none; +} + +.layout { + min-height: 0; + padding: 10px 14px 12px; + display: grid; + grid-template-columns: minmax(360px, 0.78fr) minmax(460px, 1.22fr); + grid-template-rows: auto minmax(0, 1fr); + gap: 10px; + overflow: hidden; +} + +.workflow-strip { + grid-column: 1 / -1; + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 10px; +} + +.metric-card { + min-width: 0; + padding: 12px 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: rgba(255, 255, 255, 0.84); + box-shadow: var(--shadow-soft); +} + +.metric-label { + color: var(--muted); + font-size: 12px; + font-weight: 750; +} + +.metric-value { + margin-top: 6px; + overflow: hidden; + color: var(--text); + text-overflow: ellipsis; + white-space: nowrap; + font-size: 16px; + font-weight: 850; +} + +.panel, +.simple-panel { + min-height: 0; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface); + box-shadow: var(--shadow); +} + +.panel { + overflow: hidden; + display: flex; + flex-direction: column; +} + +.panel-scroll { + min-height: 0; + overflow: auto; + padding: 10px; +} + +.input-panel .panel-scroll { + overflow: visible; + padding: 8px; +} + +.input-panel .form-section { + padding: 6px 0 8px; +} + +.panel-head { + padding: 10px 10px 8px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, #fff, #fbfcfe); +} + +.panel-head h2 { + margin: 0; + font-size: 16px; + line-height: 1.2; +} + +.panel-head p { + margin: 2px 0 0; +} + +.form-section { + padding: 8px 0 10px; + border-bottom: 1px solid var(--line); +} + +.form-section:first-child { + padding-top: 0; +} + +.form-section:last-child { + border-bottom: 0; + padding-bottom: 0; +} + +.section-kicker { + margin: 0 0 6px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + color: var(--muted); + font-size: 12px; + font-weight: 850; +} + +.section-kicker::before { + content: ""; + width: 7px; + height: 22px; + border-radius: 999px; + background: var(--accent); +} + +.section-kicker span:first-child { + margin-right: auto; +} + +.muted { + color: var(--muted); +} + +.small { + margin: 0 0 6px; + font-size: 12px; +} + +.meta { + color: var(--faint); + font-size: 12px; + font-weight: 700; +} + +label { + display: block; + margin: 6px 0 3px; + color: #344054; + font-size: 12px; + font-weight: 750; +} + +.field-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; +} + +.field-head label { + min-width: 0; +} + +input, +select, +textarea, +button { + width: 100%; + border: 1px solid var(--line-strong); + border-radius: var(--radius); + padding: 7px 9px; + color: var(--text); + background: #fff; + font-size: 13px; + min-height: var(--control-h); + transition: + border-color 0.18s ease, + box-shadow 0.18s ease, + background-color 0.18s ease, + transform 0.18s ease; +} + +textarea { + resize: vertical; + line-height: 1.6; + min-height: 90px; +} + +input::placeholder, +textarea::placeholder { + color: #9aa4b2; +} + +input:focus, +select:focus, +textarea:focus { + outline: none; + border-color: var(--accent); + box-shadow: 0 0 0 3px rgba(255, 122, 26, 0.16); +} + +button { + margin-top: 6px; + cursor: pointer; + font-weight: 850; + line-height: 1.2; +} + +button:hover { + transform: translateY(-1px); +} + +button:disabled { + cursor: not-allowed; + opacity: 0.62; + transform: none; +} + +button.primary { + border-color: var(--accent); + color: #fff; + background: linear-gradient(180deg, #ff922e, #ff6b16); + box-shadow: 0 12px 24px rgba(255, 107, 22, 0.24); +} + +button.primary:hover { + background: var(--accent-2); +} + +button.danger { + border-color: #dc2626; + color: #fff; + background: #dc2626; + box-shadow: 0 10px 20px rgba(220, 38, 38, 0.2); +} + +button.danger:hover { + background: #b91c1c; +} + +button.secondary, +.subtle-btn { + border-color: var(--line-strong); + color: var(--text); + background: #fff; +} + +button.secondary:hover, +.subtle-btn:hover, +.subtle-link:hover { + background: var(--surface-3); } .topbar-btn { width: auto; margin-top: 0; white-space: nowrap; + min-height: var(--control-h); +} + +.icon-btn { + width: var(--control-h); + min-width: var(--control-h); + min-height: var(--control-h); + height: var(--control-h); + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + border: 1px solid var(--line-strong); + border-radius: var(--radius); + background: #fff; + color: var(--text); + text-decoration: none; + font-size: 15px; + font-weight: 800; + line-height: 1; + cursor: pointer; +} + +.icon-btn:hover { + background: var(--surface-3); + transform: translateY(-1px); } .subtle-link { - display: inline-block; - text-decoration: none; - color: var(--accent-2); - border: 1px solid #cbd5e1; - border-radius: 10px; + display: inline-flex; + align-items: center; + justify-content: center; + min-height: var(--control-h); padding: 8px 12px; + border: 1px solid var(--line-strong); + border-radius: var(--radius); + color: var(--text); background: #fff; + text-decoration: none; font-size: 13px; - font-weight: 700; + font-weight: 850; + transition: + background-color 0.18s ease, + transform 0.18s ease; } -.simple-wrap { - max-width: 760px; - margin: 48px auto; - padding: 0 20px; +.subtle-link:hover { + transform: translateY(-1px); } -.simple-panel { - overflow: visible; +.grid2 { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; } -.section-title { - margin: 16px 0 4px; - font-size: 16px; +.actions { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + align-items: stretch; +} + +.actions > button, +.actions > .subtle-link { + min-height: var(--control-h); } .actions-inline { display: flex; - gap: 8px; - align-items: end; justify-content: flex-end; + align-items: stretch; + gap: 8px; + flex-wrap: wrap; +} + +.actions-inline > button, +.actions-inline > .subtle-link { + min-height: var(--control-h); } .check-row { - margin-top: 10px; + margin-top: 8px; display: flex; justify-content: space-between; align-items: center; @@ -162,96 +611,43 @@ body.simple-page { display: inline-flex; align-items: center; gap: 8px; - font-size: 13px; - font-weight: 600; color: var(--muted); + font-size: 13px; + font-weight: 750; } -.check-label input[type="checkbox"] { +.check-label input[type="checkbox"], +.multi-dropdown-option input[type="checkbox"] { width: 16px; height: 16px; -} - -.layout { - max-width: 1240px; - height: calc(100vh - 72px); - margin: 0 auto; - padding: 0 20px; - display: grid; - grid-template-columns: minmax(320px, 42%) 1fr; - gap: 12px; - overflow: hidden; -} - -.panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 12px; - padding: 14px; - box-shadow: 0 6px 18px rgba(15, 23, 42, 0.05); - min-height: 0; - overflow: hidden; -} - -h1, -h2 { - margin-top: 0; -} - -.panel-head { - margin-bottom: 10px; -} - -.panel-head h2 { margin: 0; - font-size: 18px; -} - -.muted { - color: var(--muted); - margin-top: 2px; -} - -label { - display: block; - margin-top: 8px; - margin-bottom: 4px; - font-size: 13px; - font-weight: 600; -} - -.field-head { - display: flex; - justify-content: space-between; - align-items: baseline; -} - -.multi-field .field-head label { - margin-top: 0; + accent-color: var(--accent); } .multi-field { min-width: 0; } +.multi-field .field-head label { + margin-top: 0; +} + .multi-dropdown { + position: relative; width: 100%; } .multi-dropdown > summary { list-style: none; cursor: pointer; - border: 1px solid var(--line); - border-radius: 10px; - padding: 8px 10px; - font-size: 13px; - font-weight: 600; + border: 1px solid var(--line-strong); + border-radius: var(--radius); + padding: 7px 9px; background: #fff; display: flex; justify-content: space-between; align-items: center; gap: 8px; - transition: border-color 0.2s ease, box-shadow 0.2s ease; } .multi-dropdown > summary::-webkit-details-marker { @@ -260,302 +656,244 @@ label { .multi-dropdown > summary::after { content: ""; - width: 0; - height: 0; - border-left: 5px solid transparent; - border-right: 5px solid transparent; - border-top: 6px solid var(--muted); + width: 8px; + height: 8px; + border-right: 2px solid var(--faint); + border-bottom: 2px solid var(--faint); + transform: rotate(45deg) translateY(-2px); flex-shrink: 0; - transition: transform 0.15s ease; -} - -.multi-dropdown[open] > summary::after { - transform: rotate(180deg); } .multi-dropdown[open] > summary { border-color: var(--accent); - box-shadow: 0 0 0 3px var(--accent-soft); + box-shadow: 0 0 0 3px rgba(255, 122, 26, 0.16); +} + +.multi-dropdown[open] > summary::after { + transform: rotate(225deg) translateY(-1px); } .multi-dropdown-text { flex: 1; min-width: 0; overflow: hidden; + color: var(--text); text-overflow: ellipsis; white-space: nowrap; - font-weight: 600; - color: var(--text); + font-size: 14px; + font-weight: 700; } .multi-dropdown-body { + position: absolute; + z-index: 20; + width: 100%; margin-top: 4px; border: 1px solid var(--line); - border-radius: 10px; - padding: 6px 4px; - background: var(--panel); - max-height: 200px; + border-radius: var(--radius); + padding: 6px; + background: var(--surface); + max-height: 180px; overflow-y: auto; - box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); + box-shadow: 0 18px 42px rgba(15, 23, 42, 0.16); } .multi-dropdown-option { + width: auto; + margin: 0; + padding: 6px 8px; display: flex; align-items: center; gap: 8px; - padding: 8px 10px; - font-size: 13px; - font-weight: 500; + border-radius: 6px; cursor: pointer; - margin: 0; - border-radius: 8px; - width: auto; + font-size: 13px; + font-weight: 650; } .multi-dropdown-option:hover { background: var(--accent-soft); } -.multi-dropdown-option input { - width: auto; - margin: 0; - flex-shrink: 0; - accent-color: var(--accent); -} - .multi-extra { margin-top: 4px; } .target-chars-inline { display: flex; - align-items: center; + align-items: stretch; + flex-direction: column; gap: 8px; min-width: 0; } .target-chars-inline #targetBodyChars { - width: 110px; - flex: 0 0 110px; + width: 100%; + flex: 0 0 auto; } .target-chars-quick { display: flex; + align-items: center; gap: 6px; - margin-top: 0; - flex-wrap: nowrap; - max-width: 100%; min-width: 0; - overflow-x: auto; - overflow-y: hidden; - scrollbar-width: thin; + flex-wrap: wrap; + overflow: visible; } -.target-char-chip { +.target-char-chip, +button.target-char-chip { width: auto; + min-width: 54px; margin-top: 0; - padding: 4px 8px; - border-radius: 999px; - border: 1px solid #cbd5e1; - background: #fff; - color: #334155; - font-size: 12px; - font-weight: 700; - line-height: 1.5; + padding: 5px 8px; flex: 0 0 auto; -} - -.target-char-chip:hover { - background: #f8fafc; + border-radius: 999px; + border-color: var(--line-strong); + color: #344054; + background: #fff; + font-size: 12px; + line-height: 1.2; + box-shadow: none; } .target-char-chip.is-active { + border-color: var(--accent); + color: var(--accent-2); + background: var(--accent-soft); +} + +.cover-tools { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 8px; + align-items: center; +} + +.cover-mode-switch { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; + margin-bottom: 8px; +} + +.cover-mode-btn { + margin-top: 0; + min-height: 34px; + border: 1px solid var(--line-strong); + background: #fff; + color: var(--text); +} + +.cover-mode-btn.is-active { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-2); } -.target-chars-block { - width: 100%; +.cover-ai-box { + display: grid; + gap: 10px; + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); } -.meta { +.cover-ai-copy { + display: grid; + gap: 3px; +} + +.cover-ai-copy strong { + color: var(--text); + font-size: 13px; +} + +.cover-ai-copy span { color: var(--muted); font-size: 12px; -} - -input, -select, -textarea, -button { - width: 100%; - border-radius: 10px; - border: 1px solid var(--line); - padding: 8px 10px; - font-size: 13px; - transition: border-color 0.2s ease, box-shadow 0.2s ease, background-color 0.2s ease; -} - -textarea { - resize: vertical; line-height: 1.5; } -input:focus, -select:focus, -textarea:focus { - outline: none; - border-color: #93c5fd; - box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12); -} - -button { - cursor: pointer; - margin-top: 8px; - font-weight: 700; -} - -button:hover { - filter: brightness(0.98); -} - -button.primary { - background: var(--accent); - color: #fff; - border-color: var(--accent); -} - -button.primary:hover { - background: var(--accent-2); -} - -button.secondary { - background: #fff; - color: var(--text); - border-color: var(--line); -} - -button.secondary:hover { - background: #f8fdff; -} - -.subtle-btn { - background: #fff; - border-color: #cbd5e1; - color: var(--accent-2); -} - -button:disabled { - cursor: not-allowed; - opacity: 0.65; -} - -/* 覆盖全局 button 宽度,确保快捷字数按钮始终横向紧凑排列 */ -.target-chars-quick { - display: flex; - flex-wrap: nowrap; - align-items: center; -} - -.target-chars-quick .target-char-chip, -button.target-char-chip { - width: auto; - min-width: 56px; +.cover-auto-check { margin-top: 0; - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; } -.actions { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; +.cover-preview-wrap { + width: 100%; + max-width: 480px; } -.grid2 { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 10px; +.cover-preview { + display: block; + width: 100%; + aspect-ratio: 900 / 383; + object-fit: cover; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + box-shadow: var(--shadow-soft); } -.cover-tools { - display: grid; - grid-template-columns: 1fr auto; - gap: 8px; - align-items: center; -} - -.cover-tools button { - margin-top: 0; +.cover-tools button, +.poster-actions-row button { width: auto; + margin-top: 0; white-space: nowrap; } -.status { - min-height: 18px; - margin-top: 6px; - color: var(--accent-2); - font-weight: 500; - font-size: 12px; -} - -.small { - font-size: 12px; - margin: 0 0 6px; -} - .body-split { display: grid; - grid-template-columns: 1fr 0.9fr; - gap: 10px; + grid-template-columns: minmax(0, 1fr) minmax(260px, 0.92fr); + gap: 12px; align-items: stretch; min-height: 0; } .body-split textarea { - min-height: 170px; - max-height: 240px; + min-height: 180px; + max-height: 250px; } .preview-panel { + min-width: 0; display: flex; flex-direction: column; - min-width: 0; } .markdown-preview { flex: 1; - min-height: 170px; - max-height: 240px; + min-height: 180px; + max-height: 250px; overflow: auto; padding: 10px 12px; border: 1px solid var(--line); - border-radius: 10px; - background: #fafcfb; + border-radius: var(--radius); + background: #fffdf8; + color: #253044; font-size: 14px; - line-height: 1.65; + line-height: 1.7; } .markdown-preview h2 { - font-size: 1.15rem; margin: 1em 0 0.5em; color: #111827; + font-size: 1.14rem; } .markdown-preview h3 { - font-size: 1.05rem; margin: 0.9em 0 0.4em; + font-size: 1.03rem; } .markdown-preview p { - margin: 0.5em 0; + margin: 0.55em 0; } .markdown-preview ul, .markdown-preview ol { - margin: 0.4em 0 0.6em 1.2em; + margin: 0.45em 0 0.65em 1.2em; padding: 0; } @@ -563,34 +901,906 @@ button.target-char-chip { margin: 0.25em 0; } -@media (max-width: 960px) { - body { - overflow: auto; +.poster-tools { + margin-top: 8px; + padding: 8px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); +} + +.poster-actions-row { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 8px; + align-items: center; +} + +.poster-auto-check { + justify-content: flex-start; +} + +.poster-preview-list { + display: grid; + grid-template-columns: 1fr; + gap: 8px; + max-height: 170px; + overflow: auto; + padding-right: 2px; +} + +.poster-card { + border: 1px solid var(--line); + border-radius: var(--radius); + padding: 8px; + display: grid; + grid-template-columns: 76px minmax(0, 1fr); + gap: 10px; + align-items: center; + background: #fff; +} + +.poster-thumb { + width: 76px; + height: 106px; + object-fit: cover; + border: 1px solid #dbe5f3; + border-radius: 6px; + background: var(--surface-3); +} + +.poster-meta { + min-width: 0; +} + +.poster-topline { + color: var(--accent-2); + font-size: 12px; + font-weight: 850; +} + +.poster-excerpt { + margin: 5px 0 0; + color: #475569; + font-size: 12px; + line-height: 1.45; +} + +.poster-link { + margin-top: 6px; + display: inline-block; + color: var(--accent-2); + text-decoration: none; + font-size: 12px; + font-weight: 800; +} + +.poster-link:hover { + text-decoration: underline; +} + +.status { + min-height: 20px; + margin: 6px 0 0; + color: var(--accent-2); + font-size: 13px; + font-weight: 750; +} + +.publish-actions { + position: sticky; + bottom: 0; + margin: 0 -16px -16px; + padding: 8px 10px; + border-top: 1px solid var(--line); + background: rgba(255, 255, 255, 0.92); + backdrop-filter: blur(10px); +} + +.simple-page { + overflow: auto; +} + +.simple-wrap { + width: min(1040px, calc(100% - 36px)); + margin: 34px auto; +} + +.simple-panel { + overflow: hidden; +} + +.simple-head { + padding: 22px 24px; + border-bottom: 1px solid var(--line); + background: linear-gradient(180deg, #fff, #f8fafc); +} + +.simple-brand-lockup .logo-mark { + width: 42px; + height: 42px; + box-shadow: 0 10px 24px rgba(19, 31, 53, 0.14); +} + +.simple-head h1, +.simple-head h2 { + margin: 0; + font-size: 24px; + line-height: 1.2; +} + +.simple-head p { + margin: 8px 0 0; +} + +.simple-body { + padding: 20px 24px 24px; + background: #fffaf3; +} + +.settings-grid { + display: grid; + grid-template-columns: 220px minmax(0, 1fr); + gap: 18px; +} + +.settings-nav { + padding: 12px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); + align-self: start; +} + +.settings-nav a, +.settings-nav span { + display: block; + padding: 10px; + border-radius: 6px; + color: var(--muted); + text-decoration: none; + font-size: 13px; + font-weight: 800; +} + +.settings-nav span { + color: var(--text); + background: var(--accent-soft); + box-shadow: 0 1px 0 rgba(15, 23, 42, 0.05); +} + +.settings-content { + min-width: 0; + display: grid; + gap: 14px; +} + +.settings-section { + padding: 0; + border-bottom: 0; +} + +.settings-section:last-child { + padding-bottom: 0; + border-bottom: 0; +} + +.settings-card { + padding: 14px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + box-shadow: var(--shadow-soft); +} + +.settings-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); +} + +.settings-panel { + min-height: 0; +} + +.settings-panel-scroll { + padding: 12px; +} + +.guide-layout { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); +} + +.guide-panel { + min-height: 0; +} + +.guide-scroll { + padding: 14px; +} + +.guide-hero { + display: flex; + justify-content: space-between; + align-items: flex-end; + gap: 18px; + padding: 18px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: linear-gradient(135deg, #ffffff 0%, #fff0dc 100%); + box-shadow: var(--shadow-soft); +} + +.guide-eyebrow { + margin: 0 0 8px; + color: var(--accent-2); + font-size: 12px; + font-weight: 850; +} + +.guide-hero h2 { + margin: 0; + font-size: 26px; + line-height: 1.2; +} + +.guide-hero p { + max-width: 680px; + margin: 8px 0 0; + line-height: 1.65; +} + +.guide-hero-actions { + display: flex; + gap: 10px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.guide-grid { + margin-top: 14px; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 12px; +} + +.guide-card, +.guide-checklist, +.guide-faq { + border: 1px solid var(--line); + border-radius: var(--radius); + background: #fff; + box-shadow: var(--shadow-soft); +} + +.guide-card { + padding: 14px; + display: flex; + flex-direction: column; + min-height: 210px; + opacity: 0; + transform: translateY(8px); + animation: guideCardIn 0.45s ease forwards; +} + +.guide-grid .guide-card:nth-child(1) { + animation-delay: 0.05s; +} +.guide-grid .guide-card:nth-child(2) { + animation-delay: 0.12s; +} +.guide-grid .guide-card:nth-child(3) { + animation-delay: 0.19s; +} +.guide-grid .guide-card:nth-child(4) { + animation-delay: 0.26s; +} +.guide-grid .guide-card:nth-child(5) { + animation-delay: 0.33s; +} +.guide-grid .guide-card:nth-child(6) { + animation-delay: 0.4s; +} + +.guide-step { + width: 42px; + height: 30px; + display: inline-flex; + align-items: center; + justify-content: center; + border-radius: 999px; + color: var(--accent-2); + background: var(--accent-soft); + font-size: 12px; + font-weight: 900; + animation: guidePulse 2.2s ease-in-out infinite; +} + +@keyframes guideCardIn { + from { + opacity: 0; + transform: translateY(8px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes guidePulse { + 0%, + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 122, 26, 0.16); + } + 50% { + transform: scale(1.05); + box-shadow: 0 0 0 8px rgba(255, 122, 26, 0); + } +} + +.guide-card h3, +.guide-section-head h3 { + margin: 12px 0 8px; + font-size: 16px; +} + +.guide-card p { + margin: 0; + color: var(--muted); + font-size: 13px; + line-height: 1.65; +} + +.guide-link { + margin-top: auto; + padding-top: 12px; + color: var(--accent-2); + text-decoration: none; + font-size: 13px; + font-weight: 850; +} + +.guide-link:hover { + text-decoration: underline; +} + +.guide-checklist, +.guide-faq { + margin-top: 14px; + padding: 16px; +} + +.guide-section-head { + display: flex; + justify-content: space-between; + align-items: baseline; + gap: 12px; + margin-bottom: 10px; +} + +.guide-section-head h3 { + margin: 0; +} + +.checklist-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.checklist-grid .check-label { + padding: 10px; + border: 1px solid var(--line); + border-radius: var(--radius); + background: var(--surface-2); +} + +.guide-faq details { + border-top: 1px solid var(--line); + padding: 12px 0; +} + +.guide-faq details:first-of-type { + border-top: 0; +} + +.guide-faq summary { + cursor: pointer; + color: var(--text); + font-weight: 850; +} + +.guide-faq p { + margin: 8px 0 0; + color: var(--muted); + font-size: 13px; + line-height: 1.65; +} + +.section-title { + margin: 0 0 12px; + font-size: 16px; +} + +.auth-card { + width: min(720px, calc(100% - 36px)); + margin: 8vh auto 0; +} + +.auth-page { + height: 100vh; + overflow: hidden; + background: + radial-gradient(circle at 18% 18%, rgba(255, 183, 77, 0.34), transparent 300px), + radial-gradient(circle at 78% 12%, rgba(255, 122, 26, 0.2), transparent 360px), + linear-gradient(135deg, #fffaf2 0%, #fff1df 52%, #ffe2bd 100%); +} + +.auth-shell { + width: min(1180px, calc(100% - 36px)); + height: calc(100vh - 40px); + max-height: 760px; + margin: 20px auto; + display: grid; + grid-template-columns: minmax(0, 1.08fr) 430px; + gap: 18px; + align-items: stretch; +} + +.auth-showcase, +.auth-panel { + border: 1px solid rgba(223, 191, 152, 0.78); + border-radius: 24px; + background: rgba(255, 255, 255, 0.72); + box-shadow: 0 26px 80px rgba(144, 73, 15, 0.14); + backdrop-filter: blur(18px); +} + +.auth-showcase { + position: relative; + overflow: hidden; + padding: 26px; + display: flex; + flex-direction: column; + justify-content: space-between; + gap: 18px; +} + +.auth-showcase::before { + content: ""; + position: absolute; + inset: auto -90px -110px auto; + width: 280px; + height: 280px; + border-radius: 50%; + background: radial-gradient(circle, rgba(255, 122, 26, 0.3), rgba(255, 122, 26, 0)); + pointer-events: none; +} + +.auth-brand-lockup { + position: relative; + z-index: 1; +} + +.auth-logo { + width: 54px; + height: 54px; + border-radius: 16px; +} + +.auth-brand-lockup h1 { + margin: 0; + font-size: 24px; + line-height: 1.15; +} + +.auth-brand-lockup p { + margin: 6px 0 0; + color: var(--muted); + font-size: 13px; +} + +.auth-hero-copy { + position: relative; + z-index: 1; + max-width: 660px; +} + +.auth-kicker { + margin: 0 0 10px; + color: var(--accent-2); + font-size: 12px; + font-weight: 900; + letter-spacing: 0.08em; + text-transform: uppercase; +} + +.auth-hero-copy h2 { + margin: 0; + color: #2d1f17; + font-size: clamp(34px, 4.5vw, 52px); + line-height: 1.06; + letter-spacing: 0; +} + +.auth-hero-copy p:last-child { + max-width: 540px; + margin: 12px 0 0; + color: var(--muted); + font-size: 15px; + line-height: 1.6; +} + +.auth-preview-card { + position: relative; + z-index: 1; + max-width: 620px; + border: 1px solid rgba(223, 191, 152, 0.88); + border-radius: 18px; + background: rgba(255, 255, 255, 0.76); + box-shadow: 0 22px 54px rgba(144, 73, 15, 0.16); + overflow: hidden; +} + +.auth-preview-top { + height: 42px; + padding: 0 16px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--line); + background: rgba(255, 250, 243, 0.86); +} + +.auth-preview-top span { + width: 10px; + height: 10px; + border-radius: 50%; + background: #ffad42; +} + +.auth-preview-top span:nth-child(2) { + background: #ff7a1a; +} + +.auth-preview-top span:nth-child(3) { + background: #e33d18; +} + +.auth-preview-body { + padding: 14px; + display: grid; + gap: 12px; +} + +.auth-preview-cover { + min-height: 112px; + display: grid; + grid-template-columns: 82px minmax(0, 1fr); + gap: 14px; + align-items: center; + padding: 14px; + border-radius: 16px; + background: linear-gradient(135deg, #ffb23f, #ff6b16); + color: #fff; +} + +.auth-preview-cover img { + width: 74px; + height: 74px; + border-radius: 20px; + box-shadow: 0 16px 32px rgba(114, 45, 6, 0.24); +} + +.auth-preview-cover strong { + display: block; + font-size: 22px; +} + +.auth-preview-cover p { + margin: 8px 0 0; + color: rgba(255, 255, 255, 0.84); +} + +.auth-preview-lines { + display: grid; + gap: 10px; +} + +.auth-preview-lines i { + display: block; + height: 12px; + border-radius: 999px; + background: #f2dec4; +} + +.auth-preview-lines i:nth-child(2) { + width: 78%; +} + +.auth-preview-lines i:nth-child(3) { + width: 58%; +} + +.auth-preview-steps { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 10px; +} + +.auth-preview-steps span { + padding: 10px; + border-radius: 12px; + background: var(--surface-2); + color: var(--accent-2); + text-align: center; + font-size: 13px; + font-weight: 850; +} + +.auth-feature-row { + position: relative; + z-index: 1; + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 10px; +} + +.auth-feature-row div { + padding: 12px; + border: 1px solid rgba(223, 191, 152, 0.76); + border-radius: 16px; + background: rgba(255, 255, 255, 0.62); +} + +.auth-feature-row strong, +.auth-feature-row span { + display: block; +} + +.auth-feature-row strong { + color: var(--text); + font-size: 14px; +} + +.auth-feature-row span { + margin-top: 4px; + color: var(--muted); + font-size: 12px; + line-height: 1.5; +} + +.auth-panel { + padding: 28px; + align-self: center; +} + +.auth-panel-head h2 { + margin: 0; + font-size: 28px; +} + +.auth-panel-head p { + margin: 8px 0 0; + color: var(--muted); + line-height: 1.7; +} + +.auth-form { + margin-top: 22px; + display: grid; + gap: 12px; +} + +.auth-form input { + min-height: 46px; + border-radius: 14px; + padding: 11px 13px; + background: rgba(255, 255, 255, 0.88); +} + +.auth-form .actions { + margin-top: 4px; +} + +.auth-form .actions button { + min-height: 46px; + border-radius: 14px; +} + +.auth-link { + color: var(--accent-2); + text-decoration: none; + font-size: 13px; + font-weight: 850; +} + +.auth-link:hover { + text-decoration: underline; +} + +.auth-footnote { + margin-top: 18px; + padding: 12px; + border-radius: 14px; + background: var(--accent-soft); + color: var(--muted); + font-size: 12px; + line-height: 1.6; +} + +@media (max-width: 1180px) { + .product-shell { + grid-template-columns: 200px minmax(0, 1fr); } .layout { grid-template-columns: 1fr; - height: auto; + overflow: auto; } - .body-split { + body:not(.simple-page), + .workspace { + height: auto; + min-height: 100vh; + overflow: auto; + } + + .panel { + min-height: 0; + } + + .auth-shell { grid-template-columns: 1fr; + height: auto; + max-height: none; + } + + .auth-panel { + align-self: stretch; + } + + .auth-page { + height: auto; + min-height: 100vh; + overflow: auto; + } +} + +@media (max-width: 860px) { + .product-shell { + display: block; + } + + .side-nav { + min-height: auto; + display: block; + padding: 14px 18px; + } + + .side-brand { + padding-bottom: 12px; + } + + .nav-group, + .side-footer { + display: none; } .topbar { - align-items: flex-start; - gap: 8px; + padding: 16px 18px; + align-items: stretch; flex-direction: column; } - .topbar-actions { + .topbar-actions, + .wechat-account-switch { + width: 100%; + } + + .layout { + padding: 14px 18px 22px; + } + + .workflow-strip { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + + .grid2, + .body-split, + .settings-grid, + .guide-grid, + .checklist-grid { + grid-template-columns: 1fr; + } + + .guide-hero { + align-items: flex-start; + flex-direction: column; + } + + .guide-hero-actions { + justify-content: flex-start; + } + + .cover-tools, + .poster-actions-row { + grid-template-columns: 1fr; + } + + .cover-tools button, + .poster-actions-row button { width: 100%; - justify-content: space-between; } .actions-inline { justify-content: flex-start; - align-items: center; - margin-top: 8px; + } + + .auth-shell { + width: calc(100% - 28px); + margin: 14px auto; + } + + .auth-showcase, + .auth-panel { + border-radius: 18px; + padding: 22px; + } + + .auth-feature-row, + .auth-preview-steps { + grid-template-columns: 1fr; + } + + .auth-preview-cover { + grid-template-columns: 72px minmax(0, 1fr); + } + + .auth-preview-cover img { + width: 68px; + height: 68px; + } +} + +@media (max-width: 560px) { + .workflow-strip, + .actions { + grid-template-columns: 1fr; + } + + .target-chars-inline { + align-items: stretch; + flex-direction: column; + } + + .target-chars-inline #targetBodyChars { + width: 100%; + flex-basis: auto; + } + + .simple-wrap, + .auth-card { + width: calc(100% - 24px); + margin-top: 18px; + } + + .simple-head, + .simple-body { + padding: 18px; + } + + .auth-showcase, + .auth-panel { + padding: 18px; + } + + .auth-hero-copy h2 { + font-size: 32px; + } + + .auth-form .actions { + grid-template-columns: 1fr; } } diff --git a/app/templates/auth.html b/app/templates/auth.html index 5bded71..72aeaa5 100644 --- a/app/templates/auth.html +++ b/app/templates/auth.html @@ -4,38 +4,90 @@ {{ app_name }} - 登录注册 - - + + - -
-
-

登录 / 注册

-

登录后将跳转到编辑主页。

+ +
+
+
+ +
+

{{ app_name }}

+

公众号内容工作台

+
+
-
+
+

AI Content Studio

+

从原文到草稿,一气呵成

+

改写、封面、发布,集中完成。

+
+ +
+
+
+ +
+ 今日选题 +

自动生成公众号封面

+
+
+
+ 改写 + 封面 + 发布 +
+
+
+ +
+
+ AI 改写 + 标题、摘要、正文一次成稿 +
+
+ 封面生成 + 按标题自动生成头图 +
+
+ 草稿发布 + 直达公众号草稿箱 +
+
+
+ +
+
+

欢迎回来

+

登录后继续创作。

+
+ +
- +
- +
-
-
- - 忘记密码? +
+ + 忘记密码? +
+ +
+ + +
+

-
- - -
-

+
首次使用建议先完成公众号与模型配置。
diff --git a/app/templates/forgot_password.html b/app/templates/forgot_password.html index 58d3319..e35d98d 100644 --- a/app/templates/forgot_password.html +++ b/app/templates/forgot_password.html @@ -4,36 +4,44 @@ {{ app_name }} - 忘记密码 - - + + -
+
-

忘记密码

+
+
+ +

重置密码

+
+

使用注册时保存的个人重置码恢复账号访问。

+
-
-
- - +
+
+
+ + +
+
+ + +
- - + + +
+

重置码仅在注册时展示一次,请妥善保存。

+
+ +
+

+ -
-
- - -
-

请向管理员获取重置码。若未改配置,默认重置码为 x2ws-reset-2026(建议尽快修改)。

-
- -
-

-
diff --git a/app/templates/guide.html b/app/templates/guide.html new file mode 100644 index 0000000..7658d77 --- /dev/null +++ b/app/templates/guide.html @@ -0,0 +1,134 @@ + + + + + + {{ app_name }} - 新手引导 + + + + +
+ + +
+
+
+ + +
+
+ +
+
+
+
+
+

新手引导

+

从空账号到第一篇公众号草稿

+

按下面顺序完成配置、生成、检查和发布。每一步都对应当前项目里的真实页面和按钮。

+
+ +
+ +
+
+
01
+

准备发布账号

+

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

+ 打开账号设置 +
+ +
+
02
+

配置 AI 模型

+

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

+ 打开模型配置 +
+ +
+
03
+

输入原文与策略

+

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

+ 进入写作输入 +
+ +
+
04
+

生成并人工复核

+

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

+ 查看发布内容 +
+ +
+
05
+

补齐封面和海报

+

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

+ 处理内容素材 +
+ +
+
06
+

发布到草稿箱

+

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

+ 回到发布动作 +
+
+ +
+
+

发布前检查

+

适合每次出稿前快速扫一遍。

+
+
+ + + + + + +
+
+ +
+
+

常见问题

+

这些是新账号最常遇到的卡点。

+
+
+ 提示未绑定公众号怎么办? +

进入账号与模型设置,新增公众号并设为当前账号。绑定后回到内容生产页,顶部发表主体会显示当前账号。

+
+
+ 模型不可用或生成失败怎么办? +

检查 API Key、Base URL、模型名、超时秒数和输出 token 上限。第三方兼容接口通常需要填写完整 Base URL。

+
+
+ 发布成功后在哪里继续编辑? +

内容会进入公众号草稿箱。最终标题、封面、排版和群发前预览,建议在公众号后台完成最后确认。

+
+
+
+
+
+
+
+ + diff --git a/app/templates/index.html b/app/templates/index.html index 2d9df86..bb68e51 100644 --- a/app/templates/index.html +++ b/app/templates/index.html @@ -4,42 +4,59 @@ {{ app_name }} - - + + -
-
-

{{ app_name }}

-

从原文到公众号草稿,一页完成编辑、封面和发布。

-
-
-
+ + + + +
+
+
+ + ? + + +
+
-

内容输入

+

写作输入

+
+
0 字
- +
- +
@@ -59,10 +76,12 @@
- +
+
+
@@ -83,23 +102,23 @@
- +
- +
- +
- +
@@ -109,9 +128,13 @@
+
+

+
+
@@ -119,6 +142,8 @@

发布内容

+
+
@@ -127,38 +152,93 @@ 0 字
+
- -
- - -
- -

未上传时将使用后端默认封面策略。

- +
- + + 900×383 横版头图 +
+
+ + +
+ +
+ +
+ + +
+
+ + +
+
+ +

当前为手动上传模式。

+
+ +
+
+ 0 字
- +
- 实时同步 + 实时
+
-
+
+
+
+ + 自动上传 +
+
+ + +
+

改写后可生成段落海报。

+
+
+
+ +
+
+ + - + diff --git a/app/templates/settings.html b/app/templates/settings.html index d1e4aad..678b13f 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -3,65 +3,151 @@ - {{ app_name }} - 公众号设置 - - + {{ app_name }} - 账号与模型设置 + + - -
-
-
-

公众号设置

-

支持绑定多个公众号并切换当前发布账号。

+ +
+ -
-
- - +
+
+
+ + ? +
-
- 返回主页 - -
-
+ -

新增公众号

-
-
- - -
-
- - -
-
-
- - -
- +
+
+
+
+
+
+ + +
-

账号安全

-
-
- - -
-
- - -
-
-
- 忘记密码提示 - -
-

-
-
+

新增公众号

+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
- +
+

AI 模型配置

+
+
+ + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+ +
+ +
+

账号安全

+
+
+ + +
+
+ + +
+
+
+ 忘记密码提示 + +
+
+
+ + +
+
+ + +
+
+ +

+
+ + + +
+ + + diff --git a/readme.md b/readme.md index 57a5a01..c5b8e6c 100644 --- a/readme.md +++ b/readme.md @@ -1,4 +1,4 @@ -# X2WeChat Studio +# AI发糕 把 X 上的优质文章快速改写为公众号可发布版本,并支持同步推送到 IM。 @@ -6,8 +6,6 @@ ```bash cp .env.example .env -# 填写 .env 中的 OPENAI / 微信 / IM 参数 - docker compose up --build ``` @@ -22,23 +20,18 @@ docker compose up --build 3. 点击 `发布到公众号草稿箱`。 4. 可选点击 `发送到 IM` 同步到团队群。 -## 3. 环境变量说明 +## 3. 配置说明 -- `OPENAI_API_KEY`:AI 改写能力。 -- `OPENAI_BASE_URL`:可选,兼容第三方网关。 -- `OPENAI_MODEL`:默认 `gpt-4.1-mini`。 -- `WECHAT_APPID` / `WECHAT_SECRET`:公众号发布必填。 -- `WECHAT_AUTHOR`:草稿默认作者名。 +- `AI 模型配置`、`公众号 AppID/Secret`:由用户在“账号与模型”页面录入,不再依赖 `.env`。 - `IM_WEBHOOK_URL`:IM 推送地址(飞书/Slack/企微等)。 - `IM_SECRET`:可选签名。 - `AUTH_DB_PATH`:账号数据库文件路径(SQLite)。 - `AUTH_SESSION_TTL_SEC`:普通登录会话时长(秒)。 - `AUTH_REMEMBER_SESSION_TTL_SEC`:勾选“限时免登”时的会话时长(秒)。 -- `AUTH_PASSWORD_RESET_KEY`:忘记密码重置码(用于“用户名+重置码”找回,默认 `x2ws-reset-2026`,建议改掉)。 ## 4. 说明 -- 未配置 `OPENAI_API_KEY` 时,系统会使用本地降级改写模板,便于你先跑通流程。 +- 未配置用户级 AI 模型时,改写接口会提示先去“账号与模型”页面完成配置。 - 建议发布前人工复核事实与引用,避免版权和失真风险。 - 登录页支持“限时免登”,设置页支持修改密码;忘记密码页支持通过“用户名 + 重置码”重置密码。 diff --git a/start.sh b/start.sh index fd61106..02b0a31 100755 --- a/start.sh +++ b/start.sh @@ -24,7 +24,7 @@ else exit 1 fi -echo "Starting X2WeChat Studio..." +echo "Starting AI发糕..." $COMPOSE_CMD up --build -d echo "Service is starting in background."