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

@@ -0,0 +1,218 @@
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Calendar, Crown, MessageSquare, VolumeX } from "lucide-react";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
import ImageLightbox from "@/components/markdown/ImageLightbox";
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
import { toast } from "@/lib/toast";
export type ChatUserCardTarget = {
user: User;
/** 群内身份(可从成员表查到时携带) */
roomRole?: "owner" | "admin" | "member";
muted?: boolean;
};
/**
* 聊天用户资料卡:头像(可进图箱)+ 基本信息 + 主页/私聊入口。
* 公开资料拉取失败时降级为消息内已有的基础字段,不阻塞弹层。
*/
export default function ChatUserCard({
target,
meId,
onClose,
}: {
target: ChatUserCardTarget;
meId: number;
onClose: () => void;
}) {
const router = useRouter();
const allowMessages = useAllowMessages();
const [profile, setProfile] = useState<UserProfile | null>(null);
const [dmBusy, setDmBusy] = useState(false);
const [avatarOpen, setAvatarOpen] = useState(false);
const { user } = target;
const isSelf = user.id === meId;
const name = user.nickname || user.username;
useEffect(() => {
let alive = true;
setProfile(null);
apiGetUserProfile(user.id)
.then((p) => {
if (alive) setProfile(p);
})
.catch(() => {
// 静默:资料卡仍展示消息携带的基础字段
});
return () => {
alive = false;
};
}, [user.id]);
const startDM = async () => {
setDmBusy(true);
try {
const { room } = await apiOpenDirectChat(user.id);
onClose();
router.push(`/chat/${room.id}`);
} catch (e) {
toast(e instanceof Error ? e.message : "无法发起私聊");
} finally {
setDmBusy(false);
}
};
const sig = (profile?.user.signature || "").trim();
const canPreviewAvatar = !!user.avatar;
const stats = profile?.stats;
return (
<>
<Modal
open
title="用户资料"
ariaLabel={`${name} 的资料卡`}
onClose={onClose}
maxWidthClass="max-w-sm"
>
{/* 头部:头像 + 昵称/角色 */}
<div className="flex items-center gap-3.5">
{canPreviewAvatar ? (
<button
type="button"
className="cursor-zoom-in rounded-full outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
aria-label="查看头像"
onClick={() => setAvatarOpen(true)}
>
<Avatar name={name} src={user.avatar || undefined} size={64} />
</button>
) : (
<Avatar name={name} src={user.avatar || undefined} size={64} />
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<p
className="text-[16px] font-extrabold tracking-tight truncate"
style={{ color: "var(--ink)" }}
>
{name}
</p>
{user.role && user.role !== "user" && (
<RoleBadge role={user.role} boardIds={user.board_ids} />
)}
{profile?.user.banned === true && (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-semibold"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
已封禁
</span>
)}
</div>
<p className="meta text-[12.5px] mt-0.5 truncate">@{user.username}</p>
{target.roomRole === "owner" && (
<p
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px] font-medium"
style={{ color: "var(--gold)" }}
>
<Crown size={12} /> 群主
</p>
)}
{target.roomRole === "admin" && (
<p
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px] font-medium"
style={{ color: "var(--accent)" }}
>
<Crown size={12} /> 群管理员
</p>
)}
{target.muted && (
<p
className="mt-1.5 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<VolumeX size={12} /> 禁言中
</p>
)}
</div>
</div>
{/* 签名 */}
{sig && (
<p
className="mt-3 text-[13px] leading-relaxed"
style={{ color: "var(--ink-2)" }}
>
{sig}
</p>
)}
{/* 基本信息行 */}
<div
className="mt-3.5 pt-3 flex items-center gap-x-3 gap-y-1.5 flex-wrap text-[12.5px]"
style={{ borderTop: "1px solid var(--line)", color: "var(--ink-2)" }}
>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{stats ? stats.post_count : "–"}
</span>{" "}
帖子
</span>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{stats ? stats.comment_count : "–"}
</span>{" "}
评论
</span>
<span>
<span className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
{stats ? stats.points : "–"}
</span>{" "}
积分
</span>
{profile?.user.created_at && (
<span className="inline-flex items-center gap-1 meta">
<Calendar size={12} />
{new Date(profile.user.created_at).toLocaleDateString("zh-CN")} 加入
</span>
)}
</div>
{/* 操作区 */}
<div className="mt-4 flex items-center gap-2">
<Link href={`/u/${user.id}`} className="btn btn-line btn-sm flex-1 justify-center">
用户主页
</Link>
{allowMessages && !isSelf && (
<button
type="button"
onClick={startDM}
disabled={dmBusy}
className="btn btn-accent btn-sm flex-1 justify-center gap-1.5"
>
<MessageSquare size={14} />
{dmBusy ? "打开中…" : "发私信"}
</button>
)}
</div>
</Modal>
{avatarOpen && canPreviewAvatar && (
<ImageLightbox
items={[{ src: user.avatar, alt: `${name} 的头像` }]}
index={0}
onClose={() => setAvatarOpen(false)}
onIndexChange={() => {}}
/>
)}
</>
);
}