feat(settings): 站点界面动画总开关与子开关
管理后台可关全站动画,并细调代码折叠、平滑滚动、装饰入场;统一 lib/motion,尊重系统减少动态效果。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -552,6 +552,14 @@ export interface PublicSettings {
|
||||
code_block_auto_fold: boolean;
|
||||
/** 折叠阈值行数(超过则折叠;0=无预览);缺省 5 */
|
||||
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 = {
|
||||
@@ -563,6 +571,10 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
post_cooldown_hours: 24,
|
||||
code_block_auto_fold: true,
|
||||
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 {
|
||||
@@ -586,6 +598,10 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
data.code_block_fold_lines <= 100
|
||||
? Math.round(data.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();
|
||||
}
|
||||
|
||||
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}`, {
|
||||
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();
|
||||
}
|
||||
@@ -1073,6 +1109,15 @@ export async function apiDeleteComment(postId: string, commentId: number) {
|
||||
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> {
|
||||
@@ -1488,6 +1533,10 @@ export type UpdateSiteSettingsBody = {
|
||||
post_cooldown_hours?: number;
|
||||
code_block_auto_fold?: boolean;
|
||||
code_block_fold_lines?: number;
|
||||
ui_animations?: boolean;
|
||||
anim_code_fold?: boolean;
|
||||
anim_smooth_scroll?: boolean;
|
||||
anim_chrome?: boolean;
|
||||
};
|
||||
|
||||
// 更新站点设置(字段可选);accent 传空串恢复默认主题色
|
||||
@@ -1657,16 +1706,26 @@ export async function apiAdminContentComments(
|
||||
return data;
|
||||
}
|
||||
|
||||
async function contentAction(url: string, method: string): Promise<void> {
|
||||
const res = await fetchWithRefresh(url, { method, headers: clientHeaders() });
|
||||
async function contentAction(
|
||||
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) {
|
||||
const data = await res.json().catch(() => ({}));
|
||||
throw new Error(data.error || "操作失败");
|
||||
}
|
||||
}
|
||||
|
||||
export const apiAdminSoftDeletePost = (id: number) =>
|
||||
contentAction(`/api/admin/content/posts/${id}`, "DELETE");
|
||||
export const apiAdminSoftDeletePost = (
|
||||
id: number,
|
||||
opts: { delete_type: string; delete_reason: string }
|
||||
) => contentAction(`/api/admin/content/posts/${id}`, "DELETE", opts);
|
||||
export const apiAdminRestorePost = (id: number) =>
|
||||
contentAction(`/api/admin/content/posts/${id}/restore`, "PUT");
|
||||
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;
|
||||
code_block_auto_fold?: boolean;
|
||||
code_block_fold_lines?: number;
|
||||
ui_animations?: boolean;
|
||||
anim_code_fold?: boolean;
|
||||
anim_smooth_scroll?: boolean;
|
||||
anim_chrome?: boolean;
|
||||
}
|
||||
|
||||
// 群聊新消息:data 为落库的 ChatMessage(含 sender)
|
||||
|
||||
Reference in New Issue
Block a user