From 87f330850f0da82159ffdac870befcd6cc834b57 Mon Sep 17 00:00:00 2001 From: Gaofeng Date: Wed, 3 Dec 2025 23:49:29 +0800 Subject: [PATCH] =?UTF-8?q?1.=20=E6=99=AE=E9=80=9A=E7=94=A8=E6=88=B7?= =?UTF-8?q?=EF=BC=9A=20=E6=AF=8F=E6=AC=A1=E7=99=BB=E5=BD=95=EF=BC=8C?= =?UTF-8?q?=E5=88=B7=E6=96=B0=E7=95=8C=E9=9D=A2=E6=97=B6=EF=BC=8C=E7=95=8C?= =?UTF-8?q?=E9=9D=A2=E5=BC=B9=E5=87=BA=E5=85=AC=E5=91=8A=E7=AA=97=E5=8F=A3?= =?UTF-8?q?=EF=BC=8C=E5=91=8A=E7=9F=A5=E5=85=B6=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=89=80=E5=8F=91=E5=B8=83=E7=9A=84=E6=9C=80=E6=96=B0=E5=85=AC?= =?UTF-8?q?=E5=91=8A=202.=20=E7=AE=A1=E7=90=86=E5=91=98=E7=94=A8=E6=88=B7?= =?UTF-8?q?=EF=BC=9A=20=E5=9C=A8=E7=99=BB=E5=BD=95=E5=90=8E=E7=9A=84?= =?UTF-8?q?=E7=95=8C=E9=9D=A2=E4=B8=AD=EF=BC=8C=20=E5=8F=AF=E4=BB=A5?= =?UTF-8?q?=E5=8F=91=E5=B8=83=E6=96=B0=E7=9A=84=E5=85=AC=E5=91=8A=EF=BC=8C?= =?UTF-8?q?=E4=B9=9F=E5=8F=AF=E4=BB=A5=E5=AF=B9=E5=B7=B2=E7=BB=8F=E5=8F=91?= =?UTF-8?q?=E5=B8=83=E7=9A=84=E5=85=AC=E5=91=8A=E8=BF=9B=E8=A1=8C=E4=BF=AE?= =?UTF-8?q?=E6=94=B9=EF=BC=8C=E5=88=A0=E9=99=A4=E7=AD=89=E3=80=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/open_webui/main.py | 2 + backend/open_webui/models/announcements.py | 490 ++++++++++++++++++ backend/open_webui/routers/announcements.py | 352 +++++++++++++ src/lib/apis/announcements/index.ts | 263 ++++++++++ src/lib/components/AnnouncementModal.svelte | 109 ++++ src/lib/components/layout/Sidebar.svelte | 9 +- .../components/layout/Sidebar/UserMenu.svelte | 35 ++ src/lib/stores/index.ts | 1 + src/routes/(app)/+layout.svelte | 81 ++- .../(app)/announcements/manage/+page.svelte | 281 ++++++++++ 10 files changed, 1621 insertions(+), 2 deletions(-) create mode 100644 backend/open_webui/models/announcements.py create mode 100644 backend/open_webui/routers/announcements.py create mode 100644 src/lib/apis/announcements/index.ts create mode 100644 src/lib/components/AnnouncementModal.svelte create mode 100644 src/routes/(app)/announcements/manage/+page.svelte diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 899e018b7d..7ee7b247f6 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -78,6 +78,7 @@ from open_webui.routers import ( auths, channels, chats, + announcements, notes, folders, configs, @@ -1330,6 +1331,7 @@ app.include_router( user_models.router, prefix="/api/v1/user/models", tags=["user_models"] ) app.include_router(channels.router, prefix="/api/v1/channels", tags=["channels"]) +app.include_router(announcements.router, prefix="/api/v1/announcements", tags=["announcements"]) app.include_router(chats.router, prefix="/api/v1/chats", tags=["chats"]) app.include_router(notes.router, prefix="/api/v1/notes", tags=["notes"]) diff --git a/backend/open_webui/models/announcements.py b/backend/open_webui/models/announcements.py new file mode 100644 index 0000000000..c01d49037c --- /dev/null +++ b/backend/open_webui/models/announcements.py @@ -0,0 +1,490 @@ +""" +公告系统数据模型模块 + +本模块定义了公告系统的数据库模型和数据访问层。 + +============================================================================== +数据库表结构说明(SQLite DDL) +============================================================================== + +本模块对应的数据库表如下(本项目使用 SQLite): + +1. 公告主表 (announcement) +--------------------------- +CREATE TABLE announcement ( + id TEXT PRIMARY KEY, -- 公告 UUID + title TEXT NOT NULL, -- 公告标题 + content TEXT NOT NULL, -- 公告内容(支持 Markdown) + status VARCHAR(32) NOT NULL DEFAULT 'active', -- 状态:active(激活) / archived(已归档) + created_by TEXT NOT NULL, -- 创建者用户 ID + created_at BIGINT NOT NULL, -- 创建时间(纳秒时间戳) + updated_at BIGINT NOT NULL, -- 更新时间(纳秒时间戳) + meta JSON DEFAULT NULL -- 扩展元数据(JSON 格式) +); + +-- 索引建议 +CREATE INDEX idx_announcement_status ON announcement(status); +CREATE INDEX idx_announcement_created_at ON announcement(created_at DESC); + + +2. 公告阅读记录表 (announcement_read) +-------------------------------------- +CREATE TABLE announcement_read ( + id TEXT PRIMARY KEY, -- 记录 UUID + user_id TEXT NOT NULL, -- 用户 ID + announcement_id TEXT NOT NULL, -- 公告 ID(关联 announcement.id) + read_at BIGINT NOT NULL -- 阅读时间(纳秒时间戳) +); + +-- 索引建议(关键性能优化) +CREATE INDEX idx_announcement_read_user ON announcement_read(user_id); +CREATE INDEX idx_announcement_read_announcement ON announcement_read(announcement_id); +CREATE UNIQUE INDEX idx_announcement_read_unique ON announcement_read(user_id, announcement_id); + + +============================================================================== +数据库表建立流程(实际操作) +============================================================================== + +直接在 SQLite 中执行 DDL(手动建表) +-------------------------------------------- + +sqlite3 backend/data/webui.db + +sqlite> -- 执行上述两个 CREATE TABLE 语句 +sqlite> -- 执行索引创建语句(可选但强烈推荐) + +-- 常用 SQLite 命令: +sqlite> .tables -- 显示所有表 +sqlite> .schema announcement -- 查看表结构 +sqlite> SELECT * FROM announcement LIMIT 10; -- 查询数据 + +============================================================================== +SQLAlchemy 类型映射说明(SQLite) +============================================================================== +Column(Text) → SQLite: TEXT +Column(String(32)) → SQLite: TEXT (SQLite 不强制长度限制) +Column(BigInteger) → SQLite: INTEGER +Column(JSON) → SQLite: TEXT (以 JSON 字符串存储) + +注意事项: +- 时间戳使用 BigInteger 存储纳秒级时间戳(int(time.time_ns())) +- UUID 使用 Text 存储字符串形式(str(uuid.uuid4())) +- JSON 字段存储扩展元数据,便于未来功能扩展 +- 索引设计遵循查询模式:status 过滤、created_at 排序、user_id 查询 +""" + +import time +import uuid +from typing import Optional + +from pydantic import BaseModel, ConfigDict +from sqlalchemy import BigInteger, Column, String, Text, JSON + +from open_webui.internal.db import Base, get_db + + +############################################################################## +# SQLAlchemy 数据库模型 (ORM Models) +############################################################################## + +class Announcement(Base): + """ + 公告主表 - 存储系统公告内容 + + 对应数据库表:announcement + + 字段说明: + - id: 公告唯一标识 (UUID) + - title: 公告标题 + - content: 公告内容(支持 Markdown) + - status: 公告状态(active: 激活, archived: 已归档) + - created_by: 创建者用户 ID + - created_at: 创建时间(纳秒时间戳) + - updated_at: 更新时间(纳秒时间戳) + - meta: 扩展元数据(JSON 格式,用于存储额外配置) + """ + __tablename__ = "announcement" + + id = Column(Text, primary_key=True) + title = Column(Text, nullable=False) + content = Column(Text, nullable=False) + status = Column(String(32), nullable=False, default="active") + created_by = Column(Text, nullable=False) + created_at = Column(BigInteger, nullable=False) + updated_at = Column(BigInteger, nullable=False) + meta = Column(JSON, nullable=True) + + +class AnnouncementRead(Base): + """ + 公告阅读状态表 - 记录用户的公告阅读记录 + + 对应数据库表:announcement_read + + 字段说明: + - id: 记录唯一标识 (UUID) + - user_id: 用户 ID + - announcement_id: 公告 ID(外键关联 announcement.id) + - read_at: 阅读时间(纳秒时间戳) + + 用途: + - 追踪每个用户对每条公告的阅读状态 + - 支持"未读公告"提醒功能 + - (user_id, announcement_id) 组合应唯一 + """ + __tablename__ = "announcement_read" + + id = Column(Text, primary_key=True) + user_id = Column(Text, nullable=False) + announcement_id = Column(Text, nullable=False) + read_at = Column(BigInteger, nullable=False) + + +############################################################################## +# Pydantic 数据模型 (Data Transfer Objects) +############################################################################## + +class AnnouncementModel(BaseModel): + """ + 公告完整数据模型 - 用于 API 响应 + + 用途:将 ORM 对象转换为 JSON 可序列化的 Pydantic 模型 + 配置:from_attributes=True 允许从 SQLAlchemy 模型自动转换 + """ + model_config = ConfigDict(from_attributes=True) + + id: str + title: str + content: str + status: str + created_by: str + created_at: int + updated_at: int + meta: Optional[dict] = None + + +class AnnouncementReadModel(BaseModel): + """ + 公告阅读记录数据模型 - 用于 API 响应 + + 用途:表示单个用户对单条公告的阅读记录 + """ + model_config = ConfigDict(from_attributes=True) + + id: str + user_id: str + announcement_id: str + read_at: int + + +class AnnouncementForm(BaseModel): + """ + 创建公告请求模型 - 用于 POST /api/announcements + + 必填字段:title, content + 可选字段:status(默认 active), meta + """ + title: str + content: str + status: Optional[str] = "active" + meta: Optional[dict] = None + + +class AnnouncementUpdateForm(BaseModel): + """ + 更新公告请求模型 - 用于 PUT /api/announcements/{id} + + 所有字段均可选,仅更新提供的字段 + """ + title: Optional[str] = None + content: Optional[str] = None + status: Optional[str] = None + meta: Optional[dict] = None + + +class AnnouncementUserView(AnnouncementModel): + """ + 用户视角的公告模型 - 包含阅读状态 + + 继承自 AnnouncementModel,额外添加: + - is_read: 当前用户是否已读 + - read_at: 当前用户的阅读时间(如果已读) + + 用途:GET /api/announcements/latest 返回给前端 + """ + is_read: bool = False + read_at: Optional[int] = None + + +############################################################################## +# 数据访问层 (Data Access Layer) +############################################################################## + +class AnnouncementReadsTable: + """ + 公告阅读记录数据访问层 + + 职责:管理用户的公告阅读状态 + """ + + def bulk_mark_read(self, user_id: str, announcement_ids: list[str]) -> list[AnnouncementReadModel]: + """ + 批量标记公告为已读 + + 参数: + - user_id: 用户 ID + - announcement_ids: 公告 ID 列表 + + 返回: + - 阅读记录列表(包括新创建和已存在的记录) + + 逻辑: + 1. 查询已存在的阅读记录 + 2. 对于未读的公告,创建新的阅读记录 + 3. 返回完整的阅读记录列表 + + 注意:幂等操作,重复调用不会创建重复记录 + """ + now = int(time.time_ns()) + results: list[AnnouncementReadModel] = [] + + with get_db() as db: + # 查询已存在的阅读记录 + existing = ( + db.query(AnnouncementRead) + .filter( + AnnouncementRead.user_id == user_id, + AnnouncementRead.announcement_id.in_(announcement_ids), + ) + .all() + ) + existing_map = { + read.announcement_id: AnnouncementReadModel.model_validate(read) for read in existing + } + + # 对于未读的公告,创建新的阅读记录 + for announcement_id in announcement_ids: + if announcement_id in existing_map: + results.append(existing_map[announcement_id]) + continue + + read = AnnouncementReadModel( + **{ + "id": str(uuid.uuid4()), + "user_id": user_id, + "announcement_id": announcement_id, + "read_at": now, + } + ) + db.add(AnnouncementRead(**read.model_dump())) + results.append(read) + + db.commit() + + return results + + def get_read_map(self, user_id: str, announcement_ids: list[str]) -> dict[str, AnnouncementReadModel]: + """ + 获取用户的阅读记录映射 + + 参数: + - user_id: 用户 ID + - announcement_ids: 公告 ID 列表 + + 返回: + - 字典 {announcement_id: AnnouncementReadModel} + + 用途:快速查询多个公告的阅读状态,用于前端显示"已读/未读"标识 + """ + with get_db() as db: + reads = ( + db.query(AnnouncementRead) + .filter( + AnnouncementRead.user_id == user_id, + AnnouncementRead.announcement_id.in_(announcement_ids), + ) + .all() + ) + return {read.announcement_id: AnnouncementReadModel.model_validate(read) for read in reads} + + +class AnnouncementsTable: + """ + 公告数据访问层 + + 职责:提供公告的 CRUD 操作(创建、读取、更新、删除) + """ + + def insert(self, form_data: AnnouncementForm, user_id: str) -> AnnouncementModel: + """ + 创建新公告 + + 参数: + - form_data: 公告表单数据(标题、内容、状态、元数据) + - user_id: 创建者用户 ID + + 返回: + - 创建的公告完整数据 + + 注意:自动生成 UUID、时间戳,默认状态为 active + """ + now = int(time.time_ns()) + data = AnnouncementModel( + **{ + "id": str(uuid.uuid4()), + "title": form_data.title, + "content": form_data.content, + "status": form_data.status or "active", + "meta": form_data.meta, + "created_by": user_id, + "created_at": now, + "updated_at": now, + } + ) + + with get_db() as db: + db.add(Announcement(**data.model_dump())) + db.commit() + + return data + + def get_by_id(self, id: str) -> Optional[AnnouncementModel]: + """ + 根据 ID 查询单个公告 + + 参数: + - id: 公告 ID + + 返回: + - 公告数据(如果存在),否则返回 None + """ + with get_db() as db: + announcement = db.query(Announcement).filter(Announcement.id == id).first() + return AnnouncementModel.model_validate(announcement) if announcement else None + + def list( + self, + status: Optional[str] = None, + since: Optional[int] = None, + limit: Optional[int] = None, + ) -> list[AnnouncementModel]: + """ + 查询公告列表 + + 参数: + - status: 过滤状态(如 "active", "archived"),None 表示不过滤 + - since: 仅返回创建时间晚于此时间戳的公告(纳秒),用于增量拉取 + - limit: 限制返回数量 + + 返回: + - 公告列表,按创建时间倒序排列(最新的在前) + + 用途: + - 管理员查看所有公告:不传参数 + - 用户查看最新公告:status="active", limit=20 + - 增量拉取:status="active", since=上次拉取时间 + """ + with get_db() as db: + query = db.query(Announcement) + if status: + query = query.filter(Announcement.status == status) + if since: + query = query.filter(Announcement.created_at > since) + + query = query.order_by(Announcement.created_at.desc()) + if limit: + query = query.limit(limit) + + announcements = query.all() + return [AnnouncementModel.model_validate(item) for item in announcements] + + def update(self, id: str, form_data: AnnouncementUpdateForm) -> Optional[AnnouncementModel]: + """ + 更新公告 + + 参数: + - id: 公告 ID + - form_data: 更新表单数据(仅包含需要更新的字段) + + 返回: + - 更新后的公告数据,如果公告不存在则返回 None + + 注意: + - 仅更新提供的字段,未提供的字段保持不变 + - 自动更新 updated_at 时间戳 + """ + with get_db() as db: + announcement = db.query(Announcement).filter(Announcement.id == id).first() + if not announcement: + return None + + payload = form_data.model_dump(exclude_unset=True) + if "title" in payload: + announcement.title = payload["title"] + if "content" in payload: + announcement.content = payload["content"] + if "status" in payload and payload["status"]: + announcement.status = payload["status"] + if "meta" in payload: + announcement.meta = payload["meta"] + + announcement.updated_at = int(time.time_ns()) + db.commit() + db.refresh(announcement) + return AnnouncementModel.model_validate(announcement) + + def archive(self, id: str) -> Optional[AnnouncementModel]: + """ + 归档公告(软删除) + + 参数: + - id: 公告 ID + + 返回: + - 归档后的公告数据,如果公告不存在则返回 None + + 注意: + - 不物理删除数据,仅将状态设置为 "archived" + - 归档后的公告不会在用户端显示,但管理员仍可查看 + """ + with get_db() as db: + announcement = db.query(Announcement).filter(Announcement.id == id).first() + if not announcement: + return None + + announcement.status = "archived" + announcement.updated_at = int(time.time_ns()) + db.commit() + db.refresh(announcement) + return AnnouncementModel.model_validate(announcement) + + def delete(self, id: str) -> bool: + """ + 硬删除公告及其所有阅读记录 + + 参数: + - id: 公告 ID + + 返回: + - True 表示删除成功,False 表示公告不存在 + """ + with get_db() as db: + # 先删除阅读记录,避免外键/数据残留 + db.query(AnnouncementRead).filter( + AnnouncementRead.announcement_id == id + ).delete() + + deleted = db.query(Announcement).filter(Announcement.id == id).delete() + db.commit() + + return bool(deleted) + + +############################################################################## +# 单例实例 (Singleton Instances) +############################################################################## + +# 全局单例,供路由层直接导入使用 +Announcements = AnnouncementsTable() +AnnouncementReads = AnnouncementReadsTable() diff --git a/backend/open_webui/routers/announcements.py b/backend/open_webui/routers/announcements.py new file mode 100644 index 0000000000..18c6543cf0 --- /dev/null +++ b/backend/open_webui/routers/announcements.py @@ -0,0 +1,352 @@ +""" +公告系统 API 路由模块 + +本模块提供系统公告的 RESTful API 接口,支持: +- 用户端:查看最新公告、标记已读 +- 管理端:公告 CRUD 管理(创建、查询、更新、归档) + +路由前缀:/api/announcements +权限控制: +- 用户接口(/latest, /read):需要 verified 用户身份 +- 管理接口(/, POST, PUT, DELETE):需要 admin 用户身份 + +数据流: +用户请求 → FastAPI Router → 数据访问层(models/announcements.py)→ 数据库 +""" + +import logging +from typing import Optional + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from pydantic import BaseModel + +from open_webui.constants import ERROR_MESSAGES +from open_webui.env import SRC_LOG_LEVELS +from open_webui.models.announcements import ( + Announcements, + AnnouncementReads, + AnnouncementForm, + AnnouncementUpdateForm, + AnnouncementUserView, + AnnouncementModel, +) +from open_webui.models.users import UserResponse, Users +from open_webui.utils.auth import get_admin_user, get_verified_user + +log = logging.getLogger(__name__) +log.setLevel(SRC_LOG_LEVELS["MODELS"]) + +router = APIRouter() + + +############################################################################## +# 请求/响应模型 +############################################################################## + +class AnnouncementReadRequest(BaseModel): + """ + 标记已读请求模型 + + 用于批量标记多个公告为已读 + """ + ids: list[str] + + +class AnnouncementWithAuthor(AnnouncementModel): + """ + 带作者信息的公告模型 + + 继承自 AnnouncementModel,额外包含创建者的用户信息 + 用于管理端列表接口 + """ + author: Optional[UserResponse] = None + + +############################################################################## +# 用户端 API(需要登录验证) +############################################################################## + +@router.get("/latest", response_model=list[AnnouncementUserView]) +async def get_latest_announcements( + request: Request, + since: Optional[int] = None, + limit: Optional[int] = 20, + user=Depends(get_verified_user), +): + """ + 获取最新公告列表(用户端) + + 权限:已验证用户 + 方法:GET /api/announcements/latest + + 查询参数: + - since: 可选,纳秒时间戳。仅返回创建时间晚于此值的公告(用于增量拉取) + - limit: 可选,限制返回数量,默认 20 条 + + 返回: + - 公告列表(仅 active 状态),每条公告包含: + - 公告基本信息(id, title, content, status, created_at 等) + - is_read: 当前用户是否已读 + - read_at: 当前用户的阅读时间(如果已读) + + 用途: + - 前端首次加载:since=None, limit=20 + - 增量拉取新公告:since=上次拉取的最新公告时间戳 + + 示例: + GET /api/announcements/latest?limit=10 + GET /api/announcements/latest?since=1638316800000000000 + """ + # 查询激活状态的公告列表 + announcements = Announcements.list(status="active", since=since, limit=limit) + + # 获取当前用户的阅读记录 + read_map = AnnouncementReads.get_read_map( + user.id, [announcement.id for announcement in announcements] + ) + + # 为每条公告附加阅读状态 + return [ + AnnouncementUserView( + **announcement.model_dump(), + is_read=announcement.id in read_map, + read_at=read_map.get(announcement.id).read_at if announcement.id in read_map else None, + ) + for announcement in announcements + ] + + +@router.post("/read") +async def mark_read( + payload: AnnouncementReadRequest, + user=Depends(get_verified_user), +): + """ + 批量标记公告为已读(用户端) + + 权限:已验证用户 + 方法:POST /api/announcements/read + + 请求体: + { + "ids": ["uuid1", "uuid2", ...] + } + + 返回: + { + "success": true, + "updated": 2 // 成功标记的数量 + } + + 注意: + - 幂等操作,重复标记不会报错 + - 仅标记指定用户的阅读记录,不影响其他用户 + - 用于用户点击公告或滚动浏览时标记已读 + + 示例: + POST /api/announcements/read + {"ids": ["123e4567-e89b-12d3-a456-426614174000"]} + """ + if not payload.ids: + return {"success": True, "updated": 0} + + AnnouncementReads.bulk_mark_read(user.id, payload.ids) + return {"success": True, "updated": len(payload.ids)} + + +############################################################################## +# 管理端 API(需要管理员权限) +############################################################################## + +# Support both with and without trailing slash for list/create +@router.get("", response_model=list[AnnouncementWithAuthor]) +@router.get("/", response_model=list[AnnouncementWithAuthor]) +async def list_announcements( + status: Optional[str] = None, + user=Depends(get_admin_user), +): + """ + 获取公告列表(管理端) + + 权限:管理员 + 方法:GET /api/announcements + + 查询参数: + - status: 可选,过滤状态("active" 或 "archived")。不传则返回所有状态 + + 返回: + - 公告列表,每条包含作者用户信息(id, name, email, role 等) + - 按创建时间倒序排列 + + 用途: + - 管理员查看所有公告 + - 管理员查看已归档公告:status=archived + + 示例: + GET /api/announcements + GET /api/announcements?status=active + GET /api/announcements?status=archived + """ + announcements = Announcements.list(status=status) + + # 批量查询作者信息(避免 N+1 查询) + authors = {a.created_by: Users.get_user_by_id(a.created_by) for a in announcements} + + return [ + AnnouncementWithAuthor( + **a.model_dump(), + author=UserResponse(**authors[a.created_by].model_dump()) if authors.get(a.created_by) else None, + ) + for a in announcements + ] + + +@router.post("", response_model=AnnouncementModel) +@router.post("/", response_model=AnnouncementModel) +async def create_announcement( + form_data: AnnouncementForm, + user=Depends(get_admin_user), +): + """ + 创建新公告(管理端) + + 权限:管理员 + 方法:POST /api/announcements + + 请求体: + { + "title": "公告标题", + "content": "公告内容(支持 Markdown)", + "status": "active", // 可选,默认 "active" + "meta": {} // 可选,扩展元数据 + } + + 返回: + - 创建的完整公告数据(包含自动生成的 id, created_at, updated_at) + + 注意: + - 自动记录创建者为当前管理员用户 + - 默认状态为 active,创建后立即对用户可见 + + 示例: + POST /api/announcements + { + "title": "系统维护通知", + "content": "系统将于明天凌晨 2:00-4:00 进行维护升级", + "status": "active" + } + """ + announcement = Announcements.insert(form_data, user.id) + return announcement + + +@router.put("/{announcement_id}", response_model=AnnouncementModel) +async def update_announcement( + announcement_id: str, + form_data: AnnouncementUpdateForm, + user=Depends(get_admin_user), +): + """ + 更新公告(管理端) + + 权限:管理员 + 方法:PUT /api/announcements/{announcement_id} + + 路径参数: + - announcement_id: 公告 UUID + + 请求体(所有字段均可选): + { + "title": "新标题", + "content": "新内容", + "status": "active" | "archived", + "meta": {} + } + + 返回: + - 更新后的完整公告数据 + + 异常: + - 404 NOT_FOUND:公告不存在 + + 注意: + - 仅更新提供的字段,未提供的字段保持原值 + - 自动更新 updated_at 时间戳 + - 可通过设置 status="archived" 归档公告 + + 示例: + PUT /api/announcements/123e4567-e89b-12d3-a456-426614174000 + {"content": "更新后的公告内容"} + """ + updated = Announcements.update(announcement_id, form_data) + if not updated: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + return updated + + +@router.delete("/{announcement_id}", response_model=AnnouncementModel) +async def delete_announcement( + announcement_id: str, + user=Depends(get_admin_user), +): + """ + 归档公告(管理端) + + 权限:管理员 + 方法:DELETE /api/announcements/{announcement_id} + + 路径参数: + - announcement_id: 公告 UUID + + 返回: + - 归档后的完整公告数据(status 变为 "archived") + + 异常: + - 404 NOT_FOUND:公告不存在 + + 注意: + - 软删除,不物理删除数据 + - 归档后的公告不会在用户端显示(/latest 不返回) + - 管理员仍可通过 GET /?status=archived 查看已归档公告 + - 归档后可通过 PUT 接口重新激活:{"status": "active"} + + 示例: + DELETE /api/announcements/123e4567-e89b-12d3-a456-426614174000 + """ + deleted = Announcements.archive(announcement_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + return deleted + + +@router.delete("/{announcement_id}/hard") +async def hard_delete_announcement( + announcement_id: str, + user=Depends(get_admin_user), +): + """ + 物理删除公告及其阅读记录(管理端) + + 权限:管理员 + 方法:DELETE /api/v1/announcements/{announcement_id}/hard + + 返回: + - {"success": True} 删除成功 + + 注意: + - 会删除公告本身以及 announcement_read 中所有关联记录 + - 不可恢复,请谨慎调用 + """ + deleted = Announcements.delete(announcement_id) + if not deleted: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + return {"success": True} diff --git a/src/lib/apis/announcements/index.ts b/src/lib/apis/announcements/index.ts new file mode 100644 index 0000000000..0327228517 --- /dev/null +++ b/src/lib/apis/announcements/index.ts @@ -0,0 +1,263 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export type Announcement = { + id: string; + title: string; + content: string; + status: string; + created_by: string; + created_at: number; + updated_at: number; + meta?: Record | null; + author?: { + id: string; + name?: string; + email?: string; + image?: string | null; + role?: string; + }; +}; + +export type AnnouncementUserView = Announcement & { + is_read?: boolean; + read_at?: number | null; +}; + +const headers = (token: string) => ({ + Accept: 'application/json', + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}` +}); + +const parseJsonSafe = async (res: Response) => { + const text = await res.text(); + if (!text) return null; + try { + return JSON.parse(text); + } catch (err) { + throw new Error( + typeof err === 'string' ? err : err?.message ?? 'Invalid JSON response from announcements API' + ); + } +}; + +export const listAnnouncements = async (token: string, status?: string) => { + let error: any = null; + const searchParams = new URLSearchParams(); + if (status) searchParams.append('status', status); + + const res = await fetch(`${WEBUI_API_BASE_URL}/announcements?${searchParams.toString()}`, { + method: 'GET', + headers: headers(token) + }) + .then(async (res) => { + if (!res.ok) { + let payload: any = null; + try { + payload = await res.json(); + } catch (_) { + payload = await res.text(); + } + throw payload; + } + return (await parseJsonSafe(res)) ?? []; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) throw error; + return res as Announcement[]; +}; + +export const getLatestAnnouncements = async ( + token: string, + options?: { since?: number; limit?: number } +) => { + let error: any = null; + const searchParams = new URLSearchParams(); + if (options?.since) searchParams.append('since', `${options.since}`); + if (options?.limit) searchParams.append('limit', `${options.limit}`); + + const res = await fetch(`${WEBUI_API_BASE_URL}/announcements/latest?${searchParams.toString()}`, { + method: 'GET', + headers: headers(token) + }) + .then(async (res) => { + if (!res.ok) { + let payload: any = null; + try { + payload = await res.json(); + } catch (_) { + payload = await res.text(); + } + throw payload; + } + return (await parseJsonSafe(res)) ?? []; + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) throw error; + return res as AnnouncementUserView[]; +}; + +export const createAnnouncement = async ( + token: string, + payload: { title: string; content: string; status?: string; meta?: Record } +) => { + let error: any = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/announcements`, { + method: 'POST', + headers: headers(token), + body: JSON.stringify(payload) + }) + .then(async (res) => { + if (!res.ok) { + let payload: any = null; + try { + payload = await res.json(); + } catch (_) { + payload = await res.text(); + } + throw payload; + } + return parseJsonSafe(res); + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) throw error; + return res as Announcement; +}; + +export const updateAnnouncement = async ( + token: string, + id: string, + payload: { title?: string; content?: string; status?: string; meta?: Record } +) => { + let error: any = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/announcements/${id}`, { + method: 'PUT', + headers: headers(token), + body: JSON.stringify(payload) + }) + .then(async (res) => { + if (!res.ok) { + let payload: any = null; + try { + payload = await res.json(); + } catch (_) { + payload = await res.text(); + } + throw payload; + } + return parseJsonSafe(res); + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) throw error; + return res as Announcement; +}; + +export const deleteAnnouncement = async (token: string, id: string) => { + let error: any = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/announcements/${id}`, { + method: 'DELETE', + headers: headers(token) + }) + .then(async (res) => { + if (!res.ok) { + let payload: any = null; + try { + payload = await res.json(); + } catch (_) { + payload = await res.text(); + } + throw payload; + } + return parseJsonSafe(res); + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) throw error; + return res as Announcement; +}; + +export const markAnnouncementsRead = async (token: string, ids: string[]) => { + let error: any = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/announcements/read`, { + method: 'POST', + headers: headers(token), + body: JSON.stringify({ ids }) + }) + .then(async (res) => { + if (!res.ok) { + let payload: any = null; + try { + payload = await res.json(); + } catch (_) { + payload = await res.text(); + } + throw payload; + } + return parseJsonSafe(res); + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) throw error; + return res as { success: boolean; updated: number }; +}; + +export const destroyAnnouncement = async (token: string, id: string) => { + let error: any = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/announcements/${id}/hard`, { + method: 'DELETE', + headers: headers(token) + }) + .then(async (res) => { + if (!res.ok) { + let payload: any = null; + try { + payload = await res.json(); + } catch (_) { + payload = await res.text(); + } + throw payload; + } + return parseJsonSafe(res); + }) + .catch((err) => { + error = err; + console.error(err); + return null; + }); + + if (error) throw error; + return res as { success: boolean }; +}; diff --git a/src/lib/components/AnnouncementModal.svelte b/src/lib/components/AnnouncementModal.svelte new file mode 100644 index 0000000000..1fb871a5ac --- /dev/null +++ b/src/lib/components/AnnouncementModal.svelte @@ -0,0 +1,109 @@ + + +{#if open} +
+
+
+
+
公告
+
+ +
+ +
+ {#if announcements.length === 0} +
+ 暂无公告 +
+ {:else} + {#each announcements as item} +
+ {#if isNew(item)} + + {/if} +
+
+
{item.title}
+ {#if item.status !== 'active'} + + {item.status} + + {/if} +
+
+ {`发布于 ${new Date(item.created_at / 1_000_000).toLocaleString([], { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + hour12: false + })}`} +
+
+
+ {@html render(item.content)} +
+
+ {/each} + {/if} +
+ +
+ +
+
+
+{/if} + + diff --git a/src/lib/components/layout/Sidebar.svelte b/src/lib/components/layout/Sidebar.svelte index 910599cefc..d7f50ced91 100644 --- a/src/lib/components/layout/Sidebar.svelte +++ b/src/lib/components/layout/Sidebar.svelte @@ -25,7 +25,8 @@ isApp, models, selectedFolder, - WEBUI_NAME + WEBUI_NAME, + showAnnouncements } from '$lib/stores'; import { onMount, getContext, tick, onDestroy } from 'svelte'; @@ -767,6 +768,9 @@ if (e.detail === 'archived-chat') { showArchivedChats.set(true); } + if (e.detail === 'announcements') { + showAnnouncements.set(true); + } }} >
{$i18n.t('Admin Panel')}
+ { + show = false; + if ($mobile) { + await tick(); + showSidebar.set(false); + } + }} + > +
+ +
+
{$i18n.t('公告管理')}
+
+ {:else} + { + show = false; + dispatch('show', 'announcements'); + console.log('点击了公告'); + if ($mobile) { + await tick(); + showSidebar.set(false); + } + }} + > +
+ +
+
{$i18n.t('公告')}
+
{/if} {#if help} diff --git a/src/lib/stores/index.ts b/src/lib/stores/index.ts index 3c0cdddd53..50a11d5661 100644 --- a/src/lib/stores/index.ts +++ b/src/lib/stores/index.ts @@ -75,6 +75,7 @@ export const showSettings = writable(false); export const showShortcuts = writable(false); export const showArchivedChats = writable(false); export const showChangelog = writable(false); +export const showAnnouncements = writable(false); export const showMemoryPanel = writable(false); export const showControls = writable(false); diff --git a/src/routes/(app)/+layout.svelte b/src/routes/(app)/+layout.svelte index f9a8470465..30a0f4c9e2 100644 --- a/src/routes/(app)/+layout.svelte +++ b/src/routes/(app)/+layout.svelte @@ -1,6 +1,6 @@ + +
+
+
+
+ {$i18n.t('公告管理')} +
+

+ {$i18n.t('创建、更新或归档平台公告,发布后用户登录会看到最新公告。')} +

+
+
+ +
+
+ + +
+ +
+ + {#if editing} + + {/if} +
+
+ +
+
+
+ {$i18n.t('公告列表')} +
+ {#if loading} +
{$i18n.t('Loading...')}
+ {/if} +
+ +
+ {#if announcements.length === 0} +
+ {$i18n.t('暂无公告')} +
+ {:else} + {#each announcements as item} +
+
+
+
{item.title}
+ {#if item.status !== 'active'} + + {item.status} + + {/if} +
+

{item.content}

+
+ {$i18n.t('更新于')}: {new Date(item.updated_at / 1_000_000).toLocaleString()} +
+
+
+ + + +
+
+ {/each} + {/if} +
+
+ + {#if pendingAction} +
+
+
+ {pendingAction.type === 'archive' ? $i18n.t('确认归档') : $i18n.t('确认删除')} +
+

+ {pendingAction.type === 'archive' + ? $i18n.t('归档后将从用户端隐藏,确认归档这条公告吗?') + : $i18n.t('删除后不可恢复,确认删除这条公告吗?')} +

+
+
{pendingAction.item.title}
+
+ {pendingAction.item.content} +
+
+
+ + +
+
+
+ {/if} +