feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等

新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
This commit is contained in:
2026-10-01 03:06:05 +08:00
parent 7fbd6ba699
commit ff2ab286fb
141 changed files with 7527 additions and 1407 deletions

View File

@@ -9,7 +9,7 @@ import {
} from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { pageGlyph } from "@/lib/pageGlyph";
import {
@@ -94,7 +94,7 @@ export default async function AboutIndexPage() {
const announcements = overview?.announcements ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
const left = <SiteDocIdentity kind="about" lead={siteLead} />;
const right = <SiteDocAnnoNav items={announcements} />;

View File

@@ -46,6 +46,7 @@ import Modal from "@/components/Modal";
import { AdText } from "@/components/AdText";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
type SectionId = "overview" | "queue" | "promote" | "sponsors";
@@ -882,8 +883,14 @@ function AdCard({
{/* 预览缩略 */}
<div className="j13-ads-thumb shrink-0" aria-hidden>
{ad.kind === "image" && ad.image_url ? (
// 88×44 缩略位:本站图走 96/160 密度变体;外链图保持原地址
// eslint-disable-next-line @next/next/no-img-element
<img src={ad.image_url} alt="" className="h-full w-full object-contain" />
<img
src={isOwnImage(ad.image_url) ? variantURL(ad.image_url, pickVariantWidth(88)) : ad.image_url}
srcSet={isOwnImage(ad.image_url) ? densitySrcSet(ad.image_url, 88) : undefined}
alt=""
className="h-full w-full object-contain"
/>
) : ad.kind === "text" && ad.title ? (
<div className="flex h-full w-full items-center justify-center p-1.5">
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
@@ -990,8 +997,14 @@ function PaymentEditor({
{/* 二维码:点击即上传 */}
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
{payment.qr_url ? (
// 96×96 收款码位:本站图走 96/160 变体
// eslint-disable-next-line @next/next/no-img-element
<img src={payment.qr_url} alt="" className="h-full w-full object-contain" />
<img
src={isOwnImage(payment.qr_url) ? variantURL(payment.qr_url, pickVariantWidth(96)) : payment.qr_url}
srcSet={isOwnImage(payment.qr_url) ? densitySrcSet(payment.qr_url, 96) : undefined}
alt=""
className="h-full w-full object-contain"
/>
) : (
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
<Upload size={16} />
@@ -1084,8 +1097,14 @@ function SponsorEditor({
{/* Logo:点击即上传 */}
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
{item.logo_url ? (
// 120×56 Logo 位:本站图走 160/256 变体
// eslint-disable-next-line @next/next/no-img-element
<img src={item.logo_url} alt="" className="h-full w-full rounded-lg object-contain" />
<img
src={isOwnImage(item.logo_url) ? variantURL(item.logo_url, pickVariantWidth(120)) : item.logo_url}
srcSet={isOwnImage(item.logo_url) ? densitySrcSet(item.logo_url, 120) : undefined}
alt=""
className="h-full w-full rounded-lg object-contain"
/>
) : (
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
<Upload size={15} />

View File

@@ -12,7 +12,7 @@ import {
type SponsorsConfig,
} from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -22,7 +22,7 @@ export const metadata: Metadata = {
export default async function AdminAdsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ADS)) {
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../../Forbidden";
import AnnouncementEditLoader from "./AnnouncementEditLoader";
@@ -19,7 +19,7 @@ export default async function AdminAnnouncementEditPage({ params }: PageProps) {
const { id } = await params;
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../Forbidden";
import AnnouncementComposeClient from "../AnnouncementComposeClient";
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
export default async function AdminAnnouncementNewPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,18 +5,18 @@ import AnnouncementAdmin from "./AnnouncementAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminAnnouncements, type Announcement } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
title: "站点公告管理",
};
// 公告管理仅管理员及以上可用(板块管理员无公告权;后端路由另有守卫)
// 公告管理:管理员及以上角色自带;超管/站长亦可授予任意账号
export default async function AdminAnnouncementsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.ANNOUNCEMENTS)) {
return <Forbidden text="公告管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
const SURFACE_LABEL: Record<BgSurface, string> = {
site: "前台背景",
@@ -101,7 +102,9 @@ export default function BgMediaPicker({
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.url}
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt={`${SURFACE_LABEL[surface]}候选图`}
className="w-full h-full object-cover"
loading="lazy"

View File

@@ -42,7 +42,7 @@ import {
} from "@/lib/api";
import { canModerateAuthor } from "@/lib/roles";
import { formatRelative } from "@/lib/format";
import { postPath } from "@/lib/urlStyle";
import { postPath, commentAnchor } from "@/lib/urlStyle";
import { toast } from "@/lib/toast";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
import Avatar from "@/components/Avatar";
@@ -692,7 +692,7 @@ export default function ContentAdmin({
c.post_title
) : (
<Link
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
className="hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -22,6 +22,7 @@ import {
type FriendLinkStatus,
} from "@/lib/api";
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
initialLinks: FriendLink[];
@@ -237,9 +238,11 @@ export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
style={{ borderTop: "1px solid var(--line)" }}
>
{l.logo_url ? (
// 36px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(36)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 36) : undefined}
alt=""
width={36}
height={36}

View File

@@ -5,7 +5,7 @@ import { redirect } from "next/navigation";
import { ShieldAlert } from "lucide-react";
import { authCookieHeader, REFRESH_COOKIE, CSRF_COOKIE, TOKEN_COOKIE } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
import AdminShell from "@/components/admin/AdminShell";
import AdminSessionRecover from "./AdminSessionRecover";
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
};
// 权限以 Go 端为唯一判定;此处仅决定管理界面是否直出。
// 允许:管理角色,或仅有消息管理/群管权限的用户(仅消息菜单可见)。
// 允许:管理角色 / 消息管理/群管权限 / 任一渠道权限(公告/单页/书库/广告)。
export default async function AdminLayout({ children }: { children: React.ReactNode }) {
const cookieStore = await cookies();
const cookie = authCookieHeader(cookieStore);
@@ -24,7 +24,8 @@ export default async function AdminLayout({ children }: { children: React.ReactN
const theme = cookieStore.get("j13-theme")?.value === "dark" ? "dark" : "light";
const allowed =
!!me.user && (isStaff(me.user.role) || canAccessAdminMessages(me.user));
!!me.user &&
(isStaff(me.user.role) || canAccessAdminMessages(me.user) || hasAnyChannelPerm(me.user));
if (!allowed || !me.user) {
// 已登录但无后台权限:保留提示卡片

View File

@@ -1,6 +1,6 @@
"use client";
import { useMemo, useRef, useState } from "react";
import { useMemo, useRef, useState, type ChangeEvent } from "react";
import {
BookOpen,
Download,
@@ -17,6 +17,7 @@ import {
apiAdminCreateLibraryDoc,
apiAdminDeleteLibraryDoc,
apiAdminDeleteLibraryFile,
apiAdminImportLibraryZip,
apiAdminListLibraryDocs,
apiAdminPurgeLibraryDoc,
apiAdminUpdateLibraryDoc,
@@ -25,17 +26,20 @@ import {
apiUploadPostImage,
type LibraryDoc,
type LibraryFileItem,
type LibraryImportReport,
type LibraryInput,
} from "@/lib/api";
import { formatDateTime, formatRelative } from "@/lib/format";
import { libraryDocPath } from "@/lib/urlStyle";
import { libraryDocPath, userPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { toast } from "@/lib/toast";
import ConfirmDialog from "@/components/ConfirmDialog";
import Avatar from "@/components/Avatar";
import MarkdownEditor from "@/components/MarkdownEditor";
import { AdminEmpty, AdminField, AdminPageHeader, AdminStatusChip, AdminSwitch } from "@/components/admin";
import LibrarySectionsPanel from "./LibrarySectionsPanel";
import LibraryCoverPicker from "./LibraryCoverPicker";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
// 与后端 libraryAllowedExts 保持一致;前端先拦一道,后端仍会校验
const ALLOWED_EXTS = new Set([
@@ -59,9 +63,12 @@ function formatSize(n: number): string {
interface DocForm {
title: string;
author: string;
slug: string;
description: string;
cover_url: string;
cover_width: number;
cover_height: number;
published: boolean;
sort_order: number;
entries_auto: boolean;
@@ -69,9 +76,12 @@ interface DocForm {
const EMPTY_FORM: DocForm = {
title: "",
author: "",
slug: "",
description: "",
cover_url: "",
cover_width: 0,
cover_height: 0,
published: false,
sort_order: 0,
entries_auto: false,
@@ -80,9 +90,12 @@ const EMPTY_FORM: DocForm = {
function formOfDoc(d: LibraryDoc): DocForm {
return {
title: d.title,
author: d.author ?? "",
slug: d.slug,
description: d.description ?? "",
cover_url: d.cover_url ?? "",
cover_width: d.cover_width ?? 0,
cover_height: d.cover_height ?? 0,
published: d.published,
sort_order: d.sort_order,
entries_auto: d.entries_auto ?? false,
@@ -145,6 +158,13 @@ export default function LibraryAdmin({
const [fileDeleteTarget, setFileDeleteTarget] = useState<LibraryFileItem | null>(null);
const [fileDeleteBusy, setFileDeleteBusy] = useState(false);
// 书库导入恢复(上传导出 ZIP)
const importInputRef = useRef<HTMLInputElement>(null);
const [importing, setImporting] = useState(false);
const [importReport, setImportReport] = useState<LibraryImportReport | null>(null);
// create 模式遇到 conflict 时保留 File,供确认后 overwrite 重传
const [importOverwrite, setImportOverwrite] = useState<{ file: File; count: number } | null>(null);
// 条目删除(软删)与彻底删除
const [docDeleteTarget, setDocDeleteTarget] = useState<LibraryDoc | null>(null);
const [docDeleteBusy, setDocDeleteBusy] = useState(false);
@@ -170,6 +190,34 @@ export default function LibraryAdmin({
}
}
async function runImport(file: File, mode: "create" | "overwrite") {
setImporting(true);
setImportReport(null);
try {
const rep = await apiAdminImportLibraryZip(file, mode);
setImportReport(rep);
const okN = rep.imported.length;
const failN = rep.failed.length;
if (okN > 0) toast(`成功导入 ${okN} 本书${failN ? `,${failN} 本未导入` : ""}`, "ok");
else toast("没有书籍被导入,请查看失败明细", "error");
await reload();
if (mode === "create") {
const conflicts = rep.failed.filter((f) => f.reason === "conflict").length;
if (conflicts > 0) setImportOverwrite({ file, count: conflicts });
}
} catch (e) {
toast(e instanceof Error ? e.message : "导入失败");
} finally {
setImporting(false);
}
}
function onPickImportFile(e: ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0];
e.target.value = ""; // 允许重选同一文件
if (file) void runImport(file, "create");
}
function openNew() {
setForm(EMPTY_FORM);
setSnapshot(JSON.stringify(EMPTY_FORM));
@@ -221,8 +269,11 @@ export default function LibraryAdmin({
const input: LibraryInput = {
slug,
title,
author: form.author.trim(),
description: form.description,
cover_url: form.cover_url.trim(),
cover_width: form.cover_width,
cover_height: form.cover_height,
published: form.published,
sort_order: form.sort_order,
entries_auto: form.entries_auto,
@@ -250,9 +301,13 @@ export default function LibraryAdmin({
if (!file) return;
setCoverBusy(true);
try {
const res = await apiUploadPostImage(file);
const res = await apiUploadPostImage(file, undefined, "library_cover");
if (res.url) {
patchForm({ cover_url: res.url });
patchForm({
cover_url: res.url,
cover_width: res.attachment?.width ?? 0,
cover_height: res.attachment?.height ?? 0,
});
} else {
setFormError(res.error || "封面上传失败");
}
@@ -267,8 +322,8 @@ export default function LibraryAdmin({
async function pickCoverFromMedia(attachmentId: number) {
setCoverBusy(true);
try {
const url = await apiLibraryCoverFromMedia(attachmentId);
patchForm({ cover_url: url });
const { url, width, height } = await apiLibraryCoverFromMedia(attachmentId);
patchForm({ cover_url: url, cover_width: width, cover_height: height });
} catch (e) {
setFormError(e instanceof Error ? e.message : "选用图库图片失败");
} finally {
@@ -374,17 +429,103 @@ export default function LibraryAdmin({
title="书库管理"
description="书籍、文档与页面作品。书籍由介绍、目录与章节正文组成,像书一样阅读;附件仅提供下载分发。"
actions={
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
<>
<a
href="/api/admin/library/export"
className="btn btn-ghost"
title="把全部书籍打包为 ZIP(介绍、章节、附件与封面),用于迁移或备份"
>
<Download size={15} aria-hidden />
导出全部
</a>
<button
type="button"
className="btn btn-ghost"
onClick={() => importInputRef.current?.click()}
disabled={importing}
title="上传导出的 ZIP 备份包,恢复书籍(介绍、章节、附件与封面)"
>
{importing ? (
<Loader2 size={15} className="animate-spin" aria-hidden />
) : (
<Upload size={15} aria-hidden />
)}
{importing ? "导入中…" : "导入恢复"}
</button>
<input
ref={importInputRef}
type="file"
accept=".zip,application/zip"
className="hidden"
onChange={onPickImportFile}
/>
<button
type="button"
className="btn btn-primary"
onClick={() => guardNav(openNew)}
disabled={saving}
>
<Plus size={15} /> 新建条目
</button>
</>
}
/>
{importReport ? (
<div
className="panel mx-3.5 sm:mx-4 my-3 px-4 py-3 text-[13px]"
role="status"
style={{ borderColor: "var(--line)" }}
>
<div className="flex items-center gap-2">
<strong style={{ color: "var(--ink)" }}>
导入完成:成功 {importReport.imported.length} 本
(新建 {importReport.imported.filter((i) => i.action === "created").length} / 覆盖{" "}
{importReport.imported.filter((i) => i.action === "overwritten").length}),失败{" "}
{importReport.failed.length} 本
</strong>
<button
type="button"
className="ml-auto btn btn-ghost"
style={{ padding: "2px 6px" }}
onClick={() => setImportReport(null)}
aria-label="关闭导入结果"
>
<X size={14} aria-hidden />
</button>
</div>
{importReport.failed.length > 0 ? (
<ul className="mt-2 flex flex-col gap-1">
{importReport.failed.map((f, i) => (
<li key={`${f.slug}-${i}`} className="meta flex flex-wrap items-baseline gap-x-2">
<span style={{ color: "var(--ink-2)" }}>
{f.title || f.slug}
{f.slug ? `(${f.slug})` : ""}
</span>
<span
style={{
color:
f.reason === "conflict" || f.reason === "deleted"
? "var(--warn, #b45309)"
: "var(--danger, #b91c1c)",
}}
>
{f.reason === "conflict"
? "同名书籍已存在,可覆盖导入"
: f.reason === "deleted"
? "地址被已删除条目占用,需先彻底删除"
: f.reason === "invalid"
? "备份数据不受支持"
: "导入失败"}
</span>
<span>{f.error}</span>
</li>
))}
</ul>
) : null}
</div>
) : null}
<div
className="admin-library-split"
data-view={editorOpen ? "editor" : "list"}
@@ -435,9 +576,11 @@ export default function LibraryAdmin({
}}
>
{d.cover_url ? (
// 36px 封面位:本站图走 48/96 变体
// eslint-disable-next-line @next/next/no-img-element
<img
src={d.cover_url}
src={isOwnImage(d.cover_url) ? variantURL(d.cover_url, pickVariantWidth(36)) : d.cover_url}
srcSet={isOwnImage(d.cover_url) ? densitySrcSet(d.cover_url, 36) : undefined}
alt=""
className="w-9 h-9 rounded-lg object-cover shrink-0"
style={{ border: "1px solid var(--line)" }}
@@ -560,6 +703,16 @@ export default function LibraryAdmin({
? `${editingDoc.sections?.length ?? 0} 章节 · ${editingDoc.files?.length ?? 0} 附件`
: ""}
</span>
{typeof editingId === "number" ? (
<a
href={`/api/admin/library/docs/${editingId}/export`}
className="btn btn-line btn-sm shrink-0"
title="导出本书为 ZIP(介绍、章节、附件与本地封面),用于迁移或备份"
>
<Download size={13} aria-hidden />
导出备份
</a>
) : null}
</div>
{/* 书籍信息 tab(两个 tab 均保持挂载,避免章节编辑状态丢失)。
@@ -586,6 +739,15 @@ export default function LibraryAdmin({
className="admin-doc-title"
/>
<AdminField
id="lib-author"
label="作者"
maxLength={100}
value={form.author}
onChange={(author) => patchForm({ author })}
placeholder="作者或编著者,多位可用逗号分隔(可选)"
/>
<div className="admin-doc-field">
<label className="admin-doc-label" htmlFor="lib-slug">
访问地址
@@ -628,6 +790,7 @@ export default function LibraryAdmin({
placeholder="条目介绍,支持 Markdown…"
rows={12}
ariaLabel="条目介绍"
uploadSource="library_content"
/>
</div>
</div>
@@ -636,56 +799,58 @@ export default function LibraryAdmin({
<aside className="flex flex-col gap-4 lg:min-h-0">
<div className="admin-doc-field">
<p className="admin-doc-label">封面(可选)</p>
<div className="flex items-center gap-2.5">
<div className="flex flex-col gap-2">
{form.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.cover_url}
src={isOwnImage(form.cover_url) ? variantURL(form.cover_url, 256) : form.cover_url}
alt="封面预览"
className="w-12 h-12 rounded-lg object-cover shrink-0"
className="w-full h-32 rounded-xl object-cover"
style={{ border: "1px solid var(--line)" }}
/>
) : (
<button
type="button"
className="w-12 h-12 rounded-lg flex items-center justify-center shrink-0"
className="w-full h-32 rounded-xl flex flex-col items-center justify-center gap-1.5"
style={{ border: "1px dashed var(--control-border)" }}
aria-label="上传封面"
onClick={() => coverInputRef.current?.click()}
>
<ImagePlus size={16} style={{ color: "var(--ink-3)" }} aria-hidden />
<ImagePlus size={20} style={{ color: "var(--ink-3)" }} aria-hidden />
<span className="text-[11.5px]" style={{ color: "var(--ink-3)" }}>
点击上传封面
</span>
</button>
)}
<div className="flex flex-col gap-1">
<div className="flex gap-1.5">
<button
type="button"
className="btn btn-line btn-sm flex-1"
disabled={coverBusy}
onClick={() => coverInputRef.current?.click()}
>
{coverBusy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<ImagePlus size={13} aria-hidden />
)}
{form.cover_url ? "更换" : "上传"}
</button>
<button
type="button"
className="btn btn-line btn-sm flex-1"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
</div>
<div className="flex items-center gap-1.5">
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => coverInputRef.current?.click()}
>
{coverBusy ? (
<Loader2 size={13} className="animate-spin" aria-hidden />
) : (
<ImagePlus size={13} aria-hidden />
)}
{form.cover_url ? "更换" : "上传"}
</button>
<button
type="button"
className="btn btn-line btn-sm"
disabled={coverBusy}
onClick={() => setCoverPickerOpen(true)}
>
<Images size={13} aria-hidden />
图库
</button>
{form.cover_url ? (
<button
type="button"
className="admin-doc-textbtn"
onClick={() => patchForm({ cover_url: "" })}
className="admin-doc-textbtn ml-auto"
style={{ minHeight: 0, minWidth: 0, padding: "1px 6px", fontSize: 12 }}
onClick={() => patchForm({ cover_url: "", cover_width: 0, cover_height: 0 })}
>
移除
</button>
@@ -749,6 +914,30 @@ export default function LibraryAdmin({
<p className="admin-doc-hint">数字越小越靠前。</p>
</div>
{typeof editingId === "number" && editingDoc?.creator ? (
<div className="admin-doc-field">
<p className="admin-doc-label">创建者</p>
<a
href={userPath(urlStyle, editingDoc.creator.id)}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 text-[13px] font-medium max-w-full"
style={{ color: "var(--ink)" }}
title={`@${editingDoc.creator.username}`}
>
<Avatar
size={22}
name={editingDoc.creator.nickname || editingDoc.creator.username}
src={editingDoc.creator.avatar}
/>
<span className="truncate">
{editingDoc.creator.nickname || editingDoc.creator.username}
</span>
</a>
<p className="admin-doc-hint">条目创建后归属不变。</p>
</div>
) : null}
{/* 附件管理:长列表列内滚动 */}
<div className="admin-doc-field flex flex-col lg:flex-1 lg:min-h-0">
<p className="admin-doc-label shrink-0">
@@ -956,6 +1145,27 @@ export default function LibraryAdmin({
</section>
</div>
{/* 导入遇到同名书籍:确认后覆盖重传(保留同一 File) */}
<ConfirmDialog
open={!!importOverwrite}
title="覆盖导入同名书籍?"
message={
importOverwrite
? `备份包中有 ${importOverwrite.count} 本书的地址已被现有书籍占用。选择覆盖将用备份包整体替换这些书籍的介绍、章节、附件与封面,现有内容不可恢复;其余书籍正常新建。`
: ""
}
danger
busy={importing}
confirmLabel="覆盖导入"
cancelLabel="保留现有"
onCancel={() => !importing && setImportOverwrite(null)}
onConfirm={() => {
const pending = importOverwrite;
setImportOverwrite(null);
if (pending) void runImport(pending.file, "overwrite");
}}
/>
{/* 未保存关闭/切换确认 */}
<ConfirmDialog
open={!!pendingNav}

View File

@@ -4,8 +4,35 @@ import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
/** 书籍封面图库选择弹窗:列出本人媒体库图片,选中后由父组件复制一份作封面素材 */
/** 来源标签映射 */
const SOURCE_LABELS: Record<string, string> = {
library_cover: "书籍封面",
library_content: "书籍内容",
post: "帖子插图",
};
function sourceLabel(source?: string): string {
return SOURCE_LABELS[source || "post"] || source || "帖子插图";
}
/** 按来源分组(library_cover 优先,其次 library_content,最后 post/其他) */
function groupBySource(items: MediaAttachment[]): [string, MediaAttachment[]][] {
const groups = new Map<string, MediaAttachment[]>();
for (const item of items) {
const key = item.source || "post";
if (!groups.has(key)) groups.set(key, []);
groups.get(key)!.push(item);
}
// 按优先级排序
const order = ["library_cover", "library_content", "post"];
return Array.from(groups.entries()).sort(
([a], [b]) => order.indexOf(a) - order.indexOf(b)
);
}
/** 书籍封面图库选择弹窗:列出本人媒体库图片,按来源分组 */
export default function LibraryCoverPicker({
open,
onClose,
@@ -45,13 +72,15 @@ export default function LibraryCoverPicker({
};
}, [open]);
const groups = groupBySource(items);
return (
<Modal
open={open}
title="从图库选择封面"
description="选择后会复制一份作为封面素材,之后删除原图不影响封面。"
description="已选作封面的图片会单独分组,再次选择直接复用。"
onClose={onClose}
maxWidthClass="max-w-lg"
maxWidthClass="max-w-2xl"
dismissOnOverlay
>
{loading ? (
@@ -67,28 +96,52 @@ export default function LibraryCoverPicker({
图库还没有图片。上传封面或发帖插图后,会自动出现在这里。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2 max-h-[46vh] overflow-y-auto">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="复制一份用作封面"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="图库候选封面" className="w-full h-full object-contain" loading="lazy" />
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
<div className="flex flex-col gap-5 max-h-[60vh] overflow-y-auto">
{groups.map(([source, groupItems]) => (
<section key={source}>
<h4
className="text-[11.5px] font-bold tracking-wide uppercase mb-2 px-0.5"
style={{ color: "var(--ink-3)" }}
>
<Images size={14} />
</span>
</button>
{sourceLabel(source)}
<span className="ml-1.5 font-normal opacity-70">({groupItems.length})</span>
</h4>
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{groupItems.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip={
item.source === "library_cover"
? "已作封面,点击直接复用"
: "复制一份用作封面"
}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt="图库候选封面"
className="w-full h-full object-contain"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
</section>
))}
</div>
)}

View File

@@ -410,6 +410,7 @@ export default function LibrarySectionsPanel({
placeholder="本章正文,支持 Markdown…"
rows={16}
ariaLabel="章节正文"
uploadSource="library_content"
/>
</div>
</div>

View File

@@ -5,7 +5,7 @@ import LibraryAdmin from "./LibraryAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminLibraryDocs, type LibraryDoc } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
export default async function AdminLibraryPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.LIBRARY)) {
return <Forbidden text="书库管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,6 +5,7 @@ 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";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
function formatBytes(n: number): string {
if (n < 1024) return `${n} B`;
@@ -24,10 +25,11 @@ function formatDims(item: MediaLibraryItem): string {
return "—";
}
/** 网格缩略图:本地图走服务端缩略接口(磁盘缓存),远程对象直连原图(头像类本身小) */
/** 网格缩略图:本站图走统一变体端点(磁盘缓存,256 档);非本站图原样返回 */
function thumbSrc(item: MediaLibraryItem): string {
if (item.url.startsWith("/uploads/")) {
return `/api/admin/media-library/thumb?u=${encodeURIComponent(item.url)}`;
// 网格缩略走统一变体端点(256 档);非本站图原样返回
if (isOwnImage(item.url)) {
return variantURL(item.url, 256);
}
return item.url;
}
@@ -143,6 +145,8 @@ export default function MediaLibrary({
<span className="block relative aspect-square overflow-hidden">
<img
src={thumbSrc(item)}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [160, 256, 384, 512]) : undefined}
sizes="(min-width:1280px) 16vw, (min-width:1024px) 25vw, (min-width:640px) 33vw, 50vw"
alt={item.name}
loading="lazy"
className="absolute inset-0 w-full h-full object-cover"

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../../Forbidden";
import PageEditLoader from "./PageEditLoader";
@@ -19,7 +19,7 @@ export default async function AdminPageEditPage({ params }: PageProps) {
const { id } = await params;
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}

View File

@@ -3,7 +3,7 @@ import { Suspense } from "react";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../../Forbidden";
import PageComposeClient from "../PageComposeClient";
@@ -14,7 +14,7 @@ export const metadata: Metadata = {
export default async function AdminPageNewPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,7 +5,7 @@ import PagesAdmin from "./PagesAdmin";
import { authCookieHeader } from "@/lib/cookies";
import { fetchAdminSitePages, type SitePage } from "@/lib/api";
import { getMeCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Forbidden from "../Forbidden";
export const metadata: Metadata = {
@@ -15,7 +15,7 @@ export const metadata: Metadata = {
export default async function AdminPagesPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isAdminOrAbove(me.user.role)) {
if (!me.user || !hasPerm(me.user, PERMS.PAGES)) {
return <Forbidden text="单页管理仅管理员及以上角色可操作。" />;
}

View File

@@ -5,6 +5,7 @@ import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { mediaURLFitsSlot, type BrandSlot } from "@/lib/brand";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
const SLOT_LABEL: Record<BrandSlot, string> = {
logo_light: "浅色 Logo",
@@ -88,7 +89,14 @@ export default function BrandMediaPicker({
data-tip="用作草稿预览,点保存后才生效"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt={`${SLOT_LABEL[slot]}候选图`} className="w-full h-full object-contain" loading="lazy" />
<img
src={isOwnImage(item.url) ? variantURL(item.url, 160) : item.url}
srcSet={isOwnImage(item.url) ? widthSrcSet(item.url, [96, 160, 256, 384]) : undefined}
sizes="(max-width:520px) 23vw, 96px"
alt={`${SLOT_LABEL[slot]}候选图`}
className="w-full h-full object-contain"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}

View File

@@ -21,6 +21,7 @@ import {
type BrandSlot,
} from "@/lib/brand";
import { toast } from "@/lib/toast";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
import BrandMediaPicker from "./BrandMediaPicker";
type SlotDraft = {
@@ -233,8 +234,13 @@ export default function BrandSeoPanel({
<div key={slot} className={`admin-brand-asset${compact ? " is-compact" : ""}`}>
<div className={`admin-brand-swatch ${swatchClass}`}>
{preview ? (
// 预览槽(≤56px 高,宽标可能更宽):本站图走 160/384 密度变体
// eslint-disable-next-line @next/next/no-img-element
<img src={preview} alt="" />
<img
src={isOwnImage(preview) ? variantURL(preview, pickVariantWidth(160)) : preview}
srcSet={isOwnImage(preview) ? densitySrcSet(preview, 160) : undefined}
alt=""
/>
) : (
<span>未设置</span>
)}

View File

@@ -2,6 +2,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import {
AlertTriangle,
ArrowDown,
ArrowLeft,
ArrowRight,
@@ -64,6 +65,7 @@ import Modal from "@/components/Modal";
import useUnsavedGuard from "@/hooks/useUnsavedGuard";
import { toast } from "@/lib/toast";
import { mergeWidgetCode, parseMixedWidgetCode } from "@/lib/widgetCode";
import { lintWidgetCode, type WidgetLintIssue } from "@/lib/widgetLint";
type Side = "left" | "right";
type SectionId = SidebarScope | "widgets";
@@ -102,6 +104,9 @@ const SIDES: { scope: SidebarScope; icon: LucideIcon; desc: string; sides: Side[
const SIDE_LABEL: Record<Side, string> = { left: "左栏", right: "右栏" };
const WIDGET_CODE_MAX_BYTES = 64 * 1024;
// 提交给后端的工具草稿(混写框拆分后的三字段 + 名称)
type WidgetDraftCode = { name: string; html: string; css: string; js: string };
export default function SidebarAdmin({
initialConfig,
initialWidgets,
@@ -125,6 +130,10 @@ export default function SidebarAdmin({
const [draftName, setDraftName] = useState("");
const [mixed, setMixed] = useState("");
const [deleteId, setDeleteId] = useState<number | null>(null);
// 保存时静态扫描结果(error 禁止保存并列在此处;warn 弹二次确认框)
const [lintIssues, setLintIssues] = useState<WidgetLintIssue[] | null>(null);
const [warnConfirmOpen, setWarnConfirmOpen] = useState(false);
const pendingCodeRef = useRef<WidgetDraftCode | null>(null);
const draftBytes = useMemo(() => new TextEncoder().encode(mixed).length, [mixed]);
const draftOk = draftName.trim().length > 0 && draftBytes <= WIDGET_CODE_MAX_BYTES;
@@ -196,6 +205,9 @@ export default function SidebarAdmin({
setEditingId(null);
setDraftName("");
setMixed("");
setLintIssues(null);
setWarnConfirmOpen(false);
pendingCodeRef.current = null;
setEditorOpen(true);
};
@@ -203,14 +215,15 @@ export default function SidebarAdmin({
setEditingId(w.id);
setDraftName(w.name);
setMixed(mergeWidgetCode({ html: w.html, css: w.css, js: w.js }));
setLintIssues(null);
setWarnConfirmOpen(false);
pendingCodeRef.current = null;
setEditorOpen(true);
};
const submitWidget = async () => {
if (!draftOk) return;
const doSubmitWidget = async (code: WidgetDraftCode) => {
setBusy(true);
try {
const code = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
if (editingId == null) {
await apiAdminCreateSidebarWidget(code);
toast("工具已创建", "ok");
@@ -220,6 +233,9 @@ export default function SidebarAdmin({
toast("工具已保存", "ok");
}
setEditorOpen(false);
setLintIssues(null);
setWarnConfirmOpen(false);
pendingCodeRef.current = null;
await reloadAll();
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
@@ -228,6 +244,34 @@ export default function SidebarAdmin({
}
};
// 保存按钮:先静态扫描——有 error 禁止保存并列出;只有 warn 弹确认框;无问题直接保存
const submitWidget = () => {
if (!draftOk || busy) return;
const code: WidgetDraftCode = { name: draftName.trim(), ...parseMixedWidgetCode(mixed) };
const issues = lintWidgetCode(code);
setLintIssues(issues.length ? issues : null);
if (issues.some((i) => i.level === "error")) return;
const warns = issues.filter((i) => i.level === "warn");
if (warns.length > 0) {
pendingCodeRef.current = code;
setWarnConfirmOpen(true);
return;
}
void doSubmitWidget(code);
};
const confirmWarnSubmit = () => {
const code = pendingCodeRef.current;
pendingCodeRef.current = null;
setWarnConfirmOpen(false);
if (code) void doSubmitWidget(code);
};
const cancelWarnSubmit = () => {
pendingCodeRef.current = null;
setWarnConfirmOpen(false);
};
const toggleWidget = async (w: SidebarWidgetRecord) => {
setBusy(true);
try {
@@ -402,7 +446,7 @@ export default function SidebarAdmin({
<SectionHead
icon={<Code2 size={16} />}
title="自定义工具"
desc="HTML + CSS + JS 代码块,前台原样渲染执行(不做消毒)"
desc="HTML + CSS + JS 代码块,在独立 Shadow DOM 内渲染执行(不做消毒)"
extra={<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}><Plus size={14} /> 新增工具</button>}
/>
<div className="p-4 sm:p-5">
@@ -487,7 +531,7 @@ export default function SidebarAdmin({
footer={
<>
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={() => setEditorOpen(false)}>取消</button>
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => void submitWidget()}>
<button type="button" className="btn btn-primary btn-sm" disabled={busy || !draftOk} onClick={() => submitWidget()}>
{busy ? "保存中…" : editingId == null ? "创建" : "保存"}
</button>
</>
@@ -506,13 +550,20 @@ export default function SidebarAdmin({
<textarea
className="field w-full min-h-[240px] font-mono text-[12.5px]"
value={mixed}
onChange={(e) => setMixed(e.target.value)}
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 页面加载后在工具位置执行\n</script>`}
onChange={(e) => {
setMixed(e.target.value);
setLintIssues(null);
}}
placeholder={`<div class="my-banner">节日快乐!</div>\n\n<style>\n .my-banner { border-radius: 12px; }\n</style>\n\n<script>\n // 脚本参数:(root, shadow, toolId, registerCleanup)\n // 元素查找一律从 root 出发:root.querySelector(".my-banner")\n</script>`}
/>
</label>
{lintIssues && lintIssues.length > 0 && <WidgetLintPanel issues={lintIssues} />}
<details className="rounded-xl border px-3 py-2" style={{ borderColor: "var(--line)" }}>
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:window.J13 / 主题变量</summary>
<summary className="meta cursor-pointer select-none text-[12px]">开放能力速查:脚本参数 / window.J13 / 主题变量</summary>
<div className="mt-2 space-y-1.5 text-[12px] leading-relaxed" style={{ color: "var(--ink-2)" }}>
<p>脚本接收参数 <code>(root, shadow, toolId, registerCleanup)</code>:元素查找一律 <code>root.querySelector(...)</code>,禁止 <code>document</code> 全局查找(同页多个同款工具会互相取错)</p>
<p>定时器 / 监听器请通过 <code>registerCleanup(fn)</code> 注册清理,离开页面时自动执行(如 <code>clearInterval</code>)</p>
<p>样式仅在工具内生效,直接写类名即可;禁止 <code>body</code> / <code>html</code> / <code>*</code> 全局选择器与 <code>position: fixed</code>,变量请定义在 <code>.tool-root</code> 上</p>
<p><code>J13.user</code>:当前登录用户(未登录为 null)</p>
<p><code>J13.theme.get()</code> 返回 &quot;light&quot; | &quot;dark&quot;;<code> J13.theme.onChange(fn)</code> 监听明暗切换</p>
<p><code>J13.getOverview()</code>:站点公开统计(Promise,users / posts / online 等)</p>
@@ -520,11 +571,22 @@ export default function SidebarAdmin({
</div>
</details>
<p className="meta text-[11.5px]">
CSS 自动限定在本板块内、不污染其他区域;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
工具运行在独立 Shadow DOM 内,样式不外泄;前台原样渲染执行(不做消毒),自行把控安全;外部脚本库需内联引入。
</p>
</div>
</Modal>
{/* 仅 warn 级问题时的二次确认 */}
<ConfirmDialog
open={warnConfirmOpen}
title="代码存在风险提醒"
message={`检测到 ${lintIssues?.filter((i) => i.level === "warn").length ?? 0} 条提醒(不阻止保存,明细见编辑器列表)。确认了解风险并保存?`}
confirmLabel="仍要保存"
busy={busy}
onConfirm={confirmWarnSubmit}
onCancel={cancelWarnSubmit}
/>
{/* 删除工具确认 */}
<ConfirmDialog
open={deleteId != null}
@@ -550,6 +612,75 @@ export default function SidebarAdmin({
);
}
/* ─── 工具代码静态扫描结果面板 ─── */
const LINT_FIELD_LABEL: Record<WidgetLintIssue["field"], string> = {
js: "JS",
css: "CSS",
html: "HTML",
};
function WidgetLintPanel({ issues }: { issues: WidgetLintIssue[] }) {
const errors = issues.filter((i) => i.level === "error");
const warns = issues.filter((i) => i.level === "warn");
return (
<div className="space-y-2">
{errors.length > 0 && (
<div
className="rounded-xl border p-2.5"
style={{
borderColor: "color-mix(in srgb, var(--danger) 40%, var(--line))",
background: "var(--danger-soft)",
}}
>
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--danger)" }}>
<AlertTriangle size={13} aria-hidden />
{errors.length} 个错误,修复后才能保存
</p>
<ul className="space-y-1">
{errors.map((issue, idx) => (
<LintIssueRow key={`e-${idx}`} issue={issue} />
))}
</ul>
</div>
)}
{warns.length > 0 && (
<div
className="rounded-xl border p-2.5"
style={{
borderColor: "color-mix(in srgb, var(--gold) 40%, var(--line))",
background: "var(--gold-soft)",
}}
>
<p className="mb-1.5 flex items-center gap-1.5 text-[12px] font-semibold" style={{ color: "var(--gold)" }}>
<Info size={13} aria-hidden />
{warns.length} 个提醒,确认风险后仍可保存
</p>
<ul className="space-y-1">
{warns.map((issue, idx) => (
<LintIssueRow key={`w-${idx}`} issue={issue} />
))}
</ul>
</div>
)}
</div>
);
}
function LintIssueRow({ issue }: { issue: WidgetLintIssue }) {
const tone = issue.level === "error" ? "var(--danger)" : "var(--gold)";
return (
<li className="flex gap-1.5 text-[11.5px] leading-relaxed">
<span
className="mt-px shrink-0 rounded px-1 py-px font-mono text-[10.5px] font-semibold tabular-nums"
style={{ color: tone, background: "color-mix(in srgb, currentColor 10%, transparent)" }}
>
{LINT_FIELD_LABEL[issue.field]} {issue.line}:{issue.column}
</span>
<span style={{ color: "var(--ink-2)" }}>{issue.message}</span>
</li>
);
}
/* ─── 分区卡片头 ─── */
function SectionHead({
icon,

View File

@@ -16,7 +16,7 @@ import { formatDate, formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import RoleBadge from "@/components/RoleBadge";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import {
AdminEmpty,
AdminPager,
@@ -356,7 +356,7 @@ export default function UserAuditClient({
<span className="text-[11.5px] meta">帖子 #{c.post_id}</span>
) : (
<Link
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
className="text-[11.5px] hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -29,6 +29,7 @@ import {
AD_IMAGE_WIDTH,
AD_TEXT_COL_WIDTH,
} from "@/lib/ads";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
type Kind = "image" | "text";
@@ -183,7 +184,8 @@ export default function AdsBuyClient() {
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={selectedPay.qr_url}
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(160)) : selectedPay.qr_url}
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 160) : undefined}
alt="收款码"
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
style={{ borderColor: "var(--line)" }}
@@ -381,7 +383,8 @@ export default function AdsBuyClient() {
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={selectedPay.qr_url}
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(144)) : selectedPay.qr_url}
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 144) : undefined}
alt={`${selectedPay.name}收款码`}
className="h-36 w-36 object-contain"
/>

View File

@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { notFound } from "next/navigation";
import { Megaphone } from "lucide-react";
import { Megaphone, Pin } from "lucide-react";
import { fetchAnnouncementDetail, fetchOverview } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
@@ -59,7 +59,7 @@ export default async function AnnouncementPage({ params }: PageProps) {
const loginHref = `/login?redirect=${encodeURIComponent(`/announcement/${id}`)}`;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const toc = extractToc(a.content);
@@ -116,7 +116,11 @@ export default async function AnnouncementPage({ params }: PageProps) {
showTimeOfDay
badges={
<>
{a.pinned ? <span className="badge-pin">置顶</span> : null}
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
</>
}

View File

@@ -1,11 +1,11 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import Link from "next/link";
import { Megaphone } from "lucide-react";
import { Megaphone, Pin } from "lucide-react";
import { fetchAnnouncementsPage, fetchOverview, type Announcement } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { formatDate } from "@/lib/format";
import Pagination from "@/components/Pagination";
import {
@@ -57,7 +57,11 @@ function FeaturedAnnouncement({ a, urlStyle }: { a: Announcement; urlStyle: UrlS
>
置顶公告
</span>
{a.pinned ? <span className="badge-pin">置顶</span> : null}
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
@@ -87,7 +91,11 @@ function AnnouncementRow({ a, showDivider, urlStyle }: { a: Announcement; showDi
style={showDivider ? { borderTop: "1px solid var(--line)" } : undefined}
>
<div className="flex items-center gap-2 flex-wrap">
{a.pinned ? <span className="badge-pin">置顶</span> : null}
{a.pinned ? (
<span className="badge-pin" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
) : null}
<span className={`j13-anno-tag is-${a.tag_color}`}>{a.tag}</span>
<time className="meta text-[12px] tabular-nums ml-auto" dateTime={a.created_at}>
{formatDate(a.created_at)}
@@ -133,7 +141,7 @@ export default async function AnnouncementsListPage({ searchParams }: PageProps)
const sidebarPages = overview?.sidebar_pages ?? [];
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
// 首页置顶条目作头图;其余走列表行(置顶已在头图时不再重复)
const featured = page === 1 ? announcements.find((a) => a.pinned) : undefined;

View File

@@ -18,6 +18,7 @@ import {
Copy,
ChevronsDown,
Smile,
VolumeX,
} from "lucide-react";
import {
apiListChatMessages,
@@ -41,11 +42,13 @@ import {
RT_CHAT_MESSAGE,
RT_CHAT_MEMBERSHIP,
RT_CHAT_RECALLED,
RT_CHAT_MEMBER_MUTED,
} from "@/lib/realtime";
import type {
RtChatMessageData,
RtChatMembershipData,
RtChatRecalledData,
RtChatMemberMutedData,
} from "@/lib/realtime";
import { canOverseeChat, isOwner as isSiteOwner } from "@/lib/roles";
import { toast } from "@/lib/toast";
@@ -55,6 +58,7 @@ import LevelBadge from "@/components/LevelBadge";
import BadgeChip from "@/components/BadgeChip";
import AutolinkText from "@/components/AutolinkText";
import ConfirmDialog from "@/components/ConfirmDialog";
import Modal from "@/components/Modal";
import ContextMenu, { type CtxMenuItem } from "@/components/ContextMenu";
import ChatUserCard, { type ChatUserCardTarget } from "@/components/ChatUserCard";
import EmojiPicker from "@/components/EmojiPicker";
@@ -591,8 +595,21 @@ export default function ChatRoomClient({
y: number;
message: ChatMessage;
} | null>(null);
/** 头像右键菜单:对成员的管理操作 */
const [userCtxMenu, setUserCtxMenu] = useState<{
x: number;
y: number;
user: User;
member?: ChatRoomMember;
} | null>(null);
const [confirm, setConfirm] = useState<ConfirmState>(null);
const [confirmBusy, setConfirmBusy] = useState(false);
/** 禁言弹窗(含时长选择,QQ 风格) */
const [muteTarget, setMuteTarget] = useState<{
uid: number;
nickname: string;
} | null>(null);
const [muteBusy, setMuteBusy] = useState(false);
const [replyTo, setReplyTo] = useState<ChatMessage | null>(null);
const [mentionOpen, setMentionOpen] = useState(false);
const [mentionFilter, setMentionFilter] = useState("");
@@ -684,7 +701,7 @@ export default function ChatRoomClient({
const openUserCard = useCallback(
(u: User) => {
const mem = members.find((m) => m.user_id === u.id);
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted });
setUserCard({ user: u, roomRole: mem?.role, muted: mem?.muted, mutedUntil: mem?.muted_until });
},
[members]
);
@@ -856,6 +873,40 @@ export default function ChatRoomClient({
return off;
}, [roomId, router]);
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
useEffect(() => {
const off = realtime.on<RtChatMemberMutedData>(
RT_CHAT_MEMBER_MUTED,
(data) => {
if (!data || data.room_id !== roomId) return;
setMembers((prev) =>
prev.map((m) =>
m.user_id === data.user_id
? { ...m, muted: data.muted, muted_until: data.muted_until }
: m
)
);
}
);
return off;
}, [roomId, members, meId]);
// 禁言/解禁系统提示(居中展示,类似撤回提示)
const mutedNotice = useMemo(() => {
const mem = members.find((m) => m.user_id === meId);
if (!mem?.muted) return null;
return mem;
}, [members, meId]);
const muteCountdown = useCountdown(mutedNotice?.muted_until, () => {
// 倒计时到期:前端自动解除禁言状态(惰性清理,无需等待 WS 或发消息)
setMembers((prev) =>
prev.map((m) =>
m.user_id === meId ? { ...m, muted: false, muted_until: null } : m
)
);
});
const loadMore = useCallback(async () => {
if (!canView || !hasMore || loadingMore || messages.length === 0) return;
setLoadingMore(true);
@@ -1119,7 +1170,7 @@ export default function ChatRoomClient({
const handleRichInput = () => {
const el = inputRef.current;
if (!el) return;
if (!el || mutedNotice) return;
const text = domToText(el);
if (text === lastRichText.current) return;
if (text.length > 2000) {
@@ -1153,6 +1204,10 @@ export default function ChatRoomClient({
const handleRichKeyDown = (e: React.KeyboardEvent<HTMLDivElement>) => {
// IME 组合中不拦截按键
if (e.nativeEvent.isComposing || e.keyCode === 229) return;
if (mutedNotice) {
e.preventDefault();
return;
}
if (mentionOpen && mentionCandidates.length > 0) {
if (e.key === "ArrowDown") {
e.preventDefault();
@@ -1196,6 +1251,10 @@ export default function ChatRoomClient({
/** 粘贴一律按纯文本插入(换行转 <br>),避免富文本结构污染序列化 */
const handleRichPaste = (e: React.ClipboardEvent<HTMLDivElement>) => {
if (mutedNotice) {
e.preventDefault();
return;
}
e.preventDefault();
const text = e.clipboardData.getData("text/plain");
if (!text) return;
@@ -1309,23 +1368,55 @@ export default function ChatRoomClient({
const handleMute = (uid: number, nickname: string, muted: boolean) => {
if (!canKickMembers) return;
if (muted) {
// 打开时长选择弹窗(QQ 风格)
setMuteTarget({ uid, nickname });
return;
}
setConfirm({
title: muted ? "禁言成员" : "解除禁言",
message: muted
? `确定禁言 ${nickname}?禁言后无法在本群发言。`
: `确定解除 ${nickname} 的禁言?`,
confirmLabel: muted ? "禁言" : "解禁",
danger: muted,
title: "解除禁言",
message: `确定解除 ${nickname} 的禁言?`,
confirmLabel: "解禁",
action: async () => {
await apiMuteChatMember(roomId, uid, muted);
await apiMuteChatMember(roomId, uid, false);
setMembers((prev) =>
prev.map((m) => (m.user_id === uid ? { ...m, muted } : m))
prev.map((m) =>
m.user_id === uid ? { ...m, muted: false, muted_until: null } : m
)
);
toast(muted ? "已禁言" : "已解禁", "ok");
toast("已解禁", "ok");
},
});
};
const submitMute = async (durationMinutes: number) => {
if (!muteTarget) return;
setMuteBusy(true);
try {
await apiMuteChatMember(roomId, muteTarget.uid, true, durationMinutes);
const until =
durationMinutes > 0
? new Date(Date.now() + durationMinutes * 60_000).toISOString()
: null;
setMembers((prev) =>
prev.map((m) =>
m.user_id === muteTarget.uid
? { ...m, muted: true, muted_until: until }
: m
)
);
toast(
durationMinutes > 0 ? `已禁言 ${formatMuteDuration(durationMinutes)}` : "已永久禁言",
"ok"
);
setMuteTarget(null);
} catch (e) {
toast(e instanceof Error ? e.message : "禁言失败");
} finally {
setMuteBusy(false);
}
};
const handleSetAdmin = (uid: number, nickname: string, makeAdmin: boolean) => {
if (!siteOwner) return;
setConfirm({
@@ -1454,6 +1545,62 @@ export default function ChatRoomClient({
return items;
};
/** 用户头像右键菜单:按权限展示成员管理项 */
const userMenuItems = (u: User, mem?: ChatRoomMember): CtxMenuItem[] => {
const close = () => setUserCtxMenu(null);
const items: CtxMenuItem[] = [
{
key: "profile",
label: "查看资料卡",
icon: <Users size={14} />,
onClick: () => {
close();
openUserCard(u);
},
},
];
if (u.id === meId || isDirect) return items;
const isTargetOwner = mem?.role === "owner";
const isTargetAdmin = mem?.role === "admin";
if (canKickMembers && !isTargetOwner) {
const muted = mem?.muted ?? false;
items.push({
key: "mute",
label: muted ? "解禁" : "禁言",
icon: <VolumeX size={14} />,
danger: !muted,
onClick: () => {
close();
handleMute(u.id, u.nickname || u.username, !muted);
},
});
items.push({
key: "kick",
label: "移出群聊",
icon: <UserX size={14} />,
danger: true,
onClick: () => {
close();
handleKick(u.id, u.nickname || u.username);
},
});
}
if (siteOwner && !isTargetOwner) {
items.push({
key: "setAdmin",
label: isTargetAdmin ? "取消管理员" : "设为管理员",
icon: <Crown size={14} />,
onClick: () => {
close();
handleSetAdmin(u.id, u.nickname || u.username, !isTargetAdmin);
},
});
}
return items;
};
// 点击空白关闭右键菜单 — ContextMenu 自带
const shellClass = embedded
@@ -1650,6 +1797,23 @@ export default function ChatRoomClient({
5 * 60_000;
const reEditText = mine ? takeReEditDraft(m.id) : null;
// 系统消息(如禁言通知):居中灰字
if (m.sender_id === 0) {
return (
<div
key={m.id}
className="flex justify-center px-2 py-0.5"
>
<p
className="text-[12px] leading-relaxed text-center max-w-[90%]"
style={{ color: "var(--ink-3)" }}
>
{m.content}
</p>
</div>
);
}
// 普通成员:居中系统提示(本人可重新编辑);不展示原文
if (recalled && !(canModerateChat && m.content)) {
return (
@@ -1704,10 +1868,26 @@ export default function ChatRoomClient({
>
<div className="w-8 shrink-0">
{!grouped && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
@@ -1715,7 +1895,7 @@ export default function ChatRoomClient({
src={m.sender.avatar || undefined}
size={32}
/>
</button>
</div>
)}
</div>
<div
@@ -1784,10 +1964,26 @@ export default function ChatRoomClient({
>
<div className="w-8 shrink-0">
{!grouped && (
<button
type="button"
className="rounded-full transition-opacity hover:opacity-85"
<div
role="button"
tabIndex={0}
className="rounded-full transition-opacity hover:opacity-85 cursor-pointer"
onClick={() => openUserCard(m.sender)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") openUserCard(m.sender);
}}
onContextMenu={(e) => {
if (m.sender_id === meId || isDirect) return;
e.preventDefault();
e.stopPropagation();
const mem = members.find((x) => x.user_id === m.sender_id);
setUserCtxMenu({
x: e.clientX,
y: e.clientY,
user: m.sender,
member: mem,
});
}}
aria-label={`查看 ${m.sender.nickname || m.sender.username} 的资料卡`}
>
<Avatar
@@ -1795,7 +1991,7 @@ export default function ChatRoomClient({
src={m.sender.avatar || undefined}
size={32}
/>
</button>
</div>
)}
</div>
<div
@@ -2028,18 +2224,32 @@ export default function ChatRoomClient({
<div className="relative shrink-0 flex items-center" ref={emojiPanelWrapRef}>
<button
type="button"
disabled={!!mutedNotice}
className={`w-9 h-9 flex items-center justify-center rounded-full shrink-0 transition-colors${
emojiOpen ? " bg-[var(--accent-soft)]" : " hover:bg-[var(--panel-2)]"
mutedNotice
? " opacity-40 cursor-not-allowed"
: emojiOpen
? " bg-[var(--accent-soft)]"
: " hover:bg-[var(--panel-2)]"
}`}
style={{ color: emojiOpen ? "var(--accent)" : "var(--ink-2)" }}
aria-label="表情"
style={{
color: mutedNotice
? "var(--ink-3)"
: emojiOpen
? "var(--accent)"
: "var(--ink-2)",
}}
aria-label={mutedNotice ? "禁言中,无法发送表情" : "表情"}
aria-expanded={emojiOpen}
onMouseDown={(e) => e.preventDefault()}
onClick={() => setEmojiOpen((v) => !v)}
onClick={() => {
if (mutedNotice) return;
setEmojiOpen((v) => !v);
}}
>
<Smile size={19} />
</button>
{emojiOpen && (
{emojiOpen && !mutedNotice && (
<div className="j13-chat-emoji-panel">
<EmojiPicker onPick={insertEmoji} />
</div>
@@ -2047,17 +2257,28 @@ export default function ChatRoomClient({
</div>
<div
ref={inputRef}
contentEditable
contentEditable={!mutedNotice}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label="输入消息"
data-placeholder={isDirect ? "输入消息…" : "输入消息,@ 可提醒成员"}
aria-label={mutedNotice ? "禁言中" : "输入消息"}
data-placeholder={
mutedNotice
? `禁言中${muteCountdown ? `,剩余 ${muteCountdown}` : "(永久)"}`
: isDirect
? "输入消息…"
: "输入消息,@ 可提醒成员"
}
className="j13-chat-scroll j13-chat-composer-input j13-chat-rich-input relative flex-1 min-w-0 text-[14px] leading-5 py-2"
style={mutedNotice ? { color: "var(--ink-3)", cursor: "not-allowed" } : undefined}
onInput={handleRichInput}
onKeyDown={handleRichKeyDown}
onPaste={handleRichPaste}
onFocus={(e) => {
if (mutedNotice) {
e.currentTarget.blur();
return;
}
// 键盘弹出兜底:把输入框滚入可视区(个别浏览器
// interactiveWidget=resizes-content 不生效时仍能聚焦可见)
const composer = e.currentTarget.closest(".j13-chat-composer");
@@ -2067,7 +2288,7 @@ export default function ChatRoomClient({
<button
type="button"
onClick={send}
disabled={sending || !content.trim()}
disabled={sending || !content.trim() || !!mutedNotice}
className="btn btn-accent btn-sm gap-1.5 h-9 px-3.5 shrink-0 rounded-full"
aria-label="发送"
>
@@ -2156,6 +2377,7 @@ export default function ChatRoomClient({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
/>
@@ -2204,6 +2426,7 @@ export default function ChatRoomClient({
user: mem.user,
roomRole: mem.role,
muted: mem.muted,
mutedUntil: mem.muted_until,
})
}
showTitle
@@ -2220,6 +2443,15 @@ export default function ChatRoomClient({
/>
)}
{userCtxMenu && (
<ContextMenu
x={userCtxMenu.x}
y={userCtxMenu.y}
items={userMenuItems(userCtxMenu.user, userCtxMenu.member)}
onClose={() => setUserCtxMenu(null)}
/>
)}
{userCard && (
<ChatUserCard
target={userCard}
@@ -2240,10 +2472,179 @@ export default function ChatRoomClient({
if (!confirmBusy) setConfirm(null);
}}
/>
<MuteModal
open={!!muteTarget}
nickname={muteTarget?.nickname ?? ""}
busy={muteBusy}
onConfirm={submitMute}
onClose={() => {
if (!muteBusy) setMuteTarget(null);
}}
/>
</div>
);
}
/** 禁言时长选项(分钟),0 = 永久,参考 QQ 禁言 */
const MUTE_DURATIONS: { value: number; label: string }[] = [
{ value: 10, label: "10 分钟" },
{ value: 60, label: "1 小时" },
{ value: 12 * 60, label: "12 小时" },
{ value: 24 * 60, label: "1 天" },
{ value: 3 * 24 * 60, label: "3 天" },
{ value: 7 * 24 * 60, label: "7 天" },
{ value: 30 * 24 * 60, label: "30 天" },
{ value: 0, label: "永久" },
];
function formatMuteDuration(minutes: number): string {
return MUTE_DURATIONS.find((d) => d.value === minutes)?.label ?? `${minutes} 分钟`;
}
/** 禁言系统提示用的时长格式:返回「x天x小时x分钟」 */
function formatMuteCountdown(untilIso: string): string {
const until = new Date(untilIso).getTime();
const diff = until - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return `${days} 天 ${hours} 小时`;
if (hours > 0) return `${hours} 小时 ${minutes} 分钟`;
return `${minutes} 分钟`;
}
/** 成员列表禁言徽标内的剩余时长,永久/null 返回空串 */
function formatMuteRemainShort(until?: string | null): string {
if (!until) return "";
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) return "";
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
if (days > 0) return ` ${days}d`;
if (hours > 0) return ` ${hours}h`;
return ` ${minutes}m`;
}
/** 禁言倒计时:返回「x天x小时x分钟」或 null(永久/未禁言);到期触发 onExpire */
function useCountdown(
until?: string | null,
onExpire?: () => void
): string | null {
const [text, setText] = useState<string | null>(null);
const expiredRef = useRef(false);
useEffect(() => {
expiredRef.current = false;
if (!until) {
setText(null);
return;
}
const update = () => {
const diff = new Date(until).getTime() - Date.now();
if (diff <= 0) {
if (!expiredRef.current) {
expiredRef.current = true;
onExpire?.();
}
setText("已到期");
return;
}
const days = Math.floor(diff / 86_400_000);
const hours = Math.floor((diff % 86_400_000) / 3_600_000);
const minutes = Math.floor((diff % 3_600_000) / 60_000);
const seconds = Math.floor((diff % 60_000) / 1000);
if (days > 0) setText(`${days} 天 ${hours} 小时`);
else if (hours > 0) setText(`${hours} 小时 ${minutes} 分钟`);
else if (minutes > 0) setText(`${minutes} 分 ${seconds} 秒`);
else setText(`${seconds} 秒`);
};
update();
const t = setInterval(update, 1000);
return () => clearInterval(t);
}, [until, onExpire]);
return text;
}
/** 禁言成员弹窗(QQ 风格:选择时长) */
function MuteModal({
open,
nickname,
busy,
onConfirm,
onClose,
}: {
open: boolean;
nickname: string;
busy: boolean;
onConfirm: (durationMinutes: number) => void;
onClose: () => void;
}) {
const [duration, setDuration] = useState<number>(24 * 60);
useEffect(() => {
if (open) setDuration(24 * 60);
}, [open]);
return (
<Modal
open={open}
title="禁言成员"
onClose={onClose}
busy={busy}
maxWidthClass="max-w-sm"
role="alertdialog"
footer={
<>
<button
type="button"
onClick={onClose}
disabled={busy}
className="btn btn-line btn-sm"
>
取消
</button>
<button
type="button"
onClick={() => onConfirm(duration)}
disabled={busy}
className="btn btn-primary btn-sm"
>
{busy ? "禁言中..." : "禁言"}
</button>
</>
}
>
<div className="space-y-3">
<p className="text-[13px]" style={{ color: "var(--ink-2)" }}>
选择 <span style={{ color: "var(--ink)" }}>{nickname}</span> 的禁言时长,到期自动解除。
</p>
<div className="grid grid-cols-2 gap-2">
{MUTE_DURATIONS.map((d) => {
const active = d.value === duration;
return (
<button
key={d.value}
type="button"
onClick={() => setDuration(d.value)}
className="h-9 rounded-lg text-[13px] transition-colors"
style={{
background: active ? "var(--accent-soft)" : "var(--bg)",
color: active ? "var(--accent)" : "var(--ink-2)",
border: `1px solid ${active ? "var(--accent)" : "var(--line)"}`,
}}
>
{d.label}
</button>
);
})}
</div>
</div>
</Modal>
);
}
function JoinButton({
roomId,
onJoined,
@@ -2361,7 +2762,7 @@ function MembersPanel({
{name}
{m.muted ? (
<span className="ml-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
· 禁言
· 禁言{formatMuteRemainShort(m.muted_until)}
</span>
) : null}
</Link>

View File

@@ -6,7 +6,7 @@ export default function ChatLayout({ children }: { children: React.ReactNode })
return (
<div
data-chat-viewport
className="flex flex-col flex-1 min-h-0 w-full max-w-6xl mx-auto"
className="flex flex-col flex-1 min-h-0 w-full"
>
{children}
</div>

View File

@@ -376,6 +376,11 @@ html[data-lv-fx="off"] .lv-text {
hsl(var(--cover-h) 28% 16%);
}
/* 书库页标题:书香气衬线(宋体/楷体回退) */
.j13-library-title {
font-family: "Songti SC", "STSong", "SimSun", "Noto Serif CJK SC", serif;
}
/* 全站悬停即显提示气泡(配合 TipHost + data-tip,替代原生 title 延迟) */
.j13-tip-bubble {
position: fixed;
@@ -2421,14 +2426,23 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
height: 32px;
padding: 0 8px;
}
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用) */
/* 窄屏(手机端)编辑工具栏:收成单行核心工具 + 「更多」菜单(评论栏与正文编辑器共用)。
空间不足时允许「编写/预览」折到第二行,不能让固定宽按钮溢出后与模式切换重叠 */
.j13-md-editor.is-narrow .j13-md-toolbar {
flex-wrap: nowrap;
flex-wrap: wrap;
gap: 2px;
row-gap: 6px;
padding: 2px 0 8px;
}
.j13-md-editor.is-narrow .j13-md-tool-groups {
flex-wrap: nowrap;
/* 默认 min-width:0 会让本组被压缩、内部按钮溢出到模式切换下方;
内容宽度作为最小宽,放不下时把右侧切换挤到下一行 */
min-width: max-content;
}
.j13-md-editor.is-narrow .j13-md-toolbar-end {
/* 单行时左侧 flex-1 已把它顶到右侧;折行后在第二行保持右对齐 */
margin-left: auto;
}
.j13-md-editor.is-narrow .j13-md-tool {
width: 30px;
@@ -6489,29 +6503,23 @@ a.j13-mention-token:hover {
background: var(--accent-soft);
color: var(--accent);
}
.badge-pin {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 4px 11px;
background: var(--clay-soft);
color: var(--clay);
border-radius: 4px;
white-space: nowrap;
}
/* 置顶 / 推荐:图标徽章(无文字,配 title/aria-label),全站统一,节省标题空间 */
.badge-pin,
.badge-rec {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
font-weight: 500;
padding: 4px 11px;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 6px;
}
.badge-pin {
background: var(--clay-soft);
color: var(--clay);
}
.badge-rec {
background: var(--gold-soft);
color: var(--gold);
border-radius: 4px;
white-space: nowrap;
}
.badge-pending {
display: inline-flex;
@@ -8206,6 +8214,15 @@ a.j13-mention-token:hover {
}
}
/* 书库全书搜索:正文命中样式由 BookSearch 运行时注入 ::highlight() 规则
(构建器 CSS 压缩器不识别该伪元素);.j13-find-mark 仅用于搜索弹窗片段预览 */
.j13-find-mark {
background: color-mix(in srgb, var(--accent) 24%, transparent);
color: inherit;
border-radius: 3px;
padding: 0 0.1em;
}
/* ============================================================
Markdown 正文
============================================================ */
@@ -8763,6 +8780,14 @@ html.dark .prose-content pre[data-theme] span {
}
}
/* 移动端聚焦评论框(主评论/回复/编辑)时隐藏回顶浮钮:
虚拟键盘弹起后浮钮会贴近输入区造成遮挡与误触,失焦后自动恢复 */
@media (max-width: 1023px) {
body:has(#comments .j13-md-textarea:focus) .j13-back-top {
display: none;
}
}
/* 首页移动端:板块条白底上贴页头(#main 顶部不留白,板块条与帖子框连成一张白纸) */
@media (max-width: 1023px) {
body:has(.j13-fluid-home) #main {
@@ -9284,12 +9309,12 @@ button.cmt-edited {
background: color-mix(in srgb, var(--accent) 18%, transparent);
}
/* 评论内联回复表单:出现在目标评论正下方 */
/* 评论内联回复表单:与楼层块同级全宽显示(DOM 见 CommentSection 列表 map)。
前侧间距由 .cmt-item 的 padding-bottom 提供 */
.cmt-reply-form {
padding: 12px 14px;
border-radius: 14px;
background: var(--panel-2);
margin-top: 8px;
}
/* 评论区编辑器:矮输入区,底栏含提交 */
@@ -9312,12 +9337,32 @@ button.cmt-edited {
flex-wrap: wrap;
}
@container (max-width: 420px) {
/* 手机端评论栏:工具按钮收到 30px(与 is-narrow 一致),「编写/预览」收成图标,
保证核心工具与「更多」单行排开不与模式切换重叠;
极端窄宽(<340px)放不下时,模式切换整体换到第二行而不是压在「更多」上 */
.cmt-md-editor .j13-md-editor.is-comment .j13-md-toolbar {
flex-wrap: wrap;
row-gap: 6px;
}
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool {
width: 36px;
height: 36px;
width: 30px;
height: 30px;
}
.cmt-md-editor .j13-md-editor.is-comment .j13-md-tool-groups {
flex: 1 0 auto;
min-width: min-content;
}
.cmt-md-editor .j13-md-toolbar-end {
margin-left: auto;
}
.cmt-md-editor .j13-md-mode button {
padding: 0 6px;
width: 30px;
padding: 0;
gap: 0;
justify-content: center;
}
.cmt-md-editor .j13-md-mode-text {
display: none;
}
}
@@ -9943,15 +9988,12 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
outline-offset: -2px;
}
/* ── 侧栏管理:站长自定义工具容器 ──
/* ── 侧栏管理:站长自定义工具容器(Shadow DOM 宿主) ──
不附加面板底色/边框,视觉完全交给工具自带的 HTML/CSS;
仅保证在侧栏列内不横向溢出。 */
仅保证在侧栏列内不横向溢出,工具内容隔离在 shadow 内(见 widgetRuntime)。 */
.j13-widget-body {
min-width: 0;
}
.j13-widget-body > .j13-widget-html {
min-width: 0;
}
/* ── 抖音表情(douyin-emoji):编辑器弹层面板 / 聊天面板 / 行内图片 ── */
.j13-emoji-picker {

View File

@@ -140,6 +140,18 @@ export default async function RootLayout({
className={`${theme === "dark" ? "dark" : ""} ${motionHtmlClass} ${jakarta.variable} ${notoSC.variable} ${jetbrainsMono.variable}`.trim()}
>
<body className="min-h-screen flex flex-col">
{/* Noto Sans SC 字体声明异步加载(postbuild 抽到 public/fonts/noto-sans-sc.css):
动态插入的 stylesheet 不阻塞渲染;度量校正 fallback 面仍在阻塞 CSS 中,
首屏先用栈内系统字体(PingFang/雅黑),切片就绪后 swap */}
<script
dangerouslySetInnerHTML={{
__html:
'(function(){var l=document.createElement("link");l.rel="stylesheet";l.href="/fonts/noto-sans-sc.css";document.head.appendChild(l)})();',
}}
/>
<noscript>
<link rel="stylesheet" href="/fonts/noto-sans-sc.css" />
</noscript>
{accentCSS ? (
// React 托管的 SSR 主题节点;客户端换肤走 j13-accent-runtime,禁止对此外挂 remove
<style

View File

@@ -9,13 +9,14 @@ import { getPublicSettingsCached } from "@/lib/serverData";
import MarkdownBody from "@/components/MarkdownBody";
import { SiteDocMasthead } from "@/components/site-doc";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryDocPath, libraryDocReadPath } from "@/lib/urlStyle";
import { libraryDocPath, libraryDocReadPath, userPath } from "@/lib/urlStyle";
import { webPageJsonLd } from "@/lib/jsonLd";
import JsonLd from "@/components/JsonLd";
import { estimateReadingMinutes } from "@/lib/toc";
import { groupSections } from "@/lib/librarySections";
import LightboxImage from "@/components/LightboxImage";
import AdaptiveCoverSlot from "@/components/AdaptiveCoverSlot";
import Avatar from "@/components/Avatar";
interface PageProps {
params: Promise<{ slug: string }>;
@@ -64,23 +65,27 @@ function ExtChip({ ext }: { ext: string }) {
function FileRow({ f }: { f: LibraryFileItem }) {
return (
<div
className="rounded-xl px-4 py-3"
className="rounded-xl px-3 py-2.5"
style={{ border: "1px solid var(--line)", background: "var(--bg)" }}
>
<div className="flex flex-wrap items-center gap-x-3 gap-y-1.5">
{/* 紧凑两行式:适配左栏窄列,文件名过长截断(悬停可见全名),避免被压成逐字换行 */}
<div className="flex items-center gap-2 min-w-0">
<ExtChip ext={f.ext} />
<span
className="text-[13.5px] font-medium min-w-0 break-all"
className="text-[13px] font-medium min-w-0 truncate"
style={{ color: "var(--ink)" }}
title={f.name}
>
{f.name}
</span>
<span className="meta text-[11.5px]">
</div>
<div className="mt-2 flex items-center justify-between gap-2">
<span className="meta text-[11.5px] shrink-0">
{formatSize(f.size)} · {f.download_count} 次下载
</span>
<a
href={`/api/library/files/${f.id}/download`}
className="btn btn-sm btn-primary ml-auto inline-flex items-center gap-1.5 shrink-0"
className="btn btn-sm btn-primary inline-flex items-center gap-1.5 shrink-0"
>
<Download size={13} /> 下载
</a>
@@ -159,12 +164,23 @@ export default async function LibraryDocPage({ params }: PageProps) {
{/* 横版封面(宽>高且足够大):全宽置于内容上方;竖版或过小的横版图时此槽位不渲染。
小图不强行放大占满全宽:max-w-full 下保持自然尺寸,容器随内容收缩并水平居中 */}
{d.cover_url ? (
<AdaptiveCoverSlot orientation="landscape" src={d.cover_url}>
<AdaptiveCoverSlot
orientation="landscape"
src={d.cover_url}
naturalWidth={d.cover_width}
naturalHeight={d.cover_height}
>
<div
className="mt-6 w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
<LightboxImage
src={d.cover_url}
alt={d.title}
className="max-w-full h-auto block"
variantWidths={[768, 1080, 1920]}
sizes="(max-width:1280px) 92vw, 1080px"
/>
</div>
</AdaptiveCoverSlot>
) : null}
@@ -175,18 +191,40 @@ export default async function LibraryDocPage({ params }: PageProps) {
{d.cover_url ? (
// 竖版封面(长>宽)及过小的横版封面:按图片自然比例显示在左栏;
// 小图不强行放大占满:保持自然尺寸,在槽位最大区域内水平垂直居中
<AdaptiveCoverSlot orientation="portrait" src={d.cover_url}>
<AdaptiveCoverSlot
orientation="portrait"
src={d.cover_url}
naturalWidth={d.cover_width}
naturalHeight={d.cover_height}
>
<div
className="w-fit max-w-full mx-auto overflow-hidden rounded-2xl"
style={{ border: "1px solid var(--line)" }}
>
<LightboxImage src={d.cover_url} alt={d.title} className="max-w-full h-auto block" />
<LightboxImage
src={d.cover_url}
alt={d.title}
className="max-w-full h-auto block"
variantWidths={[384, 768]}
sizes="(max-width:1023px) 92vw, 340px"
/>
</div>
</AdaptiveCoverSlot>
) : (
<CoverFallback title={d.title} />
)}
<dl className="mt-4 flex flex-col gap-2 text-[13px]">
{d.author ? (
<div className="flex items-start justify-between gap-3">
<dt className="meta shrink-0">作者</dt>
<dd
className="font-semibold text-right min-w-0"
style={{ color: "var(--ink)" }}
>
{d.author}
</dd>
</div>
) : null}
<div className="flex items-center justify-between">
<dt className="meta">章节数</dt>
<dd className="tabular-nums font-semibold" style={{ color: "var(--ink)" }}>
@@ -199,6 +237,30 @@ export default async function LibraryDocPage({ params }: PageProps) {
{d.files.length}
</dd>
</div>
{d.creator ? (
<div className="flex items-center justify-between gap-2">
<dt className="meta shrink-0">创建者</dt>
<dd className="min-w-0">
<Link
href={userPath(urlStyle, d.creator.id)}
className="inline-flex items-center gap-1.5 min-w-0"
title={`@${d.creator.username}`}
>
<Avatar
size={20}
name={d.creator.nickname || d.creator.username}
src={d.creator.avatar}
/>
<span
className="text-[12.5px] font-medium truncate max-w-[9rem]"
style={{ color: "var(--ink)" }}
>
{d.creator.nickname || d.creator.username}
</span>
</Link>
</dd>
</div>
) : null}
</dl>
{chapterCount > 0 ? (
<Link
@@ -208,9 +270,28 @@ export default async function LibraryDocPage({ params }: PageProps) {
<BookOpen size={15} aria-hidden /> 开始阅读
</Link>
) : null}
{/* 附件下载紧跟阅读按钮:左栏桌面端 sticky 常驻,移动端排在介绍之前,
避免介绍过长时用户需要长距离滚动寻找下载入口 */}
{d.files.length > 0 ? (
<section id="files" className="mt-5 scroll-mt-24">
<h2
className="text-[13.5px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={14} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
<div className="mt-2.5 flex flex-col gap-2">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
</section>
) : null}
</aside>
{/* 右栏:介绍 + 附件 */}
{/* 右栏:介绍(附件下载已移至左栏阅读按钮下方) */}
<div className="mt-6 lg:mt-0 min-w-0">
<section>
<h2
@@ -228,25 +309,6 @@ export default async function LibraryDocPage({ params }: PageProps) {
)}
</div>
</section>
<section id="files" className="mt-9 scroll-mt-24">
<h2
className="text-[15px] font-bold inline-flex items-center gap-2"
style={{ color: "var(--ink)" }}
>
<Download size={15} style={{ color: "var(--accent)" }} aria-hidden />
附件下载({d.files.length})
</h2>
{d.files.length === 0 ? (
<p className="meta text-[12.5px] mt-3">暂无附件</p>
) : (
<div className="mt-3 flex flex-col gap-2.5">
{d.files.map((f) => (
<FileRow key={f.id} f={f} />
))}
</div>
)}
</section>
</div>
</div>
</article>

View File

@@ -21,7 +21,9 @@ import { splitContentSegments } from "@/lib/directiveBlocks";
import { wrapImplicitEntries } from "@/lib/entryBlocks";
import PostToc, { type TocItem } from "@/components/PostToc";
import MobileRailDrawers from "@/components/MobileRailDrawers";
import BookSearch from "@/components/BookSearch";
import { formatRelative } from "@/lib/format";
import { markdownToSearchText, type BookChapterIndex } from "@/lib/bookSearch";
interface PageProps {
params: Promise<{ slug: string }>;
@@ -127,6 +129,18 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
[current.content, ...subs.map((s) => s.content)].filter(Boolean).join("\n\n")
);
// 全书搜索索引:详情接口已一次返回全部章节,这里把每章(含小节标题/正文)
// 剥成可见纯文本随 RSC 下发给 BookSearch,无需额外交互请求
const searchIndex: BookChapterIndex[] = groups.map((g, i) => ({
no: i + 1,
title: g.chapter.title,
plain: markdownToSearchText(
[g.chapter.title, g.chapter.content, ...g.subs.flatMap((s) => [s.title, s.content])]
.filter(Boolean)
.join("\n\n")
),
}));
// 书籍侧栏内容:桌面左栏与移动端左抽屉共用(详情入口 + 章节列表)
const bookRail = (
<div className="flex flex-col gap-3">
@@ -247,11 +261,19 @@ export default async function LibraryReadPage({ params, searchParams }: PageProp
{/* 中间:单章正文 + 上下章导航(占满中栏全宽;手机端 panel 贴边铺满) */}
<div className="j13-doc-bleed min-w-0">
<article className="panel p-6 sm:p-8">
<article id="j13-book-article" className="panel p-6 sm:p-8">
<header>
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<div className="flex items-center justify-between gap-3">
<p className="meta text-[12px]">
第 {nav.index + 1} / {nav.total} 章 · 约 {chapterMinutes} 分钟
</p>
<BookSearch
slug={d.slug}
urlStyle={urlStyle}
currentNo={nav.index + 1}
chapters={searchIndex}
/>
</div>
<h2
id="ch-top"
className="mt-1.5 scroll-mt-24 text-[24px] sm:text-[26px] font-extrabold leading-snug"

View File

@@ -1,106 +1,9 @@
import type { Metadata } from "next";
import type { CSSProperties } from "react";
import Link from "next/link";
import { BookOpen, FileDown } from "lucide-react";
import { fetchLibraryList, type LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { libraryPath, libraryDocPath } from "@/lib/urlStyle";
function formatSize(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0 B";
if (n < 1024) return `${n} B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
return `${(n / (1024 * 1024)).toFixed(1)} MB`;
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(lib/coverColor.ts) */
function TextCover({ title }: { title: string }) {
const name = title.trim() || "书";
return (
<div
className="j13-text-cover w-full h-full flex items-center justify-center px-5 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span
className="text-[22px] font-extrabold leading-snug text-center line-clamp-3 break-all"
>
{name}
</span>
</div>
);
}
function ExtChip({ ext }: { ext: string }) {
return (
<span
className="text-[10.5px] font-semibold px-1.5 py-0.5 rounded-md uppercase tracking-wide"
style={{
color: "var(--accent)",
background: "var(--accent-soft)",
border: "1px solid color-mix(in srgb, var(--accent) 30%, transparent)",
}}
>
{ext}
</span>
);
}
function DocCard({
doc,
href,
}: {
doc: LibraryDocListItem;
href: string;
}) {
const exts = Array.from(new Set(doc.files.map((f) => f.ext))).slice(0, 4);
const totalSize = doc.files.reduce((acc, f) => acc + f.size, 0);
return (
<Link
href={href}
className="group flex flex-col overflow-hidden rounded-[20px] transition-transform hover:-translate-y-0.5"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
<div className="h-36 overflow-hidden" style={{ borderBottom: "1px solid var(--line)" }}>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={doc.cover_url}
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.03]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
</div>
<div className="p-4 flex flex-col gap-2 flex-1">
<h2
className="text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
<div className="mt-auto flex flex-wrap items-center gap-1.5">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
</div>
</Link>
);
}
import { libraryPath } from "@/lib/urlStyle";
import LibrarySearchGrid from "@/components/LibrarySearchGrid";
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
@@ -113,7 +16,6 @@ export async function generateMetadata(): Promise<Metadata> {
export default async function LibraryPage() {
const settings = await getPublicSettingsCached();
const urlStyle = settings.url_style;
// 接口失败降级为空态,不让页面 500
let docs: LibraryDocListItem[] = [];
@@ -126,53 +28,52 @@ export default async function LibraryPage() {
return (
<div className="min-w-0">
{/* Hero:渐变洗底(与排行榜同语言) */}
{/* Hero:书香气——serif 大标题、两侧晕染、底部水纹线 */}
<section
className="relative overflow-hidden rounded-[28px] px-6 py-8 sm:px-10 sm:py-10 text-center"
className="relative overflow-hidden px-6 py-12 sm:px-12 sm:py-16 text-center"
style={{
background:
"radial-gradient(120% 160% at 12% -10%, color-mix(in srgb, var(--accent) 16%, transparent), transparent 55%)," +
"radial-gradient(90% 140% at 90% -20%, color-mix(in srgb, var(--accent) 8%, transparent), transparent 50%)",
border: "1px solid var(--line)",
"radial-gradient(140% 180% at 50% -20%, color-mix(in srgb, var(--accent) 14%, transparent), transparent 60%)," +
"radial-gradient(80% 120% at 8% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)," +
"radial-gradient(80% 120% at 92% 0%, color-mix(in srgb, var(--accent) 6%, transparent), transparent 50%)",
}}
>
<div
aria-hidden
className="absolute -top-16 -right-10 w-56 h-56 rounded-full pointer-events-none"
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 w-[520px] h-[520px] rounded-full pointer-events-none"
style={{
background: "color-mix(in srgb, var(--accent) 14%, transparent)",
filter: "blur(48px)",
background: "color-mix(in srgb, var(--accent) 10%, transparent)",
filter: "blur(72px)",
}}
/>
<div className="relative">
<p
className="text-[11px] sm:text-[12px] font-semibold tracking-[0.35em] uppercase mb-4"
style={{ color: "var(--accent)" }}
>
Library
</p>
<h1
className="text-[26px] sm:text-[32px] leading-tight font-extrabold tracking-tight inline-flex items-center gap-2.5"
className="j13-library-title text-[32px] sm:text-[44px] leading-tight font-bold tracking-wide"
style={{ color: "var(--ink)" }}
>
<BookOpen size={26} style={{ color: "var(--accent)" }} />
书库
</h1>
<p className="meta mt-2 text-[13.5px]">书籍、文档与页面作品,在线阅读或下载</p>
<div
aria-hidden
className="mx-auto mt-5 h-px w-24 sm:w-32"
style={{
background:
"linear-gradient(90deg, transparent, color-mix(in srgb, var(--accent) 40%, var(--line)), transparent)",
}}
/>
<p className="meta mt-4 text-[13.5px] sm:text-[14px] tracking-wide">
书籍、文档与页面作品,在线阅读或下载
</p>
</div>
</section>
{docs.length === 0 ? (
<div
className="mt-6 rounded-[20px] px-6 py-14 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[12.5px] mt-1.5">管理员可在后台「书库管理」发布条目</p>
</div>
) : (
<div className="mt-6 grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{docs.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
<LibrarySearchGrid docs={docs} urlStyle={settings.url_style} />
</div>
);
}

View File

@@ -11,6 +11,7 @@ import {
type FriendLinkStatus,
} from "@/lib/api";
import LinksClient from "./LinksClient";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
links: FriendLink[];
@@ -240,9 +241,11 @@ export default function LinksBoard({
{/* 头部:LOGO + 名称 + 状态 */}
<div className="flex items-center gap-3">
{edit.logo_url ? (
// 40px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={edit.logo_url}
src={isOwnImage(edit.logo_url) ? variantURL(edit.logo_url, pickVariantWidth(40)) : edit.logo_url}
srcSet={isOwnImage(edit.logo_url) ? densitySrcSet(edit.logo_url, 40) : undefined}
alt=""
width={40}
height={40}
@@ -475,9 +478,11 @@ export default function LinksBoard({
}}
>
{l.logo_url ? (
// 48px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(48)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 48) : undefined}
alt=""
loading="lazy"
width={48}

View File

@@ -4,6 +4,7 @@ import { useRef, useState } from "react";
import Link from "next/link";
import { Loader2, Upload } from "lucide-react";
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
isLoggedIn: boolean;
@@ -155,9 +156,11 @@ export default function LinksClient({ isLoggedIn, canManage, onClose }: Props) {
aria-hidden
>
{form.logo_url ? (
// 64px 预览位:本站图走 96/160 变体;外链地址保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.logo_url}
src={isOwnImage(form.logo_url) ? variantURL(form.logo_url, pickVariantWidth(64)) : form.logo_url}
srcSet={isOwnImage(form.logo_url) ? densitySrcSet(form.logo_url, 64) : undefined}
alt=""
className="h-full w-full object-contain"
onError={(e) => {

View File

@@ -4,7 +4,7 @@ import { fetchFriendLinks, fetchSiteState, type FriendLink } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { brandAliasPath } from "@/lib/brand";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { requestCanonical } from "@/lib/canonicalMeta";
import { resolveSiteOrigin } from "@/lib/siteUrl";
import LinksBoard from "./LinksBoard";
@@ -34,7 +34,7 @@ export default async function FriendLinksPage() {
getMeCached(cookie || undefined),
]);
const isLoggedIn = !!me.user;
const canManage = !!me.user && isAdminOrAbove(me.user.role);
const canManage = !!me.user && hasPerm(me.user, PERMS.ANNOUNCEMENTS);
// 站点 LOGO 用固定对外地址(换图不变链,对方收藏的地址永不失效),补全为绝对地址供申请人直接复制
const siteBase = siteState.site_url?.trim() || resolveSiteOrigin() || "";
const logoAlias = settings.logo_light_url

View File

@@ -3,18 +3,28 @@
import { useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus } from "lucide-react";
import { Heart, MessageCircle, CheckCheck, Bell, CheckCircle2, XCircle, AtSign, ClipboardList, Award, AlertTriangle, UserPlus, ChevronRight } from "lucide-react";
import { apiMarkRead, apiMarkAllRead, type NotificationsResponse, type NotificationItem } from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
/**
* 通知列表交互层:数据由服务端 SSR 直出,这里只承担已读标记等客户端操作。
* 布局:左栏 sticky 概览(未读数/全部已读)+ 右列紧凑列表(单条两行内)。
*/
export default function NotificationsClient({ initialData }: { initialData: NotificationsResponse }) {
export default function NotificationsClient({
initialData,
initialUnread,
children,
}: {
initialData: NotificationsResponse;
initialUnread: number;
children?: React.ReactNode;
}) {
const router = useRouter();
const urlStyle = useUrlStyle();
const [notifications, setNotifications] = useState<NotificationItem[]>(initialData.notifications || []);
const [unreadCount, setUnreadCount] = useState(initialUnread);
const handleClick = async (n: NotificationItem) => {
if (!n.is_read) {
@@ -22,6 +32,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
setNotifications((list) =>
list.map((item) => (item.id === n.id ? { ...item, is_read: true } : item))
);
setUnreadCount((c) => Math.max(0, c - 1));
}
// @提及通知跳到群聊房间;徽章通知无关联帖子(仅标已读);关注通知跳到关注者主页;其余跳到帖子/评论
if (n.type === "mention" && n.room_id) {
@@ -32,9 +43,11 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
return;
} else if (n.type === "follow") {
router.push(userPath(urlStyle, n.actor_id));
} else if (n.comment_id > 0) {
} else if (n.comment_id > 0 && n.comment_floor) {
// 评论/回复/点赞等:直达评论所在楼层(跨页由帖子页深链定位换页)
router.push(`${postPath(urlStyle, n.post_id)}#comment-${n.comment_id}`);
router.push(
`${postPath(urlStyle, n.post_id)}${commentAnchor(n.comment_floor, n.comment_is_root ?? true, n.comment_id)}`
);
} else {
router.push(postPath(urlStyle, n.post_id));
}
@@ -43,6 +56,7 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
const handleMarkAll = async () => {
await apiMarkAllRead();
setNotifications((list) => list.map((item) => ({ ...item, is_read: true })));
setUnreadCount(0);
// 同步刷新 Header 未读红点(layout 重新取 unread_count)
router.refresh();
};
@@ -57,157 +71,176 @@ export default function NotificationsClient({ initialData }: { initialData: Noti
return `${Math.floor(hour / 24)} 天前`;
};
const hasUnread = notifications.some((n) => !n.is_read);
const iconOf = (n: NotificationItem) => {
const t = n.type;
const node =
t === "approved" ? <CheckCircle2 size={15} />
: t === "rejected" || t === "deleted" ? <XCircle size={15} />
: t === "necro_reply" ? <AlertTriangle size={15} />
: t === "like" ? <Heart size={15} />
: t === "mention" ? <AtSign size={15} />
: t === "pending_review" ? <ClipboardList size={15} />
: t === "badge" ? <Award size={15} />
: t === "follow" ? <UserPlus size={15} />
: <MessageCircle size={15} />;
const color =
t === "approved" ? "var(--ok)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger)"
: t === "like" ? "var(--clay)"
: t === "badge" ? "var(--gold)"
: "var(--accent)";
const bg =
t === "approved" ? "var(--ok-soft)"
: t === "rejected" || t === "deleted" || t === "necro_reply" ? "var(--danger-soft)"
: t === "like" ? "var(--clay-soft)"
: t === "badge" ? "var(--gold-soft)"
: "var(--accent-soft)";
return { node, color, bg };
};
return (
<>
{hasUnread && (
<div className="flex justify-end mb-4">
<button onClick={handleMarkAll} className="btn btn-line btn-sm">
{/* 左栏:概览 + 全部已读(lg 起 sticky) */}
<aside className="panel p-4 sm:p-5 lg:sticky lg:top-[var(--rail-top)]">
<p className="text-xs font-bold tracking-widest uppercase" style={{ color: "var(--ink-3)" }}>概览</p>
<div className="mt-2.5 flex items-baseline gap-1.5">
<span
className="text-[30px] leading-none font-extrabold"
style={{ color: unreadCount > 0 ? "var(--accent)" : "var(--ink)", fontVariantNumeric: "tabular-nums" }}
>
{unreadCount}
</span>
<span className="meta">条未读</span>
</div>
<p className="meta mt-1.5">共 {initialData.total} 条通知</p>
<div className="my-3.5" style={{ borderTop: "1px solid var(--line)" }} />
{unreadCount > 0 ? (
<button onClick={handleMarkAll} className="btn btn-line btn-sm w-full justify-center">
<CheckCheck size={14} /> 全部已读
</button>
</div>
)}
) : (
<p className="meta flex items-center gap-1.5" style={{ color: "var(--ok)" }}>
<CheckCircle2 size={14} /> 通知都已读完
</p>
)}
</aside>
{notifications.length === 0 ? (
<div className="panel text-center py-20 px-6">
<span
className="inline-flex w-14 h-14 rounded-full items-center justify-center mb-4"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Bell size={26} />
</span>
<p className="font-bold text-lg" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1.5">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const iconNode = n.type === "approved"
? <CheckCircle2 size={16} />
: n.type === "rejected" || n.type === "deleted"
? <XCircle size={16} />
: n.type === "necro_reply"
? <AlertTriangle size={16} />
: n.type === "like"
? <Heart size={16} />
: n.type === "mention"
? <AtSign size={16} />
: n.type === "pending_review"
? <ClipboardList size={16} />
: n.type === "badge"
? <Award size={16} />
: n.type === "follow"
? <UserPlus size={16} />
: <MessageCircle size={16} />;
const iconColor = n.type === "approved"
? "var(--ok)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger)"
: n.type === "like"
? "var(--clay)"
: n.type === "badge"
? "var(--gold)"
: "var(--accent)";
const iconBg = n.type === "approved"
? "var(--ok-soft)"
: n.type === "rejected" || n.type === "deleted" || n.type === "necro_reply"
? "var(--danger-soft)"
: n.type === "like"
? "var(--clay-soft)"
: n.type === "badge"
? "var(--gold-soft)"
: "var(--accent-soft)";
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className={`w-full text-left px-4 sm:px-5 py-4 flex items-start gap-3.5 transition-colors hover:bg-[var(--panel-2)] focus-visible:bg-[var(--panel-2)] ${
n.is_read ? "" : "bg-[var(--panel-2)]"
}`}
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
{/* 右列:紧凑通知列表 + 分页 */}
<div className="min-w-0">
{notifications.length === 0 ? (
<div className="panel text-center py-16 px-6">
<span
className="inline-flex w-12 h-12 rounded-full items-center justify-center mb-3.5"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<span
className="w-10 h-10 shrink-0 rounded-full flex items-center justify-center"
style={{ color: iconColor, background: iconBg }}
>
{iconNode}
</span>
<div className="flex-1 min-w-0">
<p className="text-sm leading-snug" style={{ color: "var(--ink)" }}>
{approval ? (
<Bell size={22} />
</span>
<p className="font-bold" style={{ color: "var(--ink)" }}>还没有通知</p>
<p className="meta mt-1">当有人评论或点赞你的帖子时,会在这里提醒你</p>
</div>
) : (
<div className="panel overflow-hidden p-0">
{notifications.map((n, i) => {
const approval = n.type === "approved" || n.type === "rejected";
// 系统提醒(ActorID=0):不渲染操作者链接,仅展示标题与内容
const system = n.type === "necro_reply";
const icon = iconOf(n);
return (
<button
key={n.id}
onClick={() => handleClick(n)}
className="relative w-full text-left pl-4 pr-3 sm:pr-4 py-2.5 flex items-center gap-3 transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{
borderTop: i === 0 ? "none" : "1px solid var(--line)",
}}
>
{/* 未读:左侧主题色竖条(已读透明占位,文本基线对齐不跳) */}
<span
aria-hidden
className="absolute left-0 top-2 bottom-2 w-[3px] rounded-full"
style={{ background: n.is_read ? "transparent" : "var(--accent)" }}
/>
<span
className="w-[30px] h-[30px] shrink-0 rounded-[9px] flex items-center justify-center"
style={{ color: icon.color, background: icon.bg }}
>
{icon.node}
</span>
<div className="flex-1 min-w-0">
<p className="text-[13.5px] leading-snug flex items-baseline gap-x-1.5" style={{ color: "var(--ink)" }}>
{approval ? (
<span
className="font-bold shrink-0"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
</span>
) : system ? (
<span className="font-bold shrink-0" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link
href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()}
className="font-semibold shrink-0 max-w-[10em] truncate hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>
<span className="meta shrink-0">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: n.type === "mention"
? "在群中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
{n.type === "mention"
? n.room && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</span>
)
: n.post && (
<span className="truncate min-w-0" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</span>
)}
<span className="meta text-xs shrink-0 ml-auto pl-2">{formatTime(n.created_at)}</span>
</p>
{n.content && (
<p className="text-[13px] mt-0.5 truncate" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
</div>
{n.type === "follow" && (
<span
className="font-bold"
style={{ color: n.type === "approved" ? "var(--ok)" : "var(--danger)" }}
className="shrink-0 inline-flex items-center gap-0.5 text-xs font-semibold"
style={{ color: "var(--accent)" }}
>
{n.type === "approved" ? "审核通过" : "未通过审核"}
查看主页 <ChevronRight size={13} />
</span>
) : system ? (
<span className="font-bold" style={{ color: "var(--danger)" }}>
旧帖回复提醒
</span>
) : (
<>
<Link
href={userPath(urlStyle, n.actor.id)}
onClick={(e) => e.stopPropagation()}
className="font-semibold hover:text-[var(--accent)] transition-colors"
>
{n.actor.nickname}
</Link>{" "}
<span className="meta">
{n.type === "comment"
? "评论了你的帖子"
: n.type === "reply"
? "回复了你的评论"
: n.type === "mention"
? "在消息中提到了你"
: n.type === "pending_review"
? "有新内容待审核"
: n.type === "deleted"
? "删除了你的帖子"
: n.type === "badge"
? "向你颁布了徽章"
: n.type === "follow"
? "关注了你"
: "点赞了你的帖子"}
</span>
</>
)}
</p>
{n.type === "mention" ? (
n.room && (
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
# {n.room.name}
</p>
)
) : (
n.post && (
<p className="text-sm mt-1 truncate" style={{ color: "var(--ink-2)" }}>
《{n.post.title}》
</p>
)
)}
{n.content && (
<p className="text-[13px] mt-1.5 line-clamp-2" style={{ color: "var(--ink-3)" }}>
{n.content}
</p>
)}
<p className="meta text-xs mt-2">{formatTime(n.created_at)}</p>
</div>
{!n.is_read && (
<span className="w-2.5 h-2.5 shrink-0 mt-2 rounded-full" style={{ background: "var(--clay)" }} />
)}
</button>
);
})}
</div>
)}
</button>
);
})}
</div>
)}
{children}
</div>
</>
);
}

View File

@@ -30,8 +30,8 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
}
return (
<div className="max-w-2xl mx-auto">
<h1 className="text-[26px] sm:text-[30px] font-extrabold tracking-tight mb-6" style={{ color: "var(--ink)" }}>
<div className="w-full">
<h1 className="text-[22px] sm:text-[26px] font-extrabold tracking-tight mb-4 sm:mb-5" style={{ color: "var(--ink)" }}>
通知中心
</h1>
@@ -42,15 +42,17 @@ export default async function NotificationsPage({ searchParams }: NotificationsP
<Link href="/notifications" className="btn btn-line btn-sm">重新加载</Link>
</div>
) : (
<>
<NotificationsClient initialData={data} />
<Pagination
total={data.total}
page={data.page}
size={data.size}
basePath="/notifications"
/>
</>
<div className="grid gap-4 items-start lg:grid-cols-[248px_minmax(0,1fr)]">
{/* 左栏 sticky 概览 + 全部已读(客户端组件,行内未读数点击后同步刷新);右列列表 + 分页 */}
<NotificationsClient initialData={data} initialUnread={me.unread_count}>
<Pagination
total={data.total}
page={data.page}
size={data.size}
basePath="/notifications"
/>
</NotificationsClient>
</div>
)}
</div>
);

View File

@@ -5,7 +5,7 @@ import { Megaphone } from "lucide-react";
import { fetchSitePageBySlug, fetchOverview } from "@/lib/api";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import { extractToc, estimateReadingMinutes, markdownHeadingToc } from "@/lib/toc";
import MarkdownBody from "@/components/MarkdownBody";
import PostToc from "@/components/PostToc";
@@ -58,7 +58,7 @@ export default async function SitePagePublic({ params }: PageProps) {
const p = data.page;
const sidebarPages = overview?.sidebar_pages ?? [];
const announcements = overview?.announcements ?? [];
const canManagePages = !!me.user && isAdminOrAbove(me.user.role);
const canManagePages = !!me.user && hasPerm(me.user, PERMS.PAGES);
const siteLead = settings.site_description?.trim() || undefined;
const urlStyle = settings.url_style;
const toc = extractToc(p.content);

View File

@@ -18,7 +18,7 @@ import SidebarRenderer, { type SidebarBlockProps } from "@/components/sidebar/Si
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached, getPublicSettingsCached } from "@/lib/serverData";
import { siteDocumentTitle } from "@/lib/brand";
import { isAdminOrAbove } from "@/lib/roles";
import { hasPerm, PERMS } from "@/lib/roles";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
import FeedTabs from "@/components/home/FeedTabs";
@@ -127,8 +127,8 @@ export default async function HomePage({ searchParams }: HomePageProps) {
const sidebar = sidebarData ?? { config: defaultSidebarConfig(), widgets: [] };
const user = me.user;
const boards = overview?.boards ?? [];
const canManageAnnouncements = !!user && isAdminOrAbove(user.role);
const canManagePages = canManageAnnouncements;
const canManageAnnouncements = !!user && hasPerm(user, PERMS.ANNOUNCEMENTS);
const canManagePages = !!user && hasPerm(user, PERMS.PAGES);
const sidebarPages = overview?.sidebar_pages ?? [];
const activeBoard = boards.find((b) => b.id === boardId) || boardSidebar?.board || null;
const showBoardPanels = boardId > 0 && !isSearch;

View File

@@ -8,6 +8,8 @@ import {
MessageCircle,
Flame,
ChevronRight,
Pin,
Star,
} from "lucide-react";
import {
fetchPostDetail,
@@ -358,22 +360,31 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
absoluteCanonical(postPath(urlStyle, post.id), commentPage > 1 ? { page: commentPage } : undefined) ||
entityUrl;
const authorName = post.user.nickname || post.user.username;
// 楼层号 = 跨页连续的主评论序号(含被过滤掉的软删/空占位),故按未过滤索引计算
const visibleFloors: JsonLdVisibleComment[] = canFetchComments
? comments
.filter((c) => !c.deleted && (c.content || "").trim())
.map((c) => ({
id: c.id,
content: c.content,
created_at: c.created_at,
author: c.user
? { id: c.user.id, name: c.user.nickname || c.user.username }
: undefined,
}))
.map((c, i): JsonLdVisibleComment | null =>
!c.deleted && (c.content || "").trim()
? {
id: c.id,
floor: (commentPage - 1) * 20 + i + 1,
is_root: true,
content: c.content,
created_at: c.created_at,
author: c.user
? { id: c.user.id, name: c.user.nickname || c.user.username }
: undefined,
}
: null
)
.filter((x): x is JsonLdVisibleComment => x !== null)
: [];
const acceptedForLd: JsonLdVisibleComment | null =
canFetchComments && acceptedAnswer && !acceptedAnswer.deleted && acceptedAnswer.content?.trim()
? {
id: acceptedAnswer.id,
floor: acceptedAnswer.floor,
is_root: acceptedAnswer.is_root,
content: acceptedAnswer.content,
created_at: acceptedAnswer.created_at,
author: acceptedAnswer.user
@@ -475,15 +486,27 @@ export default async function PostDetailPage({ params, searchParams }: PageProps
<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>}
{post.recommended && <span className="badge-rec shrink-0 mt-2 sm:mt-3">推荐</span>}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
{/* 置顶/推荐与标题包在同一不可折行的 flex item 内:外层 flex-wrap 收集行时只看
已在行内元素的收缩能力,shrink-0 徽章会让超长 h1 整体掉到第二行;内层 min-w-0
使 h1 在徽章旁的剩余空间内折行,徽章始终与标题首行同行(移动端) */}
<div className="flex items-start gap-2 min-w-0">
{post.pinned > 0 && (
<span className="badge-pin shrink-0 mt-2 sm:mt-3" title="置顶" aria-label="置顶">
<Pin size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0 mt-2 sm:mt-3" title="推荐" aria-label="推荐">
<Star size={13} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<h1
className="text-[26px] sm:text-[34px] font-extrabold leading-tight tracking-tight min-w-0"
style={{ color: "var(--ink)" }}
>
{post.title}
</h1>
</div>
{/* 其余徽章紧跟标题右侧;问答不显示类型徽章 */}
{postType !== "discussion" && postType !== "question" && (
<span className="badge-type shrink-0 mt-2 sm:mt-3">{postTypeLabel(postType)}</span>

View File

@@ -1,6 +1,6 @@
import Link from "next/link";
import {
FileText, MessageCircle, AlertCircle, Bookmark, UserPlus,
FileText, MessageCircle, AlertCircle, Bookmark, UserPlus, Pin, Star,
} from "lucide-react";
import Pagination from "@/components/Pagination";
import PostRow from "@/components/PostRow";
@@ -12,7 +12,7 @@ import { formatRelative } from "@/lib/format";
import type {
UserProfile, UserCommentsResponse, UserFavoritesResponse, UserFollowingResponse, PostListItem,
} from "@/lib/api";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
/** 本页卡片统一阴影;用 color-mix 让暗色主题自动加深,避免写死浅色阴影 */
@@ -44,10 +44,11 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
<div className="flex items-start gap-2">
{pinned && (
<span
className="mt-0.5 inline-flex shrink-0 items-center rounded px-1.5 py-px text-[11px] font-semibold leading-[1.5] text-white"
style={{ background: "#f59e0b" }}
className="badge-pin shrink-0 mt-0.5"
title="置顶"
aria-label="置顶"
>
置顶
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<h2
@@ -56,7 +57,11 @@ async function PostCardItem({ post, allowComments }: { post: PostListItem; allow
>
{post.title}
</h2>
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
{post.recommended && (
<span className="badge-rec shrink-0 mt-0.5" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
</div>
<div
className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1.5 text-[12px]"
@@ -313,7 +318,7 @@ export default async function UserProfileContent({
{(commentData.comments ?? []).map((c) => (
<Link
key={c.id}
href={`${postPath(urlStyle, c.post_id)}#comment-${c.id}`}
href={`${postPath(urlStyle, c.post_id)}${commentAnchor(c.floor, c.is_root, c.id)}`}
className="group block px-4 sm:px-5 py-4 border-t transition-colors hover:bg-[var(--item-hover-bg)] focus-visible:bg-[var(--item-hover-bg)]"
style={{ borderColor: "var(--line)" }}
>

View File

@@ -258,9 +258,9 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
/>
</div>
{/* 本人操作按钮 / 访客私聊:绝对定位在卡片右上角 */}
{/* 本人操作按钮 / 访客私聊:移动端独占顶部一行(避免遮挡徽章/签名),sm 起绝对定位右上角 */}
{isOwner ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<div className="relative z-20 flex items-center justify-end gap-2 px-5 sm:px-6 pt-3 sm:absolute sm:top-3 sm:right-3 sm:px-0 sm:pt-0">
<Link href={`${userPath(urlStyle, Number(id))}?tab=settings`} className="btn btn-line btn-sm">
<PenLine size={13.5} />
<span className="hidden sm:inline">编辑资料</span>
@@ -276,7 +276,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</Link>
</div>
) : currentUser ? (
<div className="absolute top-3 right-3 z-20 flex items-center gap-2">
<div className="relative z-20 flex items-center justify-end gap-2 px-5 sm:px-6 pt-3 sm:absolute sm:top-3 sm:right-3 sm:px-0 sm:pt-0">
<FollowButton userId={u.id} following={u.is_following === true} />
<StaffUserMenu
target={{
@@ -295,10 +295,10 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</div>
) : null}
{/* 身份 + 行内统计:垂直居中,高度约 120px */}
{/* 身份 + 行内统计:垂直居中,高度约 120px;移动端按钮已独占一行,无需右侧预留 */}
<div
className={`relative z-10 flex min-h-[120px] items-center gap-3.5 px-5 sm:px-6 py-3.5 ${
hasHeroActions ? "pr-24 sm:pr-32" : ""
hasHeroActions ? "sm:pr-32" : ""
}`}
>
<ProfileHeroAvatar
@@ -374,7 +374,7 @@ export default async function UserPage({ params, searchParams }: UserPageProps)
</p>
{u.signature && (
<p
className="mt-1 text-[13px] leading-snug line-clamp-1"
className="mt-1 text-[13px] leading-snug whitespace-pre-wrap break-words"
style={{ color: "var(--ink-2)" }}
>
{u.signature}