feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-17 04:00:14 +08:00
parent cda5fad2e5
commit 9a55c2f6f2
41 changed files with 5655 additions and 1810 deletions

View File

@@ -1,300 +0,0 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useEffect, useState } from "react";
import type { LucideIcon } from "lucide-react";
import {
LayoutDashboard,
Users,
Megaphone,
Palette,
ShieldCheck,
ArrowLeft,
Coins,
Globe,
FolderOpen,
} from "lucide-react";
import type { User } from "@/lib/api";
import { apiAdminPendingCounts } from "@/lib/api";
import {
canModerateAny,
isAdminOrAbove,
isSuperOrOwner,
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import Avatar from "@/components/Avatar";
import RoleBadge from "@/components/RoleBadge";
interface NavItem {
href: string;
label: string;
icon: LucideIcon;
exact?: boolean;
visible: (u: RoleUserLike) => boolean;
pendingBadge?: boolean;
}
interface NavGroup {
title: string;
items: NavItem[];
}
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置,便于后期在组内扩展
const NAV_GROUPS: NavGroup[] = [
{
title: "概览",
items: [
{ href: "/admin", label: "仪表盘", icon: LayoutDashboard, exact: true, visible: () => true },
],
},
{
title: "内容",
items: [
{
href: "/admin/content",
label: "内容管理",
icon: FolderOpen,
visible: (u) => canModerateAny(u),
pendingBadge: true,
},
{
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "成员与经济",
items: [
{
href: "/admin/users",
label: "用户权限",
icon: Users,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/economy",
label: "积分看板",
icon: Coins,
visible: (u) => isAdminOrAbove(u.role),
},
],
},
{
title: "站点",
items: [
{
href: "/admin/settings",
label: "站点设置",
icon: Globe,
visible: (u) => isSuperOrOwner(u.role),
},
{
href: "/admin/appearance",
label: "外观",
icon: Palette,
visible: (u) => isSuperOrOwner(u.role),
},
],
},
];
function isActive(pathname: string, href: string, exact?: boolean) {
return exact ? pathname === href : pathname === href || pathname.startsWith(`${href}/`);
}
function usePendingCounts(enabled: boolean) {
const [counts, setCounts] = useState({ posts: 0, comments: 0 });
useEffect(() => {
if (!enabled) return;
let alive = true;
const load = () => {
apiAdminPendingCounts()
.then((c) => {
if (alive) setCounts(c);
})
.catch(() => {});
};
load();
const off = realtime.on(RT_MODERATION_CHANGED, () => load());
const offVisible = onDocumentVisible(load);
return () => {
alive = false;
off();
offVisible();
};
}, [enabled]);
return counts.posts + counts.comments;
}
function PendingBadge({ count }: { count: number }) {
if (count <= 0) return null;
return (
<span
className="ml-auto shrink-0 min-w-[20px] h-5 px-1.5 rounded-full inline-flex items-center justify-center text-[10.5px] font-medium tabular-nums"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
aria-label={`${count} 条待审核`}
>
{count > 99 ? "99+" : count}
</span>
);
}
function visibleGroups(user: RoleUserLike) {
return NAV_GROUPS.map((g) => ({
...g,
items: g.items.filter((i) => i.visible(user)),
})).filter((g) => g.items.length > 0);
}
function flatItems(user: RoleUserLike) {
return visibleGroups(user).flatMap((g) => g.items);
}
export function AdminSidebar({ user }: { user: User }) {
const pathname = usePathname();
const groups = visibleGroups(user);
const pendingTotal = usePendingCounts(flatItems(user).some((i) => i.pendingBadge));
return (
<div className="panel p-3">
<div className="flex items-center gap-2.5 px-2 pt-1.5 pb-3.5" style={{ borderBottom: "1px solid var(--line)" }}>
<span
className="w-9 h-9 rounded-xl flex items-center justify-center shrink-0"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<ShieldCheck size={18} />
</span>
<span className="leading-tight min-w-0">
<span className="block text-[14.5px] font-extrabold tracking-tight truncate" style={{ color: "var(--ink)" }}>
管理面板
</span>
<span className="block text-[10.5px] mt-0.5 tracking-wide" style={{ color: "var(--ink-3)" }}>
J13 ADMIN
</span>
</span>
</div>
<nav className="flex flex-col gap-3 pt-3 pb-2" aria-label="后台导航">
{groups.map((group) => (
<div key={group.title}>
<p className="field-label px-2 pb-1.5" style={{ marginBottom: 0 }}>
{group.title}
</p>
<div className="flex flex-col gap-1">
{group.items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] font-medium transition-colors"
style={
active
? { background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }
: { color: "var(--ink-2)" }
}
>
<Icon
size={16}
className="shrink-0"
style={{ color: active ? "var(--nav-active-ink)" : "var(--ink-3)" }}
/>
{item.label}
{item.pendingBadge && <PendingBadge count={pendingTotal} />}
</Link>
);
})}
</div>
</div>
))}
</nav>
<div className="pt-2" style={{ borderTop: "1px solid var(--line)" }}>
<Link
href="/"
className="flex items-center gap-2.5 px-3 py-2.5 rounded-xl text-[13.5px] transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)" }}
>
<ArrowLeft size={16} className="shrink-0" style={{ color: "var(--ink-3)" }} />
返回站点
</Link>
</div>
<div className="flex items-center gap-2.5 mt-2 px-2.5 py-2.5 rounded-xl" style={{ background: "var(--panel-2)" }}>
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={32} />
<div className="min-w-0 leading-tight">
<p className="text-[13px] font-medium truncate" style={{ color: "var(--ink)" }}>
{user.nickname}
</p>
<p className="text-[11px] truncate" style={{ color: "var(--ink-3)" }}>
@{user.username}
</p>
</div>
<span className="ml-auto shrink-0">
<RoleBadge role={user.role} boardIds={user.board_ids} size={11} />
</span>
</div>
</div>
);
}
export function AdminNavMobile({ user }: { user: User }) {
const pathname = usePathname();
const items = flatItems(user);
const pendingTotal = usePendingCounts(items.some((i) => i.pendingBadge));
return (
<div
className="lg:hidden sticky z-30 -mx-4 sm:-mx-6 px-4 sm:px-6 py-2.5 mb-4"
style={{
top: 68,
background: "color-mix(in srgb, var(--bg) 88%, transparent)",
backdropFilter: "blur(14px)",
WebkitBackdropFilter: "blur(14px)",
borderBottom: "1px solid var(--line)",
}}
>
<nav className="flex items-center gap-2 overflow-x-auto" aria-label="后台导航">
{items.map((item) => {
const active = isActive(pathname, item.href, item.exact);
const Icon = item.icon;
return (
<Link
key={item.href}
href={item.href}
aria-current={active ? "page" : undefined}
className="admin-seg shrink-0"
style={
active
? undefined
: { background: "var(--panel)", border: "1px solid var(--line)" }
}
>
<Icon size={14} />
{item.label}
{item.pendingBadge && pendingTotal > 0 && (
<span className="admin-seg-count">{pendingTotal > 99 ? "99+" : pendingTotal}</span>
)}
</Link>
);
})}
<Link
href="/"
className="admin-seg shrink-0"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<ArrowLeft size={14} />
返回站点
</Link>
</nav>
</div>
);
}

View File

@@ -0,0 +1,244 @@
"use client";
import { useState, useTransition } from "react";
import Link from "next/link";
import { ChevronRight, Eye, UserPlus, Users } from "lucide-react";
import {
apiAdminAnalytics,
type AnalyticsOverview,
type NewUser,
} from "@/lib/api";
import { postTypeLabel } from "@/lib/postMeta";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { AdminSegmented } from "@/components/admin";
import {
AdminChartCard,
DualLineChart,
NamedBarChart,
NamedPieChart,
SingleAreaChart,
VisitTrendChart,
} from "@/components/admin/charts";
type RangeKey = "7d" | "30d";
const fmt = (n: number) => n.toLocaleString("zh-CN");
function PanelHead({ title, extra }: { title: string; extra?: string }) {
return (
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</span>
{extra && (
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{extra}
</span>
)}
</div>
);
}
function mergePostCommentSeries(data: AnalyticsOverview) {
const map = new Map<string, { date: string; posts: number; comments: number }>();
for (const row of data.posts_series) {
map.set(row.date, { date: row.date, posts: row.count, comments: 0 });
}
for (const row of data.comments_series) {
const prev = map.get(row.date);
if (prev) prev.comments = row.count;
else map.set(row.date, { date: row.date, posts: 0, comments: row.count });
}
return [...map.values()].sort((a, b) => a.date.localeCompare(b.date));
}
export default function DashboardClient({
initialAnalytics,
newUsers,
totalUsers,
canUsers,
}: {
initialAnalytics: AnalyticsOverview;
newUsers: NewUser[];
totalUsers?: number;
canUsers: boolean;
}) {
const [range, setRange] = useState<RangeKey>("7d");
const [data, setData] = useState(initialAnalytics);
const [error, setError] = useState<string | null>(null);
const [pending, startTransition] = useTransition();
const switchRange = (next: RangeKey) => {
if (next === range || pending) return;
setRange(next);
setError(null);
startTransition(async () => {
try {
const res = await apiAdminAnalytics(next);
setData(res);
} catch {
setError("趋势数据加载失败,请稍后重试");
}
});
};
const activitySeries = mergePostCommentSeries(data);
const registerSeries = data.registers_series.map((r) => ({
date: r.date,
count: r.count,
}));
const boardBars = data.boards.map((b) => ({
label: b.label || b.key,
count: b.count,
}));
const typeSlices = data.post_types.map((t) => ({
label: postTypeLabel(t.key),
count: t.count,
}));
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-3 flex-wrap">
<p className="meta text-[12.5px]" style={{ color: "var(--ink-3)" }}>
{pending ? "正在切换区间…" : `近 ${data.range_days} 日趋势`}
</p>
<AdminSegmented<RangeKey>
ariaLabel="分析时间范围"
size="sm"
role="group"
value={range}
onChange={switchRange}
options={[
{ key: "7d", label: "近 7 日" },
{ key: "30d", label: "近 30 日" },
]}
/>
</div>
{error && (
<p className="text-[13px] px-1" style={{ color: "var(--danger)" }} role="alert">
{error}
</p>
)}
<AdminChartCard title="访问趋势" hint="页面浏览量(PV)与独立访客(UV)">
<VisitTrendChart data={data.visits} />
</AdminChartCard>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<AdminChartCard title="发帖与评论" hint="按日新增">
<DualLineChart
data={activitySeries}
aKey="posts"
bKey="comments"
aName="发帖"
bName="评论"
/>
</AdminChartCard>
<AdminChartCard title="新注册" hint="按日新增成员">
<SingleAreaChart data={registerSeries} dataKey="count" name="注册" color="var(--ok)" />
</AdminChartCard>
<AdminChartCard title="板块发帖分布" hint="区间内发帖量">
<NamedBarChart data={boardBars} layout="horizontal" />
</AdminChartCard>
<AdminChartCard title="帖子类型" hint="区间内发帖构成">
<NamedPieChart data={typeSlices} />
</AdminChartCard>
</div>
<div className="grid lg:grid-cols-2 gap-4 items-start">
<div className="panel overflow-hidden">
<PanelHead
title="最新注册成员"
extra={totalUsers != null ? `共 ${fmt(totalUsers)} 人` : undefined}
/>
{newUsers.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无成员数据</p>
) : (
newUsers.slice(0, 8).map((u) => (
<Link
key={u.id}
href={`/u/${u.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={34}
/>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden />
{formatRelative(u.created_at)}加入
</span>
</Link>
))
)}
{canUsers && (
<Link
href="/admin/users"
className="flex items-center justify-center gap-1.5 px-4 py-3 text-[13px] transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden />
前往用户管理
<ChevronRight size={14} aria-hidden />
</Link>
)}
</div>
<div className="panel overflow-hidden">
<PanelHead title="热门浏览" extra={`近 ${data.range_days} 日`} />
{data.top_viewed.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无浏览数据</p>
) : (
data.top_viewed.map((p, i) => (
<Link
key={p.id}
href={`/post/${p.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-7 h-7 rounded-md flex items-center justify-center shrink-0 text-[12px] font-bold tabular-nums"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
aria-hidden
>
{i + 1}
</span>
<span className="min-w-0 flex-1 text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{p.title}
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[12px] tabular-nums"
style={{ color: "var(--ink-3)" }}
>
<Eye size={13} aria-hidden />
{fmt(p.view_count)}
</span>
</Link>
))
)}
</div>
</div>
</div>
);
}

View File

