feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
This commit is contained in:
@@ -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} />;
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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="公告管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) {
|
||||
// 已登录但无后台权限:保留提示卡片
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -410,6 +410,7 @@ export default function LibrarySectionsPanel({
|
||||
placeholder="本章正文,支持 Markdown…"
|
||||
rows={16}
|
||||
ariaLabel="章节正文"
|
||||
uploadSource="library_content"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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="书库管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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="单页管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
|
||||
@@ -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" }}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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> 返回 "light" | "dark";<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,
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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"
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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}
|
||||
</>
|
||||
|
||||
@@ -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)}
|
||||
/>
|
||||
) : (
|
||||
|
||||
327
frontend/components/BookSearch.tsx
Normal file
327
frontend/components/BookSearch.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)" }} />
|
||||
|
||||
@@ -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)" }} /> 管理面板
|
||||
|
||||
@@ -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"
|
||||
|
||||
241
frontend/components/LibrarySearchGrid.tsx
Normal file
241
frontend/components/LibrarySearchGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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]">
|
||||
|
||||
@@ -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)" }}
|
||||
>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
};
|
||||
|
||||
@@ -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),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
// 客户端:站长任命/撤销群管理员
|
||||
|
||||
92
frontend/lib/bookSearch.test.ts
Normal file
92
frontend/lib/bookSearch.test.ts
Normal 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
147
frontend/lib/bookSearch.ts
Normal 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);
|
||||
}
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) } : {}),
|
||||
})),
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
52
frontend/lib/responsiveImage.ts
Normal file
52
frontend/lib/responsiveImage.ts
Normal 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(", ");
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
/**
|
||||
* 能否管理指定板块的内容(帖子/评论的编辑删除审核):
|
||||
* 管理员及以上不限板块;板块管理员仅限被授权板块
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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: "{" }`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
218
frontend/lib/widgetLint.test.ts
Normal file
218
frontend/lib/widgetLint.test.ts
Normal 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
378
frontend/lib/widgetLint.ts
Normal 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;
|
||||
}
|
||||
179
frontend/lib/widgetRuntime.ts
Normal file
179
frontend/lib/widgetRuntime.ts
Normal 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();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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?)$).*)",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
4
frontend/next-env.d.ts
vendored
4
frontend/next-env.d.ts
vendored
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
79
frontend/scripts/extract-font-css.mjs
Normal file
79
frontend/scripts/extract-font-css.mjs
Normal 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)`
|
||||
);
|
||||
Reference in New Issue
Block a user