feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
119
frontend/components/BoardIcon.tsx
Normal file
119
frontend/components/BoardIcon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
218
frontend/components/ChatUserCard.tsx
Normal file
218
frontend/components/ChatUserCard.tsx
Normal 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={() => {}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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>
|
||||
))}
|
||||
|
||||
Reference in New Issue
Block a user