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,119 @@
import {
Bookmark,
Book,
Briefcase,
Bell,
Camera,
Code,
Coffee,
Cpu,
Film,
Flame,
FlaskConical,
Gamepad2,
Globe,
Hash,
Heart,
HelpCircle,
Megaphone,
MessageCircle,
Music,
Newspaper,
Palette,
Pencil,
Rocket,
Sparkles,
Star,
type LucideIcon,
} from "lucide-react";
/** 板块图标库:icon 字段值 → lucide 图标;与后端种子数据保持一致 */
const BOARD_ICON_MAP: Record<string, LucideIcon> = {
"message-circle": MessageCircle,
code: Code,
"help-circle": HelpCircle,
coffee: Coffee,
sparkles: Sparkles,
star: Star,
heart: Heart,
bookmark: Bookmark,
hash: Hash,
flame: Flame,
bell: Bell,
megaphone: Megaphone,
newspaper: Newspaper,
book: Book,
briefcase: Briefcase,
camera: Camera,
music: Music,
film: Film,
"gamepad-2": Gamepad2,
rocket: Rocket,
globe: Globe,
pencil: Pencil,
palette: Palette,
cpu: Cpu,
"flask-conical": FlaskConical,
};
/** 管理端图标选项:value 即 icon 字段值,label 为中文描述 */
export const BOARD_ICONS: { value: string; label: string }[] = [
{ value: "message-circle", label: "综合" },
{ value: "hash", label: "话题" },
{ value: "code", label: "代码" },
{ value: "help-circle", label: "求助" },
{ value: "coffee", label: "闲聊" },
{ value: "sparkles", label: "闪光" },
{ value: "star", label: "星标" },
{ value: "heart", label: "心形" },
{ value: "bookmark", label: "书签" },
{ value: "flame", label: "热门" },
{ value: "bell", label: "通知" },
{ value: "megaphone", label: "公告" },
{ value: "newspaper", label: "资讯" },
{ value: "book", label: "学习" },
{ value: "briefcase", label: "职场" },
{ value: "camera", label: "摄影" },
{ value: "music", label: "音乐" },
{ value: "film", label: "影视" },
{ value: "gamepad-2", label: "游戏" },
{ value: "rocket", label: "创业" },
{ value: "globe", label: "国际" },
{ value: "pencil", label: "写作" },
{ value: "palette", label: "设计" },
{ value: "cpu", label: "硬件" },
{ value: "flask-conical", label: "科学" },
];
/** 配色总数:av-0 ~ av-(N-1),与 globals.css 保持一致 */
export const BOARD_COLOR_COUNT = 16;
/**
* 解析板块配色下标(全站统一规则):
* - color_index > 0:固定配色,按 N 取模(存 1..16 渲染 av-0..av-15)
* - 其余(0 = 旧数据未设置 / -1 = 自动):按板块 ID 轮换,任何页面位置都一致
*/
export function boardColor(b: { id: number; color_index?: number | null }): number {
const ci = b.color_index ?? 0;
if (ci > 0) return ci % BOARD_COLOR_COUNT;
return b.id % BOARD_COLOR_COUNT;
}
/** 板块图标:icon 匹配 lucide 图标名;未知/缺失时回退为板块名首字 */
export default function BoardIcon({
icon,
name,
size = 15,
}: {
icon?: string | null;
name: string;
size?: number;
}) {
const I = icon ? BOARD_ICON_MAP[icon] : undefined;
if (I) return <I size={size} aria-hidden="true" />;
return (
<span aria-hidden="true" style={{ fontSize: Math.max(10, size * 0.78), lineHeight: 1 }}>
{name.slice(0, 1)}
</span>
);
}

View File

