feat: 管理端板块管理与旧版数据导入,补充部署运营文档
- 新增管理端板块管理页面与后端接口(admin_board) - 新增旧版数据导入:legacyimport 服务、导入面板、importusers 命令行工具 - 聊天用户卡片、板块图标等 UI 组件与界面优化 - 补充 about/公告/1Panel 部署等文档 - gitignore 排除 dist/ 构建产物与 .agents/ 本地工具目录
This commit is contained in:
@@ -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
|
||||
|
||||
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal file
739
frontend/app/admin/boards/BoardsAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
frontend/app/admin/boards/page.tsx
Normal file
33
frontend/app/admin/boards/page.tsx
Normal 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} />;
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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="过审免审"
|
||||
/>
|
||||
|
||||
@@ -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: "维护与诊断" },
|
||||
];
|
||||
|
||||
|
||||
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal file
415
frontend/app/admin/settings/import/ImportPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
10
frontend/app/admin/settings/import/page.tsx
Normal file
10
frontend/app/admin/settings/import/page.tsx
Normal 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 />;
|
||||
}
|
||||
Reference in New Issue
Block a user