feat: 实时消息与管理端运营能力
新增群聊/私信与公共 WebSocket 总线,站长用户内容档案,并将通知与待审角标改为推送驱动;同步精简管理仪表盘并修复主题换肤 DOM 冲突。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user