feat(settings): 站点界面动画总开关与子开关
管理后台可关全站动画,并细调代码折叠、平滑滚动、装饰入场;统一 lib/motion,尊重系统减少动态效果。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -29,6 +29,27 @@ type updateSettingsRequest struct {
|
|||||||
PostCooldownHours *int `json:"post_cooldown_hours"`
|
PostCooldownHours *int `json:"post_cooldown_hours"`
|
||||||
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
CodeBlockAutoFold *bool `json:"code_block_auto_fold"`
|
||||||
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
CodeBlockFoldLines *int `json:"code_block_fold_lines"`
|
||||||
|
UIAnimations *bool `json:"ui_animations"`
|
||||||
|
AnimCodeFold *bool `json:"anim_code_fold"`
|
||||||
|
AnimSmoothScroll *bool `json:"anim_smooth_scroll"`
|
||||||
|
AnimChrome *bool `json:"anim_chrome"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func settingsPayload(saved service.PublicSiteSettings) gin.H {
|
||||||
|
return gin.H{
|
||||||
|
"accent": saved.Accent,
|
||||||
|
"trust_reviewed_publish": saved.TrustReviewedPublish,
|
||||||
|
"site_name": saved.SiteName,
|
||||||
|
"site_description": saved.SiteDescription,
|
||||||
|
"allow_register": saved.AllowRegister,
|
||||||
|
"post_cooldown_hours": saved.PostCooldownHours,
|
||||||
|
"code_block_auto_fold": saved.CodeBlockAutoFold,
|
||||||
|
"code_block_fold_lines": saved.CodeBlockFoldLines,
|
||||||
|
"ui_animations": saved.UIAnimations,
|
||||||
|
"anim_code_fold": saved.AnimCodeFold,
|
||||||
|
"anim_smooth_scroll": saved.AnimSmoothScroll,
|
||||||
|
"anim_chrome": saved.AnimChrome,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// PUT /api/admin/settings
|
// PUT /api/admin/settings
|
||||||
@@ -40,7 +61,9 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil &&
|
if req.Accent == nil && req.TrustReviewedPublish == nil && req.SiteName == nil &&
|
||||||
req.SiteDescription == nil && req.AllowRegister == nil &&
|
req.SiteDescription == nil && req.AllowRegister == nil &&
|
||||||
req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil {
|
req.PostCooldownHours == nil && req.CodeBlockAutoFold == nil && req.CodeBlockFoldLines == nil &&
|
||||||
|
req.UIAnimations == nil && req.AnimCodeFold == nil && req.AnimSmoothScroll == nil &&
|
||||||
|
req.AnimChrome == nil {
|
||||||
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
|
c.JSON(http.StatusBadRequest, gin.H{"error": "未提供任何可更新的设置项"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -113,6 +136,30 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if req.UIAnimations != nil {
|
||||||
|
if err := h.Setting.SetUIAnimations(*req.UIAnimations); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.AnimCodeFold != nil {
|
||||||
|
if err := h.Setting.SetAnimCodeFold(*req.AnimCodeFold); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.AnimSmoothScroll != nil {
|
||||||
|
if err := h.Setting.SetAnimSmoothScroll(*req.AnimSmoothScroll); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if req.AnimChrome != nil {
|
||||||
|
if err := h.Setting.SetAnimChrome(*req.AnimChrome); err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": "保存站点设置失败"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
saved, err := h.Setting.Public()
|
saved, err := h.Setting.Public()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -120,29 +167,12 @@ func (h *Handlers) UpdateSettings(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
payload := gin.H{
|
payload := settingsPayload(saved)
|
||||||
"accent": saved.Accent,
|
|
||||||
"trust_reviewed_publish": saved.TrustReviewedPublish,
|
|
||||||
"site_name": saved.SiteName,
|
|
||||||
"site_description": saved.SiteDescription,
|
|
||||||
"allow_register": saved.AllowRegister,
|
|
||||||
"post_cooldown_hours": saved.PostCooldownHours,
|
|
||||||
"code_block_auto_fold": saved.CodeBlockAutoFold,
|
|
||||||
"code_block_fold_lines": saved.CodeBlockFoldLines,
|
|
||||||
}
|
|
||||||
h.Hub.BroadcastAll(realtime.Envelope{
|
h.Hub.BroadcastAll(realtime.Envelope{
|
||||||
Type: realtime.EventSettingsChanged,
|
Type: realtime.EventSettingsChanged,
|
||||||
Data: payload,
|
Data: payload,
|
||||||
})
|
})
|
||||||
c.JSON(http.StatusOK, gin.H{
|
out := settingsPayload(saved)
|
||||||
"ok": true,
|
out["ok"] = true
|
||||||
"accent": saved.Accent,
|
c.JSON(http.StatusOK, out)
|
||||||
"trust_reviewed_publish": saved.TrustReviewedPublish,
|
|
||||||
"site_name": saved.SiteName,
|
|
||||||
"site_description": saved.SiteDescription,
|
|
||||||
"allow_register": saved.AllowRegister,
|
|
||||||
"post_cooldown_hours": saved.PostCooldownHours,
|
|
||||||
"code_block_auto_fold": saved.CodeBlockAutoFold,
|
|
||||||
"code_block_fold_lines": saved.CodeBlockFoldLines,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,6 +25,14 @@ const (
|
|||||||
SettingKeyCodeBlockAutoFold = "code_block_auto_fold"
|
SettingKeyCodeBlockAutoFold = "code_block_auto_fold"
|
||||||
// SettingKeyCodeBlockFoldLines 超过该行数则折叠;0=无预览;缺行=5
|
// SettingKeyCodeBlockFoldLines 超过该行数则折叠;0=无预览;缺行=5
|
||||||
SettingKeyCodeBlockFoldLines = "code_block_fold_lines"
|
SettingKeyCodeBlockFoldLines = "code_block_fold_lines"
|
||||||
|
// SettingKeyUIAnimations 界面动画总开关;缺行视为开启
|
||||||
|
SettingKeyUIAnimations = "ui_animations"
|
||||||
|
// SettingKeyAnimCodeFold 代码折叠高度/跟滚动画;缺行视为开启
|
||||||
|
SettingKeyAnimCodeFold = "anim_code_fold"
|
||||||
|
// SettingKeyAnimSmoothScroll 平滑滚动;缺行视为开启
|
||||||
|
SettingKeyAnimSmoothScroll = "anim_smooth_scroll"
|
||||||
|
// SettingKeyAnimChrome 装饰入场(列表/Toast/点赞等);缺行视为开启
|
||||||
|
SettingKeyAnimChrome = "anim_chrome"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -57,6 +65,10 @@ type PublicSiteSettings struct {
|
|||||||
PostCooldownHours int `json:"post_cooldown_hours"`
|
PostCooldownHours int `json:"post_cooldown_hours"`
|
||||||
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
CodeBlockAutoFold bool `json:"code_block_auto_fold"`
|
||||||
CodeBlockFoldLines int `json:"code_block_fold_lines"`
|
CodeBlockFoldLines int `json:"code_block_fold_lines"`
|
||||||
|
UIAnimations bool `json:"ui_animations"`
|
||||||
|
AnimCodeFold bool `json:"anim_code_fold"`
|
||||||
|
AnimSmoothScroll bool `json:"anim_smooth_scroll"`
|
||||||
|
AnimChrome bool `json:"anim_chrome"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SettingService 站点级键值设置
|
// SettingService 站点级键值设置
|
||||||
@@ -119,6 +131,10 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
PostCooldownHours: DefaultCooldownHours,
|
PostCooldownHours: DefaultCooldownHours,
|
||||||
CodeBlockAutoFold: true,
|
CodeBlockAutoFold: true,
|
||||||
CodeBlockFoldLines: DefaultCodeFoldLines,
|
CodeBlockFoldLines: DefaultCodeFoldLines,
|
||||||
|
UIAnimations: true,
|
||||||
|
AnimCodeFold: true,
|
||||||
|
AnimSmoothScroll: true,
|
||||||
|
AnimChrome: true,
|
||||||
}
|
}
|
||||||
accent, err := s.AccentColor()
|
accent, err := s.AccentColor()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -174,6 +190,30 @@ func (s *SettingService) Public() (PublicSiteSettings, error) {
|
|||||||
return out, err
|
return out, err
|
||||||
}
|
}
|
||||||
out.CodeBlockFoldLines = foldLines
|
out.CodeBlockFoldLines = foldLines
|
||||||
|
|
||||||
|
uiAnim, err := s.UIAnimations()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.UIAnimations = uiAnim
|
||||||
|
|
||||||
|
animFold, err := s.AnimCodeFold()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.AnimCodeFold = animFold
|
||||||
|
|
||||||
|
animScroll, err := s.AnimSmoothScroll()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.AnimSmoothScroll = animScroll
|
||||||
|
|
||||||
|
animChrome, err := s.AnimChrome()
|
||||||
|
if err != nil {
|
||||||
|
return out, err
|
||||||
|
}
|
||||||
|
out.AnimChrome = animChrome
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -318,3 +358,55 @@ func (s *SettingService) SetCodeBlockFoldLines(lines int) error {
|
|||||||
}
|
}
|
||||||
return s.putValue(SettingKeyCodeBlockFoldLines, strconv.Itoa(lines))
|
return s.putValue(SettingKeyCodeBlockFoldLines, strconv.Itoa(lines))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UIAnimations 界面动画总开关。缺行视为开启;关闭后全站减少动态效果。
|
||||||
|
func (s *SettingService) UIAnimations() (bool, error) {
|
||||||
|
v, found, err := s.getValue(SettingKeyUIAnimations)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return parseBoolDefaultTrue(v, found), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetUIAnimations(on bool) error {
|
||||||
|
return s.setBoolDefaultTrue(SettingKeyUIAnimations, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnimCodeFold 代码折叠动画子开关(总开关开启时生效)。
|
||||||
|
func (s *SettingService) AnimCodeFold() (bool, error) {
|
||||||
|
v, found, err := s.getValue(SettingKeyAnimCodeFold)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return parseBoolDefaultTrue(v, found), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetAnimCodeFold(on bool) error {
|
||||||
|
return s.setBoolDefaultTrue(SettingKeyAnimCodeFold, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnimSmoothScroll 平滑滚动子开关(总开关开启时生效)。
|
||||||
|
func (s *SettingService) AnimSmoothScroll() (bool, error) {
|
||||||
|
v, found, err := s.getValue(SettingKeyAnimSmoothScroll)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return parseBoolDefaultTrue(v, found), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetAnimSmoothScroll(on bool) error {
|
||||||
|
return s.setBoolDefaultTrue(SettingKeyAnimSmoothScroll, on)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AnimChrome 装饰入场子开关(列表/Toast/点赞等;总开关开启时生效)。
|
||||||
|
func (s *SettingService) AnimChrome() (bool, error) {
|
||||||
|
v, found, err := s.getValue(SettingKeyAnimChrome)
|
||||||
|
if err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
return parseBoolDefaultTrue(v, found), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SettingService) SetAnimChrome(on bool) error {
|
||||||
|
return s.setBoolDefaultTrue(SettingKeyAnimChrome, on)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Globe,
|
Globe,
|
||||||
Loader2,
|
Loader2,
|
||||||
Save,
|
Save,
|
||||||
|
Sparkles,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {
|
import {
|
||||||
@@ -63,6 +64,13 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
const [togglingReg, setTogglingReg] = useState(false);
|
const [togglingReg, setTogglingReg] = useState(false);
|
||||||
const [togglingFold, setTogglingFold] = useState(false);
|
const [togglingFold, setTogglingFold] = useState(false);
|
||||||
const [savingFoldLines, setSavingFoldLines] = useState(false);
|
const [savingFoldLines, setSavingFoldLines] = useState(false);
|
||||||
|
const [uiAnimations, setUiAnimations] = useState(initial.ui_animations !== false);
|
||||||
|
const [animCodeFold, setAnimCodeFold] = useState(initial.anim_code_fold !== false);
|
||||||
|
const [animSmoothScroll, setAnimSmoothScroll] = useState(
|
||||||
|
initial.anim_smooth_scroll !== false
|
||||||
|
);
|
||||||
|
const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false);
|
||||||
|
const [togglingMotion, setTogglingMotion] = useState(false);
|
||||||
|
|
||||||
const dirty =
|
const dirty =
|
||||||
siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
|
siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
|
||||||
@@ -151,6 +159,32 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const toggleMotionField = async (
|
||||||
|
key: "ui_animations" | "anim_code_fold" | "anim_smooth_scroll" | "anim_chrome",
|
||||||
|
next: boolean
|
||||||
|
) => {
|
||||||
|
if (togglingMotion) return;
|
||||||
|
setTogglingMotion(true);
|
||||||
|
try {
|
||||||
|
const res = await apiUpdateSiteSettings({ [key]: next });
|
||||||
|
setUiAnimations(res.ui_animations);
|
||||||
|
setAnimCodeFold(res.anim_code_fold);
|
||||||
|
setAnimSmoothScroll(res.anim_smooth_scroll);
|
||||||
|
setAnimChrome(res.anim_chrome);
|
||||||
|
const labels: Record<typeof key, [string, string]> = {
|
||||||
|
ui_animations: ["已开启界面动画", "已关闭界面动画(全站减少动态效果)"],
|
||||||
|
anim_code_fold: ["已开启代码折叠动画", "已关闭代码折叠动画"],
|
||||||
|
anim_smooth_scroll: ["已开启平滑滚动", "已关闭平滑滚动"],
|
||||||
|
anim_chrome: ["已开启装饰入场动画", "已关闭装饰入场动画"],
|
||||||
|
};
|
||||||
|
toast(next ? labels[key][0] : labels[key][1], "ok");
|
||||||
|
} catch (e) {
|
||||||
|
toast(e instanceof Error ? e.message : "保存失败");
|
||||||
|
} finally {
|
||||||
|
setTogglingMotion(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-3xl">
|
<div className="max-w-3xl">
|
||||||
<div className="mb-5">
|
<div className="mb-5">
|
||||||
@@ -286,6 +320,79 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section className="j13-module p-5 sm:p-6 mb-5">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<h2 className="text-[15px] font-semibold flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
|
||||||
|
<Sparkles size={15} /> 界面动画
|
||||||
|
</h2>
|
||||||
|
<p className="meta text-[12.5px] mt-1 leading-relaxed">
|
||||||
|
总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={uiAnimations}
|
||||||
|
disabled={togglingMotion}
|
||||||
|
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
|
||||||
|
label="界面动画总开关"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
{uiAnimations ? (
|
||||||
|
<div className="mt-4 pt-4 border-t border-[var(--line)] space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
代码折叠动画
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||||
|
代码块展开/收起的高度过渡与跟滚。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={animCodeFold}
|
||||||
|
disabled={togglingMotion}
|
||||||
|
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
|
||||||
|
label="代码折叠动画"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
平滑滚动
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||||
|
目录跳转、回顶、新内容提示等 scroll 行为。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={animSmoothScroll}
|
||||||
|
disabled={togglingMotion}
|
||||||
|
onChange={() =>
|
||||||
|
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
|
||||||
|
}
|
||||||
|
label="平滑滚动"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-start justify-between gap-4 flex-wrap">
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
|
||||||
|
装饰入场
|
||||||
|
</p>
|
||||||
|
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
|
||||||
|
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
checked={animChrome}
|
||||||
|
disabled={togglingMotion}
|
||||||
|
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
|
||||||
|
label="装饰入场"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,7 @@ import type {
|
|||||||
} from "@/lib/realtime";
|
} from "@/lib/realtime";
|
||||||
import { canOverseeChat } from "@/lib/roles";
|
import { canOverseeChat } from "@/lib/roles";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { scrollBehavior } from "@/lib/motion";
|
||||||
import Avatar from "@/components/Avatar";
|
import Avatar from "@/components/Avatar";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||||
@@ -870,7 +871,7 @@ export default function ChatRoomClient({
|
|||||||
|
|
||||||
const scrollToMsg = (id: number) => {
|
const scrollToMsg = (id: number) => {
|
||||||
const el = msgRefs.current.get(id);
|
const el = msgRefs.current.get(id);
|
||||||
el?.scrollIntoView({ behavior: "smooth", block: "center" });
|
el?.scrollIntoView({ behavior: scrollBehavior(), block: "center" });
|
||||||
};
|
};
|
||||||
|
|
||||||
const msgMenuItems = (m: ChatMessage): CtxMenuItem[] => {
|
const msgMenuItems = (m: ChatMessage): CtxMenuItem[] => {
|
||||||
|
|||||||
@@ -1888,6 +1888,9 @@ a.j13-mention-token:hover {
|
|||||||
transition: none !important;
|
transition: none !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
html.j13-reduce-motion .prose-content .md-code-body {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
.prose-content .md-code-fold {
|
.prose-content .md-code-fold {
|
||||||
display: flex;
|
display: flex;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -2164,6 +2167,23 @@ html.dark .prose-content pre[data-theme] span {
|
|||||||
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
* { animation-duration: 0.01ms !important; transition-duration: 0.01ms !important; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 站点关闭界面动画总开关(与系统 reduce 等效) */
|
||||||
|
html.j13-reduce-motion * {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 仅关闭装饰入场子开关 */
|
||||||
|
html.j13-reduce-chrome .j13-fade-up,
|
||||||
|
html.j13-reduce-chrome .j13-toast-in,
|
||||||
|
html.j13-reduce-chrome .j13-heart-pop,
|
||||||
|
html.j13-reduce-chrome .j13-pulse-dot,
|
||||||
|
html.j13-reduce-chrome .j13-tip-bubble,
|
||||||
|
html.j13-reduce-chrome .j13-tip-in {
|
||||||
|
animation: none !important;
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
/* 头像配色(浅/暗各自保证可读) */
|
/* 头像配色(浅/暗各自保证可读) */
|
||||||
.av-0 { background: rgba(52, 81, 200, 0.12); color: #3451c8; }
|
.av-0 { background: rgba(52, 81, 200, 0.12); color: #3451c8; }
|
||||||
.av-1 { background: rgba(10, 99, 117, 0.12); color: #0a6375; }
|
.av-1 { background: rgba(10, 99, 117, 0.12); color: #0a6375; }
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import AccountGuard from "@/components/AccountGuard";
|
|||||||
import ThemeSync from "@/components/ThemeSync";
|
import ThemeSync from "@/components/ThemeSync";
|
||||||
import RealtimeProvider from "@/components/RealtimeProvider";
|
import RealtimeProvider from "@/components/RealtimeProvider";
|
||||||
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
|
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
|
||||||
|
import MotionProvider from "@/components/MotionProvider";
|
||||||
import type { User } from "@/lib/api";
|
import type { User } from "@/lib/api";
|
||||||
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
|
||||||
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
import { TOKEN_COOKIE, authCookieHeader } from "@/lib/cookies";
|
||||||
import { accentStyleCSS } from "@/lib/theme";
|
import { accentStyleCSS } from "@/lib/theme";
|
||||||
|
import { motionConfigFromSettings } from "@/lib/motion";
|
||||||
|
|
||||||
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
// 构建期下载并自托管字体:同源加载 + 自动 preload + fallback 度量校正,
|
||||||
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
// 消除 F5 时 Google CDN 字体到达后整页"由粗变细"的 FOUT 闪动
|
||||||
@@ -73,6 +75,13 @@ export default async function RootLayout({
|
|||||||
const settings = await getPublicSettingsCached();
|
const settings = await getPublicSettingsCached();
|
||||||
const accent = settings.accent;
|
const accent = settings.accent;
|
||||||
const accentCSS = accentStyleCSS(accent || "");
|
const accentCSS = accentStyleCSS(accent || "");
|
||||||
|
const motionCfg = motionConfigFromSettings(settings);
|
||||||
|
const motionHtmlClass = [
|
||||||
|
!motionCfg.ui_animations ? "j13-reduce-motion" : "",
|
||||||
|
motionCfg.ui_animations && !motionCfg.anim_chrome ? "j13-reduce-chrome" : "",
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(" ");
|
||||||
|
|
||||||
let user: User | null = null;
|
let user: User | null = null;
|
||||||
let unread = 0;
|
let unread = 0;
|
||||||
@@ -89,7 +98,7 @@ export default async function RootLayout({
|
|||||||
return (
|
return (
|
||||||
<html
|
<html
|
||||||
lang="zh-CN"
|
lang="zh-CN"
|
||||||
className={`${theme === "dark" ? "dark" : ""} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`}
|
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
|
||||||
>
|
>
|
||||||
<body className="min-h-screen flex flex-col">
|
<body className="min-h-screen flex flex-col">
|
||||||
{accentCSS ? (
|
{accentCSS ? (
|
||||||
@@ -116,12 +125,14 @@ export default async function RootLayout({
|
|||||||
tabIndex={-1}
|
tabIndex={-1}
|
||||||
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none"
|
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none"
|
||||||
>
|
>
|
||||||
<CodeFoldProvider
|
<MotionProvider initial={motionCfg}>
|
||||||
initialEnabled={settings.code_block_auto_fold}
|
<CodeFoldProvider
|
||||||
initialFoldLines={settings.code_block_fold_lines}
|
initialEnabled={settings.code_block_auto_fold}
|
||||||
>
|
initialFoldLines={settings.code_block_fold_lines}
|
||||||
{children}
|
>
|
||||||
</CodeFoldProvider>
|
{children}
|
||||||
|
</CodeFoldProvider>
|
||||||
|
</MotionProvider>
|
||||||
</main>
|
</main>
|
||||||
<Footer siteName={settings.site_name} />
|
<Footer siteName={settings.site_name} />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { ArrowUp, MessageSquare } from "lucide-react";
|
import { ArrowUp, MessageSquare } from "lucide-react";
|
||||||
import LikeButton from "./LikeButton";
|
import LikeButton from "./LikeButton";
|
||||||
|
import { scrollBehavior } from "@/lib/motion";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 帖子详情移动端浮动操作条(lg 以下显示):
|
* 帖子详情移动端浮动操作条(lg 以下显示):
|
||||||
@@ -28,7 +29,7 @@ export default function MobilePostBar({
|
|||||||
|
|
||||||
const scrollToComments = () => {
|
const scrollToComments = () => {
|
||||||
document.getElementById("comment-input")?.scrollIntoView({
|
document.getElementById("comment-input")?.scrollIntoView({
|
||||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
behavior: scrollBehavior(),
|
||||||
block: "center",
|
block: "center",
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -36,7 +37,7 @@ export default function MobilePostBar({
|
|||||||
const scrollToTop = () => {
|
const scrollToTop = () => {
|
||||||
window.scrollTo({
|
window.scrollTo({
|
||||||
top: 0,
|
top: 0,
|
||||||
behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
|
behavior: scrollBehavior(),
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
52
frontend/components/MotionProvider.tsx
Normal file
52
frontend/components/MotionProvider.tsx
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, type ReactNode } from "react";
|
||||||
|
import {
|
||||||
|
applyMotionSiteConfig,
|
||||||
|
getMotionSiteConfig,
|
||||||
|
motionConfigFromSettings,
|
||||||
|
type MotionSiteConfig,
|
||||||
|
} from "@/lib/motion";
|
||||||
|
import { realtime, RT_SETTINGS_CHANGED, type RtSettingsData } from "@/lib/realtime";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根布局注入:SSR 初值写入 motion 模块 + html class;settings:changed 热更新。
|
||||||
|
*/
|
||||||
|
export default function MotionProvider({
|
||||||
|
initial,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
initial: MotionSiteConfig;
|
||||||
|
children: ReactNode;
|
||||||
|
}) {
|
||||||
|
useEffect(() => {
|
||||||
|
applyMotionSiteConfig(motionConfigFromSettings(initial));
|
||||||
|
}, [initial]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return realtime.on<RtSettingsData>(RT_SETTINGS_CHANGED, (data) => {
|
||||||
|
if (!data) return;
|
||||||
|
const cur = getMotionSiteConfig();
|
||||||
|
applyMotionSiteConfig(
|
||||||
|
motionConfigFromSettings({
|
||||||
|
ui_animations:
|
||||||
|
typeof data.ui_animations === "boolean"
|
||||||
|
? data.ui_animations
|
||||||
|
: cur.ui_animations,
|
||||||
|
anim_code_fold:
|
||||||
|
typeof data.anim_code_fold === "boolean"
|
||||||
|
? data.anim_code_fold
|
||||||
|
: cur.anim_code_fold,
|
||||||
|
anim_smooth_scroll:
|
||||||
|
typeof data.anim_smooth_scroll === "boolean"
|
||||||
|
? data.anim_smooth_scroll
|
||||||
|
: cur.anim_smooth_scroll,
|
||||||
|
anim_chrome:
|
||||||
|
typeof data.anim_chrome === "boolean" ? data.anim_chrome : cur.anim_chrome,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return children;
|
||||||
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { scrollBehavior } from "@/lib/motion";
|
||||||
|
|
||||||
export interface TocItem {
|
export interface TocItem {
|
||||||
level: 2 | 3;
|
level: 2 | 3;
|
||||||
@@ -44,13 +45,12 @@ export default function PostToc({ items }: { items: TocItem[] }) {
|
|||||||
}
|
}
|
||||||
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
|
const el = navRef.current?.querySelector<HTMLElement>(`[data-toc-id="${active}"]`);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
const reduce = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
el.scrollIntoView({ block: "nearest", behavior: scrollBehavior() });
|
||||||
el.scrollIntoView({ block: "nearest", behavior: reduce ? "auto" : "smooth" });
|
|
||||||
}, [active]);
|
}, [active]);
|
||||||
|
|
||||||
const jump = (id: string) => (e: React.MouseEvent) => {
|
const jump = (id: string) => (e: React.MouseEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
document.getElementById(id)?.scrollIntoView({ behavior: "smooth", block: "start" });
|
document.getElementById(id)?.scrollIntoView({ behavior: scrollBehavior(), block: "start" });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
type RtFeedChangedData,
|
type RtFeedChangedData,
|
||||||
} from "@/lib/realtime";
|
} from "@/lib/realtime";
|
||||||
import type { FeedSort } from "@/components/home/FeedTabs";
|
import type { FeedSort } from "@/components/home/FeedTabs";
|
||||||
|
import { scrollBehavior } from "@/lib/motion";
|
||||||
|
|
||||||
interface FeedNewTipProps {
|
interface FeedNewTipProps {
|
||||||
sort: FeedSort;
|
sort: FeedSort;
|
||||||
@@ -52,7 +53,7 @@ export default function FeedNewTip({
|
|||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
setVisible(false);
|
setVisible(false);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
window.scrollTo({ top: 0, behavior: scrollBehavior() });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
|
import { Check, Copy, ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { toast } from "@/lib/toast";
|
import { toast } from "@/lib/toast";
|
||||||
|
import { allowMotion } from "@/lib/motion";
|
||||||
import { useCodeFoldConfig } from "./CodeFoldProvider";
|
import { useCodeFoldConfig } from "./CodeFoldProvider";
|
||||||
|
|
||||||
const FOLD_ANIM_MS = 360;
|
const FOLD_ANIM_MS = 360;
|
||||||
@@ -34,10 +35,7 @@ function measureCollapsedPx(host: HTMLElement, foldLines: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function prefersReducedMotion(): boolean {
|
function prefersReducedMotion(): boolean {
|
||||||
return (
|
return !allowMotion("codeFold");
|
||||||
typeof window !== "undefined" &&
|
|
||||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type MdCodeBlockProps = HTMLAttributes<HTMLPreElement> & {
|
type MdCodeBlockProps = HTMLAttributes<HTMLPreElement> & {
|
||||||
|
|||||||
@@ -552,6 +552,14 @@ export interface PublicSettings {
|
|||||||
code_block_auto_fold: boolean;
|
code_block_auto_fold: boolean;
|
||||||
/** 折叠阈值行数(超过则折叠;0=无预览);缺省 5 */
|
/** 折叠阈值行数(超过则折叠;0=无预览);缺省 5 */
|
||||||
code_block_fold_lines: number;
|
code_block_fold_lines: number;
|
||||||
|
/** 界面动画总开关;缺省 true */
|
||||||
|
ui_animations: boolean;
|
||||||
|
/** 代码折叠动画子开关;缺省 true */
|
||||||
|
anim_code_fold: boolean;
|
||||||
|
/** 平滑滚动子开关;缺省 true */
|
||||||
|
anim_smooth_scroll: boolean;
|
||||||
|
/** 装饰入场子开关;缺省 true */
|
||||||
|
anim_chrome: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||||
@@ -563,6 +571,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
|||||||
post_cooldown_hours: 24,
|
post_cooldown_hours: 24,
|
||||||
code_block_auto_fold: true,
|
code_block_auto_fold: true,
|
||||||
code_block_fold_lines: 5,
|
code_block_fold_lines: 5,
|
||||||
|
ui_animations: true,
|
||||||
|
anim_code_fold: true,
|
||||||
|
anim_smooth_scroll: true,
|
||||||
|
anim_chrome: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
|
function normalizePublicSettings(data: Partial<PublicSettings> | null | undefined): PublicSettings {
|
||||||
@@ -586,6 +598,10 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
|||||||
data.code_block_fold_lines <= 100
|
data.code_block_fold_lines <= 100
|
||||||
? Math.round(data.code_block_fold_lines)
|
? Math.round(data.code_block_fold_lines)
|
||||||
: DEFAULT_PUBLIC_SETTINGS.code_block_fold_lines,
|
: DEFAULT_PUBLIC_SETTINGS.code_block_fold_lines,
|
||||||
|
ui_animations: data?.ui_animations !== false,
|
||||||
|
anim_code_fold: data?.anim_code_fold !== false,
|
||||||
|
anim_smooth_scroll: data?.anim_smooth_scroll !== false,
|
||||||
|
anim_chrome: data?.anim_chrome !== false,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -931,10 +947,30 @@ export async function apiDrawLottery(postId: string) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function apiDeletePost(postId: string) {
|
/** 管理删帖类型(与后端 model.PostDeleteType* 一致) */
|
||||||
|
export const POST_DELETE_TYPES = [
|
||||||
|
{ value: "spam", label: "垃圾广告" },
|
||||||
|
{ value: "violation", label: "违规内容" },
|
||||||
|
{ value: "off_topic", label: "灌水跑题" },
|
||||||
|
{ value: "duplicate", label: "重复内容" },
|
||||||
|
{ value: "other", label: "其他" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
export function postDeleteTypeLabel(t?: string): string {
|
||||||
|
return POST_DELETE_TYPES.find((x) => x.value === t)?.label || t || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function apiDeletePost(
|
||||||
|
postId: string,
|
||||||
|
opts?: { delete_type?: string; delete_reason?: string }
|
||||||
|
) {
|
||||||
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
|
const res = await fetchWithRefresh(`/api/posts/${postId}`, {
|
||||||
method: "DELETE",
|
method: "DELETE",
|
||||||
headers: clientHeaders(),
|
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||||
|
body: JSON.stringify({
|
||||||
|
delete_type: opts?.delete_type || "",
|
||||||
|
delete_reason: opts?.delete_reason || "",
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
@@ -1073,6 +1109,15 @@ export async function apiDeleteComment(postId: string, commentId: number) {
|
|||||||
return res.json();
|
return res.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** 版主硬删评论及子树(不占位) */
|
||||||
|
export async function apiPurgeComment(postId: string, commentId: number) {
|
||||||
|
const res = await fetchWithRefresh(`/api/posts/${postId}/comments/${commentId}/purge`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: clientHeaders(),
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
// ===== 通知 =====
|
// ===== 通知 =====
|
||||||
|
|
||||||
export async function apiFetchNotifications(page = 1): Promise<NotificationsResponse> {
|
export async function apiFetchNotifications(page = 1): Promise<NotificationsResponse> {
|
||||||
@@ -1488,6 +1533,10 @@ export type UpdateSiteSettingsBody = {
|
|||||||
post_cooldown_hours?: number;
|
post_cooldown_hours?: number;
|
||||||
code_block_auto_fold?: boolean;
|
code_block_auto_fold?: boolean;
|
||||||
code_block_fold_lines?: number;
|
code_block_fold_lines?: number;
|
||||||
|
ui_animations?: boolean;
|
||||||
|
anim_code_fold?: boolean;
|
||||||
|
anim_smooth_scroll?: boolean;
|
||||||
|
anim_chrome?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||||
@@ -1657,16 +1706,26 @@ export async function apiAdminContentComments(
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function contentAction(url: string, method: string): Promise<void> {
|
async function contentAction(
|
||||||
const res = await fetchWithRefresh(url, { method, headers: clientHeaders() });
|
url: string,
|
||||||
|
method: string,
|
||||||
|
body?: Record<string, unknown>
|
||||||
|
): Promise<void> {
|
||||||
|
const res = await fetchWithRefresh(url, {
|
||||||
|
method,
|
||||||
|
headers: clientHeaders(body ? { "Content-Type": "application/json" } : undefined),
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const data = await res.json().catch(() => ({}));
|
const data = await res.json().catch(() => ({}));
|
||||||
throw new Error(data.error || "操作失败");
|
throw new Error(data.error || "操作失败");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const apiAdminSoftDeletePost = (id: number) =>
|
export const apiAdminSoftDeletePost = (
|
||||||
contentAction(`/api/admin/content/posts/${id}`, "DELETE");
|
id: number,
|
||||||
|
opts: { delete_type: string; delete_reason: string }
|
||||||
|
) => contentAction(`/api/admin/content/posts/${id}`, "DELETE", opts);
|
||||||
export const apiAdminRestorePost = (id: number) =>
|
export const apiAdminRestorePost = (id: number) =>
|
||||||
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
|
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
|
||||||
export const apiAdminPurgePost = (id: number) =>
|
export const apiAdminPurgePost = (id: number) =>
|
||||||
|
|||||||
89
frontend/lib/motion.ts
Normal file
89
frontend/lib/motion.ts
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/**
|
||||||
|
* 全站动画策略:系统「减少动态效果」优先;站点总开关/子开关可进一步关闭。
|
||||||
|
* 新增性能相关动画时:CSS 挂 j13-reduce-*,JS 走 allowMotion / scrollBehavior。
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type MotionKind = "codeFold" | "smoothScroll" | "chrome";
|
||||||
|
|
||||||
|
export type MotionSiteConfig = {
|
||||||
|
/** 界面动画总开关 */
|
||||||
|
ui_animations: boolean;
|
||||||
|
/** 代码折叠高度与跟滚 */
|
||||||
|
anim_code_fold: boolean;
|
||||||
|
/** 平滑滚动 */
|
||||||
|
anim_smooth_scroll: boolean;
|
||||||
|
/** 装饰入场:列表/Toast/点赞/脉冲等 */
|
||||||
|
anim_chrome: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const DEFAULT_MOTION_CONFIG: MotionSiteConfig = {
|
||||||
|
ui_animations: true,
|
||||||
|
anim_code_fold: true,
|
||||||
|
anim_smooth_scroll: true,
|
||||||
|
anim_chrome: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
let siteConfig: MotionSiteConfig = { ...DEFAULT_MOTION_CONFIG };
|
||||||
|
|
||||||
|
export function getMotionSiteConfig(): MotionSiteConfig {
|
||||||
|
return siteConfig;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function motionConfigFromSettings(s: {
|
||||||
|
ui_animations?: boolean;
|
||||||
|
anim_code_fold?: boolean;
|
||||||
|
anim_smooth_scroll?: boolean;
|
||||||
|
anim_chrome?: boolean;
|
||||||
|
}): MotionSiteConfig {
|
||||||
|
return {
|
||||||
|
ui_animations: s.ui_animations !== false,
|
||||||
|
anim_code_fold: s.anim_code_fold !== false,
|
||||||
|
anim_smooth_scroll: s.anim_smooth_scroll !== false,
|
||||||
|
anim_chrome: s.anim_chrome !== false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function osPrefersReducedMotion(): boolean {
|
||||||
|
return (
|
||||||
|
typeof window !== "undefined" &&
|
||||||
|
window.matchMedia("(prefers-reduced-motion: reduce)").matches
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 是否允许指定类动画(OS reduce 或总开关关闭则一律否) */
|
||||||
|
export function allowMotion(kind: MotionKind = "chrome"): boolean {
|
||||||
|
if (osPrefersReducedMotion()) return false;
|
||||||
|
if (!siteConfig.ui_animations) return false;
|
||||||
|
switch (kind) {
|
||||||
|
case "codeFold":
|
||||||
|
return siteConfig.anim_code_fold;
|
||||||
|
case "smoothScroll":
|
||||||
|
return siteConfig.anim_smooth_scroll;
|
||||||
|
case "chrome":
|
||||||
|
return siteConfig.anim_chrome;
|
||||||
|
default:
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function scrollBehavior(): ScrollBehavior {
|
||||||
|
return allowMotion("smoothScroll") ? "smooth" : "auto";
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 同步 documentElement class,供 CSS 与 SSR 初值一致 */
|
||||||
|
export function syncMotionHtmlClasses(cfg: MotionSiteConfig = siteConfig): void {
|
||||||
|
if (typeof document === "undefined") return;
|
||||||
|
const root = document.documentElement;
|
||||||
|
root.classList.toggle("j13-reduce-motion", !cfg.ui_animations);
|
||||||
|
root.classList.toggle(
|
||||||
|
"j13-reduce-chrome",
|
||||||
|
cfg.ui_animations && !cfg.anim_chrome
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 应用站点动画配置(SSR 初值 / WS 热更新) */
|
||||||
|
export function applyMotionSiteConfig(cfg: MotionSiteConfig): MotionSiteConfig {
|
||||||
|
siteConfig = motionConfigFromSettings(cfg);
|
||||||
|
syncMotionHtmlClasses(siteConfig);
|
||||||
|
return siteConfig;
|
||||||
|
}
|
||||||
@@ -47,6 +47,10 @@ export interface RtSettingsData {
|
|||||||
post_cooldown_hours?: number;
|
post_cooldown_hours?: number;
|
||||||
code_block_auto_fold?: boolean;
|
code_block_auto_fold?: boolean;
|
||||||
code_block_fold_lines?: number;
|
code_block_fold_lines?: number;
|
||||||
|
ui_animations?: boolean;
|
||||||
|
anim_code_fold?: boolean;
|
||||||
|
anim_smooth_scroll?: boolean;
|
||||||
|
anim_chrome?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||||
|
|||||||
Reference in New Issue
Block a user