登录 / 注册
+登录后将跳转到编辑主页。
+ +diff --git a/.env.example b/.env.example index 231e863..a6d4057 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,12 @@ WECHAT_AUTHOR=AI 编辑部 IM_WEBHOOK_URL= # 若 webhook 需要签名可填 IM_SECRET= + +# 账号数据 SQLite 文件(建议放在容器挂载目录,如 /app/data/app.db) +AUTH_DB_PATH=./data/app.db +# 普通登录有效期(秒),默认 1 天 +AUTH_SESSION_TTL_SEC=86400 +# 勾选“限时免登”后的有效期(秒),默认 7 天 +AUTH_REMEMBER_SESSION_TTL_SEC=604800 +# 忘记密码重置码(建议自定义;为空时将使用默认值 x2ws-reset-2026) +AUTH_PASSWORD_RESET_KEY=x2ws-reset-2026 diff --git a/.gitignore b/.gitignore index 06c00d0..9a690d5 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc .pytest_cache/ +data/* +!data/.gitkeep diff --git a/app/config.py b/app/config.py index 86eba6b..b7bee0e 100644 --- a/app/config.py +++ b/app/config.py @@ -48,5 +48,11 @@ class Settings(BaseSettings): im_webhook_url: str | None = Field(default=None, alias="IM_WEBHOOK_URL") im_secret: str | None = Field(default=None, alias="IM_SECRET") + auth_db_path: str = Field(default="./data/app.db", alias="AUTH_DB_PATH") + auth_cookie_name: str = Field(default="x2ws_session", alias="AUTH_COOKIE_NAME") + auth_session_ttl_sec: int = Field(default=86400, alias="AUTH_SESSION_TTL_SEC") + auth_remember_session_ttl_sec: int = Field(default=604800, alias="AUTH_REMEMBER_SESSION_TTL_SEC") + auth_password_reset_key: str | None = Field(default="x2ws-reset-2026", alias="AUTH_PASSWORD_RESET_KEY") + settings = Settings() diff --git a/app/main.py b/app/main.py index 5eb9b31..4bfc500 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,10 @@ from __future__ import annotations +import hmac import logging from urllib.parse import urlparse -from fastapi import FastAPI, File, Request, UploadFile +from fastapi import FastAPI, File, Request, Response, UploadFile from fastapi.responses import HTMLResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates @@ -11,9 +12,19 @@ from fastapi.templating import Jinja2Templates from app.config import settings from app.logging_setup import configure_logging from app.middleware import RequestContextMiddleware -from app.schemas import IMPublishRequest, RewriteRequest, WechatPublishRequest +from app.schemas import ( + AuthCredentialRequest, + ChangePasswordRequest, + ForgotPasswordResetRequest, + IMPublishRequest, + RewriteRequest, + WechatBindingRequest, + WechatPublishRequest, + WechatSwitchRequest, +) from app.services.ai_rewriter import AIRewriter from app.services.im import IMPublisher +from app.services.user_store import UserStore from app.services.wechat import WechatPublisher configure_logging() @@ -39,13 +50,52 @@ templates = Jinja2Templates(directory="app/templates") rewriter = AIRewriter() wechat = WechatPublisher() im = IMPublisher() +users = UserStore(settings.auth_db_path) + + +def _session_ttl(remember_me: bool) -> int: + normal = max(600, int(settings.auth_session_ttl_sec)) + remembered = max(normal, int(settings.auth_remember_session_ttl_sec)) + return remembered if remember_me else normal + + +def _current_user(request: Request) -> dict | None: + token = request.cookies.get(settings.auth_cookie_name, "") + return users.get_user_by_session(token) if token else None + + +def _require_user(request: Request) -> dict | None: + u = _current_user(request) + if not u: + return None + return u @app.get("/", response_class=HTMLResponse) async def index(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/", status_code=302) return templates.TemplateResponse("index.html", {"request": request, "app_name": settings.app_name}) +@app.get("/auth", response_class=HTMLResponse) +async def auth_page(request: Request): + nxt = (request.query_params.get("next") or "/").strip() or "/" + if _current_user(request): + return RedirectResponse(url=nxt, status_code=302) + return templates.TemplateResponse( + "auth.html", + {"request": request, "app_name": settings.app_name, "next": nxt}, + ) + + +@app.get("/settings", response_class=HTMLResponse) +async def settings_page(request: Request): + if not _current_user(request): + return RedirectResponse(url="/auth?next=/settings", status_code=302) + return templates.TemplateResponse("settings.html", {"request": request, "app_name": settings.app_name}) + + @app.get("/favicon.ico", include_in_schema=False) async def favicon(): # 浏览器通常请求 /favicon.ico,统一跳转到静态图标 @@ -68,6 +118,174 @@ async def api_config(): } +@app.get("/api/auth/me") +async def auth_me(request: Request): + user = _current_user(request) + if not user: + return {"ok": True, "logged_in": False} + binding = users.get_active_wechat_binding(user["id"]) + bindings = users.list_wechat_bindings(user["id"]) + return { + "ok": True, + "logged_in": True, + "user": {"id": user["id"], "username": user["username"]}, + "wechat_bound": bool(binding and binding.get("appid") and binding.get("secret")), + "active_wechat_account": binding, + "wechat_accounts": bindings, + } + + +@app.post("/api/auth/register") +async def auth_register(req: AuthCredentialRequest, response: Response): + username = (req.username or "").strip() + password = req.password or "" + if len(username) < 2: + return {"ok": False, "detail": "用户名至少 2 个字符"} + if len(password) < 6: + return {"ok": False, "detail": "密码至少 6 个字符"} + try: + user = users.create_user(username, password) + except Exception as exc: + logger.exception("auth_register_failed username=%s detail=%s", username, str(exc)) + return {"ok": False, "detail": "注册失败:账号库异常,请稍后重试"} + if not user: + return {"ok": False, "detail": "用户名已存在"} + ttl = _session_ttl(bool(req.remember_me)) + token = users.create_session(user["id"], ttl_seconds=ttl) + response.set_cookie( + key=settings.auth_cookie_name, + value=token, + httponly=True, + samesite="lax", + max_age=ttl, + path="/", + ) + return {"ok": True, "detail": "注册并登录成功", "user": user} + + +@app.post("/api/auth/login") +async def auth_login(req: AuthCredentialRequest, response: Response): + try: + user = users.verify_user((req.username or "").strip(), req.password or "") + except Exception as exc: + logger.exception("auth_login_failed username=%s detail=%s", (req.username or "").strip(), str(exc)) + return {"ok": False, "detail": "登录失败:账号库异常,请稍后重试"} + if not user: + return {"ok": False, "detail": "用户名或密码错误"} + ttl = _session_ttl(bool(req.remember_me)) + token = users.create_session(user["id"], ttl_seconds=ttl) + response.set_cookie( + key=settings.auth_cookie_name, + value=token, + httponly=True, + samesite="lax", + max_age=ttl, + path="/", + ) + return {"ok": True, "detail": "登录成功", "user": user} + + +@app.post("/api/auth/logout") +async def auth_logout(request: Request, response: Response): + 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.get("/auth/forgot", response_class=HTMLResponse) +async def forgot_password_page(request: Request): + return templates.TemplateResponse("forgot_password.html", {"request": request, "app_name": settings.app_name}) + + +@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) + if not ok: + return {"ok": False, "detail": "用户不存在,无法重置"} + return {"ok": True, "detail": "密码重置成功,请返回登录页重新登录"} + + +@app.post("/api/auth/password/change") +async def auth_change_password(req: ChangePasswordRequest, request: Request, response: Response): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + + old_password = req.old_password or "" + new_password = req.new_password or "" + if len(old_password) < 1: + return {"ok": False, "detail": "请输入当前密码"} + if len(new_password) < 6: + return {"ok": False, "detail": "新密码至少 6 个字符"} + if old_password == new_password: + return {"ok": False, "detail": "新密码不能与当前密码相同"} + + ok = users.change_password(user["id"], old_password, new_password) + if not ok: + return {"ok": False, "detail": "当前密码错误,修改失败"} + + users.delete_sessions_by_user(user["id"]) + ttl = _session_ttl(False) + token = users.create_session(user["id"], ttl_seconds=ttl) + response.set_cookie( + key=settings.auth_cookie_name, + value=token, + httponly=True, + samesite="lax", + max_age=ttl, + path="/", + ) + return {"ok": True, "detail": "密码修改成功,已刷新登录状态"} + + +@app.post("/api/auth/wechat/bind") +async def auth_wechat_bind(req: WechatBindingRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + appid = (req.appid or "").strip() + secret = (req.secret or "").strip() + if not appid or not secret: + return {"ok": False, "detail": "appid/secret 不能为空"} + created = users.add_wechat_binding( + user_id=user["id"], + account_name=(req.account_name or "").strip() or "公众号账号", + appid=appid, + secret=secret, + author=(req.author or "").strip(), + thumb_media_id=(req.thumb_media_id or "").strip(), + thumb_image_path=(req.thumb_image_path or "").strip(), + ) + return {"ok": True, "detail": "公众号账号绑定成功", "account": created} + + +@app.post("/api/auth/wechat/switch") +async def auth_wechat_switch(req: WechatSwitchRequest, request: Request): + user = _require_user(request) + if not user: + return {"ok": False, "detail": "请先登录"} + ok = users.switch_active_wechat_binding(user["id"], int(req.account_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", "") @@ -99,6 +317,12 @@ async def rewrite(req: RewriteRequest, request: Request): @app.post("/api/publish/wechat") async def publish_wechat(req: WechatPublishRequest, 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", "") logger.info( "api_wechat_in rid=%s title_chars=%d summary_chars=%d body_md_chars=%d author_set=%s", @@ -108,7 +332,7 @@ async def publish_wechat(req: WechatPublishRequest, request: Request): len(req.body_markdown or ""), bool((req.author or "").strip()), ) - out = await wechat.publish_draft(req, request_id=rid) + out = await wechat.publish_draft(req, request_id=rid, account=binding) wcode = (out.data or {}).get("errcode") if isinstance(out.data, dict) else None logger.info( "api_wechat_out rid=%s ok=%s wechat_errcode=%s detail_preview=%s", @@ -122,11 +346,17 @@ async def publish_wechat(req: WechatPublishRequest, request: Request): @app.post("/api/wechat/cover/upload") async def upload_wechat_cover(request: Request, file: UploadFile = File(...)): + 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", "") fn = file.filename or "cover.jpg" content = await file.read() logger.info("api_wechat_cover_upload_in rid=%s filename=%s bytes=%d", rid, fn, len(content)) - out = await wechat.upload_cover(fn, content, request_id=rid) + out = await wechat.upload_cover(fn, content, request_id=rid, account=binding) logger.info( "api_wechat_cover_upload_out rid=%s ok=%s detail=%s", rid, @@ -136,6 +366,28 @@ async def upload_wechat_cover(request: Request, file: UploadFile = File(...)): return out +@app.post("/api/wechat/material/upload") +async def upload_wechat_material(request: Request, file: UploadFile = File(...)): + 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", "") + fn = file.filename or "material.jpg" + content = await file.read() + logger.info("api_wechat_material_upload_in rid=%s filename=%s bytes=%d", rid, fn, len(content)) + out = await wechat.upload_body_material(fn, content, request_id=rid, account=binding) + logger.info( + "api_wechat_material_upload_out rid=%s ok=%s detail=%s", + rid, + out.ok, + (out.detail or "")[:160], + ) + 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 82f6ed0..e1eb183 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -6,6 +6,7 @@ from pydantic import BaseModel, Field class RewriteRequest(BaseModel): source_text: str = Field(..., min_length=20) title_hint: str = "" + writing_style: str = "科普解读" tone: str = "专业、可信、可读性强" audience: str = "公众号读者" keep_points: str = "" @@ -41,3 +42,33 @@ class PublishResponse(BaseModel): ok: bool detail: str data: dict | None = None + + +class AuthCredentialRequest(BaseModel): + username: str + password: str + remember_me: bool = False + + +class ChangePasswordRequest(BaseModel): + old_password: str + new_password: str + + +class ForgotPasswordResetRequest(BaseModel): + username: str + reset_key: str + new_password: str + + +class WechatBindingRequest(BaseModel): + account_name: str = "" + appid: str + secret: str + author: str = "" + thumb_media_id: str = "" + thumb_image_path: str = "" + + +class WechatSwitchRequest(BaseModel): + account_id: int diff --git a/app/services/ai_rewriter.py b/app/services/ai_rewriter.py index c8c9d8f..b0feaf3 100644 --- a/app/services/ai_rewriter.py +++ b/app/services/ai_rewriter.py @@ -56,9 +56,8 @@ SYSTEM_PROMPT = """ 3) 只输出合法 JSON:title, summary, body_markdown; 4) **body_markdown 约束**:按内容密度使用 **4~6 个自然段**;段与段之间用一个空行分隔;**不要**使用 # / ## 标题符号;正文以 **约 500 字**为目标,优先完整表达并避免冗长重复; 5) title、summary 也要短:标题约 8~18 字;摘要约 40~80 字; -6) 正文每段需首行缩进(建议段首使用两个全角空格「 」),避免顶格; -7) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语; -8) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "。 +6) 关键观点需要加粗:请用 Markdown `**加粗**` 标出 2~4 个重点短语; +7) JSON 字符串内引号请用「」或『』,勿用未转义的英文 "。 """.strip() @@ -74,7 +73,6 @@ body_markdown 写法: - 使用 **4~6 段**:每段若干完整句子,段之间 **\\n\\n**(空一行); - **禁止** markdown 标题(不要用 #); - 正文目标约 **500 字**(可上下浮动),以信息完整为先,避免冗长和重复; -- 每段段首请保留首行缩进(两个全角空格「 」); - 请用 `**...**` 加粗 2~4 个关键观点词; - 内容顺序建议:首段交代在说什么;中间段展开关键信息;末段收束或提醒(均须紧扣原文,勿乱发挥)。 """.strip() @@ -319,6 +317,7 @@ class AIRewriter: 用户改写偏好: - 标题参考:{req.title_hint or '自动生成'} +- 写作风格:{req.writing_style} - 语气风格:{req.tone} - 目标读者:{req.audience} - 必须保留观点:{req.keep_points or '无'} @@ -335,6 +334,7 @@ class AIRewriter: 用户改写偏好: - 标题参考:{req.title_hint or '自动生成'} +- 写作风格:{req.writing_style} - 语气风格:{req.tone} - 目标读者:{req.audience} - 必须保留观点:{req.keep_points or '无'} @@ -827,12 +827,6 @@ class AIRewriter: if "**" not in body: issues.append("关键观点未加粗(建议 2~4 处)") - paragraphs = [p.strip() for p in re.split(r"\n\s*\n", body) if p.strip()] - if paragraphs: - no_indent = sum(1 for p in paragraphs if not p.startswith(" ")) - if no_indent >= max(2, len(paragraphs) // 2): - issues.append("正文缺少首行缩进(建议每段段首使用两个全角空格)") - if self._looks_like_raw_copy(source, body, lenient=lenient): issues.append("改写与原文相似度过高,疑似未充分重写") diff --git a/app/services/user_store.py b/app/services/user_store.py new file mode 100644 index 0000000..b65468f --- /dev/null +++ b/app/services/user_store.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import hashlib +import hmac +import secrets +import sqlite3 +import time +from pathlib import Path + + +class UserStore: + def __init__(self, db_path: str) -> None: + self._db_path = db_path + p = Path(db_path) + if p.parent and not p.parent.exists(): + p.parent.mkdir(parents=True, exist_ok=True) + self._init_db() + + def _conn(self) -> sqlite3.Connection: + c = sqlite3.connect(self._db_path) + c.row_factory = sqlite3.Row + return c + + def _init_db(self) -> None: + with self._conn() as c: + self._ensure_users_table(c) + self._ensure_sessions_table(c) + c.execute( + """ + CREATE TABLE IF NOT EXISTS wechat_bindings ( + user_id INTEGER PRIMARY KEY, + appid TEXT NOT NULL, + secret TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + thumb_media_id TEXT NOT NULL DEFAULT '', + thumb_image_path TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) + c.execute( + """ + CREATE TABLE IF NOT EXISTS wechat_accounts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + account_name TEXT NOT NULL, + appid TEXT NOT NULL, + secret TEXT NOT NULL, + author TEXT NOT NULL DEFAULT '', + thumb_media_id TEXT NOT NULL DEFAULT '', + thumb_image_path TEXT NOT NULL DEFAULT '', + updated_at INTEGER NOT NULL, + UNIQUE(user_id, account_name), + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) + c.execute( + """ + CREATE TABLE IF NOT EXISTS user_prefs ( + user_id INTEGER PRIMARY KEY, + active_wechat_account_id INTEGER, + updated_at INTEGER NOT NULL, + 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" + ).fetchall() + for r in rows: + now = int(time.time()) + cur = c.execute( + """ + INSERT OR IGNORE INTO wechat_accounts + (user_id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at) + VALUES (?, '默认公众号', ?, ?, ?, ?, ?, ?) + """, + ( + int(r["user_id"]), + r["appid"] or "", + r["secret"] or "", + r["author"] or "", + r["thumb_media_id"] or "", + r["thumb_image_path"] or "", + int(r["updated_at"] or now), + ), + ) + if cur.rowcount: + aid = int( + c.execute( + "SELECT id FROM wechat_accounts WHERE user_id=? AND account_name='默认公众号'", + (int(r["user_id"]),), + ).fetchone()["id"] + ) + 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=COALESCE(user_prefs.active_wechat_account_id, excluded.active_wechat_account_id), + updated_at=excluded.updated_at + """, + (int(r["user_id"]), aid, now), + ) + + def _table_columns(self, c: sqlite3.Connection, table_name: str) -> set[str]: + rows = c.execute(f"PRAGMA table_info({table_name})").fetchall() + 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"} + c.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """ + ) + cols = self._table_columns(c, "users") + if required.issubset(cols): + return + + now = int(time.time()) + c.execute("PRAGMA foreign_keys=OFF") + c.execute("DROP TABLE IF EXISTS users_new") + c.execute( + """ + CREATE TABLE users_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + username TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + password_salt TEXT NOT NULL, + created_at INTEGER NOT NULL + ) + """ + ) + + if {"username", "password_hash", "password_salt"}.issubset(cols): + if "created_at" in cols: + c.execute( + """ + INSERT OR IGNORE INTO users_new(id, username, password_hash, password_salt, created_at) + SELECT id, username, password_hash, password_salt, COALESCE(created_at, ?) + FROM users + """, + (now,), + ) + 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,), + ) + elif {"username", "password"}.issubset(cols): + if "created_at" in cols: + rows = c.execute("SELECT id, username, password, created_at FROM users").fetchall() + else: + rows = c.execute("SELECT id, username, password FROM users").fetchall() + for r in rows: + username = (r["username"] or "").strip() + raw_pwd = str(r["password"] or "") + if not username or not raw_pwd: + continue + salt = secrets.token_hex(16) + pwd_hash = self._hash_password(raw_pwd, 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 (?, ?, ?, ?, ?) + """, + (int(r["id"]), username, pwd_hash, salt, created_at), + ) + + c.execute("DROP TABLE users") + c.execute("ALTER TABLE users_new RENAME TO users") + c.execute("PRAGMA foreign_keys=ON") + + def _ensure_sessions_table(self, c: sqlite3.Connection) -> None: + required = {"token_hash", "user_id", "expires_at", "created_at"} + c.execute( + """ + CREATE TABLE IF NOT EXISTS sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) + cols = self._table_columns(c, "sessions") + if required.issubset(cols): + return + c.execute("DROP TABLE IF EXISTS sessions") + c.execute( + """ + CREATE TABLE sessions ( + token_hash TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + expires_at INTEGER NOT NULL, + created_at INTEGER NOT NULL, + FOREIGN KEY(user_id) REFERENCES users(id) + ) + """ + ) + + def _hash_password(self, password: str, salt: str) -> str: + data = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt.encode("utf-8"), 120_000) + return data.hex() + + def _hash_token(self, token: str) -> str: + return hashlib.sha256(token.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) + 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), + ) + uid = int(cur.lastrowid) + return {"id": uid, "username": username} + except sqlite3.IntegrityError: + return None + except sqlite3.Error as exc: + raise RuntimeError(f"create_user_db_error: {exc}") from exc + + def verify_user(self, username: str, password: str) -> dict | None: + try: + with self._conn() as c: + row = c.execute( + "SELECT id, username, password_hash, password_salt FROM users WHERE username=?", + (username,), + ).fetchone() + except sqlite3.Error as exc: + raise RuntimeError(f"verify_user_db_error: {exc}") from exc + if not row: + return None + calc = self._hash_password(password, row["password_salt"]) + if not hmac.compare_digest(calc, row["password_hash"]): + return None + return {"id": int(row["id"]), "username": row["username"]} + + def change_password(self, user_id: int, old_password: str, new_password: str) -> bool: + with self._conn() as c: + row = c.execute( + "SELECT password_hash, password_salt FROM users WHERE id=?", + (user_id,), + ).fetchone() + if not row: + return False + calc_old = self._hash_password(old_password, row["password_salt"]) + if not hmac.compare_digest(calc_old, row["password_hash"]): + return False + new_salt = secrets.token_hex(16) + new_hash = self._hash_password(new_password, new_salt) + c.execute( + "UPDATE users SET password_hash=?, password_salt=? WHERE id=?", + (new_hash, new_salt, user_id), + ) + return True + + def reset_password_by_username(self, username: str, new_password: str) -> bool: + uname = (username or "").strip() + if not uname: + return False + with self._conn() as c: + row = c.execute("SELECT id FROM users WHERE username=?", (uname,)).fetchone() + if not row: + return False + new_salt = secrets.token_hex(16) + new_hash = self._hash_password(new_password, new_salt) + c.execute( + "UPDATE users SET password_hash=?, password_salt=? WHERE id=?", + (new_hash, new_salt, int(row["id"])), + ) + return True + + def create_session(self, user_id: int, ttl_seconds: int = 7 * 24 * 3600) -> str: + token = secrets.token_urlsafe(32) + token_hash = self._hash_token(token) + now = int(time.time()) + exp = now + max(600, int(ttl_seconds)) + with self._conn() as c: + c.execute( + "INSERT OR REPLACE INTO sessions(token_hash, user_id, expires_at, created_at) VALUES (?, ?, ?, ?)", + (token_hash, user_id, exp, now), + ) + return token + + def delete_session(self, token: str) -> None: + if not token: + return + with self._conn() as c: + c.execute("DELETE FROM sessions WHERE token_hash=?", (self._hash_token(token),)) + + def delete_sessions_by_user(self, user_id: int) -> None: + with self._conn() as c: + c.execute("DELETE FROM sessions WHERE user_id=?", (user_id,)) + + def get_user_by_session(self, token: str) -> dict | None: + if not token: + return None + now = int(time.time()) + th = self._hash_token(token) + with self._conn() as c: + c.execute("DELETE FROM sessions WHERE expires_at < ?", (now,)) + row = c.execute( + """ + 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>=? + """, + (th, now), + ).fetchone() + if not row: + return None + return {"id": int(row["id"]), "username": row["username"]} + + def save_wechat_binding( + self, + user_id: int, + appid: str, + secret: str, + author: str = "", + thumb_media_id: str = "", + thumb_image_path: str = "", + ) -> None: + now = int(time.time()) + with self._conn() as c: + c.execute( + """ + INSERT INTO wechat_bindings(user_id, appid, secret, author, thumb_media_id, thumb_image_path, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id) DO UPDATE SET + appid=excluded.appid, + secret=excluded.secret, + author=excluded.author, + thumb_media_id=excluded.thumb_media_id, + thumb_image_path=excluded.thumb_image_path, + updated_at=excluded.updated_at + """, + (user_id, appid, secret, author, thumb_media_id, thumb_image_path, now), + ) + + def get_wechat_binding(self, user_id: int) -> dict | None: + return self.get_active_wechat_binding(user_id) + + def list_wechat_bindings(self, user_id: int) -> list[dict]: + with self._conn() as c: + rows = c.execute( + """ + SELECT id, account_name, appid, author, thumb_media_id, thumb_image_path, updated_at + FROM wechat_accounts + WHERE user_id=? + ORDER BY updated_at DESC, id DESC + """, + (user_id,), + ).fetchall() + 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 + out: list[dict] = [] + for r in rows: + out.append( + { + "id": int(r["id"]), + "account_name": r["account_name"] or "", + "appid": r["appid"] or "", + "author": r["author"] or "", + "thumb_media_id": r["thumb_media_id"] or "", + "thumb_image_path": r["thumb_image_path"] or "", + "updated_at": int(r["updated_at"] or 0), + "active": int(r["id"]) == active_id, + } + ) + return out + + def add_wechat_binding( + self, + user_id: int, + account_name: str, + appid: str, + secret: str, + author: str = "", + thumb_media_id: str = "", + thumb_image_path: str = "", + ) -> dict: + now = int(time.time()) + name = account_name.strip() or f"公众号{now % 10000}" + with self._conn() as c: + try: + cur = c.execute( + """ + INSERT INTO wechat_accounts + (user_id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, name, appid, secret, author, thumb_media_id, thumb_image_path, now), + ) + except sqlite3.IntegrityError: + name = f"{name}-{now % 1000}" + cur = c.execute( + """ + INSERT INTO wechat_accounts + (user_id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + """, + (user_id, name, appid, secret, author, thumb_media_id, thumb_image_path, now), + ) + aid = int(cur.lastrowid) + 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, aid, now), + ) + return {"id": aid, "account_name": name} + + def switch_active_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( + """ + 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, account_id, now), + ) + return True + + def get_active_wechat_binding(self, user_id: int) -> dict | None: + with self._conn() as c: + pref = c.execute( + "SELECT active_wechat_account_id FROM user_prefs WHERE user_id=?", + (user_id,), + ).fetchone() + aid = int(pref["active_wechat_account_id"]) if pref and pref["active_wechat_account_id"] else None + row = None + if aid: + row = c.execute( + """ + SELECT id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at + FROM wechat_accounts + WHERE id=? AND user_id=? + """, + (aid, user_id), + ).fetchone() + if not row: + row = c.execute( + """ + SELECT id, account_name, appid, secret, author, thumb_media_id, thumb_image_path, updated_at + FROM wechat_accounts + 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_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, int(row["id"]), int(time.time())), + ) + if not row: + return None + return { + "id": int(row["id"]), + "account_name": row["account_name"] or "", + "appid": row["appid"] or "", + "secret": row["secret"] or "", + "author": row["author"] or "", + "thumb_media_id": row["thumb_media_id"] or "", + "thumb_image_path": row["thumb_image_path"] or "", + "updated_at": int(row["updated_at"] or 0), + } diff --git a/app/services/wechat.py b/app/services/wechat.py index 194bfc0..5325929 100644 --- a/app/services/wechat.py +++ b/app/services/wechat.py @@ -64,17 +64,34 @@ def _detail_for_draft_error(data: dict) -> str: class WechatPublisher: def __init__(self) -> None: - self._access_token = None - self._expires_at = 0 + self._token_cache: dict[str, dict[str, int | str]] = {} self._runtime_thumb_media_id: str | None = None - async def publish_draft(self, req: WechatPublishRequest, request_id: str = "") -> PublishResponse: + def _resolve_account(self, account: dict | None = None) -> dict[str, str]: + src = account or {} + appid = (src.get("appid") or 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() + return { + "appid": appid, + "secret": secret, + "author": author, + "thumb_media_id": thumb_media_id, + "thumb_image_path": thumb_image_path, + } + + async def publish_draft( + self, req: WechatPublishRequest, request_id: str = "", account: dict | None = None + ) -> PublishResponse: rid = request_id or "-" - if not settings.wechat_appid or not settings.wechat_secret: + acct = self._resolve_account(account) + if not acct["appid"] or not acct["secret"]: logger.warning("wechat skipped rid=%s reason=missing_appid_or_secret", rid) return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置") - token, token_from_cache, token_err_body = await self._get_access_token() + token, token_from_cache, token_err_body = await self._get_access_token(acct["appid"], acct["secret"]) if not token: detail = _detail_for_token_error(token_err_body) logger.error("wechat access_token_unavailable rid=%s detail=%s", rid, detail[:200]) @@ -91,7 +108,7 @@ class WechatPublisher: logger.info("wechat_thumb rid=%s source=request_media_id", rid) thumb_id = req_thumb else: - thumb_id = await self._resolve_thumb_media_id(token, rid) + thumb_id = await self._resolve_thumb_media_id(token, rid, account=acct) if not thumb_id: return PublishResponse( ok=False, @@ -115,7 +132,7 @@ class WechatPublisher: { "article_type": "news", "title": req.title[:32] if len(req.title) > 32 else req.title, - "author": (req.author or settings.wechat_author)[:16], + "author": (req.author or acct["author"] or settings.wechat_author)[:16], "digest": (req.summary or "")[:128], "content": html, "content_source_url": "", @@ -126,7 +143,7 @@ class WechatPublisher: ] } - explicit_used = bool((settings.wechat_thumb_media_id or "").strip()) + explicit_used = bool((acct.get("thumb_media_id") or "").strip()) draft_url = f"https://api.weixin.qq.com/cgi-bin/draft/add?access_token={token}" async with httpx.AsyncClient(timeout=25) as client: logger.info( @@ -142,7 +159,9 @@ class WechatPublisher: rid, ) self._runtime_thumb_media_id = None - thumb_alt = await self._resolve_thumb_media_id(token, rid, force_skip_explicit=True) + thumb_alt = await self._resolve_thumb_media_id( + token, rid, force_skip_explicit=True, account=acct + ) if thumb_alt: payload["articles"][0]["thumb_media_id"] = thumb_alt async with httpx.AsyncClient(timeout=25) as client: @@ -167,33 +186,69 @@ class WechatPublisher: ) return PublishResponse(ok=True, detail="已发布到公众号草稿箱", data=data) - async def upload_cover(self, filename: str, content: bytes, request_id: str = "") -> PublishResponse: + async def upload_cover( + self, filename: str, content: bytes, request_id: str = "", account: dict | None = None + ) -> PublishResponse: """上传封面到微信永久素材,返回 thumb_media_id。""" rid = request_id or "-" - if not settings.wechat_appid or not settings.wechat_secret: + acct = self._resolve_account(account) + if not acct["appid"] or not acct["secret"]: 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() + 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: - mid = await self._upload_permanent_image(client, token, content, filename) - if not mid: + material = await self._upload_permanent_image(client, token, content, filename) + if not material: return PublishResponse( ok=False, detail="封面上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed", ) + mid = material["media_id"] self._runtime_thumb_media_id = mid logger.info("wechat_cover_upload_ok rid=%s filename=%s media_id=%s", rid, filename, mid) return PublishResponse(ok=True, detail="封面上传成功", data={"thumb_media_id": mid, "filename": filename}) + async def upload_body_material( + self, filename: str, content: bytes, request_id: str = "", account: dict | None = None + ) -> PublishResponse: + """上传正文图片到微信永久素材库,返回 media_id 与可插入正文的 URL。""" + rid = request_id or "-" + acct = self._resolve_account(account) + if not acct["appid"] or not acct["secret"]: + return PublishResponse(ok=False, detail="缺少 WECHAT_APPID / WECHAT_SECRET 配置") + if not content: + return PublishResponse(ok=False, detail="素材文件为空") + + token, _, token_err_body = await self._get_access_token(acct["appid"], acct["secret"]) + if not token: + return PublishResponse(ok=False, detail=_detail_for_token_error(token_err_body), data=token_err_body) + + async with httpx.AsyncClient(timeout=60) as client: + material = await self._upload_permanent_image(client, token, content, filename) + if not material: + return PublishResponse( + ok=False, + detail="素材上传失败:请检查图片格式/大小,或查看日志中的 wechat_material_add_failed", + ) + + logger.info( + "wechat_body_material_upload_ok rid=%s filename=%s media_id=%s url=%s", + rid, + filename, + material.get("media_id"), + material.get("url"), + ) + return PublishResponse(ok=True, detail="素材上传成功", data=material) + async def _upload_permanent_image( self, client: httpx.AsyncClient, token: str, content: bytes, filename: str - ) -> str | None: + ) -> dict[str, str] | None: url = f"https://api.weixin.qq.com/cgi-bin/material/add_material?access_token={token}&type=image" ctype = "image/png" if filename.lower().endswith(".png") else "image/jpeg" files = {"media": (filename, content, ctype)} @@ -206,11 +261,14 @@ class WechatPublisher: if not mid: logger.warning("wechat_material_add_no_media_id body=%s", data) return None - return mid + return {"media_id": mid, "url": data.get("url") or ""} - async def _resolve_thumb_media_id(self, token: str, rid: str, *, force_skip_explicit: bool = False) -> str | None: + async def _resolve_thumb_media_id( + self, token: str, rid: str, *, force_skip_explicit: bool = False, account: dict | None = None + ) -> str | None: """draft/add 要求 thumb_media_id 为永久图片素材;优先用配置,否则上传文件或内置图。""" - explicit = (settings.wechat_thumb_media_id or "").strip() + acct = self._resolve_account(account) + explicit = (acct.get("thumb_media_id") or "").strip() if explicit and not force_skip_explicit: logger.info("wechat_thumb rid=%s source=config_media_id", rid) return explicit @@ -219,35 +277,40 @@ class WechatPublisher: logger.info("wechat_thumb rid=%s source=runtime_cache", rid) return self._runtime_thumb_media_id - path = (settings.wechat_thumb_image_path or "").strip() + path = (acct.get("thumb_image_path") or "").strip() async with httpx.AsyncClient(timeout=60) as client: if path: p = Path(path) if p.is_file(): content = p.read_bytes() - mid = await self._upload_permanent_image(client, token, content, p.name) - if mid: - self._runtime_thumb_media_id = mid + material = await self._upload_permanent_image(client, token, content, p.name) + if material: + self._runtime_thumb_media_id = material["media_id"] logger.info("wechat_thumb rid=%s source=path_upload ok=1", rid) - return mid + return material["media_id"] logger.warning("wechat_thumb rid=%s source=path_upload ok=0 path=%s", rid, path) else: logger.warning("wechat_thumb rid=%s path_not_found=%s", rid, path) content, fname = _build_default_cover_jpeg() - mid = await self._upload_permanent_image(client, token, content, fname) - if mid: - self._runtime_thumb_media_id = mid + material = await self._upload_permanent_image(client, token, content, fname) + if material: + self._runtime_thumb_media_id = material["media_id"] logger.info("wechat_thumb rid=%s source=default_jpeg_upload ok=1", rid) - return mid + return material["media_id"] logger.error("wechat_thumb rid=%s source=default_jpeg_upload ok=0", rid) return None - async def _get_access_token(self) -> tuple[str | None, bool, dict | None]: + async def _get_access_token(self, appid: str, secret: str) -> tuple[str | None, bool, dict | None]: """成功时第三项为 None;失败时为微信返回的 JSON(含 errcode/errmsg)。""" now = int(time.time()) - if self._access_token and now < self._expires_at - 60: - return self._access_token, True, None + key = appid.strip() + cached = self._token_cache.get(key) + if cached: + token = str(cached.get("token") or "") + exp = int(cached.get("expires_at") or 0) + if token and now < exp - 60: + return token, True, None logger.info("wechat_http_get endpoint=cgi-bin/token reason=refresh_access_token") async with httpx.AsyncClient(timeout=20) as client: @@ -255,8 +318,8 @@ class WechatPublisher: "https://api.weixin.qq.com/cgi-bin/token", params={ "grant_type": "client_credential", - "appid": settings.wechat_appid, - "secret": settings.wechat_secret, + "appid": appid, + "secret": secret, }, ) data = r.json() if r.content else {} @@ -270,6 +333,5 @@ class WechatPublisher: ) return None, False, data if isinstance(data, dict) else None - self._access_token = token - self._expires_at = now + int(data.get("expires_in", 7200)) + self._token_cache[key] = {"token": token, "expires_at": now + int(data.get("expires_in", 7200))} return token, False, None diff --git a/app/static/app.js b/app/static/app.js index caf027c..cfaa368 100644 --- a/app/static/app.js +++ b/app/static/app.js @@ -16,11 +16,45 @@ const rewriteBtn = $("rewriteBtn"); const wechatBtn = $("wechatBtn"); const imBtn = $("imBtn"); const coverUploadBtn = $("coverUploadBtn"); +const logoutBtn = $("logoutBtn"); function countText(v) { return (v || "").trim().length; } +/** 多选子项用顿号拼接,可选补充用分号接在末尾 */ +function buildMultiPrompt(nameAttr, extraId) { + const boxes = document.querySelectorAll(`input[name="${nameAttr}"]:checked`); + const parts = Array.from(boxes).map((b) => (b.value || "").trim()).filter(Boolean); + const extraEl = extraId ? $(extraId) : null; + const extra = extraEl ? (extraEl.value || "").trim() : ""; + let s = parts.join("、"); + if (extra) s = s ? `${s};${extra}` : extra; + return s; +} + +function updateMultiDropdownSummary(nameAttr, summaryId, emptyHint) { + const el = $(summaryId); + if (!el) return; + const parts = Array.from(document.querySelectorAll(`input[name="${nameAttr}"]:checked`)).map((b) => + (b.value || "").trim(), + ); + el.textContent = parts.length ? parts.join("、") : emptyHint; +} + +function initMultiDropdowns() { + const pairs = [ + { name: "audienceChip", summary: "audienceSummary", empty: "点击展开,选择目标读者…" }, + { name: "toneChip", summary: "toneSummary", empty: "点击展开,选择语气风格…" }, + ]; + pairs.forEach(({ name, summary, empty }) => { + updateMultiDropdownSummary(name, summary, empty); + document.querySelectorAll(`input[name="${name}"]`).forEach((cb) => { + cb.addEventListener("change", () => updateMultiDropdownSummary(name, summary, empty)); + }); + }); +} + function updateCounters() { $("sourceCount").textContent = `${countText($("sourceText").value)} 字`; $("summaryCount").textContent = `${countText($("summary").value)} 字`; @@ -51,6 +85,104 @@ async function postJSON(url, body) { return data; } +async function fetchAuthMe() { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (!data.ok || !data.logged_in) { + window.location.href = "/auth?next=/"; + return null; + } + return data; +} + +function renderWechatAccountSelect(me) { + const sel = $("wechatAccountSelect"); + const hint = $("wechatAccountStatus"); + if (!sel) return; + const list = Array.isArray(me.wechat_accounts) ? me.wechat_accounts : []; + const activeId = + me.active_wechat_account && me.active_wechat_account.id + ? Number(me.active_wechat_account.id) + : 0; + sel.innerHTML = ""; + if (!list.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "暂无公众号"; + sel.appendChild(opt); + sel.disabled = true; + if (hint) hint.textContent = "请先在「公众号设置」绑定"; + return; + } + sel.disabled = false; + list.forEach((a) => { + const opt = document.createElement("option"); + opt.value = String(a.id); + const name = (a.account_name || "未命名").trim(); + const appid = (a.appid || "").trim(); + opt.textContent = appid ? `${name} (${appid})` : name; + if ((activeId && Number(a.id) === activeId) || a.active) opt.selected = true; + sel.appendChild(opt); + }); +} + +function flashWechatAccountHint(msg, clearMs = 2600) { + const hint = $("wechatAccountStatus"); + if (!hint) return; + hint.textContent = msg; + if (clearMs > 0) { + window.clearTimeout(flashWechatAccountHint._t); + flashWechatAccountHint._t = window.setTimeout(() => { + hint.textContent = ""; + }, clearMs); + } +} + +const wechatAccountSelect = $("wechatAccountSelect"); +if (wechatAccountSelect) { + wechatAccountSelect.addEventListener("change", async () => { + const id = Number(wechatAccountSelect.value || 0); + if (!id) return; + try { + const out = await postJSON("/api/auth/wechat/switch", { account_id: id }); + if (!out.ok) { + flashWechatAccountHint(out.detail || "切换失败", 4000); + const me = await fetchAuthMe(); + if (me) renderWechatAccountSelect(me); + return; + } + const me = await fetchAuthMe(); + if (me) renderWechatAccountSelect(me); + flashWechatAccountHint("已切换"); + } catch (e) { + flashWechatAccountHint(e.message || "切换失败", 4000); + const me = await fetchAuthMe(); + if (me) renderWechatAccountSelect(me); + } + }); +} + +async function initWechatAccountSwitch() { + const me = await fetchAuthMe(); + if (me) renderWechatAccountSelect(me); +} + +async function logoutAndGoAuth() { + try { + await postJSON("/api/auth/logout", {}); + } catch { + // 忽略退出接口异常,直接跳转认证页 + } + window.location.href = "/auth?next=/"; +} + +if (logoutBtn) { + logoutBtn.addEventListener("click", async () => { + setLoading(logoutBtn, true, "退出登录", "退出中..."); + await logoutAndGoAuth(); + }); +} + $("rewriteBtn").addEventListener("click", async () => { const sourceText = $("sourceText").value.trim(); if (sourceText.length < 20) { @@ -61,11 +193,13 @@ $("rewriteBtn").addEventListener("click", async () => { setStatus("正在改写..."); setLoading(rewriteBtn, true, "改写并排版", "改写中..."); try { + const audience = buildMultiPrompt("audienceChip", "audienceExtra") || "公众号读者"; + const tone = buildMultiPrompt("toneChip", "toneExtra") || "专业、可信、可读性强"; const data = await postJSON("/api/rewrite", { source_text: sourceText, title_hint: $("titleHint").value, - tone: $("tone").value, - audience: $("audience").value, + tone, + audience, keep_points: $("keepPoints").value, avoid_words: $("avoidWords").value, }); @@ -104,6 +238,11 @@ $("wechatBtn").addEventListener("click", async () => { setStatus("公众号草稿发布成功"); } catch (e) { setStatus(`公众号发布失败: ${e.message}`, true); + if ((e.message || "").includes("请先登录")) { + window.location.href = "/auth?next=/"; + } else if ((e.message || "").includes("未绑定公众号")) { + window.location.href = "/settings"; + } } finally { setLoading(wechatBtn, false, "发布到公众号草稿箱", "发布中..."); } @@ -133,6 +272,11 @@ if (coverUploadBtn) { } catch (e) { if (hint) hint.textContent = "封面上传失败,请看状态提示。"; setStatus(`封面上传失败: ${e.message}`, true); + if ((e.message || "").includes("请先登录")) { + window.location.href = "/auth?next=/"; + } else if ((e.message || "").includes("未绑定公众号")) { + window.location.href = "/settings"; + } } finally { setLoading(coverUploadBtn, false, "上传封面并绑定", "上传中..."); } @@ -161,3 +305,5 @@ $("imBtn").addEventListener("click", async () => { }); updateCounters(); +initMultiDropdowns(); +initWechatAccountSwitch(); diff --git a/app/static/auth.js b/app/static/auth.js new file mode 100644 index 0000000..970d520 --- /dev/null +++ b/app/static/auth.js @@ -0,0 +1,71 @@ +const $ = (id) => document.getElementById(id); + +function setStatus(msg, danger = false) { + const el = $("status"); + if (!el) return; + el.style.color = danger ? "#b42318" : "#0f5f3d"; + el.textContent = msg; +} + +function setLoading(button, loading, idleText, loadingText) { + if (!button) return; + button.disabled = loading; + button.textContent = loading ? loadingText : idleText; +} + +async function postJSON(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "请求失败"); + return data; +} + +function nextPath() { + const nxt = (window.__NEXT_PATH__ || "/").trim(); + if (!nxt.startsWith("/")) return "/"; + return nxt; +} + +function fields() { + return { + username: ($("username") && $("username").value.trim()) || "", + password: ($("password") && $("password").value) || "", + remember_me: Boolean($("rememberMe") && $("rememberMe").checked), + }; +} + +async function authAction(url, button, idleText, loadingText, okMessage) { + setLoading(button, true, idleText, loadingText); + try { + const data = await postJSON(url, fields()); + if (!data.ok) { + setStatus(data.detail || "操作失败", true); + return; + } + setStatus(okMessage); + window.location.href = nextPath(); + } catch (e) { + setStatus(e.message || "请求异常", true); + } finally { + setLoading(button, false, idleText, loadingText); + } +} + +const loginBtn = $("loginBtn"); +const registerBtn = $("registerBtn"); + +if (loginBtn) { + loginBtn.addEventListener("click", async () => { + await authAction("/api/auth/login", loginBtn, "登录", "登录中...", "登录成功,正在跳转..."); + }); +} + +if (registerBtn) { + registerBtn.addEventListener("click", async () => { + await authAction("/api/auth/register", registerBtn, "注册", "注册中...", "注册成功,正在跳转..."); + }); +} diff --git a/app/static/forgot_password.js b/app/static/forgot_password.js new file mode 100644 index 0000000..d9f32f7 --- /dev/null +++ b/app/static/forgot_password.js @@ -0,0 +1,52 @@ +const $ = (id) => document.getElementById(id); + +function setStatus(msg, danger = false) { + const el = $("status"); + if (!el) return; + el.style.color = danger ? "#b42318" : "#0f5f3d"; + el.textContent = msg; +} + +function setLoading(button, loading, idleText, loadingText) { + if (!button) return; + button.disabled = loading; + button.textContent = loading ? loadingText : idleText; +} + +async function postJSON(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "请求失败"); + return data; +} + +const resetBtn = $("resetBtn"); + +if (resetBtn) { + resetBtn.addEventListener("click", async () => { + setLoading(resetBtn, true, "重置密码", "提交中..."); + try { + const out = await postJSON("/api/auth/password/forgot", { + username: ($("username") && $("username").value.trim()) || "", + reset_key: ($("resetKey") && $("resetKey").value.trim()) || "", + new_password: ($("newPassword") && $("newPassword").value) || "", + }); + if (!out.ok) { + setStatus(out.detail || "重置失败", true); + return; + } + setStatus("密码重置成功,2 秒后跳转登录页。"); + setTimeout(() => { + window.location.href = "/auth?next=/"; + }, 2000); + } catch (e) { + setStatus(e.message || "重置失败", true); + } finally { + setLoading(resetBtn, false, "重置密码", "提交中..."); + } + }); +} diff --git a/app/static/settings.js b/app/static/settings.js new file mode 100644 index 0000000..0c1fae6 --- /dev/null +++ b/app/static/settings.js @@ -0,0 +1,153 @@ +const $ = (id) => document.getElementById(id); + +function setStatus(msg, danger = false) { + const el = $("status"); + if (!el) return; + el.style.color = danger ? "#b42318" : "#0f5f3d"; + el.textContent = msg; +} + +function setLoading(button, loading, idleText, loadingText) { + if (!button) return; + button.disabled = loading; + button.textContent = loading ? loadingText : idleText; +} + +async function postJSON(url, body) { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.detail || "请求失败"); + return data; +} + +async function authMe() { + const res = await fetch("/api/auth/me"); + const data = await res.json(); + if (!data.logged_in) { + window.location.href = "/auth?next=/settings"; + return null; + } + return data; +} + +function renderAccounts(me) { + const sel = $("accountSelect"); + if (!sel) return; + const list = Array.isArray(me.wechat_accounts) ? me.wechat_accounts : []; + const active = me.active_wechat_account && me.active_wechat_account.id ? Number(me.active_wechat_account.id) : 0; + sel.innerHTML = ""; + if (!list.length) { + const opt = document.createElement("option"); + opt.value = ""; + opt.textContent = "暂无公众号,请先绑定"; + sel.appendChild(opt); + return; + } + list.forEach((a) => { + const opt = document.createElement("option"); + opt.value = String(a.id); + opt.textContent = `${a.account_name} (${a.appid})`; + if ((active && a.id === active) || a.active) opt.selected = true; + sel.appendChild(opt); + }); +} + +async function refresh() { + const me = await authMe(); + if (!me) return; + renderAccounts(me); +} + +const accountSelect = $("accountSelect"); +const bindBtn = $("bindBtn"); +const logoutBtn = $("logoutBtn"); +const changePwdBtn = $("changePwdBtn"); + +if (accountSelect) { + accountSelect.addEventListener("change", async () => { + const id = Number(accountSelect.value || 0); + if (!id) return; + try { + const out = await postJSON("/api/auth/wechat/switch", { account_id: id }); + if (!out.ok) { + setStatus(out.detail || "切换失败", true); + return; + } + setStatus("已切换当前公众号。"); + await refresh(); + } catch (e) { + setStatus(e.message || "切换失败", true); + } + }); +} + +if (bindBtn) { + bindBtn.addEventListener("click", async () => { + setLoading(bindBtn, true, "绑定并设为当前账号", "绑定中..."); + try { + const out = await postJSON("/api/auth/wechat/bind", { + account_name: ($("accountName") && $("accountName").value.trim()) || "", + appid: ($("appid") && $("appid").value.trim()) || "", + secret: ($("secret") && $("secret").value.trim()) || "", + author: "", + thumb_media_id: "", + thumb_image_path: "", + }); + if (!out.ok) { + setStatus(out.detail || "绑定失败", true); + return; + } + setStatus("公众号绑定成功,已切换为当前账号。"); + if ($("appid")) $("appid").value = ""; + if ($("secret")) $("secret").value = ""; + await refresh(); + } catch (e) { + setStatus(e.message || "绑定失败", true); + } finally { + setLoading(bindBtn, false, "绑定并设为当前账号", "绑定中..."); + } + }); +} + +if (logoutBtn) { + logoutBtn.addEventListener("click", async () => { + setLoading(logoutBtn, true, "退出登录", "退出中..."); + try { + await postJSON("/api/auth/logout", {}); + window.location.href = "/auth?next=/"; + } catch (e) { + setStatus(e.message || "退出失败", true); + } finally { + setLoading(logoutBtn, false, "退出登录", "退出中..."); + } + }); +} + +if (changePwdBtn) { + changePwdBtn.addEventListener("click", async () => { + setLoading(changePwdBtn, true, "修改密码", "提交中..."); + try { + const out = await postJSON("/api/auth/password/change", { + old_password: ($("oldPassword") && $("oldPassword").value) || "", + new_password: ($("newPassword") && $("newPassword").value) || "", + }); + if (!out.ok) { + setStatus(out.detail || "修改密码失败", true); + return; + } + setStatus("密码修改成功。"); + if ($("oldPassword")) $("oldPassword").value = ""; + if ($("newPassword")) $("newPassword").value = ""; + } catch (e) { + setStatus(e.message || "修改密码失败", true); + } finally { + setLoading(changePwdBtn, false, "修改密码", "提交中..."); + } + }); +} + +refresh(); diff --git a/app/static/style.css b/app/static/style.css index 60c78c6..aa2edab 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -22,6 +22,12 @@ body { overflow: hidden; } +body.simple-page { + height: auto; + min-height: 100vh; + overflow: auto; +} + .topbar { max-width: 1240px; height: 72px; @@ -52,6 +58,120 @@ body { border-radius: 999px; } +.topbar-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.wechat-account-switch { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + max-width: min(420px, 100%); +} + +.wechat-account-label { + font-size: 13px; + font-weight: 600; + color: var(--muted); + 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; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.topbar-btn { + width: auto; + margin-top: 0; + white-space: nowrap; +} + +.subtle-link { + display: inline-block; + text-decoration: none; + color: var(--accent-2); + border: 1px solid #cbd5e1; + border-radius: 10px; + padding: 8px 12px; + background: #fff; + font-size: 13px; + font-weight: 700; +} + +.simple-wrap { + max-width: 760px; + margin: 48px auto; + padding: 0 20px; +} + +.simple-panel { + overflow: visible; +} + +.section-title { + margin: 16px 0 4px; + font-size: 16px; +} + +.actions-inline { + display: flex; + gap: 8px; + align-items: end; + justify-content: flex-end; +} + +.check-row { + margin-top: 10px; + display: flex; + justify-content: space-between; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.check-label { + margin: 0; + display: inline-flex; + align-items: center; + gap: 8px; + font-size: 13px; + font-weight: 600; + color: var(--muted); +} + +.check-label input[type="checkbox"] { + width: 16px; + height: 16px; +} + .layout { max-width: 1240px; height: calc(100vh - 72px); @@ -106,12 +226,114 @@ label { align-items: baseline; } +.multi-field .field-head label { + margin-top: 0; +} + +.multi-field { + min-width: 0; +} + +.multi-dropdown { + 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; + 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 { + display: none; +} + +.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); + 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); +} + +.multi-dropdown-text { + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-weight: 600; + color: var(--text); +} + +.multi-dropdown-body { + margin-top: 4px; + border: 1px solid var(--line); + border-radius: 10px; + padding: 6px 4px; + background: var(--panel); + max-height: 200px; + overflow-y: auto; + box-shadow: 0 10px 28px rgba(15, 23, 42, 0.1); +} + +.multi-dropdown-option { + display: flex; + align-items: center; + gap: 8px; + padding: 8px 10px; + font-size: 13px; + font-weight: 500; + cursor: pointer; + margin: 0; + border-radius: 8px; + width: auto; +} + +.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; +} + .meta { color: var(--muted); font-size: 12px; } input, +select, textarea, button { width: 100%; @@ -128,6 +350,7 @@ textarea { } input:focus, +select:focus, textarea:focus { outline: none; border-color: #93c5fd; @@ -289,4 +512,15 @@ button:disabled { gap: 8px; flex-direction: column; } + + .topbar-actions { + width: 100%; + justify-content: space-between; + } + + .actions-inline { + justify-content: flex-start; + align-items: center; + margin-top: 8px; + } } diff --git a/app/templates/auth.html b/app/templates/auth.html new file mode 100644 index 0000000..5bded71 --- /dev/null +++ b/app/templates/auth.html @@ -0,0 +1,47 @@ + + +
+ + +登录后将跳转到编辑主页。
+ +请向管理员获取重置码。若未改配置,默认重置码为 x2ws-reset-2026(建议尽快修改)。
+从原文到公众号草稿,一页完成编辑、封面和发布。
-