feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等

新增:
- BrandCropModal 品牌图裁剪弹窗 + cropImage 工具
- Markdown 行内短代码(inlineShortcodes + remark 插件)
- MdDetails 折叠块、MdVideo 视频块组件
- directiveBlocks/inlineShortcodes 测试

其它:
- 聊天后端(handler/service chat)调整
- Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
2026-10-02 01:40:06 +08:00
parent 3e98052c16
commit 99c0f0319d
23 changed files with 2012 additions and 119 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import { useState, useEffect, useLayoutEffect, useRef, useCallback, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -581,6 +581,14 @@ export default function ChatRoomClient({
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
const [hasMore, setHasMore] = useState(initialHasMore);
const [loadingMore, setLoadingMore] = useState(false);
// 加载历史的在飞锁(同步 ref,滚动事件连发时 state 闭包来不及更新,见 loadMore)
const loadingMoreRef = useRef(false);
// 上翻加载完成后恢复 scrollTop:column-reverse 理论上 prepend 不移动视口,
// 但 React 重渲染期间某些浏览器/场景会重置 scrollTop,强制恢复兜底
const stabilizeScrollRef = useRef(false);
const stabilizeScrollValueRef = useRef(0);
// handleScroll 冷却:一次自动加载后 800ms 内不再触发,防链式冲顶
const lastAutoLoadRef = useRef(0);
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
const [showJumpBottom, setShowJumpBottom] = useState(false);
@@ -700,6 +708,9 @@ export default function ChatRoomClient({
const openUserCard = useCallback(
(u: User) => {
// 菜单互斥:打开资料卡前关掉两个右键菜单
setCtxMenu(null);
setUserCtxMenu(null);
const mem = members.find((m) => m.user_id === u.id);
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
},
@@ -810,8 +821,9 @@ export default function ChatRoomClient({
if (-el.scrollTop < 120) {
scrollToBottom(true);
markReadToLatest();
} else {
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示
} else if (data.sender_id !== 0) {
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示;
// 系统提示(禁言/解禁等管理动作)不计入「新消息」提醒
setNewBelowCount((c) => c + 1);
}
});
@@ -908,41 +920,60 @@ export default function ChatRoomClient({
});
const loadMore = useCallback(async () => {
// 在飞锁必须用 ref:滚动事件在一次上翻里连发,React 尚未重渲染时
// loadingMore(state 闭包)仍是 false,会并发连发多页请求。
if (loadingMoreRef.current) return;
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
loadingMoreRef.current = true;
setLoadingMore(true);
const el0 = listRef.current;
const prevHeight = el0?.scrollHeight ?? 0;
const prevTop = el0?.scrollTop ?? 0;
// 保存当前滚动位置,prepend 完成后恢复(防 React 重渲染导致的意外重置)
const el = listRef.current;
const savedScrollTop = el?.scrollTop ?? 0;
try {
const before = messages[0].id;
const res = await apiListChatMessages(roomId, before, 30);
if ((res.messages || []).length > 0) {
setMessages((prev) => [...res.messages, ...prev]);
setHasMore(res.has_more);
requestAnimationFrame(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:历史插在上方,scrollTop 为负;
// 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动
el.scrollTop = prevTop - (el.scrollHeight - prevHeight);
// 去重兜底:防止旧闭包重复拉同一页导致重复 key
setMessages((prev) => {
const seen = new Set(prev.map((p) => p.id));
const fresh = res.messages.filter((x) => !seen.has(x.id));
return fresh.length ? [...fresh, ...prev] : prev;
});
setHasMore(res.has_more);
// 标记需要在提交后恢复滚动位置
stabilizeScrollValueRef.current = savedScrollTop;
stabilizeScrollRef.current = true;
} else {
setHasMore(false);
}
} catch {
toast("加载历史消息失败");
} finally {
loadingMoreRef.current = false;
setLoadingMore(false);
}
}, [canView, hasMore, loadingMore, messages, roomId]);
// prepend 完成后恢复滚动位置:column-reverse 理论上应稳定,但某些浏览器在
// React 提交 DOM 时会重置 scrollTop,强制恢复以保证视口不动
useLayoutEffect(() => {
if (!stabilizeScrollRef.current) return;
stabilizeScrollRef.current = false;
const el = listRef.current;
if (el) {
el.scrollTop = stabilizeScrollValueRef.current;
}
}, [messages]);
const handleScroll = useCallback(() => {
const el = listRef.current;
if (!el) return;
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
const fromBottom = -el.scrollTop;
// 滚向上方历史:距顶部不足 60px 时加载更多
if (el.scrollHeight - el.clientHeight - fromBottom < 60) {
// 滚向上方历史:距顶部不足 60px 时加载更多;加 800ms 冷却防链式冲顶
const now = Date.now();
if (el.scrollHeight - el.clientHeight - fromBottom < 60 && now - lastAutoLoadRef.current > 800) {
lastAutoLoadRef.current = now;
loadMore();
}
if (fromBottom < 60) {
@@ -1285,6 +1316,9 @@ export default function ChatRoomClient({
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
if (m.recalled_at) return;
// 菜单互斥:打开消息菜单前关掉头像菜单与资料卡
setUserCtxMenu(null);
setUserCard(null);
setCtxMenu({ x, y, message: m });
};
@@ -1368,6 +1402,10 @@ export default function ChatRoomClient({
const handleMute = (uid: number, nickname: string, muted: boolean) => {
if (!canKickMembers) return;
// 入口可能是头像右键菜单或成员列表按钮:先兜底关闭浮层菜单,
// 避免低概率时序下菜单残留并与弹窗叠层(菜单 z-index 高于弹窗)
setUserCtxMenu(null);
setCtxMenu(null);
if (muted) {
// 打开时长选择弹窗(QQ 风格)
setMuteTarget({ uid, nickname });
@@ -1440,7 +1478,11 @@ export default function ChatRoomClient({
const canRecallMsg = (m: ChatMessage) => {
if (m.recalled_at) return false;
if (canModerateChat) return true;
if (canModerateChat) {
// 站长(站点 Owner)的消息仅全站监管可撤回,群主/群管/站点消息管无权
if (!oversee && isSiteOwner(m.sender.role)) return false;
return true;
}
return m.sender_id === meId && isRealMember;
};
@@ -1752,7 +1794,9 @@ export default function ChatRoomClient({
ref={listRef}
onScroll={handleScroll}
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden flex flex-col-reverse px-4 sm:px-5 py-4"
style={{ minHeight: 0 }}
// overflowAnchor:none:column-reverse 原点在底部,prepend 天然不移动视口;
// 关掉浏览器原生锚定,避免它对顶部内容变化做多余补偿
style={{ minHeight: 0, overflowAnchor: "none" }}
>
{!canView ? (
<div className="text-center py-12 px-4">
@@ -1796,9 +1840,14 @@ export default function ChatRoomClient({
new Date(prev.created_at).getTime() <
5 * 60_000;
const reEditText = mine ? takeReEditDraft(m.id) : null;
// 发送者的群内角色(群主/管理徽标展示用;私聊不需要)
const senderRoomRole = !isDirect
? members.find((x) => x.user_id === m.sender_id)?.role
: undefined;
// 系统消息(如禁言通知):居中灰字
// 系统消息(如禁言通知):居中灰字;禁言类通知红色突出
if (m.sender_id === 0) {
const isMuteNotice = m.content.includes("已被禁言");
return (
<div
key={m.id}
@@ -1806,7 +1855,12 @@ export default function ChatRoomClient({
>
<p
className="text-[12px] leading-relaxed text-center max-w-[90%]"
style={{ color: "var(--ink-3)" }}
style={{
color: isMuteNotice
? "var(--danger)"
: "var(--ink-3)",
fontWeight: isMuteNotice ? 600 : undefined,
}}
>
{m.content}
</p>
@@ -1881,6 +1935,9 @@ export default function ChatRoomClient({
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
setCtxMenu(null);
setUserCard(null);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
@@ -1913,6 +1970,18 @@ export default function ChatRoomClient({
>
{m.sender.nickname}
</span>
{!isDirect && senderRoomRole === "admin" && (
<span
className="text-[10px] font-medium px-1 rounded"
style={{
color: "var(--accent)",
background:
"color-mix(in srgb, var(--accent) 12%, transparent)",
}}
>
管理
</span>
)}
{!isDirect && (
<>
<LevelBadge level={m.sender.level} />
@@ -1928,23 +1997,37 @@ export default function ChatRoomClient({
<div
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text w-full"
style={{
background: "var(--panel-2)",
background:
"color-mix(in srgb, var(--gold) 7%, var(--panel-2))",
border:
"1px solid color-mix(in srgb, var(--line) 80%, var(--ink-3))",
"1.5px dashed color-mix(in srgb, var(--gold) 55%, transparent)",
color: "var(--ink)",
borderBottomRightRadius: mine ? 4 : undefined,
borderBottomLeftRadius: mine ? undefined : 4,
opacity: 0.92,
}}
>
<div
className="inline-flex items-center gap-1 mb-1.5 text-[11px]"
style={{ color: "var(--ink-3)" }}
>
<Undo2 size={11} strokeWidth={2.4} />
已撤回
<div className="flex items-center gap-1.5 mb-1.5">
<span
className="inline-flex items-center gap-1 text-[10.5px] font-medium px-1.5 py-px rounded-full"
style={{
color: "var(--gold)",
background:
"color-mix(in srgb, var(--gold) 14%, transparent)",
border:
"1px solid color-mix(in srgb, var(--gold) 35%, transparent)",
}}
>
<Undo2 size={10} strokeWidth={2.4} />
已撤回
</span>
<span
className="text-[10.5px]"
style={{ color: "var(--ink-3)" }}
>
仅管理可见
</span>
</div>
<div style={{ color: "var(--ink-2)" }}>
<div className="italic" style={{ color: "var(--ink-2)" }}>
{renderChatContent(m.content, userIndex, openUserCard)}
</div>
</div>
@@ -1977,6 +2060,9 @@ export default function ChatRoomClient({
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
// 菜单互斥:打开头像菜单前关掉消息菜单与资料卡
setCtxMenu(null);
setUserCard(null);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
@@ -2032,6 +2118,23 @@ export default function ChatRoomClient({
)}
{m.sender.nickname}
</button>
{!isDirect && senderRoomRole === "admin" && (
<span
className="text-[10px] font-medium px-1 rounded"
style={{
color: "var(--accent)",
background:
"color-mix(in srgb, var(--accent) 12%, transparent)",
}}
>
管理
</span>
)}
{/* 群聊与私聊都展示等级/徽章标识 */}
<LevelBadge level={m.sender.level} />
{(m.sender.badges ?? []).slice(0, 1).map((ub) => (
<BadgeChip key={ub.id} badge={ub.badge} />
))}
<span className="meta text-[10.5px]">
{formatTime(m.created_at)}
</span>
@@ -2434,7 +2537,8 @@ export default function ChatRoomClient({
</aside>
)}
{ctxMenu && (
{/* 弹层打开期间不再渲染右键菜单:渲染级互斥兜底,杜绝菜单悬在弹窗上方 */}
{ctxMenu && !muteTarget && !confirm && !userCard && (
<ContextMenu
x={ctxMenu.x}
y={ctxMenu.y}
@@ -2443,7 +2547,7 @@ export default function ChatRoomClient({
/>
)}
{userCtxMenu && (
{userCtxMenu && !muteTarget && !confirm && !userCard && (
<ContextMenu
x={userCtxMenu.x}
y={userCtxMenu.y}