feat: 管理端板块管理与旧版数据导入,补充部署运营文档

- 新增管理端板块管理页面与后端接口(admin_board)
- 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具
- 聊天用户卡片、板块图标等 UI 组件与界面优化
- 补充 about/公告/1Panel 部署等文档
- gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
2026-09-24 03:37:44 +08:00
parent c1f6a6636b
commit 3e55b5d230
53 changed files with 5078 additions and 409 deletions

View File

@@ -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"