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}

View File

@@ -1,7 +1,7 @@
import Link from "next/link";
import { CheckCircle2, CircleHelp, Coins } from "lucide-react";
import type { AcceptedAnswer } from "@/lib/api";
import { userPath } from "@/lib/urlStyle";
import { userPath, commentAnchor } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
import Avatar from "@/components/Avatar";
import MarkdownBody from "@/components/MarkdownBody";
@@ -74,7 +74,7 @@ export default async function AcceptedAnswerCard({
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
href={commentAnchor(answer.floor, answer.is_root, answer.id)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
@@ -95,7 +95,7 @@ export default async function AcceptedAnswerCard({
<>
<span aria-hidden>·</span>
<a
href={`#comment-${answer.id}`}
href={commentAnchor(answer.floor, answer.is_root, answer.id)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -1,51 +1,92 @@
"use client";
import { useEffect, useRef, useState, type ReactNode } from "react";
import { variantURL } from "@/lib/responsiveImage";
/** 顶部横版槽位的最小门槛:自然宽度低于该值的横版图视为过小,不占顶部布局、归入左栏 */
const MIN_TOP_WIDTH = 600;
/** 由自然宽高判定当前槽位是否该显示(与 measure() 内逻辑保持一致) */
function slotMatches(
orientation: "portrait" | "landscape",
w: number,
h: number
): boolean {
const landscape = w > h;
// 仅"横版且足够大"才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && w >= MIN_TOP_WIDTH;
return orientation === "landscape" ? topWorthy : !topWorthy;
}
/**
* 封面自适应槽位:同一封面在两个位置各放一个槽位(左栏=竖版、顶部全宽=横版),
* 组件用隐藏探测图读取自然宽高,只渲染与自身方向匹配的槽位。
* 竖版槽默认显示(书封多为竖版),探测为横版时两槽自动换位,避免闪空。
* 横版但过小(自然宽度 < MIN_TOP_WIDTH)的图同样只渲染在左栏槽位。
* 组件按方向决定是否渲染自身内容。
*
* 已知尺寸(naturalWidth/naturalHeight > 0)时同步判定,SSR 首帧即正确方向——
* 避免"先竖版占位、hydration 后探测再切横版"的闪烁与布局抖动。
* 尺寸未知(外链/存量未存尺寸)时回退到隐藏探测图运行时测量。
*/
export default function AdaptiveCoverSlot({
src,
orientation,
naturalWidth,
naturalHeight,
children,
}: {
src: string;
orientation: "portrait" | "landscape";
/** 封面自然宽;>0 时跳过探测直接判定 */
naturalWidth?: number;
/** 封面自然高;>0 时跳过探测直接判定 */
naturalHeight?: number;
children: ReactNode;
}) {
const known =
typeof naturalWidth === "number" &&
naturalWidth > 0 &&
typeof naturalHeight === "number" &&
naturalHeight > 0;
const ref = useRef<HTMLImageElement>(null);
const [match, setMatch] = useState(orientation === "portrait");
const measure = (img: HTMLImageElement) => {
if (!img.naturalWidth || !img.naturalHeight) return;
const landscape = img.naturalWidth > img.naturalHeight;
// 仅“横版且足够大”才配得上顶部槽位;过小的横版图随竖版走左栏
const topWorthy = landscape && img.naturalWidth >= MIN_TOP_WIDTH;
setMatch(orientation === "landscape" ? topWorthy : !topWorthy);
};
// 已知尺寸 → 同步初值;未知 → 仍默认竖版显示(书封多为竖版),探测后纠正
const [match, setMatch] = useState(
known ? slotMatches(orientation, naturalWidth!, naturalHeight!) : orientation === "portrait"
);
// 仅未知尺寸时需要探测
useEffect(() => {
if (known) return;
// 缓存图可能在 hydration 前就完成加载而错过 onLoad
if (ref.current?.complete) measure(ref.current);
}, []);
if (ref.current?.complete) {
const img = ref.current;
if (img.naturalWidth && img.naturalHeight) {
setMatch(slotMatches(orientation, img.naturalWidth, img.naturalHeight));
}
}
}, [known, orientation]);
// 已知尺寸时不渲染探测图:省一次请求,且 SSR 输出即稳定
if (known) {
return <>{match ? children : null}</>;
}
return (
<>
{/* 探测只需尺寸信息:取 768 档变体而非原图(600~767 宽的源后端透传原尺寸,
≥768 的源探测宽 768 仍 ≥MIN_TOP_WIDTH,横竖版与门槛判定均不失真) */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
ref={ref}
src={src}
src={variantURL(src, 768)}
alt=""
aria-hidden
className="hidden"
onLoad={(e) => measure(e.currentTarget)}
onLoad={(e) => {
const img = e.currentTarget;
if (img.naturalWidth && img.naturalHeight) {
setMatch(slotMatches(orientation, img.naturalWidth, img.naturalHeight));
}
}}
/>
{match ? children : null}
</>

View File

@@ -1,6 +1,7 @@
"use client";
import { useEffect, useState } from "react";
import { densitySrcSet, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
// 柔和多色头像:按用户名确定性取色,圆形软底,浅/暗主题在 globals.css 各自配色
function hash(s: string) {
@@ -30,6 +31,8 @@ export default function Avatar({
// 切换到新的图片地址时重置失败状态
useEffect(() => setImgFailed(false), [src]);
const showImg = !!src && !imgFailed;
// showImg 为真时 src 必为字符串;统一收敛类型供变体 helper 使用
const imgSrc = src ?? "";
return (
<span
@@ -40,11 +43,15 @@ export default function Avatar({
aria-hidden="true"
>
{showImg ? (
// 源图 ≤512px,按显示尺寸取 1x/2x 变体(PageSpeed:38px 位不再下 512 原图)
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
src={variantURL(imgSrc, pickVariantWidth(size))}
srcSet={densitySrcSet(imgSrc, size)}
alt={name.trim() || "用户头像"}
className="w-full h-full object-cover"
draggable={false}
onContextMenu={(e) => e.preventDefault()}
onError={() => setImgFailed(true)}
/>
) : (

View File

@@ -0,0 +1,327 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import { ChevronRight, Search, X } from "lucide-react";
import Modal from "@/components/Modal";
import { libraryDocReadPath, type UrlStyle } from "@/lib/urlStyle";
import { searchBook, type BookChapterIndex, type BookSnippet } from "@/lib/bookSearch";
/** 阅读页正文章节容器 id(read/page.tsx 的 <article>),高亮作用域 */
const ARTICLE_ID = "j13-book-article";
/** 关键词长度上限(避免超长输入卡顿) */
const MAX_QUERY = 64;
/** CSS Custom Highlight 注册名(对应 globals.css 的 ::highlight(...)) */
const HL_ALL = "j13-find";
const HL_CURRENT = "j13-find-current";
/** 特性检测:CSS Custom Highlight API(Chrome105+/Safari17.2+/FF140+),不支持时只跳转不高亮 */
function highlightsSupported(): boolean {
return (
typeof window !== "undefined" &&
typeof window.Highlight !== "undefined" &&
typeof CSS !== "undefined" &&
!!CSS.highlights
);
}
function clearHighlights(): void {
if (!highlightsSupported()) return;
CSS.highlights!.delete(HL_ALL);
CSS.highlights!.delete(HL_CURRENT);
}
/**
* 运行时注入高亮样式:构建器的 CSS 压缩器不识别 ::highlight() 会丢规则,
* 且 highlight 伪元素不继承自定义属性,这里从 :root 读出 --accent/--accent-on
* 的实际色值拼规则。hex 转带透明度的 rgba;非 hex 值原样使用。
*/
let highlightStyleInjected = false;
function ensureHighlightStyle(): void {
if (highlightStyleInjected || !highlightsSupported()) return;
const css = getComputedStyle(document.documentElement);
const accent = (css.getPropertyValue("--accent") || "").trim();
const accentOn = (css.getPropertyValue("--accent-on") || "").trim() || "#fff";
let h = accent;
if (h.length === 4) h = "#" + h.slice(1).split("").map((c) => c + c).join("");
const soft =
/^#[0-9a-f]{6}$/i.test(h)
? `rgba(${parseInt(h.slice(1, 3), 16)},${parseInt(h.slice(3, 5), 16)},${parseInt(h.slice(5, 7), 16)},0.24)`
: accent;
const style = document.createElement("style");
style.dataset.j13 = "find-highlight";
style.textContent =
`::highlight(${HL_ALL}){background-color:${soft}}` +
`::highlight(${HL_CURRENT}){background-color:${accent};color:${accentOn}}`;
document.head.appendChild(style);
highlightStyleInjected = true;
}
/**
* 收集正文内命中 Range:按文本节点逐个匹配(大小写不敏感子串),不改 DOM。
* 用 Range 而非包 <mark>:正文是 server 组件渲染的,直接替换文本节点会让
* React 在章节切换后的原地复用错位;Range 随章节重建自然失效,无副作用。
* 跨标签边界的词匹配不到——可接受,绝大多数中文命中落在同一文本节点。
*/
function collectRanges(root: HTMLElement, query: string): Range[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const targets: Text[] = [];
const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, {
acceptNode(node) {
const el = node.parentElement;
if (!el || !node.nodeValue) return NodeFilter.FILTER_REJECT;
const tag = el.tagName;
if (tag === "SCRIPT" || tag === "STYLE" || tag === "NOSCRIPT") {
return NodeFilter.FILTER_REJECT;
}
return node.nodeValue.toLowerCase().includes(q)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT;
},
});
let n: Node | null;
while ((n = walker.nextNode())) targets.push(n as Text);
const ranges: Range[] = [];
for (const textNode of targets) {
const tv = textNode.nodeValue ?? "";
const low = tv.toLowerCase();
let from = 0;
for (;;) {
const idx = low.indexOf(q, from);
if (idx < 0) break;
const range = document.createRange();
range.setStart(textNode, idx);
range.setEnd(textNode, idx + q.length);
ranges.push(range);
from = idx + q.length;
}
}
return ranges;
}
/** 注册高亮(全部命中 + 当前命中两组);返回文档序命中 Range 列表 */
function paintRanges(query: string, currentOrdinal: number): Range[] {
const root = document.getElementById(ARTICLE_ID);
if (!root) return [];
const ranges = collectRanges(root, query);
if (!highlightsSupported()) return ranges;
ensureHighlightStyle();
clearHighlights();
if (ranges.length > 0) {
const all = new Highlight(...ranges);
const cur = new Highlight();
const curRange = ranges[Math.min(Math.max(currentOrdinal, 0), ranges.length - 1)];
if (curRange) cur.add(curRange);
CSS.highlights!.set(HL_ALL, all);
CSS.highlights!.set(HL_CURRENT, cur);
}
return ranges;
}
/** 滚动到命中 Range(视口垂直居中,留出吸顶头空间) */
function scrollToRange(range: Range, smooth: boolean): void {
const rect = range.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return;
const top =
window.scrollY + rect.top - window.innerHeight / 2 + (rect.height || 16) / 2;
window.scrollTo({ top: Math.max(0, top), behavior: smooth ? "smooth" : "auto" });
}
/** 重新高亮当前章并定位到指定命中(ordinal 为章内 0 基命中序号) */
function highlightAndScroll(query: string, ordinal: number, smooth: boolean): void {
const ranges = paintRanges(query, ordinal);
const target = ranges[Math.min(Math.max(ordinal, 0), ranges.length - 1)];
if (!target) return;
// 双层 rAF:等章节切换后的布局与图片占位稳定再定位
requestAnimationFrame(() =>
requestAnimationFrame(() => scrollToRange(target, smooth))
);
}
function SnippetText({ snip }: { snip: BookSnippet }) {
return (
<span className="leading-relaxed">
{snip.before ? <span className="meta">…{snip.before}</span> : null}
<mark className="j13-find-mark">{snip.match}</mark>
{snip.after ? <span className="meta">{snip.after}…</span> : null}
</span>
);
}
export interface BookSearchProps {
slug: string;
urlStyle: UrlStyle;
/** 当前章序号(1 基) */
currentNo: number;
chapters: BookChapterIndex[];
}
export default function BookSearch({ slug, urlStyle, currentNo, chapters }: BookSearchProps) {
const router = useRouter();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
/**
* 跨章跳转待执行的高亮。组件跨章导航不重挂载(位置稳定),query 状态保留,
* 仅在 currentNo 变化、新章 DOM 提交后执行一次,因此无需把关键词写进 URL。
*/
const pendingRef = useRef<{ q: string; ordinal: number } | null>(null);
const trimmed = query.trim();
const results = useMemo(() => searchBook(chapters, trimmed), [chapters, trimmed]);
const totalHits = useMemo(() => results.reduce((acc, h) => acc + h.count, 0), [results]);
useEffect(() => {
const pending = pendingRef.current;
if (!pending) return;
pendingRef.current = null;
highlightAndScroll(pending.q, pending.ordinal, false);
}, [currentNo]);
// 关键词清空或离开读页时撤掉正文高亮
useEffect(() => {
if (!trimmed) clearHighlights();
}, [trimmed]);
useEffect(() => () => clearHighlights(), []);
const focusHit = (no: number, ordinal: number) => {
if (no === currentNo) {
highlightAndScroll(trimmed, ordinal, true);
setOpen(false);
return;
}
pendingRef.current = { q: trimmed, ordinal };
setOpen(false);
router.push(libraryDocReadPath(urlStyle, slug, no));
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && results[0]) {
const first = results[0];
focusHit(first.no, first.snippets[0]?.ordinal ?? 0);
}
};
return (
<>
<button
type="button"
onClick={() => {
setOpen(true);
requestAnimationFrame(() => inputRef.current?.focus());
}}
className="inline-flex items-center gap-2 rounded-full pl-3.5 pr-4 py-2 text-[13px] transition-colors hover:bg-[var(--panel-2)] hover:border-[color-mix(in_srgb,var(--accent)_40%,var(--line))] shrink-0 w-36 sm:w-52"
style={{ color: "var(--ink-3)", border: "1px solid var(--line)", background: "var(--panel)" }}
aria-label="搜索本书"
>
<Search size={14} className="shrink-0" aria-hidden />
<span className="truncate text-left">搜索本书</span>
</button>
<Modal
open={open}
onClose={() => setOpen(false)}
title="搜索本书"
description={`共 ${chapters.length} 章 · 命中后可直接跳到对应位置`}
maxWidthClass="max-w-lg"
panelClassName="p-5 sm:p-6"
>
<div className="relative">
<Search
size={15}
className="absolute left-3.5 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
ref={inputRef}
type="text"
value={query}
maxLength={MAX_QUERY}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={onKeyDown}
placeholder="搜索章节标题与正文"
aria-label="搜索章节标题与正文"
className="j13-search-field w-full rounded-xl pl-10 pr-9 py-2.5 text-[13.5px]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除关键词"
className="absolute right-3 top-1/2 -translate-y-1/2 w-6 h-6 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={13} />
</button>
) : null}
</div>
<div className="mt-3 max-h-[56vh] overflow-y-auto j13-rail-scroll -mx-1 px-1">
{!trimmed ? (
<p className="meta text-[12.5px] py-8 text-center">
输入关键词,按章查看命中片段,点击即可跳转
</p>
) : totalHits === 0 ? (
<p className="meta text-[12.5px] py-8 text-center">全书没有找到「{trimmed}」</p>
) : (
<div className="flex flex-col gap-2">
<p className="meta text-[11.5px] px-1">
{results.length} 章命中 · 共 {totalHits} 处
</p>
{results.map((hit) => {
const isCurrent = hit.no === currentNo;
return (
<section
key={hit.no}
className="rounded-xl overflow-hidden"
style={{ border: "1px solid var(--line)" }}
>
<button
type="button"
onClick={() => focusHit(hit.no, 0)}
className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-[var(--panel-2)] transition-colors"
>
<span
className="text-[12.5px] font-bold truncate min-w-0"
style={{ color: "var(--ink)" }}
>
第 {hit.no} 章 · {hit.title}
</span>
{isCurrent ? (
<span
className="shrink-0 text-[10px] font-semibold px-1.5 py-0.5 rounded"
style={{ color: "var(--accent)", background: "var(--accent-soft)" }}
>
本章
</span>
) : null}
<span className="meta text-[11px] ml-auto shrink-0 inline-flex items-center gap-0.5">
{hit.count} 处
<ChevronRight size={12} aria-hidden />
</span>
</button>
{hit.snippets.map((snip) => (
<button
key={snip.ordinal}
type="button"
onClick={() => focusHit(hit.no, snip.ordinal)}
className="block w-full text-left px-3 py-1.5 text-[12.5px] border-t border-[var(--line)] hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-2)" }}
>
<SnippetText snip={snip} />
</button>
))}
</section>
);
})}
</div>
)}
</div>
</Modal>
</>
);
}

View File

@@ -1,4 +1,5 @@
import { brandLogoBox, type BrandLogoFit, type BrandLogoSize, type BrandMark } from "@/lib/brand";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
/** 页眉品牌锁:图加文字 / 纯图 / 纯文字。图槽按画幅占位,铺法决定图怎么填进框。 */
export default function BrandLockup({
@@ -45,11 +46,11 @@ export default function BrandLockup({
{src ? (
both ? (
<>
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" />
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={Math.max(box.w, box.h)} />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={Math.max(box.w, box.h)} />
</>
) : (
<Logo src={src} fit={fit} alt={label} decorative={showText} />
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={Math.max(box.w, box.h)} />
)
) : (
<Bubble h={box.h} label={showText ? undefined : label} />
@@ -81,6 +82,7 @@ function Logo({
alt,
decorative = false,
extra = "",
cssPx,
}: {
src: string;
fit: BrandLogoFit;
@@ -88,6 +90,8 @@ function Logo({
/** 旁边已有站名时,读屏跳过,HTML 仍保留 alt 供检索 */
decorative?: boolean;
extra?: string;
/** 图槽最大边 CSS px,用于密度变体 */
cssPx: number;
}) {
if (fit === "repeat") {
return (
@@ -100,9 +104,17 @@ function Logo({
/>
);
}
// 本站光栅图走 1x/2x 变体(SVG/ICO 后端透传);外链图保持原地址
const own = isOwnImage(src);
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={src} alt={alt} aria-hidden={decorative || undefined} className={`j13-brand-logo ${extra}`} />
<img
src={own ? variantURL(src, pickVariantWidth(cssPx)) : src}
srcSet={own ? densitySrcSet(src, cssPx) : undefined}
alt={alt}
aria-hidden={decorative || undefined}
className={`j13-brand-logo ${extra}`}
/>
);
}

View File

@@ -16,11 +16,26 @@ import { userPath } from "@/lib/urlStyle";
import { apiGetUserProfile, apiOpenDirectChat, type UserProfile, type User } from "@/lib/api";
import { toast } from "@/lib/toast";
/** 计算禁言剩余时间,返回「x天x小时x分钟」格式 */
function formatMuteRemain(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} 天`;
if (hours > 0) return `${hours} 小时`;
return `${minutes} 分钟`;
}
export type ChatUserCardTarget = {
user: User;
/** 群内身份(可从成员表查到时携带) */
roomRole?: "owner" | "admin" | "member";
muted?: boolean;
/** 禁言截止时间(ISO 字符串);null/缺省表示永久 */
mutedUntil?: string | null;
};
/**
@@ -149,6 +164,9 @@ export default function ChatUserCard({
style={{ color: "var(--ink-3)" }}
>
<VolumeX size={12} /> 禁言中
{target.mutedUntil && (
<span>· {formatMuteRemain(target.mutedUntil)}后解除</span>
)}
</p>
)}
</div>

View File

@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { Fragment, useEffect, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import {
@@ -40,8 +40,7 @@ import ConfirmDialog from "@/components/ConfirmDialog";
import DeleteCommentModal, { type DeleteCommentPayload } from "@/components/DeleteCommentModal";
import EditHistoryModal from "@/components/EditHistoryModal";
import { scrollFlashTo } from "@/lib/flash";
import { fetchCommentLocation } from "@/lib/api";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
interface CommentSectionProps {
@@ -194,7 +193,9 @@ function CommentMoreMenu({ items }: { items: MoreItem[] }) {
function focusCommentControl(id: number, kind: "reply" | "more") {
window.requestAnimationFrame(() => {
document.querySelector<HTMLElement>(`#comment-${id} [data-cmt-focus="${kind}"]`)?.focus();
document
.querySelector<HTMLElement>(`[data-comment-id="${id}"] [data-cmt-focus="${kind}"]`)
?.focus();
});
}
@@ -293,26 +294,44 @@ export default function CommentSection({
return () => window.removeEventListener("hashchange", focusComposer);
}, [user, commentsOpen]);
// 深链定位:#comment-{id} → 滚动 + 主题色浅底高亮渐隐。
// 目标是折叠楼层里的子回复时先展开所属楼层;不在当前页时查楼层号换页后重试。
// 深链定位:#comment-{floor}(主楼)或 #comment-{floor}-r{cid}(楼中楼)。
// 锚点数字是帖内相对楼层号,直接决定目标分页;目标是折叠楼层里的回复时先展开;
// 楼层号锚点(#楼)、「回复 @xx」、采纳答案卡等页内跳转经 hashchange 同样获得高亮。
const locatedRef = useRef<number | null>(null); // 已跨页尝试过的评论 ID,防循环
const attemptedHashRef = useRef<string | null>(null); // 已跨页尝试过的锚点,防循环
useEffect(() => {
if (!commentsOpen) return;
let cancelled = false;
const locate = async () => {
const m = window.location.hash.match(/^#comment-(\d+)$/);
const locate = () => {
const m = window.location.hash.match(/^#comment-(\d+)(?:-r(\d+))?$/);
if (!m) return;
const id = Number(m[1]);
const el = document.getElementById(`comment-${id}`);
if (el) {
scrollFlashTo(el);
const floor = Number(m[1]);
const replyId = m[2] ? Number(m[2]) : 0;
if (floor > total) return; // 楼层不存在(异常/过期链接),停留在当前页
const destPage = Math.max(1, Math.ceil(floor / size));
const hash = window.location.hash;
if (destPage !== page) {
// 不在目标页:带锚点换页,comments 刷新后本 effect 重新运行完成定位
if (attemptedHashRef.current === hash) return;
attemptedHashRef.current = hash;
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}${hash}`, {
scroll: false,
});
return;
}
// 不在 DOM:先看是否在本页数据里(折叠的子回复)→ 展开所属楼层后定位
const node = findComment(comments, id);
if (node) {
const rootId = node.root_id ?? node.parent_id ?? node.id;
const flashTarget = () => {
const el = replyId
? document.querySelector<HTMLElement>(`[data-comment-id="${replyId}"]`)
: document.getElementById(`comment-${floor}`);
if (el) scrollFlashTo(el);
};
if (!replyId) {
flashTarget();
return;
}
// 楼中楼:先展开所属楼层(折叠态不渲染回复节点),再滚动高亮
const node = findComment(comments, replyId);
const rootId = node?.root_id ?? node?.parent_id ?? 0;
if (node && rootId) {
setExpandedFloors((prev) => {
if (prev.has(rootId)) return prev;
const next = new Set(prev);
@@ -320,21 +339,10 @@ export default function CommentSection({
return next;
});
window.setTimeout(() => {
if (!cancelled) scrollFlashTo(document.getElementById(`comment-${id}`));
if (!cancelled) flashTarget();
}, 160);
return;
}
// 不在本页:查楼层号 → 换页(comments 变化后本 effect 再次运行并定位)
if (locatedRef.current === id) return; // 已尝试仍找不到(如被拒/未公开),放弃
locatedRef.current = id;
try {
const floor = await fetchCommentLocation(postId, id);
if (cancelled || floor <= 0) return;
const destPage = Math.max(1, Math.ceil(floor / size));
if (destPage === page) return; // 同页却无节点(异常数据),放弃
router.replace(`${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${id}`, { scroll: false });
} catch {
/* 定位失败静默:停留在当前页 */
} else {
flashTarget();
}
};
locate();
@@ -344,7 +352,7 @@ export default function CommentSection({
window.removeEventListener("hashchange", locate);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [comments, commentsOpen, page, postId, size]);
}, [comments, commentsOpen, page, postId, size, total]);
const submitFloor = async () => {
if (!commentsOpen || !content.trim() || submitLock.current || floorUploading) return;
@@ -360,10 +368,11 @@ export default function CommentSection({
toast("已提交,等待审核通过后公开");
}
if (destPage !== page) {
const anchor = commentAnchor(total + 1, true);
setAwayHref(
destPage > 1
? `${postPath(urlStyle, Number(postId))}?page=${destPage}#comment-${res.comment.id}`
: `${postPath(urlStyle, Number(postId))}#comment-${res.comment.id}`
? `${postPath(urlStyle, Number(postId))}?page=${destPage}${anchor}`
: `${postPath(urlStyle, Number(postId))}${anchor}`
);
} else {
setAwayHref(null);
@@ -853,9 +862,9 @@ export default function CommentSection({
const totalPages = Math.max(1, Math.ceil(total / size));
// 内联回复表单(出现在目标评论正下方;全站关评时不渲染)
// 内联回复表单:渲染在所属楼层块外侧(见下方列表 map),任意回复层级均全宽;全站关评时不渲染
const inlineReplyForm = commentsOpen && replyTarget && (
<form onSubmit={handleSubmitReply} className="cmt-reply-form mt-2">
<form onSubmit={handleSubmitReply} className="cmt-reply-form">
<div className="flex items-center justify-between gap-2 mb-2 text-[13px]" style={{ color: "var(--ink-2)" }}>
<span>
回复 <span style={{ color: "var(--accent)", fontWeight: 600 }}>@{replyTarget.nickname}</span>
@@ -1016,7 +1025,12 @@ export default function CommentSection({
if (!isFloor) {
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}-r${c.id}` : undefined}
data-comment-id={c.id}
className="cmt-reply-row scroll-mt-24"
>
<div className="flex gap-3">
{identity}
<div className="flex-1 min-w-0">
@@ -1040,7 +1054,12 @@ export default function CommentSection({
}
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-item scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}` : undefined}
data-comment-id={c.id}
className="cmt-item scroll-mt-24"
>
<div className="flex gap-3">
{identity}
<div className="flex-1 min-w-0">
@@ -1050,7 +1069,7 @@ export default function CommentSection({
{timeMeta(c)}
</div>
{floor ? (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
<a href={`#comment-${floor}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
) : null}
@@ -1088,7 +1107,12 @@ export default function CommentSection({
const isReplyToReply = depth >= 2;
const isOp = (author?.id ?? c.user_id) === authorId;
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-reply-row scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}-r${c.id}` : undefined}
data-comment-id={c.id}
className="cmt-reply-row scroll-mt-24"
>
<div className="flex gap-3">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
@@ -1120,7 +1144,7 @@ export default function CommentSection({
{timeMeta(c)}
{editedMark(c)}
{isReplyToReply && parent && (
<a href={`#comment-${parent.id}`} className="cmt-reply-target">
<a href={`#comment-${floor}-r${parent.id}`} className="cmt-reply-target">
回复 {parentAuthor}
</a>
)}
@@ -1174,7 +1198,6 @@ export default function CommentSection({
)}
<CommentMoreMenu items={moreItemsFor(c)} />
</div>
{replyTarget?.id === c.id && inlineReplyForm}
</div>
</div>
{node.replies && node.replies.length > 0 && (
@@ -1189,7 +1212,12 @@ export default function CommentSection({
// 主评论(楼层)
const isOp = (author?.id ?? c.user_id) === authorId;
return (
<div key={c.id} id={`comment-${c.id}`} className="cmt-item scroll-mt-24">
<div
key={c.id}
id={floor ? `comment-${floor}` : undefined}
data-comment-id={c.id}
className="cmt-item scroll-mt-24"
>
<div className="flex gap-3">
{author ? (
<Link href={userPath(urlStyle, author.id)} className="shrink-0" aria-label={authorName}>
@@ -1223,7 +1251,7 @@ export default function CommentSection({
{editedMark(c)}
</div>
{floor ? (
<a href={`#comment-${c.id}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
<a href={`#comment-${floor}`} className="cmt-floor-label" aria-label={`楼层 ${floor}`}>
#{floor}
</a>
) : null}
@@ -1276,7 +1304,6 @@ export default function CommentSection({
)}
<CommentMoreMenu items={moreItemsFor(c)} />
</div>
{replyTarget?.id === c.id && inlineReplyForm}
{node.replies && node.replies.length > 0 && (
<div className="mt-3">
<button
@@ -1396,7 +1423,14 @@ export default function CommentSection({
)}
<div>
{comments.map((node, i) => renderComment(node, 0, (page - 1) * size + i + 1))}
{/* 回复框统一渲染在所属楼层块外侧:无论回复楼层还是任意深度子回复,
都按全宽显示,不受头像列与嵌套缩进挤压 */}
{comments.map((node, i) => (
<Fragment key={node.id}>
{renderComment(node, 0, (page - 1) * size + i + 1)}
{replyTarget?.rootId === node.id ? inlineReplyForm : null}
</Fragment>
))}
{comments.length === 0 && (
<div className="text-center py-14">
<MessagesSquare size={28} className="mx-auto mb-3" style={{ color: "var(--ink-3)" }} />

View File

@@ -24,7 +24,7 @@ import {
import { apiLogout, apiMe, apiChatUnreadSummary, type User } from "@/lib/api";
import { hasAuthCookieHint } from "@/lib/cookies";
import { userPath } from "@/lib/urlStyle";
import { canAccessAdminMessages, isStaff } from "@/lib/roles";
import { canAccessAdminMessages, hasAnyChannelPerm, isStaff } from "@/lib/roles";
import { onUserUpdate, onForceLogout } from "@/lib/userEvents";
import { realtime, RT_CHAT_UNREAD } from "@/lib/realtime";
import type { RtChatUnreadData } from "@/lib/realtime";
@@ -598,7 +598,7 @@ function UserMenu({ user, onLogout }: { user: User; onLogout: () => void }) {
<Settings size={15} style={{ color: "var(--ink-3)" }} /> 账号设置
</Link>
</div>
{(isStaff(user.role) || canAccessAdminMessages(user)) && (
{(isStaff(user.role) || canAccessAdminMessages(user) || hasAnyChannelPerm(user)) && (
<div className="p-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" onClick={() => setOpen(false)} className={itemCls} role="menuitem">
<ShieldCheck size={15} style={{ color: "var(--accent)" }} /> 管理面板

View File

@@ -4,6 +4,7 @@ import { useEffect, useState } from "react";
import { Images, Link2, Loader2, Upload } from "lucide-react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
type InsertTab = "upload" | "url" | "media";
@@ -212,7 +213,9 @@ export default function ImageInsertModal({
>
{/* 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="媒体库图片"
className="w-full h-full object-contain"
loading="lazy"

View File

@@ -0,0 +1,241 @@
"use client";
import { useMemo, useState, type CSSProperties } from "react";
import Link from "next/link";
import { FileDown, PenLine, Search, X } from "lucide-react";
import type { LibraryDocListItem } from "@/lib/api";
import { coverHue } from "@/lib/coverColor";
import { libraryDocPath, type UrlStyle } from "@/lib/urlStyle";
import { variantURL, widthSrcSet } from "@/lib/responsiveImage";
import { formatRelative } from "@/lib/format";
import Avatar from "@/components/Avatar";
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`;
}
/** 简介 markdown → 纯文本摘要(去标记、压平空白,卡片两行截断用) */
function plainExcerpt(md: string): string {
return md
.replace(/!\[[^\]]*\]\([^)]*\)/g, "") // 图片
.replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") // 链接保留文字
.replace(/[*_~`#>]+/g, "") // 行内/块级标记
.replace(/\s+/g, " ")
.trim();
}
/** 文字封面:无图时用完整书名 + 书名派生稳定色相洗底(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-4 select-none"
style={{ "--cover-h": coverHue(name) } as CSSProperties}
aria-hidden
>
<span className="j13-library-title text-[19px] font-bold leading-snug text-center line-clamp-4 break-all tracking-wide">
{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);
const creatorName = doc.creator ? doc.creator.nickname || doc.creator.username : "";
const excerpt = plainExcerpt(doc.description ?? "");
return (
<Link href={href} className="group flex flex-col min-w-0">
{/* 竖版书形封面:书脊光影 + 悬停浮出简介 */}
<div
className="relative aspect-[3/4] overflow-hidden rounded-[10px] shadow-[var(--shadow-sm)] transition-[transform,box-shadow] duration-200 group-hover:-translate-y-1.5 group-hover:shadow-[var(--shadow-lg)]"
style={{ background: "var(--panel-2)" }}
>
{doc.cover_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={variantURL(doc.cover_url, 384)}
srcSet={widthSrcSet(doc.cover_url, [384, 512, 768])}
sizes="(max-width:640px) 46vw, (max-width:1024px) 30vw, (max-width:1280px) 22vw, 220px"
alt={doc.title}
className="w-full h-full object-cover transition-transform duration-300 group-hover:scale-[1.04]"
loading="lazy"
decoding="async"
draggable={false}
/>
) : (
<TextCover title={doc.title} />
)}
{/* 书脊/书口光影,营造实体书体积感 */}
<div
aria-hidden
className="absolute inset-0 rounded-[10px] pointer-events-none"
style={{
background:
"linear-gradient(90deg, rgba(0,0,0,0.16) 0%, rgba(0,0,0,0.05) 2.5%, transparent 5%, transparent 96%, rgba(0,0,0,0.05) 98.5%, rgba(0,0,0,0.1) 100%)",
boxShadow: "inset 0 0 0 1px rgba(0,0,0,0.06)",
}}
/>
{excerpt ? (
<div
className="absolute inset-0 flex items-end p-3.5 opacity-0 group-hover:opacity-100 transition-opacity duration-200 pointer-events-none"
style={{
background:
"linear-gradient(to top, rgba(18,20,26,0.92) 0%, rgba(18,20,26,0.55) 55%, rgba(18,20,26,0.15) 100%)",
}}
>
<p className="text-[12px] leading-relaxed text-white/90 line-clamp-5">{excerpt}</p>
</div>
) : null}
</div>
{/* 封面下方书目信息:衬线书名 + 作者 + 文件概要 */}
<div className="pt-3 flex flex-col gap-1 min-w-0">
<h2
className="j13-library-title text-[15px] font-bold leading-snug line-clamp-2"
style={{ color: "var(--ink)" }}
>
{doc.title}
</h2>
{doc.author ? (
<p className="meta flex items-center gap-1.5 text-[12px] min-w-0">
<PenLine size={11} className="shrink-0" style={{ color: "var(--accent)" }} aria-hidden />
<span className="truncate" title={doc.author}>
{doc.author}
</span>
</p>
) : null}
<div className="mt-1 flex items-center gap-1.5 min-w-0">
{exts.map((ext) => (
<ExtChip key={ext} ext={ext} />
))}
<span className="meta text-[11px] ml-auto inline-flex items-center gap-1 shrink-0">
<FileDown size={11} />
{doc.file_count > 0 ? `${doc.file_count} 个文件 · ${formatSize(totalSize)}` : "暂无文件"}
</span>
</div>
<div className="flex items-center gap-1.5 min-w-0">
{doc.creator ? (
<span className="flex items-center gap-1.5 min-w-0 shrink">
<Avatar size={16} name={creatorName} src={doc.creator.avatar} />
<span className="meta text-[11px] truncate max-w-[6rem]" title={creatorName}>
{creatorName}
</span>
</span>
) : null}
<span className="meta text-[11px] truncate shrink min-w-0">
{doc.creator ? "· " : ""}
{formatRelative(doc.created_at)}
</span>
</div>
</div>
</Link>
);
}
/**
* 书库目录:书架式竖版封面网格 + 书名/作者即时过滤(目录接口一次返回全部已发布
* 条目,纯客户端过滤;上限与后端 ListPublished 的 100 条一致)。
*/
export default function LibrarySearchGrid({
docs,
urlStyle,
}: {
docs: LibraryDocListItem[];
urlStyle: UrlStyle;
}) {
const [query, setQuery] = useState("");
const q = query.trim().toLowerCase();
const filtered = useMemo(() => {
if (!q) return docs;
return docs.filter(
(d) => d.title.toLowerCase().includes(q) || d.author.toLowerCase().includes(q)
);
}, [docs, q]);
if (docs.length === 0) {
return (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[15px] font-medium" style={{ color: "var(--ink-2)" }}>
书库还没有内容
</p>
<p className="meta text-[13px] mt-2">管理员可在后台「书库管理」发布条目</p>
</div>
);
}
return (
<div className="mt-2">
{/* 搜索:居中宽框,与大气 Hero 呼应 */}
<div className="relative max-w-lg mx-auto">
<Search
size={16}
className="absolute left-4 top-1/2 -translate-y-1/2 pointer-events-none"
style={{ color: "var(--ink-3)" }}
aria-hidden
/>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索书名或作者"
aria-label="搜索书名或作者"
className="j13-search-field w-full rounded-full pl-11 pr-10 py-3 text-[14px] shadow-[var(--shadow-sm)]"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
/>
{query ? (
<button
type="button"
onClick={() => setQuery("")}
aria-label="清除搜索"
className="absolute right-3 top-1/2 -translate-y-1/2 w-7 h-7 flex items-center justify-center rounded-full hover:bg-[var(--panel-2)] transition-colors"
style={{ color: "var(--ink-3)" }}
>
<X size={14} />
</button>
) : null}
</div>
{filtered.length === 0 ? (
<div
className="mt-8 rounded-[24px] px-6 py-16 text-center"
style={{ background: "var(--panel)", border: "1px solid var(--line)" }}
>
<p className="text-[14px] font-medium" style={{ color: "var(--ink-2)" }}>
没有找到与「{query.trim()}」相关的书籍
</p>
<p className="meta text-[12.5px] mt-2">换个书名或作者关键词试试</p>
</div>
) : (
<div className="mt-8 grid gap-x-5 gap-y-9 grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-6">
{filtered.map((d) => (
<DocCard key={d.id} doc={d} href={libraryDocPath(urlStyle, d.slug)} />
))}
</div>
)}
</div>
);
}

View File

@@ -2,29 +2,43 @@
import { useState } from "react";
import ImageLightbox from "@/components/markdown/ImageLightbox";
import { variantURL, widthSrcSet } from "@/lib/responsiveImage";
/**
* 静态图片图箱入口(区别于 markdown 管线的 MdImage):
* 封面等非 markdown 图片点击进全屏图箱(单图,无多图切换)。
* 在 Link 内使用时 preventDefault + stopPropagation 阻断外层导航——
* 点图片本身是看图,点卡片其余区域仍走原链接。
*
* 可变宽度位可传 variantWidths(白名单档位)+ sizes:页面内显示变体,
* 图箱始终打开 data-full 指向的原图。
*/
export default function LightboxImage({
src,
alt,
className,
variantWidths,
sizes,
}: {
src: string;
alt: string;
className?: string;
/** 传入白名单档位则输出宽度 srcset;不传则直连原图 */
variantWidths?: readonly number[];
/** 配合 srcset 的 sizes 提示 */
sizes?: string;
}) {
const [open, setOpen] = useState(false);
const hasVariants = !!variantWidths && variantWidths.length > 0;
return (
<>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={src}
src={hasVariants ? variantURL(src, variantWidths[0]) : src}
srcSet={hasVariants ? widthSrcSet(src, variantWidths) : undefined}
sizes={hasVariants ? sizes : undefined}
alt={alt}
data-full={src}
className={`cursor-zoom-in${className ? ` ${className}` : ""}`}
loading="lazy"
decoding="async"

View File

@@ -83,6 +83,8 @@ type MarkdownEditorProps = {
onUploadingChange?: (uploading: boolean) => void;
/** 书级「全章节条目卡模式」:插入免壳模板、预览按隐式条目渲染 */
entriesAuto?: boolean;
/** 图片上传时标记的 source(如 library_content),默认 post */
uploadSource?: string;
};
type EditorMode = "write" | "preview";
@@ -164,6 +166,7 @@ export default function MarkdownEditor({
error,
onUploadingChange,
entriesAuto = false,
uploadSource,
}: MarkdownEditorProps) {
const taRef = useRef<HTMLTextAreaElement>(null);
const urlStyle = useUrlStyle();
@@ -466,7 +469,7 @@ export default function MarkdownEditor({
}
setUploading(true);
try {
const res = await apiUploadPostImage(file);
const res = await apiUploadPostImage(file, undefined, uploadSource);
if (!res.url) {
const msg = res.error || "上传失败";
setUploadError(msg);
@@ -1268,12 +1271,13 @@ export default function MarkdownEditor({
</div>
) : null}
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="j13-md-toolbar-end flex items-center gap-2 shrink-0">
<div className="j13-md-mode" role="tablist" aria-label="编辑模式">
<button
type="button"
role="tab"
aria-selected={mode === "write"}
aria-label="编写模式"
className={mode === "write" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
@@ -1281,12 +1285,13 @@ export default function MarkdownEditor({
}}
onClick={() => setMode("write")}
>
<Pencil size={12} /> 编写
<Pencil size={12} /> <span className="j13-md-mode-text">编写</span>
</button>
<button
type="button"
role="tab"
aria-selected={mode === "preview"}
aria-label="预览模式"
className={mode === "preview" ? "is-on" : ""}
onMouseDown={(e) => {
e.preventDefault();
@@ -1294,7 +1299,7 @@ export default function MarkdownEditor({
}}
onClick={() => setMode("preview")}
>
<Eye size={12} /> 预览
<Eye size={12} /> <span className="j13-md-mode-text">预览</span>
</button>
</div>
{!toolNarrow && (

View File

@@ -14,7 +14,7 @@ import {
import { realtime, RT_NOTIFICATION_NEW } from "@/lib/realtime";
import { onDocumentVisible } from "@/lib/visibilityReconcile";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
export default function NotificationBell({ initialUnread = 0 }: { initialUnread?: number }) {
const router = useRouter();
@@ -111,9 +111,11 @@ export default function NotificationBell({ initialUnread = 0 }: { initialUnread?
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));
}

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { Pin } from "lucide-react";
import { Pin, Star } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { postPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
@@ -71,7 +71,16 @@ export default async function PinnedPosts({
<span className="truncate">{p.board.name}</span>
</>
)}
{p.recommended && <span className="badge-rec !text-[10.5px] !px-1.5 !py-0 shrink-0">推荐</span>}
{p.recommended && (
<span
className="badge-rec shrink-0"
style={{ width: 16, height: 16 }}
title="推荐"
aria-label="推荐"
>
<Star size={10} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
</div>
</div>
<div className="hidden sm:flex items-center gap-3 shrink-0 meta text-[12.5px]">

View File

@@ -18,7 +18,7 @@ import {
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
import { useAllowComments } from "@/components/CommentsPolicyProvider";
import { postPath, userPath } from "@/lib/urlStyle";
import { postPath, userPath, commentAnchor } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
export default function PostInteractPanels({
@@ -250,7 +250,11 @@ export function QuestionStatusCard({ postId, state }: { postId: number; state: Q
<>
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
href={commentAnchor(
state.accepted_floor,
state.accepted_answer?.is_root ?? true,
state.accepted_comment_id
)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>
@@ -530,7 +534,11 @@ function BountyPanel({ state }: { postId: number; state: BountyState }) {
<p className="j13-typed-meta">
已采纳{" "}
<a
href={`#comment-${state.accepted_comment_id}`}
href={commentAnchor(
state.accepted_floor,
state.accepted_answer?.is_root ?? true,
state.accepted_comment_id
)}
className="hover:underline"
style={{ color: "var(--accent)" }}
>

View File

@@ -1,5 +1,5 @@
import Link from "next/link";
import { ArrowLeft } from "lucide-react";
import { ArrowLeft, Pin, Star } from "lucide-react";
import type { PostListItem } from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { getPublicSettingsCached } from "@/lib/serverData";
@@ -44,6 +44,8 @@ export default async function PostRow({
const pt = normalizePostType(post.post_type);
const statusBadge = pt !== "discussion" ? typeStatusLabel(pt, post.type_status) : "";
const statusClass = statusBadge ? typeStatusClass(pt, post.type_status) : "";
// 移动端徽章过多会挤占标题字数:仅隐藏「进行中」(开放式状态,详情页仍可见),终态徽章保留
const hideStatusOnMobile = statusBadge === "进行中";
const authorName = post.user.nickname || post.user.username;
return (
@@ -70,8 +72,16 @@ export default async function PostRow({
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 min-w-0">
{/* 仅置顶/推荐在标题左侧 */}
{post.pinned > 0 && <span className="badge-pin shrink-0">置顶</span>}
{post.recommended && <span className="badge-rec shrink-0">推荐</span>}
{post.pinned > 0 && (
<span className="badge-pin shrink-0" title="置顶" aria-label="置顶">
<Pin size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
{post.recommended && (
<span className="badge-rec shrink-0" title="推荐" aria-label="推荐">
<Star size={12} fill="currentColor" strokeWidth={2.2} aria-hidden />
</span>
)}
<div className="min-w-0 flex items-center gap-2 flex-1 overflow-hidden">
<Link
href={postPath(urlStyle, post.id)}
@@ -88,7 +98,11 @@ export default async function PostRow({
<span className="badge-type shrink-0">{postTypeLabel(post.post_type)}</span>
)}
{statusBadge && statusClass && (
<span className={`${statusClass} shrink-0`}>{statusBadge}</span>
<span
className={`${statusClass} shrink-0${hideStatusOnMobile ? " hidden sm:inline-flex" : ""}`}
>
{statusBadge}
</span>
)}
</div>
</div>

View File

@@ -2,11 +2,38 @@
import { useEffect, useState } from "react";
import Link from "next/link";
import { Ban, ExternalLink, Loader2, RotateCcw, ShieldCheck, Users as UsersIcon } from "lucide-react";
import { apiAdminSetUserBan, apiAdminSetUserRole, apiAdminSetUserMessages, type Board } from "@/lib/api";
import type { LucideIcon } from "lucide-react";
import {
Ban,
BookOpen,
CircleDollarSign,
ExternalLink,
FileText,
Loader2,
Megaphone,
RotateCcw,
ShieldCheck,
Users as UsersIcon,
} from "lucide-react";
import {
apiAdminSetUserBan,
apiAdminSetUserPerms,
apiAdminSetUserRole,
apiAdminSetUserMessages,
type Board,
} from "@/lib/api";
import { useUrlStyle } from "@/components/SiteBrandProvider";
import { userPath } from "@/lib/urlStyle";
import { ROLES, ROLE_META, roleMeta } from "@/lib/roles";
import {
CHANNEL_PERMS,
PERM_META,
PERMS,
ROLE_LEVEL,
ROLE_META,
ROLES,
roleLevel,
roleMeta,
} from "@/lib/roles";
import { formatDate } from "@/lib/format";
import { toast } from "@/lib/toast";
import Avatar from "@/components/Avatar";
@@ -23,12 +50,22 @@ export interface RoleAccountTarget {
board_ids?: number[];
banned: boolean;
can_manage_messages?: boolean;
/** 账号级渠道权限(公告/单页/书库/广告);undefined 表示尚未加载完成 */
perm_overrides?: string[];
avatar?: string;
email?: string;
points?: number;
created_at?: string;
}
// 渠道权限图标(与后台导航一致)
const CHANNEL_ICONS: Record<(typeof CHANNEL_PERMS)[number], LucideIcon> = {
[PERMS.ANNOUNCEMENTS]: Megaphone,
[PERMS.PAGES]: FileText,
[PERMS.LIBRARY]: BookOpen,
[PERMS.ADS]: CircleDollarSign,
};
interface RoleOption {
value: string;
title: string;
@@ -80,6 +117,7 @@ export default function RoleAccountModal({
const [boardIds, setBoardIds] = useState<number[]>([]);
const [banned, setBanned] = useState(false);
const [manageMessages, setManageMessages] = useState(false);
const [permSel, setPermSel] = useState<string[]>([]);
const [err, setErr] = useState("");
useEffect(() => {
@@ -88,6 +126,7 @@ export default function RoleAccountModal({
setBoardIds(target.board_ids ?? []);
setBanned(!!target.banned);
setManageMessages(!!target.can_manage_messages);
setPermSel(target.perm_overrides ?? []);
setErr("");
}
}, [target]);
@@ -98,6 +137,10 @@ export default function RoleAccountModal({
setBoardIds((prev) => (prev.includes(id) ? prev.filter((b) => b !== id) : [...prev, id]));
};
const togglePerm = (perm: string) => {
setPermSel((prev) => (prev.includes(perm) ? prev.filter((p) => p !== perm) : [...prev, perm]));
};
const sameBoards =
JSON.stringify([...(target.board_ids ?? [])].sort((a, b) => a - b)) ===
JSON.stringify([...boardIds].sort((a, b) => a - b));
@@ -108,6 +151,13 @@ export default function RoleAccountModal({
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN &&
manageMessages !== !!target.can_manage_messages;
/** 渠道权限可编辑性跟随弹窗中选中的角色:管理员及以上自带全部渠道 */
const channelEditable = roleLevel(role) < ROLE_LEVEL[ROLES.ADMIN];
const targetPerms = target.perm_overrides ?? [];
const permsChanged =
target.perm_overrides !== undefined &&
channelEditable &&
JSON.stringify([...targetPerms].sort()) !== JSON.stringify([...permSel].sort());
const willBan = banChanged && banned;
const willUnban = banChanged && !banned;
@@ -116,7 +166,7 @@ export default function RoleAccountModal({
setErr("请至少选择一个授权板块");
return;
}
if (!roleChanged && !banChanged && !messagesChanged) {
if (!roleChanged && !banChanged && !messagesChanged && !permsChanged) {
onClose();
return;
}
@@ -126,6 +176,7 @@ export default function RoleAccountModal({
let nextBoards = target.board_ids ?? [];
let nextBanned = !!target.banned;
let nextMessages = !!target.can_manage_messages;
let nextPerms = target.perm_overrides ?? [];
if (roleChanged) {
const res = await apiAdminSetUserRole(
target.id,
@@ -135,6 +186,14 @@ export default function RoleAccountModal({
nextRole = res.user.role;
nextBoards = res.user.board_ids ?? [];
}
if (permsChanged) {
const res = await apiAdminSetUserPerms(target.id, permSel);
nextPerms = res.user.perm_overrides ?? [...permSel].sort();
}
if (roleChanged && roleLevel(nextRole) >= ROLE_LEVEL[ROLES.ADMIN]) {
// 升至管理员及以上:后端已清空账号级渠道授权
nextPerms = [];
}
if (banChanged) {
const res = await apiAdminSetUserBan(target.id, banned);
nextBanned = !!res.user.banned;
@@ -145,6 +204,7 @@ export default function RoleAccountModal({
}
const parts: string[] = [];
if (roleChanged) parts.push("角色已更新");
if (permsChanged) parts.push("渠道权限已更新");
if (willBan) parts.push("已封禁");
if (willUnban) parts.push("已解封");
if (messagesChanged) parts.push(manageMessages ? "已授予消息管理" : "已撤销消息管理");
@@ -155,6 +215,7 @@ export default function RoleAccountModal({
board_ids: nextBoards,
banned: nextBanned,
can_manage_messages: nextMessages,
perm_overrides: nextPerms,
});
onClose();
} catch (e) {
@@ -291,7 +352,7 @@ export default function RoleAccountModal({
})}
</div>
)}
{err && (
{err && role === ROLES.BOARD_ADMIN && (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
@@ -299,6 +360,108 @@ export default function RoleAccountModal({
</div>
)}
{target.role !== ROLES.OWNER && (
<div className="mt-5 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<p className="text-[12px] font-medium tracking-wide mb-2.5" style={{ color: "var(--ink-3)" }}>
渠道权限
</p>
{target.perm_overrides === undefined ? (
<p
className="text-[12px] flex items-center gap-1.5"
style={{ color: "var(--ink-3)" }}
aria-live="polite"
>
<Loader2 size={13} className="animate-spin" aria-hidden /> 渠道权限加载中…
</p>
) : (
<>
<div className="flex flex-col gap-2">
{CHANNEL_PERMS.map((perm) => {
const Icon = CHANNEL_ICONS[perm];
const meta = PERM_META[perm];
const on = channelEditable && permSel.includes(perm);
const body = (
<>
<span className="flex items-center gap-2.5 min-w-0">
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{
background: on || !channelEditable ? "var(--accent-soft)" : "var(--panel)",
color: on || !channelEditable ? "var(--accent)" : "var(--ink-3)",
}}
aria-hidden
>
<Icon size={15} />
</span>
<span className="min-w-0">
<span
className="block text-[13.5px] font-medium"
style={{ color: on ? "var(--accent)" : "var(--ink)" }}
>
{meta.label}
</span>
<span className="block mt-0.5 text-[11.5px] leading-snug" style={{ color: "var(--ink-3)" }}>
{meta.desc}
</span>
</span>
</span>
{channelEditable ? (
<span
className="shrink-0 w-10 h-6 rounded-full relative transition-colors"
style={{ background: on ? "var(--accent)" : "var(--line)" }}
aria-hidden
>
<span
className="absolute top-0.5 w-5 h-5 rounded-full bg-white transition-transform"
style={{ left: on ? "1.125rem" : "0.125rem" }}
/>
</span>
) : (
<span
className="shrink-0 inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium leading-[1.6]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
角色自带
</span>
)}
</>
);
const rowStyle = {
border: `1.5px solid ${on ? "var(--accent)" : "var(--line)"}`,
background: on ? "var(--accent-soft)" : "var(--panel-2)",
};
const rowCls =
"w-full rounded-xl px-3.5 py-3 text-left transition-colors flex items-center justify-between gap-3";
return channelEditable ? (
<button
key={perm}
type="button"
role="switch"
aria-checked={on}
disabled={acting}
onClick={() => togglePerm(perm)}
className={rowCls}
style={rowStyle}
>
{body}
</button>
) : (
<div key={perm} className={rowCls} style={rowStyle} aria-disabled="true">
{body}
</div>
);
})}
</div>
<p className="mt-2 text-[11.5px] leading-relaxed" style={{ color: "var(--ink-3)" }}>
{channelEditable
? "授予后该账号即可进入后台管理对应渠道,立即生效;可随时收回。"
: "管理员及以上角色自带全部渠道权限,无需单独授予。"}
</p>
</>
)}
</div>
)}
{canGrantSuper &&
target.role !== ROLES.OWNER &&
target.role !== ROLES.SUPER_ADMIN && (
@@ -396,6 +559,12 @@ export default function RoleAccountModal({
</p>
)}
</div>
{err && role !== ROLES.BOARD_ADMIN && (
<p className="mt-3 text-[12px]" style={{ color: "var(--danger)" }}>
{err}
</p>
)}
</Modal>
);
}

View File

@@ -7,7 +7,6 @@ import Footer from "@/components/Footer";
import BackToTop from "@/components/BackToTop";
import MobileTabBar from "@/components/MobileTabBar";
import type { User } from "@/lib/api";
import { isWideShellPath } from "@/lib/siteUrl";
import { libraryPath, userPath } from "@/lib/urlStyle";
import { useSiteBrand } from "@/components/SiteBrandProvider";
@@ -93,9 +92,6 @@ export default function SiteChrome({
if (enabledTabs.has("me") && meHref) tabBarRoots.push(meHref);
const showTabBar =
!!pathname && !pathIsComposeStudio(pathname) && tabBarRoots.includes(pathname);
// 书库页正文区放宽(Header/Footer 保持 1440,避免顶栏菜单位置随页面跳动)
const wideShell = isWideShellPath(pathname);
return (
<>
<Header
@@ -114,7 +110,7 @@ export default function SiteChrome({
<main
id="main"
tabIndex={-1}
className={`flex-1 w-full ${wideShell ? "max-w-[1680px]" : "max-w-[1440px]"} mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none`}
className="flex-1 w-full max-w-[1440px] mx-auto px-4 sm:px-6 pt-4 sm:pt-[var(--main-pad-y)] pb-[var(--main-pad-y)] outline-none"
>
{children}
</main>

View File

@@ -13,6 +13,7 @@ import {
Users,
} from "lucide-react";
import {
apiAdminGetUserPerms,
apiAdminSetUserBan,
apiListBoards,
apiMe,
@@ -61,6 +62,8 @@ export default function StaffUserMenu({
const [roleOpen, setRoleOpen] = useState(false);
const [boards, setBoards] = useState<Board[]>([]);
const [acting, setActing] = useState(false);
/** 目标账号的渠道权限(打开弹窗时按需拉取;undefined = 加载中) */
const [rolePerms, setRolePerms] = useState<string[] | undefined>(undefined);
useEffect(() => {
apiMe()
@@ -99,11 +102,15 @@ export default function StaffUserMenu({
const openRoleModal = () => {
setOpen(false);
setRoleOpen(true);
setRolePerms(undefined);
if (boards.length === 0) {
apiListBoards()
.then((res) => setBoards(res.boards ?? []))
.catch(() => toast("板块列表加载失败"));
}
apiAdminGetUserPerms(target.id)
.then((res) => setRolePerms(res.perm_overrides))
.catch(() => toast("渠道权限加载失败"));
};
const handleBanConfirm = async () => {
@@ -130,6 +137,7 @@ export default function StaffUserMenu({
role: target.role,
board_ids: target.board_ids,
banned,
perm_overrides: rolePerms,
avatar: target.avatar,
created_at: target.created_at,
points: target.points,
@@ -241,6 +249,7 @@ export default function StaffUserMenu({
onClose={() => setRoleOpen(false)}
onSaved={(next) => {
setBanned(next.banned);
setRolePerms(next.perm_overrides);
onBannedChange?.(next.banned);
setRoleOpen(false);
router.refresh();

View File

@@ -129,6 +129,10 @@ function shouldShowTip(host: Element, tip: string): boolean {
/**
* 全站悬停即显提示:监听 [data-tip],替代浏览器原生 title 的延迟气泡。
* 挂在 root layout 即可,业务侧写 data-tip="文案" 并保留 aria-label。
*
* 移动端策略:仅在 (hover: hover) and (pointer: fine) 设备上响应真实鼠标
* 指针与键盘焦点。触屏 tap 既派发合成鼠标事件又会抢占焦点,若照常弹出,
* 会出现「一点击就弹、只能点别处关闭」的体验,因此触屏一律不触发。
*/
export default function TipHost() {
const [tip, setTip] = useState<TipState>(null);
@@ -193,6 +197,15 @@ export default function TipHost() {
}, delay);
};
// 仅在具备真实悬停能力的设备(鼠标/触控板)上启用;纯触屏直接不参与
const hoverMq = window.matchMedia("(hover: hover) and (pointer: fine)");
let canHover = hoverMq.matches;
// 只在键盘导航期间允许聚焦气泡(:focus-visible 语义),点按产生的焦点不弹
let keyboardActive = false;
// 触摸后的短暂窗口,兜底忽略浏览器派发的合成鼠标事件
let lastTouchAt = 0;
const TOUCH_GUARD_MS = 500;
const onOver = (e: Event) => {
const t = e.target;
if (!(t instanceof Element)) return;
@@ -204,8 +217,19 @@ export default function TipHost() {
tryShow(host);
};
const onPointerOver = (e: PointerEvent) => {
if (!canHover || e.pointerType !== "mouse") return;
if (Date.now() - lastTouchAt < TOUCH_GUARD_MS) return;
onOver(e);
};
const onFocusIn = (e: FocusEvent) => {
if (!canHover || !keyboardActive) return;
onOver(e);
};
const onOut = (e: Event) => {
if (!(e instanceof MouseEvent) && !(e instanceof FocusEvent)) return;
if (!(e instanceof PointerEvent) && !(e instanceof FocusEvent)) return;
const related = "relatedTarget" in e ? e.relatedTarget : null;
if (
related instanceof Node &&
@@ -217,25 +241,52 @@ export default function TipHost() {
hide();
};
const onKeyDown = () => {
keyboardActive = true;
};
const onPointerDown = (e: PointerEvent) => {
if (e.pointerType !== "keyboard") keyboardActive = false;
};
const onTouchStart = () => {
lastTouchAt = Date.now();
keyboardActive = false;
hide();
};
const onHoverMqChange = (e: MediaQueryListEvent) => {
canHover = e.matches;
if (!canHover) hide();
};
const onScrollOrResize = () => {
if (!activeEl.current) return;
tryShow(activeEl.current);
};
// mouseover 冒泡,便于 closest;立即显示,无延时
document.addEventListener("mouseover", onOver);
document.addEventListener("mouseout", onOut);
document.addEventListener("focusin", onOver);
// pointerover 冒泡,便于 closest;仅响应真实鼠标指针,触屏/手写笔不弹
document.addEventListener("pointerover", onPointerOver);
document.addEventListener("pointerout", onOut);
document.addEventListener("focusin", onFocusIn);
document.addEventListener("focusout", onOut);
document.addEventListener("keydown", onKeyDown, true);
document.addEventListener("pointerdown", onPointerDown, true);
document.addEventListener("touchstart", onTouchStart, true);
hoverMq.addEventListener("change", onHoverMqChange);
window.addEventListener("scroll", onScrollOrResize, true);
window.addEventListener("resize", onScrollOrResize);
return () => {
clearPending();
document.removeEventListener("mouseover", onOver);
document.removeEventListener("mouseout", onOut);
document.removeEventListener("focusin", onOver);
document.removeEventListener("pointerover", onPointerOver);
document.removeEventListener("pointerout", onOut);
document.removeEventListener("focusin", onFocusIn);
document.removeEventListener("focusout", onOut);
document.removeEventListener("keydown", onKeyDown, true);
document.removeEventListener("pointerdown", onPointerDown, true);
document.removeEventListener("touchstart", onTouchStart, true);
hoverMq.removeEventListener("change", onHoverMqChange);
window.removeEventListener("scroll", onScrollOrResize, true);
window.removeEventListener("resize", onScrollOrResize);
};

View File

@@ -37,10 +37,12 @@ import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
import {
canModerateAny,
canAccessAdminMessages,
hasPerm,
isAdminOrAbove,
isOwner,
isStaff,
isSuperOrOwner,
PERMS,
type RoleUserLike,
} from "@/lib/roles";
import { realtime, RT_MODERATION_CHANGED } from "@/lib/realtime";
@@ -102,19 +104,19 @@ const NAV_GROUPS: NavGroup[] = [
href: "/admin/announcements",
label: "公告管理",
icon: Megaphone,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.ANNOUNCEMENTS),
},
{
href: "/admin/pages",
label: "单页管理",
icon: FileText,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.PAGES),
},
{
href: "/admin/library",
label: "书库管理",
icon: BookOpen,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.LIBRARY),
},
{
href: "/admin/friend-links",
@@ -155,7 +157,7 @@ const NAV_GROUPS: NavGroup[] = [
href: "/admin/ads",
label: "推广与赞助",
icon: CircleDollarSign,
visible: (u) => isAdminOrAbove(u.role),
visible: (u) => hasPerm(u, PERMS.ADS),
},
],
},

View File

@@ -43,6 +43,7 @@ import {
AD_TIP_DELAY_MS,
adHoverTip,
} from "@/lib/ads";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
@@ -428,9 +429,11 @@ export function FriendLinks({ links }: { links: FriendLink[] }) {
title={l.description || l.name}
>
{l.logo_url ? (
// 14px 显示位:本站图走 48/96 变体(PageSpeed 典型场景);外链 logo 保持原地址
// eslint-disable-next-line @next/next/no-img-element
<img
src={l.logo_url}
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(14)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 14) : undefined}
alt=""
width={14}
height={14}
@@ -511,9 +514,11 @@ export function AdsPanel({
data-tip={adHoverTip(ad.ends_at)}
data-tip-delay={AD_TIP_DELAY_MS}
>
{/* 固定 248×60 位:本站图走 256/512 密度变体 */}
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={ad.image_url}
src={isOwnImage(ad.image_url ?? "") ? variantURL(ad.image_url ?? "", pickVariantWidth(AD_IMAGE_WIDTH)) : ad.image_url}
srcSet={isOwnImage(ad.image_url ?? "") ? densitySrcSet(ad.image_url ?? "", AD_IMAGE_WIDTH) : undefined}
alt="推广"
className="j13-ad-image"
width={AD_IMAGE_WIDTH}
@@ -579,8 +584,15 @@ export function SponsorsPanel({
const tip = (sp.hover_text || sp.name || "").trim();
const href = sp.link_url?.trim() || undefined;
const inner = (
// logo ≤52px 高:本站图走 96/160 变体
// eslint-disable-next-line @next/next/no-img-element
<img src={sp.logo_url} alt={sp.name} className="j13-sponsor-logo" loading="lazy" />
<img
src={isOwnImage(sp.logo_url) ? variantURL(sp.logo_url, pickVariantWidth(52)) : sp.logo_url}
srcSet={isOwnImage(sp.logo_url) ? densitySrcSet(sp.logo_url, 52) : undefined}
alt={sp.name}
className="j13-sponsor-logo"
loading="lazy"
/>
);
return (
<li key={sp.id} className="j13-sponsor">

View File

@@ -2,27 +2,33 @@
import { useState, useCallback, type ImgHTMLAttributes } from "react";
import ImageLightbox, { type LightboxItem } from "./ImageLightbox";
import { isOwnImage, variantURL, widthSrcSet } from "@/lib/responsiveImage";
type MdImageProps = ImgHTMLAttributes<HTMLImageElement>;
/** 图箱收集的原图地址:data-full 优先(页面显示变体时仍指向原图) */
function fullSrc(el: HTMLImageElement): string {
return el.dataset.full || el.currentSrc || el.getAttribute("src") || "";
}
function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index: number } {
const root = from.closest(".prose-content");
const nodes = root
? Array.from(root.querySelectorAll<HTMLImageElement>("img.md-content-img[src]"))
: [from];
const items: LightboxItem[] = nodes
.map((el) => ({
src: el.currentSrc || el.getAttribute("src") || "",
alt: el.dataset.caption || undefined,
}))
.filter((it) => !!it.src);
const selfSrc = from.currentSrc || from.getAttribute("src") || "";
let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0;
if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
}
return { items, index };
const root = from.closest(".prose-content");
const nodes = root
? Array.from(root.querySelectorAll<HTMLImageElement>("img.md-content-img[src]"))
: [from];
const items: LightboxItem[] = nodes
.map((el) => ({
src: fullSrc(el),
alt: el.dataset.caption || undefined,
}))
.filter((it) => !!it.src);
const selfSrc = fullSrc(from);
let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0;
if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
}
return { items, index };
}
/** Markdown 正文图片:点击进入图箱,同文多图可切换 */
@@ -42,6 +48,9 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
const provided = (typeof alt === "string" ? alt.trim() : "") || (typeof title === "string" ? title.trim() : "");
const imgAlt = provided || "图片";
// 同源图(本地 /uploads 或 /api/media 对象)才可走变体端点;外链图原样加载
const isLocalSrc = typeof src === "string" && isOwnImage(src);
// 抖音表情(/emoji/ 路径):行内小图,不进图箱、不带 caption;
// alt 由 expandDyEmojiShortcodes 产出("dy:<key>"),悬停 title 显示其短代码
const isEmoji = typeof src === "string" && src.includes("/emoji/");
@@ -78,9 +87,13 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
{...rest}
src={src}
// 同源图(/uploads、/api/media)页面内走宽度变体;外链图保持原 src 不回归
src={isLocalSrc ? variantURL(src, 512) : src}
srcSet={isLocalSrc ? widthSrcSet(src, [512, 768, 1080, 1536]) : undefined}
sizes={isLocalSrc ? "(max-width:768px) 92vw, 720px" : undefined}
alt={imgAlt}
data-caption={provided || undefined}
data-full={src}
title={title}
className={`md-content-img${className ? ` ${className}` : ""}`}
loading="lazy"

View File

@@ -1,14 +1,17 @@
"use client";
import { useEffect, useRef } from "react";
import { useEffect, useLayoutEffect, useRef } from "react";
import type { SidebarWidgetPublic, User } from "@/lib/api";
import { scopeWidgetCss } from "@/lib/widgetCode";
import { mountWidget } from "@/lib/widgetRuntime";
import { ensureWidgetApi } from "@/lib/widgetApi";
// 站长自定义工具块:HTML/CSS 随 SSR 直出(dangerouslySetInnerHTML),
// JS 在客户端挂载时注入执行、卸载时移除。站长可信内容,不做消毒/沙箱。
// CSS 经 scopeWidgetCss 限定在 `.j13-widget-{id}` 容器内,不污染页面其他区域;
// 工具内可直接用站点主题变量(var(--accent) 等)与 window.J13 接口。
// 站长自定义工具块:所有工具统一在 Shadow DOM 内渲染(attachShadow 是客户端能力,
// 因此服务端只输出空容器,HTML/CSS/JS 全部在客户端 effect 中注入,无水合不一致)。
// - CSS 注入 shadow 内,不污染全站,也不被全站样式污染;
// - JS 以函数形式执行,接收 (root, shadow, toolId, registerCleanup),
// DOM 查找从 root 出发,同页多个同款工具互不干扰;
// - unmount 时统一执行 cleanup 并清空 shadow。
// 站长可信内容,不做消毒/沙箱。
export default function CustomWidgetBlock({
widget,
viewer,
@@ -16,39 +19,36 @@ export default function CustomWidgetBlock({
widget: SidebarWidgetPublic;
viewer?: User | null;
}) {
const hostRef = useRef<HTMLDivElement>(null);
const hostRef = useRef<HTMLElement>(null);
// 先于站长 JS 建立站点接口(声明顺序保证本 effect 先执行)
useEffect(() => {
// 服务端用 useEffect 避免告警,客户端用 layout effect 首帧前完成注入,避免闪烁
const useIsomorphicLayoutEffect =
typeof window !== "undefined" ? useLayoutEffect : useEffect;
// 两个 effect 同类型、按声明顺序执行:先建立站点接口,再挂载工具,
// 保证用户脚本首帧同步读取 window.J13 时接口已就绪。
useIsomorphicLayoutEffect(() => {
ensureWidgetApi(viewer);
}, [viewer]);
useEffect(() => {
useIsomorphicLayoutEffect(() => {
const host = hostRef.current;
if (!host || !widget.js) return;
const script = document.createElement("script");
script.dataset.widgetScript = String(widget.id);
script.textContent = widget.js;
host.appendChild(script);
return () => {
script.remove();
};
}, [widget.id, widget.js]);
if (!host) return;
const handle = mountWidget(host, {
id: widget.id,
html: widget.html,
css: widget.css,
js: widget.js,
});
return () => handle.unmount();
}, [widget.id, widget.html, widget.css, widget.js]);
return (
<section
className={`j13-widget-body j13-widget-${widget.id}`}
ref={hostRef}
className="j13-widget-body"
data-widget-id={widget.id}
data-widget-name={widget.name}
>
{widget.css ? (
<style dangerouslySetInnerHTML={{ __html: scopeWidgetCss(widget.css, widget.id) }} />
) : null}
{widget.html ? (
<div className="j13-widget-html" dangerouslySetInnerHTML={{ __html: widget.html }} />
) : null}
{/* 自定义 JS 的宿主节点:脚本由客户端创建后挂到此处执行 */}
<div ref={hostRef} />
</section>
/>
);
}

View File

@@ -82,6 +82,8 @@ export interface User {
board_ids?: number[];
// 站点消息管理(站长授予;站长/超管固有监管不必依赖此字段)
can_manage_messages?: boolean;
// 生效权限码(角色默认 ∪ 账号级渠道授予;仅 /api/me 返回,用于后台入口显隐)
perms?: string[];
// 作为群管理员的群数量(/me;用于后台消息入口)
chat_admin_room_count?: number;
// 仅 /api/me 与 /api/profile 响应中返回(用户自身敏感信息)
@@ -284,13 +286,26 @@ export interface LibrarySectionItem {
updated_at: string;
}
/** 书库条目创建者摘要(已注销用户时接口省略该字段) */
export interface LibraryCreator {
id: number;
username: string;
nickname: string;
avatar: string;
}
/** 书库条目(详情/管理端,含文件与章节) */
export interface LibraryDoc {
id: number;
slug: string;
title: string;
author: string;
creator_id: number;
creator?: LibraryCreator | null;
description: string;
cover_url: string;
cover_width: number;
cover_height: number;
published: boolean;
sort_order: number;
entries_auto?: boolean; // 全章节条目卡模式(章节内容免写 [entries] 壳)
@@ -301,11 +316,14 @@ export interface LibraryDoc {
sections: LibrarySectionItem[];
}
/** 书库公开目录条目(不含 description 全文) */
/** 书库公开目录条目(description 为 markdown 原文,卡片内按纯文本截断展示) */
export interface LibraryDocListItem {
id: number;
slug: string;
title: string;
author: string;
description: string;
creator?: LibraryCreator | null;
cover_url: string;
sort_order: number;
file_count: number;
@@ -317,8 +335,11 @@ export interface LibraryDocListItem {
export interface LibraryInput {
slug: string;
title: string;
author?: string;
description?: string;
cover_url?: string;
cover_width?: number;
cover_height?: number;
published: boolean;
sort_order?: number;
entries_auto?: boolean;
@@ -492,6 +513,8 @@ export interface Post {
export interface AcceptedAnswer {
id: number;
floor: number;
/** false = 楼中楼回复,锚点需 #comment-{floor}-r{id} */
is_root: boolean;
content: string;
created_at: string;
deleted?: boolean;
@@ -683,6 +706,10 @@ export interface UserCommentItem {
post_id: number;
post_title: string;
content: string;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
/** 主楼/楼中楼:楼中楼锚点需 #comment-{floor}-r{id} */
is_root: boolean;
created_at: string;
}
@@ -728,6 +755,10 @@ export interface NotificationItem {
content: string;
is_read: boolean;
created_at: string;
/** 关联评论在所属帖内的相对楼层号(0/缺省 = 评论已彻底删除) */
comment_floor?: number;
/** 关联评论是否主楼;楼中楼锚点需 #comment-{floor}-r{comment_id} */
comment_is_root?: boolean;
actor: User;
post?: { id: number; title: string };
room?: { id: number; name: string } | null;
@@ -1407,18 +1438,6 @@ export async function fetchComments(
return res.json();
}
// 评论定位:返回楼层号(与楼层分页口径一致),0 = 不存在。
// 通知/主页评论深链 #comment-{id} 跨页时用它换到正确页码。
export async function fetchCommentLocation(
postId: string | number,
commentId: number
): Promise<number> {
const res = await fetch(`${API_BASE}/api/posts/${postId}/comments/${commentId}`);
if (!res.ok) return 0;
const data = (await res.json()) as { floor?: number };
return data.floor || 0;
}
export async function fetchUserProfile(
id: string,
page = 1,
@@ -2331,6 +2350,8 @@ export interface MediaAttachment {
id: number;
url: string;
kind: "avatar" | "image";
/** 图片来源:post=帖子插图,library_cover=书籍封面,library_content=书籍正文 */
source?: string;
size: number;
width: number;
height: number;
@@ -2354,7 +2375,8 @@ export async function apiUploadAvatar(
// 上传帖子插图(JPEG / PNG / WebP / GIF,≤5MB;服务端自动转存 WebP,GIF 除外)
export async function apiUploadPostImage(
file: File | Blob,
filename = "image.jpg"
filename = "image.jpg",
source?: string
): Promise<{ url?: string; attachment?: MediaAttachment; error?: string }> {
const form = new FormData();
const name =
@@ -2362,6 +2384,7 @@ export async function apiUploadPostImage(
? file.name
: filename;
form.append("file", file, name);
if (source) form.append("source", source);
const res = await fetchWithRefresh("/api/upload/image", {
method: "POST",
headers: clientHeaders(),
@@ -2965,6 +2988,48 @@ export async function apiAdminDeleteLibraryFile(fid: number): Promise<void> {
}
}
/** 书库导入报告(POST /api/admin/library/import) */
export interface LibraryImportItem {
slug: string;
title: string;
action: "created" | "overwritten" | string;
sections: number;
files: number;
}
export interface LibraryImportFailure {
slug: string;
title: string;
/** conflict=slug 被在用条目占用(可改 overwrite 重导);deleted=被软删条目占用;invalid/failed */
reason: "conflict" | "deleted" | "invalid" | "failed" | string;
error: string;
}
export interface LibraryImportReport {
mode: string;
total: number;
imported: LibraryImportItem[];
failed: LibraryImportFailure[];
}
/** 上传书库导出 ZIP 恢复书籍;mode=create(冲突跳过)/ overwrite(覆盖同名) */
export async function apiAdminImportLibraryZip(
file: File,
mode: "create" | "overwrite"
): Promise<LibraryImportReport> {
const form = new FormData();
form.append("file", file, file.name);
form.append("mode", mode);
const res = await fetchWithRefresh(`/api/admin/library/import`, {
method: "POST",
headers: clientHeaders(),
body: form,
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "导入失败");
return (data as { report: LibraryImportReport }).report;
}
// ===== 书库章节(两级树)=====
export interface LibrarySectionInput {
@@ -3067,6 +3132,8 @@ export interface AdminUser {
board_ids: number[]; // 板块管理员的授权板块
banned: boolean;
can_manage_messages?: boolean;
// 账号级渠道权限(公告/单页/书库/广告;角色固有之外的增量授予)
perm_overrides?: string[];
points?: number;
total_points?: number;
level?: number;
@@ -3210,6 +3277,33 @@ export async function apiAdminSetUserMessages(
return data as { user: AdminUser };
}
// 读取账号级渠道权限(超管/站长;前台快捷菜单的授权弹窗回显用)
export async function apiAdminGetUserPerms(
id: number
): Promise<{ perm_overrides: string[] }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, {
headers: clientHeaders(),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "渠道权限加载失败");
return data as { perm_overrides: string[] };
}
// 超管/站长授予/撤销账号级渠道权限(公告/单页/书库/广告)
export async function apiAdminSetUserPerms(
id: number,
perms: string[]
): Promise<{ user: AdminUser }> {
const res = await fetchWithRefresh(`/api/admin/users/${id}/perms`, {
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ perms }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "渠道权限更新失败");
return data as { user: AdminUser };
}
// 管理员手动调整用户积分(加分计累计可升级;扣分仅扣余额不降级),理由必填
export interface AdminAdjustPointsResult {
balance: number;
@@ -3547,6 +3641,9 @@ export interface AdminAuditCommentItem {
deleted: boolean;
created_at: string;
deleted_at?: string | null;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
is_root: boolean;
}
export interface AdminAuditMessageItem {
@@ -3788,8 +3885,10 @@ export async function apiBackgroundFromMedia(
return url;
}
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL(与原图解耦) */
export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<string> {
/** 把本人媒体库图片复制一份作书籍封面素材,返回副本 URL 与尺寸(与原图解耦) */
export async function apiLibraryCoverFromMedia(
attachmentId: number
): Promise<{ url: string; width: number; height: number }> {
const res = await fetchWithRefresh("/api/admin/library/cover/from-media", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
@@ -3799,7 +3898,8 @@ export async function apiLibraryCoverFromMedia(attachmentId: number): Promise<st
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
const url = String(data.url || "");
if (!url) throw new Error("选用媒体库图片失败");
return url;
const att = (data.attachment || {}) as { width?: number; height?: number };
return { url, width: Number(att.width) || 0, height: Number(att.height) || 0 };
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====
@@ -3877,6 +3977,9 @@ export interface AdminContentComment {
deleted: boolean;
deleted_at?: string;
created_at: string;
/** 所属帖内相对楼层号(0 = 评论已不可见) */
floor: number;
is_root: boolean;
board: Board;
user: User;
}
@@ -4089,6 +4192,7 @@ export interface ChatRoomMember {
role: "owner" | "admin" | "member";
last_read_message_id: number;
muted: boolean;
muted_until?: string | null;
created_at: string;
updated_at: string;
user: User;
@@ -4106,6 +4210,8 @@ export interface ChatMessage {
recalled_at?: string | null;
recalled_by?: number;
sender: User;
/** 系统提示消息(如禁言通知):sender_id=0,居中灰字渲染 */
is_system?: boolean;
/** 撤回操作者(已撤回时后端 Preload;本人撤回时通常等于 sender) */
recaller?: User | null;
}
@@ -4376,23 +4482,24 @@ export async function apiKickChatMember(
return data as { ok: boolean };
}
// 客户端:禁言/解禁
// 客户端:禁言/解禁;durationMinutes 可选:0/缺省=永久,>0=指定分钟数
export async function apiMuteChatMember(
roomId: number,
userId: number,
muted: boolean
): Promise<{ ok: boolean; muted: boolean }> {
muted: boolean,
durationMinutes?: number
): Promise<{ ok: boolean; muted: boolean; duration_minutes?: number }> {
const res = await fetchWithRefresh(
`/api/chat/rooms/${roomId}/members/${userId}/mute`,
{
method: "PUT",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ muted }),
body: JSON.stringify({ muted, duration_minutes: durationMinutes ?? 0 }),
}
);
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "禁言操作失败");
return data as { ok: boolean; muted: boolean };
return data as { ok: boolean; muted: boolean; duration_minutes?: number };
}
// 客户端:站长任命/撤销群管理员

View File

@@ -0,0 +1,92 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { markdownToSearchText, searchBook, type BookChapterIndex } from "./bookSearch.ts";
describe("markdownToSearchText", () => {
it("条目卡指令保留值、剥离标记符号", () => {
const md = `[entries]
## 安全气囊
@tags 汽车 | 安全 | 主:被动
@summary 气囊需配合安全带使用。
备注 :: 详见召回公告
@source NHTSA 报告 | https://a.gov/x
[/entries]`;
const text = markdownToSearchText(md);
assert.ok(text.includes("安全气囊"));
assert.ok(text.includes("汽车"));
assert.ok(text.includes("主:被动"));
assert.ok(text.includes("气囊需配合安全带使用。"));
assert.ok(text.includes("备注 详见召回公告"));
assert.ok(text.includes("NHTSA 报告"));
assert.ok(!text.includes("@tags"));
assert.ok(!text.includes("@summary"));
assert.ok(!text.includes("@source"));
assert.ok(!text.includes("::"));
assert.ok(!text.includes("[entries"));
assert.ok(!text.includes("https://a.gov/x"));
});
it("锁定的 hide 块整体不可搜,未锁块保留正文", () => {
const md = `公开段落内容。
[hide login]
登录后可见的秘密
[/hide]
[hide points=10 locked]
积分秘密内容
[/hide]`;
const text = markdownToSearchText(md);
assert.ok(text.includes("公开段落内容"));
assert.ok(text.includes("登录后可见的秘密"));
assert.ok(!text.includes("积分秘密内容"));
});
it("剥离代码围栏与行内 markdown 语法但保留代码与锚文本", () => {
const md = "见 [官网说明](https://x.com) 与 `const x = 1`,\n\n```ts\ncodeInside();\n```";
const text = markdownToSearchText(md);
assert.ok(text.includes("官网说明"));
assert.ok(!text.includes("https://x.com"));
assert.ok(text.includes("const x = 1"));
assert.ok(text.includes("codeInside();"));
assert.ok(!text.includes("```"));
});
});
describe("searchBook", () => {
const chapters: BookChapterIndex[] = [
{ no: 1, title: "第一章", plain: "今天天气很好,适合出门散步。路边有一朵花。" },
{ no: 2, title: "第二章", plain: "第二章没有关键词。" },
{ no: 3, title: "第三章", plain: "再次提到天气,天气真的不错。" },
];
it("按章聚合命中数与片段,大小写不敏感", () => {
const hits = searchBook(chapters, "天气");
assert.equal(hits.length, 2);
assert.deepEqual(
hits.map((h) => [h.no, h.count]),
[
[1, 1],
[3, 2],
]
);
assert.equal(hits[1].snippets[0].match, "天气");
assert.ok(hits[1].snippets[0].after.includes(",天气真的"));
});
it("片段最多生成 SNIPPET_LIMIT 个,但 count 为真实总数", () => {
const cs: BookChapterIndex[] = [{ no: 1, title: "t", plain: "天气 天气 天气 天气" }];
const hits = searchBook(cs, "天气");
assert.equal(hits[0].count, 4);
assert.equal(hits[0].snippets.length, 3);
assert.deepEqual(hits[0].snippets.map((s) => s.ordinal), [0, 1, 2]);
});
it("空关键词返回空", () => {
assert.deepEqual(searchBook(chapters, " "), []);
});
it("无命中返回空数组", () => {
assert.deepEqual(searchBook(chapters, "航天飞机"), []);
});
});

147
frontend/lib/bookSearch.ts Normal file
View File

@@ -0,0 +1,147 @@
/**
* 书库全书搜索:章节 markdown → 检索纯文本 + 关键词匹配/片段生成。
* 纯函数、无 DOM 依赖,供阅读页搜索面板(client)与单测共用。
*/
import { publicMarkdownForSeo } from "./hideBlocks.ts";
import { parseEntrySource } from "./entryBlocks.ts";
/** 传给客户端的章节索引:no 为书内章序号(1 基,与 ?c=/ ?sec= 一致) */
export interface BookChapterIndex {
no: number;
title: string;
/** markdown 经剥离后的可见纯文本 */
plain: string;
}
export interface BookSnippet {
/** 命中前缀文(约 SNIPPET_CONTEXT 字) */
before: string;
/** 命中原文(保留原始大小写) */
match: string;
/** 命中后缀文 */
after: string;
/** 该片段是本章内第几个命中(0 基,与 DOM 高亮顺序对应) */
ordinal: number;
}
export interface BookChapterHit {
no: number;
title: string;
/** 本章命中总数 */
count: number;
/** 前若干个命中的上下文片段 */
snippets: BookSnippet[];
}
export const SNIPPET_CONTEXT = 28;
export const SNIPPET_LIMIT = 3;
const TAGS_RE = /^@tags\s+(.+)$/i;
const SUMMARY_RE = /^@summary(?:\s+(.*))?$/i;
const SOURCE_RE = /^@source\s+(.+)$/i;
const FIELD_RE = /^([^:@\n]{1,40}?)\s*::\s*(.*)$/;
/** [entries] / [entries:off] / [timeline] 等块壳标记行(hide 已由 SEO 逻辑处理) */
const DIRECTIVE_SHELL_RE = /^\[\/?(?:entries(?::off)?|timeline)\]$/i;
/**
* 章节 markdown → 检索用纯文本。
* 目标:页面可见的文字基本都能被搜到,且不命中标记符号。
* - 锁定的 [hide] 块整体剔除,未锁块保留正文(复用 SEO 公开文本逻辑)
* - 条目卡指令保留「值」:@tags 留标签、字段::值 留「字段 值」、@summary/@source 留文本
* - 块壳标记、代码围栏、图片/链接语法、HTML 标签、强调符号一律剥离
*/
export function markdownToSearchText(md: string): string {
if (!md) return "";
// 统一换行;剔除锁块(未锁 hide 仅留正文,标记行已不在)
const lines = publicMarkdownForSeo(md).replace(/\r\n?/g, "\n").split("\n");
const kept: string[] = [];
let inCode = false;
for (const raw of lines) {
const t = raw.trim();
if (t.startsWith("```")) {
inCode = !inCode;
continue; // 围栏本身去掉,代码内容保留可搜
}
if (inCode) {
kept.push(raw);
continue;
}
if (!t) continue;
if (DIRECTIVE_SHELL_RE.test(t)) continue;
const tags = t.match(TAGS_RE);
if (tags) {
kept.push(tags[1].replace(/[||]/g, " "));
continue;
}
const summary = t.match(SUMMARY_RE);
if (summary) {
if (summary[1]) kept.push(summary[1]);
continue;
}
const source = t.match(SOURCE_RE);
if (source) {
// 仅保留来源文本,丢弃尾部 URL
kept.push(parseEntrySource(source[1]).text);
continue;
}
if (/^@plain$/i.test(t)) continue;
const field = t.match(FIELD_RE);
if (field) {
kept.push(`${field[1]} ${field[2]}`);
continue;
}
kept.push(raw);
}
return kept
.join("\n")
.replace(/<!--[\s\S]*?-->/g, " ") // HTML 注释(如 <!-- git: ... -->)
.replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1") // 图片 → alt
.replace(/\[([^\]]+)\]\([^)]*\)/g, "$1") // 链接 → 锚文本
.replace(/<\/?[a-z][^>]*>/gi, " ") // 内联 HTML 标签
.replace(/^#{1,6}\s+/gm, "") // 标题井号
.replace(/^\s*>+\s?/gm, "") // 引用前缀
.replace(/^\s*[-+*]\s+/gm, "") // 无序列表点
.replace(/^\s*\d+[.)]\s+/gm, "") // 有序列表序号
.replace(/[`*_~]+/g, "") // 行内代码/强调/删除线
.replace(/\|/g, " ") // 表格竖线
.replace(/\s+/g, " ")
.trim();
}
/** 全书关键词匹配:大小写不敏感子串,命中按章聚合,仅前 SNIPPET_LIMIT 个命中生成片段 */
export function searchBook(chapters: BookChapterIndex[], query: string): BookChapterHit[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const hits: BookChapterHit[] = [];
for (const ch of chapters) {
const hay = ch.plain.toLowerCase();
const snippets: BookSnippet[] = [];
let count = 0;
let from = 0;
for (;;) {
const idx = hay.indexOf(q, from);
if (idx < 0) break;
if (snippets.length < SNIPPET_LIMIT) {
snippets.push({
ordinal: count,
before: ch.plain.slice(Math.max(0, idx - SNIPPET_CONTEXT), idx),
match: ch.plain.slice(idx, idx + q.length),
after: ch.plain.slice(idx + q.length, idx + q.length + SNIPPET_CONTEXT),
});
}
count++;
from = idx + q.length;
}
if (count > 0) hits.push({ no: ch.no, title: ch.title, count, snippets });
}
return hits;
}
/** 全书命中总数 */
export function countBookHits(chapters: BookChapterIndex[], query: string): number {
return searchBook(chapters, query).reduce((acc, h) => acc + h.count, 0);
}

View File

@@ -33,16 +33,18 @@ describe("questionJsonLd", () => {
createdAt: "2026-09-01T00:00:00Z",
author: { id: 3, name: "甲" },
postId: 9,
accepted: { id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
accepted: { id: 4, floor: 1, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
answers: [
{ id: 4, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
{ id: 6, content: "先 EXPLAIN", created_at: "2026-09-03T00:00:00Z" },
{ id: 4, floor: 1, content: "用索引", created_at: "2026-09-02T00:00:00Z", author: { id: 5, name: "乙" } },
{ id: 6, floor: 2, content: "先 EXPLAIN", created_at: "2026-09-03T00:00:00Z" },
],
});
assert.ok(data);
const q = data.mainEntity as { acceptedAnswer: { text: string }; suggestedAnswer: { text: string }[] };
const q = data.mainEntity as { acceptedAnswer: { text: string; url: string }; suggestedAnswer: { text: string; url: string }[] };
assert.equal(q.acceptedAnswer.text, "用索引");
assert.equal(q.acceptedAnswer.url, "https://bbs.example.com/post/9#comment-1");
assert.deepEqual(q.suggestedAnswer.map((a) => a.text), ["先 EXPLAIN"]);
assert.equal(q.suggestedAnswer[0].url, "https://bbs.example.com/post/9#comment-2");
assert.equal(data["@type"], "QAPage");
});
});
@@ -57,11 +59,13 @@ describe("discussionJsonLd", () => {
author: { id: 1, name: "丙" },
postId: 8,
pageUrl: "https://bbs.example.com/post/8?page=2",
comments: [{ id: 2, content: "可见楼层", created_at: "2026-09-01T01:00:00Z" }],
comments: [{ id: 2, floor: 21, content: "可见楼层", created_at: "2026-09-01T01:00:00Z" }],
});
assert.equal(data?.url, "https://bbs.example.com/post/8");
const page = data?.mainEntityOfPage as { url: string };
assert.equal(page.url, "https://bbs.example.com/post/8?page=2");
assert.equal((data?.comment as { text: string }[])[0].text, "可见楼层");
const firstComment = (data?.comment as { text: string; url: string }[])[0];
assert.equal(firstComment.text, "可见楼层");
assert.equal(firstComment.url, "https://bbs.example.com/post/8#comment-21");
});
});

View File

@@ -1,5 +1,5 @@
import { absoluteCanonical, resolveSiteOrigin, type QueryBag } from "./siteUrl.ts";
import { postPath, userPath, type UrlStyle } from "./urlStyle.ts";
import { postPath, userPath, commentAnchor, type UrlStyle } from "./urlStyle.ts";
/** 防止用户正文里的 </script> 提前闭合 JSON-LD 脚本 */
export function serializeJsonLd(data: unknown): string {
@@ -88,13 +88,17 @@ export function listingJsonLd(input: {
export type JsonLdVisibleComment = {
id: number;
/** 帖内相对楼层号;评论 URL 锚点使用它而非全局评论 ID */
floor: number;
/** 楼中楼回复需 -r{id} 精确锚定;楼层评论可缺省(默认 true) */
is_root?: boolean;
content: string;
created_at: string;
author?: JsonLdPerson;
};
function commentUrl(entityUrl: string, id: number): string {
return `${entityUrl}#comment-${id}`;
function commentUrl(entityUrl: string, c: JsonLdVisibleComment): string {
return `${entityUrl}${commentAnchor(c.floor, c.is_root ?? true, c.id)}`;
}
/** 问答帖:一问多答。只收录当前页可见楼层,不把折叠回复或未展示评论写进去。 */
@@ -124,7 +128,7 @@ export function questionJsonLd(input: {
"@type": "Answer" as const,
text: a.content,
dateCreated: a.created_at,
url: commentUrl(entityUrl, a.id),
url: commentUrl(entityUrl, a),
...(a.author ? { author: personNode(a.author, urlStyle) } : {}),
});
return {
@@ -208,7 +212,7 @@ export function discussionJsonLd(input: {
"@type": "Comment",
text: c.content,
dateCreated: c.created_at,
url: commentUrl(entityUrl, c.id),
url: commentUrl(entityUrl, c),
...(c.author ? { author: personNode(c.author, urlStyle) } : {}),
})),
}

View File

@@ -20,6 +20,7 @@ export const RT_PRESENCE_UPDATE = "presence:update";
export const RT_CHAT_MESSAGE = "chat:message";
export const RT_CHAT_MEMBERSHIP = "chat:membership";
export const RT_CHAT_RECALLED = "chat:message_recalled";
export const RT_CHAT_MEMBER_MUTED = "chat:member_muted";
export const RT_CHAT_UNREAD = "chat:unread";
export const RT_NOTIFICATION_NEW = "notification:new";
export const RT_FEED_CHANGED = "feed:changed";
@@ -125,6 +126,18 @@ export interface RtChatRecalledData {
};
}
// 成员被禁言/解禁
export interface RtChatMemberMutedData {
room_id: number;
user_id: number;
muted: boolean;
muted_until?: string | null;
duration_minutes?: number;
target_name?: string;
operator_id: number;
operator_name: string;
}
// 未读增量(推送给未在房内订阅的成员,用于 Header / 会话列表角标)
// delta 通常为 +1(新消息);撤回未读消息时为 -1,此时不含 preview,不得当新消息处理
export interface RtChatUnreadData {

View File

@@ -0,0 +1,52 @@
// 自适应图片:统一生成后端变体地址(GET /api/img?u=&w=)。
// 宽度白名单与后端 backend/service/image_variants.go 同值,约 1.5x 步进。
// 源内容不可变(随机哈希文件名),变体可被浏览器/CDN 长期 immutable 缓存。
/** 宽度白名单(48~1920) */
export const VARIANT_WIDTHS = [48, 96, 160, 256, 384, 512, 768, 1080, 1536, 1920] as const;
/**
* pickVariantWidth 选「≥ 需求」的最小白名单档(保证清晰度);
* 需求超过最大档时回落最大档(源图不够大时后端会原样透传,不会放大)。
*/
export function pickVariantWidth(cssPx: number, dpr = 1): number {
const need = Math.max(1, Math.round(cssPx * dpr));
for (const w of VARIANT_WIDTHS) {
if (w >= need) return w;
}
return VARIANT_WIDTHS[VARIANT_WIDTHS.length - 1];
}
/** variantURL 指定宽度的变体地址 */
export function variantURL(src: string, w: number): string {
return `/api/img?u=${encodeURIComponent(src)}&w=${w}`;
}
/** isOwnImage 是否本站图片(/uploads 本地文件或 /api/media 对象),只有本站图能走变体端点 */
export function isOwnImage(src: string): boolean {
return src.startsWith("/uploads/") || src.startsWith("/api/media/");
}
/**
* densitySrcSet 固定尺寸位的密度 srcset(如头像、固定广告位):
* 返回 "url 1x, url 2x";不同 DPR 选中同档时自动去重。
*/
export function densitySrcSet(src: string, cssPx: number, dprs: number[] = [1, 2]): string {
const seen = new Set<number>();
const parts: string[] = [];
for (const dpr of dprs) {
const w = pickVariantWidth(cssPx, dpr);
if (seen.has(w)) continue;
seen.add(w);
parts.push(`${variantURL(src, w)} ${dpr}x`);
}
return parts.join(", ");
}
/**
* widthSrcSet 可变宽度位的宽度 srcset(如封面、正文图),配合 sizes 使用。
* widths 请直接传白名单档位。
*/
export function widthSrcSet(src: string, widths: readonly number[]): string {
return widths.map((w) => `${variantURL(src, w)} ${w}w`).join(", ");
}

View File

@@ -44,6 +44,38 @@ export function isOwner(role?: string | null): boolean {
return role === ROLES.OWNER;
}
// 后台功能点权限码(与后端 service.Perm* 一致)。
// 渠道类权限可由超管/站长账号级授予任意账号;/me 下发的 perms 为
// 角色默认 ∪ 账号授予的生效集合,前端只做显隐判定,最终权限以后端为准。
export const PERMS = {
ANNOUNCEMENTS: "announcements",
PAGES: "pages",
LIBRARY: "library",
ADS: "ads",
MESSAGES: "messages",
} as const;
export type PermKey = (typeof PERMS)[keyof typeof PERMS];
/** 可账号级授予的渠道权限(与后端 GrantablePerms 一致) */
export const CHANNEL_PERMS = [
PERMS.ANNOUNCEMENTS,
PERMS.PAGES,
PERMS.LIBRARY,
PERMS.ADS,
] as const;
/** 渠道权限展示元信息(角色与账号弹窗/后台导航共用) */
export const PERM_META: Record<
(typeof CHANNEL_PERMS)[number],
{ label: string; desc: string }
> = {
[PERMS.ANNOUNCEMENTS]: { label: "公告管理", desc: "发布、编辑、置顶与删除站点公告" },
[PERMS.PAGES]: { label: "单页管理", desc: "创建与维护站点单页(/p/:slug)" },
[PERMS.LIBRARY]: { label: "书库管理", desc: "管理书库条目、章节与文件" },
[PERMS.ADS]: { label: "广告与赞助", desc: "管理广告位与赞助配置、审批投放" },
};
/** 站长/超管:群聊监管旁路(可见全部群含私聊、撤回任意消息) */
export function canOverseeChat(role?: string | null): boolean {
return role === ROLES.OWNER || role === ROLES.SUPER_ADMIN;
@@ -59,10 +91,23 @@ export interface RoleUserLike {
role?: string | null;
board_ids?: number[] | null;
can_manage_messages?: boolean | null;
/** 生效权限码(/me 下发;角色默认 ∪ 账号授予) */
perms?: string[] | null;
/** 是否至少管理一个群(/me 可带;后台入口用) */
chat_admin_room_count?: number | null;
}
/** 是否拥有某后台功能点(依据 /me 下发的生效权限码;最终以后端校验为准) */
export function hasPerm(user: RoleUserLike | null | undefined, perm: string): boolean {
if (!user) return false;
return (user.perms ?? []).includes(perm);
}
/** 是否拥有任一渠道权限(后台准入判定用) */
export function hasAnyChannelPerm(user: RoleUserLike | null | undefined): boolean {
return CHANNEL_PERMS.some((p) => hasPerm(user, p));
}
/**
* 能否管理指定板块的内容(帖子/评论的编辑删除审核):
* 管理员及以上不限板块;板块管理员仅限被授权板块

View File

@@ -6,7 +6,6 @@ import {
canonicalPath,
homeCanonicalQuery,
isTrackingParam,
isWideShellPath,
parseSiteOrigin,
resolveSiteOrigin,
} from "./siteUrl.ts";
@@ -117,23 +116,3 @@ describe("homeCanonicalQuery", () => {
});
});
describe("isWideShellPath(书库页放宽档位)", () => {
it("覆盖三种伪静态风格的书库路径", () => {
assert.equal(isWideShellPath("/library"), true);
assert.equal(isWideShellPath("/library/abc"), true);
assert.equal(isWideShellPath("/library/abc/read"), true);
assert.equal(isWideShellPath("/library.html"), true);
assert.equal(isWideShellPath("/library-abc.html"), true);
assert.equal(isWideShellPath("/library-abc.html?sec=2"), true);
assert.equal(isWideShellPath("/library-abc-read-2.html"), true);
});
it("非书库路径不放宽", () => {
assert.equal(isWideShellPath("/"), false);
assert.equal(isWideShellPath("/post-1.html"), false);
assert.equal(isWideShellPath("/p-about.html"), false);
assert.equal(isWideShellPath("/u/5"), false);
assert.equal(isWideShellPath("/admin/library"), false);
assert.equal(isWideShellPath("/my-library.html"), false);
assert.equal(isWideShellPath(null), false);
});
});

View File

@@ -115,21 +115,6 @@ export function canonicalBoard(raw: string | undefined): number | undefined {
return n;
}
/** 书库页路径:命中时公开站三件套(Header/main/Footer)放宽到 1680px,
* 读页三栏与条目卡等内容更宽裕;其余页面保持 1440px。
* 需覆盖三种伪静态风格:/library[/...](default)、/library.html 与 /library-{slug}.html(html/discuz,
* 新章节地址 = 详情路径 + ?sec=,pathname 与详情一致) */
export function isWideShellPath(pathname: string | null | undefined): boolean {
if (!pathname) return false;
const p = pathname.split("?")[0];
return (
p === "/library" ||
p.startsWith("/library/") ||
p === "/library.html" ||
/^\/library-.*\.html$/.test(p)
);
}
export function defaultFeedSort(allowComments: boolean): FeedSort {
return allowComments ? "latest" : "new";
}

View File

@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { describe, it } from "node:test";
import {
announcementPath,
commentAnchor,
entityPath,
leaderboardPath,
libraryDocPath,
@@ -88,6 +89,15 @@ describe("路径构造器", () => {
assert.equal(entityPath("default", "user", 9), "/u/9");
assert.equal(entityPath("html", "announcement", 9), "/announcement-9.html");
});
it("评论锚点使用帖内相对楼层号", () => {
assert.equal(commentAnchor(1, true, 147), "#comment-1");
assert.equal(commentAnchor(3, false, 147), "#comment-3-r147");
// 楼中楼缺评论 id 时退化为楼层锚点
assert.equal(commentAnchor(3, false), "#comment-3");
// 楼层无效(评论已不可见)返回空串,由调用方回落纯帖子路径
assert.equal(commentAnchor(0, true, 147), "");
assert.equal(commentAnchor(Number.NaN, false, 147), "");
});
});
describe("matchPrettyPath", () => {

View File

@@ -76,6 +76,16 @@ export function postPath(style: UrlStyle, id: number): string {
return style === "default" ? STANDARD_TEMPLATES.post(n) : PRETTY_TEMPLATES[style].post(n);
}
/**
* 评论锚点:使用评论在所属帖内的相对楼层号,而非全站评论 ID。
* 主楼 → #comment-{floor};楼中楼 → #comment-{floor}-r{commentId}(楼层负责定位/分页/展开,
* r 段精确定位具体回复)。楼层号无效时返回空串,调用方应回退为不带锚点的帖子路径。
*/
export function commentAnchor(floor: number, isRoot: boolean, commentId?: number): string {
if (!(Number.isFinite(floor) && floor > 0)) return "";
return isRoot || !commentId ? `#comment-${floor}` : `#comment-${floor}-r${commentId}`;
}
/** 用户主页路径;?tab= 等查询参数由调用方拼接 */
export function userPath(style: UrlStyle, id: number): string {
const n = guardId(id);

View File

@@ -1,6 +1,6 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { mergeWidgetCode, parseMixedWidgetCode, scopeWidgetCss } from "./widgetCode.ts";
import { mergeWidgetCode, parseMixedWidgetCode } from "./widgetCode.ts";
describe("parseMixedWidgetCode", () => {
it("拆分混写的 style/script 段", () => {
@@ -45,58 +45,3 @@ describe("mergeWidgetCode", () => {
assert.equal(mergeWidgetCode({ html: "hi", css: "", js: "" }), "hi");
});
});
describe("scopeWidgetCss", () => {
const p = ".j13-widget-7";
it("普通规则加前缀", () => {
assert.equal(scopeWidgetCss(".a { color: red; }", 7), `${p} .a { color: red; }`);
});
it("选择器列表逐个前缀,括号内逗号不分割", () => {
assert.equal(
scopeWidgetCss(".a, .b {}", 7),
`${p} .a, ${p} .b {}`
);
assert.equal(
scopeWidgetCss(':is(.a, .b) { color: red }', 7),
`${p} :is(.a, .b) { color: red }`
);
});
it("root/body 选择器替换为容器本身", () => {
assert.equal(scopeWidgetCss(":root { --x: 1; }", 7), `${p} { --x: 1; }`);
assert.equal(scopeWidgetCss("body { margin: 0 }", 7), `${p} { margin: 0 }`);
assert.equal(scopeWidgetCss("body .card {}", 7), `${p} .card {}`);
});
it("容器自身伪类紧贴前缀", () => {
assert.equal(scopeWidgetCss(":hover { opacity: .8 }", 7), `${p}:hover { opacity: .8 }`);
});
it("@media 内层前缀化,头部原样", () => {
assert.equal(
scopeWidgetCss("@media (min-width: 10px) { .a {} }", 7),
`@media (min-width: 10px) { ${p} .a {} }`
);
});
it("@keyframes 原样保留", () => {
const css = "@keyframes spin { from { opacity: 0 } to { opacity: 1 } }";
assert.equal(scopeWidgetCss(css, 7), css);
});
it("@import 语句原样保留", () => {
assert.equal(
scopeWidgetCss('@import url("a.css"); .a {}', 7),
'@import url("a.css"); .j13-widget-7 .a {}'
);
});
it("注释与字符串内的花括号不干扰配对", () => {
assert.equal(
scopeWidgetCss("/* { nope } */ .a::after { content: \"{\" }", 7),
`/* { nope } */ ${p} .a::after { content: "{" }`
);
});
});

View File

@@ -1,8 +1,7 @@
// 自定义侧栏工具代码处理:
// - parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
// - scopeWidgetCss:渲染时把工具 CSS 限定在其容器(.j13-widget-{id})内,
// 逐条规则加前缀,避免污染页面其他区域。
// parseMixedWidgetCode / mergeWidgetCode:「混写一框」(内联 <style>/<script>)与分栏三字段互转;
// 三字段存储结构不变,拆分仅发生在管理端提交/切换视图时。
// 工具的样式隔离由渲染端 Shadow DOM 负责(见 widgetRuntime.ts),本模块不再做 CSS 作用域改写。
export interface WidgetCodeParts {
html: string;
@@ -51,162 +50,3 @@ export function mergeWidgetCode(parts: WidgetCodeParts): string {
if (parts.js.trim()) out.push(`<script>\n${parts.js.trim()}\n</script>`);
return out.join("\n\n");
}
/* ─────────────────────────── CSS 作用域 ─────────────────────────── */
// 按 , 分割选择器列表,忽略 () / [] 与引号内的逗号(:is(a,b)、[attr="a,b"])
function splitSelectorList(sel: string): string[] {
const out: string[] = [];
let depth = 0;
let quote = "";
let start = 0;
for (let i = 0; i < sel.length; i++) {
const ch = sel[i];
if (quote) {
if (ch === "\\") i++;
else if (ch === quote) quote = "";
continue;
}
if (ch === '"' || ch === "'") {
quote = ch;
continue;
}
if (ch === "(" || ch === "[") depth++;
else if (ch === ")" || ch === "]") depth = Math.max(0, depth - 1);
else if (ch === "," && depth === 0) {
out.push(sel.slice(start, i));
start = i + 1;
}
}
out.push(sel.slice(start));
return out;
}
// 单个选择器加前缀:
// - :root / :scope / html / body 开头 → 视作容器本身(变量定义在容器上,板块内可用);
// - 以伪类/伪元素(:)开头 → 紧贴容器(容器自身状态,如 .j13-widget-1:hover);
// - 其余 → 后代选择器。
function scopeOneSelector(sel: string, prefix: string): string | null {
const s = sel.trim();
if (!s) return null;
const rootLike = s.match(/^(?::root\b|:scope\b|html\b|body\b)([\s\S]*)$/);
if (rootLike) {
const rest = rootLike[1].replace(/^\s+/, "");
return rest ? `${prefix} ${rest}` : prefix;
}
if (s.startsWith(":")) {
// 功能型伪类开头(:is( / :where( / :has( / :not( )→ 按容器后代处理;
// 简单伪类/伪元素(:hover、::before…)→ 紧贴容器(容器自身状态)。
return /^::?[a-zA-Z-]+\(/.test(s) ? `${prefix} ${s}` : prefix + s;
}
return `${prefix} ${s}`;
}
/**
* 把 CSS 限定到容器内:
* - 普通规则:选择器逐个加 `.j13-widget-{id}` 前缀;
* - @media/@supports/@container/@layer 块:头部原样,内部规则递归前缀化;
* - @keyframes/@font-face/@property 等:整块原样(内部是帧/描述符,不是选择器);
* - 注释、引号内容(如 content:"{")不影响括号配对。
* 已知取舍:同名 @keyframes 在多个工具间仍是全局的,命名请自带区分。
*/
export function scopeWidgetCss(css: string, id: number): string {
const prefix = `.j13-widget-${id}`;
const n = css.length;
let out = "";
let i = 0;
// 从 from 起读一个平衡的 {...} 块(引号与转义内的括号不计入深度)
const readBlock = (from: number): { body: string; end: number } => {
let depth = 0;
let quote = "";
for (let j = from; j < n; j++) {
const ch = css[j];
if (quote) {
if (ch === "\\") j++;
else if (ch === quote) quote = "";
continue;
}
if (ch === '"' || ch === "'") quote = ch;
else if (ch === "{") depth++;
else if (ch === "}") {
depth--;
if (depth === 0) return { body: css.slice(from, j + 1), end: j + 1 };
}
}
return { body: css.slice(from), end: n };
};
while (i < n) {
if (css.startsWith("/*", i)) {
const close = css.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
out += css.slice(i, stop);
i = stop;
continue;
}
if (css[i] === "}") {
// 多余闭括号容错,原样透传
out += css[i];
i++;
continue;
}
// 读取选择器/语句头直到 { ; } 或结尾(引号内不截断)
let j = i;
let quote = "";
for (; j < n; j++) {
const c = css[j];
if (quote) {
if (c === "\\") j++;
else if (c === quote) quote = "";
continue;
}
if (c === '"' || c === "'") {
quote = c;
continue;
}
if (c === "{" || c === ";" || c === "}") break;
}
const head = css.slice(i, j);
if (j >= n) {
out += head;
break;
}
if (css[j] === ";") {
// @import / @charset / @layer a, b; 等语句原样保留
out += head + ";";
i = j + 1;
continue;
}
if (css[j] === "}") {
out += head;
i = j + 1;
continue;
}
const { body, end } = readBlock(j);
const trimmedHead = head.trim();
if (trimmedHead.startsWith("@")) {
const at = trimmedHead.split(/[\s(]/)[0].toLowerCase();
if (at === "@media" || at === "@supports" || at === "@container" || at === "@layer") {
const inner = body.slice(1, -1);
out += head + "{" + scopeWidgetCss(inner, id) + "}";
} else {
out += head + body;
}
} else {
const scoped = splitSelectorList(head)
.map((s) => scopeOneSelector(s, prefix))
.filter((s): s is string => s !== null);
if (scoped.length) {
// 保留选择器前后的原始空白,尽量不改变站长代码的排版
const lead = head.match(/^\s*/)?.[0] ?? "";
const trail = head.match(/\s*$/)?.[0] ?? "";
out += lead + scoped.join(", ") + trail + body;
} else {
out += head + body;
}
}
i = end;
}
return out;
}

View File

@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import { describe, it } from "node:test";
import { lintWidgetCode, type WidgetLintIssue } from "./widgetLint.ts";
const codes = (issues: WidgetLintIssue[]) => issues.map((i) => i.code);
describe("lintWidgetCode - JS 全局查找", () => {
it("document 上的六种查找 API 全部报 error", () => {
const js = [
'document.getElementById("a");',
'document.querySelector(".a");',
'document.querySelectorAll(".a");',
'document.getElementsByClassName("a");',
'document.getElementsByTagName("div");',
'document.getElementsByName("a");',
].join("\n");
const issues = lintWidgetCode({ js });
assert.equal(issues.length, 6);
assert.ok(issues.every((i) => i.level === "error" && i.code === "no-global-dom-lookup"));
assert.deepEqual(
issues.map((i) => i.line),
[1, 2, 3, 4, 5, 6]
);
});
it("window.document 与 ownerDocument 上的同款查找同样命中", () => {
const issues = lintWidgetCode({
js: 'window.document.querySelector(".x");\nroot.ownerDocument.getElementById("y");',
});
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "no-global-dom-lookup"));
});
it("从 root 出发的查找不报错", () => {
const issues = lintWidgetCode({
js: [
'root.querySelector(".a");',
'root.querySelectorAll(".a");',
'root.getElementById("a");',
'el.getElementsByClassName("a");',
].join("\n"),
});
assert.equal(issues.length, 0);
});
it("注释、字符串、正则字面量中的关键词不误报", () => {
const js = `
// document.getElementById("x")
/* document.querySelector(".y") */
const s1 = "document.getElementById('z')";
const s2 = 'document.querySelector(".z")';
const re = /document\\.getElementById/;
const tpl = \`document.getElementsByName("t")\`;
root.querySelector(".real");
`;
const issues = lintWidgetCode({ js });
assert.deepEqual(codes(issues), []);
});
it("行列号按原文定位", () => {
const js = "\n\n document.getElementById('x');";
const [issue] = lintWidgetCode({ js });
assert.equal(issue.line, 3);
assert.equal(issue.column, 3);
});
});
describe("lintWidgetCode - setInterval", () => {
it("只有 setInterval、全文无 clearInterval 时报 warn", () => {
const issues = lintWidgetCode({
js: "const t = setInterval(fn, 100);",
});
assert.equal(issues.length, 1);
assert.equal(issues[0].level, "warn");
assert.equal(issues[0].code, "warn-set-interval");
assert.equal(issues[0].line, 1);
});
it("setInterval 与 clearInterval 配对出现时不报", () => {
assert.deepEqual(
lintWidgetCode({ js: "const t = setInterval(fn, 100);\nclearInterval(t);" }),
[]
);
});
it("safeInterval 封装(cleanup 内清理)不报", () => {
const js = [
"function safeInterval(fn, ms) {",
" var id = setInterval(fn, ms);",
" registerCleanup(function () { clearInterval(id); });",
" return id;",
"}",
"safeInterval(render, 1000);",
].join("\n");
assert.deepEqual(lintWidgetCode({ js }), []);
});
it("clearInterval 只写在注释或字符串里不算配对,仍报 warn", () => {
const issues = lintWidgetCode({
js: 'const t = setInterval(f, 100);\n// clearInterval(t);\nconst s = "clearInterval(t)";',
});
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "warn-set-interval");
assert.equal(issues[0].line, 1);
});
it("window.setInterval 同样命中;注释里的 setInterval 不报", () => {
const issues = lintWidgetCode({
js: "window.setInterval(f, 50);\n// setInterval(f, 50);",
});
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "warn-set-interval");
});
it("多个无清理的 setInterval 逐处提醒", () => {
const issues = lintWidgetCode({
js: "setInterval(a, 10);\nsetInterval(b, 20);",
});
assert.deepEqual(
issues.map((i) => i.line),
[1, 2]
);
});
});
describe("lintWidgetCode - CSS position:fixed", () => {
it("position: fixed 报 error,兼容空格与 !important", () => {
const css = ".a { position: fixed; }\n.b { position:fixed !important; }\n.c { position: absolute; }";
const issues = lintWidgetCode({ css });
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "no-fixed-position" && i.level === "error"));
assert.deepEqual(
issues.map((i) => i.line),
[1, 2]
);
});
it("@media 内层规则同样检查", () => {
const issues = lintWidgetCode({ css: "@media (min-width: 100px) { .m { position: fixed; } }" });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "no-fixed-position");
});
it("注释与 content 字符串不造成误报", () => {
const css = `/* position: fixed */\n.a::after { content: "position: fixed"; color: red; }`;
assert.deepEqual(codes(lintWidgetCode({ css })), []);
});
});
describe("lintWidgetCode - CSS 全局选择器", () => {
it("body / html / * 开头的规则报 error", () => {
const issues = lintWidgetCode({
css: "body { margin: 0; }\nhtml.dark .a { color: #000; }\n* { box-sizing: border-box; }",
});
assert.equal(issues.length, 3);
assert.ok(issues.every((i) => i.code === "no-global-selector" && i.level === "error"));
});
it("选择器列表逐个判定,.body 类名与后代通配不误伤", () => {
const issues = lintWidgetCode({
css: ".body, body .card, .a *, html, *.x {}",
});
// 命中:body .card、html、*.x;.body 与 .a * 不命中
assert.equal(issues.length, 3);
});
it("@media 内层选择器同样检查", () => {
const issues = lintWidgetCode({ css: "@media (min-width: 1px) { body { color: red; } }" });
assert.equal(issues.length, 1);
assert.equal(issues[0].code, "no-global-selector");
});
});
describe("lintWidgetCode - transform: scale", () => {
it("transform 含 scale() 报 warn,支持厂商前缀与嵌套", () => {
const issues = lintWidgetCode({
css: ".a { transform: scale(1.2); }\n@media (min-width: 1px) { .b { -webkit-transform: scale(0.8); } }\n.c { transform: translateX(2px); }",
});
assert.equal(issues.length, 2);
assert.ok(issues.every((i) => i.code === "warn-scale-transform" && i.level === "warn"));
});
it("@keyframes 里的 scale 不报", () => {
const css = "@keyframes spin { from { transform: scale(0.9); } to { transform: scale(1); } }";
assert.deepEqual(codes(lintWidgetCode({ css })), []);
});
});
describe("lintWidgetCode - 综合", () => {
it("空输入与干净代码返回空列表", () => {
assert.deepEqual(lintWidgetCode({}), []);
assert.deepEqual(
lintWidgetCode({
html: '<div class="box"></div>',
css: ".box { color: red; transform: rotate(45deg); }",
js: 'root.querySelector(".box").textContent = "hi";',
}),
[]
);
});
it("css 问题排在 js 问题之前,error 与 warn 各自分级", () => {
const issues = lintWidgetCode({
css: "body { position: fixed; }\n.a { transform: scale(2); }",
js: "setInterval(f, 10);",
});
assert.deepEqual(codes(issues), [
"no-global-selector",
"no-fixed-position",
"warn-scale-transform",
"warn-set-interval",
]);
assert.deepEqual(
issues.map((i) => i.level),
["error", "error", "warn", "warn"]
);
});
});

378
frontend/lib/widgetLint.ts Normal file
View File

@@ -0,0 +1,378 @@
// 自定义侧栏工具保存前静态扫描:纯字符串启发式检查,不依赖 AST / 第三方库。
// 扫描时先做「等长掩码」——注释、字符串、正则字面量被替换为空格(换行保留),
// 因此写在注释/字符串里的关键词不会误报,行列号仍按原文计算。
//
// error(命中禁止保存):
// JS document / ownerDocument 上的全局元素查找 API
// CSS position: fixed
// CSS 以 body / html / * 开头的全局选择器
// warn(提醒确认后可保存):
// JS setInterval((仅当整份代码没有任何 clearInterval 调用时才报;
// 出现 clearInterval 即视为作者已自行管理定时器生命周期,避免对规范写法的误导性提醒)
// CSS transform ... scale(
//
// 已知取舍:HTML 内联事件属性(onclick="" 等)本轮不扫描;模板字符串 ${} 内的代码不扫描。
export type WidgetLintField = "js" | "css" | "html";
export type WidgetLintLevel = "error" | "warn";
export interface WidgetLintIssue {
level: WidgetLintLevel;
field: WidgetLintField;
/** 机器可读规则码 */
code: string;
/** 1-based 行号(按原始文本) */
line: number;
/** 1-based 列号(按原始文本) */
column: number;
message: string;
}
export interface WidgetLintInput {
html?: string;
css?: string;
js?: string;
}
/* ─────────────────────────── 通用工具 ─────────────────────────── */
/** 把 [0, n) 内的字符替换为空格,换行保留(保证掩码与原文等长、行列号一致) */
function blankRange(mask: string[], from: number, to: number, src: string): void {
for (let k = from; k < to && k < mask.length; k++) {
mask[k] = src[k] === "\n" ? "\n" : " ";
}
}
interface LineMap {
lineStarts: number[];
}
function buildLineMap(src: string): LineMap {
const lineStarts = [0];
for (let i = 0; i < src.length; i++) {
if (src[i] === "\n") lineStarts.push(i + 1);
}
return { lineStarts };
}
function locate(map: LineMap, index: number): { line: number; column: number } {
const { lineStarts } = map;
// 二分:最后一个 <= index 的行首
let lo = 0;
let hi = lineStarts.length - 1;
while (lo < hi) {
const mid = (lo + hi + 1) >> 1;
if (lineStarts[mid] <= index) lo = mid;
else hi = mid - 1;
}
return { line: lo + 1, column: index - lineStarts[lo] + 1 };
}
/* ─────────────────────────── JS 掩码 ─────────────────────────── */
const REGEX_PREV_PUNCT = new Set(["(", ",", "=", ":", "[", "!", "&", "|", "?", "{", ";", "+", "-", "*", "%", "^", "~", "<", ">"]);
const REGEX_PREV_KEYWORDS = new Set([
"return", "typeof", "instanceof", "in", "of", "new", "delete", "void", "case", "do", "else", "yield", "await",
]);
// 判断 slashIndex 处的 / 更可能是正则起始还是除号
function regexAllowedBefore(src: string, slashIndex: number): boolean {
let j = slashIndex - 1;
while (j >= 0 && src[j] !== "\n" && /\s/.test(src[j])) j--;
if (j < 0 || src[j] === "\n") return true;
if (REGEX_PREV_PUNCT.has(src[j])) return true;
let k = j;
while (k >= 0 && /[\w$]/.test(src[k])) k--;
const word = src.slice(k + 1, j + 1);
return REGEX_PREV_KEYWORDS.has(word);
}
/** 等长掩码 JS:注释、字符串、正则字面量 → 空格(换行保留) */
export function maskJavaScript(src: string): string {
const mask = src.split("");
const n = src.length;
let i = 0;
while (i < n) {
const ch = src[i];
const next = src[i + 1];
if (ch === "/" && next === "/") {
let j = i + 2;
while (j < n && src[j] !== "\n") j++;
blankRange(mask, i, j, src);
i = j;
continue;
}
if (ch === "/" && next === "*") {
const close = src.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
blankRange(mask, i, stop, src);
i = stop;
continue;
}
if (ch === '"' || ch === "'" || ch === "`") {
const quote = ch;
let j = i + 1;
while (j < n) {
if (src[j] === "\\") j += 2;
else if (src[j] === quote) { j++; break; }
else j++;
}
// 模板字符串 ${} 内的表达式不解析(接受该处的漏报)
blankRange(mask, i, Math.min(j, n), src);
i = j;
continue;
}
if (ch === "/" && regexAllowedBefore(src, i)) {
let j = i + 1;
let inClass = false;
while (j < n) {
const c = src[j];
if (c === "\\") j += 2;
else if (c === "\n") break; // 正则不能跨行,容错按除法放弃掩码
else if (c === "[") inClass = true;
else if (c === "]") inClass = false;
else if (c === "/" && !inClass) { j++; break; }
else j++;
}
while (j < n && /[\w$]/.test(src[j])) j++; // flags
blankRange(mask, i, j, src);
i = j;
continue;
}
i++;
}
return mask.join("");
}
/* ─────────────────────────── CSS 掩码与解析 ─────────────────────────── */
/** 等长掩码 CSS:块注释与字符串 → 空格(换行保留) */
export function maskCss(src: string): string {
const mask = src.split("");
const n = src.length;
let i = 0;
while (i < n) {
if (src[i] === "/" && src[i + 1] === "*") {
const close = src.indexOf("*/", i + 2);
const stop = close === -1 ? n : close + 2;
blankRange(mask, i, stop, src);
i = stop;
continue;
}
if (src[i] === '"' || src[i] === "'") {
const quote = src[i];
let j = i + 1;
while (j < n) {
if (src[j] === "\\") j += 2;
else if (src[j] === quote) { j++; break; }
else j++;
}
blankRange(mask, i, Math.min(j, n), src);
i = j;
continue;
}
i++;
}
return mask.join("");
}
/** 从 openBrace 后一位起找匹配的 }(掩码文本,引号/注释已是空格) */
function matchBrace(src: string, from: number, to: number): number {
let depth = 1;
for (let i = from; i < to; i++) {
if (src[i] === "{") depth++;
else if (src[i] === "}") {
depth--;
if (depth === 0) return i;
}
}
return to;
}
/** 选择器按顶层逗号切分,保留相对偏移;忽略 () / [] 内的逗号 */
function splitSelectors(head: string): { sel: string; offset: number }[] {
const out: { sel: string; offset: number }[] = [];
let depth = 0;
let start = 0;
for (let i = 0; i < head.length; i++) {
const c = head[i];
if (c === "(" || c === "[") depth++;
else if (c === ")" || c === "]") depth = Math.max(0, depth - 1);
else if (c === "," && depth === 0) {
out.push({ sel: head.slice(start, i), offset: start });
start = i + 1;
}
}
out.push({ sel: head.slice(start), offset: start });
return out;
}
const NESTING_AT = new Set(["@media", "@supports", "@container", "@layer"]);
/* ─────────────────────────── 规则实现 ─────────────────────────── */
const LOOKUP_METHODS = "getElementById|querySelectorAll|querySelector|getElementsByClassName|getElementsByTagName|getElementsByName";
const GLOBAL_LOOKUP_RE = new RegExp(`\\bdocument\\s*\\.\\s*(?:${LOOKUP_METHODS})\\s*\\(`, "g");
const OWNER_LOOKUP_RE = new RegExp(`\\.ownerDocument\\s*\\.\\s*(?:${LOOKUP_METHODS})\\s*\\(`, "g");
const SET_INTERVAL_RE = /\bsetInterval\s*\(/g;
const CLEAR_INTERVAL_RE = /\bclearInterval\s*\(/;
function lintJs(js: string, issues: WidgetLintIssue[]): void {
const map = buildLineMap(js);
const masked = maskJavaScript(js);
const pushAt = (
level: WidgetLintLevel,
code: string,
index: number,
message: string
) => {
const { line, column } = locate(map, index);
issues.push({ level, field: "js", code, line, column, message });
};
for (const re of [GLOBAL_LOOKUP_RE, OWNER_LOOKUP_RE]) {
re.lastIndex = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(masked)) !== null) {
pushAt(
"error",
"no-global-dom-lookup",
m.index,
"禁止从 document/ownerDocument 全局查找元素:同页多个同款工具会互相取错元素。请改用 root.querySelector / root.getElementById 等从 root 出发的查找。"
);
}
}
// 代码里已出现 clearInterval 调用(含 safeInterval 等封装内部、自终止定时器),
// 视为作者已处理清理,不再提醒;完全没有清理痕迹时才逐处 warn。
if (!CLEAR_INTERVAL_RE.test(masked)) {
SET_INTERVAL_RE.lastIndex = 0;
let im: RegExpExecArray | null;
while ((im = SET_INTERVAL_RE.exec(masked)) !== null) {
pushAt(
"warn",
"warn-set-interval",
im.index,
"setInterval 未发现配对的 clearInterval:请保存返回的 id,并在 registerCleanup 中清理,否则离开页面后定时器仍在执行。"
);
}
}
}
function lintCss(css: string, issues: WidgetLintIssue[]): void {
const map = buildLineMap(css);
const src = maskCss(css);
const n = src.length;
const pushAt = (
level: WidgetLintLevel,
code: string,
index: number,
message: string
) => {
const { line, column } = locate(map, index);
issues.push({ level, field: "css", code, line, column, message });
};
const checkSelectors = (head: string, headStart: number) => {
for (const part of splitSelectors(head)) {
const sel = part.sel.trim();
if (!sel) continue;
const abs = headStart + part.offset + (part.sel.length - part.sel.trimStart().length);
if (sel[0] === "*") {
pushAt(
"error",
"no-global-selector",
abs,
"禁止以 * 开头的全局选择器。Shadow DOM 内样式天然隔离,请直接写具体类名。"
);
continue;
}
const gm = sel.match(/^(html|body)\b/i);
if (gm) {
pushAt(
"error",
"no-global-selector",
abs,
`禁止以 ${gm[1].toLowerCase()} 开头的全局选择器:在 Shadow DOM 内它匹配不到任何元素。请改用 .tool-root(CSS 变量也定义在 .tool-root 上)。`
);
}
}
};
const checkDeclarations = (body: string, bodyStart: number) => {
let cursor = 0;
for (const decl of body.split(";")) {
const rel = cursor;
cursor += decl.length + 1;
const colon = decl.indexOf(":");
if (colon < 0) continue;
const prop = decl.slice(0, colon).trim().toLowerCase();
const val = decl.slice(colon + 1).trim();
if (!prop) continue;
const propAbs = bodyStart + rel + decl.indexOf(prop);
if (prop === "position" && /^fixed\b/i.test(val)) {
pushAt(
"error",
"no-fixed-position",
propAbs,
"禁止 position: fixed:会脱离工具容器、盖住全站。请改用 absolute(相对 .tool-root 定位)。"
);
}
if ((prop === "transform" || prop.endsWith("-transform")) && /\bscale\s*\(/i.test(val)) {
pushAt(
"warn",
"warn-scale-transform",
propAbs,
"transform: scale() 可能溢出容器引出版块滚动条;.tool-root 已设 overflow:hidden 裁切,请确认动画不依赖溢出区域可见。"
);
}
}
};
// 递归下降解析规则块;@keyframes 内部整块跳过(动画里的 transform:scale 是正常用法)
const parse = (from: number, to: number) => {
let i = from;
while (i < to) {
const headStart = i;
let j = i;
while (j < to && src[j] !== "{" && src[j] !== "}" && src[j] !== ";") j++;
const head = src.slice(i, j);
if (j >= to) break;
if (src[j] === ";") {
i = j + 1;
continue;
}
if (src[j] === "}") {
i = j + 1; // 容错:多余闭括号
continue;
}
const close = matchBrace(src, j + 1, to);
const headTrim = head.trim();
if (headTrim.startsWith("@")) {
const at = headTrim.split(/[\s(]/)[0].toLowerCase();
if (NESTING_AT.has(at)) parse(j + 1, close);
// @keyframes / @font-face / 其他 at 块:整体跳过
} else {
checkSelectors(head, headStart);
checkDeclarations(src.slice(j + 1, close), j + 1);
}
i = close + 1;
}
};
parse(0, n);
}
/**
* 保存前静态扫描。返回问题列表(按字段、源码位置排序);
* 有 error 时调用方应阻止保存,仅有 warn 时需用户二次确认。
*/
export function lintWidgetCode(input: WidgetLintInput): WidgetLintIssue[] {
const issues: WidgetLintIssue[] = [];
// html 本轮不产出规则;css 问题排在 js 之前
if (input.css) lintCss(input.css, issues);
if (input.js) lintJs(input.js, issues);
return issues;
}

View File

@@ -0,0 +1,179 @@
// 自定义侧栏工具的 Shadow DOM 运行时(仅浏览器端,由 CustomWidgetBlock 的 effect 调用):
// - 每个工具实例独占一个 shadow root:用户 CSS 注入 shadow 内,天然不外泄;
// - 用户 HTML 挂在 .tool-root 下,用户 JS 以函数形式手动执行,
// 通过参数拿到 (root, shadow, toolId, registerCleanup),DOM 查找一律从 root 出发;
// - mount / unmount 幂等:同一 host 复用 shadow root、先清空再注入,
// 兼容 React StrictMode 双调用、依赖变更重挂与同页多个同款工具实例。
export interface WidgetSource {
id: number;
html: string;
css: string;
js: string;
}
/** 用户脚本通过 registerCleanup 注册的卸载回调(停定时器、移除监听等) */
export type WidgetCleanup = () => void;
/** 传给用户脚本的运行时上下文 */
export interface WidgetRuntimeContext {
/** 工具根节点:所有 querySelector / getElementById 都应从这里出发 */
root: HTMLElement;
shadow: ShadowRoot;
toolId: number;
registerCleanup: (fn: WidgetCleanup) => void;
}
export interface WidgetHandle {
unmount: () => void;
}
// shadow 边界内没有 Tailwind preflight,这里补一份最小 reset:
// 只做「盒模型一致 + 外边距归零 + 字体颜色继承」,视觉仍完全交给用户 CSS。
// CSS 变量(var(--accent) 等)沿 DOM 继承,可自动穿过 shadow 边界,无需在此声明。
const BASE_CSS = `
:host {
display: block;
min-width: 0;
}
.tool-root {
overflow: hidden;
min-width: 0;
}
.tool-root,
.tool-root *,
.tool-root *::before,
.tool-root *::after {
box-sizing: border-box;
}
.tool-root blockquote,
.tool-root dl,
.tool-root dd,
.tool-root h1,
.tool-root h2,
.tool-root h3,
.tool-root h4,
.tool-root h5,
.tool-root h6,
.tool-root hr,
.tool-root figure,
.tool-root p,
.tool-root pre {
margin: 0;
}
.tool-root button,
.tool-root input,
.tool-root select,
.tool-root textarea {
font: inherit;
letter-spacing: inherit;
color: inherit;
}
.tool-root button {
background: transparent;
border: 0;
padding: 0;
cursor: pointer;
}
.tool-root img,
.tool-root svg,
.tool-root video,
.tool-root canvas {
display: block;
max-width: 100%;
}
`.trim();
function reportError(toolId: number, phase: string, err: unknown): void {
// 用户代码出错不静默:统一带工具 id 前缀,便于在控制台定位
console.error(`[自定义工具#${toolId}] ${phase}失败:`, err);
}
/**
* 把工具挂载到 host:attachShadow → 注入基础/用户样式 → 放 .tool-root → 执行用户 JS。
* 返回的 unmount 幂等,重复调用只有第一次生效。
*/
export function mountWidget(host: HTMLElement, source: WidgetSource): WidgetHandle {
const shadow = host.shadowRoot ?? host.attachShadow({ mode: "open" });
// 幂等重挂(StrictMode / 依赖变更):清掉上一次的全部内容后重建
shadow.replaceChildren();
const baseStyle = document.createElement("style");
baseStyle.dataset.widgetBase = "";
baseStyle.textContent = BASE_CSS;
shadow.appendChild(baseStyle);
if (source.css.trim()) {
const userStyle = document.createElement("style");
userStyle.dataset.widgetStyle = String(source.id);
// shadow 内天然隔离,用户 CSS 原样注入、不再加选择器前缀
userStyle.textContent = source.css;
shadow.appendChild(userStyle);
}
const root = document.createElement("div");
root.className = "tool-root";
root.dataset.tool = String(source.id);
if (source.html.trim()) root.innerHTML = source.html;
shadow.appendChild(root);
const cleanups: WidgetCleanup[] = [];
let disposed = false;
const context: WidgetRuntimeContext = {
root,
shadow,
toolId: source.id,
registerCleanup(fn) {
if (disposed) {
// 卸载后注册没有意义,直接丢弃并提示,避免用户误以为还会执行
try {
fn();
} catch (err) {
reportError(source.id, "卸载后注册的 cleanup 执行", err);
}
return;
}
if (typeof fn === "function") cleanups.push(fn);
},
};
if (source.js.trim()) {
try {
// 站长可信内容:函数构造器手动执行,参数即工具运行时上下文
const fn = new Function(
"root",
"shadow",
"toolId",
"registerCleanup",
`"use strict";\n${source.js}`
) as (
root: HTMLElement,
shadow: ShadowRoot,
toolId: number,
registerCleanup: WidgetRuntimeContext["registerCleanup"]
) => unknown;
fn(context.root, context.shadow, context.toolId, context.registerCleanup);
} catch (err) {
reportError(source.id, "脚本执行", err);
}
}
return {
unmount() {
if (disposed) return;
disposed = true;
// 后注册的先执行,符合多数资源(监听/定时器)的释放直觉
for (let i = cleanups.length - 1; i >= 0; i--) {
try {
cleanups[i]();
} catch (err) {
reportError(source.id, "cleanup 执行", err);
}
}
cleanups.length = 0;
shadow.replaceChildren();
},
};
}

View File

@@ -337,7 +337,8 @@ export const config = {
matcher: [
// 小写 /robots.txt 由 Metadata Route 直接响应,不进 middleware。
// 故意不排除 .txt:否则 /Robots.txt 到不了 rewriteRobotsCase。
"/((?!api/|healthz|_next/static/|_next/image/|favicon.ico|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?)$).*)",
// fonts/ 为构建抽取的静态字体声明,排除以免页面级 no-store 覆盖其缓存头。
"/((?!api/|healthz|fonts/|_next/static/|_next/image/|favicon.ico|robots.txt|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|woff2?)$).*)",
],
};

View File

@@ -1,7 +1,7 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/dev/types/routes.d.ts";
import "./.next/dev/types/root-params.d.ts";
import "./.next/types/routes.d.ts";
import "./.next/types/root-params.d.ts";
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.

View File

@@ -39,6 +39,17 @@ const nextConfig: NextConfig = {
},
];
},
async headers() {
return [
{
// 构建后抽取的 Noto Sans SC 字体声明(异步加载,见 scripts/extract-font-css.mjs)。
// 引用的 woff2 在 /_next/static/media/ 已 immutable;CSS 本身文件名固定、内容随
// 字体版本变,用 1 天中等 TTL 稳妥。
source: "/fonts/:path*",
headers: [{ key: "Cache-Control", value: "public, max-age=86400" }],
},
];
},
};
export default nextConfig;

View File

@@ -4,10 +4,12 @@
"private": true,
"scripts": {
"dev": "next dev -p 3000",
"predev": "node scripts/extract-font-css.mjs",
"build": "next build",
"postbuild": "node scripts/extract-font-css.mjs",
"start": "next start -p 3000",
"lint": "next lint",
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/mobileTabs.test.ts lib/headerNav.test.ts lib/widgetCode.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts lib/entryBlocks.test.ts"
"test": "node --experimental-strip-types --test lib/siteUrl.test.ts lib/jsonLd.test.ts lib/urlStyle.test.ts lib/mobileTabs.test.ts lib/headerNav.test.ts lib/widgetCode.test.ts lib/widgetLint.test.ts lib/smartDiff.test.ts lib/dyEmoji.test.ts lib/entryBlocks.test.ts lib/bookSearch.test.ts"
},
"dependencies": {
"hast-util-to-jsx-runtime": "^2.3.6",

View File

@@ -0,0 +1,79 @@
// 构建后处理:把 next/font 生成的 Noto Sans SC 字体声明(6 字重 × ~101 个
// unicode-range 切片 ≈ 606 条 @font-face,压缩后 ~200 KiB)从渲染阻塞的 CSS
// chunk 中抽到 public/fonts/noto-sans-sc.css,由 app/layout.tsx 注入的脚本异步
// 加载。next/font 机制原样保留:woff2 仍在 /_next/static/media/(immutable),
// 度量校正 fallback 面与 Jakarta/JetBrains 声明仍留在阻塞 CSS。
//
// 挂载点:package.json 的 postbuild(构建后必跑)与 predev(有上次构建产物时
// 让 dev 也有字体;无产物则静默跳过)。
//
// 用法:node scripts/extract-font-css.mjs
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
const root = resolve(dirname(fileURLToPath(import.meta.url)), "..");
const staticDir = join(root, ".next", "static");
const outDir = join(root, "public", "fonts");
const outFile = join(outDir, "noto-sans-sc.css");
// 递归收集 .next/static 下所有产物 CSS(chunks 与 css 目录都扫,与 bundler 无关)
function collectCssFiles(dir) {
if (!existsSync(dir)) return [];
const out = [];
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const p = join(dir, entry.name);
if (entry.isDirectory()) out.push(...collectCssFiles(p));
else if (entry.isFile() && entry.name.endsWith(".css")) out.push(p);
}
return out;
}
// @font-face 内无嵌套花括号(压缩产物),逐块提取
const FACE_RE = /@font-face\{[^{}]*\}/g;
// font-family 值精确等于 "Noto Sans SC"(产物中可能不带引号);
// "Noto Sans SC Fallback" 因值不等而自然排除,必须留在阻塞 CSS 兜度量
function isNotoFace(block) {
const m = block.match(/font-family\s*:\s*([^;}]+)/);
if (!m) return false;
return m[1].trim().replace(/^["']|["']$/g, "") === "Noto Sans SC";
}
const cssFiles = collectCssFiles(staticDir);
let extracted = [];
const touched = new Map(); // file -> 重写后的内容
for (const file of cssFiles) {
const content = readFileSync(file, "utf8");
const faces = content.match(FACE_RE);
if (!faces) continue;
const noto = faces.filter(isNotoFace);
if (noto.length === 0) continue;
// 抽出块里的相对 URL(../media/)改为绝对路径:CSS 将从 /fonts/ 引用
extracted.push(...noto.map((b) => b.replaceAll("../media/", "/_next/static/media/")));
let next = content;
for (const b of noto) next = next.replace(b, "");
touched.set(file, next);
}
if (extracted.length === 0) {
if (existsSync(outFile) || !existsSync(staticDir)) {
// 已抽取过 / 上游产物无字体声明(无构建产物时的 predev 场景):静默跳过
console.log("[extract-font-css] 无可抽取的 Noto Sans SC 声明,跳过");
process.exit(0);
}
// 有构建产物却扫不到字体声明:bundler 行为可能变化,必须响亮失败
console.error(
"[extract-font-css] 构建产物中未找到 Noto Sans SC @font-face,字体加载机制可能已变化,请人工检查 .next/static 下的 CSS"
);
process.exit(1);
}
mkdirSync(outDir, { recursive: true });
writeFileSync(outFile, extracted.join("\n") + "\n");
for (const [file, content] of touched) writeFileSync(file, content);
console.log(
`[extract-font-css] 已抽出 ${extracted.length} 条 @font-face → public/fonts/noto-sans-sc.css(涉及 ${touched.size} 个 chunk)`
);