feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -3,18 +3,28 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus, ChevronRight } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
/**
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
* 布局:左栏 sticky 概览(未读数/全部已读)+ 右列紧凑列表(单条两行内)。
*/
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
export default function NotificationsClient({
initialData,
initialUnread,
children,
}: {
initialData: NotificationsResponse;
initialUnread: number;
children?: React.ReactNode;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
const [unreadCount, setUnreadCount] = useState(initialUnread);
const handleClick = async (n: NotificationItem) => {
if (!n.is_read) {
@@ -22,6 +32,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
setUnreadCount((c) => Math.max(0, c - 1));
}
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
@@ -32,9 +43,11 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
return;
} else if (n.type === "follow") {
router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0) {
} else if (n.comment_id > 0 && n.comment_floor) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
router.push(`${postPath(urlStyle, n.post_id)}#comment-${n.comment_id}`);
router.push(
`${postPath(urlStyle, n.post_id)}${commentAnchor(n.comment_floor, n.comment_is_root ?? true, n.comment_id)}`
);
} else {
router.push(postPath(urlStyle, n.post_id));
}
@@ -43,6 +56,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
const handleMarkAll = async () => {
await apiMarkAllRead();
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
setUnreadCount(0);
// 同步刷新 Header 未读红点(layout 重新取 unread_count)
router.refresh();
};
@@ -57,157 +71,176 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
return `${Math.floor(hour / 24)} 天前`;
};
const hasUnread = notifications.some((n) => !n.is_read);
const iconOf = (n: NotificationItem) => {
const t = n.type;
const node =
t === "approved" ? <CheckCircle2 size={15} />
: t === "rejected" || t === "deleted" ? <XCircle size={15} />
: t === "necro_reply" ? <AlertTriangle size={15} />
: t === "like" ? <Heart size={15} />
: t === "mention" ? <AtSign size={15} />
: t === "pending_review" ? <ClipboardList size={15} />
: t === "badge" ? <Award size={15} />
: t === "follow" ? <UserPlus size={15} />
: <MessageCircle size={15} />;
const color =
t === "approved" ? "var(--ok)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger)"
: t === "like" ? "var(--clay)"
: t === "badge" ? "var(--gold)"
: "var(--accent)";
const bg =
t === "approved" ? "var(--ok-soft)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger-soft)"
: t === "like" ? "var(--clay-soft)"
: t === "badge" ? "var(--gold-soft)"
: "var(--accent-soft)";
return { node, color, bg };
};
return (
<>
{hasUnread && (
<div className="flex justify-end mb-4">
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
{/* 左栏:概览 + 全部已读(lg 起 sticky) */}
<aside className="panel p-4 sm:p-5 lg:sticky lg:top-[var(--rail-top)]">
<p className="text-xs font-bold tracking-widest uppercase" style={{ color: "var(--ink-3)" }}>概览</p>
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className="text-[30px] leading-none font-extrabold"
style={{ color: unreadCount > 0 ? "var(--accent)" : "var(--ink)", fontVariantNumeric: "tabular-nums" }}
>
{unreadCount}
</span>
<span className="meta">条未读</span>
</div>
<p className="meta mt-1.5">共 {initialData.total} 条通知</p>
<div className="my-3.5" style={{ borderTop: "1px solid var(--line)" }} />
{unreadCount > 0 ? (
<button onClick={handleMarkAll} className="btn btn-line btn-sm w-full justify-center">
<CheckCheck size={14} /> 全部已读
</button>
</div>
)}
) : (
<p className="meta flex items-center gap-1.5" style={{ color: "var(--ok)" }}>
<CheckCircle2 size={14} /> 通知都已读完
</p>
)}
</aside>
{notifications.length === 0 ? (
<div className="panel text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Bell size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1.5">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const iconNode = n.type === "approved"
? <CheckCircle2 size={16} />
: n.type === "rejected" || n.type === "deleted"
? <XCircle size={16} />
: n.type === "necro_reply"
? <AlertTriangle size={16} />
: n.type === "like"
? <Heart size={16} />
: n.type === "mention"
? <AtSign size={16} />
: n.type === "pending_review"
? <ClipboardList size={16} />
: n.type === "badge"
? <Award size={16} />
: n.type === "follow"
? <UserPlus size={16} />
: <MessageCircle size={16} />;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
: n.type === "badge"
? "var(--gold)"
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
: n.type === "badge"
? "var(--gold-soft)"
: "var(--accent-soft)";
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className={`w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
{/* 右列:紧凑通知列表 + 分页 */}
<div className="min-w-0">
{notifications.length === 0 ? (
<div className="panel text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3.5"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<span
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
style={{ color: iconColor, background: iconBg }}
>
{iconNode}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
{approval ? (
<Bell size={22} />
</span>
<p className="font-bold" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const icon = iconOf(n);
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className="relative w-full text-left pl-4 pr-3 sm:pr-4 py-2.5 flex items-center gap-3 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
>
{/* 未读:左侧主题色竖条(已读透明占位,文本基线对齐不跳) */}
<span
aria-hidden
className="absolute left-0 top-2 bottom-2 w-[3px] rounded-full"
style={{ background: n.is_read ? "transparent" : "var(--accent)" }}
/>
<span
className="w-[30px] h-[30px] shrink-0 rounded-[9px] flex items-center justify-center"
style={{ color: icon.color, background: icon.bg }}
>
{icon.node}
</span>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] leading-snug flex items-baseline gap-x-1.5" style={{ color: "var(--ink)" }}>
{approval ? (
<span
className="font-bold shrink-0"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
</span>
) : system ? (
<span className="font-bold shrink-0" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link
href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()}
className="font-semibold shrink-0 max-w-[10em] truncate hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>
<span className="meta shrink-0">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: n.type === "mention"
? "在群中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
{n.type === "mention"
? n.room && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</span>
)
: n.post && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</span>
)}
<span className="meta text-xs shrink-0 ml-auto pl-2">{formatTime(n.created_at)}</span>
</p>
{n.content && (
<p className="text-[13px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
</div>
{n.type === "follow" && (
<span
className="font-bold"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
className="shrink-0 inline-flex items-center gap-0.5 text-xs font-semibold"
style={{ color: "var(--accent)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
查看主页 <ChevronRight size={13} />
</span>
) : system ? (
<span className="font-bold" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link
href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()}
className="font-semibold hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>{" "}
<span className="meta">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: n.type === "mention"
? "在消息中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
</p>
{n.type === "mention" ? (
n.room && (
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</p>
)
) : (
n.post && (
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</p>
)
)}
{n.content && (
<p className="text-[13px] mt-1.5 line-clamp-2" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
<p className="meta text-xs mt-2">{formatTime(n.created_at)}</p>
</div>
{!n.is_read && (
<span className="w-2.5 h-2.5 shrink-0 mt-2 rounded-full" style={{ background: "var(--clay)" }} />
)}
</button>
);
})}
</div>
)}
</button>
);
})}
</div>
)}
{children}
</div>
</>
);
}