feat: 代码片段功能与 Markdown 内嵌片段渲染、文档截图等

This commit is contained in:
2026-10-09 18:53:38 +08:00
parent 5598e1cbb7
commit da72843c70
64 changed files with 7047 additions and 248 deletions

View File

@@ -1,6 +1,6 @@
"use client";
import { ArrowDown, ArrowUp, BookOpen, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
import { ArrowDown, ArrowUp, BookOpen, CodeXml, ExternalLink, Home, Plus, Trash2, Trophy } from "lucide-react";
import { useEffect, useState } from "react";
import { AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, type PublicSettings } from "@/lib/api";
@@ -19,6 +19,7 @@ const BUILTIN_ICONS: Record<HeaderNavBuiltinKey, React.ReactNode> = {
home: <Home size={14} />,
leaderboard: <Trophy size={14} />,
library: <BookOpen size={14} />,
snippets: <CodeXml size={14} />,
};
/** 内置项目标页说明(实际地址随伪静态风格变化,不允许改地址) */
@@ -26,6 +27,7 @@ const BUILTIN_HINTS: Record<HeaderNavBuiltinKey, string> = {
home: "站点首页 /",
leaderboard: "排行榜页(地址随伪静态风格变化)",
library: "书库页(地址随伪静态风格变化)",
snippets: "片段广场页(地址随伪静态风格变化)",
};
type BuiltinSwitches = Record<HeaderNavBuiltinKey, boolean>;
@@ -42,6 +44,7 @@ function switchesOf(items: HeaderNavItem[]): BuiltinSwitches {
home: present.has("home"),
leaderboard: present.has("leaderboard"),
library: present.has("library"),
snippets: present.has("snippets"),
};
}

View File

