feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import Link from "next/link";
|
||||
import { useRouter } from "next/navigation";
|
||||
import {
|
||||
@@ -16,7 +16,6 @@ import {
|
||||
Undo2,
|
||||
Reply,
|
||||
Copy,
|
||||
AtSign,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiListChatMessages,
|
||||
@@ -53,6 +52,7 @@ import Avatar from "@/components/Avatar";
|
||||
import AutolinkText from "@/components/AutolinkText";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
|
||||
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
|
||||
|
||||
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
|
||||
const MARK_READ_GAP_MS = 2_000;
|
||||
@@ -137,7 +137,6 @@ function RecallNotice({
|
||||
href={`/u/${uid}`}
|
||||
className="transition-opacity hover:opacity-80"
|
||||
style={nameStyle}
|
||||
data-tip={`查看 ${name} 的主页`}
|
||||
>
|
||||
{name}
|
||||
</Link>
|
||||
@@ -206,10 +205,11 @@ function formatTime(dateStr: string) {
|
||||
return `${md} ${hm}`;
|
||||
}
|
||||
|
||||
function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
const byUsername = new Map(
|
||||
members.map((m) => [m.user.username.toLowerCase(), m.user])
|
||||
);
|
||||
function highlightMentions(
|
||||
content: string,
|
||||
users: Map<string, User>,
|
||||
onOpenCard: (u: User) => void
|
||||
) {
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let last = 0;
|
||||
@@ -217,19 +217,21 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
let i = 0;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
if (m.index > last) parts.push(content.slice(last, m.index));
|
||||
const user = byUsername.get(m[1].toLowerCase());
|
||||
const user = users.get(m[1].toLowerCase());
|
||||
parts.push(
|
||||
user ? (
|
||||
<Link
|
||||
key={`m-${i}`}
|
||||
href={`/u/${user.id}`}
|
||||
className="j13-mention-token"
|
||||
data-tip={`查看 ${user.nickname || user.username} 的主页`}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{m[1]}
|
||||
</Link>
|
||||
<button
|
||||
key={`m-${i}`}
|
||||
type="button"
|
||||
className="j13-mention-token"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onOpenCard(user);
|
||||
}}
|
||||
onContextMenu={(e) => e.stopPropagation()}
|
||||
>
|
||||
@{user.nickname || user.username}
|
||||
</button>
|
||||
) : (
|
||||
<span key={`m-${i}`}>@{m[1]}</span>
|
||||
)
|
||||
@@ -242,67 +244,154 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
|
||||
}
|
||||
|
||||
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
|
||||
function renderChatContent(content: string, members: ChatRoomMember[]) {
|
||||
function renderChatContent(
|
||||
content: string,
|
||||
users: Map<string, User>,
|
||||
onOpenCard: (u: User) => void
|
||||
) {
|
||||
return (
|
||||
<AutolinkText
|
||||
text={content}
|
||||
forceNewTab
|
||||
renderText={(seg) => <>{highlightMentions(seg, members)}</>}
|
||||
renderText={(seg) => <>{highlightMentions(seg, users, onOpenCard)}</>}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
|
||||
function renderComposerMirror(content: string, tokens: Set<string>) {
|
||||
if (!content) return null;
|
||||
const parts: React.ReactNode[] = [];
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let last = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
let i = 0;
|
||||
while ((m = re.exec(content)) !== null) {
|
||||
if (m.index > last) parts.push(content.slice(last, m.index));
|
||||
const key = m[1].toLowerCase();
|
||||
parts.push(
|
||||
tokens.has(key) ? (
|
||||
<span key={`t-${i}`} className="j13-mention-token">
|
||||
@{m[1]}
|
||||
</span>
|
||||
) : (
|
||||
<span key={`t-${i}`}>@{m[1]}</span>
|
||||
)
|
||||
);
|
||||
last = m.index + m[0].length;
|
||||
i++;
|
||||
/**
|
||||
* 编辑框提及方案:内容直接存「@昵称」(宽度即昵称宽度,光标/选区天然对齐),
|
||||
* mentionTokens 记录 昵称→用户名 映射,发送时再翻译回 @用户名(后端按用户名解析提醒)。
|
||||
* 昵称可含空格/@ 等任意字符,因此按字面匹配扫描(最长优先 + 边界校验 + 占位去重)。
|
||||
*/
|
||||
function collectMentionSpans(
|
||||
text: string,
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number; key: string }[] {
|
||||
const spans: { start: number; end: number; key: string }[] = [];
|
||||
if (!text || tokens.size === 0) return spans;
|
||||
const keys = [...tokens.keys()].sort((a, b) => b.length - a.length);
|
||||
const occupied = new Array<boolean>(text.length).fill(false);
|
||||
for (const key of keys) {
|
||||
if (!key) continue;
|
||||
const needle = `@${key}`;
|
||||
let from = 0;
|
||||
for (;;) {
|
||||
const idx = text.indexOf(needle, from);
|
||||
if (idx < 0) break;
|
||||
from = idx + 1;
|
||||
const end = idx + needle.length;
|
||||
// @ 前需为行首/空白,昵称后需为空白/行尾(与插入时的「@昵称 」格式一致)
|
||||
const prevCh = idx > 0 ? text[idx - 1] : "";
|
||||
if (prevCh && !/\s/.test(prevCh)) continue;
|
||||
const nextCh = end < text.length ? text[end] : "";
|
||||
if (nextCh && !/\s/.test(nextCh)) continue;
|
||||
let clash = false;
|
||||
for (let i = idx; i < end; i++) {
|
||||
if (occupied[i]) {
|
||||
clash = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (clash) continue;
|
||||
for (let i = idx; i < end; i++) occupied[i] = true;
|
||||
spans.push({ start: idx, end, key });
|
||||
}
|
||||
}
|
||||
return spans.sort((a, b) => a.start - b.start);
|
||||
}
|
||||
|
||||
/** 输入区镜像层:已确认的 @ 以蓝色昵称呈现(胶囊样式在发出后的气泡里) */
|
||||
function renderComposerMirror(content: string, tokens: Map<string, string>) {
|
||||
if (!content) return null;
|
||||
const spans = collectMentionSpans(content, tokens);
|
||||
const parts: React.ReactNode[] = [];
|
||||
let last = 0;
|
||||
spans.forEach((s, i) => {
|
||||
if (s.start > last) parts.push(content.slice(last, s.start));
|
||||
parts.push(
|
||||
<span key={`t-${i}`} className="j13-mention-token">
|
||||
@{s.key}
|
||||
</span>
|
||||
);
|
||||
last = s.end;
|
||||
});
|
||||
if (last < content.length) parts.push(content.slice(last));
|
||||
// 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行
|
||||
if (content.endsWith("\n")) parts.push("\n");
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
|
||||
function translateMentionsOut(text: string, tokens: Map<string, string>): string {
|
||||
if (tokens.size === 0) return text;
|
||||
const spans = collectMentionSpans(text, tokens);
|
||||
if (spans.length === 0) return text;
|
||||
let out = "";
|
||||
let last = 0;
|
||||
for (const s of spans) {
|
||||
out += text.slice(last, s.start) + `@${tokens.get(s.key) || s.key}`;
|
||||
last = s.end;
|
||||
}
|
||||
return out + text.slice(last);
|
||||
}
|
||||
|
||||
/** 服务端文本(@用户名)→ 编辑框文本(@昵称),并为可识别的提及建立 token(重新编辑草稿用) */
|
||||
function hydrateMentionsIn(
|
||||
text: string,
|
||||
userIndex: Map<string, User>
|
||||
): { text: string; tokens: Map<string, string> } {
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
const replaces: { start: number; end: number; display: string; username: string }[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const u = userIndex.get(m[1].toLowerCase());
|
||||
if (!u) continue;
|
||||
const display = u.nickname || u.username;
|
||||
if (!display || display === m[1]) continue;
|
||||
replaces.push({
|
||||
start: m.index,
|
||||
end: m.index + m[0].length,
|
||||
display,
|
||||
username: m[1],
|
||||
});
|
||||
}
|
||||
const tokens = new Map<string, string>();
|
||||
let out = text;
|
||||
for (let i = replaces.length - 1; i >= 0; i--) {
|
||||
const r = replaces[i];
|
||||
out = out.slice(0, r.start) + `@${r.display}` + out.slice(r.end);
|
||||
tokens.set(r.display, r.username);
|
||||
}
|
||||
return { text: out, tokens };
|
||||
}
|
||||
|
||||
/** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */
|
||||
function mentionTokenBefore(
|
||||
text: string,
|
||||
cursor: number,
|
||||
tokens: Set<string>
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number } | null {
|
||||
const before = text.slice(0, cursor);
|
||||
const m = before.match(/@([A-Za-z0-9_-]{3,32})( ?)$/);
|
||||
if (!m || !tokens.has(m[1].toLowerCase())) return null;
|
||||
return { start: cursor - m[0].length, end: cursor };
|
||||
for (const s of collectMentionSpans(text, tokens)) {
|
||||
if (s.end === cursor) return { start: s.start, end: cursor };
|
||||
if (s.end + 1 === cursor && text[s.end] === " ") {
|
||||
return { start: s.start, end: cursor };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */
|
||||
function mentionTokenAfter(
|
||||
text: string,
|
||||
cursor: number,
|
||||
tokens: Set<string>
|
||||
tokens: Map<string, string>
|
||||
): { start: number; end: number } | null {
|
||||
const after = text.slice(cursor);
|
||||
const m = after.match(/^@([A-Za-z0-9_-]{3,32})( ?)/);
|
||||
if (!m || !tokens.has(m[1].toLowerCase())) return null;
|
||||
return { start: cursor, end: cursor + m[0].length };
|
||||
for (const s of collectMentionSpans(text, tokens)) {
|
||||
if (s.start !== cursor) continue;
|
||||
const end = s.end < text.length && text[s.end] === " " ? s.end + 1 : s.end;
|
||||
return { start: cursor, end };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function ChatRoomClient({
|
||||
@@ -344,8 +433,12 @@ export default function ChatRoomClient({
|
||||
const [mentionOpen, setMentionOpen] = useState(false);
|
||||
const [mentionFilter, setMentionFilter] = useState("");
|
||||
const [mentionIndex, setMentionIndex] = useState(0);
|
||||
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
|
||||
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
|
||||
/** 从选择器/悬浮确认插入的 @:key=展示昵称,value=用户名(发送时翻译回) */
|
||||
const [mentionTokens, setMentionTokens] = useState<Map<string, string>>(
|
||||
() => new Map()
|
||||
);
|
||||
/** 用户资料卡(头像/@昵称点击弹出) */
|
||||
const [userCard, setUserCard] = useState<ChatUserCardTarget | null>(null);
|
||||
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
|
||||
const [reEditDrafts, setReEditDrafts] = useState<
|
||||
Record<number, { text: string; until: number }>
|
||||
@@ -369,6 +462,25 @@ export default function ChatRoomClient({
|
||||
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
|
||||
const canKickMembers = isOwner || isRoomAdmin || oversee;
|
||||
|
||||
/** @解析索引:成员表 + 消息发送者(成员未加载/已退群仍能显示昵称) */
|
||||
const userIndex = useMemo(() => {
|
||||
const idx = new Map<string, User>();
|
||||
const add = (u?: User | null) => {
|
||||
if (u?.username) idx.set(u.username.toLowerCase(), u);
|
||||
};
|
||||
members.forEach((m) => add(m.user));
|
||||
messages.forEach((m) => add(m.sender));
|
||||
return idx;
|
||||
}, [members, messages]);
|
||||
|
||||
const openUserCard = useCallback(
|
||||
(u: User) => {
|
||||
const mem = members.find((m) => m.user_id === u.id);
|
||||
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
|
||||
},
|
||||
[members]
|
||||
);
|
||||
|
||||
const rememberReEdit = useCallback((id: number, text: string) => {
|
||||
const t = text.trim();
|
||||
if (!t) return;
|
||||
@@ -578,12 +690,17 @@ export default function ChatRoomClient({
|
||||
const prevTokens = mentionTokens;
|
||||
const replyId = replyTo?.id || 0;
|
||||
setContent("");
|
||||
setMentionTokens(new Set());
|
||||
setMentionTokens(new Map());
|
||||
setReplyTo(null);
|
||||
setMentionOpen(false);
|
||||
requestAnimationFrame(() => syncComposerHeight());
|
||||
try {
|
||||
const { message } = await apiSendChatMessage(roomId, prevContent, replyId);
|
||||
// 编辑框内存「@昵称」,发出前翻译回 @用户名(后端按用户名解析提及)
|
||||
const { message } = await apiSendChatMessage(
|
||||
roomId,
|
||||
translateMentionsOut(prevContent, prevTokens),
|
||||
replyId
|
||||
);
|
||||
setMessages((prev) => {
|
||||
if (prev.some((m) => m.id === message.id)) return prev;
|
||||
return [...prev, message];
|
||||
@@ -624,42 +741,53 @@ export default function ChatRoomClient({
|
||||
.slice(0, 8);
|
||||
})();
|
||||
|
||||
const insertMention = (username: string) => {
|
||||
/**
|
||||
* 插入 @提及:
|
||||
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
|
||||
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
|
||||
*/
|
||||
const insertMention = (
|
||||
username: string,
|
||||
nickname: string,
|
||||
opts?: { replaceTrigger?: boolean }
|
||||
) => {
|
||||
const el = inputRef.current;
|
||||
const val = content;
|
||||
const cursor = el?.selectionStart ?? val.length;
|
||||
const before = val.slice(0, cursor);
|
||||
let before = val.slice(0, cursor);
|
||||
const after = val.slice(cursor);
|
||||
const at = before.lastIndexOf("@");
|
||||
const head = at >= 0 ? before.slice(0, at) : before;
|
||||
const next = `${head}@${username} ${after}`;
|
||||
if (opts?.replaceTrigger) {
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at >= 0) before = before.slice(0, at);
|
||||
} else if (before && !/\s$/.test(before)) {
|
||||
before = `${before} `;
|
||||
}
|
||||
// 编辑框直接写入昵称(宽度即昵称宽度),映射表记录昵称→用户名
|
||||
const display = nickname || username;
|
||||
const next = `${before}@${display} ${after}`;
|
||||
setContent(next);
|
||||
setMentionTokens((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.add(username.toLowerCase());
|
||||
return n;
|
||||
});
|
||||
setMentionTokens((prev) => new Map(prev).set(display, username));
|
||||
setMentionOpen(false);
|
||||
setMentionFilter("");
|
||||
requestAnimationFrame(() => {
|
||||
const pos = head.length + username.length + 2;
|
||||
const pos = before.length + display.length + 2;
|
||||
el?.focus();
|
||||
el?.setSelectionRange(pos, pos);
|
||||
syncComposerHeight();
|
||||
});
|
||||
};
|
||||
|
||||
const pruneMentionTokens = (text: string, tokens: Set<string>) => {
|
||||
const pruneMentionTokens = (text: string, tokens: Map<string, string>) => {
|
||||
if (tokens.size === 0) return tokens;
|
||||
const present = new Set<string>();
|
||||
const re = /@([A-Za-z0-9_-]{3,32})/g;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = re.exec(text)) !== null) {
|
||||
const key = m[1].toLowerCase();
|
||||
if (tokens.has(key)) present.add(key);
|
||||
}
|
||||
const present = new Set(
|
||||
collectMentionSpans(text, tokens).map((s) => s.key)
|
||||
);
|
||||
if (present.size === tokens.size) return tokens;
|
||||
return present;
|
||||
const next: Map<string, string> = new Map();
|
||||
tokens.forEach((username, key) => {
|
||||
if (present.has(key)) next.set(key, username);
|
||||
});
|
||||
return next;
|
||||
};
|
||||
|
||||
const onComposerChange = (raw: string) => {
|
||||
@@ -668,7 +796,7 @@ export default function ChatRoomClient({
|
||||
const el = inputRef.current;
|
||||
const cursor = el?.selectionStart ?? raw.length;
|
||||
const before = raw.slice(0, cursor);
|
||||
const m = before.match(/@([A-Za-z0-9_-]{0,32})$/);
|
||||
const m = before.match(/@([^\s@]{0,32})$/);
|
||||
if (m && isRealMember && !isDirect) {
|
||||
setMentionOpen(true);
|
||||
setMentionFilter(m[1] || "");
|
||||
@@ -696,7 +824,13 @@ export default function ChatRoomClient({
|
||||
if (e.key === "Enter" || e.key === "Tab") {
|
||||
e.preventDefault();
|
||||
const pick = mentionCandidates[mentionIndex];
|
||||
if (pick) insertMention(pick.user.username);
|
||||
if (pick) {
|
||||
insertMention(
|
||||
pick.user.username,
|
||||
pick.user.nickname || pick.user.username,
|
||||
{ replaceTrigger: true }
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === "Escape") {
|
||||
@@ -762,6 +896,21 @@ export default function ChatRoomClient({
|
||||
}
|
||||
};
|
||||
|
||||
/** 光标落点在已确认 @token 内部时吸附到 token 末尾(token 原子化,飞书体验) */
|
||||
const onComposerSelect = () => {
|
||||
const el = inputRef.current;
|
||||
if (!el || mentionTokens.size === 0) return;
|
||||
const start = el.selectionStart;
|
||||
const end = el.selectionEnd;
|
||||
if (start !== end) return;
|
||||
for (const s of collectMentionSpans(content, mentionTokens)) {
|
||||
if (start > s.start && start < s.end) {
|
||||
el.setSelectionRange(s.end, s.end);
|
||||
return;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
|
||||
if (m.recalled_at) return;
|
||||
setCtxMenu({ x, y, message: m });
|
||||
@@ -928,13 +1077,16 @@ export default function ChatRoomClient({
|
||||
toast("重新编辑已过期");
|
||||
return;
|
||||
}
|
||||
// 草稿存的是服务端文本(@用户名),回填时翻译为 @昵称 并重建 token
|
||||
const { text: display, tokens } = hydrateMentionsIn(text, userIndex);
|
||||
setReplyTo(null);
|
||||
setContent(text);
|
||||
setContent(display);
|
||||
setMentionTokens(tokens);
|
||||
requestAnimationFrame(() => {
|
||||
const el = inputRef.current;
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
el.setSelectionRange(text.length, text.length);
|
||||
el.setSelectionRange(display.length, display.length);
|
||||
el.style.height = "auto";
|
||||
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
|
||||
});
|
||||
@@ -972,13 +1124,10 @@ export default function ChatRoomClient({
|
||||
];
|
||||
if (!isDirect && isRealMember && m.sender_id !== meId) {
|
||||
items.push({
|
||||
key: "at",
|
||||
label: `提及 @${m.sender.username}`,
|
||||
icon: <AtSign size={14} />,
|
||||
onClick: () => {
|
||||
setContent((c) => `${c}${c && !c.endsWith(" ") ? " " : ""}@${m.sender.username} `);
|
||||
inputRef.current?.focus();
|
||||
},
|
||||
key: "profile",
|
||||
label: "查看资料卡",
|
||||
icon: <Users size={14} />,
|
||||
onClick: () => openUserCard(m.sender),
|
||||
});
|
||||
}
|
||||
if (canRecallMsg(m)) {
|
||||
@@ -1030,6 +1179,25 @@ export default function ChatRoomClient({
|
||||
)}
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{isDirect && peer && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() =>
|
||||
openUserCard({
|
||||
...peer,
|
||||
role: "",
|
||||
})
|
||||
}
|
||||
aria-label={`查看 ${peer.nickname || peer.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={peer.nickname || peer.username}
|
||||
src={peer.avatar || undefined}
|
||||
size={24}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
<h1
|
||||
className="font-extrabold text-[16px] truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
@@ -1222,11 +1390,20 @@ export default function ChatRoomClient({
|
||||
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
{!grouped && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => openUserCard(m.sender)}
|
||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex flex-col min-w-0 max-w-[78%] ${
|
||||
@@ -1267,7 +1444,7 @@ export default function ChatRoomClient({
|
||||
已撤回
|
||||
</div>
|
||||
<div style={{ color: "var(--ink-2)" }}>
|
||||
{renderChatContent(m.content, members)}
|
||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1286,11 +1463,18 @@ export default function ChatRoomClient({
|
||||
>
|
||||
<div className="w-8 shrink-0">
|
||||
{!grouped && (
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => openUserCard(m.sender)}
|
||||
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
|
||||
>
|
||||
<Avatar
|
||||
name={m.sender.nickname || m.sender.username}
|
||||
src={m.sender.avatar || undefined}
|
||||
size={32}
|
||||
/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
@@ -1303,12 +1487,34 @@ export default function ChatRoomClient({
|
||||
{!isDirect && m.sender_id === ownerId && (
|
||||
<Crown size={11} style={{ color: "var(--gold)" }} />
|
||||
)}
|
||||
<span
|
||||
className="text-[12px] truncate"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
<button
|
||||
type="button"
|
||||
className={`text-[12px] truncate transition-colors ${
|
||||
!isDirect && isRealMember
|
||||
? "group/nm cursor-pointer text-[color:var(--ink-2)] hover:text-[color:var(--accent)]"
|
||||
: "cursor-default text-[color:var(--ink-2)]"
|
||||
}`}
|
||||
onClick={() => {
|
||||
if (!isDirect && isRealMember) {
|
||||
insertMention(
|
||||
m.sender.username,
|
||||
m.sender.nickname || m.sender.username
|
||||
);
|
||||
}
|
||||
}}
|
||||
disabled={isDirect || !isRealMember}
|
||||
>
|
||||
{/* 悬浮时名字前出现 @,整个名字成为可 @ 点击对象 */}
|
||||
{!isDirect && isRealMember && (
|
||||
<span
|
||||
aria-hidden
|
||||
className="hidden group-hover/nm:inline font-semibold text-[color:var(--accent)]"
|
||||
>
|
||||
@
|
||||
</span>
|
||||
)}
|
||||
{m.sender.nickname}
|
||||
</span>
|
||||
</button>
|
||||
<span className="meta text-[10.5px]">
|
||||
{formatTime(m.created_at)}
|
||||
</span>
|
||||
@@ -1362,7 +1568,7 @@ export default function ChatRoomClient({
|
||||
{m.reply_snap}
|
||||
</button>
|
||||
)}
|
||||
{renderChatContent(m.content, members)}
|
||||
{renderChatContent(m.content, userIndex, openUserCard)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1428,7 +1634,11 @@ export default function ChatRoomClient({
|
||||
}}
|
||||
onMouseDown={(e) => {
|
||||
e.preventDefault();
|
||||
insertMention(m.user.username);
|
||||
insertMention(
|
||||
m.user.username,
|
||||
m.user.nickname || m.user.username,
|
||||
{ replaceTrigger: true }
|
||||
);
|
||||
}}
|
||||
>
|
||||
<Avatar
|
||||
@@ -1476,6 +1686,7 @@ export default function ChatRoomClient({
|
||||
value={content}
|
||||
onChange={(e) => onComposerChange(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
onSelect={onComposerSelect}
|
||||
onScroll={(e) => {
|
||||
if (mirrorRef.current) {
|
||||
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
|
||||
@@ -1587,6 +1798,13 @@ export default function ChatRoomClient({
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
onOpenCard={(mem) =>
|
||||
setUserCard({
|
||||
user: mem.user,
|
||||
roomRole: mem.role,
|
||||
muted: mem.muted,
|
||||
})
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{rightPanel === "invite" && (
|
||||
@@ -1628,6 +1846,13 @@ export default function ChatRoomClient({
|
||||
onKick={handleKick}
|
||||
onMute={handleMute}
|
||||
onSetAdmin={handleSetAdmin}
|
||||
onOpenCard={(mem) =>
|
||||
setUserCard({
|
||||
user: mem.user,
|
||||
roomRole: mem.role,
|
||||
muted: mem.muted,
|
||||
})
|
||||
}
|
||||
showTitle
|
||||
/>
|
||||
</aside>
|
||||
@@ -1642,6 +1867,14 @@ export default function ChatRoomClient({
|
||||
/>
|
||||
)}
|
||||
|
||||
{userCard && (
|
||||
<ChatUserCard
|
||||
target={userCard}
|
||||
meId={meId}
|
||||
onClose={() => setUserCard(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={!!confirm}
|
||||
title={confirm?.title || ""}
|
||||
@@ -1700,6 +1933,7 @@ function MembersPanel({
|
||||
onKick,
|
||||
onMute,
|
||||
onSetAdmin,
|
||||
onOpenCard,
|
||||
showTitle = false,
|
||||
}: {
|
||||
members: ChatRoomMember[];
|
||||
@@ -1711,6 +1945,7 @@ function MembersPanel({
|
||||
onKick: (uid: number, nickname: string) => void;
|
||||
onMute: (uid: number, nickname: string, muted: boolean) => void;
|
||||
onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void;
|
||||
onOpenCard: (m: ChatRoomMember) => void;
|
||||
showTitle?: boolean;
|
||||
}) {
|
||||
return (
|
||||
@@ -1756,7 +1991,14 @@ function MembersPanel({
|
||||
key={m.id}
|
||||
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
|
||||
>
|
||||
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full transition-opacity hover:opacity-85"
|
||||
onClick={() => onOpenCard(m)}
|
||||
aria-label={`查看 ${name} 的资料卡`}
|
||||
>
|
||||
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
|
||||
</button>
|
||||
<Link
|
||||
href={`/u/${m.user.id}`}
|
||||
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"
|
||||
|
||||
Reference in New Issue
Block a user