@@ -3,6 +3,7 @@
import { useEffect, useId, useRef, useState } from "react";
import { Check, ChevronDown } from "lucide-react";
import type { Board } from "@/lib/api";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
type BoardPickerProps = {
boards: Board[];
@@ -15,10 +16,6 @@ type BoardPickerProps = {
id?: string;
};
function boardColorIndex(b: Board, fallbackIndex: number) {
return Math.max(0, b.color_index ?? fallbackIndex) % 8;
}
export default function BoardPicker({
boards,
value,
@@ -104,9 +101,7 @@ export default function BoardPicker({
? "暂无可用板块"
: "请选择板块";
const colorIdx = selected
? boardColorIndex(selected, selectedIdx)
: 0;
const colorIdx = selected ? boardColor(selected) : 0;
return (
<div className="j13-board-picker" ref={rootRef}>
@@ -126,7 +121,7 @@ export default function BoardPicker({
className={`j13-board-trigger-ico${selected ? ` av-${colorIdx}` : " is-placeholder"}`}
aria-hidden
>
{(selected?.name || "板").slice(0, 1)}
<BoardIcon icon={selected?.icon} name={selected?.name || "板"} size={16} />
</span>
<span className="j13-board-trigger-copy">
<span className="j13-board-trigger-name">{label}</span>
@@ -160,7 +155,6 @@ export default function BoardPicker({
>
{boards.map((b, i) => {
const selectedHere = b.id === value;
const c = boardColorIndex(b, i);
return (
<li key={b.id} role="presentation">
<button
@@ -173,8 +167,8 @@ export default function BoardPicker({
onMouseEnter={() => setActiveIndex(i)}
onClick={() => pick(b.id)}
>
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
{b.name.slice(0, 1)}
<span className={`j13-board-trigger-ico av-${boardColor(b)}`} aria-hidden>
<BoardIcon icon={b.icon} name={b.name} size={16} />
</span>
<span className="flex-1 min-w-0 truncate">{b.name}</span>
{selectedHere && <Check size={15} className="shrink-0" />}
@@ -188,19 +182,23 @@ export default function BoardPicker({
);
}
/** 编辑态只读板块徽章(与触发器同高) */
/** 编辑态只读板块徽章(与触发器同高;boardId + 原始 color_index,内部按全站规则解析配色) */
export function BoardBadge({
name,
boardId,
colorIndex = 0,
icon,
}: {
name: string;
boardId: number;
colorIndex?: number;
icon?: string;
}) {
const c = Math.max(0, colorIndex) % 8;
const c = boardColor({ id: boardId, color_index: colorIndex });
return (
<div className="j13-board-badge">
<span className={`j13-board-trigger-ico av-${c}`} aria-hidden>
{name.slice(0, 1)}
<BoardIcon icon={icon} name={name} size={16} />
</span>
<span className="truncate">{name}</span>
</div>

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={() => {}}
/>
)}
</>
);
}

View File

@@ -169,6 +169,7 @@ type EditProps = {
boardName?: string;
boardId?: number;
boardColorIndex?: number;
boardIcon?: string;
cancelHref: string;
};
@@ -1397,7 +1398,9 @@ export default function PostComposer(props: PostComposerProps) {
) : (
<BoardBadge
name={props.boardName || "未知板块"}
boardId={props.boardId || 0}
colorIndex={props.boardColorIndex ?? 0}
icon={props.boardIcon}
/>
)}
</section>

View File

@@ -10,6 +10,7 @@ import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import Modal from "@/components/Modal";
import RoleBadge from "@/components/RoleBadge";
import BoardIcon from "@/components/BoardIcon";
/** 角色与账号弹窗所需的最小用户形状(后台列表项 / 公开资料均可) */
export interface RoleAccountTarget {
@@ -281,6 +282,7 @@ export default function RoleAccountModal({
</svg>
)}
</span>
<BoardIcon icon={b.icon} name={b.name} size={13} />
<span className="truncate">{b.name}</span>
</button>
);

View File

@@ -1,4 +1,4 @@
"use client";
"use client";
import Link from "next/link";
import { usePathname, useRouter } from "next/navigation";
@@ -23,6 +23,7 @@ import {
ChevronDown,
FileText,
CircleDollarSign,
Columns3,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
@@ -30,6 +31,7 @@ import {
canModerateAny,
canAccessAdminMessages,
isAdminOrAbove,
isOwner,
isStaff,
isSuperOrOwner,
type RoleUserLike,
@@ -117,6 +119,17 @@ const NAV_GROUPS: NavGroup[] = [
},
],
},
{
title: "板块",
items: [
{
href: "/admin/boards",
label: "板块管理",
icon: Columns3,
visible: (u) => isOwner(u.role),
},
],
},
{
title: "站点",
items: [

View File

@@ -5,6 +5,7 @@ import { useSearchParams } from "next/navigation";
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) {
@@ -52,7 +53,7 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
{boards.map((b, i) => (
{boards.map((b) => (
<Link
key={b.id}
href={boardHref(b.id)}
@@ -60,8 +61,8 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
aria-current={activeBoard === b.id ? "page" : undefined}
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
>
<span className={`av-${i % 8} j13-bcard-ico`} aria-hidden="true">
{b.name.slice(0, 1)}
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
<BoardIcon icon={b.icon} name={b.name} size={15} />
</span>
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
<span className="j13-bcard-name truncate">{b.name}</span>
@@ -126,6 +127,7 @@ export function WorkspaceNavMobile({
className="j13-chip"
aria-current={activeBoard === b.id ? "page" : undefined}
>
<BoardIcon icon={b.icon} name={b.name} size={13} />
{b.name}
</Link>
))}