diff --git a/ROADMAP.md b/ROADMAP.md
index e038b3f8..db9ef1ff 100644
--- a/ROADMAP.md
+++ b/ROADMAP.md
@@ -190,8 +190,10 @@ Order = user-visible value first; each ships with backend + UI + tests.
5. **System extensions** — activity/audit log, site announcements, node selection, report-abuse queue (fixes #3480, #3479 IP whitelist). See §1.3a.
- [x] PR #144 — task `creator_ip` capture with CIDR-capable admin filter (#115 OSS half), group remote-download quotas per count + per volume (#16), yt-dlp downloader provider (#88), progressive image preview (#113), v3 migrator `DatabaseURL` passthrough (#42)
- [x] `activity_event` entity (immutable, tx-aware, actor+IP+CID) + per-file Activity dialog + admin `/admin/event` feed + per-type enablement + retention cron (#184)
- - [ ] Event coverage remainder: share-link viewed, version ops, thumb-gen, metadata/view, mount, WebDAV acct, payment, account link/nick/avatar/password/2FA/passkey, abuse, OAuth grant
- - [ ] site announcement modal + dismissal; group `allowed_nodes` + task `target_node`; `abuse_report` + admin queue + share context-menu Report entry
+ - [x] Coverage wave 2: email/user-activated/token-refresh/share-viewed/version/metadata/view/thumb/live-photo/copy-from/webdav/profile+security/oauth/admin-ops/import (1bbaddf)
+ - [ ] Event coverage remainder (needs unbuilt features): payment_*, link/unlink_account, membership_unsubscribe, report_abuse, mount, quota-notify
+ - [x] site announcement: `announcement` setting (markdown) + post-login modal + per-user dismissal re-triggering on content change (#184)
+ - [ ] group `allowed_nodes` + task `target_node`; `abuse_report` + admin queue + share context-menu Report entry
## 5. Phase C — security + quality
diff --git a/frontend/public/locales/en-US/application.json b/frontend/public/locales/en-US/application.json
index 8f0e94cf..25789c2c 100644
--- a/frontend/public/locales/en-US/application.json
+++ b/frontend/public/locales/en-US/application.json
@@ -1112,5 +1112,9 @@
],
"reportDescription": "Additional description",
"reportAbuseSuccess": "Report submitted."
+ },
+ "announcement": {
+ "title": "Announcement",
+ "dontShowAgain": "Don't show this again"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/public/locales/zh-CN/application.json b/frontend/public/locales/zh-CN/application.json
index 884bf3b5..03f406e2 100644
--- a/frontend/public/locales/zh-CN/application.json
+++ b/frontend/public/locales/zh-CN/application.json
@@ -1112,5 +1112,9 @@
],
"reportDescription": "补充说明",
"reportAbuseSuccess": "举报已提交"
+ },
+ "announcement": {
+ "title": "公告",
+ "dontShowAgain": "不再显示"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/src/api/api.ts b/frontend/src/api/api.ts
index 95c41ba8..590ffe41 100644
--- a/frontend/src/api/api.ts
+++ b/frontend/src/api/api.ts
@@ -1333,6 +1333,22 @@ export function sendUploadAvatar(avatar?: Blob, contentType?: string): ThunkResp
};
}
+export function getAnnouncement(): ThunkResponse<{ content?: string }> {
+ return async (dispatch, _getState) => {
+ return await dispatch(
+ send(
+ `/user/setting/announcement`,
+ {
+ method: "GET",
+ },
+ {
+ ...defaultOpts,
+ },
+ ),
+ );
+ };
+}
+
export function sendUpdateUserSetting(settings: PatchUserSetting): ThunkResponse {
return async (dispatch, _getState) => {
return await dispatch(
diff --git a/frontend/src/api/user.ts b/frontend/src/api/user.ts
index f04f7811..c416728e 100644
--- a/frontend/src/api/user.ts
+++ b/frontend/src/api/user.ts
@@ -178,6 +178,8 @@ export interface PatchUserSetting {
trash_retention?: number;
// Preferred storage policy hash ID; "" inherits the group default.
preferred_policy?: string;
+ // Marks the current site announcement as seen.
+ dismiss_announcement?: boolean;
}
export interface PasskeyCredentialOption {
diff --git a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx
index 90b248af..e26b20c6 100644
--- a/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx
+++ b/frontend/src/component/Admin/Settings/SiteInformation/SiteInformation.tsx
@@ -79,7 +79,13 @@ const SiteInformation = () => {
-
+ setSettings({ announcement: e.target.value })}
+ value={values.announcement}
+ />
{t("settings.announcementDes")}
diff --git a/frontend/src/component/FileManager/Dialogs/AnnouncementDialog.tsx b/frontend/src/component/FileManager/Dialogs/AnnouncementDialog.tsx
new file mode 100644
index 00000000..62645c29
--- /dev/null
+++ b/frontend/src/component/FileManager/Dialogs/AnnouncementDialog.tsx
@@ -0,0 +1,85 @@
+import { LoadingButton } from "@mui/lab";
+import { Box, Checkbox, Dialog, DialogActions, DialogContent, DialogTitle, FormControlLabel, Skeleton, useTheme } from "@mui/material";
+import { lazy, Suspense, useCallback, useEffect, useState } from "react";
+import { useTranslation } from "react-i18next";
+import { getAnnouncement, sendUpdateUserSetting } from "../../../api/api.ts";
+import { useAppDispatch } from "../../../redux/hooks.ts";
+
+const MarkdownEditor = lazy(() => import("../../Viewers/MarkdownEditor/Editor.tsx"));
+
+const Loading = () => (
+
+
+
+
+);
+
+// AnnouncementDialog fetches the current site announcement once on mount and
+// shows it as a post-login modal. Dismissing with "don't show again" records
+// the content server-side so the modal stays silent until the admin edits it.
+const AnnouncementDialog = () => {
+ const { t } = useTranslation();
+ const theme = useTheme();
+ const dispatch = useAppDispatch();
+ const [content, setContent] = useState();
+ const [dontShow, setDontShow] = useState(false);
+ const [closing, setClosing] = useState(false);
+
+ useEffect(() => {
+ let mounted = true;
+ dispatch(getAnnouncement())
+ .then((res) => {
+ if (mounted && res.content) {
+ setContent(res.content);
+ }
+ })
+ .catch(() => {});
+ return () => {
+ mounted = false;
+ };
+ }, [dispatch]);
+
+ const close = useCallback(() => {
+ if (dontShow) {
+ setClosing(true);
+ dispatch(sendUpdateUserSetting({ dismiss_announcement: true }))
+ .catch(() => {})
+ .finally(() => setContent(undefined));
+ } else {
+ setContent(undefined);
+ }
+ }, [dispatch, dontShow]);
+
+ if (!content) {
+ return null;
+ }
+
+ return (
+
+ );
+};
+
+export default AnnouncementDialog;
diff --git a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx
index 7c3355e6..b38bbce0 100644
--- a/frontend/src/component/FileManager/Dialogs/Dialogs.tsx
+++ b/frontend/src/component/FileManager/Dialogs/Dialogs.tsx
@@ -12,6 +12,7 @@ import ManageShares from "./Share/ManageShares.tsx";
import StoragePolicy from "./StoragePolicy.tsx";
import AclDialog from "./Acl/AclDialog.tsx";
import ActivityDialog from "./Activity/ActivityDialog.tsx";
+import AnnouncementDialog from "./AnnouncementDialog.tsx";
import StaleVersionConfirm from "./StaleVersionConfirm.tsx";
import SaveAs from "./SaveAs.tsx";
import Photopea from "../../Viewers/Photopea/Photopea.tsx";
@@ -88,6 +89,7 @@ const Dialogs = () => {
{archivePreview != undefined && }
{desktopMountSetup != undefined && }
+
>
);
};
diff --git a/inventory/types/types.go b/inventory/types/types.go
index 72add853..2de18076 100644
--- a/inventory/types/types.go
+++ b/inventory/types/types.go
@@ -31,6 +31,10 @@ type (
// PreferredPolicy is the user's default storage policy, chosen from
// the policies allowed for their group. 0 means the group default.
PreferredPolicy int `json:"preferred_policy,omitempty"`
+ // DismissedAnnouncement stores the announcement content the user last
+ // dismissed. When the admin edits the announcement it differs from
+ // this value and the modal is shown again.
+ DismissedAnnouncement string `json:"dismissed_announcement,omitempty"`
}
// LBPolicyRef binds a child storage policy to a load_balance policy with
diff --git a/pkg/setting/provider.go b/pkg/setting/provider.go
index 0140cbe7..bbdfe23d 100644
--- a/pkg/setting/provider.go
+++ b/pkg/setting/provider.go
@@ -263,6 +263,9 @@ type (
// AuditLogRetentionDays returns the audit retention window in days;
// 0 keeps events forever.
AuditLogRetentionDays(ctx context.Context) int
+ // Announcement returns the site-wide markdown announcement shown to
+ // logged-in users. "" disables the announcement modal.
+ Announcement(ctx context.Context) string
}
UseFirstSiteUrlCtxKey = struct{}
)
@@ -742,6 +745,10 @@ func (s *settingProvider) AuditLogRetentionDays(ctx context.Context) int {
return s.getInt(ctx, "audit_log_retention_days", 0)
}
+func (s *settingProvider) Announcement(ctx context.Context) string {
+ return s.getString(ctx, "announcement", "")
+}
+
func (s *settingProvider) DefaultShares(ctx context.Context) []int {
raw := s.getString(ctx, "default_symbolics", "")
if raw == "" {
diff --git a/routers/controllers/user.go b/routers/controllers/user.go
index 969ec9f3..e36de3fe 100644
--- a/routers/controllers/user.go
+++ b/routers/controllers/user.go
@@ -251,6 +251,16 @@ func UpdateOption(c *gin.Context) {
c.JSON(200, serializer.Response{})
}
+// UserAnnouncement returns the current site announcement unless dismissed.
+func UserAnnouncement(c *gin.Context) {
+ service := ParametersFromContext[*user.AnnouncementService](c, user.AnnouncementParamCtx{})
+ res, err := service.Get(c)
+ if respondErr(c, err) {
+ return
+ }
+ c.JSON(200, serializer.Response{Data: res})
+}
+
// UserCredit returns the caller's credit balance and active grants.
func UserCredit(c *gin.Context) {
service := ParametersFromContext[*user.CreditService](c, user.CreditParamCtx{})
diff --git a/routers/router.go b/routers/router.go
index 332e47b9..eacbd538 100644
--- a/routers/router.go
+++ b/routers/router.go
@@ -1445,6 +1445,11 @@ func initMasterRouter(dep dependency.Dep) *gin.Engine {
{
// 获取当前用户设定
setting.GET("", controllers.UserSetting)
+ // 当前公告(已忽略时为空)
+ setting.GET("announcement",
+ controllers.FromQuery[usersvc.AnnouncementService](usersvc.AnnouncementParamCtx{}),
+ controllers.UserAnnouncement,
+ )
// 从文件上传头像
setting.PUT("avatar", middleware.RequiredScopes(types.ScopeUserInfoWrite), controllers.UploadAvatar)
// 更改用户设定
diff --git a/service/user/setting.go b/service/user/setting.go
index 08c1632b..3c4c4923 100644
--- a/service/user/setting.go
+++ b/service/user/setting.go
@@ -225,6 +225,29 @@ func updateAvatarFile(ctx context.Context, u *ent.User, contentType string, file
return nil
}
+// AnnouncementService serves the current site announcement to logged-in
+// users, filtered by their dismissal record.
+type AnnouncementService struct{}
+
+type AnnouncementParamCtx struct{}
+
+// Get returns the announcement content, or "" when unset or dismissed.
+func (s *AnnouncementService) Get(c *gin.Context) (*AnnouncementResponse, error) {
+ dep := dependency.FromContext(c)
+ u := inventory.UserFromContext(c)
+
+ current := dep.SettingProvider().Announcement(c)
+ if current == "" || current == u.Settings.DismissedAnnouncement {
+ return &AnnouncementResponse{}, nil
+ }
+
+ return &AnnouncementResponse{Content: current}, nil
+}
+
+type AnnouncementResponse struct {
+ Content string `json:"content,omitempty"`
+}
+
type (
PatchUserSetting struct {
Nick *string `json:"nick" binding:"omitempty,min=1,max=255"`
@@ -250,6 +273,9 @@ type (
// PreferredPolicy selects the user's default storage policy from the
// group's allowed set, hashid-encoded. "" clears the preference.
PreferredPolicy *string `json:"preferred_policy" binding:"omitempty"`
+ // DismissAnnouncement records the current site announcement as seen
+ // so the modal does not show again until the content changes.
+ DismissAnnouncement *bool `json:"dismiss_announcement" binding:"omitempty"`
}
PatchUserSettingParamsCtx struct{}
)
@@ -374,6 +400,11 @@ func (s *PatchUserSetting) Patch(c *gin.Context) error {
saveSetting = true
}
+ if s.DismissAnnouncement != nil && *s.DismissAnnouncement {
+ u.Settings.DismissedAnnouncement = dep.SettingProvider().Announcement(c)
+ saveSetting = true
+ }
+
if s.CurrentPassword != nil && s.NewPassword != nil {
if err := auth.CheckScope(c, types.ScopeUserSecurityInfoWrite); err != nil {
return err