feat: 实时消息与管理端运营能力

新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-15 22:58:16 +08:00
parent 29db9ae9b5
commit 42c395a864
55 changed files with 7607 additions and 346 deletions

View File

@@ -9,6 +9,7 @@ import {
ChevronRight,
Clock,
ExternalLink,
FolderSearch,
History,
Loader2,
Monitor,
@@ -31,9 +32,10 @@ import {
type Board,
type LoginLog,
} from "@/lib/api";
import { ROLES } from "@/lib/roles";
import { isOwner, ROLES } from "@/lib/roles";
import { formatDate, formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { realtime } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
@@ -156,6 +158,24 @@ export default function UsersAdmin({
.catch(() => {});
}, []);
// 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变;
// 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确
useEffect(() => {
const reconcile = (ids: ReadonlySet<number>) => {
if (ids.size === 0) return;
setUsers((prev) =>
prev.map((u) => (u.online === ids.has(u.id) ? u : { ...u, online: ids.has(u.id) }))
);
};
reconcile(realtime.getOnlineIds());
const off = realtime.onPresence(({ user_id: id, online }) => {
setUsers((prev) =>
prev.map((u) => (u.id === id && u.online !== online ? { ...u, online } : u))
);
});
return off;
}, []);
const load = useCallback(async (opts: { page: number; filter: UserFilter; q: string }) => {
const seq = ++seqRef.current;
setLoading(true);
@@ -319,10 +339,24 @@ export default function UsersAdmin({
);
const renderActions = (u: AdminUser, isSelf: boolean) => {
// 站长账号:任何人不可操作(后端 403 兜底),仅保留主页入口与站长标识
const canAudit = isOwner(currentUserRole);
const auditBtn = canAudit ? (
<Link
href={`/admin/users/${u.id}`}
title="内容档案"
aria-label={`查看 ${u.username} 的内容档案`}
className={ghostBtn}
style={{ color: "var(--accent)" }}
>
<FolderSearch size={15} />
</Link>
) : null;
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
if (u.role === ROLES.OWNER) {
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<Link
href={`/u/${u.id}`}
title="查看个人主页"
@@ -336,17 +370,21 @@ export default function UsersAdmin({
}
if (isSelf) {
return (
<span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold whitespace-nowrap"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<span
className="inline-flex items-center rounded-full px-2.5 py-1 text-[11px] font-bold whitespace-nowrap"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
当前账号
</span>
</div>
);
}
const busy = actingId === u.id;
return (
<div className="flex items-center gap-1 justify-end">
{auditBtn}
<button
type="button"
title="登录历史"
@@ -623,7 +661,11 @@ export default function UsersAdmin({
</div>
<div className="min-w-0">{renderLastLogin(u)}</div>
<div className="hidden xl:block">
<p className="text-[12.5px] font-semibold tabular-nums" style={{ color: "var(--ink-2)" }}>
<p
className="text-[12.5px] font-semibold tabular-nums"
style={{ color: "var(--ink-2)" }}
title="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
<p className="text-[11px] tabular-nums" style={{ color: "var(--ink-3)" }}>
@@ -661,7 +703,11 @@ export default function UsersAdmin({
</div>
{renderStatus(u)}
</div>
<p className="mt-2.5 text-[11.5px] tabular-nums" style={{ color: "var(--ink-3)" }}>
<p
className="mt-2.5 text-[11.5px] tabular-nums"
style={{ color: "var(--ink-3)" }}
title="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}
</p>
<p className="mt-1 text-[11.5px] tabular-nums flex items-center gap-1.5" style={{ color: "var(--ink-3)" }}>
@@ -671,37 +717,49 @@ export default function UsersAdmin({
{u.last_login_at ? ` · ${formatRelative(u.last_login_at)}` : ""}
</button>
</p>
{!isSelf && u.role !== ROLES.OWNER && (
<div className="flex items-center gap-2 mt-3 flex-wrap">
<div className="flex items-center gap-2 mt-3 flex-wrap">
{isOwner(currentUserRole) && (
<Link href={`/admin/users/${u.id}`} className="btn btn-line btn-sm">
<FolderSearch size={13} /> 内容档案
</Link>
)}
{(isSelf || u.role === ROLES.OWNER) && (
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
<ExternalLink size={13} /> 主页
</Link>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setRoleTarget(u)}
className="btn btn-line btn-sm"
style={u.role !== ROLES.USER ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
<ShieldCheck size={13} />
角色权限
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setBanTarget(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
</button>
</div>
)}
)}
{!isSelf && u.role !== ROLES.OWNER && (
<>
<Link href={`/u/${u.id}`} className="btn btn-line btn-sm">
<ExternalLink size={13} /> 主页
</Link>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setRoleTarget(u)}
className="btn btn-line btn-sm"
style={u.role !== ROLES.USER ? { color: "var(--accent)", borderColor: "var(--accent)" } : undefined}
>
<ShieldCheck size={13} />
角色权限
</button>
<button
type="button"
disabled={actingId === u.id}
onClick={() => setBanTarget(u)}
className="btn btn-sm"
style={
u.banned
? { background: "var(--ok-soft)", color: "var(--ok)" }
: { background: "var(--danger-soft)", color: "var(--danger)" }
}
>
{u.banned ? <RotateCcw size={13} /> : <Ban size={13} />}
{u.banned ? "解封" : "封禁"}
</button>
</>
)}
</div>
</div>
</div>
);