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

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
import { ArrowLeft, Copy, ExternalLink, Loader2, Megaphone } from "lucide-react";
import {
apiAdminCreateAnnouncement,
apiAdminUpdateAnnouncement,
@@ -246,34 +246,68 @@ export default function AnnouncementComposeClient({
return (
<div className="admin-doc-page admin-doc-editor">
<header className="admin-doc-head">
<div className="min-w-0">
<Link href={backHref} className="admin-doc-back">
<ArrowLeft size={14} aria-hidden /> 返回列表
</Link>
<div className="admin-doc-head-title">
<header className="admin-doc-sticky">
<div className="admin-doc-sticky-inner">
<div className="admin-doc-sticky-title">
<Link href={backHref} className="admin-doc-back" aria-label="返回公告列表">
<ArrowLeft size={15} aria-hidden />
<span className="admin-doc-back-text">返回列表</span>
</Link>
<span className="admin-doc-sticky-icon" aria-hidden>
<Megaphone size={15} />
</span>
<h1>{isEdit ? "编辑公告" : "新建公告"}</h1>
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
</AdminStatusChip>
</div>
<div className="admin-doc-sticky-actions">
{canViewOnline ? (
<a
href={publicPath}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 线上页面
</a>
) : null}
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "发布" },
]}
/>
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={requestSave}
aria-busy={saving || undefined}
>
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
</div>
{canViewOnline ? (
<a
href={publicPath}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 查看线上页面
</a>
) : null}
</header>
<div className="admin-doc-split">
<div className="admin-doc-main">
<AdminField
className="admin-doc-title"
id="anno-title"
label="标题"
maxLength={200}
@@ -328,7 +362,12 @@ export default function AnnouncementComposeClient({
/>
<div className="admin-doc-field">
<p className="admin-doc-label">标签颜色</p>
<p className="admin-doc-label">
标签颜色
<span className="admin-doc-label-value">
{ANNOUNCEMENT_COLORS.find((c) => c.value === form.tag_color)?.label}
</span>
</p>
<div className="admin-doc-colors" role="radiogroup" aria-label="标签颜色">
{ANNOUNCEMENT_COLORS.map((c) => (
<button
@@ -343,7 +382,12 @@ export default function AnnouncementComposeClient({
/>
))}
</div>
<p className="admin-doc-hint">只影响标签颜色,不改正文。</p>
{/* 与前台同一套 j13-anno-tag 标记,所见即所得 */}
<div className="admin-doc-tagpreview" data-note="前台胶囊效果">
<span className={`j13-anno-tag is-${form.tag_color}`}>
{form.tag.trim() || "公告"}
</span>
</div>
{errors.tag_color ? (
<p className="admin-settings-row-error" role="alert">
{errors.tag_color}
@@ -366,65 +410,25 @@ export default function AnnouncementComposeClient({
</p>
</div>
{isEdit && saved ? (
<div className="admin-doc-field">
<p className="admin-doc-label">访问地址</p>
<p className="admin-doc-hint">
{publicPath}
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
</p>
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
<Copy size={14} aria-hidden /> 复制地址
</button>
</div>
) : (
<p className="admin-doc-hint">保存后才会有公开地址 /announcement/编号。草稿没有公开入口。</p>
)}
<div className="admin-doc-field">
<p className="admin-doc-label">保存后状态</p>
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "已发布" },
]}
/>
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
<p className="admin-doc-label">访问地址</p>
{isEdit && saved ? (
<>
<p className="admin-doc-hint">
{publicPath}
{origin ? ` · ${origin.replace(/\/$/, "")}${publicPath}` : ""}
</p>
<p className="admin-doc-hint">地址按公告编号生成,不能改成自定义路径。</p>
<button type="button" className="btn btn-line admin-doc-copy" onClick={() => void copyAddress()}>
<Copy size={14} aria-hidden /> 复制地址
</button>
</>
) : (
<p className="admin-doc-hint">保存后按公告编号生成 /announcement/编号;草稿没有公开入口。</p>
)}
</div>
</div>
</details>
<div className="admin-doc-actions" aria-busy={saving || undefined}>
<div className="admin-doc-actions-btns">
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary"
disabled={!dirty || saving}
onClick={requestSave}
>
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
<p className="admin-doc-hint">一次保存会写入标题、正文和公告设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
{canViewOnline ? (
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
) : (
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
)}
</div>
</div>
<ConfirmDialog

View File

@@ -0,0 +1,739 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import {
FolderOpen,
Plus,
Pencil,
Trash2,
Users as UsersIcon,
ChevronUp,
ChevronDown,
EyeOff,
Check,
} from "lucide-react";
import {
apiAdminAddBoardModerator,
apiAdminCreateBoard,
apiAdminDeleteBoard,
apiAdminListBoardModerators,
apiAdminListBoards,
apiAdminReorderBoards,
apiAdminRemoveBoardModerator,
apiAdminUpdateBoard,
BOARD_POST_POLICY,
type AdminBoard,
type AdminBoardInput,
type AdminBoardModerator,
} from "@/lib/api";
import {
AdminEmpty,
AdminPageHeader,
AdminStatusChip,
} from "@/components/admin";
import Modal from "@/components/Modal";
import Avatar from "@/components/Avatar";
import BoardIcon, {
BOARD_ICONS,
BOARD_COLOR_COUNT,
boardColor,
} from "@/components/BoardIcon";
const POLICY_LABEL: Record<string, string> = {
[BOARD_POST_POLICY.ALL]: "全员可发",
[BOARD_POST_POLICY.STAFF]: "仅管理员",
};
const POST_POLICY_OPTIONS: { value: string; label: string; desc: string }[] = [
{
value: BOARD_POST_POLICY.ALL,
label: "全员可发",
desc: "所有登录用户均可在此板块发帖",
},
{
value: BOARD_POST_POLICY.STAFF,
label: "仅管理员及以上",
desc: "仅版主、超级管理员、站长可发帖",
},
];
function emptyInput(): AdminBoardInput {
return {
name: "",
description: "",
icon: "message-circle",
color_index: -1,
sort_order: 0,
post_policy: BOARD_POST_POLICY.ALL,
visible: true,
};
}
function toInput(b: AdminBoard): AdminBoardInput {
return {
name: b.name,
description: b.description,
icon: b.icon,
color_index: b.color_index,
sort_order: b.sort_order,
post_policy: b.post_policy,
visible: b.visible,
};
}
function BoardEditModal({
open,
initial,
title,
busy,
editTarget,
onClose,
onSubmit,
}: {
open: boolean;
initial: AdminBoardInput;
title: string;
busy: boolean;
/** 编辑中的板块(新建为 null);自动配色预览按其 ID 轮换 */
editTarget: AdminBoard | null;
onClose: () => void;
onSubmit: (input: AdminBoardInput) => void;
}) {
const [form, setForm] = useState<AdminBoardInput>(initial);
useEffect(() => {
if (open) setForm(initial);
}, [open, initial]);
const update = (patch: Partial<AdminBoardInput>) => setForm((f) => ({ ...f, ...patch }));
// 预览配色:固定(>0)按存值取模;自动(-1/0)按已有板块 ID 轮换,新板块用 accent 占位
const previewColor =
(form.color_index ?? 0) > 0
? (form.color_index ?? 0) % BOARD_COLOR_COUNT
: editTarget
? editTarget.id % BOARD_COLOR_COUNT
: -1;
return (
<Modal
open={open}
title={title}
onClose={onClose}
busy={busy}
maxWidthClass="max-w-lg"
footer={
<>
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
取消
</button>
<button
type="button"
className="btn btn-primary"
onClick={() => onSubmit(form)}
disabled={busy || !form.name.trim()}
>
保存
</button>
</>
}
>
<div className="flex flex-col gap-4">
<label className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
板块名称 <span style={{ color: "var(--danger)" }}>*</span>
</span>
<input
type="text"
value={form.name}
maxLength={64}
onChange={(e) => update({ name: e.target.value })}
className="field"
placeholder="如:综合讨论"
/>
</label>
{/* 外观:实时预览 + 图标 + 颜色,联动更新 */}
<div
className="flex flex-col gap-3.5 rounded-xl p-3.5"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="flex items-center gap-3">
<span
className={`w-11 h-11 rounded-xl flex items-center justify-center shrink-0 ${
previewColor >= 0 ? `av-${previewColor}` : ""
}`}
style={
previewColor < 0
? { background: "var(--accent-soft)", color: "var(--accent)" }
: undefined
}
aria-hidden
>
<BoardIcon icon={form.icon} name={form.name || "板"} size={20} />
</span>
<div className="min-w-0">
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{form.name.trim() || "板块名称预览"}
</p>
<p className="meta text-[12px] truncate">
{form.description?.trim() || "描述将显示在这里"}
</p>
</div>
<span className="ml-auto meta text-[11px] shrink-0 tracking-wide">前台预览</span>
</div>
<div>
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
图标
</p>
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="板块图标">
{BOARD_ICONS.map((o) => {
const active = form.icon === o.value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
data-tip={o.label}
aria-label={o.label}
onClick={() => update({ icon: o.value })}
className="w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors"
style={{
background: "var(--panel)",
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
color: active ? "var(--accent)" : "var(--ink-2)",
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
}}
>
<BoardIcon icon={o.value} name="" size={16} />
</button>
);
})}
</div>
</div>
<div>
<p className="text-[12px] font-medium mb-1.5" style={{ color: "var(--ink-3)" }}>
图标颜色
</p>
<div className="flex flex-wrap gap-1.5" role="radiogroup" aria-label="图标颜色">
<button
type="button"
role="radio"
aria-checked={(form.color_index ?? 0) <= 0}
data-tip="自动:按板块编号轮换配色"
aria-label="自动配色"
onClick={() => update({ color_index: -1 })}
className="h-9 px-2.5 rounded-[10px] flex items-center justify-center text-[11.5px] font-medium transition-colors"
style={{
background: "var(--panel)",
border: `1.5px dashed ${(form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--line)"}`,
color: (form.color_index ?? 0) <= 0 ? "var(--accent)" : "var(--ink-3)",
boxShadow:
(form.color_index ?? 0) <= 0 ? "0 0 0 3px var(--accent-soft)" : undefined,
}}
>
自动
</button>
{/* 存值 1..16 → 渲染 av-1..av-15、av-0;0 与 -1 均视为自动 */}
{Array.from({ length: BOARD_COLOR_COUNT }, (_, k) => k + 1).map((v) => {
const active = (form.color_index ?? 0) === v;
return (
<button
key={v}
type="button"
role="radio"
aria-checked={active}
data-tip={`颜色 ${v}`}
aria-label={`颜色 ${v}`}
onClick={() => update({ color_index: v })}
className={`av-${v % BOARD_COLOR_COUNT} w-9 h-9 rounded-[10px] flex items-center justify-center transition-colors`}
style={{
border: `1.5px solid ${active ? "currentColor" : "transparent"}`,
boxShadow: active ? "0 0 0 3px var(--accent-soft)" : undefined,
}}
>
{active && <Check size={14} strokeWidth={3} />}
</button>
);
})}
</div>
<p className="meta text-[11.5px] mt-1.5">
{(form.color_index ?? 0) <= 0
? "自动模式:前台按板块编号分配配色,各页面保持一致。"
: "指定固定配色,前台所有位置保持一致。"}
</p>
</div>
</div>
<label className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
描述
</span>
<textarea
value={form.description}
maxLength={512}
rows={3}
onChange={(e) => update({ description: e.target.value })}
className="field"
placeholder="板块简介,可选"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
排序权重
</span>
<input
type="number"
value={form.sort_order ?? 0}
onChange={(e) => update({ sort_order: parseInt(e.target.value || "0", 10) })}
className="field"
placeholder="数字越小越靠前"
/>
</label>
<div className="flex flex-col gap-1.5">
<span className="text-[12.5px] font-medium" style={{ color: "var(--ink-2)" }}>
发帖策略
</span>
<div
className="grid grid-cols-2 gap-2"
role="radiogroup"
aria-label="发帖策略"
>
{POST_POLICY_OPTIONS.map((o) => {
const active = (form.post_policy || BOARD_POST_POLICY.ALL) === o.value;
return (
<button
key={o.value}
type="button"
role="radio"
aria-checked={active}
onClick={() => update({ post_policy: o.value })}
className="flex flex-col gap-0.5 rounded-[10px] px-3 py-2.5 text-left transition-colors"
style={{
background: active ? "var(--accent-soft)" : "var(--panel-2)",
border: `1.5px solid ${active ? "var(--accent)" : "var(--line)"}`,
cursor: "pointer",
}}
>
<span
className="text-[13px] font-semibold"
style={{ color: active ? "var(--accent)" : "var(--ink)" }}
>
{o.label}
</span>
<span
className="text-[11.5px] leading-snug"
style={{ color: active ? "var(--accent)" : "var(--ink-3)" }}
>
{o.desc}
</span>
</button>
);
})}
</div>
</div>
<label className="inline-flex items-center gap-2 text-[13px]" style={{ color: "var(--ink)" }}>
<input
type="checkbox"
checked={!!form.visible}
onChange={(e) => update({ visible: e.target.checked })}
className="accent-[var(--accent)]"
/>
前台可见(关闭后仅管理团队可见入口)
</label>
</div>
</Modal>
);
}
function ModeratorsModal({
open,
board,
busy,
onClose,
onChanged,
}: {
open: boolean;
board: AdminBoard | null;
busy: boolean;
onClose: () => void;
onChanged: () => void;
}) {
const [mods, setMods] = useState<AdminBoardModerator[]>([]);
const [username, setUsername] = useState("");
const [loadErr, setLoadErr] = useState("");
const [actErr, setActErr] = useState("");
const reload = useCallback(async () => {
if (!board) return;
setLoadErr("");
try {
const r = await apiAdminListBoardModerators(board.id);
setMods(r.moderators ?? []);
} catch (e) {
setLoadErr(e instanceof Error ? e.message : "获取版主失败");
}
}, [board]);
useEffect(() => {
if (open && board) {
setUsername("");
setActErr("");
reload();
}
}, [open, board, reload]);
const add = async () => {
if (!board) return;
const u = username.trim();
if (!u) return;
setActErr("");
try {
await apiAdminAddBoardModerator(board.id, u);
setUsername("");
await reload();
onChanged();
} catch (e) {
setActErr(e instanceof Error ? e.message : "添加版主失败");
}
};
const remove = async (uid: number) => {
if (!board) return;
setActErr("");
try {
await apiAdminRemoveBoardModerator(board.id, uid);
await reload();
onChanged();
} catch (e) {
setActErr(e instanceof Error ? e.message : "移除版主失败");
}
};
return (
<Modal
open={open}
title={`版主管理 · ${board?.name ?? ""}`}
description="版主需为 board_admin 角色。新加入用户若已非该角色将被自动升级并强制下线;移除最后一块授权时自动降级为普通用户。"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-lg"
footer={
<button type="button" className="btn btn-ghost" onClick={onClose} disabled={busy}>
关闭
</button>
}
>
<div className="flex flex-col gap-3">
<div className="flex items-center gap-2">
<input
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
placeholder="按用户名添加版主"
className="field flex-1"
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
add();
}
}}
/>
<button
type="button"
className="btn btn-primary btn-sm shrink-0"
onClick={add}
disabled={busy || !username.trim()}
>
添加
</button>
</div>
{actErr && <p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{actErr}</p>}
{loadErr ? (
<p className="text-[12.5px]" style={{ color: "var(--danger)" }}>{loadErr}</p>
) : mods.length === 0 ? (
<p className="meta text-[12.5px] py-4 text-center">暂无版主</p>
) : (
<ul className="flex flex-col gap-1">
{mods.map((m) => (
<li
key={m.id}
className="flex items-center gap-3 px-2 py-2 rounded-[var(--r-sm)]"
style={{ background: "var(--panel-2)" }}
>
<Avatar name={m.nickname || m.username} src={m.avatar || undefined} size={30} />
<div className="min-w-0 flex-1">
<p className="text-[13px] truncate" style={{ color: "var(--ink)" }}>
{m.nickname || m.username}
</p>
<p className="meta text-[11.5px] truncate">@{m.username}</p>
</div>
{m.banned && (
<span className="admin-status is-muted" style={{ color: "var(--danger)" }}>
已封禁
</span>
)}
<button
type="button"
className="btn btn-line btn-sm shrink-0"
onClick={() => remove(m.id)}
disabled={busy}
style={{ color: "var(--danger)" }}
>
<Trash2 size={13} /> 移除
</button>
</li>
))}
</ul>
)}
</div>
</Modal>
);
}
export default function BoardsAdmin({
initial,
initError,
}: {
initial: AdminBoard[] | null;
initError: string;
}) {
const [boards, setBoards] = useState<AdminBoard[]>(initial ?? []);
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [editOpen, setEditOpen] = useState(false);
const [editTarget, setEditTarget] = useState<AdminBoard | null>(null);
const [editInitial, setEditInitial] = useState<AdminBoardInput>(emptyInput());
const [busy, setBusy] = useState(false);
const [modsOpen, setModsOpen] = useState(false);
const [modsTarget, setModsTarget] = useState<AdminBoard | null>(null);
const reload = useCallback(async () => {
setLoading(true);
setError("");
try {
const r = await apiAdminListBoards();
setBoards(r.boards ?? []);
} catch (e) {
setError(e instanceof Error ? e.message : "获取板块列表失败");
} finally {
setLoading(false);
}
}, []);
const openCreate = () => {
setEditTarget(null);
setEditInitial(emptyInput());
setEditOpen(true);
};
const openEdit = (b: AdminBoard) => {
setEditTarget(b);
setEditInitial(toInput(b));
setEditOpen(true);
};
const submitEdit = async (input: AdminBoardInput) => {
setBusy(true);
setError("");
try {
if (editTarget) {
const r = await apiAdminUpdateBoard(editTarget.id, input);
setBoards((prev) => prev.map((b) => (b.id === r.board.id ? { ...b, ...r.board } : b)));
} else {
const r = await apiAdminCreateBoard(input);
setBoards((prev) => [...prev, r.board]);
}
setEditOpen(false);
} catch (e) {
setError(e instanceof Error ? e.message : "保存失败");
} finally {
setBusy(false);
}
};
const remove = async (b: AdminBoard) => {
if (!window.confirm(`确认删除板块「${b.name}」?该操作不可恢复。`)) return;
setBusy(true);
setError("");
try {
await apiAdminDeleteBoard(b.id);
setBoards((prev) => prev.filter((x) => x.id !== b.id));
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败");
} finally {
setBusy(false);
}
};
const move = async (idx: number, dir: -1 | 1) => {
const target = idx + dir;
if (target < 0 || target >= boards.length) return;
const next = [...boards];
const tmp = next[idx];
next[idx] = next[target];
next[target] = tmp;
// 乐观更新:失败回滚
const prev = boards;
setBoards(next);
try {
await apiAdminReorderBoards(next.map((b) => b.id));
} catch (e) {
setBoards(prev);
setError(e instanceof Error ? e.message : "排序失败");
}
};
const openMods = (b: AdminBoard) => {
setModsTarget(b);
setModsOpen(true);
};
return (
<div>
<AdminPageHeader
icon={FolderOpen}
title="板块管理"
description="新建、编辑、删除论坛板块,配置发帖策略与可见性,并指派版主。仅站长可操作。"
actions={
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建板块
</button>
}
/>
{error && (
<div
className="mb-3 px-3 py-2 rounded-[var(--r-sm)] text-[12.5px]"
style={{ background: "var(--danger-soft)", color: "var(--danger)" }}
>
{error}
</div>
)}
{boards.length === 0 ? (
<AdminEmpty
icon={<FolderOpen size={22} />}
title="暂无板块"
description="点击右上角「新建板块」创建第一个板块。"
/>
) : (
<div className="panel overflow-hidden boards-panel">
<div className="boards-head">
<span>排序</span>
<span>板块</span>
<span className="boards-actions">操作</span>
</div>
{boards.map((b, i) => (
<div key={b.id} className="boards-row">
<div className="flex items-center gap-1">
<button
type="button"
className="admin-icon-btn"
onClick={() => move(i, -1)}
disabled={i === 0 || busy}
aria-label="上移"
>
<ChevronUp size={14} />
</button>
<button
type="button"
className="admin-icon-btn"
onClick={() => move(i, 1)}
disabled={i === boards.length - 1 || busy}
aria-label="下移"
>
<ChevronDown size={14} />
</button>
</div>
<div className="min-w-0 flex items-center gap-2.5">
<span
className={`av-${boardColor(b)} w-8 h-8 rounded-[10px] flex items-center justify-center shrink-0`}
aria-hidden
>
<BoardIcon icon={b.icon} name={b.name} size={15} />
</span>
<div className="min-w-0">
<p className="text-[13.5px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{b.name}
</p>
{b.description && (
<p className="meta text-[12px] truncate">{b.description}</p>
)}
{/* 低信息密度列表:策略/状态/帖数并入元信息行,替代三列 */}
<div className="boards-meta">
<AdminStatusChip tone="muted">
{POLICY_LABEL[b.post_policy] ?? b.post_policy}
</AdminStatusChip>
{b.visible ? (
<AdminStatusChip tone="ok">可见</AdminStatusChip>
) : (
<AdminStatusChip tone="muted">
<EyeOff size={11} /> 隐藏
</AdminStatusChip>
)}
<span className="boards-meta-posts">{b.post_count ?? 0} 帖</span>
</div>
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap boards-actions">
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openEdit(b)}
disabled={busy}
>
<Pencil size={13} /> 编辑
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openMods(b)}
disabled={busy}
>
<UsersIcon size={13} /> 版主
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => remove(b)}
disabled={busy}
style={{ color: "var(--danger)" }}
>
<Trash2 size={13} /> 删除
</button>
</div>
</div>
))}
</div>
)}
{loading && (
<p className="meta text-[12px] mt-3 text-center">加载中…</p>
)}
<BoardEditModal
open={editOpen}
initial={editInitial}
title={editTarget ? `编辑板块 · ${editTarget.name}` : "新建板块"}
busy={busy}
editTarget={editTarget}
onClose={() => setEditOpen(false)}
onSubmit={submitEdit}
/>
<ModeratorsModal
open={modsOpen}
board={modsTarget}
busy={busy}
onClose={() => setModsOpen(false)}
onChanged={reload}
/>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminBoards, type AdminBoard } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isOwner } from "@/lib/roles";
import Forbidden from "../Forbidden";
import BoardsAdmin from "./BoardsAdmin";
export const metadata: Metadata = {
title: "板块管理",
};
export default async function AdminBoardsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
// 仅站长可管理板块
if (!me.user || !isOwner(me.user.role)) {
return <Forbidden text="板块管理仅站长可操作。" />;
}
let initial: AdminBoard[] | null = null;
let initError = "";
try {
const r = await fetchAdminBoards(cookie || undefined);
initial = r.boards ?? [];
} catch (e) {
initError = e instanceof Error ? e.message : "获取板块列表失败";
}
return <BoardsAdmin initial={initial} initError={initError} />;
}

