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

@@ -18,6 +18,7 @@ import {
Copy,
ChevronsDown,
Smile,
VolumeX,
} from "lucide-react";
import {
apiListChatMessages,
@@ -41,11 +42,13 @@ import {
RT_CHAT_MESSAGE,
RT_CHAT_MEMBERSHIP,
RT_CHAT_RECALLED,
RT_CHAT_MEMBER_MUTED,
} from "@/lib/realtime";
import type {
RtChatMessageData,
RtChatMembershipData,
RtChatRecalledData,
RtChatMemberMutedData,
} from "@/lib/realtime";
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
import { toast } from "@/lib/toast";
@@ -55,6 +58,7 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import Modal from "@/components/Modal";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
import EmojiPicker from "@/components/EmojiPicker";
@@ -591,8 +595,21 @@ export default function ChatRoomClient({
y: number;
message: ChatMessage;
} | null>(null);
/** 头像右键菜单:对成员的管理操作 */
const [userCtxMenu, setUserCtxMenu] = useState<{
x: number;
y: number;
user: User;
member?: ChatRoomMember;
} | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
/** 禁言弹窗(含时长选择,QQ 风格) */
const [muteTarget, setMuteTarget] = useState<{
uid: number;
nickname: string;
} | null>(null);
const [muteBusy, setMuteBusy] = useState(false);
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
@@ -684,7 +701,7 @@ export default function ChatRoomClient({
const openUserCard = useCallback(
(u: User) => {
const mem = members.find((m) => m.user_id === u.id);
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
},
[members]
);
@@ -856,6 +873,40 @@ export default function ChatRoomClient({
return off;
}, [roomId, router]);
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
useEffect(() => {
const off = realtime.on<RtChatMemberMutedData>(
RT_CHAT_MEMBER_MUTED,
(data) => {
if (!data || data.room_id !== roomId) return;
setMembers((prev) =>
prev.map((m) =>
m.user_id === data.user_id
? { ...m, muted: data.muted, muted_until: data.muted_until }
: m
)
);
}
);
return off;
}, [roomId, members, meId]);
// 禁言/解禁系统提示(居中展示,类似撤回提示)
const mutedNotice = useMemo(() => {
const mem = members.find((m) => m.user_id === meId);
if (!mem?.muted) return null;
return mem;
}, [members, meId]);
const muteCountdown = useCountdown(mutedNotice?.muted_until, () => {
// 倒计时到期:前端自动解除禁言状态(惰性清理,无需等待 WS 或发消息)
setMembers((prev) =>
prev.map((m) =>
m.user_id === meId ? { ...m, muted: false, muted_until: null } : m
)
);
});
const loadMore = useCallback(async () => {
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
setLoadingMore(true);
@@ -1119,7 +1170,7 @@ export default function ChatRoomClient({
const handleRichInput = () => {
const el = inputRef.current;
if (!el) return;
if (!el || mutedNotice) return;
const text = domToText(el);
if (text === lastRichText.current) return;
if (text.length > 2000) {
@@ -1153,6 +1204,10 @@ export default function ChatRoomClient({
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// IME 组合中不拦截按键
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (mutedNotice) {
e.preventDefault();
return;
}
if (mentionOpen && mentionCandidates.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -1196,6 +1251,10 @@ export default function ChatRoomClient({
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
if (mutedNotice) {
e.preventDefault();
return;
}
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
if (!text) return;
@@ -1309,23 +1368,55 @@ export default function ChatRoomClient({
const handleMute = (uid: number, nickname: string, muted: boolean) => {
if (!canKickMembers) return;
if (muted) {
// 打开时长选择弹窗(QQ 风格)
setMuteTarget({ uid, nickname });
return;
}
setConfirm({
title: muted ? "禁言成员" : "解除禁言",
message: muted
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
: `确定解除 ${nickname} 的禁言?`,
confirmLabel: muted ? "禁言" : "解禁",
danger: muted,
title: "解除禁言",
message: `确定解除 ${nickname} 的禁言?`,
confirmLabel: "解禁",
action: async () => {
await apiMuteChatMember(roomId, uid, muted);
await apiMuteChatMember(roomId, uid, false);
setMembers((prev) =>
prev.map((m) => (m.user_id === uid ? { ...m, muted } : m))
prev.map((m) =>
m.user_id === uid ? { ...m, muted: false, muted_until: null } : m
)
);
toast(muted ? "已禁言" : "已解禁", "ok");
toast("已解禁", "ok");
},
});
};
const submitMute = async (durationMinutes: number) => {
if (!muteTarget) return;
setMuteBusy(true);
try {
await apiMuteChatMember(roomId, muteTarget.uid, true, durationMinutes);
const until =
durationMinutes > 0
? new Date(Date.now() + durationMinutes * 60_000).toISOString()
: null;
setMembers((prev) =>
prev.map((m) =>
m.user_id === muteTarget.uid
? { ...m, muted: true, muted_until: until }
: m
)
);
toast(
durationMinutes > 0 ? `已禁言 ${formatMuteDuration(durationMinutes)}` : "已永久禁言",
"ok"
);
setMuteTarget(null);
} catch (e) {
toast(e instanceof Error ? e.message : "禁言失败");
} finally {
setMuteBusy(false);
}
};
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
if (!siteOwner) return;
setConfirm({
@@ -1454,6 +1545,62 @@ export default function ChatRoomClient({
return items;
};
/** 用户头像右键菜单:按权限展示成员管理项 */
const userMenuItems = (u: User, mem?: ChatRoomMember): CtxMenuItem[] => {
const close = () => setUserCtxMenu(null);
const items: CtxMenuItem[] = [
{
key: "profile",
label: "查看资料卡",
icon: <Users size={14} />,
onClick: () => {
close();
openUserCard(u);
},
},
];
if (u.id === meId || isDirect) return items;
const isTargetOwner = mem?.role === "owner";
const isTargetAdmin = mem?.role === "admin";
if (canKickMembers && !isTargetOwner) {
const muted = mem?.muted ?? false;
items.push({
key: "mute",
label: muted ? "解禁" : "禁言",
icon: <VolumeX size={14} />,
danger: !muted,
onClick: () => {
close();
handleMute(u.id, u.nickname || u.username, !muted);
},
});
items.push({
key: "kick",
label: "移出群聊",
icon: <UserX size={14} />,
danger: true,
onClick: () => {
close();
handleKick(u.id, u.nickname || u.username);
},
});
}
if (siteOwner && !isTargetOwner) {
items.push({
key: "setAdmin",
label: isTargetAdmin ? "取消管理员" : "设为管理员",
icon: <Crown size={14} />,
onClick: () => {
close();
handleSetAdmin(u.id, u.nickname || u.username, !isTargetAdmin);
},
});
}
return items;
};
// 点击空白关闭右键菜单 — ContextMenu 自带
const shellClass = embedded
@@ -1650,6 +1797,23 @@ export default function ChatRoomClient({
5 * 60_000;
const reEditText = mine ? takeReEditDraft(m.id) : null;
// 系统消息(如禁言通知):居中灰字
if (m.sender_id === 0) {
return (
<div
key={m.id}
className="flex justify-center px-2 py-0.5"
>
<p
className="text-[12px] leading-relaxed text-center max-w-[90%]"
style={{ color: "var(--ink-3)" }}
>
{m.content}
</p>
</div>
);
}
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
if (recalled && !(canModerateChat && m.content)) {
return (
@@ -1704,10 +1868,26 @@ export default function ChatRoomClient({
>
<div className="w-8 shrink-0">
{!grouped && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
@@ -1715,7 +1895,7 @@ export default function ChatRoomClient({
src={m.sender.avatar || undefined}
size={32}
/>
</button>
</div>
)}
</div>
<div
@@ -1784,10 +1964,26 @@ export default function ChatRoomClient({
>
<div className="w-8 shrink-0">
{!grouped && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
@@ -1795,7 +1991,7 @@ export default function ChatRoomClient({
src={m.sender.avatar || undefined}
size={32}
/>
</button>
</div>
)}
</div>
<div
@@ -2028,18 +2224,32 @@ export default function ChatRoomClient({
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
<button
type="button"
disabled={!!mutedNotice}
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
emojiOpen ? " bg-[var(--accent-soft)]" : " hover:bg-[var(--panel-2)]"
mutedNotice
? " opacity-40 cursor-not-allowed"
: emojiOpen
? " bg-[var(--accent-soft)]"
: " hover:bg-[var(--panel-2)]"
}`}
style={{ color: emojiOpen ? "var(--accent)" : "var(--ink-2)" }}
aria-label="表情"
style={{
color: mutedNotice
? "var(--ink-3)"
: emojiOpen
? "var(--accent)"
: "var(--ink-2)",
}}
aria-label={mutedNotice ? "禁言中,无法发送表情" : "表情"}
aria-expanded={emojiOpen}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setEmojiOpen((v) => !v)}
onClick={() => {
if (mutedNotice) return;
setEmojiOpen((v) => !v);
}}
>
<Smile size={19} />
</button>
{emojiOpen && (
{emojiOpen && !mutedNotice && (
<div className="j13-chat-emoji-panel">
<EmojiPicker onPick={insertEmoji} />
</div>
@@ -2047,17 +2257,28 @@ export default function ChatRoomClient({
</div>
<div
ref={inputRef}
contentEditable
contentEditable={!mutedNotice}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label="输入消息"
data-placeholder={isDirect ? "输入消息…" : "输入消息,@ 可提醒成员"}
aria-label={mutedNotice ? "禁言中" : "输入消息"}
data-placeholder={
mutedNotice
? `禁言中${muteCountdown ? `,剩余 ${muteCountdown}` : "(永久)"}`
: isDirect
? "输入消息…"
: "输入消息,@ 可提醒成员"
}
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
style={mutedNotice ? { color: "var(--ink-3)", cursor: "not-allowed" } : undefined}
onInput={handleRichInput}
onKeyDown={handleRichKeyDown}
onPaste={handleRichPaste}
onFocus={(e) => {
if (mutedNotice) {
e.currentTarget.blur();
return;
}
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
const composer = e.currentTarget.closest(".j13-chat-composer");
@@ -2067,7 +2288,7 @@ export default function ChatRoomClient({
<button
type="button"
onClick={send}
disabled={sending || !content.trim()}
disabled={sending || !content.trim() || !!mutedNotice}
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
aria-label="发送"
>
@@ -2156,6 +2377,7 @@ export default function ChatRoomClient({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
/>
@@ -2204,6 +2426,7 @@ export default function ChatRoomClient({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
showTitle
@@ -2220,6 +2443,15 @@ export default function ChatRoomClient({
/>
)}
{userCtxMenu && (
<ContextMenu
x={userCtxMenu.x}
y={userCtxMenu.y}
items={userMenuItems(userCtxMenu.user, userCtxMenu.member)}
onClose={() => setUserCtxMenu(null)}
/>
)}
{userCard && (
<ChatUserCard
target={userCard}
@@ -2240,10 +2472,179 @@ export default function ChatRoomClient({
if (!confirmBusy) setConfirm(null);
}}
/>
<MuteModal
open={!!muteTarget}
nickname={muteTarget?.nickname ?? ""}
busy={muteBusy}
onConfirm={submitMute}
onClose={() => {
if (!muteBusy) setMuteTarget(null);
}}
/>
</div>
);
}
/** 禁言时长选项(分钟),0 = 永久,参考 QQ 禁言 */
const MUTE_DURATIONS: { value: number; label: string }[] = [
{ value: 10, label: "10 分钟" },
{ value: 60, label: "1 小时" },
{ value: 12 * 60, label: "12 小时" },
{ value: 24 * 60, label: "1 天" },
{ value: 3 * 24 * 60, label: "3 天" },
{ value: 7 * 24 * 60, label: "7 天" },
{ value: 30 * 24 * 60, label: "30 天" },
{ value: 0, label: "永久" },
];
function formatMuteDuration(minutes: number): string {
return MUTE_DURATIONS.find((d) => d.value === minutes)?.label ?? `${minutes} 分钟`;
}
/** 禁言系统提示用的时长格式:返回「x天x小时x分钟」 */
function formatMuteCountdown(untilIso: string): string {
const until = new Date(untilIso).getTime();
const diff = until - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return `${days} 天 ${hours} 小时`;
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
return `${minutes} 分钟`;
}
/** 成员列表禁言徽标内的剩余时长,永久/null 返回空串 */
function formatMuteRemainShort(until?: string | null): string {
if (!until) return "";
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return ` ${days}d`;
if (hours > 0) return ` ${hours}h`;
return ` ${minutes}m`;
}
/** 禁言倒计时:返回「x天x小时x分钟」或 null(永久/未禁言);到期触发 onExpire */
function useCountdown(
until?: string | null,
onExpire?: () => void
): string | null {
const [text, setText] = useState<string | null>(null);
const expiredRef = useRef(false);
useEffect(() => {
expiredRef.current = false;
if (!until) {
setText(null);
return;
}
const update = () => {
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) {
if (!expiredRef.current) {
expiredRef.current = true;
onExpire?.();
}
setText("已到期");
return;
}
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
const seconds = Math.floor((diff % 60_000) / 1000);
if (days > 0) setText(`${days} 天 ${hours} 小时`);
else if (hours > 0) setText(`${hours} 小时 ${minutes} 分钟`);
else if (minutes > 0) setText(`${minutes} 分 ${seconds} 秒`);
else setText(`${seconds} 秒`);
};
update();
const t = setInterval(update, 1000);
return () => clearInterval(t);
}, [until, onExpire]);
return text;
}
/** 禁言成员弹窗(QQ 风格:选择时长) */
function MuteModal({
open,
nickname,
busy,
onConfirm,
onClose,
}: {
open: boolean;
nickname: string;
busy: boolean;
onConfirm: (durationMinutes: number) => void;
onClose: () => void;
}) {
const [duration, setDuration] = useState<number>(24 * 60);
useEffect(() => {
if (open) setDuration(24 * 60);
}, [open]);
return (
<Modal
open={open}
title="禁言成员"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-sm"
role="alertdialog"
footer={
<>
<button
type="button"
onClick={onClose}
disabled={busy}
className="btn btn-line btn-sm"
>
取消
</button>
<button
type="button"
onClick={() => onConfirm(duration)}
disabled={busy}
className="btn btn-primary btn-sm"
>
{busy ? "禁言中..." : "禁言"}
</button>
</>
}
>
<div className="space-y-3">
<p className="text-[13px]" style={{ color: "var(--ink-2)" }}>
选择 <span style={{ color: "var(--ink)" }}>{nickname}</span> 的禁言时长,到期自动解除。
</p>
<div className="grid grid-cols-2 gap-2">
{MUTE_DURATIONS.map((d) => {
const active = d.value === duration;
return (
<button
key={d.value}
type="button"
onClick={() => setDuration(d.value)}
className="h-9 rounded-lg text-[13px] transition-colors"
style={{
background: active ? "var(--accent-soft)" : "var(--bg)",
color: active ? "var(--accent)" : "var(--ink-2)",
border: `1px solid ${active ? "var(--accent)" : "var(--line)"}`,
}}
>
{d.label}
</button>
);
})}
</div>
</div>
</Modal>
);
}
function JoinButton({
roomId,
onJoined,
@@ -2361,7 +2762,7 @@ function MembersPanel({
{name}
{m.muted ? (
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
· 禁言
· 禁言{formatMuteRemainShort(m.muted_until)}
</span>
) : null}
</Link>