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}
|
||||
|
||||
Reference in New Issue
Block a user