View File

@@ -3,7 +3,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import Link from "next/link";
import { useRouter, useSearchParams } from "next/navigation";
import { ArrowLeft, Copy, ExternalLink, Loader2 } from "lucide-react";
import { ArrowLeft, Copy, ExternalLink, FileText, Loader2 } from "lucide-react";
import {
apiAdminCreateSitePage,
apiAdminUpdateSitePage,
@@ -283,34 +283,68 @@ export default function PageComposeClient({
return (
<div className="admin-doc-page admin-doc-editor">
<header className="admin-doc-head">
<div className="min-w-0">
<Link href={backHref} className="admin-doc-back">
<ArrowLeft size={14} aria-hidden /> 返回列表
</Link>
<div className="admin-doc-head-title">
<header className="admin-doc-sticky">
<div className="admin-doc-sticky-inner">
<div className="admin-doc-sticky-title">
<Link href={backHref} className="admin-doc-back" aria-label="返回单页列表">
<ArrowLeft size={15} aria-hidden />
<span className="admin-doc-back-text">返回列表</span>
</Link>
<span className="admin-doc-sticky-icon" aria-hidden>
<FileText size={15} />
</span>
<h1>{isEdit ? "编辑单页" : "新建单页"}</h1>
<AdminStatusChip tone={savedPublished ? "ok" : savedPublished === false ? "warn" : "accent"}>
{savedPublished ? "已发布" : savedPublished === false ? "草稿" : "尚未保存"}
</AdminStatusChip>
</div>
<div className="admin-doc-sticky-actions">
{canViewOnline && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line btn-sm"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 线上页面
</a>
) : null}
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "发布" },
]}
/>
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={requestSave}
aria-busy={saving || undefined}
>
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
</div>
{canViewOnline && savedPage ? (
<a
href={sitePagePublicPath(savedPage.slug)}
target="_blank"
rel="noopener noreferrer"
className="btn btn-line"
title="打开已发布内容。未保存的修改不会出现在这里。"
>
<ExternalLink size={14} aria-hidden /> 查看线上页面
</a>
) : null}
</header>
<div className="admin-doc-split">
<div className="admin-doc-main">
<AdminField
className="admin-doc-title"
id="page-title"
label="标题"
maxLength={200}
@@ -437,50 +471,8 @@ export default function PageComposeClient({
/>
<p className="admin-doc-hint">数字越小越靠前,可以为负数。</p>
</div>
<div className="admin-doc-field">
<p className="admin-doc-label">保存后状态</p>
<AdminSegmented<PublishKey>
role="group"
ariaLabel="保存后状态"
size="sm"
value={form.published ? "published" : "draft"}
onChange={(key) => patchForm({ published: key === "published" })}
options={[
{ key: "draft", label: "草稿" },
{ key: "published", label: "已发布" },
]}
/>
<p className="admin-doc-hint">点选只改变这次要保存的状态,不会立刻发布或下线。</p>
</div>
</div>
</details>
<div className="admin-doc-actions" aria-busy={saving || undefined}>
<div className="admin-doc-actions-btns">
<button type="button" className="btn btn-line" disabled={!dirty || saving} onClick={discard}>
撤销修改
</button>
<button
type="button"
className="btn btn-primary"
disabled={!dirty || saving}
onClick={requestSave}
>
{saving ? <Loader2 size={15} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
<p className={`admin-doc-status${errors.form ? " is-error" : ""}`} aria-live="polite">
{statusText}
</p>
</div>
<p className="admin-doc-hint">一次保存会写入标题、正文和页面设置。Ctrl/⌘ + Enter 与这个按钮相同。</p>
{canViewOnline ? (
<p className="admin-doc-hint">查看线上页面打开的是已发布内容,未保存的修改不会出现在那里。</p>
) : (
<p className="admin-doc-hint">草稿没有公开地址。要看正文,用编辑器里的预览。</p>
)}
</div>
</div>
<ConfirmDialog

View File

@@ -1,6 +1,6 @@
"use client";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, Timer } from "lucide-react";
import { ExternalLink, LogIn, MessageSquare, MessagesSquare, ShieldCheck, ShieldOff, Timer } from "lucide-react";
import { useState } from "react";
import {
AdminSettingsGroup,
@@ -27,6 +27,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
const [allowMessages, setAllowMessages] = useState(initial.allow_messages !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [trustOn, setTrustOn] = useState(initial.trust_reviewed_publish !== false);
const [withoutReviewOn, setWithoutReviewOn] = useState(initial.publish_without_review === true);
const [cooldown, setCooldown] = useState(initial.post_cooldown_hours);
const [savedCooldown, setSavedCooldown] = useState(initial.post_cooldown_hours);
const [togglingComments, setTogglingComments] = useState(false);
@@ -34,6 +35,7 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
const [togglingMessages, setTogglingMessages] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
const [togglingTrust, setTogglingTrust] = useState(false);
const [togglingWithoutReview, setTogglingWithoutReview] = useState(false);
const [savingCooldown, setSavingCooldown] = useState(false);
const [moduleDirty, setModuleDirty] = useState(false);
const leave = useUnsavedGuard(moduleDirty);
@@ -103,6 +105,25 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
}
};
const toggleWithoutReview = async () => {
if (togglingWithoutReview) return;
setTogglingWithoutReview(true);
try {
const res = await apiUpdateSiteSettings({ publish_without_review: !withoutReviewOn });
setWithoutReviewOn(res.publish_without_review === true);
toast(
res.publish_without_review
? "已开启全站免审(发帖与评论直接公开,无需过审记录)"
: "已关闭全站免审(将按过审免审规则决定是否待审)",
"ok",
);
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingWithoutReview(false);
}
};
const toggleTrust = async () => {
if (togglingTrust) return;
setTogglingTrust(true);
@@ -218,18 +239,40 @@ export default function AccessSettings({ initial }: { initial: PublicSettings })
<InstantNote saving={togglingLink} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ShieldOff size={15} /> 全站免审
</span>
}
hintBelow="所有人发帖与评论直接公开,无需有过审记录;开启后优先生效。"
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={withoutReviewOn}
disabled={togglingWithoutReview}
onChange={() => void toggleWithoutReview()}
label="全站免审"
/>
<InstantNote saving={togglingWithoutReview} />
</div>
</AdminSettingsRow>
<AdminSettingsRow
label={
<span className="inline-flex items-center gap-1.5">
<ShieldCheck size={15} /> 过审免审
</span>
}
hintBelow="用户曾有过审内容后,后续发帖与评论直接公开。"
hintBelow={
withoutReviewOn
? "全站免审已开启,所有内容直接公开,本开关暂不参与判定。"
: "用户曾有过审内容后,后续发帖与评论直接公开。"
}
>
<div className="flex items-center gap-2">
<AdminSwitch
checked={trustOn}
disabled={togglingTrust}
disabled={withoutReviewOn || togglingTrust}
onChange={() => void toggleTrust()}
label="过审免审"
/>

View File

@@ -16,6 +16,7 @@ const NAV: { href: string; label: string }[] = [
{ href: "/admin/settings/mail", label: "邮件服务" },
{ href: "/admin/settings/storage", label: "文件与存储" },
{ href: "/admin/settings/filter", label: "内容过滤" },
{ href: "/admin/settings/import", label: "数据导入" },
{ href: "/admin/settings/maintenance", label: "维护与诊断" },
];

View File

@@ -0,0 +1,415 @@
"use client";
import { useRef, useState } from "react";
import {
AlertTriangle,
CheckCircle2,
ClipboardCheck,
Database,
FileUp,
ListChecks,
ListX,
Loader2,
MessageSquare,
ScanLine,
Upload,
Users,
XCircle,
} from "lucide-react";
import {
AdminSettingsGroup,
AdminSwitch,
} from "@/components/admin";
import {
apiAdminImportLegacy,
type LegacyCommentReport,
type LegacyImportReport,
type LegacyPostReport,
} from "@/lib/api";
/** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */
const SOURCES = [
{ key: "jiang13", label: "姜十三论坛(SQLite)", enabled: true },
{ key: "wordpress", label: "WordPress", enabled: false },
{ key: "typecho", label: "Typecho", enabled: false },
] as const;
/**
* 站点设置 → 数据导入:
* 上传旧站数据库(+可选头像包)→ 预检(dry-run,不写库)→ 确认正式导入。
* 全程幂等:已导入对象自动跳过,可安全重复执行。
*/
export default function ImportPanel() {
const [source, setSource] = useState<string>("jiang13");
const [dbFile, setDbFile] = useState<File | null>(null);
const [zipFile, setZipFile] = useState<File | null>(null);
const [withContent, setWithContent] = useState(true);
const [busy, setBusy] = useState<"check" | "import" | null>(null);
const [error, setError] = useState("");
const [report, setReport] = useState<LegacyImportReport | null>(null);
const dbInputRef = useRef<HTMLInputElement>(null);
const zipInputRef = useRef<HTMLInputElement>(null);
// 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置
const [preflighted, setPreflighted] = useState(false);
const resetResult = () => {
setPreflighted(false);
setReport(null);
setError("");
};
const pickDb = (f: File | null) => {
setDbFile(f);
resetResult();
};
const pickZip = (f: File | null) => {
setZipFile(f);
resetResult();
};
const toggleContent = () => {
setWithContent((v) => !v);
resetResult();
};
const run = async (dryRun: boolean) => {
if (!dbFile || busy) return;
setBusy(dryRun ? "check" : "import");
setError("");
try {
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, {
withContent,
dryRun,
});
setReport(rep);
if (dryRun) setPreflighted(true);
} catch (e) {
setError(e instanceof Error ? e.message : "导入失败");
} finally {
setBusy(null);
}
};
return (
<div className="flex flex-col gap-4">
<AdminSettingsGroup
title="数据源"
description="选择要导入的站点程序类型;其他平台适配将陆续提供"
>
<div className="flex flex-wrap gap-2">
{SOURCES.map((s) => (
<button
key={s.key}
type="button"
disabled={!s.enabled}
onClick={() => {
setSource(s.key);
resetResult();
}}
className={`btn btn-sm ${source === s.key && s.enabled ? "btn-primary" : "btn-line"} ${!s.enabled ? "opacity-50 cursor-not-allowed" : ""}`}
aria-pressed={source === s.key}
title={s.enabled ? undefined : "计划中,敬请期待"}
>
<Database size={14} aria-hidden />
{s.label}
{!s.enabled && (
<span className="text-[11px] font-semibold" style={{ color: "var(--ink-3)" }}>
计划中
</span>
)}
</button>
))}
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="导入文件"
description="第一步:从旧站服务器或本地备份中取得以下文件"
>
<div className="flex flex-col gap-3">
<FilePickRow
label="站点数据库(必填)"
hint="旧站 data 目录下的 jiang13.db,最大 64MB"
fileName={dbFile?.name}
inputRef={dbInputRef}
accept=".db,.sqlite,.sqlite3,application/octet-stream"
onPick={pickDb}
busy={busy !== null}
/>
<FilePickRow
label="头像压缩包(可选)"
hint="把旧站 data/uploads/avatars 目录压缩为 zip(直接右键压缩该文件夹即可),最大 128MB"
fileName={zipFile?.name}
inputRef={zipInputRef}
accept=".zip,application/zip"
onPick={pickZip}
busy={busy !== null}
/>
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="导入范围"
description="第二步:确认导入范围。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数;作者账号缺失时归到站长。投票帖暂不迁移。"
>
<div className="flex items-center gap-3">
<AdminSwitch checked={withContent} onChange={toggleContent} label="同时导入帖子与评论" />
<span className="text-[13px]" style={{ color: "var(--ink)" }}>
同时导入帖子与评论
</span>
</div>
</AdminSettingsGroup>
<AdminSettingsGroup
title="预检与导入"
description="第三步:先预检确认将导入的内容,再正式写入。可重复执行,已导入的内容自动跳过。"
>
<div className="flex flex-wrap items-center gap-2">
<button
type="button"
className="btn btn-line btn-sm"
disabled={!dbFile || busy !== null}
onClick={() => void run(true)}
>
{busy === "check" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ScanLine size={14} aria-hidden />}
{busy === "check" ? "预检中…" : "预检(不写入)"}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dbFile || busy !== null || !preflighted}
onClick={() => void run(false)}
>
{busy === "import" ? <Loader2 size={14} className="animate-spin" aria-hidden /> : <ClipboardCheck size={14} aria-hidden />}
{busy === "import" ? "导入中…" : "确认正式导入"}
</button>
<span className="meta text-[12px]" aria-live="polite">
{busy
? null
: !dbFile
? "请先选择站点数据库"
: !preflighted
? "请先运行预检"
: "预检通过,可以正式导入"}
</span>
</div>
{error && (
<p className="mt-3 flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--danger)" }} role="alert">
<AlertTriangle size={14} aria-hidden />
{error}
</p>
)}
{report && <ReportView report={report} />}
</AdminSettingsGroup>
</div>
);
}
function FilePickRow({
label,
hint,
fileName,
inputRef,
accept,
onPick,
busy,
}: {
label: string;
hint: string;
fileName?: string;
inputRef: React.RefObject<HTMLInputElement | null>;
accept: string;
onPick: (f: File | null) => void;
busy: boolean;
}) {
return (
<div
className="rounded-xl px-3.5 py-3 flex items-center justify-between gap-3 flex-wrap"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
{label}
</p>
<p className="meta text-[12px] mt-0.5 truncate">{fileName || hint}</p>
</div>
<button
type="button"
className="btn btn-line btn-sm shrink-0"
onClick={() => inputRef.current?.click()}
disabled={busy}
>
<Upload size={14} aria-hidden />
选择文件
</button>
<input
ref={inputRef}
type="file"
accept={accept}
className="hidden"
onChange={(e) => {
onPick(e.target.files?.[0] ?? null);
e.target.value = "";
}}
/>
</div>
);
}
function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) {
return (
<div
className="rounded-lg px-2.5 py-1.5 inline-flex items-center gap-1.5 text-[12.5px]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<span style={{ color: ok === false ? "var(--ink-3)" : "var(--accent)" }}>{icon}</span>
<span style={{ color: "var(--ink-3)" }}>{label}</span>
<strong style={{ color: "var(--ink)" }}>{value}</strong>
</div>
);
}
function ReportView({ report }: { report: LegacyImportReport }) {
const isDry = report.dry_run;
return (
<div className="mt-4 flex flex-col gap-3">
<p
className="flex items-center gap-1.5 text-[13px] font-semibold"
style={{ color: isDry ? "var(--ink-3)" : "var(--ok)" }}
>
{isDry ? (
<>
<ScanLine size={15} aria-hidden />
预检结果(未写入任何数据)——确认无误后点击「确认正式导入」
</>
) : (
<>
<CheckCircle2 size={15} aria-hidden />
导入完成。重复执行时已导入的内容会自动跳过
</>
)}
</p>
<ReportCard icon={<Users size={14} aria-hidden />} title="用户">
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧库用户" value={report.users.total} />
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.users.imported} />
<StatChip icon={<ListX size={13} aria-hidden />} label="已存在跳过" value={report.users.skipped} ok={false} />
{report.users.avatar_written > 0 && (
<StatChip icon={<FileUp size={13} aria-hidden />} label="头像写入" value={report.users.avatar_written} />
)}
<DetailList title="已存在(跳过)" items={report.users.conflicts} />
<DetailList title="头像源缺失(字段置空)" items={report.users.avatar_missing} color="var(--danger)" />
<DetailList title="失败" items={report.users.failed} color="var(--danger)" />
</ReportCard>
{report.boards && (
<ReportCard icon={<Database size={14} aria-hidden />} title="板块">
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将新建" : "已新建"} value={report.boards.created} />
<StatChip icon={<ListX size={13} aria-hidden />} label="复用同名" value={report.boards.reused} ok={false} />
<DetailList title={isDry ? "将新建板块" : "新建板块"} items={report.boards.names} />
</ReportCard>
)}
{report.posts && <PostsView report={report.posts} isDry={isDry} />}
{report.comments && <CommentsView report={report.comments} isDry={isDry} />}
{report.notes && report.notes.length > 0 && (
<div
className="rounded-xl px-3.5 py-3 flex flex-col gap-1 text-[12.5px]"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
{report.notes.map((n, i) => (
<p key={i} className="flex items-start gap-1.5" style={{ color: "var(--ink-3)" }}>
<AlertTriangle size={13} className="mt-0.5 shrink-0" aria-hidden />
{n}
</p>
))}
</div>
)}
</div>
);
}
function PostsView({ report, isDry }: { report: LegacyPostReport; isDry: boolean }) {
return (
<ReportCard icon={<ListChecks size={14} aria-hidden />} title="帖子">
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧帖" value={report.total} />
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
{report.polls_skipped > 0 && (
<StatChip icon={<XCircle size={13} aria-hidden />} label="投票帖未迁移" value={report.polls_skipped} ok={false} />
)}
<DetailList title="投票帖(未迁移)" items={report.poll_titles} color="var(--danger)" />
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
<DetailList title="失败 / 非公开" items={report.failed} color="var(--danger)" />
</ReportCard>
);
}
function CommentsView({ report, isDry }: { report: LegacyCommentReport; isDry: boolean }) {
return (
<ReportCard icon={<MessageSquare size={14} aria-hidden />} title="评论">
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧评论" value={report.total} />
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
<DetailList title="失败" items={report.failed} color="var(--danger)" />
</ReportCard>
);
}
function ReportCard({
icon,
title,
children,
}: {
icon: React.ReactNode;
title: string;
children: React.ReactNode;
}) {
return (
<div
className="rounded-xl px-3.5 py-3 flex flex-col gap-2"
style={{ background: "var(--panel-2)", border: "1px solid var(--line)" }}
>
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
{icon}
{title}
</p>
<div className="flex flex-wrap gap-1.5">{children}</div>
</div>
);
}
function DetailList({
title,
items,
color,
}: {
title: string;
items?: string[];
color?: string;
}) {
if (!items || items.length === 0) return null;
return (
<details className="w-full">
<summary
className="flex items-center gap-1.5 cursor-pointer select-none text-[12.5px]"
style={{ color: color || "var(--ink-3)" }}
>
<ListX size={13} aria-hidden />
{title}:{items.length}
</summary>
<ul className="mt-1 ml-5 list-disc max-h-40 overflow-y-auto text-[12.5px]" style={{ color: "var(--ink-3)" }}>
{items.map((it, i) => (
<li key={i} className="break-all">
{it}
</li>
))}
</ul>
</details>
);
}

