feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面 - 移动端底部导航 MobileTabBar 替换 MobilePostBar - 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强 - site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
This commit is contained in:
@@ -16,7 +16,6 @@ import {
|
||||
SiteDocShell,
|
||||
SiteDocIdentity,
|
||||
SiteDocAnnoNav,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
|
||||
@@ -109,13 +108,6 @@ export default async function AboutIndexPage() {
|
||||
className="px-5 sm:px-6 pt-5 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<SiteDocBreadcrumb
|
||||
className="mb-3"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: "关于本站" },
|
||||
]}
|
||||
/>
|
||||
<div className="flex items-end justify-between gap-3">
|
||||
<div>
|
||||
<h1
|
||||
|
||||
@@ -88,11 +88,11 @@ export default function AdsAdmin({
|
||||
enabled: true,
|
||||
panel_title: "自助推广",
|
||||
durations: [
|
||||
{ days: 30, label: "1 个月", price_hint: "" },
|
||||
{ days: 60, label: "2 个月", price_hint: "" },
|
||||
{ days: 90, label: "3 个月", price_hint: "" },
|
||||
{ days: 180, label: "6 个月", price_hint: "" },
|
||||
{ days: 365, label: "12 个月", price_hint: "" },
|
||||
{ days: 30, label: "1 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 60, label: "2 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 90, label: "3 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 180, label: "6 个月", price_hint: "", price_hint_text: "" },
|
||||
{ days: 365, label: "12 个月", price_hint: "", price_hint_text: "" },
|
||||
],
|
||||
payments: [],
|
||||
}
|
||||
@@ -489,7 +489,10 @@ export default function AdsAdmin({
|
||||
onClick={() =>
|
||||
setCfg({
|
||||
...cfg,
|
||||
durations: [...cfg.durations, { days: 14, label: "14 天", price_hint: "" }],
|
||||
durations: [
|
||||
...cfg.durations,
|
||||
{ days: 14, label: "14 天", price_hint: "", price_hint_text: "" },
|
||||
],
|
||||
})
|
||||
}
|
||||
>
|
||||
@@ -500,43 +503,90 @@ export default function AdsAdmin({
|
||||
{cfg.durations.length === 0 ? (
|
||||
<p className="meta text-[13px]">至少保留一个时长档位。</p>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||||
{cfg.durations.map((d, i) => (
|
||||
<div key={i} className="j13-ads-row">
|
||||
<input
|
||||
type="number"
|
||||
className="field w-20"
|
||||
value={d.days}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, days: Number(e.target.value) || 1 };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="天数"
|
||||
/>
|
||||
<input
|
||||
className="field min-w-0 flex-1"
|
||||
value={d.label}
|
||||
placeholder="标签,如 1 个月"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, label: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
aria-label="删除档位"
|
||||
onClick={() =>
|
||||
setCfg({ ...cfg, durations: cfg.durations.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: "var(--line)" }}>
|
||||
<table className="w-full min-w-[600px] text-left text-[13px]">
|
||||
<thead>
|
||||
<tr className="meta text-[12px]" style={{ background: "var(--surface)" }}>
|
||||
<th className="px-3 py-2 font-medium">天数</th>
|
||||
<th className="px-3 py-2 font-medium">标签</th>
|
||||
<th className="px-3 py-2 font-medium">图片价格</th>
|
||||
<th className="px-3 py-2 font-medium">文字价格</th>
|
||||
<th className="px-3 py-2 text-right font-medium">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{cfg.durations.map((d, i) => (
|
||||
<tr key={i} style={{ borderTop: "1px solid var(--line)" }}>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
type="number"
|
||||
className="field w-20"
|
||||
value={d.days}
|
||||
min={1}
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, days: Number(e.target.value) || 1 };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="天数"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.label}
|
||||
placeholder="如 1 个月"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, label: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.price_hint}
|
||||
maxLength={32}
|
||||
placeholder="如 ¥299,可空"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, price_hint: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="图片广告价格"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<input
|
||||
className="field w-full min-w-0"
|
||||
value={d.price_hint_text}
|
||||
maxLength={32}
|
||||
placeholder="如 ¥99,可空"
|
||||
onChange={(e) => {
|
||||
const durations = [...cfg.durations];
|
||||
durations[i] = { ...d, price_hint_text: e.target.value };
|
||||
setCfg({ ...cfg, durations });
|
||||
}}
|
||||
aria-label="文字广告价格"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
aria-label="删除档位"
|
||||
onClick={() =>
|
||||
setCfg({ ...cfg, durations: cfg.durations.filter((_, j) => j !== i) })
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</SubBlock>
|
||||
|
||||
@@ -1,11 +1,12 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Pencil, Plus, Trash2, Users } from "lucide-react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Award, ChevronRight, Plus, Trash2, Users } from "lucide-react";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import ConfirmDialog from "@/components/ConfirmDialog";
|
||||
import Modal from "@/components/Modal";
|
||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||
import AdminPageHeader from "@/components/admin/AdminPageHeader";
|
||||
import { BADGE_ICONS } from "@/lib/badgeIcons";
|
||||
import { toast } from "@/lib/toast";
|
||||
import {
|
||||
@@ -32,8 +33,8 @@ export default function BadgesClient() {
|
||||
const [badges, setBadges] = useState<BadgeWithHolders[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
// 编辑/新建表单
|
||||
const [editing, setEditing] = useState<BadgeWithHolders | null>(null);
|
||||
// 主从布局:左侧列表选中项 / 右侧详情(编辑表单 + 持有者)
|
||||
const [selectedId, setSelectedId] = useState<number | null>(null);
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [form, setForm] = useState<BadgeInput>({ name: "", icon: "medal", color: "accent" });
|
||||
const [saving, setSaving] = useState(false);
|
||||
@@ -43,7 +44,6 @@ export default function BadgesClient() {
|
||||
const [deletingBusy, setDeletingBusy] = useState(false);
|
||||
|
||||
// 持有者面板
|
||||
const [holdersFor, setHoldersFor] = useState<BadgeWithHolders | null>(null);
|
||||
const [holders, setHolders] = useState<BadgeHolder[]>([]);
|
||||
const [holdersLoading, setHoldersLoading] = useState(false);
|
||||
const [userQuery, setUserQuery] = useState("");
|
||||
@@ -51,6 +51,8 @@ export default function BadgesClient() {
|
||||
const [searching, setSearching] = useState(false);
|
||||
const [grantingId, setGrantingId] = useState<number | null>(null);
|
||||
|
||||
const detailRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reload = useCallback(async () => {
|
||||
try {
|
||||
const res = await apiAdminListBadges();
|
||||
@@ -66,22 +68,85 @@ export default function BadgesClient() {
|
||||
void reload();
|
||||
}, [reload]);
|
||||
|
||||
const selected = useMemo(
|
||||
() => badges.find((b) => b.id === selectedId) ?? null,
|
||||
[badges, selectedId],
|
||||
);
|
||||
|
||||
const openCreate = () => {
|
||||
setForm({ name: "", icon: "medal", color: "accent" });
|
||||
setEditing(null);
|
||||
setCreating(true);
|
||||
setSelectedId(null);
|
||||
};
|
||||
|
||||
const openEdit = (b: BadgeWithHolders) => {
|
||||
setForm({ name: b.name, icon: b.icon || "medal", color: b.color || "accent" });
|
||||
const selectBadge = (b: BadgeWithHolders, scroll = true) => {
|
||||
setCreating(false);
|
||||
setEditing(b);
|
||||
setSelectedId(b.id);
|
||||
// 窄屏单列时,点选后滚动到右侧详情
|
||||
if (scroll && window.matchMedia("(max-width: 1023px)").matches) {
|
||||
detailRef.current?.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
};
|
||||
|
||||
const closeForm = () => {
|
||||
setCreating(false);
|
||||
setEditing(null);
|
||||
};
|
||||
// 首次加载完成后自动选中第一个徽章(删除刷新期间跳过,避免选中已删除项)
|
||||
useEffect(() => {
|
||||
if (loading || creating || deletingBusy || selectedId != null) return;
|
||||
if (badges.length > 0) selectBadge(badges[0], false);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [loading, badges, creating, deletingBusy, selectedId]);
|
||||
|
||||
// 选中项被删除时回落到第一个(或清空)
|
||||
useEffect(() => {
|
||||
if (creating || loading || selectedId == null) return;
|
||||
if (!badges.some((b) => b.id === selectedId)) {
|
||||
setSelectedId(badges.length > 0 ? badges[0].id : null);
|
||||
}
|
||||
}, [badges, selectedId, creating, loading]);
|
||||
|
||||
// 选中变化:同步表单草稿并重置搜索(badges 刻意不作为依赖,避免授予/撤销刷新时清掉搜索结果与未保存草稿)
|
||||
useEffect(() => {
|
||||
if (creating || selectedId == null) return;
|
||||
const sel = badges.find((b) => b.id === selectedId);
|
||||
if (!sel) return;
|
||||
setForm({ name: sel.name, icon: sel.icon || "medal", color: sel.color || "accent" });
|
||||
setUserQuery("");
|
||||
setSearchResults([]);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [selectedId, creating]);
|
||||
|
||||
// 持有者加载:选中变化或徽章列表刷新(授予/撤销后)重新拉取
|
||||
useEffect(() => {
|
||||
if (creating || selectedId == null) {
|
||||
setHolders([]);
|
||||
setHoldersLoading(false);
|
||||
return;
|
||||
}
|
||||
let alive = true;
|
||||
setHoldersLoading(true);
|
||||
apiAdminBadgeHolders(selectedId)
|
||||
.then((res) => {
|
||||
if (alive) setHolders(res.holders ?? []);
|
||||
})
|
||||
.catch((e) => {
|
||||
if (alive) {
|
||||
toast(e instanceof Error ? e.message : "获取持有者失败");
|
||||
setHolders([]);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (alive) setHoldersLoading(false);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, [selectedId, creating, badges]);
|
||||
|
||||
const formDirty =
|
||||
creating ||
|
||||
(selected != null &&
|
||||
(form.name !== selected.name ||
|
||||
form.icon !== (selected.icon || "medal") ||
|
||||
form.color !== (selected.color || "accent")));
|
||||
|
||||
const saveForm = async () => {
|
||||
if (saving) return;
|
||||
@@ -91,14 +156,15 @@ export default function BadgesClient() {
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) {
|
||||
await apiAdminUpdateBadge(editing.id, form);
|
||||
toast("徽章已更新", "ok");
|
||||
} else {
|
||||
await apiAdminCreateBadge(form);
|
||||
if (creating) {
|
||||
const created = await apiAdminCreateBadge(form);
|
||||
toast("徽章已创建", "ok");
|
||||
setCreating(false);
|
||||
setSelectedId(created.id);
|
||||
} else if (selected) {
|
||||
await apiAdminUpdateBadge(selected.id, form);
|
||||
toast("徽章已更新", "ok");
|
||||
}
|
||||
closeForm();
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "保存失败");
|
||||
@@ -114,7 +180,7 @@ export default function BadgesClient() {
|
||||
await apiAdminDeleteBadge(deleting.id);
|
||||
toast("徽章已删除", "ok");
|
||||
setDeleting(null);
|
||||
if (holdersFor?.id === deleting.id) setHoldersFor(null);
|
||||
if (selectedId === deleting.id) setSelectedId(null);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "删除失败");
|
||||
@@ -123,22 +189,6 @@ export default function BadgesClient() {
|
||||
}
|
||||
};
|
||||
|
||||
const openHolders = async (b: BadgeWithHolders) => {
|
||||
setHoldersFor(b);
|
||||
setUserQuery("");
|
||||
setSearchResults([]);
|
||||
setHoldersLoading(true);
|
||||
try {
|
||||
const res = await apiAdminBadgeHolders(b.id);
|
||||
setHolders(res.holders ?? []);
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "获取持有者失败");
|
||||
setHolders([]);
|
||||
} finally {
|
||||
setHoldersLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const searchUsers = async () => {
|
||||
const q = userQuery.trim();
|
||||
if (!q || searching) return;
|
||||
@@ -161,12 +211,11 @@ export default function BadgesClient() {
|
||||
};
|
||||
|
||||
const grant = async (userId: number) => {
|
||||
if (!holdersFor || grantingId != null) return;
|
||||
if (!selected || grantingId != null) return;
|
||||
setGrantingId(userId);
|
||||
try {
|
||||
await apiAdminGrantBadge(holdersFor.id, userId);
|
||||
await apiAdminGrantBadge(selected.id, userId);
|
||||
toast("已授予", "ok");
|
||||
await openHolders(holdersFor);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "授予失败");
|
||||
@@ -176,254 +225,335 @@ export default function BadgesClient() {
|
||||
};
|
||||
|
||||
const revoke = async (userId: number) => {
|
||||
if (!holdersFor) return;
|
||||
if (!selected) return;
|
||||
try {
|
||||
await apiAdminRevokeBadge(holdersFor.id, userId);
|
||||
await apiAdminRevokeBadge(selected.id, userId);
|
||||
toast("已撤销", "ok");
|
||||
await openHolders(holdersFor);
|
||||
await reload();
|
||||
} catch (e) {
|
||||
toast(e instanceof Error ? e.message : "撤销失败");
|
||||
}
|
||||
};
|
||||
|
||||
// 授予/撤销后 reload 刷新持有数;这里再同步持有者列表
|
||||
useEffect(() => {
|
||||
if (selectedId == null || creating) return;
|
||||
setHoldersLoading(true);
|
||||
apiAdminBadgeHolders(selectedId)
|
||||
.then((res) => setHolders(res.holders ?? []))
|
||||
.catch(() => setHolders([]))
|
||||
.finally(() => setHoldersLoading(false));
|
||||
}, [badges, selectedId, creating]);
|
||||
|
||||
const holdersHeldIds = useMemo(() => new Set(holders.map((h) => h.user_id)), [holders]);
|
||||
|
||||
const nameOk = runeCount(form.name.trim()) >= 1 && runeCount(form.name.trim()) <= MAX_NAME_RUNES;
|
||||
|
||||
return (
|
||||
<div className="admin-settings-stack">
|
||||
<div className="flex items-center justify-between flex-wrap gap-3">
|
||||
<div>
|
||||
<h2 className="text-lg font-bold" style={{ color: "var(--ink)" }}>
|
||||
徽章管理
|
||||
</h2>
|
||||
<p className="meta text-sm mt-1">
|
||||
管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。
|
||||
</p>
|
||||
</div>
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建徽章
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="meta px-1 py-6">加载徽章列表…</p>
|
||||
) : badges.length === 0 ? (
|
||||
<div className="panel text-center py-12">
|
||||
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="panel p-0 overflow-hidden">
|
||||
{badges.map((b, i) => (
|
||||
<div
|
||||
key={b.id}
|
||||
className="flex items-center gap-3 px-4 py-3 flex-wrap"
|
||||
style={{ borderTop: i === 0 ? "none" : "1px solid var(--line)" }}
|
||||
>
|
||||
<BadgeChip badge={b} />
|
||||
<span className="meta text-xs">
|
||||
{b.icon || "medal"} · {b.color || "accent"} · {b.holders} 人持有
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
onClick={() => void openHolders(b)}
|
||||
>
|
||||
<Users size={13} /> 持有者
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => openEdit(b)}>
|
||||
<Pencil size={13} /> 编辑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => setDeleting(b)}
|
||||
>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 持有者面板 */}
|
||||
<Modal
|
||||
open={holdersFor != null}
|
||||
title={`持有者 · ${holdersFor?.name ?? ""}`}
|
||||
onClose={() => setHoldersFor(null)}
|
||||
maxWidthClass="max-w-lg"
|
||||
>
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void searchUsers();
|
||||
}}
|
||||
placeholder="输入用户名或昵称搜索"
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
disabled={searching || !userQuery.trim()}
|
||||
onClick={() => void searchUsers()}
|
||||
>
|
||||
{searching ? "搜索中…" : "搜索"}
|
||||
<AdminPageHeader
|
||||
icon={Award}
|
||||
title="徽章管理"
|
||||
description="管理员自定义徽章:全站作者处展示前 2 枚,个人主页展示徽章墙;授予后用户会收到站内通知。"
|
||||
actions={
|
||||
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
|
||||
<Plus size={14} /> 新建徽章
|
||||
</button>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="meta text-xs mb-1.5">搜索结果</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{searchResults.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.nickname || u.username}
|
||||
<span className="meta ml-1">@{u.username}</span>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{holdersHeldIds.has(u.id) ? (
|
||||
<span className="meta text-xs">已持有</span>
|
||||
) : (
|
||||
}
|
||||
/>
|
||||
|
||||
<div className="grid gap-5 items-start lg:grid-cols-2">
|
||||
{/* 左半屏:徽章列表 */}
|
||||
<section className="panel p-0 overflow-hidden" aria-label="徽章列表">
|
||||
<div
|
||||
className="flex items-center justify-between gap-2 px-4 py-3"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="text-[13.5px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
全部徽章
|
||||
</p>
|
||||
{!loading && badges.length > 0 && (
|
||||
<span className="meta text-xs tabular-nums">{badges.length} 枚</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<p className="meta px-4 py-6">加载徽章列表…</p>
|
||||
) : badges.length === 0 ? (
|
||||
<div className="px-4 py-12 text-center">
|
||||
<p className="meta">还没有徽章,点击右上角“新建徽章”开始。</p>
|
||||
</div>
|
||||
) : (
|
||||
<ul>
|
||||
{badges.map((b, i) => {
|
||||
const active = !creating && b.id === selectedId;
|
||||
return (
|
||||
<li key={b.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={grantingId === u.id}
|
||||
onClick={() => void grant(u.id)}
|
||||
onClick={() => selectBadge(b)}
|
||||
aria-current={active ? "true" : undefined}
|
||||
className="w-full flex items-center gap-3 px-4 py-3 text-left transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{
|
||||
borderTop: i === 0 ? "none" : "1px solid var(--line)",
|
||||
background: active ? "var(--accent-soft)" : undefined,
|
||||
}}
|
||||
>
|
||||
{grantingId === u.id ? "授予中…" : "授予"}
|
||||
<BadgeChip badge={b} />
|
||||
<span className="meta text-xs">{b.holders} 人持有</span>
|
||||
<span className="flex-1" />
|
||||
<ChevronRight
|
||||
size={14}
|
||||
className="shrink-0 transition-transform duration-200"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink-3)",
|
||||
transform: active ? "translateX(2px)" : "none",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* 右半屏:编辑表单 + 持有者 */}
|
||||
<div ref={detailRef} className="flex flex-col gap-5 min-w-0">
|
||||
{creating || selected ? (
|
||||
<>
|
||||
{/* 编辑/新建表单 */}
|
||||
<section className="panel overflow-hidden">
|
||||
<div
|
||||
className="flex items-center justify-between gap-3 px-4 py-3.5 sm:px-5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
{creating ? "新建徽章" : "编辑徽章"}
|
||||
</p>
|
||||
{!creating && selected && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => setDeleting(selected)}
|
||||
>
|
||||
<Trash2 size={13} /> 删除
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
|
||||
{holdersLoading ? (
|
||||
<p className="meta text-sm py-3">加载持有者…</p>
|
||||
) : holders.length === 0 ? (
|
||||
<p className="meta text-sm py-3">暂无人持有。</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-64 overflow-y-auto">
|
||||
{holders.map((h) => (
|
||||
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{h.nickname || h.username}
|
||||
<span className="meta ml-1">@{h.username}</span>
|
||||
</span>
|
||||
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => void revoke(h.user_id)}
|
||||
<div className="flex flex-col gap-4 px-4 py-4 sm:px-5">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
名称(1–{MAX_NAME_RUNES} 字)
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
maxLength={MAX_NAME_RUNES * 2}
|
||||
placeholder="如:元老、技术之星"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
图标
|
||||
</label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{Object.keys(BADGE_ICONS).map((key) => {
|
||||
const Icon = BADGE_ICONS[key];
|
||||
const active = form.icon === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-label={key}
|
||||
title={key}
|
||||
onClick={() => setForm((f) => ({ ...f, icon: key }))}
|
||||
className="h-9 rounded-lg inline-flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||
outline: active ? "1.5px solid var(--accent)" : "none",
|
||||
}}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
颜色
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{BADGE_COLORS.map((c) => {
|
||||
const active = form.color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={c}
|
||||
title={c}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
className="px-3 h-8 rounded-full text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: `var(--${c}-soft)`,
|
||||
color: `var(--${c})`,
|
||||
outline: active ? "1.5px solid var(--ink)" : "1.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="meta text-xs mb-1.5">预览</p>
|
||||
<BadgeChip badge={{ name: form.name.trim() || "徽章名称", icon: form.icon, color: form.color }} />
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className="flex justify-end gap-2 px-4 py-3 sm:px-5"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
{creating ? (
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={() => setCreating(false)}>
|
||||
取消
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
disabled={!formDirty || saving}
|
||||
onClick={() => selected && setForm({ name: selected.name, icon: selected.icon || "medal", color: selected.color || "accent" })}
|
||||
>
|
||||
重置
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || !nameOk || (!creating && !formDirty)}
|
||||
onClick={() => void saveForm()}
|
||||
>
|
||||
{saving ? "保存中…" : creating ? "创建" : "保存修改"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* 新建/编辑表单 */}
|
||||
<Modal
|
||||
open={creating || editing != null}
|
||||
title={editing ? "编辑徽章" : "新建徽章"}
|
||||
onClose={closeForm}
|
||||
maxWidthClass="max-w-md"
|
||||
>
|
||||
<div className="flex flex-col gap-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
名称(1–{MAX_NAME_RUNES} 字)
|
||||
</label>
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))}
|
||||
maxLength={MAX_NAME_RUNES * 2}
|
||||
placeholder="如:元老、技术之星"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
图标
|
||||
</label>
|
||||
<div className="grid grid-cols-8 gap-1.5">
|
||||
{Object.keys(BADGE_ICONS).map((key) => {
|
||||
const Icon = BADGE_ICONS[key];
|
||||
const active = form.icon === key;
|
||||
return (
|
||||
<button
|
||||
key={key}
|
||||
type="button"
|
||||
aria-label={key}
|
||||
title={key}
|
||||
onClick={() => setForm((f) => ({ ...f, icon: key }))}
|
||||
className="h-9 rounded-lg inline-flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
background: active ? "var(--accent-soft)" : "var(--panel-2)",
|
||||
color: active ? "var(--accent)" : "var(--ink-2)",
|
||||
outline: active ? "1.5px solid var(--accent)" : "none",
|
||||
}}
|
||||
{/* 持有者管理 */}
|
||||
{!creating && selected && (
|
||||
<section className="panel overflow-hidden">
|
||||
<div
|
||||
className="flex items-center gap-2.5 px-4 py-3.5 sm:px-5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<Icon size={16} />
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium block mb-1.5" style={{ color: "var(--ink)" }}>
|
||||
颜色
|
||||
</label>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{BADGE_COLORS.map((c) => {
|
||||
const active = form.color === c;
|
||||
return (
|
||||
<button
|
||||
key={c}
|
||||
type="button"
|
||||
aria-label={c}
|
||||
title={c}
|
||||
onClick={() => setForm((f) => ({ ...f, color: c }))}
|
||||
className="px-3 h-8 rounded-full text-xs font-medium transition-colors"
|
||||
style={{
|
||||
background: `var(--${c}-soft)`,
|
||||
color: `var(--${c})`,
|
||||
outline: active ? "1.5px solid var(--ink)" : "1.5px solid transparent",
|
||||
}}
|
||||
>
|
||||
{c}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="meta text-xs mb-1.5">预览</p>
|
||||
<BadgeChip badge={{ name: form.name.trim() || "徽章名称", icon: form.icon, color: form.color }} />
|
||||
</div>
|
||||
<span
|
||||
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden
|
||||
>
|
||||
<Users size={16} />
|
||||
</span>
|
||||
<p className="text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
持有者
|
||||
<span className="meta ml-2 text-[12px] font-normal tabular-nums">{holders.length} 人</span>
|
||||
</p>
|
||||
</div>
|
||||
<div className="px-4 py-4 sm:px-5">
|
||||
<div className="flex gap-2 mb-4">
|
||||
<input
|
||||
value={userQuery}
|
||||
onChange={(e) => setUserQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") void searchUsers();
|
||||
}}
|
||||
placeholder="输入用户名或昵称搜索"
|
||||
className="flex-1 min-w-0"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
disabled={searching || !userQuery.trim()}
|
||||
onClick={() => void searchUsers()}
|
||||
>
|
||||
{searching ? "搜索中…" : "搜索"}
|
||||
</button>
|
||||
</div>
|
||||
{searchResults.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<p className="meta text-xs mb-1.5">搜索结果</p>
|
||||
<div className="flex flex-col gap-1">
|
||||
{searchResults.map((u) => (
|
||||
<div key={u.id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<Avatar name={u.nickname || u.username} src={u.avatar || undefined} size={26} />
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.nickname || u.username}
|
||||
<span className="meta ml-1">@{u.username}</span>
|
||||
</span>
|
||||
<span className="flex-1" />
|
||||
{holdersHeldIds.has(u.id) ? (
|
||||
<span className="meta text-xs">已持有</span>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={grantingId === u.id}
|
||||
onClick={() => void grant(u.id)}
|
||||
>
|
||||
{grantingId === u.id ? "授予中…" : "授予"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<p className="meta text-xs mb-1.5">当前持有者({holders.length})</p>
|
||||
{holdersLoading ? (
|
||||
<p className="meta text-sm py-3">加载持有者…</p>
|
||||
) : holders.length === 0 ? (
|
||||
<p className="meta text-sm py-3">暂无人持有。</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-1 max-h-72 overflow-y-auto">
|
||||
{holders.map((h) => (
|
||||
<div key={h.user_id} className="flex items-center gap-2.5 px-2 py-1.5 rounded-lg" style={{ background: "var(--panel-2)" }}>
|
||||
<span className="text-sm truncate" style={{ color: "var(--ink)" }}>
|
||||
{h.nickname || h.username}
|
||||
<span className="meta ml-1">@{h.username}</span>
|
||||
</span>
|
||||
<span className="meta text-xs">{new Date(h.awarded_at).toLocaleDateString("zh-CN")} 授予</span>
|
||||
<span className="flex-1" />
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-line btn-sm"
|
||||
style={{ color: "var(--danger)" }}
|
||||
onClick={() => void revoke(h.user_id)}
|
||||
>
|
||||
撤销
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<section className="panel">
|
||||
<AdminEmpty
|
||||
icon={<Award size={22} />}
|
||||
title={loading ? "加载中…" : "从左侧选择一个徽章"}
|
||||
description={loading ? undefined : "选中后可在此编辑名称、图标与颜色,管理持有者"}
|
||||
/>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 mt-5">
|
||||
<button type="button" className="btn btn-line btn-sm" onClick={closeForm}>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary btn-sm"
|
||||
disabled={saving || runeCount(form.name.trim()) === 0}
|
||||
onClick={() => void saveForm()}
|
||||
>
|
||||
{saving ? "保存中…" : "保存"}
|
||||
</button>
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<ConfirmDialog
|
||||
|
||||
274
frontend/app/admin/media/MediaLibrary.tsx
Normal file
274
frontend/app/admin/media/MediaLibrary.tsx
Normal file
@@ -0,0 +1,274 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { Images, Copy, ExternalLink, Eye, Check } from "lucide-react";
|
||||
import Modal from "@/components/Modal";
|
||||
import AdminEmpty from "@/components/admin/AdminEmpty";
|
||||
import type { MediaLibraryItem } from "@/lib/api";
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / 1024 / 1024).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
function formatTime(iso?: string): string {
|
||||
if (!iso) return "—";
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return "—";
|
||||
return d.toLocaleString("zh-CN", { hour12: false });
|
||||
}
|
||||
|
||||
function formatDims(item: MediaLibraryItem): string {
|
||||
if (item.width > 0 && item.height > 0) return `${item.width} × ${item.height}`;
|
||||
return "—";
|
||||
}
|
||||
|
||||
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
|
||||
function thumbSrc(item: MediaLibraryItem): string {
|
||||
if (item.url.startsWith("/uploads/")) {
|
||||
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
|
||||
}
|
||||
return item.url;
|
||||
}
|
||||
|
||||
/** 管理后台媒体库:全站图片网格 + 分类筛选 + 详情弹窗(WebP 优先,可查看原图) */
|
||||
export default function MediaLibrary({
|
||||
items,
|
||||
counts,
|
||||
total,
|
||||
initError,
|
||||
}: {
|
||||
items: MediaLibraryItem[];
|
||||
counts: Record<string, number>;
|
||||
total: number;
|
||||
initError?: string;
|
||||
}) {
|
||||
const [category, setCategory] = useState<string>("all");
|
||||
const [active, setActive] = useState<MediaLibraryItem | null>(null);
|
||||
const [showOriginal, setShowOriginal] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const filtered = useMemo(
|
||||
() => (category === "all" ? items : items.filter((i) => i.category === category)),
|
||||
[items, category]
|
||||
);
|
||||
|
||||
const tabs = useMemo(() => {
|
||||
const list: { key: string; label: string; count: number }[] = [
|
||||
{ key: "all", label: "全部", count: total },
|
||||
];
|
||||
// 按现有条目实际分类生成 tab,保持稳定顺序
|
||||
const seen = new Map<string, string>();
|
||||
for (const i of items) seen.set(i.category, i.category_name);
|
||||
for (const [key, label] of seen) {
|
||||
list.push({ key, label, count: counts[key] ?? 0 });
|
||||
}
|
||||
return list;
|
||||
}, [items, counts, total]);
|
||||
|
||||
const openItem = (item: MediaLibraryItem) => {
|
||||
setActive(item);
|
||||
setShowOriginal(false);
|
||||
setCopied(false);
|
||||
};
|
||||
|
||||
const copyLink = async (item: MediaLibraryItem) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${window.location.origin}${item.url}`);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
/* 剪贴板不可用静默 */
|
||||
}
|
||||
};
|
||||
|
||||
if (initError) {
|
||||
return (
|
||||
<AdminEmpty
|
||||
icon={<Images size={20} />}
|
||||
title="媒体库加载失败"
|
||||
description={initError}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
return (
|
||||
<AdminEmpty
|
||||
icon={<Images size={20} />}
|
||||
title="暂无图片资源"
|
||||
description="站点内还没有任何上传图片"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* 分类 tab */}
|
||||
<div className="flex items-center gap-1.5 flex-wrap mb-4">
|
||||
{tabs.map((t) => {
|
||||
const on = category === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setCategory(t.key)}
|
||||
aria-pressed={on}
|
||||
className="h-8 px-3 rounded-full text-[12.5px] font-medium inline-flex items-center gap-1.5 transition-colors"
|
||||
style={{
|
||||
border: `1px solid ${on ? "var(--accent)" : "var(--line)"}`,
|
||||
background: on ? "var(--accent-soft)" : "var(--panel)",
|
||||
color: on ? "var(--accent)" : "var(--ink-2)",
|
||||
}}
|
||||
>
|
||||
{t.label}
|
||||
<span className="tabular-nums opacity-70">{t.count}</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 图片网格 */}
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-6 gap-3">
|
||||
{filtered.map((item) => (
|
||||
<button
|
||||
key={item.url}
|
||||
type="button"
|
||||
onClick={() => openItem(item)}
|
||||
className="group text-left rounded-xl overflow-hidden transition-transform hover:-translate-y-0.5 focus-visible:-translate-y-0.5"
|
||||
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
|
||||
title={item.name}
|
||||
>
|
||||
<span className="block relative aspect-square overflow-hidden">
|
||||
<img
|
||||
src={thumbSrc(item)}
|
||||
alt={item.name}
|
||||
loading="lazy"
|
||||
className="absolute inset-0 w-full h-full object-cover"
|
||||
/>
|
||||
{item.original_url && (
|
||||
<span
|
||||
className="absolute top-1.5 left-1.5 px-1.5 py-0.5 rounded text-[10px] font-semibold"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
>
|
||||
WebP
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="block px-2.5 py-2">
|
||||
<span
|
||||
className="block text-[12px] font-medium truncate"
|
||||
style={{ color: "var(--ink)" }}
|
||||
>
|
||||
{item.name}
|
||||
</span>
|
||||
<span className="block meta text-[11px] mt-0.5">
|
||||
{formatDims(item)} · {formatBytes(item.size)}
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 详情弹窗 */}
|
||||
<Modal
|
||||
open={active !== null}
|
||||
onClose={() => setActive(null)}
|
||||
title={active?.name ?? ""}
|
||||
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
|
||||
maxWidthClass="max-w-2xl"
|
||||
dismissOnOverlay
|
||||
bodyClassName=""
|
||||
>
|
||||
{active && (
|
||||
<div>
|
||||
<div
|
||||
className="rounded-xl overflow-hidden flex items-center justify-center"
|
||||
style={{
|
||||
background:
|
||||
"repeating-conic-gradient(var(--panel-2) 0% 25%, var(--panel) 0% 50%) 50% / 20px 20px",
|
||||
maxHeight: "52vh",
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={showOriginal && active.original_url ? active.original_url : active.url}
|
||||
alt={active.name}
|
||||
className="max-w-full max-h-[52vh] w-auto h-auto object-contain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{active.original_url && (
|
||||
<p className="meta text-[12px] mt-2.5">
|
||||
{showOriginal
|
||||
? "当前显示原图(原格式,体积更大)"
|
||||
: "当前显示 WebP 压缩版;站点内容引用的均为 WebP"}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<dl className="mt-4 grid grid-cols-2 gap-x-4 gap-y-2 text-[12.5px]">
|
||||
{[
|
||||
["分类", active.category_name],
|
||||
["尺寸", formatDims(active)],
|
||||
["大小", formatBytes(active.size)],
|
||||
["格式", active.mime],
|
||||
["上传者", active.uploader || "—"],
|
||||
["时间", formatTime(active.uploaded_at)],
|
||||
].map(([k, v]) => (
|
||||
<div key={k} className="flex items-baseline gap-2 min-w-0">
|
||||
<dt className="meta shrink-0 w-11 text-right">{k}</dt>
|
||||
<dd className="truncate" style={{ color: "var(--ink)" }} title={v}>
|
||||
{v}
|
||||
</dd>
|
||||
</div>
|
||||
))}
|
||||
{active.source_url && (
|
||||
<div className="flex items-baseline gap-2 min-w-0 col-span-2">
|
||||
<dt className="meta shrink-0 w-11 text-right">来源</dt>
|
||||
<dd className="truncate">
|
||||
<a
|
||||
href={active.source_url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={active.source_label || active.source_url}
|
||||
className="hover:underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
{active.source_label || active.source_url}
|
||||
</a>
|
||||
</dd>
|
||||
</div>
|
||||
)}
|
||||
</dl>
|
||||
|
||||
<div className="mt-5 flex items-center justify-end gap-2 flex-wrap">
|
||||
{active.original_url && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm"
|
||||
onClick={() => setShowOriginal((v) => !v)}
|
||||
>
|
||||
<Eye size={14} aria-hidden />
|
||||
{showOriginal ? "看 WebP" : "查看原图"}
|
||||
</button>
|
||||
)}
|
||||
<button type="button" className="btn btn-sm" onClick={() => copyLink(active)}>
|
||||
{copied ? <Check size={14} aria-hidden /> : <Copy size={14} aria-hidden />}
|
||||
{copied ? "已复制" : "复制链接"}
|
||||
</button>
|
||||
<a
|
||||
className="btn btn-sm"
|
||||
href={active.original_url && showOriginal ? active.original_url : active.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
新窗口打开
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
49
frontend/app/admin/media/page.tsx
Normal file
49
frontend/app/admin/media/page.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import MediaLibrary from "./MediaLibrary";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { fetchAdminMediaLibrary, type MediaLibraryItem } from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isSuperOrOwner } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
import AdminPageHeader from "@/components/admin/AdminPageHeader";
|
||||
import { Images } from "lucide-react";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "媒体库",
|
||||
};
|
||||
|
||||
export default async function AdminMediaPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isSuperOrOwner(me.user.role)) {
|
||||
return <Forbidden text="媒体库仅超级管理员及以上角色可查看。" />;
|
||||
}
|
||||
|
||||
let items: MediaLibraryItem[] = [];
|
||||
let counts: Record<string, number> = {};
|
||||
let total = 0;
|
||||
let initError = "";
|
||||
try {
|
||||
const res = await fetchAdminMediaLibrary(cookie || undefined);
|
||||
items = res.items ?? [];
|
||||
counts = res.counts ?? {};
|
||||
total = res.total ?? 0;
|
||||
} catch (e) {
|
||||
initError = e instanceof Error ? e.message : "加载失败";
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<AdminPageHeader
|
||||
icon={Images}
|
||||
title="媒体库"
|
||||
description={`站点内全部图片资源盘点(共 ${total} 张)。同名图片同时存在原图与 WebP 时仅展示 WebP,点开可查看原图。`}
|
||||
/>
|
||||
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||
<MediaLibrary items={items} counts={counts} total={total} initError={initError} />
|
||||
</Suspense>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
ClipboardCheck,
|
||||
Database,
|
||||
FileUp,
|
||||
Images,
|
||||
ListChecks,
|
||||
ListX,
|
||||
Loader2,
|
||||
@@ -22,9 +23,8 @@ import {
|
||||
} from "@/components/admin";
|
||||
import {
|
||||
apiAdminImportLegacy,
|
||||
type LegacyCommentReport,
|
||||
type LegacyImportReport,
|
||||
type LegacyPostReport,
|
||||
type LegacyUserPreview,
|
||||
} from "@/lib/api";
|
||||
|
||||
/** 数据源清单:enabled=false 的来源展示为「计划中」,为后续 WordPress/Typecho 适配预留 */
|
||||
@@ -34,27 +34,40 @@ const SOURCES = [
|
||||
{ key: "typecho", label: "Typecho", enabled: false },
|
||||
] as const;
|
||||
|
||||
/** 单个旧用户的导入选择:是否建号 + 内容归属 */
|
||||
type UserSel = { create: boolean; owner: "auto" | "operator" | "custom"; target: string };
|
||||
|
||||
/**
|
||||
* 站点设置 → 数据导入:
|
||||
* 上传旧站数据库(+可选头像包)→ 预检(dry-run,不写库)→ 确认正式导入。
|
||||
* 上传旧站数据库(+可选头像包 / 帖子图片包)→ 预检(dry-run,返回用户/帖子/评论清单)
|
||||
* → 勾选导入范围(是否建号、内容归属、排除帖子/评论)→ 确认正式导入。
|
||||
* 全程幂等:已导入对象自动跳过,可安全重复执行。
|
||||
*/
|
||||
export default function ImportPanel() {
|
||||
const [source, setSource] = useState<string>("jiang13");
|
||||
const [dbFile, setDbFile] = useState<File | null>(null);
|
||||
const [zipFile, setZipFile] = useState<File | null>(null);
|
||||
const [imagesZipFile, setImagesZipFile] = useState<File | null>(null);
|
||||
const [withContent, setWithContent] = useState(true);
|
||||
const [busy, setBusy] = useState<"check" | "import" | null>(null);
|
||||
const [error, setError] = useState("");
|
||||
const [report, setReport] = useState<LegacyImportReport | null>(null);
|
||||
const [preview, setPreview] = useState<LegacyImportReport | null>(null); // 预检清单
|
||||
const [report, setReport] = useState<LegacyImportReport | null>(null); // 最近一次执行结果
|
||||
const dbInputRef = useRef<HTMLInputElement>(null);
|
||||
const zipInputRef = useRef<HTMLInputElement>(null);
|
||||
const imagesZipInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 勾选状态(预检后按清单初始化,默认全部导入)
|
||||
const [userSel, setUserSel] = useState<Record<number, UserSel>>({});
|
||||
const [postChecked, setPostChecked] = useState<Set<number>>(new Set());
|
||||
const [commentChecked, setCommentChecked] = useState<Set<number>>(new Set());
|
||||
|
||||
// 「确认导入」只有在当前配置下跑过预检后才可用;文件/选项变化即重置
|
||||
const [preflighted, setPreflighted] = useState(false);
|
||||
|
||||
const resetResult = () => {
|
||||
setPreflighted(false);
|
||||
setPreview(null);
|
||||
setReport(null);
|
||||
setError("");
|
||||
};
|
||||
@@ -67,22 +80,73 @@ export default function ImportPanel() {
|
||||
setZipFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const pickImagesZip = (f: File | null) => {
|
||||
setImagesZipFile(f);
|
||||
resetResult();
|
||||
};
|
||||
const toggleContent = () => {
|
||||
setWithContent((v) => !v);
|
||||
resetResult();
|
||||
};
|
||||
|
||||
const initSelections = (rep: LegacyImportReport) => {
|
||||
const users: Record<number, UserSel> = {};
|
||||
(rep.user_list ?? []).forEach((u) => {
|
||||
users[u.id] = { create: true, owner: "auto", target: "" };
|
||||
});
|
||||
setUserSel(users);
|
||||
setPostChecked(new Set((rep.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id)));
|
||||
setCommentChecked(new Set((rep.comment_list ?? []).filter((c) => c.published).map((c) => c.id)));
|
||||
};
|
||||
|
||||
// 由勾选状态构造请求参数(相对预检清单求差集)
|
||||
const buildOptions = () => {
|
||||
const skipUsers: number[] = [];
|
||||
const operatorUsers: number[] = [];
|
||||
const userMap: { oldId: number; username: string }[] = [];
|
||||
Object.entries(userSel).forEach(([idStr, sel]) => {
|
||||
const id = Number(idStr);
|
||||
if (sel.owner === "operator") {
|
||||
skipUsers.push(id);
|
||||
operatorUsers.push(id);
|
||||
} else if (sel.owner === "custom" && sel.target.trim()) {
|
||||
skipUsers.push(id);
|
||||
userMap.push({ oldId: id, username: sel.target.trim() });
|
||||
} else if (!sel.create) {
|
||||
skipUsers.push(id);
|
||||
}
|
||||
});
|
||||
return {
|
||||
withContent,
|
||||
dryRun: false,
|
||||
skipUsers,
|
||||
operatorUsers,
|
||||
userMap,
|
||||
skipPosts: (preview?.post_list ?? [])
|
||||
.filter((p) => p.published && !p.poll && !postChecked.has(p.id))
|
||||
.map((p) => p.id),
|
||||
skipComments: (preview?.comment_list ?? [])
|
||||
.filter((c) => c.published && !commentChecked.has(c.id))
|
||||
.map((c) => c.id),
|
||||
};
|
||||
};
|
||||
|
||||
const run = async (dryRun: boolean) => {
|
||||
if (!dbFile || busy) return;
|
||||
setBusy(dryRun ? "check" : "import");
|
||||
setError("");
|
||||
try {
|
||||
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, {
|
||||
withContent,
|
||||
const opts = dryRun ? { withContent, dryRun } : buildOptions();
|
||||
const rep = await apiAdminImportLegacy(source, dbFile, zipFile, imagesZipFile, {
|
||||
...opts,
|
||||
dryRun,
|
||||
});
|
||||
setReport(rep);
|
||||
if (dryRun) setPreflighted(true);
|
||||
if (dryRun) {
|
||||
setPreview(rep);
|
||||
initSelections(rep);
|
||||
setPreflighted(true);
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : "导入失败");
|
||||
} finally {
|
||||
@@ -90,6 +154,25 @@ export default function ImportPanel() {
|
||||
}
|
||||
};
|
||||
|
||||
const setUser = (id: number, patch: Partial<UserSel>) => {
|
||||
setUserSel((m) => {
|
||||
const cur = m[id] ?? { create: true, owner: "auto" as const, target: "" };
|
||||
const next = { ...cur, ...patch };
|
||||
// 归属指定已有账号 / 站长时,账号不再新建
|
||||
if (next.owner !== "auto") next.create = false;
|
||||
return { ...m, [id]: next };
|
||||
});
|
||||
};
|
||||
|
||||
const toggleSet = (set: Set<number>, id: number) => {
|
||||
const next = new Set(set);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
};
|
||||
|
||||
const hasLists = withContent && !!preview?.post_list?.length;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
<AdminSettingsGroup
|
||||
@@ -145,12 +228,21 @@ export default function ImportPanel() {
|
||||
onPick={pickZip}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
<FilePickRow
|
||||
label="帖子图片压缩包(可选)"
|
||||
hint="把旧站 data/uploads/posts 目录压缩为 zip,最大 128MB;帖子/评论正文里的旧站图片会迁移到新站并改写链接"
|
||||
fileName={imagesZipFile?.name}
|
||||
inputRef={imagesZipInputRef}
|
||||
accept=".zip,application/zip"
|
||||
onPick={pickImagesZip}
|
||||
busy={busy !== null}
|
||||
/>
|
||||
</div>
|
||||
</AdminSettingsGroup>
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="导入范围"
|
||||
description="第二步:确认导入范围。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数;作者账号缺失时归到站长。投票帖暂不迁移。"
|
||||
description="第二步:确认是否导入内容。板块按旧板块名自动创建(同名复用);帖子与评论保留原发布时间与浏览/点赞数。投票帖暂不迁移。"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<AdminSwitch checked={withContent} onChange={toggleContent} label="同时导入帖子与评论" />
|
||||
@@ -162,7 +254,7 @@ export default function ImportPanel() {
|
||||
|
||||
<AdminSettingsGroup
|
||||
title="预检与导入"
|
||||
description="第三步:先预检确认将导入的内容,再正式写入。可重复执行,已导入的内容自动跳过。"
|
||||
description="第三步:先预检生成内容清单并勾选导入范围,再正式写入。可重复执行,已导入的内容自动跳过。"
|
||||
>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
@@ -190,7 +282,7 @@ export default function ImportPanel() {
|
||||
? "请先选择站点数据库"
|
||||
: !preflighted
|
||||
? "请先运行预检"
|
||||
: "预检通过,可以正式导入"}
|
||||
: "已生成清单,确认勾选后可正式导入"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -201,6 +293,38 @@ export default function ImportPanel() {
|
||||
</p>
|
||||
)}
|
||||
|
||||
{preflighted && preview?.user_list && preview.user_list.length > 0 && (
|
||||
<UserSelectList list={preview.user_list} sel={userSel} onChange={setUser} />
|
||||
)}
|
||||
{hasLists && preview && (
|
||||
<PostSelectList
|
||||
list={preview.post_list ?? []}
|
||||
checked={postChecked}
|
||||
onToggle={(id) => setPostChecked((s) => toggleSet(s, id))}
|
||||
onAll={(on) =>
|
||||
setPostChecked(
|
||||
on
|
||||
? new Set((preview.post_list ?? []).filter((p) => p.published && !p.poll).map((p) => p.id))
|
||||
: new Set()
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{hasLists && preview && !!preview.comment_list?.length && (
|
||||
<CommentSelectList
|
||||
list={preview.comment_list}
|
||||
checked={commentChecked}
|
||||
onToggle={(id) => setCommentChecked((s) => toggleSet(s, id))}
|
||||
onAll={(on) =>
|
||||
setCommentChecked(
|
||||
on
|
||||
? new Set(preview.comment_list!.filter((c) => c.published).map((c) => c.id))
|
||||
: new Set()
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
|
||||
{report && <ReportView report={report} />}
|
||||
</AdminSettingsGroup>
|
||||
</div>
|
||||
@@ -258,6 +382,222 @@ function FilePickRow({
|
||||
);
|
||||
}
|
||||
|
||||
// ===== 预检后勾选清单 =====
|
||||
|
||||
const fieldStyle = { background: "var(--panel)", border: "1px solid var(--line)" } as const;
|
||||
|
||||
function UserSelectList({
|
||||
list,
|
||||
sel,
|
||||
onChange,
|
||||
}: {
|
||||
list: LegacyUserPreview[];
|
||||
sel: Record<number, UserSel>;
|
||||
onChange: (id: number, patch: Partial<UserSel>) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
<Users size={14} aria-hidden />
|
||||
用户({list.length})——取消勾选则不创建账号,其内容按右侧归属落位
|
||||
</p>
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||||
{list.map((u) => {
|
||||
const s = sel[u.id] ?? { create: true, owner: "auto" as const, target: "" };
|
||||
return (
|
||||
<div
|
||||
key={u.id}
|
||||
className="rounded-lg px-3 py-2 flex items-center gap-2.5 flex-wrap"
|
||||
style={fieldStyle}
|
||||
>
|
||||
<label className="inline-flex items-center gap-2 text-[12.5px] shrink-0" style={{ color: "var(--ink)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--accent)]"
|
||||
checked={s.create}
|
||||
disabled={s.owner !== "auto"}
|
||||
onChange={(e) => onChange(u.id, { create: e.target.checked })}
|
||||
/>
|
||||
建号
|
||||
</label>
|
||||
<div className="min-w-0 flex-1 flex items-baseline gap-1.5 flex-wrap">
|
||||
<span className="text-[13px] font-semibold truncate" style={{ color: "var(--ink)" }}>
|
||||
{u.username}
|
||||
</span>
|
||||
{u.nickname && u.nickname !== u.username && (
|
||||
<span className="meta text-[12px] truncate">{u.nickname}</span>
|
||||
)}
|
||||
{u.exists && (
|
||||
<span className="text-[11px] font-semibold" style={{ color: "var(--warn, #b45309)" }}>
|
||||
同名已存在·导入时跳过建号
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<span className="meta text-[12px] shrink-0">
|
||||
帖 {u.posts} · 评 {u.comments}
|
||||
</span>
|
||||
<select
|
||||
value={s.owner}
|
||||
onChange={(e) => onChange(u.id, { owner: e.target.value as UserSel["owner"] })}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] shrink-0"
|
||||
style={fieldStyle}
|
||||
>
|
||||
<option value="auto">归属:跟随默认</option>
|
||||
<option value="operator">归到站长</option>
|
||||
<option value="custom">指定已有账号…</option>
|
||||
</select>
|
||||
{s.owner === "custom" && (
|
||||
<input
|
||||
value={s.target}
|
||||
placeholder="本站用户名"
|
||||
onChange={(e) => onChange(u.id, { target: e.target.value })}
|
||||
className="rounded-lg px-2 py-1 text-[12.5px] w-36 shrink-0"
|
||||
style={fieldStyle}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BulkBar({ label, onAll }: { label: string; onAll: (on: boolean) => void }) {
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1.5 text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
{label}
|
||||
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(true)}>
|
||||
全选
|
||||
</button>
|
||||
<button type="button" className="btn btn-line btn-xs" onClick={() => onAll(false)}>
|
||||
全不选
|
||||
</button>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function PostSelectList({
|
||||
list,
|
||||
checked,
|
||||
onToggle,
|
||||
onAll,
|
||||
}: {
|
||||
list: { id: number; title: string; author: string; board: string; created_at: string; poll: boolean; published: boolean }[];
|
||||
checked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
onAll: (on: boolean) => void;
|
||||
}) {
|
||||
const selectable = list.filter((p) => p.published && !p.poll).length;
|
||||
return (
|
||||
<div className="mt-2 flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<p className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
<ListChecks size={14} aria-hidden />
|
||||
帖子({selectable}/{list.length} 可导入)——取消勾选即排除,其评论一并跳过
|
||||
</p>
|
||||
<BulkBar label="批量:" onAll={onAll} />
|
||||
</div>
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||||
{list.map((p) => {
|
||||
const disabled = !p.published || p.poll;
|
||||
return (
|
||||
<label
|
||||
key={p.id}
|
||||
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${disabled ? "opacity-55" : "cursor-pointer"}`}
|
||||
style={fieldStyle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--accent)] shrink-0"
|
||||
checked={!disabled && checked.has(p.id)}
|
||||
disabled={disabled}
|
||||
onChange={() => onToggle(p.id)}
|
||||
/>
|
||||
<span className="text-[13px] truncate flex-1" style={{ color: "var(--ink)" }}>
|
||||
{p.title}
|
||||
</span>
|
||||
{p.poll && (
|
||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||||
投票帖·不可迁移
|
||||
</span>
|
||||
)}
|
||||
{!p.published && (
|
||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||||
非公开
|
||||
</span>
|
||||
)}
|
||||
<span className="meta text-[12px] shrink-0 truncate max-w-[180px]">
|
||||
{p.author} · {p.board} · {fmtDate(p.created_at)}
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentSelectList({
|
||||
list,
|
||||
checked,
|
||||
onToggle,
|
||||
onAll,
|
||||
}: {
|
||||
list: { id: number; post_title: string; author: string; excerpt: string; created_at: string; published: boolean }[];
|
||||
checked: Set<number>;
|
||||
onToggle: (id: number) => void;
|
||||
onAll: (on: boolean) => void;
|
||||
}) {
|
||||
const selectable = list.filter((c) => c.published).length;
|
||||
return (
|
||||
<details className="mt-2 flex flex-col gap-2">
|
||||
<summary className="flex items-center gap-2 flex-wrap cursor-pointer select-none">
|
||||
<span className="flex items-center gap-1.5 text-[13px] font-semibold" style={{ color: "var(--ink)" }}>
|
||||
<MessageSquare size={14} aria-hidden />
|
||||
评论({selectable}/{list.length} 可导入)
|
||||
</span>
|
||||
<BulkBar label="批量:" onAll={onAll} />
|
||||
</summary>
|
||||
<div className="max-h-80 overflow-y-auto flex flex-col gap-1.5 pr-1">
|
||||
{list.map((c) => (
|
||||
<label
|
||||
key={c.id}
|
||||
className={`rounded-lg px-3 py-2 flex items-center gap-2.5 ${!c.published ? "opacity-55" : "cursor-pointer"}`}
|
||||
style={fieldStyle}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="accent-[var(--accent)] shrink-0"
|
||||
checked={c.published && checked.has(c.id)}
|
||||
disabled={!c.published}
|
||||
onChange={() => onToggle(c.id)}
|
||||
/>
|
||||
<span className="text-[12.5px] truncate flex-1" style={{ color: "var(--ink)" }}>
|
||||
{c.excerpt || "(无文字)"}
|
||||
</span>
|
||||
{!c.published && (
|
||||
<span className="text-[11px] font-semibold shrink-0" style={{ color: "var(--danger)" }}>
|
||||
非公开
|
||||
</span>
|
||||
)}
|
||||
<span className="meta text-[12px] shrink-0 truncate max-w-[240px]">
|
||||
《{c.post_title}》{c.author} · {fmtDate(c.created_at)}
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
);
|
||||
}
|
||||
|
||||
function fmtDate(s: string) {
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(d.getTime()) ? "" : d.toLocaleDateString("zh-CN");
|
||||
}
|
||||
|
||||
// ===== 结果报告 =====
|
||||
|
||||
function StatChip({ icon, label, value, ok }: { icon: React.ReactNode; label: string; value: number; ok?: boolean }) {
|
||||
return (
|
||||
<div
|
||||
@@ -282,7 +622,7 @@ function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
{isDry ? (
|
||||
<>
|
||||
<ScanLine size={15} aria-hidden />
|
||||
预检结果(未写入任何数据)——确认无误后点击「确认正式导入」
|
||||
预检结果(未写入任何数据)——请在上方勾选导入范围,确认后点击「确认正式导入」
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
@@ -296,6 +636,9 @@ function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧库用户" value={report.users.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.users.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已存在跳过" value={report.users.skipped} ok={false} />
|
||||
{report.users.excluded > 0 && (
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="不建号" value={report.users.excluded} ok={false} />
|
||||
)}
|
||||
{report.users.avatar_written > 0 && (
|
||||
<StatChip icon={<FileUp size={13} aria-hidden />} label="头像写入" value={report.users.avatar_written} />
|
||||
)}
|
||||
@@ -332,29 +675,45 @@ function ReportView({ report }: { report: LegacyImportReport }) {
|
||||
);
|
||||
}
|
||||
|
||||
function PostsView({ report, isDry }: { report: LegacyPostReport; isDry: boolean }) {
|
||||
function PostsView({ report, isDry }: { report: NonNullable<LegacyImportReport["posts"]>; isDry: boolean }) {
|
||||
return (
|
||||
<ReportCard icon={<ListChecks size={14} aria-hidden />} title="帖子">
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧帖" value={report.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
|
||||
{report.excluded > 0 && (
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
|
||||
)}
|
||||
{report.images_written > 0 && (
|
||||
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
|
||||
)}
|
||||
{report.polls_skipped > 0 && (
|
||||
<StatChip icon={<XCircle size={13} aria-hidden />} label="投票帖未迁移" value={report.polls_skipped} ok={false} />
|
||||
)}
|
||||
<DetailList title="投票帖(未迁移)" items={report.poll_titles} color="var(--danger)" />
|
||||
<DetailList title="手动排除" items={report.excluded_detail} />
|
||||
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} color="var(--danger)" />
|
||||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||||
<DetailList title="失败 / 非公开" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
);
|
||||
}
|
||||
|
||||
function CommentsView({ report, isDry }: { report: LegacyCommentReport; isDry: boolean }) {
|
||||
function CommentsView({ report, isDry }: { report: NonNullable<LegacyImportReport["comments"]>; isDry: boolean }) {
|
||||
return (
|
||||
<ReportCard icon={<MessageSquare size={14} aria-hidden />} title="评论">
|
||||
<StatChip icon={<ListChecks size={13} aria-hidden />} label="旧评论" value={report.total} />
|
||||
<StatChip icon={<CheckCircle2 size={13} aria-hidden />} label={isDry ? "将导入" : "已导入"} value={report.imported} />
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="已导入跳过" value={report.skipped} ok={false} />
|
||||
{report.excluded > 0 && (
|
||||
<StatChip icon={<ListX size={13} aria-hidden />} label="手动排除" value={report.excluded} ok={false} />
|
||||
)}
|
||||
{report.images_written > 0 && (
|
||||
<StatChip icon={<Images size={13} aria-hidden />} label={isDry ? "将迁移图片" : "图片迁移"} value={report.images_written} />
|
||||
)}
|
||||
<DetailList title="手动排除" items={report.excluded_detail} />
|
||||
<DetailList title="非公开 / 已导入跳过明细" items={report.skipped_detail} />
|
||||
<DetailList title="图片缺失(保留原链接)" items={report.images_missing} color="var(--danger)" />
|
||||
<DetailList title="作者缺失(归到站长)" items={report.owner_fallback} />
|
||||
<DetailList title="失败" items={report.failed} color="var(--danger)" />
|
||||
</ReportCard>
|
||||
|
||||
@@ -180,6 +180,16 @@ export default function SidebarAdmin({
|
||||
}
|
||||
};
|
||||
|
||||
// 放弃未保存修改:回到最近一次保存的布局
|
||||
const discardChanges = () => {
|
||||
if (busy) return;
|
||||
try {
|
||||
setConfig(JSON.parse(savedJsonRef.current));
|
||||
} catch {
|
||||
/* 忽略:快照损坏时保持现状 */
|
||||
}
|
||||
};
|
||||
|
||||
/* ---------- 自定义工具 ---------- */
|
||||
|
||||
const openCreate = () => {
|
||||
@@ -304,16 +314,41 @@ export default function SidebarAdmin({
|
||||
</nav>
|
||||
);
|
||||
|
||||
// 页头与浮动保存条共用的未保存提示 + 操作按钮
|
||||
const dirtyDot = (
|
||||
<span
|
||||
className="inline-block w-2 h-2 rounded-full animate-pulse shrink-0"
|
||||
style={{ background: "var(--gold)" }}
|
||||
aria-hidden
|
||||
/>
|
||||
);
|
||||
const saveControls = dirty ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="meta flex items-center gap-1.5 text-[12px]">
|
||||
{dirtyDot}
|
||||
布局有未保存的修改
|
||||
</span>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
|
||||
{busy ? "保存中…" : "保存布局"}
|
||||
</button>
|
||||
</div>
|
||||
) : undefined;
|
||||
|
||||
return (
|
||||
<AdminSettingsPage>
|
||||
<AdminPageHeader
|
||||
icon={PanelLeft}
|
||||
title="侧栏管理"
|
||||
description="配置各页面左右侧栏的显示、顺序与位置,管理自定义 HTML 工具"
|
||||
actions={saveControls}
|
||||
/>
|
||||
|
||||
<AdminSettingsWork nav={leftNav}>
|
||||
<div className="space-y-5 pb-24">
|
||||
<>
|
||||
<div className="space-y-5 pb-4">
|
||||
{SIDES.map(({ scope, icon, desc, sides }) => {
|
||||
const sc = config.scopes[scope] ?? {};
|
||||
const left = sc.left ?? [];
|
||||
@@ -413,27 +448,34 @@ export default function SidebarAdmin({
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</AdminSettingsWork>
|
||||
|
||||
{/* 吸底保存栏 */}
|
||||
{dirty && (
|
||||
<div
|
||||
className="fixed bottom-0 left-0 right-0 z-40 flex items-center justify-between gap-3 border-t px-4 py-3 sm:px-6"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||||
backdropFilter: "blur(12px)",
|
||||
borderColor: "var(--line)",
|
||||
}}
|
||||
>
|
||||
<p className="meta text-[12px]">
|
||||
<span className="inline-block w-2 h-2 rounded-full mr-1.5" style={{ background: "var(--gold)" }} aria-hidden />
|
||||
布局有未保存的修改
|
||||
</p>
|
||||
<button type="button" className="btn btn-primary" disabled={busy} onClick={() => void saveConfig()}>
|
||||
{busy ? "保存中…" : "保存布局"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{/* 浮动保存条:吸在内容列底部居中,滚动全程可见,不再贴屏幕角落 */}
|
||||
{dirty && (
|
||||
<div className="sticky bottom-4 z-30 flex justify-center pointer-events-none">
|
||||
<div
|
||||
className="pointer-events-auto flex items-center gap-3 rounded-full px-4 py-2"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||||
backdropFilter: "blur(12px)",
|
||||
border: "1px solid var(--line)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<span className="meta flex items-center gap-1.5 text-[12px]">
|
||||
{dirtyDot}
|
||||
布局有未保存的修改
|
||||
</span>
|
||||
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={discardChanges}>
|
||||
撤销修改
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary btn-sm" disabled={busy} onClick={() => void saveConfig()}>
|
||||
{busy ? "保存中…" : "保存布局"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
</AdminSettingsWork>
|
||||
|
||||
{/* 工具编辑弹层 */}
|
||||
<Modal
|
||||
|
||||
@@ -84,6 +84,9 @@ export default function AdsBuyClient() {
|
||||
|
||||
const selectedPay: AdPaymentOption | undefined = cfg?.payments.find((p) => p.id === paymentID);
|
||||
const selectedDuration = cfg?.durations.find((d) => d.days === days);
|
||||
const selectedPrice = selectedDuration
|
||||
? (kind === "text" ? selectedDuration.price_hint_text : selectedDuration.price_hint).trim()
|
||||
: "";
|
||||
const canSubmit = !!cfg && cfg.payments.length > 0 && !busy;
|
||||
const kindLabel = kind === "image" ? "图片广告" : "文字广告";
|
||||
|
||||
@@ -224,6 +227,7 @@ export default function AdsBuyClient() {
|
||||
<p className="text-[12.5px]">
|
||||
{kindLabel}
|
||||
{selectedDuration ? ` · ${selectedDuration.label}` : ""}
|
||||
{selectedPrice ? ` · ${selectedPrice}` : ""}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -331,9 +335,17 @@ export default function AdsBuyClient() {
|
||||
<div className="grid grid-cols-5 gap-2">
|
||||
{cfg.durations.map((d) => {
|
||||
const active = days === d.days;
|
||||
const price = (kind === "text" ? d.price_hint_text : d.price_hint).trim();
|
||||
return (
|
||||
<ChipTab key={d.days} active={active} onClick={() => setDays(d.days)} compact>
|
||||
{d.label.replace(/\s/g, "")}
|
||||
<ChipTab
|
||||
key={d.days}
|
||||
active={active}
|
||||
onClick={() => setDays(d.days)}
|
||||
compact
|
||||
priced={!!price}
|
||||
>
|
||||
<span>{d.label.replace(/\s/g, "")}</span>
|
||||
{price ? <span className="j13-ads-chip-price">{price}</span> : null}
|
||||
</ChipTab>
|
||||
);
|
||||
})}
|
||||
@@ -455,17 +467,21 @@ function ChipTab({
|
||||
onClick,
|
||||
children,
|
||||
compact,
|
||||
priced,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
compact?: boolean;
|
||||
priced?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}`}
|
||||
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}${
|
||||
priced ? " is-priced" : ""
|
||||
}`}
|
||||
>
|
||||
{children}
|
||||
</button>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
SiteDocPageNav,
|
||||
SiteDocAnnoNav,
|
||||
SiteDocMasthead,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { webPageJsonLd } from "@/lib/jsonLd";
|
||||
@@ -115,14 +114,6 @@ export default async function AnnouncementPage({ params }: PageProps) {
|
||||
}
|
||||
>
|
||||
<article className="panel p-6 sm:p-8">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/announcements", label: "站点公告" },
|
||||
{ label: a.title },
|
||||
]}
|
||||
/>
|
||||
<SiteDocMasthead
|
||||
kicker="站点公告"
|
||||
title={a.title}
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
SiteDocShell,
|
||||
SiteDocIdentity,
|
||||
SiteDocPageNav,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { canonicalPage } from "@/lib/siteUrl";
|
||||
@@ -156,13 +155,6 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
|
||||
className="px-5 sm:px-6 pt-5 pb-4"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<SiteDocBreadcrumb
|
||||
className="mb-3"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: "站点公告" },
|
||||
]}
|
||||
/>
|
||||
<h1
|
||||
className="text-[24px] sm:text-[28px] font-extrabold tracking-tight flex items-center gap-2"
|
||||
style={{ color: "var(--ink)" }}
|
||||
|
||||
@@ -114,12 +114,14 @@ export default function ChatShell({
|
||||
const longPressTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
/** 本人刚发出的消息 ID:拉列表时若水位尚未跟上,避免把这条算成未读 */
|
||||
const lastSentIdByRoom = useRef<Map<number, number>>(new Map());
|
||||
/** 搜索重开私聊:router.push 完成前 activeRoomId 仍为 null,列表刷新需据此抑制目标会话未读 */
|
||||
const enteringRoomIdRef = useRef<number | null>(null);
|
||||
|
||||
/** 拉列表/SSR 回填时,当前打开的会话未读一律置 0(已读请求可能尚未落库) */
|
||||
const applyRooms = useCallback(
|
||||
(list: ChatRoomView[]) => {
|
||||
const next = (list || []).map((r) => {
|
||||
if (activeRoomId && r.id === activeRoomId) {
|
||||
if ((activeRoomId && r.id === activeRoomId) || r.id === enteringRoomIdRef.current) {
|
||||
return { ...r, unread_count: 0 };
|
||||
}
|
||||
const sentId = lastSentIdByRoom.current.get(r.id);
|
||||
@@ -302,6 +304,9 @@ export default function ChatShell({
|
||||
// 进房即清角标;MarkRead 成功后再清一次(防 refresh 抢在已读落库前写回旧未读)
|
||||
useEffect(() => {
|
||||
if (!activeRoomId) return;
|
||||
if (enteringRoomIdRef.current === activeRoomId) {
|
||||
enteringRoomIdRef.current = null;
|
||||
}
|
||||
const clearActiveUnread = () => {
|
||||
setRooms((prev) =>
|
||||
prev.map((r) => (r.id === activeRoomId ? { ...r, unread_count: 0 } : r))
|
||||
@@ -346,6 +351,7 @@ export default function ChatShell({
|
||||
const openDM = async (uid: number) => {
|
||||
try {
|
||||
const { room } = await apiOpenDirectChat(uid);
|
||||
enteringRoomIdRef.current = room.id;
|
||||
setQuery("");
|
||||
setUserHits([]);
|
||||
router.push(`/chat/${room.id}`);
|
||||
@@ -468,7 +474,7 @@ export default function ChatShell({
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto min-h-0">
|
||||
<div className="j13-chat-scroll flex-1 overflow-y-auto min-h-0">
|
||||
{query.trim() && (
|
||||
<div className="px-2 pb-2">
|
||||
<p
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
Undo2,
|
||||
Reply,
|
||||
Copy,
|
||||
ChevronsDown,
|
||||
} from "lucide-react";
|
||||
import {
|
||||
apiListChatMessages,
|
||||
@@ -419,6 +420,10 @@ export default function ChatRoomClient({
|
||||
const [messages, setMessages] = useState<ChatMessage[]>(initialMessages);
|
||||
const [hasMore, setHasMore] = useState(initialHasMore);
|
||||
const [loadingMore, setLoadingMore] = useState(false);
|
||||
// 「回到底部」悬浮钮:column-reverse 下 scrollTop≤0(视觉底部为 0,上翻为负),
|
||||
// -scrollTop 即距底部距离;newBelowCount 为翻历史期间新到的、尚未“看见”的消息数
|
||||
const [showJumpBottom, setShowJumpBottom] = useState(false);
|
||||
const [newBelowCount, setNewBelowCount] = useState(0);
|
||||
const [content, setContent] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [members, setMembers] = useState<ChatRoomMember[]>([]);
|
||||
@@ -550,12 +555,22 @@ export default function ChatRoomClient({
|
||||
[roomId, isRealMember]
|
||||
);
|
||||
|
||||
// 悬浮钮/新消息引导回底:跳底 + 推进已读 + 清空待看计数
|
||||
const jumpToBottom = useCallback(() => {
|
||||
setNewBelowCount(0);
|
||||
scrollToBottom(true);
|
||||
markReadToLatest();
|
||||
}, [scrollToBottom, markReadToLatest]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canView) return;
|
||||
realtime.joinRoom(roomId);
|
||||
void refreshMembers();
|
||||
// 列表用 flex-col-reverse,SSR/无 JS 默认即贴底,无需首屏 scrollToBottom(避免 F5 闪一下)
|
||||
markReadToLatest();
|
||||
// 换房时重置「回到底部」悬浮钮状态
|
||||
setShowJumpBottom(false);
|
||||
setNewBelowCount(0);
|
||||
return () => {
|
||||
realtime.leaveRoom(roomId);
|
||||
};
|
||||
@@ -573,10 +588,13 @@ export default function ChatRoomClient({
|
||||
requestAnimationFrame(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
// column-reverse:靠近 scrollTop=0 即贴底
|
||||
if (el.scrollTop < 120) {
|
||||
// column-reverse:-scrollTop 为距底部距离,接近 0 即贴底
|
||||
if (-el.scrollTop < 120) {
|
||||
scrollToBottom(true);
|
||||
markReadToLatest();
|
||||
} else {
|
||||
// 正在上方翻历史:不强行跟到底,计数交给「回到底部」按钮提示
|
||||
setNewBelowCount((c) => c + 1);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -652,8 +670,9 @@ export default function ChatRoomClient({
|
||||
requestAnimationFrame(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
// column-reverse:历史插在上方,用高度差补偿保持视口锚点
|
||||
el.scrollTop = prevTop + (el.scrollHeight - prevHeight);
|
||||
// column-reverse:历史插在上方,scrollTop 为负;
|
||||
// 新增高度 H 使锚点内容距底部多 H,需 scrollTop 再减 H(更负)保持视口不动
|
||||
el.scrollTop = prevTop - (el.scrollHeight - prevHeight);
|
||||
});
|
||||
} else {
|
||||
setHasMore(false);
|
||||
@@ -668,13 +687,20 @@ export default function ChatRoomClient({
|
||||
const handleScroll = useCallback(() => {
|
||||
const el = listRef.current;
|
||||
if (!el) return;
|
||||
// column-reverse:滚向上方历史 ≈ 接近 max scrollTop
|
||||
if (el.scrollHeight - el.scrollTop - el.clientHeight < 60) {
|
||||
// column-reverse:scrollTop≤0(视觉底部为 0,上翻为负),-scrollTop 即距底部距离
|
||||
const fromBottom = -el.scrollTop;
|
||||
// 滚向上方历史:距顶部不足 60px 时加载更多
|
||||
if (el.scrollHeight - el.clientHeight - fromBottom < 60) {
|
||||
loadMore();
|
||||
}
|
||||
if (el.scrollTop < 60) {
|
||||
if (fromBottom < 60) {
|
||||
markReadToLatest();
|
||||
}
|
||||
// 「回到底部」:离开底部一段距离即出现,接近底部时消失
|
||||
setShowJumpBottom(fromBottom > 160);
|
||||
if (fromBottom < 120) {
|
||||
setNewBelowCount(0);
|
||||
}
|
||||
}, [loadMore, markReadToLatest]);
|
||||
|
||||
const syncComposerHeight = () => {
|
||||
@@ -1238,7 +1264,7 @@ export default function ChatRoomClient({
|
||||
<button
|
||||
onClick={() => togglePanel("members")}
|
||||
aria-label="成员列表"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{
|
||||
color: rightPanel === "members" ? "var(--accent)" : "var(--ink-2)",
|
||||
background: rightPanel === "members" ? "var(--accent-soft)" : undefined,
|
||||
@@ -1252,7 +1278,7 @@ export default function ChatRoomClient({
|
||||
<button
|
||||
onClick={() => togglePanel("invite")}
|
||||
aria-label="邀请成员"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{
|
||||
color: rightPanel === "invite" ? "var(--accent)" : "var(--ink-2)",
|
||||
background: rightPanel === "invite" ? "var(--accent-soft)" : undefined,
|
||||
@@ -1266,7 +1292,7 @@ export default function ChatRoomClient({
|
||||
<button
|
||||
onClick={() => togglePanel("settings")}
|
||||
aria-label="群设置"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)]"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{
|
||||
color: rightPanel === "settings" ? "var(--accent)" : "var(--ink-2)",
|
||||
background: rightPanel === "settings" ? "var(--accent-soft)" : undefined,
|
||||
@@ -1280,7 +1306,7 @@ export default function ChatRoomClient({
|
||||
<button
|
||||
onClick={handleLeave}
|
||||
aria-label={isDirect ? "关闭私聊" : "退出群聊"}
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)]"
|
||||
className="w-9 h-9 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--danger-soft)] focus-visible:bg-[var(--danger-soft)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
data-tip={isDirect ? "关闭私聊" : "退出群聊"}
|
||||
>
|
||||
@@ -1290,12 +1316,13 @@ export default function ChatRoomClient({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto flex flex-col-reverse px-4 sm:px-5 py-4"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
<div className="relative flex-1 flex flex-col" style={{ minHeight: 0 }}>
|
||||
<div
|
||||
ref={listRef}
|
||||
onScroll={handleScroll}
|
||||
className="j13-chat-scroll flex-1 overflow-y-auto overflow-x-hidden flex flex-col-reverse px-4 sm:px-5 py-4"
|
||||
style={{ minHeight: 0 }}
|
||||
>
|
||||
{!canView ? (
|
||||
<div className="text-center py-12 px-4">
|
||||
<p style={{ color: "var(--ink)" }}>
|
||||
@@ -1313,7 +1340,7 @@ export default function ChatRoomClient({
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loadingMore}
|
||||
className="mx-auto block text-[12px] meta hover:text-[var(--accent)] transition-colors py-2"
|
||||
className="mx-auto block text-[12px] meta hover:text-[var(--accent)] focus-visible:text-[var(--accent)] transition-colors py-2"
|
||||
>
|
||||
{loadingMore ? "加载中..." : "加载更早消息"}
|
||||
</button>
|
||||
@@ -1435,7 +1462,7 @@ export default function ChatRoomClient({
|
||||
</span>
|
||||
</div>
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text w-full"
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text w-full"
|
||||
style={{
|
||||
background: "var(--panel-2)",
|
||||
border:
|
||||
@@ -1531,7 +1558,7 @@ export default function ChatRoomClient({
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] break-words whitespace-pre-wrap select-text"
|
||||
className="px-3.5 py-2.5 rounded-2xl text-[14px] leading-[1.55] [overflow-wrap:anywhere] whitespace-pre-wrap select-text"
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
@@ -1566,7 +1593,7 @@ export default function ChatRoomClient({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => scrollToMsg(m.reply_to_id!)}
|
||||
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug"
|
||||
className="block w-full text-left mb-1.5 px-2 py-1.5 rounded-lg text-[12px] leading-snug [overflow-wrap:anywhere]"
|
||||
style={{
|
||||
background: mine
|
||||
? "color-mix(in srgb, var(--accent) 10%, transparent)"
|
||||
@@ -1587,6 +1614,35 @@ export default function ChatRoomClient({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{/* 回到底部:飞书式悬浮钮,贴近当前对话框右下角;翻历史期间带新消息计数 */}
|
||||
{canView && showJumpBottom && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={jumpToBottom}
|
||||
aria-label="回到底部"
|
||||
data-tip="回到底部"
|
||||
className="absolute right-4 sm:right-5 bottom-3 z-10 w-10 h-10 rounded-full flex items-center justify-center border border-gray-300 hover:border-gray-400 focus-visible:border-gray-400 transition-colors j13-toast-in text-black hover:bg-gray-50"
|
||||
style={{
|
||||
background: "#fff",
|
||||
boxShadow: "var(--shadow)",
|
||||
}}
|
||||
>
|
||||
<ChevronsDown size={18} />
|
||||
{newBelowCount > 0 && (
|
||||
<span
|
||||
className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[10px] font-medium flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--clay)",
|
||||
color: "#fff",
|
||||
border: "2px solid var(--bg)",
|
||||
}}
|
||||
>
|
||||
{newBelowCount > 99 ? "99+" : newBelowCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isRealMember ? (
|
||||
@@ -1710,10 +1766,16 @@ export default function ChatRoomClient({
|
||||
: "输入消息,@ 可提醒成员"
|
||||
}
|
||||
maxLength={2000}
|
||||
className={`j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${
|
||||
className={`j13-chat-scroll j13-chat-composer-input relative w-full bg-transparent text-[14px] leading-5 py-2 max-h-32 min-h-[36px]${
|
||||
content ? " has-value" : ""
|
||||
}`}
|
||||
style={{ color: content ? undefined : "var(--ink)" }}
|
||||
onFocus={(e) => {
|
||||
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
|
||||
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
|
||||
const composer = e.currentTarget.closest(".j13-chat-composer");
|
||||
composer?.scrollIntoView({ block: "end", behavior: "smooth" });
|
||||
}}
|
||||
onInput={(e) => {
|
||||
const t = e.currentTarget;
|
||||
t.style.height = "auto";
|
||||
@@ -1796,7 +1858,7 @@ export default function ChatRoomClient({
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-y-auto min-h-0 p-4">
|
||||
<div className="j13-chat-scroll flex-1 overflow-y-auto min-h-0 p-4">
|
||||
{rightPanel === "members" && (
|
||||
<MembersPanel
|
||||
members={members}
|
||||
@@ -1845,7 +1907,7 @@ export default function ChatRoomClient({
|
||||
|
||||
{/* 非嵌入模式:未开右栏时桌面仍显示成员常驻栏 */}
|
||||
{!embedded && canView && !isDirect && !rightPanel && (
|
||||
<aside className="hidden lg:flex flex-col panel p-4 overflow-y-auto min-h-0">
|
||||
<aside className="j13-chat-scroll hidden lg:flex flex-col panel p-4 overflow-y-auto min-h-0">
|
||||
<MembersPanel
|
||||
members={members}
|
||||
meId={meId}
|
||||
@@ -2032,7 +2094,7 @@ function MembersPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onSetAdmin(m.user_id, name, !isTargetAdmin)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded"
|
||||
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
|
||||
>
|
||||
{isTargetAdmin ? "取消管" : "设管"}
|
||||
@@ -2043,7 +2105,7 @@ function MembersPanel({
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onMute(m.user_id, name, !m.muted)}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity text-[11px] px-1"
|
||||
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity text-[11px] px-1.5 py-0.5 rounded hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
>
|
||||
{m.muted ? "解禁" : "禁言"}
|
||||
@@ -2052,7 +2114,7 @@ function MembersPanel({
|
||||
type="button"
|
||||
onClick={() => onKick(m.user_id, name)}
|
||||
aria-label={`移出 ${name}`}
|
||||
className="opacity-0 group-hover:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)]"
|
||||
className="opacity-100 lg:opacity-0 lg:group-hover:opacity-100 lg:focus-visible:opacity-100 lg:group-focus-within:opacity-100 transition-opacity w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--danger-soft)] focus-visible:bg-[var(--danger-soft)]"
|
||||
style={{ color: "var(--danger)" }}
|
||||
>
|
||||
<UserX size={12} />
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
/* 浅色(默认)——画廊白底,卡片纯白浮起 */
|
||||
--bg: #fafbfc;
|
||||
--panel: #ffffff;
|
||||
/* 前台 main 纸面:纯白,与页头/页脚(--panel)连成一张白纸,画布只在页缘露出 */
|
||||
--main-bg: #ffffff;
|
||||
--panel-2: #f2f4f7;
|
||||
--tint: #eceff3;
|
||||
--ink: #161a22;
|
||||
@@ -103,9 +105,9 @@
|
||||
--focus-glow: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
--focus-glow-danger: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
|
||||
/* 顶栏 68px 条 + 1px 底边;main 桌面 py-9。侧栏 sticky 必须等于
|
||||
/* 顶栏 56px 条 + 1px 底边;main 桌面 py-9。侧栏 sticky 必须等于
|
||||
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
|
||||
--header-bar: 68px;
|
||||
--header-bar: 56px;
|
||||
--header-h: calc(var(--header-bar) + 1px);
|
||||
--main-pad-y: 1.25rem;
|
||||
--rail-top: calc(var(--header-h) + var(--main-pad-y));
|
||||
@@ -130,6 +132,8 @@
|
||||
/* 近黑冷灰:面板逐层浮起,靛蓝提亮为长春花蓝 */
|
||||
--bg: #0c0d10;
|
||||
--panel: #181b21;
|
||||
/* 暗色 main 纸面:比画布略亮一档,保持「主内容区略浮起」的层级对应 */
|
||||
--main-bg: #131418;
|
||||
--panel-2: #20242c;
|
||||
--tint: #282d37;
|
||||
--ink: #eef1f6;
|
||||
@@ -2852,27 +2856,25 @@ body:has([data-compose-viewport]) > footer {
|
||||
gap: 0.35rem 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
/* <1024px(移动/平板,底部公共菜单显示区间):三区堆叠、统一左对齐连成整体,
|
||||
避免「左中右各自断开」的残缺观感;底部避让公共菜单 */
|
||||
@media (max-width: 1023px) {
|
||||
.site-footer {
|
||||
padding-bottom: calc(4.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
.site-footer-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.site-footer-zone,
|
||||
.site-footer-zone-left,
|
||||
.site-footer-zone-center,
|
||||
.site-footer-zone-right {
|
||||
justify-self: unset;
|
||||
width: 100%;
|
||||
}
|
||||
.site-footer-zone-left {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.site-footer-zone-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.site-footer-zone-right {
|
||||
justify-content: flex-end;
|
||||
width: 100%;
|
||||
}
|
||||
.site-footer-zone:empty {
|
||||
display: none;
|
||||
@@ -3720,6 +3722,16 @@ select:focus-visible {
|
||||
.j13-search-field:focus-visible {
|
||||
background: var(--panel);
|
||||
}
|
||||
/* 聊天界面滚动容器:隐藏滚动条(飞书式无滚动条外观,滚动能力保留) */
|
||||
.j13-chat-scroll {
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
.j13-chat-scroll::-webkit-scrollbar {
|
||||
display: none;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
.j13-chat-composer-mirror {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
@@ -6269,7 +6281,7 @@ a.j13-mention-token:hover {
|
||||
padding: 4px 11px;
|
||||
background: var(--clay-soft);
|
||||
color: var(--clay);
|
||||
border-radius: 999px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-rec {
|
||||
@@ -6281,7 +6293,7 @@ a.j13-mention-token:hover {
|
||||
padding: 4px 11px;
|
||||
background: var(--gold-soft);
|
||||
color: var(--gold);
|
||||
border-radius: 999px;
|
||||
border-radius: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.badge-pending {
|
||||
@@ -6969,16 +6981,6 @@ a.j13-mention-token:hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* 正文内嵌面包屑:无独立底块,靠所在面板保证对比 */
|
||||
.j13-page-crumb {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-page-crumb.is-ruled {
|
||||
padding-bottom: 14px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* 板块导航行:默认独立卡片;落入 j13-module 后收成内嵌列表 */
|
||||
.j13-bcard {
|
||||
display: flex;
|
||||
@@ -7063,12 +7065,6 @@ a.j13-mention-token:hover {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.j13-board-split {
|
||||
height: 1px;
|
||||
background: var(--line);
|
||||
margin: 2px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.j13-bcard {
|
||||
transition: none;
|
||||
@@ -7172,6 +7168,20 @@ a.j13-mention-token:hover {
|
||||
padding: 0 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.j13-ads-chip.is-priced {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
min-height: 0;
|
||||
padding: 6px 4px;
|
||||
line-height: 1.15;
|
||||
}
|
||||
.j13-ads-chip-price {
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
line-height: 1;
|
||||
opacity: 0.75;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.j13-ads-chip.is-on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
@@ -7480,6 +7490,7 @@ a.j13-mention-token:hover {
|
||||
/* 编辑器行(时长档位) */
|
||||
.j13-ads-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
@@ -7851,32 +7862,63 @@ a.j13-mention-token:hover {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
padding: 7px 14px;
|
||||
border: 1px solid var(--line-2);
|
||||
background: var(--panel);
|
||||
padding: 7px 12px;
|
||||
/* 无框 chip:透明边框占位,选中/悬停切底色时尺寸不跳 */
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
border-radius: 999px;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-2);
|
||||
white-space: nowrap;
|
||||
transition: background 0.16s ease, color 0.16s ease, border-color 0.16s ease;
|
||||
transition: background 0.16s ease, color 0.16s ease;
|
||||
}
|
||||
.j13-chip:hover,
|
||||
.j13-chip:focus-visible {
|
||||
background: var(--item-hover-bg);
|
||||
border-color: color-mix(in srgb, var(--accent) 38%, var(--line));
|
||||
color: var(--accent);
|
||||
}
|
||||
.j13-chip[aria-current="page"],
|
||||
.j13-chip[aria-current="page"]:hover,
|
||||
.j13-chip[aria-current="page"]:focus-visible {
|
||||
background: var(--nav-active-bg);
|
||||
border-color: var(--nav-active-line);
|
||||
color: var(--nav-active-ink);
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-weight: 600;
|
||||
}
|
||||
.j13-chip:focus-visible {
|
||||
outline-offset: -2px;
|
||||
}
|
||||
/* 板块条两端渐隐:提示「可左右滑动」,滚动到头时对应侧隐藏 */
|
||||
.j13-chip-fade {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 2.5rem;
|
||||
pointer-events: none;
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
.j13-chip-fade.is-on {
|
||||
opacity: 1;
|
||||
}
|
||||
.j13-chip-fade-left {
|
||||
left: -1rem;
|
||||
border-radius: 1rem 0 0 1rem;
|
||||
background: linear-gradient(
|
||||
to right,
|
||||
color-mix(in srgb, var(--main-bg) 92%, transparent) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
.j13-chip-fade-right {
|
||||
right: -1rem;
|
||||
border-radius: 0 1rem 1rem 0;
|
||||
background: linear-gradient(
|
||||
to left,
|
||||
color-mix(in srgb, var(--main-bg) 92%, transparent) 20%,
|
||||
transparent
|
||||
);
|
||||
}
|
||||
|
||||
/* overflow:hidden 的列表面板:整行链接外描边会被圆角裁切 */
|
||||
.panel.overflow-hidden > a:focus-visible {
|
||||
@@ -8451,29 +8493,196 @@ html.dark .prose-content pre[data-theme] span {
|
||||
color: var(--accent-on);
|
||||
outline: none;
|
||||
}
|
||||
/* 帖子移动端底栏上方抬高,避免遮挡 */
|
||||
.j13-back-top.is-lifted {
|
||||
bottom: 5.25rem;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.j13-back-top.is-lifted {
|
||||
bottom: 1.25rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.j13-back-top {
|
||||
right: 1.35rem;
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
.j13-back-top.is-lifted {
|
||||
bottom: 5.25rem;
|
||||
}
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.j13-back-top.is-lifted {
|
||||
.j13-back-top {
|
||||
bottom: 1.5rem;
|
||||
}
|
||||
}
|
||||
/* 移动端抬高避开底部公共菜单(lg 以下显示底栏);置于最后以覆盖上面的 bottom */
|
||||
@media (max-width: 1023px) {
|
||||
.j13-back-top {
|
||||
bottom: calc(4.75rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
|
||||
/* 首页移动端:板块条白底上贴页头(#main 顶部不留白,板块条与帖子框连成一张白纸) */
|
||||
@media (max-width: 1023px) {
|
||||
body:has(.j13-fluid-home) #main {
|
||||
padding-top: 0;
|
||||
}
|
||||
}
|
||||
|
||||
/* 首页桌面:顶部留白 20px → 8px,中栏信息流上移贴页头;
|
||||
sticky 侧栏的 --rail-top/--rail-max-h 在 #main 上重算(custom property
|
||||
在声明处求值,:root 的 --rail-max-h 不会因子级重定义 --rail-top 而更新) */
|
||||
@media (min-width: 1024px) {
|
||||
body:has(.j13-fluid-home) #main {
|
||||
padding-top: 0.5rem;
|
||||
--rail-top: calc(var(--header-h) + 0.5rem);
|
||||
--rail-max-h: calc(100vh - var(--rail-top) - 1rem);
|
||||
}
|
||||
}
|
||||
|
||||
/* ============================================================
|
||||
前台 main 纸面 + 首页三栏「白纸一体」
|
||||
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
|
||||
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
|
||||
背景图时让位(保持透出画布)。左栏与窄屏下沉面板无框化,
|
||||
内容直接坐在纸面上;右栏保留卡片板块感(j13-module 原样)。
|
||||
============================================================ */
|
||||
html:not(.j13-has-site-bg) #main {
|
||||
background: var(--main-bg);
|
||||
border-left: 1px solid var(--line);
|
||||
border-right: 1px solid var(--line);
|
||||
}
|
||||
|
||||
/* 左栏模块去框:j13-module 退化为无底小节,内容直接坐在纸面上 */
|
||||
.j13-rail-left .j13-module {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
box-shadow: none;
|
||||
padding: 0;
|
||||
}
|
||||
/* 左栏小节分隔:任意块相邻时画发丝线(含站长自定义工具块) */
|
||||
.j13-rail-left > * + *,
|
||||
.j13-fluid-home .j13-flat-rail > * + * {
|
||||
margin-top: 1.25rem;
|
||||
padding-top: 1.25rem;
|
||||
border-top: 1px solid var(--line);
|
||||
}
|
||||
/* 左栏小节标题:纸面小节标(弱化为 meta 级),标题图标随之缩小 */
|
||||
.j13-rail-left .j13-module-title {
|
||||
font-size: 12px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-rail-left .j13-module-title > svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
/* 栏内图标无底色:只留彩色字形本身(收成图标实际大小,避免隐形占位拉大间距) */
|
||||
.j13-rail-left .j13-bcard-ico,
|
||||
.j13-fluid-home .j13-flat-rail .j13-bcard-ico {
|
||||
background: transparent !important; /* 覆盖「全部讨论/管理面板」图标的内联底色 */
|
||||
border-radius: 0;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
font-size: 13px;
|
||||
}
|
||||
/* 首页三栏竖向分隔线:与页头下边线同色同宽,居中于栏间距(gap-6 / xl:gap-7),
|
||||
画在中栏伪元素上与左右栏共用一条;类名跟随左右栏显隐,避免悬空线 */
|
||||
.j13-home-sep-l,
|
||||
.j13-home-sep-r {
|
||||
position: relative;
|
||||
}
|
||||
@media (min-width: 1024px) {
|
||||
.j13-home-sep-l::before,
|
||||
.j13-home-sep-r::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 1px;
|
||||
background: var(--line);
|
||||
}
|
||||
.j13-home-sep-l::before {
|
||||
left: calc(-0.75rem - 0.5px);
|
||||
}
|
||||
.j13-home-sep-r::after {
|
||||
right: calc(-0.75rem - 0.5px);
|
||||
}
|
||||
}
|
||||
@media (min-width: 1280px) {
|
||||
.j13-home-sep-l::before {
|
||||
left: calc(-0.875rem - 0.5px);
|
||||
}
|
||||
.j13-home-sep-r::after {
|
||||
right: calc(-0.875rem - 0.5px);
|
||||
}
|
||||
}
|
||||
/* 左栏分组标题:小号灰字,纸面风格的段落断点 */
|
||||
.j13-rail-left .j13-rail-group,
|
||||
.j13-fluid-home .j13-flat-rail .j13-rail-group {
|
||||
padding: 10px 8px 4px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: var(--ink-3);
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
.j13-rail-left .j13-rail-group:first-child,
|
||||
.j13-fluid-home .j13-flat-rail .j13-rail-group:first-child {
|
||||
padding-top: 2px;
|
||||
}
|
||||
/* 手机端下沉面板:推广占位放开到整栏宽(248 定宽是桌面右栏尺寸,
|
||||
窄屏居中两侧大片空白显得稀疏);/ads/buy 购买页预览保持真实尺寸不受影响 */
|
||||
@media (max-width: 1023px) {
|
||||
.j13-fluid-home .j13-flat-rail .j13-ad-promo {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
/* 左栏板块导航:当前项 accent 竖条 + 浅色底 */
|
||||
.j13-rail-left .j13-module .j13-bcard {
|
||||
position: relative;
|
||||
}
|
||||
.j13-rail-left .j13-module .j13-bcard[aria-current="page"],
|
||||
.j13-rail-left .j13-module .j13-bcard[aria-current="page"]:hover,
|
||||
.j13-rail-left .j13-module .j13-bcard[aria-current="page"]:focus-visible {
|
||||
background: var(--nav-active-bg);
|
||||
border-color: transparent;
|
||||
}
|
||||
.j13-rail-left .j13-module .j13-bcard[aria-current="page"]::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 3px;
|
||||
height: 55%;
|
||||
border-radius: 2px;
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* 中栏排序 Tab:描边胶囊改下划线式,active 下划线压在首行发丝线上 */
|
||||
.j13-feed-head [role="tablist"] {
|
||||
align-items: stretch;
|
||||
}
|
||||
.j13-feed-head .j13-tab {
|
||||
position: relative;
|
||||
border: 0;
|
||||
background: transparent;
|
||||
border-radius: 0;
|
||||
/* 上下对称,Tab 文字与右侧 meta(self-center)垂直居中对齐;下划线仍贴行底 */
|
||||
padding: 7px 2px;
|
||||
}
|
||||
.j13-feed-head .j13-tab::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
height: 2px;
|
||||
border-radius: 1px 1px 0 0;
|
||||
background: transparent;
|
||||
}
|
||||
.j13-feed-head .j13-tab:hover,
|
||||
.j13-feed-head .j13-tab:focus-visible {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.j13-feed-head .j13-tab[aria-selected="true"] {
|
||||
background: transparent;
|
||||
border: 0;
|
||||
}
|
||||
.j13-feed-head .j13-tab[aria-selected="true"]::after {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
/* 评论楼层树
|
||||
主评论全宽;子回复默认折叠。视觉缩进最多两级,更深靠「回复谁」说明。 */
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import OperationalBanner from "@/components/OperationalBanner";
|
||||
import type { Metadata } from "next";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { cookies, headers } from "next/headers";
|
||||
import { Plus_Jakarta_Sans, Noto_Sans_SC, JetBrains_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
@@ -76,6 +76,14 @@ export function generateMetadata(): Metadata {
|
||||
};
|
||||
}
|
||||
|
||||
// 移动端键盘弹出时压缩布局高度(而非仅视觉平移),聊天输入框随 100dvh
|
||||
// 收缩保持可见,配合聊天页锁视口布局;再叠加聚焦时 scrollIntoView 兜底
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
interactiveWidget: "resizes-content",
|
||||
};
|
||||
|
||||
export default async function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { redirect } from "next/navigation";
|
||||
import Link from "next/link";
|
||||
import { cookies } from "next/headers";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { fetchNotifications } from "@/lib/api";
|
||||
@@ -32,12 +31,6 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
|
||||
|
||||
return (
|
||||
<div className="max-w-2xl mx-auto">
|
||||
<nav className="meta -mt-4 sm:-mt-6 mb-1.5 flex items-center gap-1 text-[12.5px] leading-none">
|
||||
<Link href="/" className="hover:text-[var(--accent)] transition-colors">首页</Link>
|
||||
<ChevronRight size={13} />
|
||||
<span style={{ color: "var(--accent)" }}>通知中心</span>
|
||||
</nav>
|
||||
|
||||
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight mb-6" style={{ color: "var(--ink)" }}>
|
||||
通知中心
|
||||
</h1>
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
SiteDocPageNav,
|
||||
SiteDocAnnoNav,
|
||||
SiteDocMasthead,
|
||||
SiteDocBreadcrumb,
|
||||
} from "@/components/site-doc";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { webPageJsonLd } from "@/lib/jsonLd";
|
||||
@@ -123,14 +122,6 @@ export default async function SitePagePublic({ params }: PageProps) {
|
||||
}
|
||||
>
|
||||
<article className="panel p-6 sm:p-8">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: "/about", label: "关于本站" },
|
||||
{ label: p.title },
|
||||
]}
|
||||
/>
|
||||
<SiteDocMasthead
|
||||
kicker="官方文件"
|
||||
title={p.title}
|
||||
|
||||
@@ -22,7 +22,6 @@ import PostRow from "@/components/PostRow";
|
||||
import FeedTabs from "@/components/home/FeedTabs";
|
||||
import FeedNewTip from "@/components/home/FeedNewTip";
|
||||
import { WorkspaceNavMobile } from "@/components/home/WorkspaceNav";
|
||||
import { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||
import { BoardMissingPanel } from "@/components/home/BoardPanels";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
import { defaultFeedSort, homeCanonicalQuery, normalizeFeedSort } from "@/lib/siteUrl";
|
||||
@@ -131,6 +130,13 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
|
||||
// 侧栏布局:板块态右栏走 board 配置(左栏跟随首页),其余走 home 配置
|
||||
const scopeLists = sidebarScopeLists(sidebar.config, showBoardPanels ? "board" : "home");
|
||||
// 三栏竖向分隔线跟随左右栏显隐(空配置不画悬空线)
|
||||
const hasLeftRail = scopeLists.left.length > 0;
|
||||
const hasRightRail = showBoardPanels
|
||||
? boardSidebar
|
||||
? scopeLists.right.length > 0
|
||||
: boardPanelsMissing
|
||||
: !!(overview && scopeLists.right.length > 0);
|
||||
const sidebarProps: SidebarBlockProps = {
|
||||
allowComments,
|
||||
user,
|
||||
@@ -202,7 +208,7 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
</h1>
|
||||
<p className="meta mt-2">共 {postsData.total} 篇结果 · 按相关度与时间排序</p>
|
||||
</div>
|
||||
<div className="panel overflow-hidden">
|
||||
<div className="panel overflow-hidden max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
|
||||
{postsData.posts.length > 0 ? (
|
||||
postsData.posts.map((post, i) => (
|
||||
<PostRow key={post.id} post={post} showBoard index={i} showViews={false} showLike={false} />
|
||||
@@ -231,37 +237,30 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
if (sort !== defaultFeedSort(allowComments)) pageQuery.sort = sort;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="j13-fluid-home">
|
||||
<JsonLd data={listingLd} />
|
||||
<WorkspaceNavMobile boards={boards} user={user} />
|
||||
<WorkspaceNavMobile boards={boards} />
|
||||
|
||||
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "mt-5 lg:mt-0"}`}>
|
||||
<div className={`flex items-start gap-6 xl:gap-7 ${activeBoard ? "" : "lg:mt-0"}`}>
|
||||
{/* 左栏:按配置渲染(缺省 = 板块目录) */}
|
||||
{scopeLists.left.length > 0 && (
|
||||
<aside className="hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
|
||||
<aside className="j13-rail-left hidden lg:block w-56 xl:w-60 shrink-0 sticky top-[var(--rail-top)] max-h-[var(--rail-max-h)]">
|
||||
<SidebarRenderer blocks={scopeLists.left} widgets={sidebar.widgets} props={sidebarProps} />
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* 中栏:tab 栏与帖子流为同一个整体卡片(左栏切板块只替换这一列) */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* 中栏:tab 栏与帖子流为同一个整体(左栏切板块只替换这一列) */}
|
||||
<div
|
||||
className={`flex-1 min-w-0 ${hasLeftRail ? "j13-home-sep-l" : ""} ${
|
||||
hasRightRail ? "j13-home-sep-r" : ""
|
||||
}`}
|
||||
>
|
||||
<h1 className="sr-only">{pageName}</h1>
|
||||
|
||||
<div className="panel overflow-hidden">
|
||||
{activeBoard ? (
|
||||
<div className="px-3 sm:px-4 pt-3">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled !mb-0"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ label: activeBoard.name },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-center justify-between gap-2 px-3 sm:px-4 pt-3 pb-2.5">
|
||||
<div className="max-sm:-mx-4">
|
||||
<div className="j13-feed-head flex items-stretch justify-between gap-2 px-3 sm:px-4">
|
||||
<FeedTabs current={sort} boardId={boardId} />
|
||||
<span className="meta shrink-0 text-[12px] tabular-nums">
|
||||
<span className="meta self-center shrink-0 text-[12px] tabular-nums">
|
||||
共 {postsData.total} 篇
|
||||
</span>
|
||||
</div>
|
||||
@@ -329,12 +328,11 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
|
||||
{/* 窄屏:面板下沉到帖子流之后(右栏列表 + 左栏列表,板块目录除外) */}
|
||||
{mobileBlocks.length > 0 && (
|
||||
<div className="mt-6 lg:hidden">
|
||||
<div className="j13-flat-rail mt-3 lg:hidden">
|
||||
<SidebarRenderer
|
||||
blocks={mobileBlocks}
|
||||
widgets={sidebar.widgets}
|
||||
props={sidebarProps}
|
||||
className="space-y-4"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -29,8 +29,6 @@ import CommentSection from "@/components/CommentSection";
|
||||
import PostActions from "@/components/PostActions";
|
||||
import LikeButton from "@/components/LikeButton";
|
||||
import FavoriteButton from "@/components/FavoriteButton";
|
||||
import { SiteDocBreadcrumb } from "@/components/site-doc";
|
||||
import MobilePostBar from "@/components/MobilePostBar";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import RoleBadge from "@/components/RoleBadge";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
@@ -119,7 +117,10 @@ function RelatedPosts({
|
||||
if (posts.length === 0) return null;
|
||||
const titleId = `${uid}-title`;
|
||||
return (
|
||||
<section className="j13-module shrink-0" aria-labelledby={titleId}>
|
||||
<section
|
||||
className="j13-module shrink-0 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
|
||||
aria-labelledby={titleId}
|
||||
>
|
||||
<p className="j13-module-title mb-1.5" id={titleId}>
|
||||
<Flame size={15} style={{ color: "var(--clay)" }} /> 同板块热议
|
||||
</p>
|
||||
@@ -175,7 +176,7 @@ function AuthorCardStacked({
|
||||
const signature = (u.signature || "").trim();
|
||||
const { post_count, comment_count, points, streak } = profile.stats;
|
||||
return (
|
||||
<div className="panel p-5 sm:p-6">
|
||||
<div className="panel p-4 sm:p-6 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
|
||||
<p
|
||||
className="text-[11.5px] font-medium tracking-[0.08em] uppercase mb-3.5"
|
||||
style={{ color: "var(--ink-3)" }}
|
||||
@@ -477,15 +478,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
return (
|
||||
<>
|
||||
<JsonLd data={jsonLd} />
|
||||
{/* 移动端浮动操作条:长文读到中途可点赞 / 跳评论框 */}
|
||||
<MobilePostBar
|
||||
postId={id}
|
||||
liked={post.liked}
|
||||
likeCount={post.like_count}
|
||||
favorited={post.favorited === true}
|
||||
allowComments={allowComments}
|
||||
/>
|
||||
|
||||
{/* 三栏阅读布局:宽度与首页工作台一致——lg = 左224 + 正文 + 右280,xl = 左240 + 正文 + 右300;以下单栏
|
||||
侧栏两列均可被站长清空,网格随之退化为两栏/单栏 */}
|
||||
<div
|
||||
@@ -506,15 +498,8 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
{/* 中栏:正文主体 */}
|
||||
<div className="min-w-0">
|
||||
<article className="w-full">
|
||||
<div className="panel p-6 sm:p-8">
|
||||
<SiteDocBreadcrumb
|
||||
className="is-ruled"
|
||||
items={[
|
||||
{ href: "/", label: "首页" },
|
||||
{ href: `/?board=${post.board.id}`, label: post.board.name },
|
||||
]}
|
||||
/>
|
||||
<header className="mb-7">
|
||||
<div className="panel p-4 sm:p-6 lg:p-8 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none">
|
||||
<header className="mb-5 sm:mb-7">
|
||||
<div className="flex items-start gap-2 flex-wrap mb-6">
|
||||
{/* 仅置顶/推荐在标题左侧 */}
|
||||
{post.pinned > 0 && <span className="badge-pin shrink-0 mt-2 sm:mt-3">置顶</span>}
|
||||
@@ -690,7 +675,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
|
||||
{/* 文末点赞/收藏:分隔线后居中,唯一的互动入口(作者行不再放置) */}
|
||||
<div
|
||||
className="mt-9 pt-7 flex justify-center gap-3"
|
||||
className="mt-6 pt-5 sm:mt-9 sm:pt-7 flex justify-center gap-3"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<LikeButton
|
||||
@@ -705,7 +690,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
|
||||
{/* 窄屏作者卡(lg 起由左栏承载,避免重复展示) */}
|
||||
{profile && (
|
||||
<div className="lg:hidden mt-6">
|
||||
<div className="lg:hidden mt-4">
|
||||
<AuthorCardStacked
|
||||
profile={profile}
|
||||
allowComments={allowComments}
|
||||
@@ -745,7 +730,10 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
}
|
||||
/>
|
||||
) : allowComments ? (
|
||||
<section className="panel p-6 sm:p-8 mt-6 text-center" aria-label="评论区">
|
||||
<section
|
||||
className="panel p-4 sm:p-6 lg:p-8 mt-4 text-center max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
|
||||
aria-label="评论区"
|
||||
>
|
||||
<p className="text-[14px]" style={{ color: "var(--ink-2)" }}>
|
||||
{post.status === "pending" ? "帖子审核通过后开放评论" : "帖子未通过审核,评论已关闭"}
|
||||
</p>
|
||||
@@ -760,7 +748,7 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
) : null}
|
||||
|
||||
{/* 窄屏相关讨论(lg 起由右栏承载) */}
|
||||
<div className="lg:hidden mt-6">
|
||||
<div className="lg:hidden mt-4">
|
||||
<RelatedPosts
|
||||
posts={related}
|
||||
boardId={post.board_id}
|
||||
@@ -782,9 +770,6 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
|
||||
</aside>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 给移动端浮动操作条让位,避免遮住页尾内容 */}
|
||||
<div className="lg:hidden h-16" aria-hidden />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -15,8 +15,8 @@ import Modal from "@/components/Modal";
|
||||
*
|
||||
* 两类告知:
|
||||
* - 账号被封禁(reason=banned):管理员封禁后由 HTTP 拦截链路派发;
|
||||
* - 登录已失效(reason=session_replaced):其他浏览器登录顶掉旧会话、
|
||||
* 安全设置剔除本设备等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
|
||||
* - 登录已失效(reason=session_replaced):安全设置剔除本设备、
|
||||
* 会话数超上限被淘汰等场景,旧浏览器当场弹窗提示,而非等硬刷新才发现。
|
||||
*
|
||||
* 登录态被顶的感知入口(探测统一收敛在 lib/api 的 apiMe 判定):
|
||||
* 1) 后端 WS 即时事件 session:replaced(旧设备 WS 在线时秒级感知);
|
||||
@@ -152,7 +152,7 @@ export default function AccountGuard() {
|
||||
id="session-ended-dialog-desc"
|
||||
className="meta text-[13px] leading-relaxed text-center"
|
||||
>
|
||||
你的账号已在其他浏览器登录,或登录会话已被移除,当前设备的登录状态已失效。
|
||||
你的登录会话已被移除(在安全设置中剔除,或会话数超出上限被淘汰),当前设备的登录状态已失效。
|
||||
请重新登录后继续使用。
|
||||
</p>
|
||||
</Modal>
|
||||
|
||||
@@ -8,13 +8,9 @@ const SHOW_AFTER = 400;
|
||||
|
||||
/**
|
||||
* 公开站长页回顶:滚过阈值后右下角浮现。
|
||||
* 短页自然不出现;帖子移动端底栏时抬高,避免叠住操作条。
|
||||
* 短页自然不出现;移动端位置已为底部公共菜单预留避让(见 globals.css)。
|
||||
*/
|
||||
export default function BackToTop({
|
||||
liftForMobileBar = false,
|
||||
}: {
|
||||
liftForMobileBar?: boolean;
|
||||
}) {
|
||||
export default function BackToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -29,7 +25,7 @@ export default function BackToTop({
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={`j13-back-top j13-toast-in${liftForMobileBar ? " is-lifted" : ""}`}
|
||||
className="j13-back-top j13-toast-in"
|
||||
onClick={() => window.scrollTo({ top: 0, behavior: scrollBehavior() })}
|
||||
aria-label="回到顶部"
|
||||
>
|
||||
|
||||
@@ -1306,7 +1306,11 @@ export default function CommentSection({
|
||||
}
|
||||
|
||||
return (
|
||||
<section id="comments" className="panel p-6 sm:p-8 mt-6 scroll-mt-24" aria-label="评论区">
|
||||
<section
|
||||
id="comments"
|
||||
className="panel p-4 sm:p-6 lg:p-8 mt-4 sm:mt-6 scroll-mt-24 max-sm:-mx-4 max-sm:rounded-none max-sm:border-x-0 max-sm:shadow-none"
|
||||
aria-label="评论区"
|
||||
>
|
||||
<div className="flex items-center gap-2.5 mb-6">
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
|
||||
评论
|
||||
|
||||
@@ -4,8 +4,6 @@ import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import {
|
||||
Menu,
|
||||
X,
|
||||
LogIn,
|
||||
UserPlus,
|
||||
LogOut,
|
||||
@@ -19,6 +17,7 @@ import {
|
||||
ChevronDown,
|
||||
ShieldCheck,
|
||||
MessageSquare,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { hasAuthCookieHint } from "@/lib/cookies";
|
||||
@@ -43,7 +42,6 @@ export default function Header({
|
||||
initialTheme = "light",
|
||||
siteName = "姜十三",
|
||||
wordmark = "",
|
||||
slogan = "",
|
||||
logoLight = "",
|
||||
logoDark = "",
|
||||
brandMark = "image_text",
|
||||
@@ -56,7 +54,6 @@ export default function Header({
|
||||
initialTheme?: "light" | "dark";
|
||||
siteName?: string;
|
||||
wordmark?: string;
|
||||
slogan?: string;
|
||||
logoLight?: string;
|
||||
logoDark?: string;
|
||||
brandMark?: BrandMark;
|
||||
@@ -70,13 +67,14 @@ export default function Header({
|
||||
pathname === "/compose" ||
|
||||
(pathname?.startsWith("/compose/") ?? false) ||
|
||||
/^\/post\/[^/]+\/edit\/?$/.test(pathname || "");
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
// 登录态以 SSR 直出为初始值,F5 无切换闪动;router.refresh() 后随 props 校正
|
||||
const [user, setUser] = useState<User | null>(initialUser);
|
||||
const [unread, setUnread] = useState(initialUnread);
|
||||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
// 移动端展开式搜索行(导航已交给底部公共菜单,顶部只留搜索/主题/铃铛/头像)
|
||||
const [searchOpen, setSearchOpen] = useState(false);
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
const mobileSearchRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 登录/登出触发 router.refresh() 后,服务端 layout 重新取态并下发新 props
|
||||
useEffect(() => {
|
||||
@@ -101,7 +99,6 @@ export default function Header({
|
||||
// 有效登录态),本地立即游客化并 router.refresh 校正 SSR 区域,
|
||||
// 封禁告知弹窗由 AccountGuard 负责,避免状态与 UI 清理分散
|
||||
useEffect(() => onForceLogout(() => {
|
||||
setMenuOpen(false);
|
||||
apiLogout()
|
||||
.catch(() => {})
|
||||
.finally(() => {
|
||||
@@ -164,8 +161,9 @@ export default function Header({
|
||||
e.preventDefault();
|
||||
const q = searchQuery.trim();
|
||||
router.push(q ? `/?q=${encodeURIComponent(q)}` : "/");
|
||||
setMenuOpen(false);
|
||||
setSearchOpen(false);
|
||||
searchInputRef.current?.blur();
|
||||
mobileSearchRef.current?.blur();
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
@@ -203,7 +201,6 @@ export default function Header({
|
||||
/* ignore */
|
||||
}
|
||||
setUser(null);
|
||||
setMenuOpen(false);
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
@@ -233,7 +230,6 @@ export default function Header({
|
||||
fit={brandLogoFit}
|
||||
name={siteName}
|
||||
wordmark={wordmark}
|
||||
slogan={slogan}
|
||||
logoLight={logoLight}
|
||||
logoDark={logoDark}
|
||||
/>
|
||||
@@ -255,9 +251,19 @@ export default function Header({
|
||||
{/* 中部弹性留白,把搜索与操作区推到右侧 */}
|
||||
<div className="hidden lg:block flex-1" aria-hidden />
|
||||
|
||||
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询),
|
||||
桌面额外显示主题/发帖/用户菜单,移动端搜索收进汉堡抽屉 */}
|
||||
<div className="flex items-center gap-2 lg:gap-3">
|
||||
{/* 右侧操作区:搜索靠右停靠 + 桌面/移动共用铃铛(仅挂载一次,避免重复轮询)。
|
||||
移动端导航交给底部公共菜单,顶部只保留 搜索/主题/铃铛/头像 */}
|
||||
<div className="flex items-center gap-2 lg:gap-3 ml-auto lg:ml-0">
|
||||
{/* 移动端搜索开关 */}
|
||||
<button
|
||||
onClick={() => setSearchOpen((v) => !v)}
|
||||
aria-label="搜索"
|
||||
aria-expanded={searchOpen}
|
||||
className={`${iconBtn} lg:hidden`}
|
||||
style={iconBtnStyle}
|
||||
>
|
||||
{searchOpen ? <X size={17} /> : <Search size={17} />}
|
||||
</button>
|
||||
<form onSubmit={handleSearch} className="hidden lg:block">
|
||||
<div className="relative w-[220px] xl:w-[260px] focus-within:w-[268px] xl:focus-within:w-[300px] transition-[width] duration-200">
|
||||
<Search
|
||||
@@ -283,18 +289,14 @@ export default function Header({
|
||||
</kbd>
|
||||
</div>
|
||||
</form>
|
||||
<ThemeToggle
|
||||
initialTheme={initialTheme}
|
||||
className={`${iconBtn} hidden lg:inline-flex`}
|
||||
style={iconBtnStyle}
|
||||
/>
|
||||
<ThemeToggle initialTheme={initialTheme} className={iconBtn} style={iconBtnStyle} />
|
||||
{user && allowMessages && (
|
||||
<Link
|
||||
href="/chat"
|
||||
prefetch={false}
|
||||
aria-label="消息"
|
||||
data-tip="消息"
|
||||
className="relative inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
|
||||
className="relative hidden lg:inline-flex items-center justify-center w-10 h-10 rounded-full transition-colors"
|
||||
style={{
|
||||
color: pathname?.startsWith("/chat")
|
||||
? "var(--accent)"
|
||||
@@ -333,95 +335,43 @@ export default function Header({
|
||||
<UserMenu user={user} onLogout={handleLogout} />
|
||||
</>
|
||||
) : (
|
||||
<div className="hidden lg:flex items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Link href="/login" className="btn btn-line btn-sm">
|
||||
<LogIn size={14} /> 登录
|
||||
</Link>
|
||||
<Link href="/register" className="btn btn-primary btn-sm">
|
||||
<Link href="/register" className="btn btn-primary btn-sm hidden sm:inline-flex">
|
||||
<UserPlus size={14} /> 注册
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
onClick={() => setMenuOpen(!menuOpen)}
|
||||
className="w-10 h-10 flex items-center justify-center rounded-full lg:hidden"
|
||||
style={{ border: "1px solid var(--line)", background: "var(--panel)", color: "var(--ink)" }}
|
||||
aria-label="菜单"
|
||||
aria-expanded={menuOpen}
|
||||
>
|
||||
{menuOpen ? <X size={19} /> : <Menu size={19} />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 移动端抽屉 */}
|
||||
{menuOpen && (
|
||||
{/* 移动端展开式搜索行 */}
|
||||
{searchOpen && (
|
||||
<div
|
||||
className="lg:hidden border-t px-4 py-4"
|
||||
className="lg:hidden border-t px-4 py-2.5"
|
||||
style={{ borderColor: "var(--line)", background: "var(--bg)" }}
|
||||
>
|
||||
<form onSubmit={handleSearch} className="relative mb-3">
|
||||
<form onSubmit={handleSearch} className="relative">
|
||||
<Search size={16} className="absolute left-4 top-1/2 -translate-y-1/2" style={{ color: "var(--ink-3)" }} />
|
||||
<input
|
||||
ref={mobileSearchRef}
|
||||
autoFocus
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={onSearchKeyDown}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Escape") {
|
||||
setSearchOpen(false);
|
||||
if (new URLSearchParams(window.location.search).get("q")) router.push("/");
|
||||
}
|
||||
}}
|
||||
aria-label="搜索帖子"
|
||||
placeholder="搜索帖子...(Esc 清除)"
|
||||
placeholder="搜索帖子..."
|
||||
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex flex-col">
|
||||
<MobileLink href="/" active={pathname === "/"}>首页</MobileLink>
|
||||
<MobileLink href="/leaderboard" active={pathname?.startsWith("/leaderboard") ?? false}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<Trophy size={15} /> 排行榜
|
||||
</span>
|
||||
</MobileLink>
|
||||
{user && !hideComposeNav && <MobileLink href="/compose">发帖</MobileLink>}
|
||||
{user && allowMessages && (
|
||||
<MobileLink href="/chat" active={pathname?.startsWith("/chat") ?? false}>
|
||||
<span className="inline-flex items-center gap-2">
|
||||
<MessageSquare size={15} /> 消息
|
||||
{chatUnread > 0 && (
|
||||
<span
|
||||
className="inline-flex items-center justify-center min-w-[16px] h-4 px-1 text-[10px] font-medium rounded-full"
|
||||
style={{ background: "var(--clay)", color: "#fff" }}
|
||||
>
|
||||
{chatUnread > 99 ? "99+" : chatUnread}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{user && (
|
||||
<MobileLink href="/notifications">
|
||||
<span className="inline-flex items-center gap-2"><Bell size={15} /> 通知中心</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{user && <MobileLink href="/settings">账号设置</MobileLink>}
|
||||
{user && (isStaff(user.role) || canAccessAdminMessages(user)) && (
|
||||
<MobileLink href="/admin">
|
||||
<span className="inline-flex items-center gap-2"><ShieldCheck size={15} /> 管理面板</span>
|
||||
</MobileLink>
|
||||
)}
|
||||
{!user && <MobileLink href="/login">登录</MobileLink>}
|
||||
{!user && <MobileLink href="/register">注册</MobileLink>}
|
||||
</div>
|
||||
<div className="pt-3 mt-2 border-t flex items-center justify-between" style={{ borderColor: "var(--line)" }}>
|
||||
<ThemeToggle
|
||||
initialTheme={initialTheme}
|
||||
labelMode="labeled"
|
||||
iconSize={14}
|
||||
className="btn btn-line btn-sm"
|
||||
/>
|
||||
{user && (
|
||||
<button onClick={handleLogout} className="btn btn-danger btn-sm">
|
||||
<LogOut size={14} /> 退出
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
@@ -455,32 +405,9 @@ function NavPill({
|
||||
);
|
||||
}
|
||||
|
||||
function MobileLink({
|
||||
href,
|
||||
active,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
active?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className="px-3 py-3 rounded-2xl text-[16px] font-semibold transition-colors"
|
||||
style={{
|
||||
color: active ? "var(--accent)" : "var(--ink)",
|
||||
background: "transparent",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 桌面端用户菜单:头像 + 昵称触发器,下拉承载个人主页/设置/登出,
|
||||
* 把零散的图标按钮收拢为一个语义明确的控件
|
||||
* 用户菜单(两端可用):头像触发器,下拉承载通知中心/个人主页/设置/登出,
|
||||
* 把零散的图标按钮收拢为一个语义明确的控件;移动端只显示头像
|
||||
*/
|
||||
function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
const [open, setOpen] = useState(false);
|
||||
@@ -506,12 +433,13 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
"w-full flex items-center gap-2.5 px-4 py-2.5 text-[13.5px] transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]";
|
||||
|
||||
return (
|
||||
<div className="relative hidden lg:block" ref={menuRef}>
|
||||
<div className="relative" ref={menuRef}>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-2 rounded-full pl-1 pr-2.5 py-1 transition-colors"
|
||||
aria-label="用户菜单"
|
||||
className="flex items-center gap-2 rounded-full p-1 lg:pl-1 lg:pr-2.5 transition-colors"
|
||||
style={{
|
||||
border: "1px solid " + (open ? "var(--accent)" : "var(--line)"),
|
||||
background: open ? "var(--accent-soft)" : "var(--panel)",
|
||||
@@ -519,14 +447,14 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
>
|
||||
<Avatar name={user.nickname || user.username} src={user.avatar || undefined} size={28} />
|
||||
<span
|
||||
className="text-[13px] max-w-[90px] truncate"
|
||||
className="hidden lg:inline text-[13px] max-w-[90px] truncate"
|
||||
style={{ color: open ? "var(--accent)" : "var(--ink)" }}
|
||||
>
|
||||
{user.nickname}
|
||||
</span>
|
||||
<ChevronDown
|
||||
size={14}
|
||||
className="transition-transform duration-200"
|
||||
className="hidden lg:block transition-transform duration-200"
|
||||
style={{
|
||||
color: "var(--ink-3)",
|
||||
transform: open ? "rotate(180deg)" : "none",
|
||||
@@ -562,6 +490,9 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-1.5">
|
||||
<Link href="/notifications" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<Bell size={15} style={{ color: "var(--ink-3)" }} /> 通知中心
|
||||
</Link>
|
||||
<Link href={`/u/${user.id}`} onClick={() => setOpen(false)} className={itemCls} role="menuitem">
|
||||
<UserIcon size={15} style={{ color: "var(--ink-3)" }} /> 个人主页
|
||||
</Link>
|
||||
|
||||
@@ -134,7 +134,9 @@ export default function ImageInsertModal({
|
||||
<span className="j13-img-dropzone-title">
|
||||
{uploading ? "图片上传中…" : "拖拽图片到此处,或点击选择文件"}
|
||||
</span>
|
||||
<span className="j13-img-dropzone-hint">支持 JPEG / PNG / WebP</span>
|
||||
<span className="j13-img-dropzone-hint">
|
||||
支持 JPEG / PNG / WebP / GIF,自动转存 WebP(GIF 除外)
|
||||
</span>
|
||||
</button>
|
||||
{uploadError && !uploading ? (
|
||||
<p className="j13-img-modal-error" role="alert">
|
||||
|
||||
@@ -413,9 +413,9 @@ export default function MarkdownEditor({
|
||||
if (!file || disabled) return;
|
||||
retryFile.current = file;
|
||||
setUploadError("");
|
||||
if (!/^image\/(jpeg|png|webp)$/i.test(file.type)) {
|
||||
setUploadError("仅支持 JPEG / PNG / WebP");
|
||||
toast("仅支持 JPEG / PNG / WebP", "error");
|
||||
if (!/^image\/(jpeg|png|webp|gif)$/i.test(file.type)) {
|
||||
setUploadError("仅支持 JPEG / PNG / WebP / GIF");
|
||||
toast("仅支持 JPEG / PNG / WebP / GIF", "error");
|
||||
return;
|
||||
}
|
||||
const maxBytes = Math.max(1, imageMaxMB) * 1024 * 1024;
|
||||
@@ -1254,7 +1254,7 @@ export default function MarkdownEditor({
|
||||
<input
|
||||
ref={fileRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/webp"
|
||||
accept="image/jpeg,image/png,image/webp,image/gif"
|
||||
className="hidden"
|
||||
onChange={(e) => void uploadImageFile(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
|
||||
@@ -1,62 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { MessageSquare } from "lucide-react";
|
||||
import LikeButton from "./LikeButton";
|
||||
import FavoriteButton from "./FavoriteButton";
|
||||
import { scrollBehavior } from "@/lib/motion";
|
||||
|
||||
/**
|
||||
* 帖子详情移动端浮动操作条(lg 以下显示):
|
||||
* 长文读到中途也能点赞 / 收藏 / 跳评论框;回顶由全局 BackToTop 承担。
|
||||
*/
|
||||
export default function MobilePostBar({
|
||||
postId,
|
||||
liked,
|
||||
likeCount,
|
||||
favorited = false,
|
||||
allowComments = true,
|
||||
}: {
|
||||
postId: string;
|
||||
liked: boolean;
|
||||
likeCount: number;
|
||||
favorited?: boolean;
|
||||
allowComments?: boolean;
|
||||
}) {
|
||||
const scrollToComments = () => {
|
||||
document.getElementById("comment-input")?.scrollIntoView({
|
||||
behavior: scrollBehavior(),
|
||||
block: "center",
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="lg:hidden fixed bottom-4 left-1/2 -translate-x-1/2 z-40" role="navigation" aria-label="阅读操作">
|
||||
<div
|
||||
className="flex items-center gap-0.5 rounded-full border px-1.5 py-1.5"
|
||||
style={{
|
||||
background: "color-mix(in srgb, var(--panel) 90%, transparent)",
|
||||
borderColor: "var(--line-2)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
}}
|
||||
>
|
||||
{allowComments ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={scrollToComments}
|
||||
className="w-11 h-11 flex items-center justify-center rounded-full transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)]"
|
||||
style={{ color: "var(--ink-2)" }}
|
||||
aria-label="跳到评论框"
|
||||
>
|
||||
<MessageSquare size={18} />
|
||||
</button>
|
||||
) : null}
|
||||
<div className="j13-bar-like">
|
||||
<LikeButton postId={postId} liked={liked} likeCount={likeCount} variant="compact" />
|
||||
</div>
|
||||
<FavoriteButton postId={postId} favorited={favorited} variant="compact" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
192
frontend/components/MobileTabBar.tsx
Normal file
192
frontend/components/MobileTabBar.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname } from "next/navigation";
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Home,
|
||||
Trophy,
|
||||
PenSquare,
|
||||
MessageSquare,
|
||||
User as UserIcon,
|
||||
} from "lucide-react";
|
||||
import { apiChatUnreadSummary, type User } from "@/lib/api";
|
||||
import { useAllowMessages } from "@/components/MessagesPolicyProvider";
|
||||
import { onDocumentVisible } from "@/lib/visibilityReconcile";
|
||||
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
|
||||
import type { RtChatUnreadData } from "@/lib/realtime";
|
||||
|
||||
/**
|
||||
* 移动端全局底部公共菜单(lg 以下显示):
|
||||
* 首页 / 排行榜 / 发帖 / 消息(未登录不显示)/ 我的。
|
||||
* 聊天页、发帖工作台、管理后台由 SiteChrome 控制不渲染,避免与锁视口布局冲突。
|
||||
*/
|
||||
export default function MobileTabBar({
|
||||
user,
|
||||
initialChatUnread = 0,
|
||||
}: {
|
||||
user: User | null;
|
||||
initialChatUnread?: number;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const allowMessages = useAllowMessages();
|
||||
const [chatUnread, setChatUnread] = useState(initialChatUnread);
|
||||
|
||||
// 登录态经 router.refresh() 下发新 props 时校正徽标
|
||||
useEffect(() => {
|
||||
setChatUnread(initialChatUnread);
|
||||
}, [initialChatUnread]);
|
||||
|
||||
// 实时未读:与 Header 同源(chat:unread 对全体成员推送;在当前会话页则忽略)
|
||||
useEffect(() => {
|
||||
if (!user || !allowMessages) return;
|
||||
const off = realtime.on<RtChatUnreadData>(RT_CHAT_UNREAD, (data) => {
|
||||
if (!data || typeof data.room_id !== "number") return;
|
||||
if (pathname === `/chat/${data.room_id}`) return;
|
||||
const delta = typeof data.delta === "number" ? data.delta : 1;
|
||||
setChatUnread((c) => Math.max(0, c + delta));
|
||||
});
|
||||
return off;
|
||||
}, [user, pathname, allowMessages]);
|
||||
|
||||
// 会话内已读 / 回前台:合并对账(短时间多次同步只打一次 summary)
|
||||
useEffect(() => {
|
||||
if (!user || !allowMessages) return;
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
const pullSummary = () => {
|
||||
apiChatUnreadSummary()
|
||||
.then((res) => setChatUnread(res.total || 0))
|
||||
.catch(() => {});
|
||||
};
|
||||
const onSync = () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(pullSummary, 250);
|
||||
};
|
||||
window.addEventListener("j13-chat-unread-sync", onSync);
|
||||
const offVisible = onDocumentVisible(pullSummary);
|
||||
return () => {
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
window.removeEventListener("j13-chat-unread-sync", onSync);
|
||||
offVisible();
|
||||
};
|
||||
}, [user, allowMessages]);
|
||||
|
||||
const showMessages = !!user && allowMessages;
|
||||
|
||||
const isActive = (match: (p: string) => boolean) =>
|
||||
!!pathname && match(pathname);
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="lg:hidden fixed bottom-0 inset-x-0 z-50 border-t"
|
||||
aria-label="底部导航"
|
||||
style={{
|
||||
borderColor: "var(--line)",
|
||||
background: "color-mix(in srgb, var(--panel) 92%, transparent)",
|
||||
backdropFilter: "blur(14px)",
|
||||
WebkitBackdropFilter: "blur(14px)",
|
||||
paddingBottom: "env(safe-area-inset-bottom)",
|
||||
}}
|
||||
>
|
||||
<div className="flex items-stretch">
|
||||
<TabLink
|
||||
href="/"
|
||||
icon={<Home size={20} />}
|
||||
label="首页"
|
||||
active={isActive((p) => p === "/")}
|
||||
/>
|
||||
<TabLink
|
||||
href="/leaderboard"
|
||||
icon={<Trophy size={20} />}
|
||||
label="排行榜"
|
||||
active={isActive((p) => p.startsWith("/leaderboard"))}
|
||||
/>
|
||||
<TabLink
|
||||
href={user ? "/compose" : "/login"}
|
||||
icon={<PenSquare size={18} />}
|
||||
label="发帖"
|
||||
prominent
|
||||
active={false}
|
||||
/>
|
||||
{showMessages && (
|
||||
<TabLink
|
||||
href="/chat"
|
||||
icon={<MessageSquare size={20} />}
|
||||
label="消息"
|
||||
active={isActive((p) => p.startsWith("/chat"))}
|
||||
badge={chatUnread > 0 ? (chatUnread > 99 ? "99+" : chatUnread) : 0}
|
||||
/>
|
||||
)}
|
||||
<TabLink
|
||||
href={user ? `/u/${user.id}` : "/login"}
|
||||
icon={<UserIcon size={20} />}
|
||||
label="我的"
|
||||
active={
|
||||
!!user && isActive((p) => p === `/u/${user.id}` || p.startsWith(`/u/${user.id}/`))
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
function TabLink({
|
||||
href,
|
||||
icon,
|
||||
label,
|
||||
active,
|
||||
prominent = false,
|
||||
badge = 0,
|
||||
}: {
|
||||
href: string;
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
active: boolean;
|
||||
prominent?: boolean;
|
||||
badge?: number | string;
|
||||
}) {
|
||||
const ink = active ? "var(--accent)" : "var(--ink-2)";
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
prefetch={false}
|
||||
aria-label={label}
|
||||
aria-current={active ? "page" : undefined}
|
||||
className="flex-1 min-w-0 flex flex-col items-center justify-center gap-0.5 pt-2 pb-1.5 transition-colors"
|
||||
style={{ color: ink }}
|
||||
>
|
||||
{prominent ? (
|
||||
<span
|
||||
className="w-9 h-9 -mt-1 flex items-center justify-center rounded-full"
|
||||
style={{ background: "var(--accent)", color: "var(--accent-on)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
{icon}
|
||||
</span>
|
||||
) : (
|
||||
<span className="relative" aria-hidden="true">
|
||||
{icon}
|
||||
{badge ? (
|
||||
<span
|
||||
className="absolute -top-1.5 -right-2.5 min-w-[16px] h-[16px] px-1 text-[9.5px] font-medium flex items-center justify-center rounded-full"
|
||||
style={{
|
||||
background: "var(--clay)",
|
||||
color: "#fff",
|
||||
border: "2px solid var(--panel)",
|
||||
}}
|
||||
>
|
||||
{badge}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
)}
|
||||
<span
|
||||
className={`text-[10px] leading-none ${
|
||||
prominent ? "mt-0.5" : ""
|
||||
} ${active ? "font-semibold" : "font-medium"}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
@@ -201,31 +201,46 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<>
|
||||
{/* 移动端遮罩:点击关闭(桌面不渲染) */}
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3.5"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
className="lg:hidden fixed inset-0 z-40"
|
||||
style={{ background: "rgba(0,0,0,0.35)" }}
|
||||
onClick={() => setOpen(false)}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="absolute right-0 mt-2.5 w-[340px] max-w-[calc(100vw-2rem)] overflow-hidden z-50 j13-toast-in flex flex-col
|
||||
max-lg:fixed max-lg:inset-x-0 max-lg:bottom-0 max-lg:top-auto max-lg:mt-0 max-lg:w-full max-lg:max-w-none max-lg:max-h-[75dvh] max-lg:rounded-t-2xl!"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
border: "1px solid var(--line)",
|
||||
borderRadius: "var(--r)",
|
||||
boxShadow: "var(--shadow-lg)",
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="flex items-center gap-1 text-xs meta hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<CheckCheck size={13} /> 全部已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{/* 移动端底部面板抓手 */}
|
||||
<div
|
||||
className="lg:hidden mx-auto mt-2 mb-[-6px] w-10 h-1 rounded-full shrink-0"
|
||||
style={{ background: "var(--line-2)" }}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className="flex items-center justify-between px-5 py-3.5 shrink-0"
|
||||
style={{ borderBottom: "1px solid var(--line)" }}
|
||||
>
|
||||
<span className="font-semibold text-[15px]" style={{ color: "var(--ink)" }}>通知</span>
|
||||
{unread > 0 && (
|
||||
<button
|
||||
onClick={handleMarkAllRead}
|
||||
className="flex items-center gap-1 text-xs meta hover:text-[var(--accent)] transition-colors"
|
||||
>
|
||||
<CheckCheck size={13} /> 全部已读
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="max-h-[24rem] overflow-y-auto">
|
||||
<div className="flex-1 min-h-0 max-h-[24rem] max-lg:max-h-none overflow-y-auto">
|
||||
{loading ? (
|
||||
<p className="text-center py-10 meta">加载中...</p>
|
||||
) : notifications.length === 0 ? (
|
||||
@@ -303,7 +318,7 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="px-4 py-2.5 text-center"
|
||||
className="px-4 py-2.5 text-center shrink-0"
|
||||
style={{ borderTop: "1px solid var(--line)" }}
|
||||
>
|
||||
<Link
|
||||
@@ -314,7 +329,8 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
|
||||
查看全部通知 →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { usePathname } from "next/navigation";
|
||||
import Header from "@/components/Header";
|
||||
import Footer from "@/components/Footer";
|
||||
import BackToTop from "@/components/BackToTop";
|
||||
import MobileTabBar from "@/components/MobileTabBar";
|
||||
import type { User } from "@/lib/api";
|
||||
import { useSiteBrand } from "@/components/SiteBrandProvider";
|
||||
|
||||
@@ -23,10 +24,9 @@ function pathIsComposeStudio(pathname: string | null) {
|
||||
return pathname === "/compose" || /^\/post\/[^/]+\/edit\/?$/.test(pathname);
|
||||
}
|
||||
|
||||
/** 帖子详情(非 edit)移动端有底栏,回顶需抬高 */
|
||||
function pathIsPostDetail(pathname: string | null) {
|
||||
if (!pathname) return false;
|
||||
return /^\/post\/[^/]+$/.test(pathname);
|
||||
/** 底部公共菜单避让:聊天 / 发帖工作台锁视口,不渲染底栏 */
|
||||
function pathHasNoTabBar(pathname: string | null) {
|
||||
return pathIsChat(pathname) || pathIsComposeStudio(pathname);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,6 +85,7 @@ export default function SiteChrome({
|
||||
|
||||
// 聊天 / 发帖工作台锁视口,窗口回顶无意义
|
||||
const showBackToTop = !pathIsChat(pathname) && !pathIsComposeStudio(pathname);
|
||||
const showTabBar = showBackToTop;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -95,7 +96,6 @@ export default function SiteChrome({
|
||||
initialTheme={theme}
|
||||
siteName={brand.site_name}
|
||||
wordmark={brand.site_wordmark}
|
||||
slogan={brand.site_slogan}
|
||||
logoLight={brand.logo_light_url}
|
||||
logoDark={brand.logo_dark_url}
|
||||
brandMark={brand.brand_mark}
|
||||
@@ -105,14 +105,15 @@ export default function SiteChrome({
|
||||
<main
|
||||
id="main"
|
||||
tabIndex={-1}
|
||||
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 py-7 sm:py-[var(--main-pad-y)] outline-none"
|
||||
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[calc(4.75rem+env(safe-area-inset-bottom))] lg:pb-[var(--main-pad-y)] outline-none"
|
||||
>
|
||||
{children}
|
||||
</main>
|
||||
<Footer siteName={brand.site_name} links={brand.footer_links} />
|
||||
{showBackToTop ? (
|
||||
<BackToTop liftForMobileBar={pathIsPostDetail(pathname)} />
|
||||
{showTabBar ? (
|
||||
<MobileTabBar user={user} initialChatUnread={chatUnread} />
|
||||
) : null}
|
||||
{showBackToTop ? <BackToTop /> : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import {
|
||||
BarChart3,
|
||||
Award,
|
||||
Layers,
|
||||
Images,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -62,7 +63,7 @@ interface NavGroup {
|
||||
items: NavItem[];
|
||||
}
|
||||
|
||||
// 分组导航:概览 → 内容运营 → 成员经济 → 站点配置
|
||||
// 分组导航:概览 → 内容 → 成员与经济 → 外观展示 → 系统
|
||||
const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "概览",
|
||||
@@ -74,6 +75,12 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{
|
||||
title: "内容",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/boards",
|
||||
label: "板块管理",
|
||||
icon: Columns3,
|
||||
visible: (u) => isOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/content",
|
||||
label: "内容管理",
|
||||
@@ -99,12 +106,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: FileText,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
label: "推广与赞助",
|
||||
icon: CircleDollarSign,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -134,23 +135,17 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: Award,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
label: "推广与赞助",
|
||||
icon: CircleDollarSign,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "站点",
|
||||
title: "外观展示",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/boards",
|
||||
label: "板块管理",
|
||||
icon: Columns3,
|
||||
visible: (u) => isOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: "站点设置",
|
||||
icon: Globe,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/appearance",
|
||||
label: "外观",
|
||||
@@ -163,6 +158,23 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
icon: PanelLeft,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/media",
|
||||
label: "媒体库",
|
||||
icon: Images,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "系统",
|
||||
items: [
|
||||
{
|
||||
href: "/admin/settings",
|
||||
label: "站点设置",
|
||||
icon: Globe,
|
||||
visible: (u) => isSuperOrOwner(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -20,6 +20,8 @@ import type {
|
||||
CheckinStatus,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import LevelBadge from "@/components/LevelBadge";
|
||||
import BadgeChip from "@/components/BadgeChip";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { formatCompact } from "@/lib/format";
|
||||
|
||||
@@ -34,16 +36,21 @@ export function Moderators({ items }: { items: BoardModerator[] }) {
|
||||
{items.length === 0 ? (
|
||||
<p className="meta px-0.5 py-1.5">暂无板块管理员</p>
|
||||
) : (
|
||||
<ul className="space-y-0.5">
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((u) => {
|
||||
const name = u.nickname || u.username;
|
||||
return (
|
||||
<li key={u.id}>
|
||||
<Link href={`/u/${u.id}`} className="j13-raillink" data-tip={name}>
|
||||
<Avatar name={name} src={u.avatar || undefined} size={30} />
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
<span className="min-w-0 truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
|
||||
{name}
|
||||
</span>
|
||||
<LevelBadge level={u.level} className="shrink-0" />
|
||||
{u.badges?.slice(0, 2).map((ub) => (
|
||||
<BadgeChip key={ub.id} badge={ub.badge} className="shrink-0" />
|
||||
))}
|
||||
<span className="flex-1" />
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
import Link from "next/link";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { Bookmark, Home, ShieldCheck } from "lucide-react";
|
||||
import type { BoardWithCount, User } from "@/lib/api";
|
||||
import { isStaff } from "@/lib/roles";
|
||||
import BoardIcon, { boardColor } from "@/components/BoardIcon";
|
||||
@@ -27,17 +28,8 @@ export function WorkspaceNavPanel({
|
||||
|
||||
return (
|
||||
<section className="j13-module flex max-h-[var(--rail-max-h)] flex-col overflow-hidden" aria-label="板块目录">
|
||||
<p className="j13-module-title shrink-0">
|
||||
<LayoutGrid size={15} style={{ color: "var(--accent)" }} aria-hidden />
|
||||
板块目录
|
||||
{boards.length > 0 ? (
|
||||
<span className="ml-auto meta text-[11.5px] font-medium tracking-normal tabular-nums">
|
||||
{boards.length} 个
|
||||
</span>
|
||||
) : null}
|
||||
</p>
|
||||
|
||||
<div className="j13-rail-scroll j13-board-list mt-2.5 min-h-0 flex-1">
|
||||
<div className="j13-rail-scroll j13-board-list min-h-0 flex-1">
|
||||
<p className="j13-rail-group">浏览</p>
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-bcard"
|
||||
@@ -53,25 +45,41 @@ export function WorkspaceNavPanel({
|
||||
<span className="j13-bcard-name flex-1 truncate">全部讨论</span>
|
||||
</Link>
|
||||
|
||||
{boards.length > 0 ? <div className="j13-board-split" role="separator" /> : null}
|
||||
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
{user ? (
|
||||
<Link href={`/u/${user.id}?tab=favorites`} className="j13-bcard">
|
||||
<span
|
||||
className="j13-bcard-ico"
|
||||
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Bookmark size={15} />
|
||||
</span>
|
||||
<span className="j13-bcard-name flex-1 truncate">我的收藏</span>
|
||||
</Link>
|
||||
))}
|
||||
) : null}
|
||||
|
||||
{boards.length > 0 ? (
|
||||
<>
|
||||
<p className="j13-rail-group">板块</p>
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-bcard"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-label={`${b.name}板块,${b.post_count} 篇讨论`}
|
||||
>
|
||||
<span className={`av-${boardColor(b)} j13-bcard-ico`} aria-hidden="true">
|
||||
<BoardIcon icon={b.icon} name={b.name} size={15} />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 flex items-center justify-between gap-2">
|
||||
<span className="j13-bcard-name truncate">{b.name}</span>
|
||||
<span className="meta shrink-0 text-[11px] tabular-nums">{b.post_count} 篇</span>
|
||||
</span>
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{showAdmin ? (
|
||||
@@ -105,46 +113,77 @@ export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?
|
||||
);
|
||||
}
|
||||
|
||||
// 移动端:发帖入口 + 横向滑动板块条
|
||||
export function WorkspaceNavMobile({
|
||||
boards,
|
||||
user,
|
||||
}: {
|
||||
boards: BoardWithCount[];
|
||||
user: User | null;
|
||||
}) {
|
||||
// 移动端:横向滑动板块条(两端渐隐指示「可左右滑动」);发帖入口由底部公共菜单承载
|
||||
export function WorkspaceNavMobile({ boards }: { boards: BoardWithCount[] }) {
|
||||
const params = useSearchParams();
|
||||
const activeBoard = Number(params.get("board")) || 0;
|
||||
const composePath =
|
||||
activeBoard > 0 ? `/compose?board=${activeBoard}` : "/compose";
|
||||
const composeHref = user
|
||||
? composePath
|
||||
: `/login?redirect=${encodeURIComponent(composePath)}`;
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const [canScrollLeft, setCanScrollLeft] = useState(false);
|
||||
const [canScrollRight, setCanScrollRight] = useState(false);
|
||||
|
||||
const updateScrollState = useCallback(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const max = el.scrollWidth - el.clientWidth;
|
||||
setCanScrollLeft(el.scrollLeft > 4);
|
||||
setCanScrollRight(max > 4 && el.scrollLeft < max - 4);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
updateScrollState();
|
||||
const el = scrollRef.current;
|
||||
if (!el || typeof ResizeObserver === "undefined") return;
|
||||
const ro = new ResizeObserver(updateScrollState);
|
||||
ro.observe(el);
|
||||
return () => ro.disconnect();
|
||||
}, [boards, updateScrollState]);
|
||||
|
||||
// 进页 / 切板块时把激活 chip 滚到可视区中央
|
||||
useEffect(() => {
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
el.querySelector<HTMLElement>('[aria-current="page"]')?.scrollIntoView({
|
||||
block: "nearest",
|
||||
inline: "center",
|
||||
});
|
||||
}, [activeBoard, boards]);
|
||||
|
||||
return (
|
||||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6">
|
||||
<div className="j13-chips">
|
||||
<Link href={composeHref} className="btn btn-primary btn-sm shrink-0">
|
||||
<PenSquare size={14} /> 发帖
|
||||
</Link>
|
||||
<Link
|
||||
href="/"
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
<div className="lg:hidden -mx-4 sm:-mx-6 px-4 sm:px-6 bg-[var(--main-bg)] border-b border-[var(--line)] py-2.5">
|
||||
<div className="relative">
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="j13-chips"
|
||||
onScroll={updateScrollState}
|
||||
>
|
||||
<Home size={13} /> 全部
|
||||
</Link>
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
href="/"
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
aria-current={activeBoard === 0 ? "page" : undefined}
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
{b.name}
|
||||
<Home size={13} /> 全部
|
||||
</Link>
|
||||
))}
|
||||
{boards.map((b) => (
|
||||
<Link
|
||||
key={b.id}
|
||||
href={boardHref(b.id)}
|
||||
className="j13-chip"
|
||||
aria-current={activeBoard === b.id ? "page" : undefined}
|
||||
>
|
||||
<BoardIcon icon={b.icon} name={b.name} size={13} />
|
||||
{b.name}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
{/* 渐隐滑动指示:遮罩与背景同色,pointer-events-none 不挡触控 */}
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-left ${canScrollLeft ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div
|
||||
className={`j13-chip-fade j13-chip-fade-right ${canScrollRight ? "is-on" : ""}`}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
import Link from "next/link";
|
||||
import { ChevronRight } from "lucide-react";
|
||||
|
||||
export type Crumb = { href?: string; label: string };
|
||||
|
||||
/** 位置指引:嵌在正文面板顶部,不单独成块 */
|
||||
export default function SiteDocBreadcrumb({
|
||||
items,
|
||||
className,
|
||||
}: {
|
||||
items: Crumb[];
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<nav
|
||||
className={`j13-page-crumb meta flex items-center gap-1 flex-wrap text-[12.5px] leading-none ${className ?? ""}`}
|
||||
aria-label="面包屑"
|
||||
>
|
||||
{items.map((item, i) => {
|
||||
const last = i === items.length - 1;
|
||||
const cls = last ? "truncate" : "shrink-0";
|
||||
return (
|
||||
<span key={`${item.label}-${i}`} className="inline-flex items-center gap-1 min-w-0">
|
||||
{i > 0 ? <ChevronRight size={12} className="shrink-0 opacity-70" aria-hidden /> : null}
|
||||
{item.href ? (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={`hover:text-[var(--accent)] transition-colors ${cls}`}
|
||||
style={last ? { color: "var(--accent)" } : undefined}
|
||||
>
|
||||
{item.label}
|
||||
</Link>
|
||||
) : (
|
||||
<span className={cls} style={last ? { color: "var(--accent)" } : undefined}>
|
||||
{item.label}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
@@ -8,14 +8,11 @@ export default function SiteDocShell({
|
||||
left,
|
||||
right,
|
||||
children,
|
||||
breadcrumb,
|
||||
mobileExtras,
|
||||
}: {
|
||||
left?: ReactNode;
|
||||
right?: ReactNode;
|
||||
children: ReactNode;
|
||||
/** 中栏正文上方的位置指引(流式排布,不再绝对定位) */
|
||||
breadcrumb?: ReactNode;
|
||||
/** 窄屏下沉到正文之后的侧栏副本 */
|
||||
mobileExtras?: ReactNode;
|
||||
}) {
|
||||
@@ -30,7 +27,6 @@ export default function SiteDocShell({
|
||||
</aside>
|
||||
|
||||
<div className="min-w-0">
|
||||
{breadcrumb}
|
||||
{children}
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,6 +4,4 @@ export { default as AboutSiteHeader } from "./AboutSiteHeader";
|
||||
export { default as SiteDocPageNav } from "./SiteDocPageNav";
|
||||
export { default as SiteDocAnnoNav } from "./SiteDocAnnoNav";
|
||||
export { default as SiteDocMasthead } from "./SiteDocMasthead";
|
||||
export { default as SiteDocBreadcrumb } from "./SiteDocBreadcrumb";
|
||||
export type { Crumb } from "./SiteDocBreadcrumb";
|
||||
export type { SiteDocIdentityKind } from "./SiteDocIdentity";
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
type FooterLinksAlign,
|
||||
} from "./brand";
|
||||
import { DEFAULT_LEVEL_DEFS, LEVEL_COLOR_KEYS, MAX_LEVEL_COUNT, type LevelDef } from "./levels";
|
||||
import { CSRF_COOKIE } from "./cookies";
|
||||
import { CSRF_COOKIE, hasAuthCookieHint } from "./cookies";
|
||||
import { publishPublicMetaSnapshot } from "./publicMetaSnapshot";
|
||||
import { emitForceLogout } from "./userEvents";
|
||||
|
||||
@@ -306,12 +306,14 @@ export interface PublicSponsorItem {
|
||||
hover_text?: string;
|
||||
}
|
||||
|
||||
/** 板块右栏:管理员公开资料 */
|
||||
/** 板块右栏:管理员公开资料(含等级与颁发的徽章) */
|
||||
export interface BoardModerator {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
avatar: string;
|
||||
level?: number;
|
||||
badges?: UserBadgeView[];
|
||||
}
|
||||
|
||||
/** 板块右栏:板级统计 */
|
||||
@@ -686,13 +688,13 @@ function clientHeaders(extra: Record<string, string> = {}): HeadersInit {
|
||||
|
||||
// ===== Refresh token 自动续期 =====
|
||||
|
||||
let refreshPromise: Promise<{ ok: boolean; banned: boolean }> | null = null;
|
||||
let refreshPromise: ReturnType<typeof apiRefresh> | null = null;
|
||||
|
||||
// 被封禁通知去重:同一登录生命周期内只强制下线/弹一次,
|
||||
// 重新登录成功(apiLogin)后复位,使"解封后再次被封"仍能再次提示
|
||||
let bannedNotified = false;
|
||||
|
||||
// 登录态被顶/被剔除通知去重(其他浏览器登录、安全设置剔除设备等)。
|
||||
// 登录态被剔除通知去重(安全设置剔除设备、会话数超上限被淘汰等)。
|
||||
// sessionSeen 标记本标签页曾处于登录态:游客触发 401 不应弹"登录已失效"
|
||||
let sessionEndedNotified = false;
|
||||
let sessionSeen = false;
|
||||
@@ -720,22 +722,25 @@ function detectBannedBody(res: Response): void {
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)
|
||||
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean }> {
|
||||
// 调用 /api/auth/refresh 刷新 access token(refresh cookie 由浏览器自动携带)。
|
||||
// transient=true 表示网络异常或后端暂不可用(含代理 5xx):此时无法证明登录态
|
||||
// 失效,调用方不应据此弹"登录已失效"——dev 后端 go run 重启窗口内尤其常见
|
||||
async function apiRefresh(): Promise<{ ok: boolean; banned: boolean; transient: boolean }> {
|
||||
try {
|
||||
const res = await fetch("/api/auth/refresh", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: clientHeaders(),
|
||||
});
|
||||
if (res.ok) return { ok: true, banned: false };
|
||||
if (res.ok) return { ok: true, banned: false, transient: false };
|
||||
if (res.status >= 500) return { ok: false, banned: false, transient: true };
|
||||
if (res.status === 403) {
|
||||
const data = await res.json().catch(() => ({} as { code?: string }));
|
||||
if (data.code === "account_banned") return { ok: false, banned: true };
|
||||
if (data.code === "account_banned") return { ok: false, banned: true, transient: false };
|
||||
}
|
||||
return { ok: false, banned: false };
|
||||
return { ok: false, banned: false, transient: false };
|
||||
} catch {
|
||||
return { ok: false, banned: false };
|
||||
return { ok: false, banned: false, transient: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -762,9 +767,10 @@ async function fetchWithRefresh(url: string, init: RequestInit): Promise<Respons
|
||||
} else if (result.banned && !bannedNotified) {
|
||||
bannedNotified = true;
|
||||
emitForceLogout("banned");
|
||||
} else if (!result.banned) {
|
||||
// refresh 失败且非封禁:登录态已失效(被顶/被剔除/过期),
|
||||
// 曾登录的标签页统一告知,避免界面仍显示已登录
|
||||
} else if (!result.banned && !result.transient) {
|
||||
// refresh 明确失败且非封禁:登录态已失效(被顶/被剔除/过期),
|
||||
// 曾登录的标签页统一告知,避免界面仍显示已登录。
|
||||
// 瞬态失败(网络/后端暂不可用)不动登录态,等后续请求自然恢复
|
||||
notifySessionEnded();
|
||||
}
|
||||
}
|
||||
@@ -1399,19 +1405,38 @@ export async function fetchNotifications(page = 1, cookieHeader?: string): Promi
|
||||
|
||||
// 获取当前登录用户(依赖 OptionalAuth,未登录返回 { user: null, unread_count: 0 })
|
||||
export async function apiMe(): Promise<MeResponse> {
|
||||
const res = await fetch("/api/me", {
|
||||
let res = await fetch("/api/me", {
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
});
|
||||
const data: MeResponse = await res.json();
|
||||
let data: MeResponse = await res.json();
|
||||
if (data.user) sessionSeen = true;
|
||||
// /me 以 200 携带封禁标记(Header 挂载静默校正/F5 后的主识别路径)
|
||||
if (data.banned === true && !bannedNotified) {
|
||||
bannedNotified = true;
|
||||
emitForceLogout("banned");
|
||||
} else if (!data.user && res.ok) {
|
||||
// 曾登录、现在明确是游客:登录态在别处被顶/被剔除(其他浏览器登录、
|
||||
// 设备剔除等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定
|
||||
if (sessionSeen && hasAuthCookieHint()) {
|
||||
// access cookie 到达 15min MaxAge 会被浏览器删除,/api/me 只能以游客
|
||||
// 应答——这不代表会话被剔除(refresh token 仍有效)。先 refresh 恢复
|
||||
// access 再复判;真被剔除时 refresh 也会明确失败,结论不变
|
||||
const refreshed = await apiRefresh();
|
||||
if (refreshed.ok) {
|
||||
res = await fetch("/api/me", { credentials: "include", cache: "no-store" });
|
||||
data = await res.json();
|
||||
if (data.user) sessionSeen = true;
|
||||
} else if (refreshed.transient) {
|
||||
// 网络/后端暂不可用:无法证明登录态失效,不下结论
|
||||
return data;
|
||||
}
|
||||
if (refreshed.banned && !bannedNotified) {
|
||||
bannedNotified = true;
|
||||
emitForceLogout("banned");
|
||||
return data;
|
||||
}
|
||||
}
|
||||
// 曾登录、经复判仍是游客:登录态在别处被剔除(设备剔除、
|
||||
// 会话超限淘汰等)。挂载校正/回前台对账/WS 4401 探测都汇聚到这条判定
|
||||
notifySessionEnded();
|
||||
}
|
||||
return data;
|
||||
@@ -2243,7 +2268,7 @@ export async function apiUploadAvatar(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
// 上传帖子插图(JPEG / PNG / WebP,≤5MB)
|
||||
// 上传帖子插图(JPEG / PNG / WebP / GIF,≤5MB;服务端自动转存 WebP,GIF 除外)
|
||||
export async function apiUploadPostImage(
|
||||
file: File | Blob,
|
||||
filename = "image.jpg"
|
||||
@@ -2949,6 +2974,7 @@ export type LegacyUserReport = {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
excluded: number;
|
||||
avatar_written: number;
|
||||
conflicts?: string[];
|
||||
avatar_missing?: string[];
|
||||
@@ -2965,9 +2991,13 @@ export type LegacyPostReport = {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
excluded: number;
|
||||
excluded_detail?: string[];
|
||||
polls_skipped: number;
|
||||
poll_titles?: string[];
|
||||
owner_fallback?: string[];
|
||||
images_written: number;
|
||||
images_missing?: string[];
|
||||
failed?: string[];
|
||||
};
|
||||
|
||||
@@ -2975,37 +3005,94 @@ export type LegacyCommentReport = {
|
||||
total: number;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
excluded: number;
|
||||
excluded_detail?: string[];
|
||||
skipped_detail?: string[];
|
||||
owner_fallback?: string[];
|
||||
images_written: number;
|
||||
images_missing?: string[];
|
||||
failed?: string[];
|
||||
};
|
||||
|
||||
// ===== 预检清单(dry-run 返回,供勾选导入范围) =====
|
||||
|
||||
export type LegacyUserPreview = {
|
||||
id: number;
|
||||
username: string;
|
||||
nickname: string;
|
||||
posts: number;
|
||||
comments: number;
|
||||
exists: boolean;
|
||||
has_avatar: boolean;
|
||||
};
|
||||
|
||||
export type LegacyPostPreview = {
|
||||
id: number;
|
||||
title: string;
|
||||
author: string;
|
||||
board: string;
|
||||
created_at: string;
|
||||
poll: boolean;
|
||||
published: boolean;
|
||||
};
|
||||
|
||||
export type LegacyCommentPreview = {
|
||||
id: number;
|
||||
post_id: number;
|
||||
post_title: string;
|
||||
author: string;
|
||||
excerpt: string;
|
||||
created_at: string;
|
||||
published: boolean;
|
||||
};
|
||||
|
||||
export type LegacyImportReport = {
|
||||
dry_run: boolean;
|
||||
users: LegacyUserReport;
|
||||
boards?: LegacyBoardReport;
|
||||
posts?: LegacyPostReport;
|
||||
comments?: LegacyCommentReport;
|
||||
user_list?: LegacyUserPreview[];
|
||||
post_list?: LegacyPostPreview[];
|
||||
comment_list?: LegacyCommentPreview[];
|
||||
notes?: string[];
|
||||
};
|
||||
|
||||
export type LegacyImportOptions = {
|
||||
withContent: boolean;
|
||||
dryRun: boolean;
|
||||
/** 不创建账号的旧用户 ID(内容仍按归属规则落位) */
|
||||
skipUsers?: number[];
|
||||
/** 内容归到站长的旧用户 ID */
|
||||
operatorUsers?: number[];
|
||||
/** 旧用户 ID → 指定已有账号用户名(内容归属) */
|
||||
userMap?: { oldId: number; username: string }[];
|
||||
/** 排除的旧帖 ID(其评论自动跳过) */
|
||||
skipPosts?: number[];
|
||||
/** 排除的旧评论 ID */
|
||||
skipComments?: number[];
|
||||
};
|
||||
|
||||
/** 上传旧站 SQLite 库(必填)与可选头像 zip,导入用户与内容。幂等可重复执行 */
|
||||
/** 上传旧站 SQLite 库(必填)、可选头像 / 帖子图片 zip 与勾选参数,导入用户与内容。幂等可重复执行 */
|
||||
export async function apiAdminImportLegacy(
|
||||
source: string,
|
||||
dbFile: File,
|
||||
zipFile: File | null,
|
||||
imagesZipFile: File | null,
|
||||
opts: LegacyImportOptions
|
||||
): Promise<LegacyImportReport> {
|
||||
const form = new FormData();
|
||||
form.append("db_file", dbFile, dbFile.name || "jiang13.db");
|
||||
if (zipFile) form.append("avatars_zip", zipFile, zipFile.name || "avatars.zip");
|
||||
if (imagesZipFile) form.append("images_zip", imagesZipFile, imagesZipFile.name || "images.zip");
|
||||
form.append("with_content", String(opts.withContent));
|
||||
form.append("dry_run", String(opts.dryRun));
|
||||
if (opts.skipUsers?.length) form.append("skip_users", opts.skipUsers.join(","));
|
||||
if (opts.operatorUsers?.length) form.append("operator_users", opts.operatorUsers.join(","));
|
||||
if (opts.userMap?.length)
|
||||
form.append("user_map", opts.userMap.map((m) => `${m.oldId}:${m.username}`).join(","));
|
||||
if (opts.skipPosts?.length) form.append("skip_posts", opts.skipPosts.join(","));
|
||||
if (opts.skipComments?.length) form.append("skip_comments", opts.skipComments.join(","));
|
||||
const res = await fetchWithRefresh(`/api/admin/import/${source}`, {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
@@ -4101,6 +4188,7 @@ export interface AdDurationOption {
|
||||
days: number;
|
||||
label: string;
|
||||
price_hint: string;
|
||||
price_hint_text: string;
|
||||
}
|
||||
|
||||
export interface AdPaymentOption {
|
||||
@@ -4293,6 +4381,35 @@ export async function fetchAdminSidebar(
|
||||
return res.json();
|
||||
}
|
||||
|
||||
/** 媒体库条目(管理后台全站图片盘点) */
|
||||
export interface MediaLibraryItem {
|
||||
url: string;
|
||||
original_url?: string; // 同名原图(仅当原图与 WebP 并存)
|
||||
category: string; // image/avatar/background/ads/brand
|
||||
category_name: string;
|
||||
name: string;
|
||||
mime: string;
|
||||
size: number;
|
||||
width: number;
|
||||
height: number;
|
||||
uploader?: string;
|
||||
uploaded_at?: string;
|
||||
source_url?: string; // 来源回链(帖子/评论锚点/用户主页)
|
||||
source_label?: string; // 来源描述(如:帖子《…》下的评论)
|
||||
}
|
||||
|
||||
/** SSR 管理端媒体库:全站图片盘点 */
|
||||
export async function fetchAdminMediaLibrary(
|
||||
cookie?: string
|
||||
): Promise<{ items: MediaLibraryItem[]; counts: Record<string, number>; total: number }> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/media-library`, {
|
||||
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("加载媒体库失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAdminGetSidebar(): Promise<{
|
||||
config: SidebarConfig;
|
||||
widgets: SidebarWidgetRecord[];
|
||||
|
||||
@@ -34,7 +34,7 @@ export function authCookieHeader(store: CookieReader): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」并校准 IP/UA。
|
||||
* 登录设备列表 SSR:必须同时转发 refresh cookie,才能识别「当前会话」。
|
||||
* 其它 SSR 接口不要用这个,避免把 refresh token 带到无关后端请求。
|
||||
*/
|
||||
export function sessionCookieHeader(store: CookieReader): string {
|
||||
|
||||
@@ -23,7 +23,7 @@ export const RT_CHAT_UNREAD = "chat:unread";
|
||||
export const RT_NOTIFICATION_NEW = "notification:new";
|
||||
export const RT_FEED_CHANGED = "feed:changed";
|
||||
export const RT_MODERATION_CHANGED = "moderation:changed";
|
||||
// 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备),推 user:{id}
|
||||
// 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰),推 user:{id}
|
||||
export const RT_SESSION_REPLACED = "session:replaced";
|
||||
|
||||
export interface RtEnvelope<T = unknown> {
|
||||
|
||||
@@ -33,7 +33,7 @@ export function onUserUpdate(handler: (patch: Partial<User>) => void): () => voi
|
||||
*
|
||||
* 触发源统一收敛在 lib/api.ts:任何客户端请求收到后端的
|
||||
* code=account_banned(中间件/refresh/登录)或 /me 的 banned 标记时派发;
|
||||
* 登录态被顶/被剔除(其他浏览器登录、安全设置剔除设备)经探测确认后
|
||||
* 登录态被剔除(安全设置剔除设备、会话数超上限被淘汰)经探测确认后
|
||||
* 以 reason=session_replaced 派发。Header 负责复用登出清理链路
|
||||
* (清 cookie、游客化、router.refresh),AccountGuard 负责弹出对应告知——
|
||||
* 状态清理与告知 UI 解耦,避免各请求调用方各自处理导致残留
|
||||
|
||||
Reference in New Issue
Block a user