feat: 品牌裁剪弹窗/Markdown 行内短代码与详情视频块等
新增: - BrandCropModal 品牌图裁剪弹窗 + cropImage 工具 - Markdown 行内短代码(inlineShortcodes + remark 插件) - MdDetails 折叠块、MdVideo 视频块组件 - directiveBlocks/inlineShortcodes 测试 其它: - 聊天后端(handler/service chat)调整 - Markdown 渲染链路、编辑器、全局样式与品牌相关面板更新
This commit is contained in:
@@ -3,8 +3,8 @@
|
||||
import { Images, Loader2 } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
import Modal from "@/components/Modal";
|
||||
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
|
||||
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
|
||||
import { apiAdminMediaLibrary, apiMyMedia } from "@/lib/api";
|
||||
import { normalizeBrandURL, type BrandSlot } from "@/lib/brand";
|
||||
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
|
||||
|
||||
const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||
@@ -13,6 +13,13 @@ const SLOT_LABEL: Record<BrandSlot, string> = {
|
||||
favicon: "Favicon",
|
||||
};
|
||||
|
||||
/** 选图条目:本人附件带 attachmentId;品牌目录历史图仅 URL(已在品牌目录,无需复制) */
|
||||
export type BrandPickItem = {
|
||||
key: string;
|
||||
url: string;
|
||||
attachmentId: number | null;
|
||||
};
|
||||
|
||||
export default function BrandMediaPicker({
|
||||
open,
|
||||
slot,
|
||||
@@ -22,9 +29,9 @@ export default function BrandMediaPicker({
|
||||
open: boolean;
|
||||
slot: BrandSlot;
|
||||
onClose: () => void;
|
||||
onPick: (item: MediaAttachment) => void;
|
||||
onPick: (item: BrandPickItem) => void;
|
||||
}) {
|
||||
const [items, setItems] = useState<MediaAttachment[]>([]);
|
||||
const [items, setItems] = useState<BrandPickItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
|
||||
@@ -33,16 +40,29 @@ export default function BrandMediaPicker({
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setError("");
|
||||
void apiMyMedia()
|
||||
.then((res) => {
|
||||
// 本人附件 + 媒体库盘点里的品牌目录历史图(品牌图不落附件,需合并展示)
|
||||
Promise.all([apiMyMedia(), apiAdminMediaLibrary().catch(() => null)])
|
||||
.then(([mediaRes, libRes]) => {
|
||||
if (cancelled) return;
|
||||
if (res.error) {
|
||||
setError(res.error);
|
||||
if (mediaRes.error) {
|
||||
setError(mediaRes.error);
|
||||
setItems([]);
|
||||
return;
|
||||
}
|
||||
const all = res.attachments || [];
|
||||
setItems(all.filter((item) => mediaURLFitsSlot(slot, item.url)));
|
||||
const out: BrandPickItem[] = (mediaRes.attachments || []).map((a) => ({
|
||||
key: `att-${a.id}`,
|
||||
url: a.url,
|
||||
attachmentId: a.id,
|
||||
}));
|
||||
const seen = new Set(out.map((i) => i.url));
|
||||
for (const it of libRes?.items || []) {
|
||||
if (it.category !== "brand") continue;
|
||||
if (!normalizeBrandURL(slot, it.url)) continue;
|
||||
if (seen.has(it.url)) continue;
|
||||
seen.add(it.url);
|
||||
out.unshift({ key: `brand-${it.url}`, url: it.url, attachmentId: null });
|
||||
}
|
||||
setItems(out);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setError("加载媒体库失败");
|
||||
@@ -59,7 +79,7 @@ export default function BrandMediaPicker({
|
||||
<Modal
|
||||
open={open}
|
||||
title={`从媒体库选择 · ${SLOT_LABEL[slot]}`}
|
||||
description="只列出格式符合的图片。保存后会复制一份到品牌目录,之后删除原图不会影响 Logo。"
|
||||
description="位图选中后进入裁剪并转成 WebP;SVG / ICO 与历史品牌图直接引用,之后删除原图不会影响 Logo。"
|
||||
onClose={onClose}
|
||||
maxWidthClass="max-w-lg"
|
||||
dismissOnOverlay
|
||||
@@ -73,12 +93,12 @@ export default function BrandMediaPicker({
|
||||
{error}
|
||||
</p>
|
||||
) : items.length === 0 ? (
|
||||
<p className="meta text-[13px] py-6 text-center">没有可用图片。Logo 需要 PNG / WebP / SVG,Favicon 还可选 ICO。</p>
|
||||
<p className="meta text-[13px] py-6 text-center">媒体库还没有图片,先上传一张吧。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
|
||||
{items.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
key={item.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onPick(item);
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
AdminSettingsActions,
|
||||
AdminSettingsGroup,
|
||||
} from "@/components/admin";
|
||||
import BrandCropModal from "@/components/BrandCropModal";
|
||||
import BrandLockup from "@/components/BrandLockup";
|
||||
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
|
||||
import {
|
||||
@@ -15,6 +16,7 @@ import {
|
||||
brandAccept,
|
||||
brandAliasPath,
|
||||
brandFileOk,
|
||||
normalizeBrandURL,
|
||||
type BrandLogoFit,
|
||||
type BrandLogoSize,
|
||||
type BrandMark,
|
||||
@@ -77,6 +79,9 @@ export default function BrandSeoPanel({
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [saveError, setSaveError] = useState("");
|
||||
const [picker, setPicker] = useState<BrandSlot | null>(null);
|
||||
const [cropSlot, setCropSlot] = useState<BrandSlot | null>(null);
|
||||
const [cropSeedFile, setCropSeedFile] = useState<File | null>(null);
|
||||
const [cropSeedUrl, setCropSeedUrl] = useState("");
|
||||
const [copiedSlot, setCopiedSlot] = useState<BrandSlot | null>(null);
|
||||
const blobs = useRef<string[]>([]);
|
||||
const saveGen = useRef(0);
|
||||
@@ -123,6 +128,18 @@ export default function BrandSeoPanel({
|
||||
return draft.committed;
|
||||
};
|
||||
|
||||
const openCrop = (slot: BrandSlot, file: File | null, url: string) => {
|
||||
setCropSlot(slot);
|
||||
setCropSeedFile(file);
|
||||
setCropSeedUrl(url);
|
||||
};
|
||||
|
||||
const closeCrop = () => {
|
||||
setCropSlot(null);
|
||||
setCropSeedFile(null);
|
||||
setCropSeedUrl("");
|
||||
};
|
||||
|
||||
const discard = () => {
|
||||
[light, dark, favicon].forEach((d) => {
|
||||
if (d.blob) URL.revokeObjectURL(d.blob);
|
||||
@@ -237,8 +254,8 @@ export default function BrandSeoPanel({
|
||||
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
|
||||
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
|
||||
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(384)) : preview}
|
||||
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 384) : undefined}
|
||||
alt=""
|
||||
/>
|
||||
) : (
|
||||
@@ -277,7 +294,14 @@ export default function BrandSeoPanel({
|
||||
toast(err);
|
||||
return;
|
||||
}
|
||||
replaceFile(slot, file);
|
||||
// SVG / ICO 无需裁剪,直接走原流程
|
||||
const isVector = file.name.toLowerCase().endsWith(".svg");
|
||||
const isIco = file.name.toLowerCase().endsWith(".ico");
|
||||
if (isVector || isIco) {
|
||||
replaceFile(slot, file);
|
||||
return;
|
||||
}
|
||||
openCrop(slot, file, "");
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
@@ -418,11 +442,50 @@ export default function BrandSeoPanel({
|
||||
onClose={() => setPicker(null)}
|
||||
onPick={(item) => {
|
||||
if (!picker) return;
|
||||
const [prev, set] = drafts[picker];
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
set({ ...prev, file: null, blob: "", mediaId: item.id, mediaUrl: item.url });
|
||||
const slot = picker;
|
||||
setPicker(null);
|
||||
// 已是品牌目录的历史图(含裁剪产物):直接引用,不再复制
|
||||
const brandURL = normalizeBrandURL(slot, item.url);
|
||||
if (brandURL) {
|
||||
const [prev, set] = drafts[slot];
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
set({ committed: brandURL, file: null, blob: "", mediaId: null, mediaUrl: "" });
|
||||
return;
|
||||
}
|
||||
const lower = item.url.toLowerCase();
|
||||
const isVector = lower.endsWith(".svg");
|
||||
const isIco = lower.endsWith(".ico");
|
||||
// SVG / ICO 不可裁剪,复制一份进品牌目录后引用
|
||||
if ((isVector || isIco) && item.attachmentId != null) {
|
||||
const [prev, set] = drafts[slot];
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
set({ ...prev, file: null, blob: "", mediaId: item.attachmentId, mediaUrl: item.url });
|
||||
return;
|
||||
}
|
||||
openCrop(slot, null, item.url);
|
||||
}}
|
||||
/>
|
||||
{cropSlot ? (
|
||||
<BrandCropModal
|
||||
open
|
||||
slot={cropSlot}
|
||||
seedFile={cropSeedFile}
|
||||
seedUrl={cropSeedUrl}
|
||||
onClose={closeCrop}
|
||||
onSeedConsumed={() => {
|
||||
setCropSeedFile(null);
|
||||
setCropSeedUrl("");
|
||||
}}
|
||||
onCropped={(url) => {
|
||||
const [, set] = drafts[cropSlot];
|
||||
set((prev) => {
|
||||
if (prev.blob) URL.revokeObjectURL(prev.blob);
|
||||
return { committed: url, file: null, blob: "", mediaId: null, mediaUrl: "" };
|
||||
});
|
||||
closeCrop();
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
</AdminSettingsGroup>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -237,7 +237,11 @@ body {
|
||||
/* 亚像素渲染在 Windows 高分屏更实;强制 grayscale 会让 400/500 中文偏虚 */
|
||||
-webkit-font-smoothing: auto;
|
||||
text-rendering: optimizeLegibility;
|
||||
font-synthesis: none; /* 禁止伪粗,避免缺档字重糊成一团 */
|
||||
/* 长手属性只禁伪粗(缺档字重糊成一团);放开伪斜体,让 em/斜体按钮生效。
|
||||
首行兜底:不支持长手属性的老浏览器维持全禁。 */
|
||||
font-synthesis: none;
|
||||
font-synthesis-weight: none;
|
||||
font-synthesis-style: auto;
|
||||
isolation: isolate; /* 让 z-index:-1 的画布层停在内容之下、html 之上 */
|
||||
}
|
||||
|
||||
@@ -4699,7 +4703,9 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.admin-brand-swatch img {
|
||||
max-height: 3.5rem;
|
||||
/* 按槽位高度铺满,宽度按比例自适应;超宽时由 max-width + contain 收边 */
|
||||
height: 100%;
|
||||
width: auto;
|
||||
max-width: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
@@ -8271,6 +8277,248 @@ a.j13-mention-token:hover {
|
||||
--entry-title-size: 1.4rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* ----- 正文短代码:[details] 折叠 / [bilibili][youtube] 视频 / ==高亮== / [kbd] 键帽 ----- */
|
||||
.prose-content .j13-md-details {
|
||||
margin: 1rem 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.prose-content .j13-md-details > summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.55rem 0.9rem;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.95em;
|
||||
color: var(--ink);
|
||||
list-style: none;
|
||||
}
|
||||
.prose-content .j13-md-details > summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
.prose-content .j13-md-details > summary:hover {
|
||||
background: var(--tint);
|
||||
}
|
||||
.prose-content .j13-md-details[open] > summary {
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
.prose-content .j13-md-details .j13-md-details-caret {
|
||||
flex-shrink: 0;
|
||||
color: var(--ink-3);
|
||||
transition: transform 0.18s ease;
|
||||
}
|
||||
.prose-content .j13-md-details[open] .j13-md-details-caret {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
.prose-content .j13-md-details-body {
|
||||
padding: 0.35rem 0.9rem 0.6rem;
|
||||
}
|
||||
.prose-content .j13-md-details-body > :first-child {
|
||||
margin-top: 0.35rem;
|
||||
}
|
||||
.prose-content .j13-md-details-body > :last-child {
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
/* 视频嵌入:16:9 容器 + 懒加载 iframe 铺满 */
|
||||
.j13-md-video {
|
||||
position: relative;
|
||||
aspect-ratio: 16 / 9;
|
||||
margin: 1rem 0;
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
background: var(--tint);
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.j13-md-video iframe {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
/* ==高亮==:荧光笔。UA 默认黄底黑字在暗色下刺眼,显式覆盖 */
|
||||
.prose-content mark {
|
||||
background: color-mix(in srgb, #fde047 55%, transparent);
|
||||
color: inherit;
|
||||
padding: 0.05em 0.25em;
|
||||
border-radius: 4px;
|
||||
-webkit-box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
}
|
||||
.dark .prose-content mark {
|
||||
background: color-mix(in srgb, #facc15 30%, transparent);
|
||||
color: #fef9c3;
|
||||
}
|
||||
|
||||
/* [kbd] 键帽:等宽 + 下边加厚模拟按键立体 */
|
||||
.prose-content kbd {
|
||||
display: inline-block;
|
||||
padding: 0.1em 0.45em;
|
||||
border: 1px solid var(--line-2);
|
||||
border-bottom-width: 2px;
|
||||
border-radius: 6px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.82em;
|
||||
line-height: 1.45;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
/* 编辑器「文字颜色」弹窗:前景/背景两行,每行预设 + 色轮 + hex 输入 */
|
||||
.j13-md-color-popover {
|
||||
width: 288px;
|
||||
}
|
||||
.j13-md-color-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.j13-md-color-row-label {
|
||||
font-size: 12px;
|
||||
color: var(--ink-3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.j13-md-color-row-items {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px;
|
||||
}
|
||||
.j13-md-color-none {
|
||||
height: 26px;
|
||||
padding: 0 9px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
background: transparent;
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.j13-md-color-none.is-active {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.j13-md-color-dot {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
.j13-md-color-dot.is-active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
/* 自定义取色器触发按钮:彩虹渐变底 + 吸管图标 */
|
||||
.j13-md-color-wheel {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line-2);
|
||||
padding: 0;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
color: #fff;
|
||||
background: conic-gradient(
|
||||
from 210deg,
|
||||
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||
);
|
||||
}
|
||||
.j13-md-color-wheel svg {
|
||||
filter: drop-shadow(0 1px 1.5px rgba(0, 0, 0, 0.65));
|
||||
}
|
||||
.j13-md-color-wheel:hover {
|
||||
transform: scale(1.08);
|
||||
}
|
||||
.j13-md-color-wheel.is-active {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.j13-md-color-hex {
|
||||
flex: 1 1 90px;
|
||||
min-width: 90px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
}
|
||||
/* 内联 HSV 取色器:在弹层文档流内展开,只撑开不浮层,不遮挡预览 */
|
||||
.j13-md-hsv {
|
||||
margin-top: 8px;
|
||||
}
|
||||
.j13-md-hsv-sv {
|
||||
position: relative;
|
||||
height: 110px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
cursor: crosshair;
|
||||
touch-action: none;
|
||||
border: 1px solid var(--line);
|
||||
}
|
||||
.j13-md-hsv-sv-thumb {
|
||||
position: absolute;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid #fff;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.45);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
.j13-md-hsv-hue {
|
||||
position: relative;
|
||||
height: 12px;
|
||||
border-radius: 6px;
|
||||
margin-top: 8px;
|
||||
cursor: pointer;
|
||||
touch-action: none;
|
||||
border: 1px solid var(--line);
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
#f00, #ff0, #0f0, #0ff, #00f, #f0f, #f00
|
||||
);
|
||||
}
|
||||
.j13-md-hsv-hue-thumb {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
width: 10px;
|
||||
height: 18px;
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
border: 1px solid rgba(0, 0, 0, 0.35);
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
|
||||
transform: translate(-50%, -50%);
|
||||
pointer-events: none;
|
||||
}
|
||||
/* 实时预览置于弹层顶部:取色器在行内展开时始终可见,未选背景时透明棋盘格 */
|
||||
.j13-md-color-preview {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 7px 10px;
|
||||
margin-bottom: 10px;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 8px;
|
||||
background:
|
||||
repeating-conic-gradient(var(--tint) 0% 25%, transparent 0% 50%) 0 0 / 14px 14px;
|
||||
}
|
||||
.j13-md-color-preview span {
|
||||
padding: 2px 10px;
|
||||
border-radius: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.prose-content h1 { font-size: 1.75rem; font-weight: 800; margin: 1.6rem 0 0.8rem; }
|
||||
.prose-content h2 {
|
||||
font-size: 1.4rem;
|
||||
|
||||
Reference in New Issue
Block a user