@@ -1,6 +1,6 @@
"use client";
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import {
Plus,
Pencil,
@@ -20,7 +20,7 @@ import {
type AnnouncementColor,
type AnnouncementInput,
} from "@/lib/api";
import { formatDate } from "@/lib/format";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
@@ -29,6 +29,7 @@ import {
AdminIconButton,
AdminPageHeader,
AdminSegmented,
AdminStatusChip,
} from "@/components/admin";
const COLORS: { value: AnnouncementColor; label: string }[] = [
@@ -42,6 +43,7 @@ const COLORS: { value: AnnouncementColor; label: string }[] = [
type FormState = AnnouncementInput & { id: number | null };
type PublishKey = "published" | "draft";
type StatusFilter = "all" | "published" | "draft";
const EMPTY_FORM: FormState = {
id: null,
@@ -52,6 +54,156 @@ const EMPTY_FORM: FormState = {
published: true,
};
/** 粗略去掉 Markdown,取一行摘要 */
function plainSummary(md: string, max = 96): string {
const text = md
.replace(/```[\s\S]*?```/g, " ")
.replace(/!\[[^\]]*\]\([^)]+\)/g, " ")
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
.replace(/[#>*_`~\-|=]/g, "")
.replace(/\s+/g, " ")
.trim();
return text.length > max ? `${text.slice(0, max)}…` : text;
}
function AnnouncementForm({
form,
setForm,
saving,
onSave,
onCancel,
}: {
form: FormState;
setForm: (f: FormState) => void;
saving: boolean;
onSave: () => void;
onCancel: () => void;
}) {
return (
<div className="panel p-5">
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
{form.id == null ? "新建公告" : "编辑公告"}
</p>
<AdminField
id="anno-title"
className="mb-4"
label="标题"
maxLength={200}
value={form.title}
onChange={(title) => setForm({ ...form, title })}
placeholder="请输入公告标题"
/>
<div className="flex items-end gap-4 mb-4 flex-wrap">
<div className="w-36">
<AdminField
id="anno-tag"
label="标签"
maxLength={32}
value={form.tag}
onChange={(tag) => setForm({ ...form, tag })}
placeholder="公告"
/>
</div>
<div>
<p className="field-label">标签颜色</p>
<div className="flex items-center gap-2">
{COLORS.map((c) => (
<button
key={c.value}
type="button"
data-tip={c.label}
aria-label={c.label}
aria-pressed={form.tag_color === c.value}
className={`j13-anno-dot is-${c.value}`}
onClick={() => setForm({ ...form, tag_color: c.value })}
/>
))}
</div>
</div>
</div>
<p className="field-label">发布状态</p>
<div className="mb-4">
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => setForm({ ...form, published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</div>
<AdminField
id="anno-content"
label="正文(支持 Markdown)"
as="textarea"
minHeight="260px"
value={form.content}
onChange={(content) => setForm({ ...form, content })}
placeholder="请输入公告正文,支持 Markdown 语法…"
/>
<div className="flex items-center gap-2.5 mt-4">
<button type="button" onClick={onSave} disabled={saving} className="btn btn-primary flex-1 justify-center">
{saving ? (
<>
<Loader2 size={15} className="animate-spin" /> 保存中…
</>
) : (
<>
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
</>
)}
</button>
<button
type="button"
onClick={onCancel}
disabled={saving}
className="btn btn-line"
aria-label="取消编辑"
>
<X size={15} /> 取消
</button>
</div>
</div>
);
}
function AnnouncementPreview({ a }: { a: Announcement }) {
return (
<div className="panel p-5">
<div className="flex items-center gap-2 flex-wrap mb-3">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
{a.published ? "已发布" : "草稿"}
</AdminStatusChip>
<span className="meta text-[11.5px] tabular-nums">{formatRelative(a.updated_at || a.created_at)}</span>
</div>
<h2 className="text-[17px] font-semibold leading-snug" style={{ color: "var(--ink)" }}>
{a.title}
</h2>
<p className="mt-3 text-[13.5px] leading-relaxed whitespace-pre-wrap" style={{ color: "var(--ink-2)" }}>
{a.content}
</p>
{a.published ? (
<a
href={`/announcement/${a.id}`}
className="btn btn-line btn-sm mt-4 inline-flex"
target="_blank"
rel="noreferrer"
>
<ExternalLink size={14} /> 查看前台页
</a>
) : null}
</div>
);
}
export default function AnnouncementAdmin({
initial,
initError,
@@ -62,10 +214,26 @@ export default function AnnouncementAdmin({
const [list, setList] = useState(initial);
const [loadError, setLoadError] = useState(initError);
const [form, setForm] = useState<FormState | null>(null);
const [selectedId, setSelectedId] = useState<number | null>(initial[0]?.id ?? null);
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [saving, setSaving] = useState(false);
const [deletingId, setDeletingId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<Announcement | null>(null);
const publishedCount = useMemo(() => list.filter((a) => a.published).length, [list]);
const draftCount = useMemo(() => list.filter((a) => !a.published).length, [list]);
const filtered = useMemo(() => {
if (statusFilter === "published") return list.filter((a) => a.published);
if (statusFilter === "draft") return list.filter((a) => !a.published);
return list;
}, [list, statusFilter]);
const selected = useMemo(
() => (selectedId == null ? null : list.find((a) => a.id === selectedId) ?? null),
[list, selectedId]
);
const load = useCallback(async () => {
try {
const res = await apiAdminListAnnouncements();
@@ -76,8 +244,13 @@ export default function AnnouncementAdmin({
}
}, []);
const startCreate = () => setForm({ ...EMPTY_FORM });
const startEdit = (a: Announcement) =>
const startCreate = () => {
setForm({ ...EMPTY_FORM });
setSelectedId(null);
};
const startEdit = (a: Announcement) => {
setSelectedId(a.id);
setForm({
id: a.id,
title: a.title,
@@ -86,6 +259,12 @@ export default function AnnouncementAdmin({
tag_color: a.tag_color,
published: a.published,
});
};
const selectRow = (a: Announcement) => {
setSelectedId(a.id);
if (form && form.id !== a.id) setForm(null);
};
async function save() {
if (!form) return;
@@ -103,13 +282,16 @@ export default function AnnouncementAdmin({
setSaving(true);
try {
if (form.id == null) {
await apiAdminCreateAnnouncement(payload);
toast("公告已发布", "ok");
const res = await apiAdminCreateAnnouncement(payload);
toast(payload.published ? "公告已发布" : "草稿已保存", "ok");
setForm(null);
setSelectedId(res.announcement?.id ?? null);
} else {
await apiAdminUpdateAnnouncement(form.id, payload);
toast("公告已更新", "ok");
setForm(null);
setSelectedId(form.id);
}
setForm(null);
await load();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
@@ -122,7 +304,11 @@ export default function AnnouncementAdmin({
setDeletingId(a.id);
try {
await apiAdminDeleteAnnouncement(a.id);
setList((l) => l.filter((x) => x.id !== a.id));
setList((l) => {
const next = l.filter((x) => x.id !== a.id);
if (selectedId === a.id) setSelectedId(next[0]?.id ?? null);
return next;
});
if (form?.id === a.id) setForm(null);
toast("公告已删除", "ok");
} catch (e) {
@@ -140,187 +326,189 @@ export default function AnnouncementAdmin({
title="站点公告管理"
description="发布、编辑与下线右栏站点公告,支持自定义标签与标签颜色"
actions={
form?.id == null ? (
form?.id == null && form != null ? undefined : (
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
) : undefined
)
}
/>
<div className="grid grid-cols-1 gap-6 items-start">
{/* 编辑/新建表单:仅在新建或编辑时展开,空闲不占位 */}
{form && (
<div className="panel p-5">
<p className="font-semibold text-[15px] mb-4" style={{ color: "var(--ink)" }}>
{form.id == null ? "新建公告" : "编辑公告"}
</p>
{/* 计数条 */}
<div className="flex items-center gap-3 mb-4 flex-wrap">
<div
className="inline-flex items-center gap-4 rounded-lg px-3.5 py-2 text-[13px]"
style={{ background: "var(--panel-2)", color: "var(--ink-2)" }}
>
<span>
草稿{" "}
<strong className="tabular-nums" style={{ color: "var(--ink)" }}>
{draftCount}
</strong>
</span>
<span aria-hidden style={{ color: "var(--line)" }}>
|
</span>
<span>
已发布{" "}
<strong className="tabular-nums" style={{ color: "var(--ink)" }}>
{publishedCount}
</strong>
</span>
</div>
<AdminSegmented<StatusFilter>
ariaLabel="公告状态筛选"
size="sm"
value={statusFilter}
onChange={setStatusFilter}
options={[
{ key: "all", label: "全部", count: list.length },
{ key: "published", label: "已发布", count: publishedCount },
{ key: "draft", label: "草稿", count: draftCount },
]}
/>
</div>
<AdminField
id="anno-title"
className="mb-4"
label="标题"
maxLength={200}
value={form.title}
onChange={(title) => setForm({ ...form, title })}
placeholder="请输入公告标题"
/>
<div className="flex items-end gap-4 mb-4 flex-wrap">
<div className="w-36">
<AdminField
id="anno-tag"
label="标签"
maxLength={32}
value={form.tag}
onChange={(tag) => setForm({ ...form, tag })}
placeholder="公告"
/>
</div>
<div>
<p className="field-label">标签颜色</p>
<div className="flex items-center gap-2">
{COLORS.map((c) => (
<button
key={c.value}
type="button"
data-tip={c.label}
aria-label={c.label}
aria-pressed={form.tag_color === c.value}
className={`j13-anno-dot is-${c.value}`}
onClick={() => setForm({ ...form, tag_color: c.value })}
/>
))}
</div>
</div>
</div>
<p className="field-label">发布状态</p>
<div className="mb-4">
<AdminSegmented<PublishKey>
role="group"
ariaLabel="发布状态"
value={form.published ? "published" : "draft"}
onChange={(key) => setForm({ ...form, published: key === "published" })}
options={[
{ key: "published", label: "立即发布" },
{ key: "draft", label: "存为草稿" },
]}
/>
</div>
<AdminField
id="anno-content"
label="正文(支持 Markdown)"
as="textarea"
minHeight="260px"
value={form.content}
onChange={(content) => setForm({ ...form, content })}
placeholder="请输入公告正文,支持 Markdown 语法…"
/>
<div className="flex items-center gap-2.5 mt-4">
<button type="button" onClick={save} disabled={saving} className="btn btn-primary flex-1 justify-center">
{saving ? (
<>
<Loader2 size={15} className="animate-spin" /> 保存中…
</>
) : (
<>
<Save size={15} /> {form.id == null ? "发布公告" : "保存修改"}
</>
)}
</button>
<button
type="button"
onClick={() => setForm(null)}
disabled={saving}
className="btn btn-line"
aria-label="取消编辑"
>
<X size={15} /> 取消
</button>
</div>
</div>
)}
{/* 公告列表 */}
<div className="panel overflow-hidden">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 items-start">
{/* 左侧列表 */}
<div className="panel overflow-hidden min-w-0">
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between"
className="px-4 sm:px-5 py-3 flex items-center justify-between"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
全部公告
<span className="font-medium text-[14px]" style={{ color: "var(--ink)" }}>
公告列表
</span>
<span className="meta text-[12px] tabular-nums">共 {list.length} 条</span>
<span className="meta text-[12px] tabular-nums">共 {filtered.length} 条</span>
</div>
{loadError ? (
<p className="alert-error m-4">{loadError}</p>
) : list.length === 0 ? (
) : filtered.length === 0 ? (
<AdminEmpty
icon={<Megaphone size={22} />}
title="还没有公告"
description="点击「新建公告」发布第一条吧"
title={list.length === 0 ? "还没有公告" : "该状态下暂无公告"}
description={list.length === 0 ? "发布第一条站点公告,会出现在首页右栏" : "试试切换筛选条件"}
/>
) : (
list.map((a) => (
<div
key={a.id}
className="group flex items-center gap-3 px-4 sm:px-5 py-3.5"
style={{ borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
{!a.published && (
<span
className="inline-flex items-center rounded-full px-2 py-px text-[10.5px] leading-[1.55]"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
草稿
</span>
)}
<span className="meta text-[11px] tabular-nums">{formatDate(a.created_at)}</span>
filtered.map((a, idx) => {
const active = selectedId === a.id || form?.id === a.id;
const summary = plainSummary(a.content);
return (
<div
key={a.id}
role="button"
tabIndex={0}
onClick={() => selectRow(a)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
selectRow(a);
}
}}
className="px-4 sm:px-5 py-3.5 cursor-pointer outline-none focus-visible:ring-2 focus-visible:ring-[var(--accent)]"
style={{
borderTop: idx === 0 ? undefined : "1px solid var(--line)",
background: active ? "var(--accent-soft)" : undefined,
}}
>
<div className="flex items-start gap-2 min-w-0">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<p
className="text-[14px] font-medium truncate max-w-full"
style={{ color: "var(--ink)" }}
data-tip={a.title}
>
{a.title}
</p>
<AdminStatusChip tone={a.published ? "ok" : "warn"}>
{a.published ? "已发布" : "草稿"}
</AdminStatusChip>
<span className="meta text-[11px] tabular-nums shrink-0">
{formatRelative(a.updated_at || a.created_at)}
</span>
<span
className="inline-flex items-center gap-0.5 shrink-0"
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
{a.published ? (
<AdminIconButton
href={`/announcement/${a.id}`}
tip="查看"
ariaLabel="查看公告"
>
<ExternalLink size={14} />
</AdminIconButton>
) : null}
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
<Pencil size={14} />
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除公告"
tone="danger"
disabled={deletingId === a.id}
onClick={() => setDeleteTarget(a)}
>
{deletingId === a.id ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Trash2 size={14} />
)}
</AdminIconButton>
</span>
</div>
{summary ? (
<p className="meta text-[12.5px] mt-1 line-clamp-1">{summary}</p>
) : null}
</div>
</div>
<p
className="mt-1 truncate text-[14px]"
style={{ color: "var(--ink)" }}
data-tip={a.title}
>
{a.title}
</p>
</div>
<div className="flex items-center gap-1 shrink-0">
{a.published && (
<AdminIconButton
href={`/announcement/${a.id}`}
tip="查看"
ariaLabel="查看公告"
>
<ExternalLink size={15} />
</AdminIconButton>
)}
<AdminIconButton tip="编辑" ariaLabel="编辑公告" onClick={() => startEdit(a)}>
<Pencil size={15} />
</AdminIconButton>
<AdminIconButton
tip="删除"
ariaLabel="删除公告"
tone="danger"
disabled={deletingId === a.id}
onClick={() => setDeleteTarget(a)}
>
{deletingId === a.id ? <Loader2 size={15} className="animate-spin" /> : <Trash2 size={15} />}
</AdminIconButton>
</div>
);
})
)}
{list.length === 0 && !loadError ? (
<div className="px-4 sm:px-5 pb-5 -mt-4 flex justify-center">
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建第一条公告
</button>
</div>
) : null}
</div>
{/* 右侧:表单 / 预览 / 占位 */}
<div className="min-w-0 lg:sticky lg:top-4">
{form ? (
<AnnouncementForm
form={form}
setForm={setForm}
saving={saving}
onSave={() => void save()}
onCancel={() => setForm(null)}
/>
) : selected ? (
<AnnouncementPreview a={selected} />
) : (
<div className="panel p-8">
<AdminEmpty
icon={<Megaphone size={22} />}
title="选择或新建公告"
description="在左侧点选一条查看预览,或新建公告"
/>
<div className="flex justify-center -mt-4 pb-2">
<button type="button" onClick={startCreate} className="btn btn-primary">
<Plus size={15} /> 新建公告
</button>
</div>
))
</div>
)}
</div>
</div>
{/* 移动端编辑时:若只在右侧显示表单,列表仍在上方可见,符合双栏降级 */}
<ConfirmDialog
open={!!deleteTarget}
title="删除公告"

View File

@@ -36,7 +36,6 @@ import {
export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) {
const router = useRouter();
const initialAccent = initial.accent || "";
// 已保存值:空串=内置默认(靛蓝)
const [savedHex, setSavedHex] = useState(initialAccent);
const [draft, setDraft] = useState(initialAccent || DEFAULT_ACCENT);
const [codeAutoFold, setCodeAutoFold] = useState(initial.code_block_auto_fold !== false);
@@ -62,12 +61,10 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
[normalized]
);
const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]);
// 保存提交值:默认靛蓝落库为空串(前端不注入覆盖,保持内置令牌单一来源)
const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : "";
const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || "");
const invalid = draft.trim() !== "" && !normalized;
// 实时预览:合法颜色即应用;离开页面时还原为已保存值,避免未保存预览残留
useEffect(() => {
if (normalized) applyAccentTheme(normalized);
return () => {
@@ -166,305 +163,311 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings }
};
return (
<div className="max-w-3xl">
<div>
<AdminPageHeader
icon={Palette}
title="外观设置"
description="配置站点主题色、代码块折叠与界面动画"
/>
<div className="flex flex-col gap-4 mb-6">
<AdminSection
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标"
>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
</AdminSection>
<AdminSection
title={
<span className="inline-flex items-center gap-1.5">
<Pipette size={15} /> 自定义颜色
</span>
}
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正,保证按钮与文字可读"
>
<div className="flex flex-wrap items-center gap-3">
<label
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
data-tip="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
<div className="flex flex-col gap-1">
<div
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
</div>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.light, borderColor: "var(--line)" }}
/>
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.dark, borderColor: "var(--line)" }}
/>
暗色 {pair.dark}
</span>
</div>
)}
</div>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
{pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</AdminSection>
<AdminSection title="实时预览">
<div
className="rounded-[var(--r-sm)] border p-4 space-y-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
<div className="grid grid-cols-1 xl:grid-cols-3 gap-4 mb-6">
{/* 主题色列 */}
<div className="flex flex-col gap-4 xl:col-span-2">
<AdminSection
title="预设主题色"
description="浅/暗两套色板均已调校,保证文字对比度达标"
>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">
次要按钮
</button>
<a
className="text-[13px]"
style={{ color: "var(--accent)" }}
href="#preview"
onClick={(e) => e.preventDefault()}
>
文字链接示例
</a>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-3">
{ACCENT_PRESETS.map((p) => {
const active = normalized === p.light;
const isDefault = p.light === DEFAULT_ACCENT;
return (
<button
key={p.key}
type="button"
onClick={() => setDraft(p.light)}
className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors"
style={{
borderColor: active ? "var(--accent)" : "var(--line)",
background: active ? "var(--accent-soft)" : "var(--panel)",
cursor: "pointer",
}}
aria-pressed={active}
data-tip={isDefault ? `${p.name}(默认)` : p.name}
>
<span className="relative flex items-center justify-center">
<span className="w-9 h-9 rounded-full overflow-hidden grid grid-rows-2 border-2 border-[var(--panel)] shadow-sm">
<span style={{ background: p.light }} />
<span style={{ background: p.dark }} />
</span>
{active && (
<span
className="absolute -bottom-1 -right-1 min-w-[18px] min-h-[18px] w-[18px] h-[18px] rounded-full flex items-center justify-center"
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
>
<Check size={11} strokeWidth={3} />
</span>
)}
</span>
<span
className="text-[12px] leading-none"
style={{ color: active ? "var(--accent)" : "var(--ink-2)" }}
>
{p.name}
</span>
</button>
);
})}
</div>
<div className="flex flex-wrap items-center gap-2">
<span className="admin-seg">未选中</span>
<span className="admin-seg" aria-selected="true">
选中标签
</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
</div>
</div>
</div>
</AdminSection>
</AdminSection>
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="开启后,帖子与评论中超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。关闭则始终完整展示。"
control={
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
}
>
{codeAutoFold ? (
<>
<label className="field-label" htmlFor="fold-lines">
折叠行数阈值
<AdminSection
title={
<span className="inline-flex items-center gap-1.5">
<Pipette size={15} /> 自定义颜色
</span>
}
description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正"
>
<div className="flex flex-wrap items-center gap-3">
<label
className="relative w-11 h-11 rounded-[var(--r-sm)] overflow-hidden border cursor-pointer shrink-0"
style={{ borderColor: "var(--line-2)" }}
data-tip="打开取色器"
>
<input
type="color"
className="absolute inset-0 w-[200%] h-[200%] -translate-x-1/4 -translate-y-1/4 cursor-pointer border-0 p-0 bg-transparent"
value={normalized || DEFAULT_ACCENT}
onChange={(e) => setDraft(e.target.value)}
aria-label="自定义主题色取色器"
/>
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
代码块行数超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<AdminStepper
id="fold-lines"
value={foldLines}
onChange={setFoldLines}
min={0}
max={100}
unit="行"
ariaLabel="折叠行数阈值"
disabled={savingFoldLines}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingFoldLines}
onClick={() => void saveFoldLines()}
<div className="flex flex-col gap-1">
<div
className={`admin-field-shell px-3 h-11 font-mono text-[14px] w-40${invalid ? " is-invalid" : ""}`}
>
{savingFoldLines ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
<span style={{ color: "var(--ink-3)" }}>#</span>
<input
value={draft.startsWith("#") ? draft.slice(1) : draft}
onChange={(e) => setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))}
className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase"
maxLength={7}
spellCheck={false}
aria-label="主题色 HEX 值"
/>
</div>
</div>
</>
) : null}
</AdminSettingRow>
{pair && (
<div className="flex items-center gap-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.light, borderColor: "var(--line)" }}
/>
浅色 {pair.light}
</span>
<span className="inline-flex items-center gap-1.5">
<span
className="w-3.5 h-3.5 rounded-full border"
style={{ background: pair.dark, borderColor: "var(--line)" }}
/>
暗色 {pair.dark}
</span>
</div>
)}
</div>
{invalid && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--danger)" }}>
颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)
</p>
)}
{pair?.adjusted && (
<p className="mt-2.5 text-[12.5px]" style={{ color: "var(--gold)" }}>
该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "}
{pair.dark})
</p>
)}
{preset && preset.light === DEFAULT_ACCENT && (
<p className="mt-2.5 text-[12.5px] meta">当前为内置默认色,保存时会清除自定义配置</p>
)}
</AdminSection>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换(等同减少动态效果)。系统若已开启「减少动态效果」,始终优先生效。开启总开关后可细调子项。"
control={
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
}
>
{uiAnimations ? (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
代码折叠动画
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
代码块展开/收起的高度过渡与跟滚。
</p>
</div>
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
<AdminSection title="实时预览">
<div
className="rounded-[var(--r-sm)] border p-4 space-y-4"
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-2.5">
<button type="button" className="btn btn-primary btn-sm">
<Save size={14} /> 主按钮
</button>
<button type="button" className="btn btn-line btn-sm">
次要按钮
</button>
<a
className="text-[13px]"
style={{ color: "var(--accent)" }}
href="#preview"
onClick={(e) => e.preventDefault()}
>
文字链接示例
</a>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
平滑滚动
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
目录跳转、回顶、新内容提示等 scroll 行为。
</p>
</div>
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
<div className="flex flex-wrap items-center gap-2">
<span className="admin-seg">未选中</span>
<span className="admin-seg" aria-selected="true">
选中标签
</span>
<span
className="inline-flex items-center rounded-full px-3.5 py-2 text-[13.5px]"
style={{ background: "var(--nav-active-bg)", color: "var(--nav-active-ink)" }}
>
导航选中态
</span>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
装饰入场
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
</p>
<div className="max-w-xs">
<div className="j13-bcard" aria-current="page">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Palette size={16} />
</span>
<span className="j13-bcard-name text-[13px] flex-1">选中的板块卡片</span>
</div>
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</AdminSettingRow>
</AdminSection>
</div>
{/* 代码折叠 + 动画列 */}
<div className="flex flex-col gap-4">
<AdminSettingRow
title={
<>
<Code2 size={15} /> 代码块自动折叠
</>
}
description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。"
control={
<AdminSwitch
checked={codeAutoFold}
disabled={togglingFold}
onChange={() => void toggleCodeFold()}
label="代码块自动折叠"
/>
}
>
{codeAutoFold ? (
<>
<label className="field-label" htmlFor="fold-lines">
折叠行数阈值
</label>
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">
超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。
</p>
<div className="flex flex-wrap items-center gap-2.5">
<AdminStepper
id="fold-lines"
value={foldLines}
onChange={setFoldLines}
min={0}
max={100}
unit="行"
ariaLabel="折叠行数阈值"
disabled={savingFoldLines}
/>
<button
type="button"
className="btn btn-line btn-sm"
disabled={savingFoldLines}
onClick={() => void saveFoldLines()}
>
{savingFoldLines ? (
<Loader2 size={14} className="animate-spin" />
) : (
<Save size={14} />
)}
保存
</button>
</div>
</>
) : null}
</AdminSettingRow>
<AdminSettingRow
title={
<>
<Sparkles size={15} /> 界面动画
</>
}
description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。"
control={
<AdminSwitch
checked={uiAnimations}
disabled={togglingMotion}
onChange={() => void toggleMotionField("ui_animations", !uiAnimations)}
label="界面动画总开关"
/>
}
>
{uiAnimations ? (
<div className="space-y-4">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
代码折叠动画
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
代码块展开/收起的高度过渡与跟滚。
</p>
</div>
<AdminSwitch
checked={animCodeFold}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_code_fold", !animCodeFold)}
label="代码折叠动画"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
平滑滚动
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
目录跳转、回顶、新内容提示等 scroll 行为。
</p>
</div>
<AdminSwitch
checked={animSmoothScroll}
disabled={togglingMotion}
onChange={() =>
void toggleMotionField("anim_smooth_scroll", !animSmoothScroll)
}
label="平滑滚动"
/>
</div>
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0 flex-1">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
装饰入场
</p>
<p className="meta text-[12.5px] mt-0.5 leading-relaxed">
列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。
</p>
</div>
<AdminSwitch
checked={animChrome}
disabled={togglingMotion}
onChange={() => void toggleMotionField("anim_chrome", !animChrome)}
label="装饰入场"
/>
</div>
</div>
) : null}
</AdminSettingRow>
</div>
</div>
<div className="flex items-center justify-between gap-3 sticky bottom-5 panel !p-3.5 pl-5 z-10">

File diff suppressed because it is too large Load Diff

View File

@@ -25,7 +25,8 @@ function parseStatus(v?: string): AdminContentStatus {
}
function parseKind(v?: string): ContentKind {
return v === "comments" ? "comments" : "posts";
if (v === "comments") return "comments";
return "posts";
}
interface ContentPageProps {
@@ -48,35 +49,37 @@ export default async function AdminContentPage({ searchParams }: ContentPageProp
let initialComments: AdminContentComment[] = [];
let initialCommentsTotal = 0;
let initError = "";
const [listSettled, counts] = await Promise.all([
initialKind === "comments"
? fetchAdminContentComments(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, kind: "comments" as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取内容列表失败",
}))
: fetchAdminContentPosts(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, kind: "posts" as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取内容列表失败",
})),
fetchAdminContentCounts(cookie || undefined),
]);
if (listSettled.ok) {
if (listSettled.kind === "posts") {
initialPosts = listSettled.res.posts;
initialPostsTotal = listSettled.res.total;
} else {
initialComments = listSettled.res.comments;
initialCommentsTotal = listSettled.res.total;
}
} else {
initError = listSettled.error;
}
const settings = await getPublicSettingsCached();
// PC 双栏同时展示:SSR 并行预取帖子与评论
const [postsSettled, commentsSettled, counts, settings] = await Promise.all([
fetchAdminContentPosts(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取帖子失败",
})),
fetchAdminContentComments(1, initialStatus, cookie || undefined)
.then((res) => ({ ok: true as const, res }))
.catch((e) => ({
ok: false as const,
error: e instanceof Error ? e.message : "获取评论失败",
})),
fetchAdminContentCounts(cookie || undefined),
getPublicSettingsCached(),
]);
if (postsSettled.ok) {
initialPosts = postsSettled.res.posts;
initialPostsTotal = postsSettled.res.total;
} else {
initError = postsSettled.error;
}
if (commentsSettled.ok) {
initialComments = commentsSettled.res.comments;
initialCommentsTotal = commentsSettled.res.total;
} else if (!initError) {
initError = commentsSettled.error;
}
return (
<ContentAdmin

View File

@@ -0,0 +1,170 @@
"use client";
import Link from "next/link";
import { Coins, Flame, Gift, TrendingUp } from "lucide-react";
import type { EconomyStats } from "@/lib/api";
import { postTypeLabel } from "@/lib/postMeta";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import {
AdminChartCard,
DeltaBarChart,
NamedBarChart,
NamedPieChart,
SingleAreaChart,
} from "@/components/admin/charts";
const fmt = (n: number) => n.toLocaleString("zh-CN");
function netDeltaLabel(n: number) {
if (n > 0) return `+${fmt(n)}`;
return fmt(n);
}
export default function EconomyClient({ stats }: { stats: EconomyStats }) {
const checkinSeries = (stats.checkin_series || []).map((d) => ({
date: d.date,
count: d.count,
}));
const ledgerByReason = (stats.ledger_by_reason || []).map((r) => ({
label: pointReasonLabel(r.key || r.label),
count: r.count,
}));
const deltaSeries = stats.delta_series || [];
const postsByType = Object.entries(stats.posts_by_type || {})
.map(([t, n]) => ({ label: postTypeLabel(t), count: Number(n) || 0 }))
.filter((d) => d.count > 0)
.sort((a, b) => b.count - a.count);
const net = stats.net_delta_7d ?? 0;
return (
<div>
<AdminPageHeader
icon={Coins}
title="积分与类型帖看板"
description="观察全站积分流通、悬赏托管与帖子类型分布"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-5">
<AdminStatCard
label="用户余额合计"
value={fmt(stats.total_balance)}
hint="全站可用积分之和"
icon={<Coins size={16} />}
/>
<AdminStatCard
label="今日签到"
value={fmt(stats.checkin_today)}
hint="今日已签到人数"
icon={<Flame size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<AdminStatCard
label="托管中悬赏"
value={`${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`}
hint="未结算帖数 / 托管积分"
icon={<Gift size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="近 7 日净变动"
value={netDeltaLabel(net)}
hint="发放减去消耗"
icon={<TrendingUp size={16} />}
color={net >= 0 ? "var(--ok)" : "var(--danger)"}
soft={net >= 0 ? "var(--ok-soft)" : "var(--danger-soft)"}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 mb-4">
<AdminChartCard title="签到趋势" hint="近 14 日每日签到人数">
{checkinSeries.length === 0 ? (
<p className="meta text-center py-16">暂无签到数据</p>
) : (
<SingleAreaChart
data={checkinSeries}
dataKey="count"
name="签到"
color="var(--clay)"
/>
)}
</AdminChartCard>
<AdminChartCard title="流水原因分布" hint="按入账原因聚合条数">
{ledgerByReason.length === 0 ? (
<p className="meta text-center py-16">暂无流水</p>
) : (
<NamedBarChart data={ledgerByReason} layout="horizontal" />
)}
</AdminChartCard>
<AdminChartCard title="发放 / 消耗" hint="近 14 日积分进出">
{deltaSeries.length === 0 ? (
<p className="meta text-center py-16">暂无数据</p>
) : (
<DeltaBarChart data={deltaSeries} />
)}
</AdminChartCard>
<AdminChartCard title="帖子类型分布" hint="全站帖子按类型占比">
{postsByType.length === 0 ? (
<p className="meta text-center py-16">暂无数据</p>
) : (
<NamedPieChart data={postsByType} />
)}
</AdminChartCard>
</div>
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-medium text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
最近流水
</div>
{(stats.recent_ledger || []).length === 0 ? (
<p className="meta text-center py-10">暂无流水</p>
) : (
<ul>
{stats.recent_ledger.map((it, idx) => (
<li
key={it.id}
className="px-5 py-3 flex items-start justify-between gap-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{pointReasonLabel(it.reason)}
{it.note ? ` · ${it.note}` : ""}
</p>
<p className="meta text-[12px] mt-0.5">{formatRelative(it.created_at)}</p>
</div>
<span
className="tabular-nums font-medium shrink-0"
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
>
{it.delta >= 0 ? `+${it.delta}` : it.delta}
</span>
</li>
))}
</ul>
)}
</section>
<p className="meta text-[12.5px] mt-5">
用户可在{" "}
<Link href="/points" className="hover:text-[var(--accent)]">
/points
</Link>{" "}
查看个人流水。
</p>
</div>
);
}

View File

@@ -1,23 +1,16 @@
import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { Coins, Flame, Gift, Layers } from "lucide-react";
import { fetchAdminEconomyStats } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { postTypeLabel } from "@/lib/postMeta";
import { pointReasonLabel } from "@/lib/points";
import { formatRelative } from "@/lib/format";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import Forbidden from "../Forbidden";
import EconomyClient from "./EconomyClient";
export const metadata: Metadata = {
title: "积分看板",
};
const fmt = (n: number) => n.toLocaleString("zh-CN");
export default async function AdminEconomyPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
@@ -40,121 +33,5 @@ export default async function AdminEconomyPage() {
);
}
const typeEntries = Object.entries(stats.posts_by_type || {}).sort(
(a, b) => b[1] - a[1]
);
return (
<div>
<AdminPageHeader
icon={Coins}
title="积分与类型帖看板"
description="观察全站积分流通、悬赏托管与帖子类型分布"
/>
<div className="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-3 mb-5">
<AdminStatCard
label="用户余额合计"
value={fmt(stats.total_balance)}
hint="全站可用积分之和"
icon={<Coins size={16} />}
/>
<AdminStatCard
label="今日签到"
value={fmt(stats.checkin_today)}
hint="今日已签到人数"
icon={<Flame size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
<AdminStatCard
label="托管中悬赏"
value={`${fmt(stats.open_bounties)} / ${fmt(stats.escrowed_points)}`}
hint="未结算帖数 / 托管积分"
icon={<Gift size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="流水条数"
value={fmt(stats.ledger_count)}
hint="历史入账出账记录"
icon={<Layers size={16} />}
/>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-medium text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
帖子类型分布
</div>
{typeEntries.length === 0 ? (
<p className="meta text-center py-10">暂无数据</p>
) : (
<ul>
{typeEntries.map(([t, n], idx) => (
<li
key={t}
className="px-5 py-3 flex items-center justify-between"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<span className="text-[14px]" style={{ color: "var(--ink)" }}>
{postTypeLabel(t)}
</span>
<span className="tabular-nums meta">{fmt(n)}</span>
</li>
))}
</ul>
)}
</section>
<section className="panel overflow-hidden">
<div
className="px-5 py-3.5 font-medium text-[15px]"
style={{ borderBottom: "1px solid var(--line)", color: "var(--ink)" }}
>
最近流水
</div>
{(stats.recent_ledger || []).length === 0 ? (
<p className="meta text-center py-10">暂无流水</p>
) : (
<ul>
{stats.recent_ledger.map((it, idx) => (
<li
key={it.id}
className="px-5 py-3 flex items-start justify-between gap-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<div className="min-w-0">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{pointReasonLabel(it.reason)}
{it.note ? ` · ${it.note}` : ""}
</p>
<p className="meta text-[12px] mt-0.5">{formatRelative(it.created_at)}</p>
</div>
<span
className="tabular-nums font-medium shrink-0"
style={{ color: it.delta >= 0 ? "var(--accent)" : "var(--danger)" }}
>
{it.delta >= 0 ? `+${it.delta}` : it.delta}
</span>
</li>
))}
</ul>
)}
</section>
</div>
<p className="meta text-[12.5px] mt-5">
用户可在{" "}
<Link href="/points" className="hover:text-[var(--accent)]">
/points
</Link>{" "}
查看个人流水。
</p>
</div>
);
return <EconomyClient stats={stats} />;
}

View File

@@ -2,10 +2,10 @@ import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { authCookieHeader, TOKEN_COOKIE } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import { AdminSidebar, AdminNavMobile } from "./AdminNav";
import AdminShell from "@/components/admin/AdminShell";
// 整个 /admin 树不进入索引(子页面无需重复声明)
export const metadata: Metadata = {
@@ -15,42 +15,51 @@ export const metadata: Metadata = {
// 权限以 Go 端 RequireStaff 为唯一判定;这里仅决定管理界面是否直出,
// 非管理团队访问任意 /admin/* 时 SSR 统一给出无权限提示(所有写接口仍由后端 403 兜底)。
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const cookie = authCookieHeader(await cookies());
const cookieStore = await cookies();
const cookie = authCookieHeader(cookieStore);
const me = await getMeCached(cookie || undefined);
const settings = await getPublicSettingsCached();
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
if (!me.user || !isStaff(me.user.role)) {
return (
<div className="max-w-md mx-auto panel p-8 text-center mt-6 lg:mt-10">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
<div
data-admin-viewport
id="main"
tabIndex={-1}
className="min-h-screen flex items-center justify-center px-4 outline-none"
style={{ background: "var(--bg)" }}
>
<div className="max-w-md w-full panel p-8 text-center">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--gold-soft)", color: "var(--gold)" }}
>
<ShieldAlert size={26} />
</span>
<h1 className="text-lg font-extrabold" style={{ color: "var(--ink)" }}>
需要管理员权限
</h1>
<p className="meta mt-2 text-[13px]">此区域仅供站点管理员访问,如有疑问请联系站长。</p>
<Link href="/" className="btn btn-primary mt-6">
返回首页
</Link>
</div>
</div>
);
}
// 未读仅在有 access cookie 时才可信;壳层铃铛用 SSR 直出避免挂载后才出红点
const unread = cookieStore.get(TOKEN_COOKIE)?.value ? me.unread_count : 0;
return (
<div>
<AdminNavMobile user={me.user} />
<div className="flex items-start gap-6 xl:gap-7">
{/* sticky 必须钉在 aside 自身:内层与侧栏同高时等于没钉住,会跟着主栏一起滚 */}
<aside
className="hidden lg:block w-60 xl:w-64 shrink-0 sticky self-start z-20 max-h-[calc(100vh-6.5rem)] overflow-y-auto"
style={{ top: 84 }}
>
<AdminSidebar user={me.user} />
</aside>
<section className="flex-1 min-w-0">{children}</section>
</div>
</div>
<AdminShell
user={me.user}
siteName={settings.site_name || "姜十三论坛"}
initialTheme={theme}
initialUnread={unread}
>
{children}
</AdminShell>
);
}

View File

@@ -0,0 +1,543 @@
"use client";
import {
useCallback,
useEffect,
useRef,
useState,
type CSSProperties,
type ReactNode,
} from "react";
import { Loader2, MessageCircle, MessagesSquare, Undo2, Users } from "lucide-react";
import {
apiAdminChatMessages,
apiAdminRecallChatMessage,
type AdminChatMessage,
type AdminChatRoomType,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminStatusChip,
} from "@/components/admin";
const PAGE_SIZE = 15;
const CLAMP_TOGGLE_CHARS = 90;
const pagerClass = "admin-content-col-pager px-3 sm:px-3.5";
type MsgKind = AdminChatRoomType;
function needsClampToggle(text: string): boolean {
if (text.length > CLAMP_TOGGLE_CHARS) return true;
return (text.match(/\n/g) || []).length >= 2;
}
function ClampText({
text,
className = "",
style,
}: {
text: string;
className?: string;
style?: CSSProperties;
}) {
const [open, setOpen] = useState(false);
const showToggle = needsClampToggle(text);
const body = text || "(空)";
return (
<div className={className}>
<p
className={`text-[12.5px] leading-relaxed whitespace-pre-wrap break-words admin-content-clamp${open ? " is-open" : ""}`}
style={style}
>
<AutolinkText text={body} forceNewTab />
</p>
{showToggle ? (
<button
type="button"
className="meta text-[11.5px] mt-0.5 hover:underline"
style={{ color: "var(--accent)" }}
onClick={() => setOpen((v) => !v)}
>
{open ? "收起" : "展开"}
</button>
) : null}
</div>
);
}
function ColumnShell({
icon,
title,
count,
toolbar,
body,
pager,
className = "",
}: {
icon: ReactNode;
title: string;
count?: number;
toolbar?: ReactNode;
body: ReactNode;
pager?: ReactNode;
className?: string;
}) {
return (
<section className={`panel admin-content-col ${className}`.trim()}>
<header
className="admin-content-col-head flex items-center gap-2 px-3.5 sm:px-4 py-3"
style={{ borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
>
<span className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden>
{icon}
</span>
<h2 className="text-[14px] font-semibold truncate" style={{ color: "var(--ink)" }}>
{title}
</h2>
{typeof count === "number" ? (
<span className="meta text-[12px] tabular-nums shrink-0">{count}</span>
) : null}
</header>
{toolbar}
<div className="admin-content-col-body">{body}</div>
{pager}
</section>
);
}
type DmPeer = { id: number; name: string; avatar: string };
/** 解析私聊:发送者在前、接收者在后 */
function resolveDmPair(m: AdminChatMessage): { sender: DmPeer; peer: DmPeer | null } {
const sender: DmPeer = {
id: m.sender_id,
name: m.sender_name || "?",
avatar: m.sender_avatar || "",
};
const fromParts = m.participants?.find((p) => p.id === m.sender_id);
if (fromParts) {
sender.name = fromParts.name || sender.name;
sender.avatar = fromParts.avatar || sender.avatar;
}
const peer =
m.participants?.find((p) => p.id !== m.sender_id) ||
m.participants?.find((p) => p.name !== sender.name) ||
null;
return {
sender,
peer: peer
? { id: peer.id, name: peer.name || "?", avatar: peer.avatar || "" }
: null,
};
}
/** 私聊方向条:头像贴在各自名字左侧,发送者在前 */
function DmDirection({
sender,
peer,
}: {
sender: DmPeer;
peer: DmPeer | null;
}) {
return (
<span className="admin-dm-pair-names">
<span className="admin-dm-person">
<Avatar name={sender.name} src={sender.avatar || undefined} size={22} />
<span className="admin-dm-person-name" style={{ color: "var(--ink)" }} data-tip={sender.name}>
{sender.name}
</span>
</span>
{peer ? (
<>
<span className="admin-dm-arrow" aria-hidden>
→
</span>
<span className="admin-dm-person">
<Avatar name={peer.name} src={peer.avatar || undefined} size={22} />
<span className="admin-dm-person-name" style={{ color: "var(--ink-2)" }} data-tip={peer.name}>
{peer.name}
</span>
</span>
</>
) : null}
</span>
);
}
function RecallButton({
busy,
onClick,
}: {
busy: boolean;
onClick: () => void;
}) {
return (
<button type="button" className="admin-content-action is-danger" disabled={busy} onClick={onClick}>
{busy ? <Loader2 size={13} className="animate-spin" /> : <Undo2 size={13} />}
撤回
</button>
);
}
function useMessageColumn(roomType: MsgKind) {
const [messages, setMessages] = useState<AdminChatMessage[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [appliedQ, setAppliedQ] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const seq = useRef(0);
const load = useCallback(
async (p: number, q: string) => {
const s = ++seq.current;
setLoading(true);
try {
const res = await apiAdminChatMessages(p, q, roomType);
if (s !== seq.current) return;
setMessages(Array.isArray(res.messages) ? res.messages : []);
setTotal(typeof res.total === "number" ? res.total : 0);
setPage(res.page || p);
setAppliedQ(q);
setError("");
} catch (e) {
if (s !== seq.current) return;
setError(e instanceof Error ? e.message : "获取消息失败");
} finally {
if (s === seq.current) setLoading(false);
}
},
[roomType]
);
useEffect(() => {
void load(1, "");
return () => {
seq.current += 1;
};
}, [load]);
const markRecalled = (id: number) => {
setMessages((prev) =>
prev.map((x) => (x.id === id ? { ...x, recalled_at: new Date().toISOString() } : x))
);
};
return {
messages,
total,
page,
keyword,
setKeyword,
appliedQ,
loading,
error,
load,
markRecalled,
};
}
/** 群聊(含大厅)| 私聊 双栏监管 */
export default function MessagesAdmin() {
const [mobileKind, setMobileKind] = useState<MsgKind>("group");
const group = useMessageColumn("group");
const direct = useMessageColumn("direct");
const [actingKey, setActingKey] = useState<string | null>(null);
const [confirm, setConfirm] = useState<{
title: string;
message: string;
run: () => Promise<void>;
} | null>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
const askRecall = (m: AdminChatMessage, markRecalled: (id: number) => void) => {
const key = `m-${m.id}`;
setConfirm({
title: "撤回这条消息?",
message: "撤回后会话中将显示为已撤回,此操作不可撤销。",
run: async () => {
setActingKey(key);
try {
await apiAdminRecallChatMessage(m.room_id, m.id);
toast("消息已撤回", "ok");
markRecalled(m.id);
} finally {
setActingKey(null);
}
},
});
};
const groupToolbar = (
<div className="admin-content-col-toolbar">
<p className="meta text-[11.5px] leading-snug">含全站大厅与普通群聊</p>
<AdminSearchField
value={group.keyword}
onChange={group.setKeyword}
onSubmit={() => void group.load(1, group.keyword.trim())}
onClear={() => {
group.setKeyword("");
void group.load(1, "");
}}
placeholder="搜索内容、群名或用户…"
ariaLabel="搜索群聊消息"
/>
</div>
);
const directToolbar = (
<div className="admin-content-col-toolbar">
<p className="meta text-[11.5px] leading-snug">一对一私信;按双方会话归档</p>
<AdminSearchField
value={direct.keyword}
onChange={direct.setKeyword}
onSubmit={() => void direct.load(1, direct.keyword.trim())}
onClear={() => {
direct.setKeyword("");
void direct.load(1, "");
}}
placeholder="搜索内容或参与者…"
ariaLabel="搜索私聊消息"
/>
</div>
);
const groupBody = (
<>
{group.error ? <p className="alert-error m-3 text-[12.5px]">{group.error}</p> : null}
{group.loading && group.messages.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : group.messages.length === 0 ? (
<AdminEmpty
icon={<Users size={20} />}
title="暂无群聊消息"
description="大厅与群聊消息会按时间出现在这里"
/>
) : (
group.messages.map((m, idx) => {
const key = `m-${m.id}`;
const busy = actingKey === key;
const recalled = !!m.recalled_at;
return (
<div
key={m.id}
className="flex items-start gap-2.5 px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<Avatar
name={m.sender_name || "?"}
src={m.sender_avatar || undefined}
size={30}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5 flex-wrap min-w-0">
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{m.sender_name || "?"}
</span>
{recalled ? <AdminStatusChip tone="muted">已撤回</AdminStatusChip> : null}
{m.is_default ? (
<AdminStatusChip tone="muted">大厅</AdminStatusChip>
) : (
<span className="meta text-[11px] truncate max-w-[10rem]" data-tip={m.room_name}>
{m.room_name}
</span>
)}
<span className="meta text-[11px] tabular-nums">
{formatRelative(m.created_at)}
</span>
</div>
<ClampText
text={m.content || "(空消息)"}
className="mt-1"
style={{
color: recalled ? "var(--ink-3)" : "var(--ink)",
textDecoration: recalled ? "line-through" : undefined,
}}
/>
{!recalled ? (
<div className="mt-1.5">
<RecallButton busy={busy} onClick={() => askRecall(m, group.markRecalled)} />
</div>
) : null}
</div>
</div>
);
})
)}
</>
);
const directBody = (
<>
{direct.error ? <p className="alert-error m-3 text-[12.5px]">{direct.error}</p> : null}
{direct.loading && direct.messages.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : direct.messages.length === 0 ? (
<AdminEmpty
icon={<MessageCircle size={20} />}
title="暂无私聊消息"
description="用户一对一私信会出现在这里,可搜索参与者"
/>
) : (
direct.messages.map((m, idx) => {
const key = `m-${m.id}`;
const busy = actingKey === key;
const recalled = !!m.recalled_at;
const { sender, peer } = resolveDmPair(m);
return (
<div
key={m.id}
className="admin-dm-row px-3 sm:px-3.5 py-3"
style={idx === 0 ? undefined : { borderTop: "1px solid var(--line)" }}
>
<article className="admin-dm-card">
<header className="admin-dm-card-head">
<div className="admin-dm-pair-meta min-w-0">
<DmDirection sender={sender} peer={peer} />
{recalled ? <AdminStatusChip tone="muted">已撤回</AdminStatusChip> : null}
<span className="meta text-[11px] tabular-nums shrink-0">
{formatRelative(m.created_at)}
</span>
</div>
</header>
<div className="admin-dm-card-body">
<ClampText
text={m.content || "(空消息)"}
style={{
color: recalled ? "var(--ink-3)" : "var(--ink)",
textDecoration: recalled ? "line-through" : undefined,
}}
/>
{!recalled ? (
<div className="mt-1.5">
<RecallButton busy={busy} onClick={() => askRecall(m, direct.markRecalled)} />
</div>
) : null}
</div>
</article>
</div>
);
})
)}
</>
);
const groupCol = (
<ColumnShell
icon={<Users size={15} />}
title="群聊"
count={!group.loading || group.total > 0 ? group.total : undefined}
toolbar={groupToolbar}
body={groupBody}
pager={
<AdminPager
page={group.page}
total={group.total}
pageSize={PAGE_SIZE}
loading={group.loading}
onChange={(p) => void group.load(p, group.appliedQ)}
className={pagerClass}
/>
}
className={mobileKind === "group" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
const directCol = (
<ColumnShell
icon={<MessageCircle size={15} />}
title="私聊"
count={!direct.loading || direct.total > 0 ? direct.total : undefined}
toolbar={directToolbar}
body={directBody}
pager={
<AdminPager
page={direct.page}
total={direct.total}
pageSize={PAGE_SIZE}
loading={direct.loading}
onChange={(p) => void direct.load(p, direct.appliedQ)}
className={pagerClass}
/>
}
className={mobileKind === "direct" ? "max-lg:flex" : "max-lg:hidden"}
/>
);
return (
<div className="admin-content-page">
<AdminPageHeader
icon={MessagesSquare}
title="消息管理"
description="分栏监管群聊与私聊;可搜索并撤回违规内容"
/>
<div className="admin-content-kind lg:hidden shrink-0" role="tablist" aria-label="消息类型">
<button
type="button"
role="tab"
aria-selected={mobileKind === "group"}
className="admin-content-kind-btn"
onClick={() => setMobileKind("group")}
>
<Users size={14} aria-hidden />
群聊
<span className="admin-content-kind-count">{group.total}</span>
</button>
<button
type="button"
role="tab"
aria-selected={mobileKind === "direct"}
className="admin-content-kind-btn"
onClick={() => setMobileKind("direct")}
>
<MessageCircle size={14} aria-hidden />
私聊
<span className="admin-content-kind-count">{direct.total}</span>
</button>
</div>
<div className="admin-content-triptych">
{groupCol}
{directCol}
</div>
<ConfirmDialog
open={!!confirm}
title={confirm?.title || ""}
message={confirm?.message || ""}
danger
busy={confirmBusy}
confirmLabel="确认撤回"
onCancel={() => !confirmBusy && setConfirm(null)}
onConfirm={() => {
if (!confirm) return;
setConfirmBusy(true);
confirm
.run()
.catch((e) => toast(e instanceof Error ? e.message : "操作失败"))
.finally(() => {
setConfirmBusy(false);
setConfirm(null);
});
}}
/>
</div>
);
}

View File

@@ -0,0 +1,22 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import Forbidden from "../Forbidden";
import MessagesAdmin from "./MessagesAdmin";
export const metadata: Metadata = {
title: "消息管理",
};
/** 群聊(含大厅)与私聊分栏监管;鉴权与 staff API 一致 */
export default async function AdminMessagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isStaff(me.user.role)) {
return <Forbidden text="消息管理仅管理团队成员可进入。" />;
}
return <MessagesAdmin />;
}

View File

@@ -2,27 +2,28 @@ import type { Metadata } from "next";
import Link from "next/link";
import { cookies } from "next/headers";
import {
UserPlus,
LayoutGrid,
ClipboardCheck,
ChevronRight,
MessageSquareText,
Eye,
FileText,
Users,
MessageSquareText,
LayoutDashboard,
Coins,
Users,
Activity,
} from "lucide-react";
import {
fetchOverview,
fetchAdminPendingCounts,
fetchAdminAnalytics,
type PendingCounts,
type AnalyticsOverview,
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { canModerateAny, isSuperOrOwner, isAdminOrAbove } from "@/lib/roles";
import Avatar from "@/components/Avatar";
import { AdminPageHeader, AdminStatCard } from "@/components/admin";
import { formatRelative } from "@/lib/format";
import DashboardClient from "./DashboardClient";
export const metadata: Metadata = {
title: "管理仪表盘",
@@ -38,93 +39,76 @@ function greeting(hour: number) {
return "晚上好";
}
function PanelHead({ title, extra }: { title: string; extra?: string }) {
return (
<div
className="px-4 sm:px-5 py-3.5 flex items-center justify-between gap-2"
style={{ borderBottom: "1px solid var(--line)" }}
>
<span className="font-medium text-[15px]" style={{ color: "var(--ink)" }}>
{title}
</span>
{extra && (
<span className="meta text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{extra}
</span>
)}
</div>
);
}
/** 待办:待审帖子 / 评论,进入内容管理的待审筛选 */
function PendingWorkPanel({ counts }: { counts: PendingCounts }) {
/** 紧凑待审条:跳转内容管理待审筛选 */
function PendingStrip({ counts }: { counts: PendingCounts }) {
const total = counts.posts + counts.comments;
const idle = total === 0;
const href =
counts.posts > 0 || counts.comments === 0
? "/admin/content?status=pending"
: "/admin/content?status=pending&kind=comments";
return (
<Link
href={
counts.posts > 0 || counts.comments === 0
? "/admin/content?status=pending"
: "/admin/content?status=pending&kind=comments"
}
className="panel hover-lift relative block p-4 sm:p-5 mb-4 group"
href={href}
className="panel hover-lift mb-4 px-4 sm:px-5 py-3 flex items-center gap-3 group"
>
<div className="flex items-start gap-3.5">
<span
className="w-11 h-11 rounded-xl flex items-center justify-center shrink-0"
style={{
background: idle ? "var(--tint)" : "var(--ok-soft)",
color: idle ? "var(--ink-3)" : "var(--ok)",
}}
aria-hidden
>
<ClipboardCheck size={20} />
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{
background: idle ? "var(--tint)" : "var(--ok-soft)",
color: idle ? "var(--ink-3)" : "var(--ok)",
}}
aria-hidden
>
<ClipboardCheck size={18} />
</span>
<div className="min-w-0 flex-1 flex items-center gap-3 sm:gap-5 flex-wrap">
<span className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
待审内容
</span>
{idle ? (
<span className="admin-status is-muted">暂无待审</span>
) : (
<span className="admin-status is-ok tabular-nums">{fmt(total)} 项</span>
)}
<span className="inline-flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<FileText size={13} style={{ color: "var(--ink-3)" }} aria-hidden />
帖子
<strong className="tabular-nums font-bold" style={{ color: "var(--ink)" }}>
{fmt(counts.posts)}
</strong>
</span>
<span className="inline-flex items-center gap-1 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<MessageSquareText size={13} style={{ color: "var(--ink-3)" }} aria-hidden />
评论
<strong className="tabular-nums font-bold" style={{ color: "var(--ink)" }}>
{fmt(counts.comments)}
</strong>
</span>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
待审内容
</p>
{idle ? (
<span
className="admin-status is-muted"
>
暂无待审
</span>
) : (
<span className="admin-status is-ok tabular-nums">
{fmt(total)} 项待处理
</span>
)}
</div>
<div className="mt-3 flex flex-wrap gap-3 sm:gap-5">
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<FileText size={14} style={{ color: "var(--ink-3)" }} aria-hidden />
待审帖子
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
{fmt(counts.posts)}
</strong>
</span>
<span className="inline-flex items-center gap-1.5 text-[13px]" style={{ color: "var(--ink-2)" }}>
<MessageSquareText size={14} style={{ color: "var(--ink-3)" }} aria-hidden />
待审评论
<strong className="tabular-nums font-extrabold" style={{ color: "var(--ink)" }}>
{fmt(counts.comments)}
</strong>
</span>
</div>
</div>
<ChevronRight
size={18}
className="shrink-0 mt-1 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</div>
<ChevronRight
size={16}
className="shrink-0 transition-transform group-hover:translate-x-0.5"
style={{ color: "var(--ink-3)" }}
/>
</Link>
);
}
const emptyAnalytics = (): AnalyticsOverview => ({
range_days: 7,
today_pv: 0,
today_uv: 0,
visits: [],
posts_series: [],
comments_series: [],
registers_series: [],
boards: [],
post_types: [],
top_viewed: [],
});
export default async function AdminDashboardPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
@@ -132,11 +116,12 @@ export default async function AdminDashboardPage() {
const canUsers = isSuperOrOwner(me.user?.role);
const canEconomy = isAdminOrAbove(me.user?.role);
const [overview, pendingCounts] = await Promise.all([
const [overview, pendingCounts, analytics] = await Promise.all([
fetchOverview(cookie).catch(() => null),
canMod
? fetchAdminPendingCounts(cookie).catch(() => ({ posts: 0, comments: 0 }))
: Promise.resolve(null),
fetchAdminAnalytics(cookie, "7d").catch(() => emptyAnalytics()),
]);
const now = new Date();
@@ -148,10 +133,7 @@ export default async function AdminDashboardPage() {
}).format(now);
const stats = overview?.stats;
const boards = overview?.boards ?? [];
const newUsers = overview?.new_users ?? [];
const topBoards = [...boards].sort((a, b) => b.post_count - a.post_count).slice(0, 6);
const maxBoardCount = Math.max(1, ...topBoards.map((b) => b.post_count));
return (
<div>
@@ -159,138 +141,58 @@ export default async function AdminDashboardPage() {
icon={LayoutDashboard}
title={`${greeting(now.getHours())},${me.user?.nickname ?? ""}`}
description={`${dateLabel} · 站点管理`}
actions={
canEconomy ? (
<Link href="/admin/economy" className="btn btn-ghost text-[13px] gap-1.5">
<Coins size={15} aria-hidden />
积分看板
</Link>
) : undefined
}
/>
{canMod && pendingCounts && <PendingWorkPanel counts={pendingCounts} />}
{canEconomy && (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3 mb-4">
<Link href="/admin/economy" className="block hover-lift rounded-[var(--r)]">
<AdminStatCard
label="积分看板"
value="查看"
hint="余额合计、悬赏托管与流水"
icon={<Coins size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
</Link>
{stats && (
<AdminStatCard
label="注册成员"
value={fmt(stats.users)}
hint="全站用户总量"
icon={<Users size={16} />}
/>
)}
</div>
)}
<div className="grid lg:grid-cols-2 gap-4 items-start">
<div className="panel overflow-hidden">
<PanelHead
title="最新注册成员"
extra={stats ? `共 ${fmt(stats.users)} 人` : undefined}
/>
{newUsers.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无成员数据</p>
) : (
newUsers.slice(0, 6).map((u) => (
<Link
key={u.id}
href={`/u/${u.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<Avatar
name={u.nickname || u.username}
src={u.avatar || undefined}
size={34}
/>
<span className="min-w-0 flex-1 leading-tight">
<span className="block text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</span>
<span className="block text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</span>
</span>
<span
className="shrink-0 inline-flex items-center gap-1 text-[11.5px]"
style={{ color: "var(--ink-3)" }}
>
<UserPlus size={12} aria-hidden />
{formatRelative(u.created_at)}加入
</span>
</Link>
))
)}
{canUsers && (
<Link
href="/admin/users"
className="flex items-center justify-center gap-1.5 px-4 py-3 text-[13px] transition-colors hover:bg-[var(--panel-2)]"
style={{
borderTop: "1px solid var(--line)",
color: "var(--accent)",
}}
>
<Users size={14} aria-hidden />
前往用户权限
<ChevronRight size={14} aria-hidden />
</Link>
)}
</div>
<div className="panel overflow-hidden">
<PanelHead title="板块话题分布" extra={`${boards.length} 个板块`} />
{topBoards.length === 0 ? (
<p className="meta text-center py-14 text-[13px]">暂无板块数据</p>
) : (
topBoards.map((b) => (
<Link
key={b.id}
href={`/?board=${b.id}`}
className="flex items-center gap-3 px-4 sm:px-5 py-3 transition-colors hover:bg-[var(--panel-2)]"
style={{ borderTop: "1px solid var(--line)" }}
>
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--panel-2)", color: "var(--accent)" }}
aria-hidden
>
<LayoutGrid size={14} />
</span>
<span className="min-w-0 flex-1">
<span className="flex items-center justify-between gap-2">
<span className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{b.name}
</span>
<span
className="text-[12px] tabular-nums shrink-0"
style={{ color: "var(--ink-2)" }}
>
{fmt(b.post_count)}
</span>
</span>
<span
className="block mt-1.5 h-1 rounded-full overflow-hidden"
style={{ background: "var(--tint)" }}
aria-hidden
>
<span
className="block h-full rounded-full"
style={{
width: `${Math.max(4, Math.round((b.post_count / maxBoardCount) * 100))}%`,
background: "var(--accent)",
}}
/>
</span>
</span>
</Link>
))
)}
</div>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<AdminStatCard
label="今日 PV"
value={fmt(analytics.today_pv)}
hint={`独立访客 UV ${fmt(analytics.today_uv)}`}
icon={<Eye size={16} />}
/>
<AdminStatCard
label="今日 UV"
value={fmt(analytics.today_uv)}
hint="独立访客"
icon={<Users size={16} />}
color="var(--ok)"
soft="var(--ok-soft)"
/>
<AdminStatCard
label="今日内容"
value={fmt(stats?.today_posts ?? 0)}
sub="帖"
hint={`评论 ${fmt(stats?.today_comments ?? 0)} · 注册 ${fmt(stats?.today_users ?? 0)}`}
icon={<FileText size={16} />}
color="var(--gold)"
soft="var(--gold-soft)"
/>
<AdminStatCard
label="在线"
value={fmt(stats?.online ?? 0)}
hint={`峰值 ${fmt(stats?.peak_online ?? 0)}`}
icon={<Activity size={16} />}
color="var(--clay)"
soft="var(--clay-soft)"
/>
</div>
{canMod && pendingCounts && <PendingStrip counts={pendingCounts} />}
<DashboardClient
initialAnalytics={analytics}
newUsers={newUsers}
totalUsers={stats?.users}
canUsers={canUsers}
/>
</div>
);
}

View File

@@ -1,30 +1,149 @@
"use client";
import { Globe, Loader2, MessageSquare, Save, UserPlus } from "lucide-react";
import { useState } from "react";
import {
Globe,
Loader2,
MessageSquare,
Save,
UserPlus,
ExternalLink,
Shield,
Mail,
HardDrive,
Ban,
Wrench,
Palette,
FileText,
} from "lucide-react";
import { useEffect, useState, type ReactNode } from "react";
import {
AdminField,
AdminPageHeader,
AdminSection,
AdminSettingRow,
AdminSwitch,
} from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
import { toast } from "@/lib/toast";
type SectionId =
| "basic"
| "content"
| "brand"
| "footer"
| "security"
| "mail"
| "storage"
| "words"
| "maintenance";
const NAV: { id: SectionId; label: string; ready?: boolean }[] = [
{ id: "basic", label: "基本信息", ready: true },
{ id: "content", label: "内容与互动", ready: true },
{ id: "brand", label: "品牌与 SEO" },
{ id: "footer", label: "页脚与合规" },
{ id: "security", label: "安全与限流" },
{ id: "mail", label: "邮件" },
{ id: "storage", label: "对象存储" },
{ id: "words", label: "敏感词" },
{ id: "maintenance", label: "系统维护" },
];
function SwitchRow({
title,
description,
control,
bordered,
}: {
title: ReactNode;
description: string;
control: ReactNode;
bordered?: boolean;
}) {
return (
<div
className="flex items-start justify-between gap-4 flex-wrap py-4 first:pt-0 last:pb-0"
style={bordered ? { borderBottom: "1px solid var(--line)" } : undefined}
>
<div className="min-w-0 flex-1">
<p className="text-[14.5px] font-medium flex items-center gap-1.5" style={{ color: "var(--ink)" }}>
{title}
</p>
<p className="meta text-[12.5px] mt-1 leading-relaxed">{description}</p>
</div>
<div className="shrink-0">{control}</div>
</div>
);
}
function ComingSoonCard({
title,
description,
icon,
}: {
title: string;
description: string;
icon: ReactNode;
}) {
return (
<AdminSection title={title} description={description}>
<div
className="rounded-[var(--r-sm)] px-4 py-10 text-center"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<span className="inline-flex mb-2 opacity-80" aria-hidden>
{icon}
</span>
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
即将推出
</p>
<p className="meta text-[12.5px] mt-1">此分区尚未开放配置</p>
</div>
</AdminSection>
);
}
export default function SettingsAdmin({ initial }: { initial: PublicSettings }) {
const [siteName, setSiteName] = useState(initial.site_name);
const [siteDescription, setSiteDescription] = useState(initial.site_description);
const [allowRegister, setAllowRegister] = useState(initial.allow_register);
const [allowComments, setAllowComments] = useState(initial.allow_comments !== false);
const [postLinkNewTab, setPostLinkNewTab] = useState(initial.post_link_new_tab !== false);
const [savedName, setSavedName] = useState(initial.site_name);
const [savedDesc, setSavedDesc] = useState(initial.site_description);
const [saving, setSaving] = useState(false);
const [togglingReg, setTogglingReg] = useState(false);
const [togglingComments, setTogglingComments] = useState(false);
const [togglingLink, setTogglingLink] = useState(false);
const [active, setActive] = useState<SectionId>("basic");
const dirty = siteName.trim() !== savedName || siteDescription.trim() !== savedDesc;
useEffect(() => {
const ids = NAV.map((n) => n.id);
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
if (visible[0]?.target?.id) {
const id = visible[0].target.id.replace(/^settings-/, "") as SectionId;
if (ids.includes(id)) setActive(id);
}
},
{ rootMargin: "-20% 0px -60% 0px", threshold: [0, 0.25, 0.5] }
);
ids.forEach((id) => {
const el = document.getElementById(`settings-${id}`);
if (el) observer.observe(el);
});
return () => observer.disconnect();
}, []);
const scrollTo = (id: SectionId) => {
setActive(id);
document.getElementById(`settings-${id}`)?.scrollIntoView({ behavior: "smooth", block: "start" });
};
const saveIdentity = async () => {
if (saving) return;
const name = siteName.trim();
@@ -80,84 +199,196 @@ export default function SettingsAdmin({ initial }: { initial: PublicSettings })
}
};
const togglePostLinkNewTab = async () => {
if (togglingLink) return;
const next = !postLinkNewTab;
setTogglingLink(true);
try {
const res = await apiUpdateSiteSettings({ post_link_new_tab: next });
setPostLinkNewTab(res.post_link_new_tab !== false);
toast(res.post_link_new_tab !== false ? "帖子与评论外链将在新标签打开" : "帖子与评论外链将在当前页打开", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败");
} finally {
setTogglingLink(false);
}
};
return (
<div className="max-w-3xl">
<div>
<AdminPageHeader
icon={Globe}
title="站点设置"
description="配置站点名称、简介与注册/评论开关;主题色、代码折叠与动画请到「外观」页调整"
description="配置站点名称、互动开关与后续扩展分区;主题色请到「外观」页调整"
/>
<div className="flex flex-col gap-4">
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
<AdminField
id="site-name"
className="mb-4"
label="站点名称"
maxLength={32}
value={siteName}
onChange={setSiteName}
placeholder="姜十三论坛"
/>
<div className="flex flex-col lg:flex-row gap-6 lg:gap-8 items-start">
<nav
className="w-full lg:w-52 shrink-0 lg:sticky lg:top-4 flex lg:flex-col gap-1 overflow-x-auto lg:overflow-visible pb-1 lg:pb-0"
aria-label="设置分区"
>
{NAV.map((item) => {
const isActive = active === item.id;
return (
<button
key={item.id}
type="button"
onClick={() => scrollTo(item.id)}
className="shrink-0 text-left px-3 py-2 rounded-[var(--r-sm)] text-[13.5px] transition-colors whitespace-nowrap"
style={{
background: isActive ? "var(--accent-soft)" : "transparent",
color: isActive ? "var(--accent)" : "var(--ink-2)",
fontWeight: isActive ? 600 : 400,
}}
aria-current={isActive ? "true" : undefined}
>
{item.label}
{!item.ready && (
<span className="ml-1.5 text-[10.5px]" style={{ color: "var(--ink-3)" }}>
即将
</span>
)}
</button>
);
})}
</nav>
<AdminField
id="site-desc"
className="mb-4"
label="站点简介"
as="textarea"
minHeight="96px"
maxLength={160}
value={siteDescription}
onChange={setSiteDescription}
placeholder="一句话介绍社区定位…"
/>
<div className="flex-1 min-w-0 flex flex-col gap-4 w-full">
<div id="settings-basic" className="scroll-mt-4">
<AdminSection title="基本信息" description="用于页眉品牌、页脚版权与 SEO 元信息">
<AdminField
id="site-name"
className="mb-4"
label="站点名称"
maxLength={32}
value={siteName}
onChange={setSiteName}
placeholder="姜十三论坛"
/>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={() => void saveIdentity()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存基本信息
</button>
<AdminField
id="site-desc"
className="mb-4"
label="站点简介"
as="textarea"
minHeight="96px"
maxLength={160}
value={siteDescription}
onChange={setSiteDescription}
placeholder="一句话介绍社区定位…"
/>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary btn-sm"
disabled={!dirty || saving}
onClick={() => void saveIdentity()}
>
{saving ? <Loader2 size={14} className="animate-spin" /> : <Save size={14} />}
保存基本信息
</button>
</div>
</AdminSection>
</div>
</AdminSection>
<AdminSettingRow
title={
<>
<UserPlus size={15} /> 开放注册
</>
}
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
control={
<AdminSwitch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
}
/>
<div id="settings-content" className="scroll-mt-4">
<AdminSection title="内容与互动" description="控制注册、评论与链接打开方式">
<SwitchRow
bordered
title={
<>
<UserPlus size={15} /> 开放注册
</>
}
description="关闭后,游客将无法提交注册;已有账号仍可登录。适用于内测或维护期。"
control={
<AdminSwitch
checked={allowRegister}
disabled={togglingReg}
onChange={() => void toggleRegister()}
label="开放注册"
/>
}
/>
<SwitchRow
bordered
title={
<>
<MessageSquare size={15} /> 开放评论
</>
}
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。"
control={
<AdminSwitch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
/>
}
/>
<SwitchRow
title={
<>
<ExternalLink size={15} /> 外链新标签打开
</>
}
description="开启后,帖子与评论中的 Markdown 外链在新标签打开;关闭则当前页跳转。"
control={
<AdminSwitch
checked={postLinkNewTab}
disabled={togglingLink}
onChange={() => void togglePostLinkNewTab()}
label="外链新标签打开"
/>
}
/>
</AdminSection>
</div>
<AdminSettingRow
title={
<>
<MessageSquare size={15} /> 开放评论
</>
}
description="关闭后前台不展示评论入口、评论数量与已有评论,并提示本站不支持评论。适用于备案审核等合规需要。"
control={
<AdminSwitch
checked={allowComments}
disabled={togglingComments}
onChange={() => void toggleComments()}
label="开放评论"
<div id="settings-brand" className="scroll-mt-4">
<ComingSoonCard
title="品牌与 SEO"
description="站点图标、OG 图与结构化元数据"
icon={<Palette size={22} />}
/>
}
/>
</div>
<div id="settings-footer" className="scroll-mt-4">
<ComingSoonCard
title="页脚与合规"
description="备案号、版权声明与合规链接"
icon={<FileText size={22} />}
/>
</div>
<div id="settings-security" className="scroll-mt-4">
<ComingSoonCard
title="安全与限流"
description="登录/注册速率与发帖冷静期等"
icon={<Shield size={22} />}
/>
</div>
<div id="settings-mail" className="scroll-mt-4">
<ComingSoonCard title="邮件" description="SMTP 与通知邮件模板" icon={<Mail size={22} />} />
</div>
<div id="settings-storage" className="scroll-mt-4">
<ComingSoonCard
title="对象存储"
description="附件与头像的云端存储配置"
icon={<HardDrive size={22} />}
/>
</div>
<div id="settings-words" className="scroll-mt-4">
<ComingSoonCard title="敏感词" description="内容过滤词库与命中策略" icon={<Ban size={22} />} />
</div>
<div id="settings-maintenance" className="scroll-mt-4">
<ComingSoonCard
title="系统维护"
description="维护模式、缓存与诊断工具"
icon={<Wrench size={22} />}
/>
</div>
</div>
</div>
</div>
);

View File

@@ -7,6 +7,7 @@ import {
ChevronLeft,
ChevronRight,
Clock,
Columns3,
ExternalLink,
FolderSearch,
History,
@@ -47,6 +48,47 @@ import {
export type UserFilter = "all" | "staff" | "banned";
const PAGE_SIZE = 20;
const COLS_STORAGE_KEY = "j13-admin-user-cols";
/** 默认始终显示的列 */
const CORE_COLS = ["member", "role", "status", "points", "last_login", "actions"] as const;
/** 可选列(localStorage 开关) */
const OPTIONAL_COLS = ["online", "email", "counts", "created_at"] as const;
type CoreCol = (typeof CORE_COLS)[number];
type OptionalCol = (typeof OPTIONAL_COLS)[number];
type ColKey = CoreCol | OptionalCol;
const OPTIONAL_LABELS: Record<OptionalCol, string> = {
online: "在线",
email: "邮箱",
counts: "帖/评数",
created_at: "注册时间",
};
const DEFAULT_OPTIONAL: Record<OptionalCol, boolean> = {
online: false,
email: false,
counts: false,
created_at: false,
};
function loadOptionalCols(): Record<OptionalCol, boolean> {
if (typeof window === "undefined") return { ...DEFAULT_OPTIONAL };
try {
const raw = localStorage.getItem(COLS_STORAGE_KEY);
if (!raw) return { ...DEFAULT_OPTIONAL };
const parsed = JSON.parse(raw) as Partial<Record<OptionalCol, boolean>>;
return {
online: !!parsed.online,
email: !!parsed.email,
counts: !!parsed.counts,
created_at: !!parsed.created_at,
};
} catch {
return { ...DEFAULT_OPTIONAL };
}
}
const FILTER_TABS: { key: UserFilter; label: string }[] = [
{ key: "all", label: "全部" },
@@ -60,6 +102,90 @@ function filterToParams(filter: UserFilter): { role?: string; status?: string }
return {};
}
function ColumnConfigPopover({
cols,
onChange,
}: {
cols: Record<OptionalCol, boolean>;
onChange: (next: Record<OptionalCol, boolean>) => void;
}) {
const [open, setOpen] = useState(false);
const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
const onDown = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) setOpen(false);
};
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") setOpen(false);
};
document.addEventListener("mousedown", onDown);
document.addEventListener("keydown", onKey);
return () => {
document.removeEventListener("mousedown", onDown);
document.removeEventListener("keydown", onKey);
};
}, [open]);
const enabledCount = OPTIONAL_COLS.filter((k) => cols[k]).length;
return (
<div className="relative shrink-0" ref={ref}>
<button
type="button"
className="btn btn-line btn-sm"
aria-haspopup="dialog"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
<Columns3 size={14} />
列
{enabledCount > 0 ? (
<span className="tabular-nums" style={{ color: "var(--accent)" }}>
+{enabledCount}
</span>
) : null}
</button>
{open && (
<div
role="dialog"
aria-label="显示列"
className="absolute right-0 top-full mt-2 z-40 w-52 p-2 j13-toast-in"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
borderRadius: "var(--r)",
boxShadow: "var(--shadow-lg)",
}}
>
<p className="px-2 py-1.5 text-[11.5px] uppercase tracking-wide" style={{ color: "var(--ink-3)" }}>
可选列
</p>
<ul className="flex flex-col gap-0.5">
{OPTIONAL_COLS.map((key) => (
<li key={key}>
<label className="flex items-center gap-2.5 px-2 py-2 rounded-[var(--r-sm)] cursor-pointer hover:bg-[var(--panel-2)] text-[13px]">
<input
type="checkbox"
checked={cols[key]}
onChange={() => onChange({ ...cols, [key]: !cols[key] })}
className="accent-[var(--accent)]"
/>
<span style={{ color: "var(--ink)" }}>{OPTIONAL_LABELS[key]}</span>
</label>
</li>
))}
</ul>
<p className="px-2 pt-1.5 pb-1 text-[11px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
成员 / 角色 / 状态 / 积分 / 上次登录 / 操作固定显示
</p>
</div>
)}
</div>
);
}
export default function UsersAdmin({
initial,
initError,
@@ -93,19 +219,29 @@ export default function UsersAdmin({
const [loading, setLoading] = useState(false);
const [error, setError] = useState(initError);
const [actingId, setActingId] = useState<number | null>(null);
// 角色与账号弹窗 / 登录历史弹窗
const [roleTarget, setRoleTarget] = useState<AdminUser | null>(null);
const [logsTarget, setLogsTarget] = useState<AdminUser | null>(null);
// 板块字典(角色弹窗勾选 + 徽章板块名)
const [boards, setBoards] = useState<Board[]>([]);
const [optCols, setOptCols] = useState<Record<OptionalCol, boolean>>(DEFAULT_OPTIONAL);
// 防止快速翻页/输入时旧响应覆盖新结果
const seqRef = useRef(0);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const urlFirstRun = useRef(true);
const router = useRouter();
useEffect(() => {
setOptCols(loadOptionalCols());
}, []);
const persistCols = (next: Record<OptionalCol, boolean>) => {
setOptCols(next);
try {
localStorage.setItem(COLS_STORAGE_KEY, JSON.stringify(next));
} catch {
/* ignore */
}
};
const boardNameMap = useMemo(() => {
const m = new Map<number, string>();
boards.forEach((b) => m.set(b.id, b.name));
@@ -118,8 +254,6 @@ export default function UsersAdmin({
.catch(() => {});
}, []);
// 在线状态实时化:WS hello 全量快照校正当前页绿点,之后 presence:update 增量跳变;
// 后端在线判定(有活跃 WS 连接)比列表接口的 5 分钟 last_seen 启发式更精确
useEffect(() => {
const reconcile = (ids: ReadonlySet<number>) => {
if (ids.size === 0) return;
@@ -162,7 +296,6 @@ export default function UsersAdmin({
}
}, []);
// 搜索框防抖(首次渲染因 keyword===appliedQ 不会触发请求)
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
@@ -177,7 +310,6 @@ export default function UsersAdmin({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [keyword]);
// 查询状态同步到 URL(首屏由 SSR 给出,跳过首次),刷新/分享可复现当前视图
useEffect(() => {
if (urlFirstRun.current) {
urlFirstRun.current = false;
@@ -191,14 +323,12 @@ export default function UsersAdmin({
router.replace(`/admin/users${qs ? `?${qs}` : ""}`, { scroll: false });
}, [appliedQ, filter, page, router]);
// Enter 立即提交,跳过防抖
const submitSearch = () => {
const q = keyword.trim();
if (q === appliedQ) return;
load({ page: 1, filter, q });
};
// 清除按钮:立即复位查询并重拉,不等待防抖,保证标题与列表同步
const clearSearch = () => {
setKeyword("");
if (appliedQ !== "" || page !== 1) {
@@ -221,12 +351,85 @@ export default function UsersAdmin({
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const tabCount = (f: UserFilter) =>
f === "all" ? summary.total : f === "staff" ? summary.admins : summary.banned;
// 有关键词或非默认筛选时,空态文案与"无成员"区分
const hasFilter = appliedQ !== "" || filter !== "all";
// 角色授权成功 / 封禁成功后的统一回刷
const reload = () => load({ page, filter, q: appliedQ });
const visibleCols = useMemo(() => {
const list: ColKey[] = ["member", "role", "status", "points"];
if (optCols.online) list.push("online");
if (optCols.email) list.push("email");
list.push("last_login");
if (optCols.counts) list.push("counts");
if (optCols.created_at) list.push("created_at");
list.push("actions");
return list;
}, [optCols]);
// Tailwind 无法动态拼 grid-cols;用 style.gridTemplateColumns
const gridStyle = useMemo(() => {
const frags: string[] = [];
for (const c of visibleCols) {
switch (c) {
case "member":
frags.push("minmax(0,1.35fr)");
break;
case "role":
frags.push("minmax(96px,0.9fr)");
break;
case "status":
frags.push("72px");
break;
case "points":
frags.push("72px");
break;
case "online":
frags.push("64px");
break;
case "email":
frags.push("minmax(120px,1fr)");
break;
case "last_login":
frags.push("146px");
break;
case "counts":
frags.push("100px");
break;
case "created_at":
frags.push("100px");
break;
case "actions":
frags.push("136px");
break;
}
}
return { gridTemplateColumns: frags.join(" ") };
}, [visibleCols]);
const colHeader = (c: ColKey): string => {
switch (c) {
case "member":
return "成员";
case "role":
return "角色";
case "status":
return "状态";
case "points":
return "积分";
case "online":
return "在线";
case "email":
return "邮箱";
case "last_login":
return "上次登录";
case "counts":
return "内容";
case "created_at":
return "注册";
case "actions":
return "操作";
}
};
const renderStatus = (u: AdminUser) => {
if (u.banned) {
return (
@@ -240,6 +443,19 @@ export default function UsersAdmin({
</span>
);
}
return (
<span
className="inline-flex items-center gap-1.5 text-[12.5px]"
style={{ color: "var(--ok)" }}
data-tip="账号正常"
>
<span className="w-2 h-2 rounded-full shrink-0" style={{ background: "var(--ok)" }} aria-hidden />
正常
</span>
);
};
const renderOnline = (u: AdminUser) => {
if (u.online) {
return (
<span
@@ -301,7 +517,6 @@ export default function UsersAdmin({
</AdminIconButton>
) : null;
// 站长账号:不可改角色/封禁,保留主页与(站长自己的)内容档案
if (u.role === ROLES.OWNER) {
return (
<div className="flex items-center gap-1 justify-end">
@@ -350,11 +565,66 @@ export default function UsersAdmin({
);
};
// 桌面端列模板(成员列收窄,新增在线状态与上次登录 IP;xl 起展示内容/注册列)
const rowGrid =
"hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 " +
"md:grid-cols-[minmax(0,1fr)_78px_minmax(108px,0.95fr)_146px_136px] " +
"xl:grid-cols-[minmax(0,1.25fr)_78px_minmax(108px,0.95fr)_146px_116px_136px]";
const renderCell = (c: ColKey, u: AdminUser, isSelf: boolean) => {
switch (c) {
case "member":
return (
<div className="flex items-center gap-2.5 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
</p>
</div>
</div>
);
case "role":
return (
<div className="min-w-0">
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNameMap} truncate />
</div>
);
case "status":
return renderStatus(u);
case "points":
return (
<span className="text-[13px] tabular-nums" style={{ color: "var(--ink-2)" }}>
{(u.points ?? 0).toLocaleString("zh-CN")}
</span>
);
case "online":
return renderOnline(u);
case "email":
return (
<span className="text-[12.5px] truncate block" style={{ color: u.email ? "var(--ink-2)" : "var(--ink-3)" }}>
{u.email || "—"}
</span>
);
case "last_login":
return <div className="min-w-0">{renderLastLogin(u)}</div>;
case "counts":
return (
<p
className="text-[12.5px] tabular-nums"
style={{ color: "var(--ink-2)" }}
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
);
case "created_at":
return (
<span className="text-[12px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</span>
);
case "actions":
return <div className="flex justify-end">{renderActions(u, isSelf)}</div>;
}
};
return (
<div>
@@ -364,7 +634,6 @@ export default function UsersAdmin({
description="检索全部成员;在「角色与账号」中配置管理角色、板块授权与封禁;站长账号受保护"
/>
{/* 汇总:用户运营独有指标(与仪表盘站点总量区分) */}
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3 mb-4">
<AdminStatCard
label="当前在线"
@@ -399,28 +668,32 @@ export default function UsersAdmin({
/>
</div>
{/* 搜索 + 筛选 + 用户列表 */}
<div className="panel overflow-hidden">
<div className="admin-toolbar">
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
<AdminSegmented<UserFilter>
role="group"
ariaLabel="用户筛选"
value={filter}
onChange={changeFilter}
options={FILTER_TABS.map((t) => ({
key: t.key,
label: t.label,
count: tabCount(t.key),
}))}
/>
<div className="flex flex-col sm:flex-row gap-3 sm:items-center w-full">
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={submitSearch}
onClear={clearSearch}
placeholder="搜索用户名、昵称或邮箱…"
ariaLabel="搜索用户"
/>
<div className="flex items-center gap-2 flex-wrap shrink-0">
<AdminSegmented<UserFilter>
role="group"
ariaLabel="用户筛选"
value={filter}
onChange={changeFilter}
options={FILTER_TABS.map((t) => ({
key: t.key,
label: t.label,
count: tabCount(t.key),
}))}
/>
<ColumnConfigPopover cols={optCols} onChange={persistCols} />
</div>
</div>
</div>
<div
@@ -445,18 +718,21 @@ export default function UsersAdmin({
</span>
</div>
{/* 桌面表头 */}
{users.length > 0 && (
<div
className={`${rowGrid} text-[11.5px] uppercase tracking-wide`}
style={{ color: "var(--ink-3)", borderBottom: "1px solid var(--line)", background: "var(--panel-2)" }}
className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5 text-[11.5px] uppercase tracking-wide"
style={{
...gridStyle,
color: "var(--ink-3)",
borderBottom: "1px solid var(--line)",
background: "var(--panel-2)",
}}
>
<span>成员</span>
<span>状态</span>
<span>角色</span>
<span>上次登录 IP</span>
<span className="xl:block hidden">内容 / 注册</span>
<span className="text-right">操作</span>
{visibleCols.map((c) => (
<span key={c} className={c === "actions" ? "text-right" : undefined}>
{colHeader(c)}
</span>
))}
</div>
)}
@@ -474,41 +750,14 @@ export default function UsersAdmin({
const isSelf = u.id === currentUserId;
return (
<div key={u.id} style={{ borderTop: "1px solid var(--line)" }}>
{/* 桌面行 */}
<div className={rowGrid}>
<div className="flex items-center gap-2.5 min-w-0">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={34} />
<div className="min-w-0 leading-tight">
<p className="text-[13.5px] truncate" style={{ color: "var(--ink)" }}>
{u.nickname}
</p>
<p className="text-[11.5px] truncate" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
</p>
<div className="hidden md:grid gap-3 items-center px-4 sm:px-5 py-3.5" style={gridStyle}>
{visibleCols.map((c) => (
<div key={c} className="min-w-0">
{renderCell(c, u, isSelf)}
</div>
</div>
<div>{renderStatus(u)}</div>
<div className="min-w-0">
<RoleBadge role={u.role} boardIds={u.board_ids} boardNames={boardNameMap} truncate />
</div>
<div className="min-w-0">{renderLastLogin(u)}</div>
<div className="hidden xl:block">
<p
className="text-[12.5px] tabular-nums"
style={{ color: "var(--ink-2)" }}
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count}
</p>
<p className="text-[11px] tabular-nums" style={{ color: "var(--ink-3)" }}>
{formatDate(u.created_at)}
</p>
</div>
<div className="flex justify-end">{renderActions(u, isSelf)}</div>
))}
</div>
{/* 移动卡片:空间紧,保留 btn-line btn-sm */}
<div className="md:hidden px-4 py-3.5">
<div className="flex items-center gap-3">
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={38} />
@@ -531,17 +780,18 @@ export default function UsersAdmin({
</div>
<p className="text-[11.5px] truncate mt-0.5" style={{ color: "var(--ink-3)" }}>
@{u.username}
{u.email ? ` · ${u.email}` : ""}
{optCols.email && u.email ? ` · ${u.email}` : ""}
</p>
</div>
{renderStatus(u)}
</div>
<p
className="mt-2.5 text-[11.5px] tabular-nums"
style={{ color: "var(--ink-3)" }}
data-tip="仅统计已发布内容;驳回/删除见内容档案"
>
帖 {u.post_count} · 评 {u.comment_count} · 注册 {formatDate(u.created_at)}
<p className="mt-2.5 text-[11.5px] tabular-nums flex flex-wrap gap-x-3 gap-y-1" style={{ color: "var(--ink-3)" }}>
<span>积分 {(u.points ?? 0).toLocaleString("zh-CN")}</span>
<span data-tip="仅统计已发布内容">
帖 {u.post_count} · 评 {u.comment_count}
</span>
<span>注册 {formatDate(u.created_at)}</span>
{optCols.online && <span>{u.online ? "在线" : "离线"}</span>}
</p>
<p className="mt-1 text-[11.5px] tabular-nums flex items-center gap-1.5" style={{ color: "var(--ink-3)" }}>
<Clock size={12} className="shrink-0" />
@@ -620,8 +870,6 @@ export default function UsersAdmin({
);
}
// ===== 登录历史弹窗 =====
function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClose: () => void }) {
const [logs, setLogs] = useState<LoginLog[]>([]);
const [total, setTotal] = useState(0);
@@ -675,7 +923,6 @@ function LoginLogsDialog({ target, onClose }: { target: AdminUser | null; onClos
panelClassName="p-6 max-h-[88vh] flex flex-col"
ariaLabel="登录历史"
footer={
// Modal footer 不好塞 AdminPager,保留原紧凑按钮
total > pageSize ? (
<>
<span className="meta text-[11.5px] tabular-nums mr-auto" style={{ color: "var(--ink-3)" }}>

View File

@@ -1439,6 +1439,264 @@ body:has([data-chat-viewport]) #main {
overflow: hidden;
}
/* ── 管理后台独立壳:全高侧栏 + 顶栏 + 可滚主区(不依赖公开 Header/Footer) ── */
body:has([data-admin-viewport]) {
height: 100dvh;
max-height: 100dvh;
overflow: hidden;
background: var(--bg);
}
.admin-shell {
display: flex;
height: 100dvh;
max-height: 100dvh;
min-height: 0;
overflow: hidden;
background: var(--bg);
}
.admin-rail {
width: 15.5rem;
flex-shrink: 0;
display: flex;
flex-direction: column;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1rem 0.75rem 1.25rem;
background: var(--panel);
border-right: 1px solid var(--line);
}
/* 桌面常驻侧栏;窄屏改用抽屉,避免与 .admin-rail 的 display 冲突 */
.admin-rail-desktop {
display: none;
}
@media (min-width: 1024px) {
.admin-rail-desktop {
display: flex;
}
}
@media (min-width: 1280px) {
.admin-rail {
width: 16.5rem;
}
}
.admin-drawer-panel {
position: relative;
z-index: 1;
display: flex;
width: min(18rem, 86vw);
height: 100%;
box-shadow: var(--shadow-lg);
animation: admin-drawer-in 0.18s ease-out;
}
.admin-rail-brand {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.35rem 0.5rem 1rem;
margin-bottom: 0.35rem;
border-bottom: 1px solid var(--line);
}
.admin-rail-brand-mark {
width: 2.25rem;
height: 2.25rem;
border-radius: 0.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
background: var(--accent);
color: var(--accent-on);
}
.admin-rail-nav {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 0.75rem 0 0.25rem;
flex: 1;
}
.admin-rail-group-title {
font-size: 11px;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-3);
padding: 0 0.65rem 0.4rem;
margin: 0;
}
.admin-rail-link {
display: flex;
align-items: center;
gap: 0.625rem;
padding: 0.55rem 0.75rem;
border-radius: 0.75rem;
font-size: 13.5px;
font-weight: 500;
color: var(--ink-2);
transition: background-color 0.15s ease, color 0.15s ease;
}
.admin-rail-link:hover {
background: var(--panel-2);
color: var(--ink);
}
.admin-rail-link.is-active {
background: var(--nav-active-bg);
color: var(--nav-active-ink);
}
.admin-rail-link.is-active svg {
color: var(--nav-active-ink);
}
.admin-rail-link:not(.is-active) svg {
color: var(--ink-3);
}
.admin-main-col {
flex: 1 1 0;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
overflow: hidden;
}
.admin-topbar {
height: 3.5rem;
flex-shrink: 0;
display: flex;
align-items: center;
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);
border-bottom: 1px solid var(--line);
z-index: 30;
}
@media (min-width: 640px) {
.admin-topbar {
padding: 0 1.5rem;
}
}
.admin-topbar-left,
.admin-topbar-right {
display: flex;
align-items: center;
gap: 0.5rem;
min-width: 0;
}
.admin-topbar-back {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.75rem;
border-radius: 999px;
font-size: 13px;
font-weight: 500;
color: var(--ink-2);
transition: background-color 0.15s ease, color 0.15s ease;
}
.admin-topbar-back:hover {
background: var(--panel);
color: var(--ink);
}
.admin-topbar-user {
display: inline-flex;
align-items: center;
gap: 0.5rem;
border-radius: 999px;
padding: 0.2rem 0.55rem 0.2rem 0.2rem;
border: 1px solid var(--line);
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.admin-canvas {
flex: 1 1 0;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
padding: 1.25rem 1rem 2rem;
}
/* 内容管理:桌面禁整页滚动,三栏底部落在视口内 */
@media (min-width: 1024px) {
.admin-canvas:has(.admin-content-page) {
display: flex;
flex-direction: column;
overflow: hidden;
padding-bottom: 1.25rem;
}
}
@media (min-width: 640px) {
.admin-canvas {
padding: 1.5rem 1.5rem 2.5rem;
}
}
@media (min-width: 1024px) {
.admin-canvas {
padding: 1.75rem 2rem 2.75rem;
}
.admin-canvas:has(.admin-content-page) {
padding-bottom: 1.25rem;
}
}
/* 移动端导航抽屉 */
.admin-drawer {
position: fixed;
inset: 0;
z-index: 60;
display: flex;
}
.admin-drawer-backdrop {
position: absolute;
inset: 0;
border: 0;
padding: 0;
cursor: pointer;
background: color-mix(in srgb, var(--ink) 28%, transparent);
}
@keyframes admin-drawer-in {
from {
transform: translateX(-100%);
opacity: 0.85;
}
to {
transform: translateX(0);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.admin-drawer-panel {
animation: none;
}
}
::selection {
background: var(--accent);
color: var(--accent-on);
@@ -1913,20 +2171,244 @@ a.j13-mention-token:hover {
}
}
/* 内容管理工具条:类型 / 状态 / 搜索同一行,避免三行左对齐或等分顶栏留白 */
.admin-content-toolbar {
/* 内容管理页:窄屏可整页滚;桌面高度由上方未分层的 canvas:has 规则锁定 */
.admin-content-page {
display: flex;
flex-direction: column;
gap: 0.75rem;
min-height: 0;
}
@media (min-width: 1024px) {
.admin-content-page {
flex: 1 1 auto;
min-height: 0;
height: 100%;
}
}
.admin-content-page > .mb-5 {
margin-bottom: 0 !important;
flex-shrink: 0;
}
.admin-content-page > .mb-5 h1 {
font-size: 1.25rem;
}
@media (min-width: 640px) {
.admin-content-page > .mb-5 h1 {
font-size: 1.375rem;
}
}
.admin-content-page > .mb-5 .meta {
margin-top: 0.25rem;
}
/* 策略条:横向排布,压缩垂直占用 */
.admin-content-policy {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.75rem 1rem;
padding: 0.75rem 1rem;
gap: 0.65rem 0.85rem;
flex-wrap: wrap;
flex-shrink: 0;
padding: 0.5rem 0.75rem;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--panel);
}
.admin-content-policy-title {
font-size: 12px;
font-weight: 650;
color: var(--ink-3);
flex-shrink: 0;
}
.admin-content-policy-row {
display: flex;
align-items: center;
gap: 0.35rem 0.5rem;
flex: 1 1 auto;
min-width: 0;
flex-wrap: wrap;
}
.admin-content-policy-chip {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.28rem 0.55rem;
border-radius: 10px;
background: var(--panel-2);
border: 1px solid transparent;
min-height: 2.25rem;
}
.admin-content-policy-chip-label {
font-size: 12.5px;
font-weight: 600;
color: var(--ink);
white-space: nowrap;
}
/* 内容管理:PC 左中右三栏等高;仅列表区滚动 */
.admin-content-triptych {
display: grid;
gap: 0.85rem;
align-items: stretch;
min-height: 18rem;
}
@media (min-width: 1024px) {
.admin-content-triptych {
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 1rem;
flex: 1 1 auto;
min-height: 0;
height: auto;
}
}
.admin-messages-panel {
flex: 1 1 auto;
min-height: 0;
}
/* 私聊监管:方向条与正文同一卡片,无间隙 */
.admin-dm-row {
min-width: 0;
}
.admin-dm-card {
display: flex;
flex-direction: column;
min-width: 0;
border-radius: 0.55rem;
background: var(--panel-2);
border: 1px solid var(--line);
overflow: hidden;
}
.admin-dm-card-head {
padding: 0.4rem 0.65rem;
border-bottom: 1px solid var(--line);
}
.admin-dm-card-body {
padding: 0.55rem 0.7rem 0.65rem;
}
.admin-dm-pair-meta {
display: flex;
align-items: center;
gap: 0.45rem;
flex: 1 1 auto;
min-width: 0;
}
.admin-dm-pair-names {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
flex: 1 1 auto;
}
.admin-dm-person {
display: inline-flex;
align-items: center;
gap: 0.35rem;
min-width: 0;
}
.admin-dm-person-name {
font-size: 12.5px;
font-weight: 600;
letter-spacing: 0.01em;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 7.5rem;
}
.admin-dm-arrow {
flex-shrink: 0;
font-weight: 500;
color: var(--ink-3);
font-size: 11px;
}
.admin-content-col {
display: flex;
flex-direction: column;
min-height: 0;
overflow: hidden;
}
@media (min-width: 1024px) {
.admin-content-col {
height: 100%;
}
}
.admin-content-col-head,
.admin-content-col-toolbar,
.admin-content-col-pager {
flex-shrink: 0;
}
.admin-content-col-body {
flex: 1 1 auto;
min-height: 0;
overflow-y: auto;
overscroll-behavior: contain;
}
.admin-content-col-toolbar {
display: flex;
flex-direction: column;
gap: 0.55rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid var(--line);
background: var(--panel);
}
@media (min-width: 640px) {
.admin-content-toolbar {
padding: 0.75rem 1.25rem;
}
.admin-content-action {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.28rem 0.55rem;
border-radius: 8px;
border: 1px solid var(--line);
background: var(--panel);
color: var(--ink-2);
font-size: 12px;
font-weight: 600;
line-height: 1.2;
cursor: pointer;
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease;
}
.admin-content-action:hover:not(:disabled) {
background: var(--panel-2);
color: var(--ink);
border-color: var(--line-2);
}
.admin-content-action:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.admin-content-action.is-danger {
color: var(--danger);
border-color: color-mix(in srgb, var(--danger) 28%, var(--line));
background: color-mix(in srgb, var(--danger) 6%, var(--panel));
}
.admin-content-action.is-danger:hover:not(:disabled) {
background: color-mix(in srgb, var(--danger) 12%, var(--panel));
}
.admin-content-action.is-ok {
color: var(--ok);
border-color: color-mix(in srgb, var(--ok) 28%, var(--line));
background: color-mix(in srgb, var(--ok) 6%, var(--panel));
}
.admin-content-clamp {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
overflow: hidden;
word-break: break-word;
}
.admin-content-clamp.is-open {
display: block;
-webkit-line-clamp: unset;
}
/* 三栏宽度下状态条横向滚动,避免折行难看 */
.admin-content-col-toolbar .admin-content-status-scroll {
width: 100%;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
scrollbar-width: thin;
}
.admin-content-col-toolbar .admin-content-status-scroll > div {
flex-wrap: nowrap !important;
}
.admin-content-col-toolbar .admin-content-status-scroll .admin-seg {
flex-shrink: 0;
}
.admin-content-kind {
display: inline-flex;
@@ -1979,35 +2461,6 @@ a.j13-mention-token:hover {
background: var(--accent-soft);
color: var(--accent);
}
.admin-content-split {
display: none;
width: 1px;
height: 1.5rem;
background: var(--line);
flex-shrink: 0;
}
.admin-content-status {
flex: 1 1 14rem;
min-width: 0;
}
.admin-content-search {
flex: 1 1 12rem;
max-width: 18rem;
min-width: 11rem;
margin-left: auto;
}
@media (min-width: 768px) {
.admin-content-split {
display: block;
}
}
@media (max-width: 767px) {
.admin-content-search {
flex-basis: 100%;
max-width: none;
margin-left: 0;
}
}
.admin-field-head {
display: flex;
@@ -2059,14 +2512,16 @@ a.j13-mention-token:hover {
.admin-search-shell {
display: flex;
align-items: center;
gap: 0.5rem;
height: 2.5rem;
padding: 0 0.75rem;
gap: 0.625rem;
min-height: 2.875rem;
height: 2.875rem;
padding: 0 0.875rem;
}
.admin-search-shell .admin-field-input {
padding: 0;
height: 100%;
font-size: 13px;
font-size: 14px;
line-height: 1.4;
}
.admin-stepper {

View File

@@ -1,14 +1,15 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { cookies, headers } from "next/headers";
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
import "./globals.css";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import SiteChrome from "@/components/SiteChrome";
import Toaster from "@/components/Toaster";
import TipHost from "@/components/TipHost";
import AccountGuard from "@/components/AccountGuard";
import ThemeSync from "@/components/ThemeSync";
import RealtimeProvider from "@/components/RealtimeProvider";
import VisitBeacon from "@/components/VisitBeacon";
import PostLinkProvider from "@/components/PostLinkProvider";
import CodeFoldProvider from "@/components/markdown/CodeFoldProvider";
import MotionProvider from "@/components/MotionProvider";
import CommentsPolicyProvider from "@/components/CommentsPolicyProvider";
@@ -65,6 +66,11 @@ export default async function RootLayout({
children: React.ReactNode;
}) {
const cookieStore = await cookies();
const headerStore = await headers();
// middleware 写入 x-pathname;供 SiteChrome SSR 初值(软导航靠客户端 pathname)
const pathname = headerStore.get("x-pathname") || "";
const isAdmin = pathname === "/admin" || pathname.startsWith("/admin/");
// 浅色优先的设计系统,仅在显式选择暗色时挂 .dark
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
@@ -113,35 +119,33 @@ export default async function RootLayout({
<a href="#main" className="skip-link">
跳到主要内容
</a>
<Header
initialUser={user}
initialUnread={unread}
initialChatUnread={chatUnread}
initialTheme={theme}
siteName={settings.site_name}
allowRegister={settings.allow_register}
/>
<main
id="main"
tabIndex={-1}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-9 outline-none"
>
<MotionProvider initial={motionCfg}>
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
<MotionProvider initial={motionCfg}>
<CommentsPolicyProvider allowComments={settings.allow_comments !== false}>
<PostLinkProvider openInNewTab={settings.post_link_new_tab !== false}>
<CodeFoldProvider
initialEnabled={settings.code_block_auto_fold}
initialFoldLines={settings.code_block_fold_lines}
>
{children}
<SiteChrome
initialIsAdmin={isAdmin}
user={user}
unread={unread}
chatUnread={chatUnread}
theme={theme}
siteName={settings.site_name}
allowRegister={settings.allow_register}
>
{children}
</SiteChrome>
</CodeFoldProvider>
</CommentsPolicyProvider>
</MotionProvider>
</main>
<Footer siteName={settings.site_name} />
</PostLinkProvider>
</CommentsPolicyProvider>
</MotionProvider>
<Toaster />
<TipHost />
<AccountGuard />
<ThemeSync initialAccent={accent || ""} />
<VisitBeacon />
<RealtimeProvider userId={user ? user.id : null} />
</body>
</html>