From 45d79701775307813245638297904c9421b20714 Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Sun, 15 Feb 2026 23:43:41 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=B7=BB=E5=8A=A0=E5=85=AC=E5=91=8A?= =?UTF-8?q?=E7=B3=BB=E7=BB=9F=EF=BC=88=E6=94=AF=E6=8C=81SQLite=E5=92=8CPos?= =?UTF-8?q?tgreSQL=EF=BC=89=20(#129)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(announcement): add database schema for SQLite and PostgreSQL Add announcement table with id, title, content, enabled, created_at, updated_at columns to both SQLite and PostgreSQL schemas to support announcement system. * feat(announcement): implement SQLite repository for announcement management Add announcement CRUD operations in SQLite repository including create, read, update, delete, and list methods with proper error handling. * feat(announcement): add HTTP handlers and auth middleware for announcement API Implement admin-only update endpoint and public read endpoint for announcements. Add auth middleware to enforce admin-only access for update operations. * feat(announcement): add frontend API client for announcement endpoints Implement API client methods for fetching announcements and updating announcement settings with proper error handling. * feat(announcement): add announcement display component to dashboard Implement announcement display section in dashboard with real-time updates and proper styling using HeroUI components. * feat(announcement): add announcement management UI to config page Implement announcement settings panel with enable/disable toggle and content editor for admin users to manage announcements. --- go-backend/internal/http/handler/handler.go | 52 ++++++++ go-backend/internal/http/middleware/auth.go | 2 + .../internal/store/postgres/sql/schema.sql | 8 ++ .../internal/store/sqlite/repository.go | 48 ++++++++ .../internal/store/sqlite/sql/schema.sql | 8 ++ vite-frontend/src/api/index.ts | 8 ++ vite-frontend/src/pages/config.tsx | 112 +++++++++++++++++- vite-frontend/src/pages/dashboard.tsx | 48 +++++++- 8 files changed, 276 insertions(+), 10 deletions(-) diff --git a/go-backend/internal/http/handler/handler.go b/go-backend/internal/http/handler/handler.go index 767dce3..37f314a 100644 --- a/go-backend/internal/http/handler/handler.go +++ b/go-backend/internal/http/handler/handler.go @@ -177,6 +177,8 @@ func (h *Handler) Register(mux *http.ServeMux) { mux.HandleFunc("/api/v1/federation/runtime/diagnose", h.authPeer(h.federationRuntimeDiagnose)) mux.HandleFunc("/api/v1/federation/runtime/command", h.authPeer(h.federationRuntimeCommand)) mux.HandleFunc("/api/v1/federation/node/import", h.nodeImport) + mux.HandleFunc("/api/v1/announcement/get", h.getAnnouncement) + mux.HandleFunc("/api/v1/announcement/update", h.updateAnnouncement) mux.HandleFunc("/flow/test", h.flowTest) mux.HandleFunc("/flow/config", h.flowConfig) @@ -1228,3 +1230,53 @@ func (h *Handler) backupImport(w http.ResponseWriter, r *http.Request) { result.AutoBackup = autoBackup response.WriteJSON(w, response.OK(result)) } + +func (h *Handler) getAnnouncement(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + ann, err := h.repo.GetAnnouncement() + if err != nil { + response.WriteJSON(w, response.Err(-1, fmt.Sprintf("获取公告失败: %v", err))) + return + } + + if ann == nil { + response.WriteJSON(w, response.OK(map[string]interface{}{ + "content": "", + "enabled": 0, + })) + return + } + + response.WriteJSON(w, response.OK(map[string]interface{}{ + "content": ann.Content, + "enabled": ann.Enabled, + })) +} + +func (h *Handler) updateAnnouncement(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + response.WriteJSON(w, response.ErrDefault("请求失败")) + return + } + + var req struct { + Content string `json:"content"` + Enabled int `json:"enabled"` + } + if err := decodeJSON(r.Body, &req); err != nil { + response.WriteJSON(w, response.Err(500, "请求参数错误")) + return + } + + now := time.Now().UnixMilli() + if err := h.repo.UpsertAnnouncement(req.Content, req.Enabled, now); err != nil { + response.WriteJSON(w, response.Err(-1, fmt.Sprintf("更新公告失败: %v", err))) + return + } + + response.WriteJSON(w, response.OKEmpty()) +} diff --git a/go-backend/internal/http/middleware/auth.go b/go-backend/internal/http/middleware/auth.go index 00eb5a4..68a1220 100644 --- a/go-backend/internal/http/middleware/auth.go +++ b/go-backend/internal/http/middleware/auth.go @@ -137,6 +137,8 @@ func requiresAdmin(path string) bool { return true case "/api/v1/config/update", "/api/v1/config/update-single": return true + case "/api/v1/announcement/update": + return true default: return false } diff --git a/go-backend/internal/store/postgres/sql/schema.sql b/go-backend/internal/store/postgres/sql/schema.sql index fcc6cc1..0fb2286 100644 --- a/go-backend/internal/store/postgres/sql/schema.sql +++ b/go-backend/internal/store/postgres/sql/schema.sql @@ -239,3 +239,11 @@ CREATE TABLE IF NOT EXISTS federation_tunnel_binding ( CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx); CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status); + +CREATE TABLE IF NOT EXISTS announcement ( + id SERIAL PRIMARY KEY, + content TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_time BIGINT NOT NULL, + updated_time BIGINT +); diff --git a/go-backend/internal/store/sqlite/repository.go b/go-backend/internal/store/sqlite/repository.go index e5329dd..fa9d7d6 100644 --- a/go-backend/internal/store/sqlite/repository.go +++ b/go-backend/internal/store/sqlite/repository.go @@ -66,6 +66,14 @@ type ViteConfig struct { Time int64 `json:"time"` } +type Announcement struct { + ID int64 `json:"id"` + Content string `json:"content"` + Enabled int `json:"enabled"` + CreatedTime int64 `json:"created_time"` + UpdatedTime sql.NullInt64 `json:"updated_time,omitempty"` +} + type UserTunnelDetail struct { ID int64 UserID int64 @@ -319,6 +327,46 @@ func (r *Repository) UpsertConfig(name, value string, now int64) error { return err } +func (r *Repository) GetAnnouncement() (*Announcement, error) { + if r == nil || r.db == nil { + return nil, errors.New("repository not initialized") + } + + row := r.db.QueryRow(`SELECT id, content, enabled, created_time, updated_time FROM announcement ORDER BY id DESC LIMIT 1`) + ann := &Announcement{} + if err := row.Scan(&ann.ID, &ann.Content, &ann.Enabled, &ann.CreatedTime, &ann.UpdatedTime); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, nil + } + return nil, err + } + return ann, nil +} + +func (r *Repository) UpsertAnnouncement(content string, enabled int, now int64) error { + if r == nil || r.db == nil { + return errors.New("repository not initialized") + } + + var count int + err := r.db.QueryRow(`SELECT COUNT(*) FROM announcement`).Scan(&count) + if err != nil { + return err + } + + if count == 0 { + _, err = r.db.Exec(` + INSERT INTO announcement(content, enabled, created_time, updated_time) + VALUES(?, ?, ?, ?) + `, content, enabled, now, now) + } else { + _, err = r.db.Exec(` + UPDATE announcement SET content = ?, enabled = ?, updated_time = ? + `, content, enabled, now) + } + return err +} + func (r *Repository) GetUserByID(id int64) (*User, error) { if r == nil || r.db == nil { return nil, errors.New("repository not initialized") diff --git a/go-backend/internal/store/sqlite/sql/schema.sql b/go-backend/internal/store/sqlite/sql/schema.sql index c5afdf3..68677ab 100644 --- a/go-backend/internal/store/sqlite/sql/schema.sql +++ b/go-backend/internal/store/sqlite/sql/schema.sql @@ -244,3 +244,11 @@ CREATE TABLE IF NOT EXISTS federation_tunnel_binding ( CREATE UNIQUE INDEX IF NOT EXISTS idx_federation_tunnel_binding_unique ON federation_tunnel_binding(tunnel_id, node_id, chain_type, hop_inx); CREATE INDEX IF NOT EXISTS idx_federation_tunnel_binding_tunnel ON federation_tunnel_binding(tunnel_id, status); + +CREATE TABLE IF NOT EXISTS announcement ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content TEXT NOT NULL, + enabled INTEGER NOT NULL DEFAULT 1, + created_time INTEGER NOT NULL, + updated_time INTEGER +); diff --git a/vite-frontend/src/api/index.ts b/vite-frontend/src/api/index.ts index d183fd2..dc33537 100644 --- a/vite-frontend/src/api/index.ts +++ b/vite-frontend/src/api/index.ts @@ -285,3 +285,11 @@ export const exportBackup = async (types: string[] = []) => { export const importBackup = (data: { types: string[]; [key: string]: any }) => Network.post("/backup/import", data); + +export interface AnnouncementData { + content: string; + enabled: number; +} + +export const getAnnouncement = () => Network.get("/announcement/get"); +export const updateAnnouncement = (data: AnnouncementData) => Network.post("/announcement/update", data); diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 163931a..a1c19ea 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom"; import { Button } from "@heroui/button"; import { Card, CardBody, CardHeader } from "@heroui/card"; import { Input } from "@heroui/input"; +import { Textarea } from "@heroui/input"; import { Spinner } from "@heroui/spinner"; import { Divider } from "@heroui/divider"; import { Switch } from "@heroui/switch"; @@ -10,7 +11,7 @@ import { Select, SelectItem } from "@heroui/select"; import { Checkbox, CheckboxGroup } from "@heroui/checkbox"; import toast from "react-hot-toast"; -import { updateConfigs, exportBackup, importBackup } from "@/api"; +import { updateConfigs, exportBackup, importBackup, getAnnouncement, updateAnnouncement, type AnnouncementData } from "@/api"; import { SettingsIcon } from "@/components/icons"; import { isAdmin } from "@/utils/auth"; import { @@ -144,6 +145,13 @@ export default function ConfigPage() { const [importFileName, setImportFileName] = useState(""); const fileInputRef = useRef(null); + const [announcement, setAnnouncement] = useState({ + content: "", + enabled: 0, + }); + const [announcementLoading, setAnnouncementLoading] = useState(true); + const [announcementSaving, setAnnouncementSaving] = useState(false); + // 权限检查 useEffect(() => { if (!isAdmin()) { @@ -188,21 +196,51 @@ export default function ConfigPage() { }; useEffect(() => { - // 延迟加载,避免阻塞初始渲染 const timer = setTimeout(() => { loadConfigs(initialConfigs); + loadAnnouncement(); }, 100); return () => clearTimeout(timer); - }, []); // 只在组件挂载时执行一次 + }, []); + + const loadAnnouncement = async () => { + setAnnouncementLoading(true); + try { + const res = await getAnnouncement(); + + if (res.code === 0 && res.data) { + setAnnouncement(res.data); + } + } catch (error) { + console.error("Failed to load announcement:", error); + } finally { + setAnnouncementLoading(false); + } + }; + + const saveAnnouncement = async () => { + setAnnouncementSaving(true); + try { + const res = await updateAnnouncement(announcement); + + if (res.code === 0) { + toast.success("公告保存成功"); + } else { + toast.error(res.msg || "保存失败"); + } + } catch { + toast.error("保存公告失败,请重试"); + } finally { + setAnnouncementSaving(false); + } + }; - // 处理配置项变更 const handleConfigChange = (key: string, value: string) => { const newConfigs = { ...configs, [key]: value }; setConfigs(newConfigs); - // 检查是否有变更 const hasChangesNow = Object.keys(newConfigs).some( (k) => newConfigs[k] !== originalConfigs[k], @@ -479,7 +517,6 @@ export default function ConfigPage() { - {/* 操作提示 */} {hasChanges && ( @@ -493,6 +530,69 @@ export default function ConfigPage() { )} + + +
+
+

公告管理

+

+ 设置首页显示的公告内容 +

+
+
+
+ + + + + {announcementLoading ? ( +
+ +
+ ) : ( + <> +
+ + setAnnouncement({ ...announcement, enabled: checked ? 1 : 0 }) + } + > + + {announcement.enabled === 1 ? "已启用" : "已禁用"} + + +

+ 启用后,公告将在首页顶部显示 +

+
+ +