View File

@@ -0,0 +1,10 @@
import type { Metadata } from "next";
import ImportPanel from "./ImportPanel";
export const metadata: Metadata = {
title: "数据导入",
};
export default function ImportSettingsPage() {
return <ImportPanel />;
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect, useRef, useCallback } from "react";
import { useState, useEffect, useRef, useCallback, useMemo } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import {
@@ -16,7 +16,6 @@ import {
Undo2,
Reply,
Copy,
AtSign,
} from "lucide-react";
import {
apiListChatMessages,
@@ -53,6 +52,7 @@ import Avatar from "@/components/Avatar";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
/** 同一房间已读:进行中去重 + 2s 内合并(滚动/新消息/Strict Mode 连打) */
const MARK_READ_GAP_MS = 2_000;
@@ -137,7 +137,6 @@ function RecallNotice({
href={`/u/${uid}`}
className="transition-opacity hover:opacity-80"
style={nameStyle}
data-tip={`查看 ${name} 的主页`}
>
{name}
</Link>
@@ -206,10 +205,11 @@ function formatTime(dateStr: string) {
return `${md} ${hm}`;
}
function highlightMentions(content: string, members: ChatRoomMember[]) {
const byUsername = new Map(
members.map((m) => [m.user.username.toLowerCase(), m.user])
);
function highlightMentions(
content: string,
users: Map<string, User>,
onOpenCard: (u: User) => void
) {
const parts: React.ReactNode[] = [];
const re = /@([A-Za-z0-9_-]{3,32})/g;
let last = 0;
@@ -217,19 +217,21 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
let i = 0;
while ((m = re.exec(content)) !== null) {
if (m.index > last) parts.push(content.slice(last, m.index));
const user = byUsername.get(m[1].toLowerCase());
const user = users.get(m[1].toLowerCase());
parts.push(
user ? (
<Link
key={`m-${i}`}
href={`/u/${user.id}`}
className="j13-mention-token"
data-tip={`查看 ${user.nickname || user.username} 的主页`}
onClick={(e) => e.stopPropagation()}
onContextMenu={(e) => e.stopPropagation()}
>
@{m[1]}
</Link>
<button
key={`m-${i}`}
type="button"
className="j13-mention-token"
onClick={(e) => {
e.stopPropagation();
onOpenCard(user);
}}
onContextMenu={(e) => e.stopPropagation()}
>
@{user.nickname || user.username}
</button>
) : (
<span key={`m-${i}`}>@{m[1]}</span>
)
@@ -242,67 +244,154 @@ function highlightMentions(content: string, members: ChatRoomMember[]) {
}
/** 消息正文:URL 自动识别 + @提及;外链新开跟随站点策略 */
function renderChatContent(content: string, members: ChatRoomMember[]) {
function renderChatContent(
content: string,
users: Map<string, User>,
onOpenCard: (u: User) => void
) {
return (
<AutolinkText
text={content}
forceNewTab
renderText={(seg) => <>{highlightMentions(seg, members)}</>}
renderText={(seg) => <>{highlightMentions(seg, users, onOpenCard)}</>}
/>
);
}
/** 输入区镜像层:已确认的 @ 显示为飞书风 token */
function renderComposerMirror(content: string, tokens: Set<string>) {
if (!content) return null;
const parts: React.ReactNode[] = [];
const re = /@([A-Za-z0-9_-]{3,32})/g;
let last = 0;
let m: RegExpExecArray | null;
let i = 0;
while ((m = re.exec(content)) !== null) {
if (m.index > last) parts.push(content.slice(last, m.index));
const key = m[1].toLowerCase();
parts.push(
tokens.has(key) ? (
<span key={`t-${i}`} className="j13-mention-token">
@{m[1]}
</span>
) : (
<span key={`t-${i}`}>@{m[1]}</span>
)
);
last = m.index + m[0].length;
i++;
/**
* 编辑框提及方案:内容直接存「@昵称」(宽度即昵称宽度,光标/选区天然对齐),
* mentionTokens 记录 昵称→用户名 映射,发送时再翻译回 @用户名(后端按用户名解析提醒)。
* 昵称可含空格/@ 等任意字符,因此按字面匹配扫描(最长优先 + 边界校验 + 占位去重)。
*/
function collectMentionSpans(
text: string,
tokens: Map<string, string>
): { start: number; end: number; key: string }[] {
const spans: { start: number; end: number; key: string }[] = [];
if (!text || tokens.size === 0) return spans;
const keys = [...tokens.keys()].sort((a, b) => b.length - a.length);
const occupied = new Array<boolean>(text.length).fill(false);
for (const key of keys) {
if (!key) continue;
const needle = `@${key}`;
let from = 0;
for (;;) {
const idx = text.indexOf(needle, from);
if (idx < 0) break;
from = idx + 1;
const end = idx + needle.length;
// @ 前需为行首/空白,昵称后需为空白/行尾(与插入时的「@昵称 」格式一致)
const prevCh = idx > 0 ? text[idx - 1] : "";
if (prevCh && !/\s/.test(prevCh)) continue;
const nextCh = end < text.length ? text[end] : "";
if (nextCh && !/\s/.test(nextCh)) continue;
let clash = false;
for (let i = idx; i < end; i++) {
if (occupied[i]) {
clash = true;
break;
}
}
if (clash) continue;
for (let i = idx; i < end; i++) occupied[i] = true;
spans.push({ start: idx, end, key });
}
}
return spans.sort((a, b) => a.start - b.start);
}
/** 输入区镜像层:已确认的 @ 以蓝色昵称呈现(胶囊样式在发出后的气泡里) */
function renderComposerMirror(content: string, tokens: Map<string, string>) {
if (!content) return null;
const spans = collectMentionSpans(content, tokens);
const parts: React.ReactNode[] = [];
let last = 0;
spans.forEach((s, i) => {
if (s.start > last) parts.push(content.slice(last, s.start));
parts.push(
<span key={`t-${i}`} className="j13-mention-token">
@{s.key}
</span>
);
last = s.end;
});
if (last < content.length) parts.push(content.slice(last));
// 末尾换行在 pre-wrap 镜像里需要占位,否则高度会少一行
if (content.endsWith("\n")) parts.push("\n");
return parts;
}
/** 发送前翻译:@昵称 → @用户名(后端按用户名解析提及) */
function translateMentionsOut(text: string, tokens: Map<string, string>): string {
if (tokens.size === 0) return text;
const spans = collectMentionSpans(text, tokens);
if (spans.length === 0) return text;
let out = "";
let last = 0;
for (const s of spans) {
out += text.slice(last, s.start) + `@${tokens.get(s.key) || s.key}`;
last = s.end;
}
return out + text.slice(last);
}
/** 服务端文本(@用户名)→ 编辑框文本(@昵称),并为可识别的提及建立 token(重新编辑草稿用) */
function hydrateMentionsIn(
text: string,
userIndex: Map<string, User>
): { text: string; tokens: Map<string, string> } {
const re = /@([A-Za-z0-9_-]{3,32})/g;
const replaces: { start: number; end: number; display: string; username: string }[] = [];
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const u = userIndex.get(m[1].toLowerCase());
if (!u) continue;
const display = u.nickname || u.username;
if (!display || display === m[1]) continue;
replaces.push({
start: m.index,
end: m.index + m[0].length,
display,
username: m[1],
});
}
const tokens = new Map<string, string>();
let out = text;
for (let i = replaces.length - 1; i >= 0; i--) {
const r = replaces[i];
out = out.slice(0, r.start) + `@${r.display}` + out.slice(r.end);
tokens.set(r.display, r.username);
}
return { text: out, tokens };
}
/** 光标前是否紧贴一个已确认的 @token(含可选尾随空格) */
function mentionTokenBefore(
text: string,
cursor: number,
tokens: Set<string>
tokens: Map<string, string>
): { start: number; end: number } | null {
const before = text.slice(0, cursor);
const m = before.match(/@([A-Za-z0-9_-]{3,32})( ?)$/);
if (!m || !tokens.has(m[1].toLowerCase())) return null;
return { start: cursor - m[0].length, end: cursor };
for (const s of collectMentionSpans(text, tokens)) {
if (s.end === cursor) return { start: s.start, end: cursor };
if (s.end + 1 === cursor && text[s.end] === " ") {
return { start: s.start, end: cursor };
}
}
return null;
}
/** 光标后是否紧跟一个已确认的 @token(含可选尾随空格) */
function mentionTokenAfter(
text: string,
cursor: number,
tokens: Set<string>
tokens: Map<string, string>
): { start: number; end: number } | null {
const after = text.slice(cursor);
const m = after.match(/^@([A-Za-z0-9_-]{3,32})( ?)/);
if (!m || !tokens.has(m[1].toLowerCase())) return null;
return { start: cursor, end: cursor + m[0].length };
for (const s of collectMentionSpans(text, tokens)) {
if (s.start !== cursor) continue;
const end = s.end < text.length && text[s.end] === " " ? s.end + 1 : s.end;
return { start: cursor, end };
}
return null;
}
export default function ChatRoomClient({
@@ -344,8 +433,12 @@ export default function ChatRoomClient({
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
const [mentionIndex, setMentionIndex] = useState(0);
/** 从选择器确认插入的 @,在输入区以 token 整体展示/删除 */
const [mentionTokens, setMentionTokens] = useState<Set<string>>(() => new Set());
/** 从选择器/悬浮确认插入的 @:key=展示昵称,value=用户名(发送时翻译回) */
const [mentionTokens, setMentionTokens] = useState<Map<string, string>>(
() => new Map()
);
/** 用户资料卡(头像/@昵称点击弹出) */
const [userCard, setUserCard] = useState<ChatUserCardTarget | null>(null);
/** 本人撤回后的可重新编辑草稿(仅本端内存,不展示正文) */
const [reEditDrafts, setReEditDrafts] = useState<
Record<number, { text: string; until: number }>
@@ -369,6 +462,25 @@ export default function ChatRoomClient({
const canManageInvite = !isDirect && (isOwner || isRoomAdmin || oversee);
const canKickMembers = isOwner || isRoomAdmin || oversee;
/** @解析索引:成员表 + 消息发送者(成员未加载/已退群仍能显示昵称) */
const userIndex = useMemo(() => {
const idx = new Map<string, User>();
const add = (u?: User | null) => {
if (u?.username) idx.set(u.username.toLowerCase(), u);
};
members.forEach((m) => add(m.user));
messages.forEach((m) => add(m.sender));
return idx;
}, [members, messages]);
const openUserCard = useCallback(
(u: User) => {
const mem = members.find((m) => m.user_id === u.id);
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
},
[members]
);
const rememberReEdit = useCallback((id: number, text: string) => {
const t = text.trim();
if (!t) return;
@@ -578,12 +690,17 @@ export default function ChatRoomClient({
const prevTokens = mentionTokens;
const replyId = replyTo?.id || 0;
setContent("");
setMentionTokens(new Set());
setMentionTokens(new Map());
setReplyTo(null);
setMentionOpen(false);
requestAnimationFrame(() => syncComposerHeight());
try {
const { message } = await apiSendChatMessage(roomId, prevContent, replyId);
// 编辑框内存「@昵称」,发出前翻译回 @用户名(后端按用户名解析提及)
const { message } = await apiSendChatMessage(
roomId,
translateMentionsOut(prevContent, prevTokens),
replyId
);
setMessages((prev) => {
if (prev.some((m) => m.id === message.id)) return prev;
return [...prev, message];
@@ -624,42 +741,53 @@ export default function ChatRoomClient({
.slice(0, 8);
})();
const insertMention = (username: string) => {
/**
* 插入 @提及:
* - replaceTrigger:替换光标前未完成的 "@xx" 触发串(选择器路径)
* - 否则在光标处追加(昵称悬浮 @ 路径),必要时补前导空格
*/
const insertMention = (
username: string,
nickname: string,
opts?: { replaceTrigger?: boolean }
) => {
const el = inputRef.current;
const val = content;
const cursor = el?.selectionStart ?? val.length;
const before = val.slice(0, cursor);
let before = val.slice(0, cursor);
const after = val.slice(cursor);
const at = before.lastIndexOf("@");
const head = at >= 0 ? before.slice(0, at) : before;
const next = `${head}@${username} ${after}`;
if (opts?.replaceTrigger) {
const at = before.lastIndexOf("@");
if (at >= 0) before = before.slice(0, at);
} else if (before && !/\s$/.test(before)) {
before = `${before} `;
}
// 编辑框直接写入昵称(宽度即昵称宽度),映射表记录昵称→用户名
const display = nickname || username;
const next = `${before}@${display} ${after}`;
setContent(next);
setMentionTokens((prev) => {
const n = new Set(prev);
n.add(username.toLowerCase());
return n;
});
setMentionTokens((prev) => new Map(prev).set(display, username));
setMentionOpen(false);
setMentionFilter("");
requestAnimationFrame(() => {
const pos = head.length + username.length + 2;
const pos = before.length + display.length + 2;
el?.focus();
el?.setSelectionRange(pos, pos);
syncComposerHeight();
});
};
const pruneMentionTokens = (text: string, tokens: Set<string>) => {
const pruneMentionTokens = (text: string, tokens: Map<string, string>) => {
if (tokens.size === 0) return tokens;
const present = new Set<string>();
const re = /@([A-Za-z0-9_-]{3,32})/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text)) !== null) {
const key = m[1].toLowerCase();
if (tokens.has(key)) present.add(key);
}
const present = new Set(
collectMentionSpans(text, tokens).map((s) => s.key)
);
if (present.size === tokens.size) return tokens;
return present;
const next: Map<string, string> = new Map();
tokens.forEach((username, key) => {
if (present.has(key)) next.set(key, username);
});
return next;
};
const onComposerChange = (raw: string) => {
@@ -668,7 +796,7 @@ export default function ChatRoomClient({
const el = inputRef.current;
const cursor = el?.selectionStart ?? raw.length;
const before = raw.slice(0, cursor);
const m = before.match(/@([A-Za-z0-9_-]{0,32})$/);
const m = before.match(/@([^\s@]{0,32})$/);
if (m && isRealMember && !isDirect) {
setMentionOpen(true);
setMentionFilter(m[1] || "");
@@ -696,7 +824,13 @@ export default function ChatRoomClient({
if (e.key === "Enter" || e.key === "Tab") {
e.preventDefault();
const pick = mentionCandidates[mentionIndex];
if (pick) insertMention(pick.user.username);
if (pick) {
insertMention(
pick.user.username,
pick.user.nickname || pick.user.username,
{ replaceTrigger: true }
);
}
return;
}
if (e.key === "Escape") {
@@ -762,6 +896,21 @@ export default function ChatRoomClient({
}
};
/** 光标落点在已确认 @token 内部时吸附到 token 末尾(token 原子化,飞书体验) */
const onComposerSelect = () => {
const el = inputRef.current;
if (!el || mentionTokens.size === 0) return;
const start = el.selectionStart;
const end = el.selectionEnd;
if (start !== end) return;
for (const s of collectMentionSpans(content, mentionTokens)) {
if (start > s.start && start < s.end) {
el.setSelectionRange(s.end, s.end);
return;
}
}
};
const openMsgMenu = (m: ChatMessage, x: number, y: number) => {
if (m.recalled_at) return;
setCtxMenu({ x, y, message: m });
@@ -928,13 +1077,16 @@ export default function ChatRoomClient({
toast("重新编辑已过期");
return;
}
// 草稿存的是服务端文本(@用户名),回填时翻译为 @昵称 并重建 token
const { text: display, tokens } = hydrateMentionsIn(text, userIndex);
setReplyTo(null);
setContent(text);
setContent(display);
setMentionTokens(tokens);
requestAnimationFrame(() => {
const el = inputRef.current;
if (!el) return;
el.focus();
el.setSelectionRange(text.length, text.length);
el.setSelectionRange(display.length, display.length);
el.style.height = "auto";
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
});
@@ -972,13 +1124,10 @@ export default function ChatRoomClient({
];
if (!isDirect && isRealMember && m.sender_id !== meId) {
items.push({
key: "at",
label: `提及 @${m.sender.username}`,
icon: <AtSign size={14} />,
onClick: () => {
setContent((c) => `${c}${c && !c.endsWith(" ") ? " " : ""}@${m.sender.username} `);
inputRef.current?.focus();
},
key: "profile",
label: "查看资料卡",
icon: <Users size={14} />,
onClick: () => openUserCard(m.sender),
});
}
if (canRecallMsg(m)) {
@@ -1030,6 +1179,25 @@ export default function ChatRoomClient({
)}
<div className="min-w-0">
<div className="flex items-center gap-2 flex-wrap">
{isDirect && peer && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() =>
openUserCard({
...peer,
role: "",
})
}
aria-label={`查看 ${peer.nickname || peer.username} 的资料卡`}
>
<Avatar
name={peer.nickname || peer.username}
src={peer.avatar || undefined}
size={24}
/>
</button>
)}
<h1
className="font-extrabold text-[16px] truncate"
style={{ color: "var(--ink)" }}
@@ -1222,11 +1390,20 @@ export default function ChatRoomClient({
className={`flex gap-2.5 ${mine ? "flex-row-reverse" : ""}`}
>
<div className="w-8 shrink-0">
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
{!grouped && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() => openUserCard(m.sender)}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
</button>
)}
</div>
<div
className={`flex flex-col min-w-0 max-w-[78%] ${
@@ -1267,7 +1444,7 @@ export default function ChatRoomClient({
已撤回
</div>
<div style={{ color: "var(--ink-2)" }}>
{renderChatContent(m.content, members)}
{renderChatContent(m.content, userIndex, openUserCard)}
</div>
</div>
</div>
@@ -1286,11 +1463,18 @@ export default function ChatRoomClient({
>
<div className="w-8 shrink-0">
{!grouped && (
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() => openUserCard(m.sender)}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
name={m.sender.nickname || m.sender.username}
src={m.sender.avatar || undefined}
size={32}
/>
</button>
)}
</div>
<div
@@ -1303,12 +1487,34 @@ export default function ChatRoomClient({
{!isDirect && m.sender_id === ownerId && (
<Crown size={11} style={{ color: "var(--gold)" }} />
)}
<span
className="text-[12px] truncate"
style={{ color: "var(--ink-2)" }}
<button
type="button"
className={`text-[12px] truncate transition-colors ${
!isDirect && isRealMember
? "group/nm cursor-pointer text-[color:var(--ink-2)] hover:text-[color:var(--accent)]"
: "cursor-default text-[color:var(--ink-2)]"
}`}
onClick={() => {
if (!isDirect && isRealMember) {
insertMention(
m.sender.username,
m.sender.nickname || m.sender.username
);
}
}}
disabled={isDirect || !isRealMember}
>
{/* 悬浮时名字前出现 @,整个名字成为可 @ 点击对象 */}
{!isDirect && isRealMember && (
<span
aria-hidden
className="hidden group-hover/nm:inline font-semibold text-[color:var(--accent)]"
>
@
</span>
)}
{m.sender.nickname}
</span>
</button>
<span className="meta text-[10.5px]">
{formatTime(m.created_at)}
</span>
@@ -1362,7 +1568,7 @@ export default function ChatRoomClient({
{m.reply_snap}
</button>
)}
{renderChatContent(m.content, members)}
{renderChatContent(m.content, userIndex, openUserCard)}
</div>
</div>
</div>
@@ -1428,7 +1634,11 @@ export default function ChatRoomClient({
}}
onMouseDown={(e) => {
e.preventDefault();
insertMention(m.user.username);
insertMention(
m.user.username,
m.user.nickname || m.user.username,
{ replaceTrigger: true }
);
}}
>
<Avatar
@@ -1476,6 +1686,7 @@ export default function ChatRoomClient({
value={content}
onChange={(e) => onComposerChange(e.target.value)}
onKeyDown={onKeyDown}
onSelect={onComposerSelect}
onScroll={(e) => {
if (mirrorRef.current) {
mirrorRef.current.scrollTop = e.currentTarget.scrollTop;
@@ -1587,6 +1798,13 @@ export default function ChatRoomClient({
onKick={handleKick}
onMute={handleMute}
onSetAdmin={handleSetAdmin}
onOpenCard={(mem) =>
setUserCard({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
})
}
/>
)}
{rightPanel === "invite" && (
@@ -1628,6 +1846,13 @@ export default function ChatRoomClient({
onKick={handleKick}
onMute={handleMute}
onSetAdmin={handleSetAdmin}
onOpenCard={(mem) =>
setUserCard({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
})
}
showTitle
/>
</aside>
@@ -1642,6 +1867,14 @@ export default function ChatRoomClient({
/>
)}
{userCard && (
<ChatUserCard
target={userCard}
meId={meId}
onClose={() => setUserCard(null)}
/>
)}
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
@@ -1700,6 +1933,7 @@ function MembersPanel({
onKick,
onMute,
onSetAdmin,
onOpenCard,
showTitle = false,
}: {
members: ChatRoomMember[];
@@ -1711,6 +1945,7 @@ function MembersPanel({
onKick: (uid: number, nickname: string) => void;
onMute: (uid: number, nickname: string, muted: boolean) => void;
onSetAdmin: (uid: number, nickname: string, makeAdmin: boolean) => void;
onOpenCard: (m: ChatRoomMember) => void;
showTitle?: boolean;
}) {
return (
@@ -1756,7 +1991,14 @@ function MembersPanel({
key={m.id}
className="flex items-center gap-2.5 px-2 py-2 rounded-lg group"
>
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
onClick={() => onOpenCard(m)}
aria-label={`查看 ${name} 的资料卡`}
>
<Avatar name={name} src={m.user.avatar || undefined} size={28} />
</button>
<Link
href={`/u/${m.user.id}`}
className="flex-1 min-w-0 truncate text-[13px] hover:text-[var(--accent)] transition-colors"

View File

@@ -3167,6 +3167,7 @@ body:has([data-admin-viewport]) {
justify-content: space-between;
gap: 0.75rem;
padding: 0 1rem;
/* 半透明 + 毛玻璃:后台画布镂空透出全站画布层,顶栏与画布融为一体 */
background: color-mix(in srgb, var(--bg) 88%, transparent);
backdrop-filter: blur(14px);
-webkit-backdrop-filter: blur(14px);
@@ -3253,6 +3254,11 @@ body:has([data-admin-viewport]) {
padding-top: 0;
}
/* 公告/单页编辑器吸顶操作栏:同上,必须留在未分层区域才能压过 .admin-canvas 的媒体查询内边距 */
.admin-canvas:has(.admin-doc-editor) {
padding-top: 0;
}
/* 移动端导航抽屉 */
.admin-drawer {
position: fixed;
@@ -3441,6 +3447,12 @@ select:focus-visible {
color: transparent;
caret-color: var(--ink);
}
/* 框选时 ::selection 前景色会盖过透明字色,露出底层真实文本(@用户名);
这里保持字形透明 + 半透明高亮,让镜像层的 @昵称 透出(宽度已补偿对齐) */
.j13-chat-composer-input.has-value::selection {
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: transparent;
}
.j13-chat-composer-input.has-value::placeholder {
color: transparent;
}
@@ -3525,20 +3537,18 @@ a.j13-mention-token:hover {
background: var(--accent);
color: var(--accent-on) !important;
}
/* 输入镜像层:不加粗/不加 padding,避免与透明 textarea 字宽错位 */
/* 输入镜像层:token 仅蓝色文字(胶囊样式只在发出后的气泡里显示);
基础胶囊色带 !important,此处需同权重覆盖 */
.j13-chat-composer-mirror .j13-mention-token {
display: inline;
padding: 0;
margin: 0;
line-height: inherit;
font-weight: inherit;
border-radius: 999px;
font-weight: 500;
border-radius: 0;
box-shadow: none;
background: color-mix(in srgb, var(--accent) 82%, #000);
}
.dark .j13-chat-composer-mirror .j13-mention-token {
background: var(--accent);
color: var(--accent-on) !important;
background: transparent !important;
color: var(--accent) !important;
}
@layer components {
@@ -3772,10 +3782,7 @@ a.j13-mention-token:hover {
font-size: 12.5px;
}
/* 站点设置:限宽左对齐,不写入后台公共主容器 */
.admin-canvas:has(.admin-settings-page) {
background: var(--bg);
}
/* 站点设置:限宽左对齐,不写入后台公共主容器(画布保持镂空,与其他管理页一致) */
@media (min-width: 1920px) {
.admin-canvas:has(.admin-settings-page) {
padding-left: 3rem; /* 宽屏距主导航最多约 48px;1024+ 默认 2rem */
@@ -3803,9 +3810,11 @@ a.j13-mention-token:hover {
top: 0;
align-self: start;
z-index: 5;
/* 滚动时盖住右侧内容,避免透出 */
background: var(--bg);
padding-block: 0.15rem 0.5rem;
/* 画布镂空后导航改白色卡片:盖住滚动内容,并与右侧设置组卡片同语言 */
background: var(--panel);
border: 1px solid var(--line);
border-radius: var(--r);
padding: 0.5rem 0.4rem;
margin-block: -0.15rem;
}
.admin-settings-nav {
@@ -4773,10 +4782,7 @@ a.j13-mention-token:hover {
outline-offset: 2px;
}
/* 单页管理:列表与编辑各自限宽,不套用设置页 1120px */
.admin-canvas:has(.admin-doc-page) {
background: var(--bg);
}
/* 单页/公告管理:限宽左对齐,不套用设置页 1120px;画布保持镂空,与其他管理页一致 */
.admin-doc-page {
width: 100%;
margin-inline: 0;
@@ -4785,56 +4791,126 @@ a.j13-mention-token:hover {
.admin-doc-list {
max-width: 1240px;
}
/* 编辑器本身通栏:吸顶栏背景/分隔线才能横贯整个画布,不在 1440px 处被切断 */
.admin-doc-editor {
max-width: 1440px;
container-type: inline-size;
container-name: doc-editor;
}
.admin-doc-head {
/* 编辑器吸顶操作栏:返回/标题/状态 + 发布状态/撤销/保存,滚动时始终可见。
画布镂空透出全站画布层,操作栏用白色块(同用户管理吸顶栏),滚动时干净盖住内容 */
.admin-doc-sticky {
position: sticky;
top: 0;
z-index: 20;
margin-inline: -1rem;
padding: 0.6rem 1rem;
background: var(--panel);
border-bottom: 1px solid var(--line);
}
@media (min-width: 640px) {
.admin-doc-sticky {
margin-inline: -1.5rem;
padding-inline: 1.5rem;
}
}
@media (min-width: 1024px) {
.admin-doc-sticky {
margin-inline: -2rem;
padding-inline: 2rem;
}
}
/* 栏内内容与下方 1440px 内容区对齐(编辑器已通栏) */
.admin-doc-sticky-inner {
display: flex;
align-items: flex-start;
align-items: center;
justify-content: space-between;
gap: 0.75rem 1rem;
margin-bottom: 1.25rem;
gap: 0.5rem 1rem;
flex-wrap: wrap;
max-width: 1440px;
margin-right: auto;
}
.admin-doc-sticky-title {
display: flex;
align-items: center;
gap: 0.55rem;
flex: 1 1 auto;
min-width: 0;
}
.admin-doc-sticky-icon {
width: 2rem;
height: 2rem;
border-radius: 0.65rem;
background: var(--accent-soft);
color: var(--accent);
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.admin-doc-sticky-title h1 {
margin: 0;
font-size: 17px;
line-height: 1.3;
font-weight: 750;
letter-spacing: -0.01em;
color: var(--ink);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.admin-doc-back {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.3rem;
min-width: 2rem;
min-height: 2rem;
font-size: 13px;
padding: 0 0.6rem;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--panel);
font-size: 12.5px;
color: var(--ink-2);
text-decoration: none;
flex-shrink: 0;
transition: border-color 0.15s ease, color 0.15s ease;
}
.admin-doc-back:hover {
color: var(--accent);
border-color: var(--accent);
}
.admin-doc-back:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: 0.4rem;
}
.admin-doc-head-title {
@media (max-width: 420px) {
.admin-doc-back-text {
display: none;
}
}
.admin-doc-sticky-actions {
display: flex;
align-items: center;
gap: 0.6rem;
gap: 0.45rem 0.55rem;
flex-wrap: wrap;
margin-top: 0.15rem;
}
.admin-doc-head-title h1 {
margin: 0;
font-size: 22px;
line-height: 1.25;
font-weight: 800;
letter-spacing: -0.02em;
color: var(--ink);
.admin-doc-sticky-actions .btn {
min-height: 2.25rem;
}
.admin-doc-status {
margin: 0 0 0 0.15rem;
font-size: 12.5px;
color: var(--ink-3);
}
.admin-doc-status.is-error {
color: var(--danger);
}
.admin-doc-split {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
max-width: 1440px;
}
.admin-doc-main {
min-width: 0;
@@ -4850,6 +4926,18 @@ a.j13-mention-token:hover {
line-height: 1.4;
color: var(--ink-2);
}
.admin-doc-label-value {
margin-left: 0.4rem;
font-weight: 600;
color: var(--ink-3);
}
/* 主栏标题输入:正文中最大的输入项,突出「先起标题」 */
.admin-doc-title .admin-field-input {
font-size: 16.5px;
font-weight: 650;
letter-spacing: -0.01em;
min-height: 2.9rem;
}
.admin-doc-hint {
margin: 0.35rem 0 0;
font-size: 12.5px;
@@ -4857,6 +4945,75 @@ a.j13-mention-token:hover {
color: var(--ink-3);
overflow-wrap: anywhere;
}
/* 编辑页主栏整块铺纯白(含标题与正文编辑区):画布镂空后编辑区域需要干净的白底,
右栏设置卡同为白卡,两卡之间露出纹饰画布 */
.admin-doc-editor .admin-doc-main {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 12px;
padding: 1.1rem 1.1rem 1.2rem;
}
/* 编辑器自带白卡边框在纯白主栏内会形成双层描边,扁平化融入主卡 */
.admin-doc-editor .admin-doc-main .j13-md-editor:not(.is-fullscreen) {
background: transparent;
border: 0;
border-radius: 0;
padding: 0;
}
/* 板块管理:低信息密度列表 —— 策略/状态/帖数并入板块元信息行,6 列并 3 列并限宽,
避免宽屏下名称列吞掉全部富余空间而显得空旷 */
.boards-panel {
max-width: 60rem;
}
.boards-head,
.boards-row {
display: grid;
grid-template-columns: 44px minmax(0, 1fr) auto;
gap: 0.75rem;
align-items: center;
padding-inline: 1rem;
}
.boards-head {
padding-block: 0.65rem;
font-size: 11.5px;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--ink-3);
border-bottom: 1px solid var(--line);
}
.boards-row {
padding-block: 0.8rem;
}
.boards-row + .boards-row {
border-top: 1px solid var(--line);
}
.boards-actions {
justify-self: end;
}
.boards-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.45rem;
margin-top: 0.4rem;
}
.boards-meta-posts {
font-size: 11.5px;
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
@media (max-width: 640px) {
.boards-head {
display: none;
}
.boards-row {
grid-template-columns: 40px minmax(0, 1fr);
}
.boards-actions {
grid-column: 2;
justify-self: start;
}
}
.admin-doc-md .j13-md-editor:not(.is-fullscreen) {
height: clamp(16rem, calc(100dvh - 22rem), 40rem);
min-height: 0;
@@ -4875,7 +5032,7 @@ a.j13-mention-token:hover {
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
padding: 0.85rem 0.9rem 1rem;
padding: 0.9rem 1rem 1.05rem;
}
.admin-doc-rail > summary {
cursor: pointer;
@@ -4886,15 +5043,39 @@ a.j13-mention-token:hover {
min-height: 2.25rem;
display: flex;
align-items: center;
position: relative;
padding-right: 1.5rem;
user-select: none;
}
.admin-doc-rail > summary::-webkit-details-marker {
display: none;
}
/* 折叠箭头:桌面双栏时 summary 隐藏,仅移动端单栏可见 */
.admin-doc-rail > summary::after {
content: "";
position: absolute;
right: 0.35rem;
top: 50%;
width: 0.42rem;
height: 0.42rem;
border-right: 1.5px solid var(--ink-3);
border-bottom: 1.5px solid var(--ink-3);
transform: translateY(-70%) rotate(45deg);
transition: transform 0.15s ease;
}
.admin-doc-rail[open] > summary::after {
transform: translateY(-30%) rotate(-135deg);
}
.admin-doc-rail-body {
display: flex;
flex-direction: column;
gap: 1rem;
padding-top: 0.35rem;
padding-top: 0.25rem;
}
/* 设置项分组:细分隔线代替卡片套卡片 */
.admin-doc-rail-body > * + * {
margin-top: 1.05rem;
padding-top: 1.05rem;
border-top: 1px solid var(--line);
}
.admin-doc-field {
min-width: 0;
@@ -4949,6 +5130,21 @@ a.j13-mention-token:hover {
flex-wrap: wrap;
gap: 0.45rem;
}
/* 标签胶囊实时预览:色块下方一条安静的展示条,与前台同款 j13-anno-tag */
.admin-doc-tagpreview {
display: flex;
align-items: center;
gap: 0.55rem;
margin-top: 0.7rem;
padding: 0.6rem 0.75rem;
border-radius: 0.7rem;
background: var(--bg);
}
.admin-doc-tagpreview::after {
content: attr(data-note);
font-size: 12px;
color: var(--ink-3);
}
.admin-doc-colors .j13-anno-dot {
width: 2.25rem;
height: 2.25rem;
@@ -4963,26 +5159,6 @@ a.j13-mention-token:hover {
color: var(--ink);
font-size: 14px;
}
.admin-doc-actions {
min-width: 0;
}
.admin-doc-actions-btns {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.6rem 0.75rem;
}
.admin-doc-actions .btn {
min-height: 2.5rem;
}
.admin-doc-status {
margin: 0;
font-size: 12.5px;
color: var(--ink-3);
}
.admin-doc-status.is-error {
color: var(--danger);
}
.admin-doc-table-wrap {
min-width: 0;
}
@@ -5129,11 +5305,11 @@ a.j13-mention-token:hover {
cursor: not-allowed;
}
@container doc-editor (min-width: 64rem) {
/* 双栏阈值取 72rem:保证吸顶操作栏单行放下,右侧设置栏再入列 */
@container doc-editor (min-width: 72rem) {
.admin-doc-split {
grid-template-columns: minmax(0, 1fr) 19rem;
column-gap: 1.5rem;
row-gap: 0.85rem;
grid-template-columns: minmax(0, 1fr) 20rem;
column-gap: 1.75rem;
}
.admin-doc-main {
grid-column: 1;
@@ -5141,10 +5317,12 @@ a.j13-mention-token:hover {
}
.admin-doc-rail {
grid-column: 2;
grid-row: 1 / span 2;
grid-row: 1;
align-self: start;
position: sticky;
top: 0;
max-height: calc(100dvh - 6rem);
/* 吸顶操作栏(约 3.7rem)之下留出呼吸空隙 */
top: 4.5rem;
max-height: calc(100dvh - 10rem);
overflow: auto;
}
.admin-doc-rail > summary {
@@ -5153,9 +5331,10 @@ a.j13-mention-token:hover {
.admin-doc-rail:not([open]) > .admin-doc-rail-body {
display: flex;
}
.admin-doc-actions {
grid-column: 1;
grid-row: 2;
/* 现代浏览器(Chrome 131+)对关闭的 details 用 content-visibility 隐藏内容,
上一条 display 规则救不回来;桌面双栏强制可见,防止移动端折叠后拉宽窗口设置凭空消失 */
.admin-doc-rail:not([open])::details-content {
content-visibility: visible;
}
}
@@ -6118,7 +6297,8 @@ a.j13-mention-token:hover {
transition: transform 0.12s ease, box-shadow 0.12s ease;
}
.j13-anno-dot:hover { transform: scale(1.12); }
.j13-anno-dot[aria-pressed="true"] {
.j13-anno-dot[aria-pressed="true"],
.j13-anno-dot[aria-checked="true"] {
border-color: var(--panel);
box-shadow: 0 0 0 2px var(--accent);
}
@@ -8499,7 +8679,7 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
transition: none !important;
}
/* 头像配色(浅/暗各自保证可读) */
/* 头像/板块配色(浅/暗各自保证可读) */
.av-0 { background: rgba(52, 81, 200, 0.12); color: #3451c8; }
.av-1 { background: rgba(10, 99, 117, 0.12); color: #0a6375; }
.av-2 { background: rgba(109, 71, 192, 0.12); color: #6d47c0; }
@@ -8508,6 +8688,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
.av-5 { background: rgba(171, 56, 93, 0.12); color: #ab385d; }
.av-6 { background: rgba(133, 88, 0, 0.14); color: #855800; }
.av-7 { background: rgba(82, 96, 122, 0.13); color: #52607a; }
.av-8 { background: rgba(5, 150, 105, 0.13); color: #059669; }
.av-9 { background: rgba(2, 132, 199, 0.13); color: #0284c7; }
.av-10 { background: rgba(67, 56, 202, 0.13); color: #4338ca; }
.av-11 { background: rgba(190, 24, 93, 0.13); color: #be185d; }
.av-12 { background: rgba(180, 83, 9, 0.14); color: #b45309; }
.av-13 { background: rgba(77, 124, 3, 0.14); color: #4d7c03; }
.av-14 { background: rgba(157, 23, 158, 0.13); color: #9d179e; }
.av-15 { background: rgba(159, 0, 51, 0.13); color: #9f0033; }
.dark .av-0 { background: rgba(169, 184, 255, 0.18); color: #a9b8ff; }
.dark .av-1 { background: rgba(92, 201, 224, 0.18); color: #5cc9e0; }
.dark .av-2 { background: rgba(192, 168, 245, 0.18); color: #c0a8f5; }
@@ -8516,6 +8704,14 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
.dark .av-5 { background: rgba(240, 147, 181, 0.18); color: #f093b5; }
.dark .av-6 { background: rgba(224, 178, 89, 0.18); color: #e0b259; }
.dark .av-7 { background: rgba(170, 182, 207, 0.18); color: #aab6cf; }
.dark .av-8 { background: rgba(110, 231, 183, 0.18); color: #6ee7b7; }
.dark .av-9 { background: rgba(125, 211, 252, 0.18); color: #7dd3fc; }
.dark .av-10 { background: rgba(165, 180, 252, 0.18); color: #a5b4fc; }
.dark .av-11 { background: rgba(251, 113, 133, 0.18); color: #fb7185; }
.dark .av-12 { background: rgba(252, 211, 77, 0.18); color: #fcd34d; }
.dark .av-13 { background: rgba(190, 242, 100, 0.18); color: #bef264; }
.dark .av-14 { background: rgba(232, 121, 249, 0.18); color: #e879f9; }
.dark .av-15 { background: rgba(248, 113, 113, 0.18); color: #f87171; }
/* ============================================================
微交互

View File

@@ -38,6 +38,14 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const [retryAfter, setRetryAfter] = useState(0);
// 登录限流倒计时:窗口期内禁用提交,避免无意义请求与提示条消失重现的闪烁
useEffect(() => {
if (retryAfter <= 0) return;
const t = setTimeout(() => setRetryAfter((s) => s - 1), 1000);
return () => clearTimeout(t);
}, [retryAfter]);
// 已登录用户访问登录页:直接回落到安全的跳转目标
useEffect(() => {
@@ -52,7 +60,7 @@ export default function LoginPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError("");
// 提交不清空 error:提示条消失又重现会造成表单跳动(闪烁),输入时再清除
setLoading(true);
try {
const res = await apiLogin(username, password);
@@ -63,6 +71,9 @@ export default function LoginPage() {
router.refresh();
} else {
setError(res.error || "登录失败");
if (typeof res.retry_after === "number" && res.retry_after > 0) {
setRetryAfter(res.retry_after);
}
}
} finally {
setLoading(false);
@@ -96,7 +107,10 @@ export default function LoginPage() {
id="login-username"
type="text"
value={username}
onChange={(e) => setUsername(e.target.value)}
onChange={(e) => {
setUsername(e.target.value);
setError("");
}}
required
autoFocus
autoComplete="username"
@@ -110,15 +124,18 @@ export default function LoginPage() {
id="login-password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
onChange={(e) => {
setPassword(e.target.value);
setError("");
}}
required
autoComplete="current-password"
className="field"
placeholder="输入密码"
/>
</div>
<button type="submit" disabled={loading} className="btn btn-primary w-full justify-center mt-2">
{loading ? "登录中..." : "登录"}
<button type="submit" disabled={loading || retryAfter > 0} className="btn btn-primary w-full justify-center mt-2">
{loading ? "登录中..." : retryAfter > 0 ? `${retryAfter} 秒后可重试` : "登录"}
</button>
</form>

View File

@@ -16,6 +16,7 @@ type Props = {
boardName: string;
boardId: number;
boardColorIndex?: number;
boardIcon?: string;
};
export default function EditPostClient({
@@ -31,6 +32,7 @@ export default function EditPostClient({
boardName,
boardId,
boardColorIndex = 0,
boardIcon,
}: Props) {
return (
<PostComposer
@@ -47,6 +49,7 @@ export default function EditPostClient({
boardName={boardName}
boardId={boardId}
boardColorIndex={boardColorIndex}
boardIcon={boardIcon}
cancelHref={`/post/${postId}`}
/>
);

View File

@@ -47,6 +47,7 @@ export default async function EditPostPage({ params }: PageProps) {
boardName={post.board?.name || "未知板块"}
boardId={post.board_id}
boardColorIndex={post.board?.color_index ?? 0}
boardIcon={post.board?.icon}
/>
</div>
);

View File

@@ -24,6 +24,7 @@ import {
type Board,
type CommentsResponse,
} from "@/lib/api";
import BoardIcon, { boardColor } from "@/components/BoardIcon";
import { postViewerCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import CommentSection from "@/components/CommentSection";
@@ -109,7 +110,7 @@ function formatDateTime(dateStr: string) {
============================================================ */
function BoardCard({ board }: { board: Board }) {
const colorIdx = Math.max(0, board.color_index ?? 0) % 8;
const colorIdx = boardColor(board);
return (
<section className="j13-module shrink-0" aria-labelledby="board-card-title">
<p className="j13-module-title mb-3" id="board-card-title">
@@ -117,9 +118,9 @@ function BoardCard({ board }: { board: Board }) {
</p>
<Link href={`/?board=${board.id}`} className="flex items-center gap-3 group">
<span
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center text-lg font-extrabold shrink-0`}
className={`av-${colorIdx} w-11 h-11 rounded-xl flex items-center justify-center shrink-0`}
>
{board.name.slice(0, 1)}
<BoardIcon icon={board.icon} name={board.name} size={20} />
</span>
<span
className="font-semibold text-[14.5px] truncate group-hover:text-[var(--accent)] transition-colors"

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>
))}

View File

@@ -82,8 +82,47 @@ export interface Board {
icon: string;
color_index?: number;
sort_order: number;
post_policy?: string;
visible?: boolean;
}
// 管理端板块:列表附带已发布帖子数
export interface AdminBoard {
id: number;
name: string;
description: string;
icon: string;
color_index: number;
sort_order: number;
post_policy: string;
visible: boolean;
post_count?: number;
}
export interface AdminBoardModerator {
id: number;
username: string;
nickname: string;
avatar: string;
email: string;
banned: boolean;
}
export type AdminBoardInput = {
name: string;
description?: string;
icon?: string;
color_index?: number | null;
sort_order?: number | null;
post_policy?: string;
visible?: boolean | null;
};
export const BOARD_POST_POLICY = {
ALL: "all",
STAFF: "staff",
} as const;
// 首页聚合接口
export interface OverviewStats {
posts: number;
@@ -695,10 +734,12 @@ export async function fetchBoards(): Promise<{ boards: BoardWithCount[] }> {
}
// 公开站点设置:accent 为站点主题色 hex(空串=内置默认);
// trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true)
// trust_reviewed_publish 为已过审用户后续免审(缺省/未配置视为 true);
// publish_without_review 为全站免审,无需过审记录直接发布(缺省/未配置视为 false)
export interface PublicSettings {
accent: string;
trust_reviewed_publish: boolean;
publish_without_review: boolean;
site_name: string;
site_description: string;
/** 无 Logo 时页眉字标;空则回退显示名称 */
@@ -772,6 +813,7 @@ export const DEFAULT_ATTACHMENT_EXTS: string[] = [
const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
accent: "",
trust_reviewed_publish: true,
publish_without_review: false,
site_name: "姜十三论坛",
site_description: "姜十三论坛 - 技术分享与讨论社区",
site_wordmark: "",
@@ -842,6 +884,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
return {
accent: data?.accent ?? "",
trust_reviewed_publish: data?.trust_reviewed_publish !== false,
publish_without_review: data?.publish_without_review === true,
site_name: (data?.site_name || "").trim() || DEFAULT_PUBLIC_SETTINGS.site_name,
site_description:
data?.site_description !== undefined
@@ -2142,6 +2185,187 @@ export async function apiAdminSetUserBan(
return data as { user: AdminUser };
}
// ===== 数据导入(超管/站长) =====
export type LegacyUserReport = {
total: number;
imported: number;
skipped: number;
avatar_written: number;
conflicts?: string[];
avatar_missing?: string[];
failed?: string[];
};
export type LegacyBoardReport = {
created: number;
reused: number;
names?: string[];
};
export type LegacyPostReport = {
total: number;
imported: number;
skipped: number;
polls_skipped: number;
poll_titles?: string[];
owner_fallback?: string[];
failed?: string[];
};
export type LegacyCommentReport = {
total: number;
imported: number;
skipped: number;
skipped_detail?: string[];
owner_fallback?: string[];
failed?: string[];
};
export type LegacyImportReport = {
dry_run: boolean;
users: LegacyUserReport;
boards?: LegacyBoardReport;
posts?: LegacyPostReport;
comments?: LegacyCommentReport;
notes?: string[];
};
export type LegacyImportOptions = {
withContent: boolean;
dryRun: boolean;
};
/** 上传旧站 SQLite 库(必填)与可选头像 zip,导入用户与内容。幂等可重复执行 */
export async function apiAdminImportLegacy(
source: string,
dbFile: File,
zipFile: File | null,
opts: LegacyImportOptions
): Promise<LegacyImportReport> {
const form = new FormData();
form.append("db_file", dbFile, dbFile.name || "jiang13.db");
if (zipFile) form.append("avatars_zip", zipFile, zipFile.name || "avatars.zip");
form.append("with_content", String(opts.withContent));
form.append("dry_run", String(opts.dryRun));
const res = await fetchWithRefresh(`/api/admin/import/${source}`, {
method: "POST",
headers: clientHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return (data.report || {}) as LegacyImportReport;
}
// ===== 板块管理(仅站长) =====
export async function fetchAdminBoards(
cookieHeader?: string
): Promise<{ boards: AdminBoard[] }> {
const res = await fetch(
`${API_BASE}/api/admin/boards`,
ssrInit(cookieHeaders(cookieHeader))
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取板块列表失败");
return data as { boards: AdminBoard[] };
}
export async function apiAdminListBoards(): Promise<{ boards: AdminBoard[] }> {
const res = await fetchWithRefresh("/api/admin/boards", {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取板块列表失败");
return data as { boards: AdminBoard[] };
}
export async function apiAdminCreateBoard(
input: AdminBoardInput
): Promise<{ board: AdminBoard }> {
const res = await fetchWithRefresh("/api/admin/boards", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "创建板块失败");
return data as { board: AdminBoard };
}
export async function apiAdminUpdateBoard(
id: number,
input: AdminBoardInput
): Promise<{ board: AdminBoard }> {
const res = await fetchWithRefresh(`/api/admin/boards/${id}`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify(input),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "更新板块失败");
return data as { board: AdminBoard };
}
export async function apiAdminDeleteBoard(id: number): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/boards/${id}`, {
method: "DELETE",
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "删除板块失败");
}
export async function apiAdminReorderBoards(ids: number[]): Promise<void> {
const res = await fetchWithRefresh("/api/admin/boards/reorder", {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ ids }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "排序失败");
}
export async function apiAdminListBoardModerators(
id: number
): Promise<{ moderators: AdminBoardModerator[] }> {
const res = await fetchWithRefresh(`/api/admin/boards/${id}/moderators`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取版主失败");
return data as { moderators: AdminBoardModerator[] };
}
export async function apiAdminAddBoardModerator(
boardId: number,
username: string
): Promise<void> {
const res = await fetchWithRefresh(`/api/admin/boards/${boardId}/moderators`, {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ username }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "添加版主失败");
}
export async function apiAdminRemoveBoardModerator(
boardId: number,
uid: number
): Promise<void> {
const res = await fetchWithRefresh(
`/api/admin/boards/${boardId}/moderators/${uid}`,
{
method: "DELETE",
headers: clientHeaders(),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "移除版主失败");
}
// ===== 站长用户内容档案 =====
export interface AdminUserAuditProfile {
@@ -2277,6 +2501,7 @@ export async function apiGetSettings(): Promise<PublicSettings> {
export type UpdateSiteSettingsBody = {
accent?: string;
trust_reviewed_publish?: boolean;
publish_without_review?: boolean;
site_name?: string;
site_description?: string;
site_wordmark?: string;
@@ -2790,6 +3015,16 @@ export async function apiOpenDirectChat(
return data as { room: ChatRoomView };
}
// 客户端:公开用户资料(资料卡用;只取第一页减小体积)
export async function apiGetUserProfile(id: number): Promise<UserProfile> {
const res = await fetchWithRefresh(`/api/users/${id}?page=1&size=1`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "获取用户资料失败");
return data as UserProfile;
}
// 客户端:搜索用户以发起私聊
export async function apiSearchChatUsers(
q: string

View File

@@ -41,6 +41,7 @@ export interface RtPresenceData {
export interface RtSettingsData {
accent: string;
trust_reviewed_publish?: boolean;
publish_without_review?: boolean;
site_name?: string;
site_description?: string;
site_wordmark?: string;

View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.