feat(admin): 独立后台壳、站点分析与聊天监管,公开站拆出主题切换与访问埋点
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
244
frontend/app/admin/DashboardClient.tsx
Normal file
244
frontend/app/admin/DashboardClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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="删除公告"
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
170
frontend/app/admin/economy/EconomyClient.tsx
Normal file
170
frontend/app/admin/economy/EconomyClient.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
543
frontend/app/admin/messages/MessagesAdmin.tsx
Normal file
543
frontend/app/admin/messages/MessagesAdmin.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
22
frontend/app/admin/messages/page.tsx
Normal file
22
frontend/app/admin/messages/page.tsx
Normal 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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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)" }}>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user