mirror of
https://github.com/open-webui/open-webui.git
synced 2025-12-13 04:45:19 +00:00
1. 普通用户: 每次登录,刷新界面时,界面弹出公告窗口,告知其管理员所发布的最新公告
2. 管理员用户: 在登录后的界面中, 可以发布新的公告,也可以对已经发布的公告进行修改,删除等。
This commit is contained in:
parent
948cdb9e98
commit
87f330850f
10 changed files with 1621 additions and 2 deletions
|
|
@ -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"])
|
||||
|
||||
|
|
|
|||
490
backend/open_webui/models/announcements.py
Normal file
490
backend/open_webui/models/announcements.py
Normal file
|
|
@ -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()
|
||||
352
backend/open_webui/routers/announcements.py
Normal file
352
backend/open_webui/routers/announcements.py
Normal file
|
|
@ -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}
|
||||
263
src/lib/apis/announcements/index.ts
Normal file
263
src/lib/apis/announcements/index.ts
Normal file
|
|
@ -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<string, unknown> | 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<string, unknown> }
|
||||
) => {
|
||||
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<string, unknown> }
|
||||
) => {
|
||||
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 };
|
||||
};
|
||||
109
src/lib/components/AnnouncementModal.svelte
Normal file
109
src/lib/components/AnnouncementModal.svelte
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
<script lang="ts">
|
||||
import type { AnnouncementUserView } from '$lib/apis/announcements';
|
||||
|
||||
import DOMPurify from 'dompurify';
|
||||
import { marked } from 'marked';
|
||||
|
||||
export let open = false;
|
||||
export let announcements: AnnouncementUserView[] = [];
|
||||
export let lastSeenAt: number = 0;
|
||||
|
||||
export let onClose: () => void;
|
||||
|
||||
const isNew = (item: AnnouncementUserView) => {
|
||||
if (item.is_read === false) return true;
|
||||
if (item.created_at && lastSeenAt && item.created_at > lastSeenAt) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
const render = (content: string) => {
|
||||
try {
|
||||
return DOMPurify.sanitize(marked.parse(content || ''));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
return content;
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
||||
{#if open}
|
||||
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 px-6">
|
||||
<div class="relative w-full max-w-4xl max-h-[85vh] overflow-hidden rounded-2xl border border-gray-100 bg-white shadow-2xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between border-b border-gray-100 px-5 py-3 dark:border-gray-800">
|
||||
<div>
|
||||
<div class="text-3xl font-semibold text-gray-900 dark:text-gray-50">公告</div>
|
||||
</div>
|
||||
<button
|
||||
class="rounded-full p-2 text-gray-500 transition hover:bg-gray-100 hover:text-gray-900 dark:hover:bg-gray-800"
|
||||
aria-label="close"
|
||||
on:click={onClose}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="max-h-[60vh] space-y-3 overflow-y-auto px-5 py-4 custom-scroll">
|
||||
{#if announcements.length === 0}
|
||||
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3 text-sm text-gray-500 dark:border-gray-800 dark:bg-gray-950 dark:text-gray-300">
|
||||
暂无公告
|
||||
</div>
|
||||
{:else}
|
||||
{#each announcements as item}
|
||||
<div
|
||||
class={`relative rounded-xl px-4 py-3 shadow-sm transition ${
|
||||
isNew(item)
|
||||
? 'bg-amber-50/70 dark:bg-amber-500/10'
|
||||
: 'bg-white dark:bg-gray-900'
|
||||
}`}
|
||||
>
|
||||
{#if isNew(item)}
|
||||
<span class="absolute right-3 top-3 rounded-full bg-amber-500 px-2 py-0.5 text-[10px] font-semibold uppercase text-white shadow-sm">新</span>
|
||||
{/if}
|
||||
<div class="flex flex-wrap items-center gap-2 justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-xl font-bold text-gray-900 dark:text-gray-50">{item.title}</div>
|
||||
{#if item.status !== 'active'}
|
||||
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-300">
|
||||
{item.status}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{`发布于 ${new Date(item.created_at / 1_000_000).toLocaleString([], {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hour12: false
|
||||
})}`}
|
||||
</div>
|
||||
</div>
|
||||
<div class="prose prose-sm mt-2 max-w-none text-gray-800 dark:prose-invert dark:text-gray-100">
|
||||
{@html render(item.content)}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-end gap-3 border-t border-gray-100 px-5 py-3 dark:border-gray-800">
|
||||
<button
|
||||
class="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 dark:border-gray-700 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
on:click={onClose}
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.custom-scroll::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
}
|
||||
.custom-scroll::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
border-radius: 999px;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -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);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -1354,6 +1358,9 @@
|
|||
if (e.detail === 'archived-chat') {
|
||||
showArchivedChats.set(true);
|
||||
}
|
||||
if (e.detail === 'announcements') {
|
||||
showAnnouncements.set(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
|
|||
|
|
@ -144,6 +144,41 @@
|
|||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('Admin Panel')}</div>
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
as="a"
|
||||
href="/announcements/manage"
|
||||
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition select-none"
|
||||
on:click={async () => {
|
||||
show = false;
|
||||
if ($mobile) {
|
||||
await tick();
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<UserGroup className="w-5 h-5" strokeWidth="1.5" />
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('公告管理')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{:else}
|
||||
<DropdownMenu.Item
|
||||
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer"
|
||||
on:click={async () => {
|
||||
show = false;
|
||||
dispatch('show', 'announcements');
|
||||
console.log('点击了公告');
|
||||
if ($mobile) {
|
||||
await tick();
|
||||
showSidebar.set(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class=" self-center mr-3">
|
||||
<span class="inline-flex h-5 w-5 items-center justify-center rounded-full bg-amber-500 text-[11px] font-semibold text-white">告</span>
|
||||
</div>
|
||||
<div class=" self-center truncate">{$i18n.t('公告')}</div>
|
||||
</DropdownMenu.Item>
|
||||
{/if}
|
||||
|
||||
{#if help}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
<script lang="ts">
|
||||
import { toast } from 'svelte-sonner';
|
||||
import { onMount, tick, getContext } from 'svelte';
|
||||
import { onMount, tick, getContext } from 'svelte';
|
||||
import { openDB, deleteDB } from 'idb';
|
||||
import fileSaver from 'file-saver';
|
||||
const { saveAs } = fileSaver;
|
||||
|
|
@ -16,6 +16,7 @@
|
|||
import { getPrompts } from '$lib/apis/prompts';
|
||||
import { getTools } from '$lib/apis/tools';
|
||||
import { getBanners } from '$lib/apis/configs';
|
||||
import { getLatestAnnouncements, markAnnouncementsRead, type AnnouncementUserView } from '$lib/apis/announcements';
|
||||
import { getUserSettings } from '$lib/apis/users';
|
||||
|
||||
import { WEBUI_VERSION } from '$lib/constants';
|
||||
|
|
@ -35,6 +36,7 @@
|
|||
showSettings,
|
||||
showShortcuts,
|
||||
showChangelog,
|
||||
showAnnouncements,
|
||||
temporaryChatEnabled,
|
||||
toolServers,
|
||||
showSearch,
|
||||
|
|
@ -44,6 +46,7 @@
|
|||
import Sidebar from '$lib/components/layout/Sidebar.svelte';
|
||||
import SettingsModal from '$lib/components/chat/SettingsModal.svelte';
|
||||
import ChangelogModal from '$lib/components/ChangelogModal.svelte';
|
||||
import AnnouncementModal from '$lib/components/AnnouncementModal.svelte';
|
||||
import AccountPending from '$lib/components/layout/Overlay/AccountPending.svelte';
|
||||
import UpdateInfoToast from '$lib/components/layout/UpdateInfoToast.svelte';
|
||||
import Spinner from '$lib/components/common/Spinner.svelte';
|
||||
|
|
@ -56,6 +59,11 @@
|
|||
|
||||
let version;
|
||||
|
||||
let loadingAnnouncements = false;
|
||||
let latestAnnouncements: AnnouncementUserView[] = [];
|
||||
let lastSeenAnnouncementAt = 0;
|
||||
let prevAnnouncementsOpen = false;
|
||||
|
||||
const clearChatInputStorage = () => {
|
||||
const chatInputKeys = Object.keys(localStorage).filter((key) => key.startsWith('chat-input'));
|
||||
if (chatInputKeys.length > 0) {
|
||||
|
|
@ -144,6 +152,62 @@
|
|||
tools.set(toolsData);
|
||||
};
|
||||
|
||||
const loadAnnouncements = async (forceShow = false) => {
|
||||
if ($user?.role !== 'user') return;
|
||||
loadingAnnouncements = true;
|
||||
lastSeenAnnouncementAt = Number(localStorage.getItem('announcementLastSeenAt') || '0');
|
||||
|
||||
const data = await getLatestAnnouncements(localStorage.token).catch((error) => {
|
||||
console.error(error);
|
||||
return null;
|
||||
});
|
||||
|
||||
if (data) {
|
||||
latestAnnouncements = data;
|
||||
const hasNew = data.some(
|
||||
(item) => item.is_read === false || (!!lastSeenAnnouncementAt && item.created_at > lastSeenAnnouncementAt)
|
||||
);
|
||||
if (forceShow) {
|
||||
showAnnouncements.set(true);
|
||||
} else {
|
||||
showAnnouncements.set(hasNew && data.length > 0);
|
||||
}
|
||||
} else if (forceShow) {
|
||||
showAnnouncements.set(true);
|
||||
}
|
||||
|
||||
loadingAnnouncements = false;
|
||||
};
|
||||
|
||||
const acknowledgeAnnouncements = async () => {
|
||||
if (!latestAnnouncements || latestAnnouncements.length === 0) {
|
||||
showAnnouncements.set(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const unreadIds = latestAnnouncements.filter((item) => !item.is_read).map((item) => item.id);
|
||||
if (unreadIds.length > 0) {
|
||||
await markAnnouncementsRead(localStorage.token, unreadIds).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.detail ?? error?.message ?? $i18n.t('Failed to mark read'));
|
||||
});
|
||||
}
|
||||
|
||||
const maxTs = Math.max(
|
||||
lastSeenAnnouncementAt || 0,
|
||||
...latestAnnouncements.map((item) => item.created_at ?? 0)
|
||||
);
|
||||
lastSeenAnnouncementAt = maxTs;
|
||||
localStorage.setItem('announcementLastSeenAt', `${maxTs}`);
|
||||
|
||||
latestAnnouncements = latestAnnouncements.map((item) => ({ ...item, is_read: true }));
|
||||
showAnnouncements.set(false);
|
||||
};
|
||||
|
||||
const closeAnnouncementModal = async () => {
|
||||
await acknowledgeAnnouncements();
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
if ($user === undefined || $user === null) {
|
||||
await goto('/auth');
|
||||
|
|
@ -162,6 +226,7 @@
|
|||
await Promise.all([setModels(), setToolServers()]);
|
||||
})
|
||||
]);
|
||||
await loadAnnouncements();
|
||||
|
||||
const setupKeyboardShortcuts = () => {
|
||||
document.addEventListener('keydown', async function (event) {
|
||||
|
|
@ -297,6 +362,14 @@
|
|||
loaded = true;
|
||||
});
|
||||
|
||||
// 用户手动打开“公告”时,确保加载数据
|
||||
$: {
|
||||
if ($showAnnouncements && !prevAnnouncementsOpen && !loadingAnnouncements) {
|
||||
loadAnnouncements(true);
|
||||
}
|
||||
prevAnnouncementsOpen = $showAnnouncements;
|
||||
}
|
||||
|
||||
const checkForVersionUpdates = async () => {
|
||||
version = await getVersionUpdates(localStorage.token).catch((error) => {
|
||||
return {
|
||||
|
|
@ -309,6 +382,12 @@
|
|||
|
||||
<SettingsModal bind:show={$showSettings} />
|
||||
<ChangelogModal bind:show={$showChangelog} />
|
||||
<AnnouncementModal
|
||||
open={$showAnnouncements}
|
||||
announcements={latestAnnouncements}
|
||||
lastSeenAt={lastSeenAnnouncementAt}
|
||||
onClose={closeAnnouncementModal}
|
||||
/>
|
||||
|
||||
{#if version && compareVersion(version.latest, version.current) && ($settings?.showUpdateToast ?? true)}
|
||||
<div class=" absolute bottom-8 right-8 z-50" in:fade={{ duration: 100 }}>
|
||||
|
|
|
|||
281
src/routes/(app)/announcements/manage/+page.svelte
Normal file
281
src/routes/(app)/announcements/manage/+page.svelte
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
<script lang="ts">
|
||||
import { onMount, getContext } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { get } from 'svelte/store';
|
||||
import { toast } from 'svelte-sonner';
|
||||
|
||||
import {
|
||||
listAnnouncements,
|
||||
createAnnouncement,
|
||||
updateAnnouncement,
|
||||
deleteAnnouncement,
|
||||
destroyAnnouncement,
|
||||
type Announcement
|
||||
} from '$lib/apis/announcements';
|
||||
import { user } from '$lib/stores';
|
||||
|
||||
const i18n = getContext('i18n');
|
||||
|
||||
let loading = true;
|
||||
let saving = false;
|
||||
let announcements: Announcement[] = [];
|
||||
let editing: Announcement | null = null;
|
||||
let pendingAction: { type: 'archive' | 'delete'; item: Announcement } | null = null;
|
||||
let form = {
|
||||
title: '',
|
||||
content: '',
|
||||
status: 'active'
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
editing = null;
|
||||
form = {
|
||||
title: '',
|
||||
content: '',
|
||||
status: 'active'
|
||||
};
|
||||
};
|
||||
|
||||
const load = async () => {
|
||||
loading = true;
|
||||
const res = await listAnnouncements(localStorage.token).catch((error) => {
|
||||
console.error(error);
|
||||
const msg =
|
||||
typeof error === 'string'
|
||||
? error
|
||||
: error?.detail ?? error?.message ?? $i18n.t('Failed to load announcements');
|
||||
toast.error(msg);
|
||||
return null;
|
||||
});
|
||||
if (res) {
|
||||
announcements = res;
|
||||
}
|
||||
loading = false;
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (!form.title || !form.content) {
|
||||
toast.error($i18n.t('Please fill title and content'));
|
||||
return;
|
||||
}
|
||||
saving = true;
|
||||
if (editing) {
|
||||
await updateAnnouncement(localStorage.token, editing.id, form).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.detail ?? error?.message ?? $i18n.t('Update failed'));
|
||||
});
|
||||
} else {
|
||||
await createAnnouncement(localStorage.token, form).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.detail ?? error?.message ?? $i18n.t('Create failed'));
|
||||
});
|
||||
}
|
||||
saving = false;
|
||||
await load();
|
||||
resetForm();
|
||||
};
|
||||
|
||||
const archive = (item: Announcement) => {
|
||||
pendingAction = { type: 'archive', item };
|
||||
};
|
||||
|
||||
const remove = (item: Announcement) => {
|
||||
pendingAction = { type: 'delete', item };
|
||||
};
|
||||
|
||||
const confirmAction = async () => {
|
||||
if (!pendingAction) return;
|
||||
const { type, item } = pendingAction;
|
||||
pendingAction = null;
|
||||
|
||||
if (type === 'archive') {
|
||||
await deleteAnnouncement(localStorage.token, item.id).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.detail ?? error?.message ?? $i18n.t('Failed to archive'));
|
||||
});
|
||||
} else {
|
||||
await destroyAnnouncement(localStorage.token, item.id).catch((error) => {
|
||||
console.error(error);
|
||||
toast.error(error?.detail ?? error?.message ?? $i18n.t('删除失败'));
|
||||
});
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
const cancelAction = () => {
|
||||
pendingAction = null;
|
||||
};
|
||||
|
||||
onMount(async () => {
|
||||
const currentUser = get(user);
|
||||
if (!currentUser || currentUser.role !== 'admin') {
|
||||
goto('/');
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="mx-auto flex max-w-5xl flex-col gap-6 px-6 py-8">
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<div class="text-2xl font-semibold text-gray-900 dark:text-gray-50">
|
||||
{$i18n.t('公告管理')}
|
||||
</div>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('创建、更新或归档平台公告,发布后用户登录会看到最新公告。')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid gap-4 rounded-2xl border border-gray-100 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="grid gap-3 md:grid-cols-2">
|
||||
<label class="flex flex-col gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-300">{$i18n.t('标题')}</span>
|
||||
<input
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-800 dark:bg-gray-950"
|
||||
placeholder={$i18n.t('请输入公告标题')}
|
||||
bind:value={form.title}
|
||||
/>
|
||||
</label>
|
||||
<label class="flex flex-col gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-300">{$i18n.t('状态')}</span>
|
||||
<select
|
||||
class="w-full rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-800 dark:bg-gray-950"
|
||||
bind:value={form.status}
|
||||
>
|
||||
<option value="active">{$i18n.t('Active')}</option>
|
||||
<option value="archived">{$i18n.t('Archived')}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
<label class="flex flex-col gap-2 text-sm">
|
||||
<span class="text-gray-600 dark:text-gray-300">{$i18n.t('正文')}</span>
|
||||
<textarea
|
||||
class="min-h-[140px] rounded-xl border border-gray-200 bg-gray-50 px-3 py-2 text-sm focus:border-blue-500 focus:outline-none dark:border-gray-800 dark:bg-gray-950"
|
||||
placeholder={$i18n.t('支持纯文本或 Markdown')}
|
||||
bind:value={form.content}
|
||||
></textarea>
|
||||
</label>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="rounded-xl bg-blue-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-blue-700 disabled:opacity-60"
|
||||
on:click={submit}
|
||||
disabled={saving}
|
||||
>
|
||||
{editing ? $i18n.t('保存') : $i18n.t('发布')}
|
||||
</button>
|
||||
{#if editing}
|
||||
<button
|
||||
class="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
on:click={resetForm}
|
||||
>
|
||||
{$i18n.t('取消编辑')}
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="rounded-2xl border border-gray-100 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
{$i18n.t('公告列表')}
|
||||
</div>
|
||||
{#if loading}
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{$i18n.t('Loading...')}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="mt-4 divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{#if announcements.length === 0}
|
||||
<div class="py-6 text-sm text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('暂无公告')}
|
||||
</div>
|
||||
{:else}
|
||||
{#each announcements as item}
|
||||
<div class="flex flex-col gap-2 py-4 md:flex-row md:items-center md:justify-between">
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<div class="text-base font-semibold text-gray-900 dark:text-gray-50">{item.title}</div>
|
||||
{#if item.status !== 'active'}
|
||||
<span class="rounded-full bg-gray-200 px-2 py-0.5 text-[10px] font-semibold text-gray-600 dark:bg-gray-800 dark:text-gray-300">
|
||||
{item.status}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<p class="text-sm text-gray-600 line-clamp-2 dark:text-gray-300">{item.content}</p>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400">
|
||||
{$i18n.t('更新于')}: {new Date(item.updated_at / 1_000_000).toLocaleString()}
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="rounded-xl border border-gray-200 px-3 py-1.5 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
on:click={() => {
|
||||
editing = item;
|
||||
form = {
|
||||
title: item.title,
|
||||
content: item.content,
|
||||
status: item.status ?? 'active'
|
||||
};
|
||||
}}
|
||||
>
|
||||
{$i18n.t('编辑')}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-red-200 px-3 py-1.5 text-sm font-semibold text-red-600 transition hover:bg-red-50 dark:border-red-900/50 dark:text-red-200 dark:hover:bg-red-950/30"
|
||||
on:click={() => archive(item)}
|
||||
>
|
||||
{$i18n.t('归档')}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl border border-red-300 px-3 py-1.5 text-sm font-semibold text-red-700 transition hover:bg-red-100 dark:border-red-900/70 dark:text-red-100 dark:hover:bg-red-950/40"
|
||||
type="button"
|
||||
on:click={() => remove(item)}
|
||||
>
|
||||
{$i18n.t('删除')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if pendingAction}
|
||||
<div class="fixed inset-0 z-40 flex items-center justify-center bg-black/40 px-4">
|
||||
<div class="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div class="text-lg font-semibold text-gray-900 dark:text-gray-50">
|
||||
{pendingAction.type === 'archive' ? $i18n.t('确认归档') : $i18n.t('确认删除')}
|
||||
</div>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-300">
|
||||
{pendingAction.type === 'archive'
|
||||
? $i18n.t('归档后将从用户端隐藏,确认归档这条公告吗?')
|
||||
: $i18n.t('删除后不可恢复,确认删除这条公告吗?')}
|
||||
</p>
|
||||
<div class="mt-4 space-y-1 text-sm">
|
||||
<div class="font-semibold text-gray-900 dark:text-gray-100">{pendingAction.item.title}</div>
|
||||
<div class="line-clamp-2 text-gray-600 dark:text-gray-300">
|
||||
{pendingAction.item.content}
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
class="rounded-xl border border-gray-200 px-4 py-2 text-sm font-semibold text-gray-700 transition hover:bg-gray-50 dark:border-gray-800 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
type="button"
|
||||
on:click={cancelAction}
|
||||
>
|
||||
{$i18n.t('取消')}
|
||||
</button>
|
||||
<button
|
||||
class="rounded-xl bg-red-600 px-4 py-2 text-sm font-semibold text-white shadow-sm transition hover:bg-red-700"
|
||||
type="button"
|
||||
on:click={confirmAction}
|
||||
>
|
||||
{pendingAction.type === 'archive' ? $i18n.t('归档') : $i18n.t('删除')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
Loading…
Reference in a new issue