@@ -0,0 +1,277 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import { CodeXml, Copy, Eye, EyeOff, GitFork, Loader2, MessagesSquare, Trash2 } from "lucide-react";
import {
AdminEmpty,
AdminPageHeader,
AdminPager,
AdminSearchField,
AdminSegmented,
} from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import Avatar from "@/components/Avatar";
import {
apiAdminDeleteSnippet,
apiAdminSetSnippetVisibility,
apiAdminSnippets,
type SnippetListItem,
} from "@/lib/api";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
import { snippetPath } from "@/lib/urlStyle";
import { useUrlStyle } from "@/components/SiteBrandProvider";
type VisibilityFilter = "all" | "public" | "private";
const PAGE_SIZE = 20;
/** 片段管理:全量列表(含私有),支持搜索、可见性筛选、下架/上架与删除。 */
export default function SnippetsAdmin() {
const urlStyle = useUrlStyle();
const [items, setItems] = useState<SnippetListItem[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [vis, setVis] = useState<VisibilityFilter>("all");
const [keyword, setKeyword] = useState("");
const [q, setQ] = useState("");
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [confirm, setConfirm] = useState<
| { kind: "delete"; item: SnippetListItem }
| { kind: "hide" | "show"; item: SnippetListItem }
| null
>(null);
const [busy, setBusy] = useState(false);
const load = useCallback(async (p: number, v: VisibilityFilter, kw: string) => {
setLoading(true);
setError("");
try {
const res = await apiAdminSnippets({
q: kw || undefined,
visibility: v === "all" ? undefined : v,
page: p,
size: PAGE_SIZE,
});
setItems(res.items);
setTotal(res.total);
setPage(p);
} catch (e) {
setError(e instanceof Error ? e.message : "加载失败");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load(1, "all", "");
}, [load]);
const runConfirm = async () => {
if (!confirm) return;
setBusy(true);
try {
if (confirm.kind === "delete") {
await apiAdminDeleteSnippet(confirm.item.id);
toast("片段已删除", "ok");
} else {
const next = confirm.kind === "hide" ? "private" : "public";
await apiAdminSetSnippetVisibility(confirm.item.id, next);
toast(next === "private" ? "已下架为私有" : "已恢复为公开", "ok");
}
setConfirm(null);
await load(page, vis, q);
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败");
} finally {
setBusy(false);
}
};
return (
<div>
<AdminPageHeader
icon={CodeXml}
title="片段管理"
description="查看全站代码片段(含私有),可下架违规公开片段或删除。"
/>
<div
className="rounded-2xl overflow-hidden"
style={{ border: "1px solid var(--line)", background: "var(--panel)" }}
>
<div
className="flex items-center justify-between gap-2 flex-wrap px-4 sm:px-5 py-3"
style={{ borderBottom: "1px solid var(--line)" }}
>
<AdminSegmented<VisibilityFilter>
ariaLabel="可见性筛选"
size="sm"
value={vis}
onChange={(v) => void load(1, v, q)}
options={[
{ key: "all", label: "全部" },
{ key: "public", label: "公开" },
{ key: "private", label: "私有" },
]}
/>
<AdminSearchField
value={keyword}
onChange={setKeyword}
onSubmit={() => {
setQ(keyword.trim());
void load(1, vis, keyword.trim());
}}
onClear={() => {
setKeyword("");
setQ("");
void load(1, vis, "");
}}
placeholder="搜索标题或描述…"
ariaLabel="搜索片段"
/>
</div>
{error ? <p className="alert-error m-3 text-[12.5px]">{error}</p> : null}
{loading && items.length === 0 ? (
<div className="flex justify-center py-14">
<Loader2 size={20} className="animate-spin" style={{ color: "var(--ink-3)" }} />
</div>
) : items.length === 0 ? (
<AdminEmpty
icon={<CodeXml size={20} />}
title="暂无片段"
description={q ? "没有匹配搜索条件的片段。" : "还没有用户发布代码片段。"}
/>
) : (
<ul className="divide-y" style={{ borderColor: "var(--line)" }}>
{items.map((it) => (
<li key={it.id} className="px-4 sm:px-5 py-3 flex items-center gap-3 flex-wrap">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 min-w-0 flex-wrap">
<Link
href={snippetPath(urlStyle, it.id)}
target="_blank"
className="truncate text-[14px] font-semibold hover:underline"
style={{ color: "var(--ink)" }}
>
{it.title}
</Link>
<span
className="shrink-0 rounded-full px-1.5 py-px text-[10.5px] font-semibold"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
{it.language}
</span>
{it.visibility === "private" ? (
<span
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
style={{ background: "var(--panel-2)", color: "var(--ink-3)" }}
>
<EyeOff size={10} /> 私有
</span>
) : (
<span
className="shrink-0 inline-flex items-center gap-1 rounded-full px-1.5 py-px text-[10.5px]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
<Eye size={10} /> 公开
</span>
)}
</div>
<div className="mt-1 flex items-center gap-2.5 text-[11.5px] flex-wrap" style={{ color: "var(--ink-3)" }}>
{it.user ? (
<span className="inline-flex items-center gap-1">
<Avatar name={it.user.nickname} src={it.user.avatar} size={14} />
{it.user.nickname}
</span>
) : (
<span>用户 #{it.user_id}</span>
)}
<span className="inline-flex items-center gap-1">
<Copy size={11} /> {it.copy_count}
</span>
<span className="inline-flex items-center gap-1">
<Eye size={11} /> {it.view_count}
</span>
<span className="inline-flex items-center gap-1">
<MessagesSquare size={11} /> {it.comment_count}
</span>
<span className="inline-flex items-center gap-1">
<GitFork size={11} /> {it.fork_count}
</span>
<span>{formatRelative(it.created_at)}</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{it.visibility === "public" ? (
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
onClick={() => setConfirm({ kind: "hide", item: it })}
>
<EyeOff size={13} /> 下架
</button>
) : (
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
onClick={() => setConfirm({ kind: "show", item: it })}
>
<Eye size={13} /> 上架
</button>
)}
<button
type="button"
className="btn btn-line btn-sm inline-flex items-center gap-1"
style={{ color: "var(--danger)" }}
onClick={() => setConfirm({ kind: "delete", item: it })}
>
<Trash2 size={13} /> 删除
</button>
</div>
</li>
))}
</ul>
)}
<AdminPager
page={page}
total={total}
pageSize={PAGE_SIZE}
loading={loading}
onChange={(p) => void load(p, vis, q)}
/>
</div>
<ConfirmDialog
open={!!confirm}
title={
confirm?.kind === "delete"
? "删除片段"
: confirm?.kind === "hide"
? "下架片段"
: "上架片段"
}
message={
confirm?.kind === "delete"
? `确定删除片段「${confirm.item.title}」吗?删除后前台不可见。`
: confirm?.kind === "hide"
? `确定将「${confirm?.item.title}」下架为私有吗?仅作者本人可见。`
: `确定将「${confirm?.item.title ?? ""}」恢复为公开吗?`
}
danger={confirm?.kind !== "show"}
busy={busy}
confirmLabel={
confirm?.kind === "delete" ? "确认删除" : confirm?.kind === "hide" ? "确认下架" : "确认上架"
}
onCancel={() => !busy && setConfirm(null)}
onConfirm={() => void runConfirm()}
/>
</div>
);
}

View File

@@ -0,0 +1,20 @@
import type { Metadata } from "next";
import { cookies } from "next/headers";
import { authCookieHeader } from "@/lib/cookies";
import { getMeCached } from "@/lib/serverData";
import { isStaff } from "@/lib/roles";
import Forbidden from "../Forbidden";
import SnippetsAdmin from "./SnippetsAdmin";
export const metadata: Metadata = {
title: "片段管理",
};
export default async function AdminSnippetsPage() {
const cookie = authCookieHeader(await cookies());
const me = await getMeCached(cookie || undefined);
if (!me.user || !isStaff(me.user.role)) {
return <Forbidden text="片段管理仅管理员及以上角色可操作。" />;
}
return <SnippetsAdmin />;
}

View File

@@ -874,6 +874,10 @@ export default function ChatRoomClient({
router.refresh();
return;
}
if (data.status === "joined") {
void refreshMembers();
return;
}
if (data.status === "kicked") {
toast("你已被移出该群");
} else {
@@ -883,7 +887,7 @@ export default function ChatRoomClient({
}
);
return off;
}, [roomId, router]);
}, [roomId, router, refreshMembers]);
// 禁言/解禁实时通知:更新成员列表 + 插入系统提示
useEffect(() => {

View File

@@ -16,6 +16,7 @@
--ink: #161a22;
--ink-2: #4b5260;
--ink-3: #5b6270;
--ink-4: #949aab; /* 比 --ink-3 更浅:首页帖子列表 meta 文字(作者/时间/统计) */
--line: #e4e7ec; /* 装饰性分隔/卡片边(非交互,配合阴影与底色高差) */
--line-2: #7c8698; /* 功能性边界:输入框/按钮/分页等可交互控件,满足 1.4.11 ≥3:1 */
@@ -139,6 +140,7 @@
--ink: #eef1f6;
--ink-2: #b6bdc9;
--ink-3: #98a1b2;
--ink-4: #71798a; /* 比 --ink-3 更淡:首页帖子列表 meta 文字(作者/时间/统计) */
--line: rgba(238, 241, 246, 0.08); /* 装饰性分隔/卡片边 */
--line-2: rgba(238, 241, 246, 0.38); /* 功能性控件边界,1.4.11 ≥3:1 */
@@ -4006,6 +4008,35 @@ a.j13-mention-token:hover {
color: var(--accent);
}
/* 验证码发送按钮:与输入框同圆角,主色浅底强调可点;hover 实心浮起 */
.btn-code {
background: var(--accent-soft);
border: 1px solid color-mix(in srgb, var(--accent) 28%, transparent);
color: var(--accent);
font-weight: 600;
border-radius: var(--r-sm);
padding: 10px 16px;
}
.btn-code:hover:not(:disabled) {
background: var(--accent);
border-color: var(--accent);
color: var(--accent-on);
transform: translateY(-1px);
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1),
0 8px 20px color-mix(in srgb, var(--accent) 34%, transparent);
}
.btn-code:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.btn-code:disabled {
background: transparent;
border-color: var(--line-2);
color: var(--ink-3);
font-weight: 500;
cursor: not-allowed;
}
.btn-danger {
background: var(--danger-soft);
color: var(--danger);
@@ -6425,8 +6456,10 @@ a.j13-mention-token:hover {
user-select: none;
}
/* ----- 点赞按钮:未赞描边态,hover 同色系填充;已赞为浅底实心态 ----- */
.j13-like {
/* ----- 点赞/收藏按钮:未激活描边态,hover 主色浅底轻浮起;激活态浅底主色描边 ----- */
.j13-like,
.j13-fav {
border-radius: 999px;
border-color: var(--line-2);
color: var(--ink-2);
background: var(--panel);
@@ -6434,41 +6467,82 @@ a.j13-mention-token:hover {
border-color 0.15s ease,
color 0.15s ease,
background-color 0.15s ease,
box-shadow 0.15s ease;
box-shadow 0.15s ease,
transform 0.15s ease;
}
.j13-like svg {
.j13-like svg,
.j13-fav svg {
transition: transform 0.15s ease;
}
.j13-like:hover:not(:disabled),
.j13-like.is-liked {
.j13-fav:hover:not(:disabled),
.j13-like.is-liked,
.j13-fav.is-favorited {
border-color: var(--accent);
color: var(--accent);
background: var(--accent-soft);
}
/* 已赞态 hover 再加深一档,并给同色系投影;心形轻微放大(不改变布局) */
.j13-like:hover:not(:disabled) {
/* hover 浮起 + 同色系投影;心形/书签轻微放大(不改变布局) */
.j13-like:hover:not(:disabled),
.j13-fav:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 2px 10px color-mix(in srgb, var(--accent) 22%, transparent);
}
.j13-like.is-liked:hover:not(:disabled) {
background: color-mix(in srgb, var(--accent) 17%, transparent);
.j13-like:active:not(:disabled),
.j13-fav:active:not(:disabled) {
transform: translateY(0);
box-shadow: none;
}
.j13-like:hover:not(:disabled) svg {
/* 文章页大按钮:12px 圆角矩形 + 幽灵底,弱化存在感;激活态浅底主色描边(文字恒为主色,保证可读) */
.j13-like-article {
border-radius: var(--r-sm);
border-color: transparent;
background: color-mix(in srgb, var(--ink) 5%, transparent);
}
.j13-like-article:hover:not(:disabled) {
border-color: color-mix(in srgb, var(--accent) 25%, transparent);
}
.j13-like-article.is-liked,
.j13-like-article.is-favorited {
color: var(--accent);
background: var(--accent-soft);
border-color: color-mix(in srgb, var(--accent) 35%, transparent);
box-shadow: none;
}
.j13-like-article.is-liked:hover:not(:disabled),
.j13-like-article.is-favorited:hover:not(:disabled) {
color: var(--accent);
background: color-mix(in srgb, var(--accent) 18%, transparent);
border-color: var(--accent);
}
.j13-like:hover:not(:disabled) svg,
.j13-fav:hover:not(:disabled) svg {
transform: scale(1.15);
}
.j13-like:active:not(:disabled) svg {
.j13-like:active:not(:disabled) svg,
.j13-fav:active:not(:disabled) svg {
transform: scale(0.95);
}
.j13-like:focus-visible {
.j13-like:focus-visible,
.j13-fav:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
@media (prefers-reduced-motion: reduce) {
.j13-like,
.j13-like svg {
.j13-fav,
.j13-like svg,
.j13-fav svg {
transition: none;
}
.j13-like:hover:not(:disabled),
.j13-fav:hover:not(:disabled),
.j13-like:active:not(:disabled),
.j13-fav:active:not(:disabled),
.j13-like:hover:not(:disabled) svg,
.j13-like:active:not(:disabled) svg {
.j13-fav:hover:not(:disabled) svg,
.j13-like:active:not(:disabled) svg,
.j13-fav:active:not(:disabled) svg {
transform: none;
}
}
@@ -7134,6 +7208,10 @@ a.j13-mention-token:hover {
color: var(--ink-3);
font-variant-numeric: tabular-nums;
}
/* 帖子列表行:除标题外的 meta 文字(作者/时间/回复/评论数/浏览数/板块胶囊)调浅一档,
让标题(var(--ink))更突出;hover 仍可被 above-link 的 accent 覆盖。 */
.j13-post-row .meta { color: var(--ink-4); }
.j13-post-row .j13-board-tag { color: var(--ink-4); }
.link-accent { color: var(--accent); }
.link-accent:hover { color: var(--accent-hi); }
@@ -9074,12 +9152,13 @@ html.dark .prose-content pre[data-theme] span {
/* ============================================================
前台 main 纸面 + 首页三栏「白纸一体」
main 铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
1px --line 描边与页头下边线围合成框;站长配置了自定义站点
背景图时让位(保持透出画布)。左栏无框化,内容直接坐在纸面上;
右栏保留卡片板块感(j13-module 原样)。
main 恒定铺 --main-bg(浅色纯白 / 暗色比画布略亮一档),左右外缘
1px --line 描边与页头下边线围合成框;即使站长配置了自定义站点
背景图也不让位——header/main/footer 连成一张白纸保证内容可读,
背景图只在页缘(1440px 画布之外)露出。左栏无框化,内容直接坐
在纸面上;右栏保留卡片板块感(j13-module 原样)。
============================================================ */
html:not(.j13-has-site-bg) #main {
#main {
background: var(--main-bg);
border-left: 1px solid var(--line);
border-right: 1px solid var(--line);
@@ -10399,3 +10478,209 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
box-shadow: var(--shadow-lg);
}
/* ===== 片段广场:编辑器窗格卡片 ===== */
/* 卡片内 shiki 双主题着色(独立作用域,不依赖 .prose-content)。
新版 shiki 的 token span 只声明 --shiki-light/--shiki-dark 变量,color 由这里应用 */
.j13-sn-code {
color: var(--shiki-light, var(--ink-2));
}
.j13-sn-code span[style*="--shiki-light"] {
color: var(--shiki-light);
background: transparent;
}
html.dark .j13-sn-code {
color: var(--shiki-dark, var(--ink-2));
}
html.dark .j13-sn-code span[style*="--shiki-dark"] {
color: var(--shiki-dark) !important;
font-style: var(--shiki-dark-font-style) !important;
font-weight: var(--shiki-dark-font-weight) !important;
}
/* 语言下拉弹层 */
.j13-sn-lang-menu {
animation: j13-sn-pop 140ms cubic-bezier(0.22, 1, 0.36, 1);
transform-origin: top left;
}
@keyframes j13-sn-pop {
from { opacity: 0; transform: translateY(-4px) scale(0.98); }
to { opacity: 1; transform: translateY(0) scale(1); }
}
/* 搜索软聚焦:光晕替代硬边框 */
.j13-sn-search {
transition: box-shadow 160ms ease, border-color 160ms ease, background 160ms ease;
}
.j13-sn-search:focus {
outline: none;
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
box-shadow: 0 0 0 3px color-mix(in srgb, var(--accent) 14%, transparent);
}
/* ===== 片段广场:赛博控制台 Hero(明暗双主题自适应) ===== */
.j13-hero {
/* 浅色:白玻璃底 + 墨色文字,辉光收敛 */
--hero-bg: #f7f9fc;
--hero-ink: #0f172a;
--hero-ink-2: #475569;
--hero-ink-3: #8593ab;
--hero-line: rgba(15, 23, 42, 0.10);
--hero-chip-bg: rgba(15, 23, 42, 0.035);
--hero-chip-icon: rgba(15, 23, 42, 0.06);
--hero-track: rgba(15, 23, 42, 0.07);
background:
radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 10%, transparent), transparent 65%),
radial-gradient(ellipse 45% 70% at 88% 115%, rgba(13, 148, 136, 0.08), transparent 70%),
radial-gradient(ellipse 35% 45% at 60% 40%, rgba(124, 58, 237, 0.06), transparent 70%),
var(--hero-bg);
border: 1px solid var(--line);
box-shadow: var(--shadow-lg);
}
html.dark .j13-hero {
/* 深色:深夜底 + 霓虹文字,辉光拉满 */
--hero-bg: #0a0e16;
--hero-ink: #e8edf7;
--hero-ink-2: #9aa7c2;
--hero-ink-3: #5f6b88;
--hero-line: rgba(148, 163, 205, 0.16);
--hero-chip-bg: rgba(148, 163, 205, 0.07);
--hero-chip-icon: rgba(148, 163, 205, 0.11);
--hero-track: rgba(148, 163, 205, 0.10);
background:
radial-gradient(ellipse 60% 80% at 12% -20%, color-mix(in srgb, var(--accent) 26%, transparent), transparent 65%),
radial-gradient(ellipse 45% 70% at 88% 115%, rgba(94, 234, 212, 0.14), transparent 70%),
radial-gradient(ellipse 35% 45% at 60% 40%, rgba(167, 139, 250, 0.10), transparent 70%),
var(--hero-bg);
border: 1px solid rgba(148, 163, 205, 0.22);
box-shadow:
0 0 0 1px rgba(10, 14, 22, 0.6),
0 18px 50px -18px color-mix(in srgb, var(--accent) 28%, transparent),
inset 0 1px 0 rgba(232, 237, 247, 0.06);
}
/* 网格底纹(铺满整个面板,仅底部极淡渐出避免压文字) */
.j13-hero-grid {
background-image:
linear-gradient(rgba(15, 23, 42, 0.05) 1px, transparent 1px),
linear-gradient(90deg, rgba(15, 23, 42, 0.05) 1px, transparent 1px);
background-size: 30px 30px;
mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%);
-webkit-mask-image: linear-gradient(to bottom, black 82%, rgba(0, 0, 0, 0.35) 100%);
}
html.dark .j13-hero-grid {
background-image:
linear-gradient(rgba(148, 163, 205, 0.09) 1px, transparent 1px),
linear-gradient(90deg, rgba(148, 163, 205, 0.09) 1px, transparent 1px);
}
/* 水平扫描光束(自上而下巡航) */
.j13-hero-scan {
position: absolute;
left: 0;
right: 0;
height: 72px;
top: -72px;
background: linear-gradient(
to bottom,
transparent,
color-mix(in srgb, var(--accent) 8%, transparent) 60%,
color-mix(in srgb, var(--accent) 30%, transparent)
);
filter: blur(0.5px);
animation: j13-hero-scan 8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
pointer-events: none;
}
@keyframes j13-hero-scan {
0% { transform: translateY(0); opacity: 0; }
8% { opacity: 1; }
60% { opacity: 1; }
68%, 100% { transform: translateY(140%); opacity: 0; }
}
/* 大标题霓虹渐变 */
.j13-hero-title {
background: linear-gradient(100deg, var(--hero-ink) 20%, var(--accent) 55%, #5eead4 85%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
text-shadow: 0 0 34px color-mix(in srgb, var(--accent) 32%, transparent);
}
/* 统计数字进入 */
.j13-hero-stat {
animation: j13-hero-rise 560ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes j13-hero-rise {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
/* 语言均衡条:加载展开 */
.j13-hero-langbar {
transform-origin: left center;
animation: j13-hero-bar 800ms cubic-bezier(0.22, 1, 0.36, 1) 260ms both;
}
@keyframes j13-hero-bar {
from { transform: scaleX(0); }
to { transform: scaleX(1); }
}
/* 呼吸状态灯 */
.j13-hero-pulse {
animation: j13-hero-pulse 2.2s ease-in-out infinite;
}
@keyframes j13-hero-pulse {
0%, 100% { opacity: 1; box-shadow: 0 0 0 0 color-mix(in srgb, var(--accent) 55%, transparent); }
50% { opacity: 0.6; box-shadow: 0 0 0 6px transparent; }
}
/* 光标闪烁 */
.j13-hero-caret {
animation: j13-hero-caret 1.1s steps(2, start) infinite;
}
@keyframes j13-hero-caret {
to { visibility: hidden; }
}
/* 语言跑马灯 */
.j13-hero-ticker {
overflow: hidden;
mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
-webkit-mask-image: linear-gradient(90deg, transparent, black 6%, black 94%, transparent);
}
.j13-hero-ticker-track {
display: inline-flex;
align-items: center;
gap: 2.25rem;
white-space: nowrap;
animation: j13-hero-ticker 36s linear infinite;
}
.j13-hero-ticker:hover .j13-hero-ticker-track {
animation-play-state: paused;
}
@keyframes j13-hero-ticker {
to { transform: translateX(-50%); }
}
@media (prefers-reduced-motion: reduce) {
.j13-hero-scan, .j13-hero-pulse, .j13-hero-caret, .j13-hero-ticker-track { animation: none; }
.j13-hero-stat, .j13-hero-langbar { animation-duration: 1ms; }
}
/* ===== 片段卡片:语言霓虹 ===== */
.j13-snc {
position: relative;
}
/* 顶部霓虹语言条 */
.j13-snc::before {
content: "";
position: absolute;
top: 0;
left: 14px;
right: 14px;
height: 2px;
border-radius: 2px;
background: linear-gradient(90deg, var(--sn-lang, var(--accent)), transparent 75%);
opacity: 0.75;
transition: opacity 200ms ease, left 200ms ease, right 200ms ease;
}
.j13-snc:hover::before {
opacity: 1;
left: 10px;
right: 10px;
}
.j13-snc:hover {
box-shadow:
0 10px 34px -14px color-mix(in srgb, var(--sn-lang, var(--accent)) 45%, transparent),
var(--shadow-lg) !important;
}

View File

@@ -190,7 +190,7 @@ export default function RegisterClient({
/>
<button
type="button"
className="btn btn-line shrink-0"
className="btn btn-code shrink-0"
disabled={sending || countdown > 0 || !email}
onClick={async () => {
// 点击即锁定,防止重复发送
@@ -201,7 +201,7 @@ export default function RegisterClient({
email,
purpose: "register",
});
setSentMessage(r.message);
setSentMessage("验证码已发送,请查收邮箱");
startCountdown();
} catch (err) {
setError((err as Error).message);
@@ -211,7 +211,7 @@ export default function RegisterClient({
}}
>
{sending
? "正在排队…"
? "发送中…"
: countdown > 0
? `${countdown}s 后可重发`
: "发送验证码"}

View File

@@ -118,7 +118,7 @@ export default function ResetPasswordPage() {
/>
<button
type="button"
className="btn btn-line shrink-0"
className="btn btn-code shrink-0"
disabled={sending || busy || !email}
onClick={() => void sendCode()}
>

View File

@@ -5,9 +5,10 @@ import {
fetchAnnouncementsPage,
fetchPublishedSitePages,
fetchLibraryList,
fetchSnippets,
} from "@/lib/api";
import { canonicalPath, resolveSiteOrigin } from "@/lib/siteUrl";
import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath } from "@/lib/urlStyle";
import { announcementPath, postPath, pagePath, leaderboardPath, libraryPath, libraryDocPath, snippetsPath, snippetPath } from "@/lib/urlStyle";
import { getPublicSettingsCached } from "@/lib/serverData";
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
@@ -40,6 +41,12 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
changeFrequency: "daily",
priority: 0.5,
},
{
url: url(snippetsPath(style)),
lastModified: new Date(),
changeFrequency: "daily",
priority: 0.5,
},
{
url: url("/about"),
lastModified: new Date(),
@@ -104,5 +111,20 @@ export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
/* 忽略 */
}
try {
const { items } = await fetchSnippets({ page: 1, size: 200 });
for (const it of items) {
if (it.visibility !== "public") continue;
entries.push({
url: url(snippetPath(style, it.id)),
lastModified: new Date(it.updated_at || it.created_at),
changeFrequency: "weekly",
priority: 0.4,
});
}
} catch {
/* 忽略 */
}
return entries;
}

View File

@@ -0,0 +1,111 @@
"use client";
import { Suspense, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { apiMe, apiGetSnippet } from "@/lib/api";
import SnippetEditor from "@/components/SnippetEditor";
function EditSnippetInner({ id }: { id: number }) {
const router = useRouter();
const [ready, setReady] = useState(false);
const [snippet, setSnippet] = useState<{
title: string;
language: string;
description: string;
code: string;
visibility: "public" | "private";
user_id: number;
} | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const me = await apiMe().catch(() => null);
if (cancelled) return;
if (!me?.user) {
router.push(`/login?redirect=/snippets/${id}/edit`);
return;
}
try {
const data = await apiGetSnippet(id);
if (cancelled) return;
if (!data) {
router.push("/snippets");
return;
}
// 仅 owner 或 staff 可编辑(后端会再次校验)
setSnippet({
title: data.snippet.title,
language: data.snippet.language,
description: data.snippet.description,
code: data.snippet.code,
visibility: data.snippet.visibility,
user_id: data.snippet.user_id,
});
setReady(true);
} catch {
if (!cancelled) router.push("/snippets");
}
})();
return () => {
cancelled = true;
};
}, [router, id]);
if (!ready || !snippet) {
return (
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在加载片段…</p>
</div>
);
}
return (
<SnippetEditor
mode="edit"
snippetId={id}
initialTitle={snippet.title}
initialLanguage={snippet.language}
initialDescription={snippet.description}
initialCode={snippet.code}
initialVisibility={snippet.visibility}
/>
);
}
export default function EditSnippetPage({
params,
}: {
params: Promise<{ id: string }>;
}) {
const [id, setId] = useState<number | null>(null);
useEffect(() => {
params.then((p) => {
const numId = Number(p.id);
if (Number.isInteger(numId) && numId > 0) setId(numId);
});
}, [params]);
if (id === null) {
return (
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在加载…</p>
</div>
);
}
return (
<div className="j13-compose-page">
<Suspense
fallback={
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在加载片段…</p>
</div>
}
>
<EditSnippetInner id={id} />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,253 @@
import type { Metadata } from "next";
import { notFound } from "next/navigation";
import Link from "next/link";
import { cookies } from "next/headers";
import { GitFork, Copy, Eye, MessageSquare } from "lucide-react";
import {
fetchSnippetById,
fetchSnippetComments,
fetchSnippetForks,
} from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { snippetPath, userPath, snippetsPath } from "@/lib/urlStyle";
import { postViewerCookieHeader } from "@/lib/cookies";
import { formatRelative } from "@/lib/format";
import MarkdownBody from "@/components/MarkdownBody";
import Avatar from "@/components/Avatar";
import SnippetCodeView from "@/components/SnippetCodeView";
import SnippetCommentSection from "@/components/SnippetCommentSection";
import SnippetCommentCountChip from "@/components/SnippetCommentCountChip";
import SnippetActions from "@/components/SnippetActions";
interface PageProps {
params: Promise<{ id: string }>;
}
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId) || numId <= 0) return { title: "片段不存在" };
const cookieHeader = postViewerCookieHeader(await cookies());
const data = await fetchSnippetById(numId, cookieHeader).catch(() => null);
if (!data) return { title: "片段不存在" };
const s = data.snippet;
return {
title: s.title,
description: s.description ? s.description.slice(0, 150) : `${s.language} 代码片段`,
...(await requestCanonical(snippetPath((await getPublicSettingsCached()).url_style, s.id))),
};
}
export default async function SnippetDetailPage({ params }: PageProps) {
const { id } = await params;
const numId = Number(id);
if (!Number.isInteger(numId) || numId <= 0) notFound();
const cookieHeader = postViewerCookieHeader(await cookies());
const [detailRes, commentsRes, forksRes, settings] = await Promise.all([
fetchSnippetById(numId, cookieHeader).catch(() => null),
fetchSnippetComments(numId, 1, 20, cookieHeader).catch(() => ({ items: [], total: 0 })),
fetchSnippetForks(numId, 1, 6).catch(() => ({ items: [], total: 0 })),
getPublicSettingsCached(),
]);
if (!detailRes) notFound();
const snippet = detailRes.snippet;
const urlStyle = settings.url_style;
const isOwner = false; // 客户端组件中通过 apiMe 校正
const isStaff = false;
return (
<div className="max-w-[1440px] mx-auto min-w-0">
<div className="mb-4 flex items-center gap-2 text-[12.5px] meta">
<Link
href={snippetsPath(urlStyle)}
className="hover:underline inline-flex items-center gap-1.5"
>
← 片段广场
</Link>
{snippet.forked_from && (
<>
<span style={{ color: "var(--ink-3)" }}>·</span>
<Link
href={snippetPath(urlStyle, snippet.forked_from.id)}
className="inline-flex items-center gap-1 hover:underline"
>
<GitFork size={11} /> Fork 自:{snippet.forked_from.title}
</Link>
</>
)}
</div>
<article className="panel px-4 py-6 sm:p-8">
{/* 头部 */}
<header>
<div className="flex items-center gap-2 flex-wrap">
<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)",
}}
>
{snippet.language}
</span>
{snippet.visibility === "private" && (
<span
className="text-[10px] font-medium px-1.5 py-0.5 rounded-md"
style={{
background: "var(--panel-2)",
color: "var(--ink-3)",
border: "1px solid var(--line)",
}}
>
私有
</span>
)}
</div>
<h1
className="mt-2.5 text-[23px] sm:text-[28px] leading-tight font-bold"
style={{ color: "var(--ink)" }}
>
{snippet.title}
</h1>
{/* 作者 + 统计 */}
<div className="mt-4 flex items-center gap-x-4 gap-y-2.5 flex-wrap">
{snippet.user && (
<Link href={userPath(urlStyle, snippet.user.id)} className="flex items-center gap-2 group min-w-0">
<Avatar
size={30}
name={snippet.user.nickname || snippet.user.username}
src={snippet.user.avatar}
/>
<span className="min-w-0">
<span
className="block text-[13.5px] font-semibold leading-tight truncate group-hover:text-[var(--accent)] transition-colors"
style={{ color: "var(--ink)" }}
>
{snippet.user.nickname || snippet.user.username}
</span>
<span className="block text-[11px] leading-tight meta truncate">
@{snippet.user.username}
</span>
</span>
</Link>
)}
<span className="meta text-[12px]">{formatRelative(snippet.created_at)}</span>
{snippet.updated_at !== snippet.created_at && (
<span className="meta text-[12px]">(编辑于 {formatRelative(snippet.updated_at)})</span>
)}
<span className="flex items-center gap-1.5 flex-wrap sm:ml-auto">
<StatChip icon={<Copy size={12} />} value={snippet.copy_count} label="复制" />
<StatChip icon={<Eye size={12} />} value={snippet.view_count} label="查看" />
<SnippetCommentCountChip
snippetId={snippet.id}
initial={snippet.comment_count}
icon={<MessageSquare size={12} />}
label="评论"
/>
<a
href={snippet.fork_count > 0 ? "#snippet-forks" : undefined}
className="inline-flex rounded-full"
style={{ cursor: snippet.fork_count > 0 ? "pointer" : "default" }}
title={snippet.fork_count > 0 ? "查看 Fork 演化" : "还没有人 Fork"}
>
<StatChip icon={<GitFork size={12} />} value={snippet.fork_count} label="Fork" />
</a>
</span>
</div>
{/* 操作 */}
<div className="mt-4 pt-4" style={{ borderTop: "1px solid var(--line)" }}>
<SnippetActions
snippet={snippet}
urlStyle={urlStyle}
isOwner={isOwner}
isStaff={isStaff}
/>
</div>
</header>
{/* 代码窗格 */}
<section className="mt-6">
<SnippetCodeView
code={snippet.code}
language={snippet.language}
snippetId={snippet.id}
defaultFold={true}
foldLines={25}
/>
</section>
{snippet.description && (
<section className="mt-7">
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
<span
className="w-1 h-1 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
说明
</h2>
<MarkdownBody content={snippet.description} />
</section>
)}
{forksRes.total > 0 && (
<section id="snippet-forks" className="mt-7 scroll-mt-24">
<h2 className="text-[13px] font-bold mb-2.5 flex items-center gap-2" style={{ color: "var(--ink)" }}>
<span
className="w-1 h-1 rounded-full"
style={{ background: "var(--accent)" }}
aria-hidden
/>
Fork({forksRes.total})
</h2>
<div className="flex flex-col gap-1.5">
{forksRes.items.slice(0, 5).map((f) => (
<Link
key={f.id}
href={snippetPath(urlStyle, f.id)}
className="inline-flex items-center gap-2 self-start max-w-full rounded-lg px-2.5 py-1.5 text-[12.5px] transition-colors hover:bg-[var(--panel-2)]"
style={{ color: "var(--ink-2)", border: "1px solid var(--line)" }}
>
<GitFork size={12} style={{ color: "var(--ink-3)" }} />
<span className="truncate">{f.title}</span>
</Link>
))}
</div>
</section>
)}
{/* 评论 */}
<section className="mt-8 pt-7" style={{ borderTop: "1px solid var(--line)" }}>
<SnippetCommentSection
snippetId={snippet.id}
initialItems={commentsRes.items}
initialTotal={commentsRes.total}
urlStyle={urlStyle}
/>
</section>
</article>
</div>
);
}
function StatChip({ icon, value, label }: { icon: React.ReactNode; value: number; label: string }) {
return (
<span
className="inline-flex items-center gap-1.5 h-7 px-2.5 rounded-full text-[11.5px] tabular-nums"
style={{ background: "var(--panel-2)", color: "var(--ink-3)", border: "1px solid var(--line)" }}
title={`${value} ${label}`}
>
<span style={{ color: "var(--ink-3)" }}>{icon}</span>
{value}
<span className="hidden sm:inline">{label}</span>
</span>
);
}

View File

@@ -0,0 +1,52 @@
"use client";
import { Suspense } from "react";
import { useRouter } from "next/navigation";
import { useEffect, useState } from "react";
import { apiMe } from "@/lib/api";
import SnippetEditor from "@/components/SnippetEditor";
function NewSnippetInner() {
const router = useRouter();
const [ready, setReady] = useState(false);
useEffect(() => {
apiMe()
.then((res) => {
if (!res.user) {
router.push("/login?redirect=/snippets/new");
return;
}
setReady(true);
})
.catch(() => {
router.push("/login?redirect=/snippets/new");
});
}, [router]);
if (!ready) {
return (
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在准备编辑器…</p>
</div>
);
}
return <SnippetEditor mode="create" />;
}
export default function NewSnippetPage() {
return (
<div className="j13-compose-page">
<Suspense
fallback={
<div className="j13-compose-stage is-studio px-8 py-16 text-center">
<p className="meta">正在准备编辑器…</p>
</div>
}
>
<NewSnippetInner />
</Suspense>
</div>
);
}

View File

@@ -0,0 +1,49 @@
import type { Metadata } from "next";
import { fetchSnippets, fetchSnippetLanguages, fetchSnippetStats, type SnippetStats } from "@/lib/api";
import { getPublicSettingsCached } from "@/lib/serverData";
import { requestCanonical } from "@/lib/canonicalMeta";
import { snippetsPath } from "@/lib/urlStyle";
import SnippetExplorer from "@/components/SnippetExplorer";
export async function generateMetadata(): Promise<Metadata> {
const settings = await getPublicSettingsCached();
return {
title: "代码片段",
description: settings.site_description,
...(await requestCanonical(snippetsPath(settings.url_style))),
};
}
export default async function SnippetsPage() {
const settings = await getPublicSettingsCached();
let initialItems: Awaited<ReturnType<typeof fetchSnippets>>["items"] = [];
let initialTotal = 0;
let languages: string[] = [];
let stats: SnippetStats | null = null;
try {
const [listRes, langRes, statsRes] = await Promise.all([
fetchSnippets({ size: 12 }),
fetchSnippetLanguages(),
fetchSnippetStats(),
]);
initialItems = listRes.items;
initialTotal = listRes.total;
languages = langRes.languages;
stats = statsRes;
} catch {
/* 降级空态 */
}
return (
<div className="min-w-0 pt-5 sm:pt-7">
<SnippetExplorer
initialItems={initialItems}
initialTotal={initialTotal}
languages={languages}
urlStyle={settings.url_style}
stats={stats}
/>
</div>
);
}