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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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