feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user