feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -30,8 +30,8 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight mb-6" style={{ color: "var(--ink)" }}>
|
||||
<div className="w-full">
|
||||
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight mb-4 sm:mb-5" style={{ color: "var(--ink)" }}>
|
||||
通知中心
|
||||
</h1>
|
||||
|
||||
@@ -42,15 +42,17 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
|
||||
<Link href="/notifications" className="btn btn-line btn-sm">重新加载</Link>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<NotificationsClient initialData={data} />
|
||||
<Pagination
|
||||
total={data.total}
|
||||
page={data.page}
|
||||
size={data.size}
|
||||
basePath="/notifications"
|
||||
/>
|
||||
</>
|
||||
<div className="grid gap-4 items-start lg:grid-cols-[248px_minmax(0,1fr)]">
|
||||
{/* 左栏 sticky 概览 + 全部已读(客户端组件,行内未读数点击后同步刷新);右列列表 + 分页 */}
|
||||
<NotificationsClient initialData={data} initialUnread={me.unread_count}>
|
||||
<Pagination
|
||||
total={data.total}
|
||||
page={data.page}
|
||||
size={data.size}
|
||||
basePath="/notifications"
|
||||
/>
|
||||
</NotificationsClient>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user