公开页补齐自引用 Canonical 与按类型的结构化数据,图片补上说明文字。

后台设置和内容编辑改成可导航布局;部署用访客 origin 生成 Canonical,避免落到容器地址。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 00:11:24 +08:00
parent 2b3d122e56
commit 40bbad2c82
51 changed files with 3042 additions and 1318 deletions

View File

@@ -38,7 +38,7 @@ export default function Avatar({
// eslint-disable-next-line @next/next/no-img-element
<img
src={src}
alt=""
alt={name.trim() || "用户头像"}
className="w-full h-full object-cover"
onError={() => setImgFailed(true)}
/>

View File

@@ -327,7 +327,12 @@ export default function AvatarCropModal({
className="absolute inset-0 rounded-full overflow-hidden bg-[var(--panel-2)] transition-transform hover:scale-105 disabled:cursor-default disabled:hover:scale-100"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img src={item.url} alt="" className="w-full h-full object-cover" loading="lazy" />
<img
src={item.url}
alt={item.kind === "avatar" ? "历史头像" : "我的图片"}
className="w-full h-full object-cover"
loading="lazy"
/>
</button>
{/* hover 指示:成品头像=直接使用,其他图片=裁剪;选用中显示 spinner */}

View File

@@ -45,18 +45,18 @@ export default function BrandLockup({
{src ? (
both ? (
<>
<Logo src={logoLight} fit={fit} extra="j13-brand-logo-light" />
<Logo src={logoDark} fit={fit} extra="j13-brand-logo-dark" />
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" />
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" />
</>
) : (
<Logo src={src} fit={fit} />
<Logo src={src} fit={fit} alt={label} decorative={showText} />
)
) : (
<Bubble h={box.h} />
<Bubble h={box.h} label={showText ? undefined : label} />
)}
</span>
) : (
<Bubble h={36} />
<Bubble h={36} label={showText ? undefined : label} />
)
) : null}
{showText ? (
@@ -75,10 +75,26 @@ export default function BrandLockup({
);
}
function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?: string }) {
function Logo({
src,
fit,
alt,
decorative = false,
extra = "",
}: {
src: string;
fit: BrandLogoFit;
alt: string;
/** 旁边已有站名时,读屏跳过,HTML 仍保留 alt 供检索 */
decorative?: boolean;
extra?: string;
}) {
if (fit === "repeat") {
return (
<span
role={decorative ? undefined : "img"}
aria-hidden={decorative || undefined}
aria-label={decorative ? undefined : alt}
className={`j13-brand-logo j13-brand-logo-tile ${extra}`}
style={{ backgroundImage: `url("${src.replace(/["\\\n\r]/g, "")}")` }}
/>
@@ -86,17 +102,20 @@ function Logo({ src, fit, extra = "" }: { src: string; fit: BrandLogoFit; extra?
}
return (
/* eslint-disable-next-line @next/next/no-img-element */
<img src={src} alt="" className={`j13-brand-logo ${extra}`} />
<img src={src} alt={alt} aria-hidden={decorative || undefined} className={`j13-brand-logo ${extra}`} />
);
}
function Bubble({ h }: { h: number }) {
function Bubble({ h, label }: { h: number; label?: string }) {
return (
<span
role={label ? "img" : undefined}
aria-label={label}
aria-hidden={label ? undefined : true}
className="j13-brand-bubble rounded-2xl flex items-center justify-center shrink-0 transition-transform duration-300 group-hover:rotate-[-6deg]"
style={{ width: h, height: h, background: "var(--accent)", color: "var(--accent-on)" }}
>
<svg width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<svg aria-hidden="true" focusable="false" width="50%" height="50%" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 11.5a8.38 8.38 0 0 1-.9 3.8 8.5 8.5 0 0 1-7.6 4.7 8.38 8.38 0 0 1-3.8-.9L3 21l1.9-5.7a8.38 8.38 0 0 1-.9-3.8 8.5 8.5 0 0 1 4.7-7.6 8.38 8.38 0 0 1 3.8-.9h.5a8.48 8.48 0 0 1 8 8v.5z" />
</svg>
</span>

View File

@@ -429,7 +429,7 @@ export default function CommentSection({
const timeMeta = (c: CommentNode) => (
<span className="meta text-[11.5px] inline-flex items-center gap-1.5 flex-wrap">
<span>{new Date(c.created_at).toLocaleString("zh-CN")}</span>
<time dateTime={c.created_at}>{new Date(c.created_at).toLocaleString("zh-CN")}</time>
{c.edited && <span title="此评论曾被编辑">已编辑</span>}
</span>
);

View File

@@ -10,7 +10,7 @@ function realVersion(pkgVersion: string): string {
return !pkgVersion || pkgVersion === "0.0.0" || pkgVersion === "0.1.0" ? "" : pkgVersion;
}
// 极简页脚:版权 + 合规链接 + 实时性能指标
// 极简页脚:左侧版权 / 版本 / 耗时,右侧备案等链接
export default function Footer({
siteName = "姜十三社区",
links = [],
@@ -20,6 +20,7 @@ export default function Footer({
}) {
const year = new Date().getFullYear();
const brand = siteName.trim() || "姜十三社区";
const visibleLinks = links.filter((item) => item.label.trim() && item.url.trim());
return (
<footer
@@ -29,10 +30,13 @@ export default function Footer({
}}
>
<div className="max-w-[1440px] mx-auto px-4 sm:px-6 py-4 flex items-center justify-between gap-x-4 gap-y-2 flex-wrap">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
{links.length > 0 ? (
<nav aria-label="页脚链接" className="flex flex-wrap items-center gap-x-3 gap-y-1 min-w-0">
{links.map((item) => (
<div className="flex items-center gap-x-3 gap-y-1 flex-wrap min-w-0">
<span className="meta text-[12px] whitespace-nowrap">© {year} {brand}</span>
<FooterTiming version={realVersion(pkg.version)} />
</div>
{visibleLinks.length > 0 ? (
<nav aria-label="页脚链接" className="flex flex-wrap items-center justify-end gap-x-3 gap-y-1 min-w-0 ml-auto">
{visibleLinks.map((item) => (
<a
key={`${item.label}-${item.url}`}
href={item.url}
@@ -44,7 +48,6 @@ export default function Footer({
))}
</nav>
) : null}
<FooterTiming version={realVersion(pkg.version)} />
</div>
</footer>
);

View File

@@ -110,8 +110,12 @@ export default function FooterTiming({ version = "" }: { version?: string }) {
return (
<Suspense
fallback={
<span className="text-[12px] meta whitespace-nowrap">
<span className="inline-flex items-center gap-2 text-[12px] tabular-nums whitespace-nowrap" style={{ color: "var(--ink-3)" }}>
版本:{version || PLACEHOLDER}
<span aria-hidden="true">·</span>
页面:{PLACEHOLDER}
<span aria-hidden="true">·</span>
模板:{PLACEHOLDER}
</span>
}
>

View File

@@ -0,0 +1,12 @@
import { serializeJsonLd } from "@/lib/jsonLd";
/** 服务端输出单个 application/ld+json,不在客户端补脚本 */
export default function JsonLd({ data }: { data: Record<string, unknown> | null }) {
if (!data) return null;
return (
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: serializeJsonLd(data) }}
/>
);
}

View File

@@ -18,8 +18,10 @@ export default function Pagination({ total, page, size, basePath, query = {}, on
const buildHref = (p: number) => {
const params = new URLSearchParams(query);
params.set("page", String(p));
return `${basePath}?${params.toString()}`;
if (p > 1) params.set("page", String(p));
else params.delete("page");
const qs = params.toString();
return qs ? `${basePath}?${qs}` : basePath;
};
const handleClick = (p: number, e: React.MouseEvent) => {

View File

@@ -75,9 +75,9 @@ export default async function PostRow({
className="stretch-link min-w-0 shrink"
aria-label={post.title}
>
<h3 className="text-[15px] font-normal leading-snug truncate text-[var(--ink)] transition-colors group-hover:text-[var(--accent)]">
<h2 className="text-[15px] font-normal leading-snug truncate text-[var(--ink)] transition-colors group-hover:text-[var(--accent)]">
{post.title}
</h3>
</h2>
</Link>
{/* 其余徽章紧跟标题右侧 */}
{post.status === "pending" && <span className="badge-pending shrink-0">待审</span>}

View File

@@ -15,6 +15,9 @@ export default function AdminField({
disabled,
className = "",
minHeight,
embed,
error,
describedBy,
}: {
id: string;
label: ReactNode;
@@ -29,27 +32,40 @@ export default function AdminField({
disabled?: boolean;
className?: string;
minHeight?: string;
/** 标签由外层设置行提供,本组件只保留计数与输入 */
embed?: boolean;
error?: string;
describedBy?: string;
}) {
const handle = (e: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) =>
onChange(e.target.value);
const invalid = Boolean(error);
const described = [describedBy, error ? `${id}-error` : null].filter(Boolean).join(" ") || undefined;
const count =
maxLength != null ? (
<span className={`admin-field-count${embed ? " is-infield" : ""}`} aria-hidden>
{value.length}/{maxLength}
</span>
) : null;
return (
<div className={className}>
<div className="admin-field-head">
<label className="field-label" htmlFor={id}>
{label}
</label>
{maxLength != null && (
<span className="admin-field-count" aria-hidden>
{value.length}/{maxLength}
</span>
)}
</div>
{hint != null && hint !== "" && (
{embed ? null : (
<div className="admin-field-head">
<label className="field-label" htmlFor={id}>
{label}
</label>
{count}
</div>
)}
{!embed && hint != null && hint !== "" && (
<p className="meta text-[12.5px] mb-2.5 leading-relaxed">{hint}</p>
)}
{as === "textarea" ? (
<div className="admin-field-shell is-area">
<div className={`admin-field-shell is-area${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
{embed ? count : null}
<textarea
id={id}
className="admin-field-input"
@@ -59,10 +75,12 @@ export default function AdminField({
onChange={handle}
placeholder={placeholder}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={described}
/>
</div>
) : (
<div className="admin-field-shell">
<div className={`admin-field-shell${embed && count ? " has-count" : ""}${invalid ? " is-invalid" : ""}`}>
<input
id={id}
type={type}
@@ -73,9 +91,17 @@ export default function AdminField({
placeholder={placeholder}
autoComplete={autoComplete}
disabled={disabled}
aria-invalid={invalid || undefined}
aria-describedby={described}
/>
{embed ? count : null}
</div>
)}
{error ? (
<p className="admin-settings-row-error" id={`${id}-error`} role="alert">
{error}
</p>
) : null}
</div>
);
}

View File

@@ -0,0 +1,160 @@
import { Loader2 } from "lucide-react";
import type { ReactNode } from "react";
/** 站点设置工作区:限宽、主内容区内左对齐,不写入后台公共主容器 */
export function AdminSettingsPage({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return <div className={`admin-settings-page${className ? ` ${className}` : ""}`}>{children}</div>;
}
/** 分类导航 + 主表单列 */
export function AdminSettingsWork({
nav,
children,
}: {
nav: ReactNode;
children: ReactNode;
}) {
return (
<div className="admin-settings-work">
<div className="admin-settings-nav-col">{nav}</div>
<div className="admin-settings-main">{children}</div>
</div>
);
}
/** 一组相关设置:标题、说明、字段、底部操作,减少逐项套卡片 */
export function AdminSettingsGroup({
title,
description,
children,
footer,
id,
className,
}: {
title: string;
description?: ReactNode;
children: ReactNode;
footer?: ReactNode;
id?: string;
className?: string;
}) {
return (
<section className={`admin-settings-group${className ? ` ${className}` : ""}`} id={id}>
<header className="admin-settings-group-head">
<h2 className="admin-settings-group-title">{title}</h2>
{description != null && description !== "" ? (
<p className="admin-settings-group-desc">{description}</p>
) : null}
</header>
<div className="admin-settings-group-body">{children}</div>
{footer != null ? <div className="admin-settings-group-footer">{footer}</div> : null}
</section>
);
}
/** 标签列 + 控件列;窄表单自动改为上下堆叠 */
export function AdminSettingsRow({
label,
htmlFor,
hint,
hintBelow,
error,
errorId,
children,
wide,
className,
}: {
label: ReactNode;
htmlFor?: string;
/** 短说明,紧邻标签 */
hint?: ReactNode;
/** 长说明,放在控件下方 */
hintBelow?: ReactNode;
error?: ReactNode;
errorId?: string;
children: ReactNode;
/** 简介等较长控件 */
wide?: boolean;
className?: string;
}) {
const Tag = htmlFor ? "label" : "div";
return (
<div className={`admin-settings-row${wide ? " is-wide" : ""}${className ? ` ${className}` : ""}`}>
<div className="admin-settings-row-label">
<Tag className="admin-settings-row-name" {...(htmlFor ? { htmlFor } : {})}>
{label}
</Tag>
{hint != null && hint !== "" ? <p className="admin-settings-row-hint">{hint}</p> : null}
</div>
<div className="admin-settings-row-control">
{children}
{hintBelow != null && hintBelow !== "" ? (
<p className="admin-settings-row-hint is-below">{hintBelow}</p>
) : null}
{error != null && error !== "" ? (
<p className="admin-settings-row-error" id={errorId} role="alert">
{error}
</p>
) : null}
</div>
</div>
);
}
/** 保存单元底部操作:按钮与状态贴齐控件列左边缘 */
export function AdminSettingsActions({
dirty,
saving,
saveLabel,
onSave,
onDiscard,
saveDisabled,
error,
children,
}: {
dirty: boolean;
saving?: boolean;
saveLabel: string;
onSave: () => void;
onDiscard?: () => void;
saveDisabled?: boolean;
error?: string;
children?: ReactNode;
}) {
const status = saving ? "正在保存…" : error ? error : dirty ? "有未保存的修改" : "已保存";
return (
<div className="admin-settings-actions" aria-busy={saving || undefined}>
<div className="admin-settings-actions-inner">
<div className="admin-settings-actions-btns">
{children}
{onDiscard ? (
<button type="button" className="btn btn-line btn-sm" disabled={!dirty || saving} onClick={onDiscard}>
撤销修改
</button>
) : null}
<button
type="button"
className="btn btn-primary btn-sm"
disabled={saveDisabled ?? (!dirty || saving)}
onClick={onSave}
>
{saving ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
{saving ? "保存中…" : saveLabel}
</button>
</div>
<p
className={`admin-settings-actions-status${error ? " is-error" : ""}`}
aria-live="polite"
>
{status}
</p>
</div>
</div>
);
}

View File

@@ -2,6 +2,13 @@ export { default as AdminPageHeader } from "./AdminPageHeader";
export { default as AdminSection } from "./AdminSection";
export { default as AdminSwitch } from "./AdminSwitch";
export { default as AdminSettingRow } from "./AdminSettingRow";
export {
AdminSettingsPage,
AdminSettingsWork,
AdminSettingsGroup,
AdminSettingsRow,
AdminSettingsActions,
} from "./AdminSettingsLayout";
export { default as AdminSegmented } from "./AdminSegmented";
export type { AdminSegmentOption } from "./AdminSegmented";
export { default as AdminSearchField } from "./AdminSearchField";

View File

@@ -10,11 +10,12 @@ export type FeedSort = "latest" | "new" | "recommended";
const TAB_KEYS: FeedSort[] = ["latest", "new", "recommended"];
// 在指定板块内切换排序时保留 ?board=
function tabHref(key: FeedSort, boardId: number) {
// 在指定板块内切换排序时保留 ?board=;默认排序不写入 URL
function tabHref(key: FeedSort, boardId: number, allowComments: boolean) {
const params = new URLSearchParams();
if (boardId > 0) params.set("board", String(boardId));
if (key !== "latest") params.set("sort", key);
const def = allowComments ? "latest" : "new";
if (key !== def) params.set("sort", key);
const qs = params.toString();
return qs ? `/?${qs}` : "/";
}
@@ -31,7 +32,7 @@ export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort;
e.preventDefault();
const next = (i + (e.key === "ArrowRight" ? 1 : keys.length - 1)) % keys.length;
tabRefs.current[next]?.focus();
router.push(tabHref(keys[next], boardId));
router.push(tabHref(keys[next], boardId, allowComments));
};
return (
@@ -44,7 +45,7 @@ export default function FeedTabs({ current, boardId = 0 }: { current: FeedSort;
ref={(el) => {
tabRefs.current[i] = el;
}}
href={tabHref(key, boardId)}
href={tabHref(key, boardId, allowComments)}
role="tab"
className="j13-tab inline-flex items-center gap-1"
aria-selected={current === key}

View File

@@ -2,8 +2,9 @@
import Link from "next/link";
import { useSearchParams } from "next/navigation";
import { PenSquare, Home, LayoutGrid } from "lucide-react";
import { PenSquare, Home, LayoutGrid, ShieldCheck } from "lucide-react";
import type { BoardWithCount, User } from "@/lib/api";
import { isStaff } from "@/lib/roles";
// 首页壳内板块切换:仅替换中栏帖子流,不离开工作台
function boardHref(id: number) {
@@ -11,9 +12,10 @@ function boardHref(id: number) {
}
// 桌面左栏:整块模块,与右栏 j13-module 对齐
export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User | null }) {
export function WorkspaceNav({ boards, user }: { boards: BoardWithCount[]; user?: User | null }) {
const params = useSearchParams();
const activeBoard = Number(params.get("board")) || 0;
const showAdmin = !!user && isStaff(user.role);
return (
// sticky 必须挂在 flex 子项上:父级 items-start 会把内层缩成内容高,失去粘滞行程
@@ -68,6 +70,21 @@ export function WorkspaceNav({ boards }: { boards: BoardWithCount[]; user?: User
</Link>
))}
</div>
{showAdmin ? (
<div className="shrink-0 mt-1.5 pt-1.5" style={{ borderTop: "1px solid var(--line)" }}>
<Link href="/admin" className="j13-bcard">
<span
className="j13-bcard-ico"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden="true"
>
<ShieldCheck size={15} />
</span>
<span className="j13-bcard-name flex-1 truncate">管理面板</span>
</Link>
</div>
) : null}
</section>
</nav>
);

View File

@@ -376,7 +376,7 @@ export default function ImageLightbox({
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={current.src}
alt={current.alt || ""}
alt={current.alt?.trim() || "图片"}
className={`md-lightbox-img${oddRot ? " is-odd-rot" : ""}${snap ? " is-snap" : ""}`}
draggable={false}
style={{

View File

@@ -13,14 +13,14 @@ function collectGallery(from: HTMLImageElement): { items: LightboxItem[]; index:
const items: LightboxItem[] = nodes
.map((el) => ({
src: el.currentSrc || el.getAttribute("src") || "",
alt: el.getAttribute("alt") || undefined,
alt: el.dataset.caption || undefined,
}))
.filter((it) => !!it.src);
const selfSrc = from.currentSrc || from.getAttribute("src") || "";
let index = items.findIndex((it) => it.src === selfSrc);
if (index < 0) index = 0;
if (items.length === 0 && selfSrc) {
return { items: [{ src: selfSrc, alt: from.alt || undefined }], index: 0 };
return { items: [{ src: selfSrc, alt: from.dataset.caption || undefined }], index: 0 };
}
return { items, index };
}
@@ -39,10 +39,13 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
setLightbox(gallery);
}, []);
const provided = (typeof alt === "string" ? alt.trim() : "") || (typeof title === "string" ? title.trim() : "");
const imgAlt = provided || "图片";
if (!src || broken) {
return (
<span className="md-img-fallback" role="img" aria-label={alt || "图片加载失败"}>
{alt || "图片加载失败"}
<span className="md-img-fallback" role="img" aria-label={provided || "图片加载失败"}>
{provided || "图片加载失败"}
</span>
);
}
@@ -53,7 +56,8 @@ export default function MdImage({ src, alt, title, className, ...rest }: MdImage
<img
{...rest}
src={src}
alt={alt || ""}
alt={imgAlt}
data-caption={provided || undefined}
title={title}
className={`md-content-img${className ? ` ${className}` : ""}`}
loading="lazy"