feat: 浏览量统计、浏览记录追踪、UI色彩优化

- 新增 Post.viewCount 字段,文章详情/列表/精选卡片显示浏览量
- 新增 PageView 模型记录访客IP(脱敏)、设备类型、来源
- 后台新增浏览记录独立页面及侧边栏菜单
- 仪表盘新增访问概览:页面访问/今日/独立IP/设备分布
- 整体色彩体系优化:降低黑白对比度、统一暖色调
- UI细节修复:focus-visible、Footer空列、mobile hover、transition-all等10+项
This commit is contained in:
胡旭
2026-06-30 09:09:46 +08:00
parent 58c27f96bf
commit fae61f924e
29 changed files with 789 additions and 84 deletions
+12
View File
@@ -18,6 +18,7 @@ model Post {
tags String // JSON 序列化的 string[]
coverImage String?
readingTime Int @default(5)
viewCount Int @default(0)
featured Boolean @default(false)
status String @default("draft")
createdAt String
@@ -34,3 +35,14 @@ model Tag {
id String @id
name String @unique
}
model PageView {
id String @id
path String
postSlug String?
ip String
userAgent String
device String @default("unknown")
referrer String?
createdAt String
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+55
View File
@@ -0,0 +1,55 @@
<svg viewBox="0 0 480 80" xmlns="http://www.w3.org/2000/svg">
<defs>
<style>
.logo-text {
font-family: 'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', serif;
}
.logo-sub {
font-family: 'Cormorant Garamond', 'Noto Serif SC', serif;
}
</style>
</defs>
<!-- 印章图标 (缩放到 64×64, 垂直居中) -->
<g transform="translate(0, 8) scale(0.32)">
<!-- 印章底色 -->
<rect width="200" height="200" rx="12" ry="12" fill="#F7F4EB"/>
<!-- 外框 -->
<path
d="M 16,14 C 50,11 140,11 184,14 C 187,50 187,140 184,186 C 140,189 50,189 16,186 C 13,140 13,50 16,14 Z"
fill="none" stroke="#A63D2F" stroke-width="7.5" stroke-linecap="round" stroke-linejoin="round"
/>
<!-- 内框 -->
<path
d="M 33,31 C 60,29 140,29 167,31 C 169,60 169,140 167,169 C 140,171 60,171 33,169 C 31,140 31,60 33,31 Z"
fill="none" stroke="#A63D2F" stroke-width="2.2" stroke-linecap="round"
/>
<!-- 「随」字 -->
<text
x="100" y="118"
text-anchor="middle" dominant-baseline="central"
font-family="'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', serif"
font-size="100" font-weight="900" fill="#A63D2F" letter-spacing="0.02em"
></text>
</g>
<!-- 博客名称 -->
<text
x="90" y="38"
class="logo-text"
font-size="28" font-weight="600" fill="#1E1B18"
letter-spacing="0.12em"
></text>
<!-- 域名 / 副标题 -->
<text
x="90" y="60"
class="logo-sub"
font-size="13" font-weight="400" fill="#6B655E"
letter-spacing="0.18em"
font-style="italic"
>asui.xyz</text>
</svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

+1 -1
View File
@@ -57,7 +57,7 @@ export default function AboutPage() {
<GsapReveal
variant="fade-up"
stagger={0.1}
className="space-y-6 font-body text-base text-ink-light leading-relaxed"
className="space-y-6 font-body text-base text-ink leading-relaxed"
>
<p>
<span className="text-ink font-medium">Sui</span>
+1
View File
@@ -10,6 +10,7 @@ const navItems = [
{ label: "文章", href: "/admin/posts", icon: "M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9a2 2 0 00-2-2h-2" },
{ label: "分类", href: "/admin/categories", icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" },
{ label: "标签", href: "/admin/tags", icon: "M7 7h.01M7 3h5c.512 0 1.024.195 1.414.586l7 7a2 2 0 010 2.828l-7 7a2 2 0 01-2.828 0l-7-7A1.994 1.994 0 013 12V7a4 4 0 014-4z" },
{ label: "浏览记录", href: "/admin/visits", icon: "M15 12a3 3 0 11-6 0 3 3 0 016 0z M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z" },
];
export default function AdminLayout({ children }: { children: React.ReactNode }) {
+111 -5
View File
@@ -12,11 +12,50 @@ interface Stats {
featured: number;
categories: number;
tags: number;
totalViews: number;
}
interface VisitRecord {
id: string;
path: string;
postSlug: string | null;
ip: string;
device: string;
referrer: string | null;
createdAt: string;
}
interface VisitStats {
totalViews: number;
todayViews: number;
uniqueIps: number;
deviceBreakdown: { device: string; count: number }[];
}
const DEVICE_LABELS: Record<string, string> = {
desktop: "桌面",
mobile: "手机",
tablet: "平板",
bot: "爬虫",
unknown: "未知",
};
function timeAgo(dateStr: string): string {
const diff = Date.now() - new Date(dateStr).getTime();
const min = Math.floor(diff / 60000);
if (min < 1) return "刚刚";
if (min < 60) return `${min} 分钟前`;
const hr = Math.floor(min / 60);
if (hr < 24) return `${hr} 小时前`;
const day = Math.floor(hr / 24);
return `${day} 天前`;
}
export default function DashboardPage() {
const [stats, setStats] = useState<Stats | null>(null);
const [recentPosts, setRecentPosts] = useState<Post[]>([]);
const [visitStats, setVisitStats] = useState<VisitStats | null>(null);
const [recentVisits, setRecentVisits] = useState<VisitRecord[]>([]);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
@@ -24,9 +63,12 @@ export default function DashboardPage() {
Promise.all([
safeFetch("/api/stats", undefined, toast).then((r) => r.json()),
safeFetch("/api/posts?page=1&pageSize=5&sortBy=createdAt&sortDir=desc", undefined, toast).then((r) => r.json()),
]).then(([s, postsResult]) => {
safeFetch("/api/visits/stats?pageSize=20&stats=1", undefined, toast).then((r) => r.json()),
]).then(([s, postsResult, visitResult]) => {
setStats(s);
setRecentPosts(postsResult.data ?? postsResult);
setVisitStats(visitResult.stats);
setRecentVisits(visitResult.views);
setLoading(false);
}).catch(() => setLoading(false));
}, [toast]);
@@ -34,8 +76,9 @@ export default function DashboardPage() {
if (loading) return <div className="font-sans text-muted-foreground">...</div>;
const statItems = stats ? [
{ label: "文章总数", value: stats.total, color: "text-foreground" },
{ label: "总浏览量", value: stats.totalViews.toLocaleString("zh-CN"), color: "text-accent" },
{ label: "已发布", value: stats.published, color: "text-accent" },
{ label: "文章总数", value: stats.total, color: "text-foreground" },
{ label: "草稿", value: stats.draft, color: "text-primary" },
{ label: "精选", value: stats.featured, color: "text-primary" },
{ label: "分类", value: stats.categories, color: "text-foreground" },
@@ -52,15 +95,78 @@ export default function DashboardPage() {
</div>
{/* Stats */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4 mb-10">
<div className="flex flex-nowrap gap-4 mb-10 overflow-x-auto pb-1">
{statItems.map((s) => (
<div key={s.label} className="p-4 rounded-xl bg-card border border-border">
<div key={s.label} className="flex-1 min-w-[110px] p-4 rounded-xl bg-card border border-border shrink-0">
<div className={`font-display text-2xl font-medium ${s.color}`}>{s.value}</div>
<div className="font-sans text-xs text-muted-foreground mt-1">{s.label}</div>
<div className="font-sans text-xs text-muted-foreground mt-1 whitespace-nowrap">{s.label}</div>
</div>
))}
</div>
{/* Visit overview */}
{visitStats && (
<div className="mb-10">
<h2 className="font-display text-xl font-medium mb-4">访</h2>
<div className="flex flex-nowrap gap-4 mb-6 overflow-x-auto pb-1">
<div className="flex-1 min-w-[100px] p-4 rounded-xl bg-card border border-border shrink-0">
<div className="font-display text-2xl font-medium text-foreground">
{visitStats.totalViews.toLocaleString("zh-CN")}
</div>
<div className="font-sans text-xs text-muted-foreground mt-1 whitespace-nowrap">访</div>
</div>
<div className="flex-1 min-w-[100px] p-4 rounded-xl bg-card border border-border shrink-0">
<div className="font-display text-2xl font-medium text-accent">
{visitStats.todayViews.toLocaleString("zh-CN")}
</div>
<div className="font-sans text-xs text-muted-foreground mt-1 whitespace-nowrap">访</div>
</div>
<div className="flex-1 min-w-[100px] p-4 rounded-xl bg-card border border-border shrink-0">
<div className="font-display text-2xl font-medium text-primary">
{visitStats.uniqueIps}
</div>
<div className="font-sans text-xs text-muted-foreground mt-1 whitespace-nowrap"> IP</div>
</div>
{visitStats.deviceBreakdown.slice(0, 3).map((d) => (
<div key={d.device} className="flex-1 min-w-[90px] p-4 rounded-xl bg-card border border-border shrink-0">
<div className="font-display text-2xl font-medium text-foreground">{d.count}</div>
<div className="font-sans text-xs text-muted-foreground mt-1 whitespace-nowrap">
{DEVICE_LABELS[d.device] || d.device}
</div>
</div>
))}
</div>
{/* Recent visits */}
<h3 className="font-display text-lg font-medium mb-3">访</h3>
<div className="space-y-1">
{recentVisits.map((v) => (
<div key={v.id} className="flex items-center gap-3 p-3 rounded-lg bg-card border border-border text-sm">
<span className={`shrink-0 font-sans text-xs px-1.5 py-0.5 rounded ${
v.device === "bot" ? "bg-muted text-muted-foreground" :
v.device === "mobile" ? "bg-primary/10 text-primary" :
"bg-accent/10 text-accent"
}`}>
{DEVICE_LABELS[v.device] || v.device}
</span>
<span className="flex-1 font-sans text-xs text-muted-foreground truncate">
{v.path}{v.postSlug ? ` · ${v.postSlug}` : ""}
</span>
<span className="shrink-0 font-sans text-xs text-muted-foreground">
{v.ip}
</span>
<span className="shrink-0 font-sans text-xs text-muted-foreground hidden sm:inline">
{timeAgo(v.createdAt)}
</span>
</div>
))}
{recentVisits.length === 0 && (
<div className="text-center py-4 font-sans text-sm text-muted-foreground">访</div>
)}
</div>
</div>
)}
{/* Recent posts */}
<h2 className="font-display text-xl font-medium mb-4"></h2>
<div className="space-y-2">
+143
View File
@@ -0,0 +1,143 @@
"use client";
import { useState, useEffect } from "react";
import { useToast, safeFetch } from "@/components/Toast";
import { ChevronLeft, ChevronRight } from "lucide-react";
interface VisitRecord {
id: string;
path: string;
postSlug: string | null;
ip: string;
device: string;
referrer: string | null;
createdAt: string;
}
const DEVICE_LABELS: Record<string, string> = {
desktop: "桌面",
mobile: "手机",
tablet: "平板",
bot: "爬虫",
unknown: "未知",
};
const DEVICE_COLORS: Record<string, string> = {
desktop: "bg-accent/10 text-accent",
mobile: "bg-primary/10 text-primary",
tablet: "bg-chart-3/10 text-chart-3",
bot: "bg-muted text-muted-foreground",
unknown: "bg-muted text-muted-foreground",
};
export default function VisitsPage() {
const [visits, setVisits] = useState<VisitRecord[]>([]);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(1);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true);
const { toast } = useToast();
useEffect(() => {
setLoading(true);
safeFetch(`/api/visits/stats?page=${page}&pageSize=30`, undefined, toast)
.then((r) => r.json())
.then((data) => {
setVisits(data.views);
setTotalPages(data.totalPages);
setTotal(data.total);
setLoading(false);
})
.catch(() => setLoading(false));
}, [page, toast]);
return (
<div>
<div className="flex items-center justify-between mb-8">
<h1 className="font-display text-3xl font-medium"></h1>
<div className="font-sans text-sm text-muted-foreground">
{total.toLocaleString("zh-CN")}
</div>
</div>
{loading ? (
<div className="font-sans text-muted-foreground">...</div>
) : visits.length === 0 ? (
<div className="text-center py-24 font-sans text-muted-foreground"></div>
) : (
<>
<div className="rounded-xl border border-border overflow-hidden">
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-border bg-muted/30">
<th className="text-left px-4 py-3 font-sans text-xs font-medium text-muted-foreground uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 font-sans text-xs font-medium text-muted-foreground uppercase tracking-wider"></th>
<th className="text-left px-4 py-3 font-sans text-xs font-medium text-muted-foreground uppercase tracking-wider hidden sm:table-cell">IP</th>
<th className="text-left px-4 py-3 font-sans text-xs font-medium text-muted-foreground uppercase tracking-wider hidden md:table-cell"></th>
<th className="text-right px-4 py-3 font-sans text-xs font-medium text-muted-foreground uppercase tracking-wider"></th>
</tr>
</thead>
<tbody>
{visits.map((v) => (
<tr key={v.id} className="border-b border-border/50 hover:bg-muted/20 transition-colors">
<td className="px-4 py-3">
<div className="flex items-center gap-2">
<span className="font-sans text-sm text-foreground truncate max-w-[200px] md:max-w-[360px]" title={v.path}>
{v.path}
</span>
{v.postSlug && (
<span className="shrink-0 font-sans text-xs text-primary bg-primary/5 px-1.5 py-0.5 rounded"></span>
)}
</div>
</td>
<td className="px-4 py-3">
<span className={`inline-block font-sans text-xs px-2 py-0.5 rounded-full ${DEVICE_COLORS[v.device] || DEVICE_COLORS.unknown}`}>
{DEVICE_LABELS[v.device] || v.device}
</span>
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground hidden sm:table-cell">
{v.ip}
</td>
<td className="px-4 py-3 font-mono text-xs text-muted-foreground truncate max-w-[180px] hidden md:table-cell">
{v.referrer || "-"}
</td>
<td className="px-4 py-3 text-right font-mono text-xs text-muted-foreground whitespace-nowrap">
{new Date(v.createdAt).toLocaleString("zh-CN", {
month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
})}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="flex items-center justify-center gap-2 mt-6 font-sans text-sm">
<button
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={page <= 1}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronLeft className="w-4 h-4" />
</button>
<span className="text-muted-foreground">
{page} / {totalPages}
</span>
<button
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={page >= totalPages}
className="p-2 rounded-lg text-muted-foreground hover:text-foreground disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
>
<ChevronRight className="w-4 h-4" />
</button>
</div>
)}
</>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
import { NextResponse } from "next/server";
import { incrementViewCount } from "@/lib/store";
export async function POST(
_request: Request,
{ params }: { params: Promise<{ slug: string }> }
) {
const { slug } = await params;
const count = await incrementViewCount(slug);
if (count === null) {
return NextResponse.json({ error: "文章不存在" }, { status: 404 });
}
return NextResponse.json({ viewCount: count });
}
+29
View File
@@ -0,0 +1,29 @@
import { NextResponse } from "next/server";
import { recordPageView } from "@/lib/store";
export async function POST(request: Request) {
try {
const body = await request.json();
const { path, postSlug, referrer } = body as {
path?: string;
postSlug?: string;
referrer?: string;
};
if (!path) {
return NextResponse.json({ error: "path is required" }, { status: 400 });
}
// 从请求头提取 IP 和 UA
const forwarded = request.headers.get("x-forwarded-for");
const ip = forwarded?.split(",")[0]?.trim() || "127.0.0.1";
const userAgent = request.headers.get("user-agent") || "unknown";
const ref = referrer || request.headers.get("referer") || undefined;
await recordPageView({ path, postSlug, ip, userAgent, referrer: ref });
return NextResponse.json({ ok: true });
} catch {
return NextResponse.json({ ok: false }, { status: 500 });
}
}
+26
View File
@@ -0,0 +1,26 @@
import { NextRequest, NextResponse } from "next/server";
import { getPageViewsPaginated, getPageViewStats } from "@/lib/store";
import { requireAuth } from "@/lib/http";
export async function GET(request: NextRequest) {
const deny = await requireAuth();
if (deny) return deny;
const url = new URL(request.url);
const page = parseInt(url.searchParams.get("page") || "1", 10);
const pageSize = parseInt(url.searchParams.get("pageSize") || "30", 10);
const needsStats = url.searchParams.get("stats") !== "0";
const [paginated, stats] = await Promise.all([
getPageViewsPaginated(page, pageSize),
needsStats ? getPageViewStats() : Promise.resolve(null),
]);
return NextResponse.json({
views: paginated.data,
total: paginated.total,
page: paginated.page,
totalPages: paginated.totalPages,
stats,
});
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+34 -30
View File
@@ -7,22 +7,21 @@
/* ── Design Tokens ── */
@theme inline {
/* Parchment & earth palette — ink-wash tones */
--color-parchment: #FDFCFA;
--color-parchment-deep: #F5F2EE;
--color-ink: #050404;
--color-ink-light: #121010;
--color-ink-muted: #2A2624;
--color-parchment: #F7F4EB;
--color-parchment-deep: #EDE8DC;
--color-ink: #1E1B18;
--color-ink-light: #1E1B18;
--color-ink-muted: #6B655E;
--color-terracotta: #A63D2F;
--color-terracotta-light: #C46B5E;
--color-sage: #6E8264;
--color-sage-light: #A3B59B;
--color-warm-gray: #C5BDB4;
--color-cream: #FAF9F7;
--color-warm-gray: #CCC6BB;
--color-cream: #F0ECE2;
/* Typography — 宋式 serif priority。
next/font 注入的 CSS 变量优先,回退到本地系统宋体。 */
--font-display: var(--font-noto-serif), "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
--font-body: var(--font-noto-serif), "Source Han Serif SC", "Songti SC", serif;
--font-body: var(--font-noto-serif), "Source Serif 4", "Source Han Serif SC", "Songti SC", serif;
--font-sans: var(--font-sans);
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
@@ -93,7 +92,7 @@ body::before {
position: fixed;
inset: 0;
pointer-events: none;
z-index: 9999;
z-index: 0;
opacity: 0.03;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='noise'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23noise)'/%3E%3C/svg%3E");
}
@@ -183,6 +182,11 @@ body::before {
.prose-literary a:hover {
text-decoration-color: var(--color-terracotta);
}
.prose-literary a:focus-visible {
outline: 2px solid var(--color-terracotta);
outline-offset: 2px;
border-radius: 2px;
}
.prose-literary img {
border-radius: 8px;
margin: 2em 0;
@@ -255,34 +259,34 @@ body::before {
:root {
/* 水墨纸质风格 — 映射到 shadcn CSS 变量 */
--background: #FDFCFA; /* parchment */
--foreground: #050404; /* ink */
--card: #FAF9F7; /* cream */
--card-foreground: #050404;
--popover: #FAF9F7;
--popover-foreground: #050404;
--background: #F7F4EB; /* parchment */
--foreground: #1E1B18; /* ink */
--card: #F0ECE2; /* cream */
--card-foreground: #1E1B18;
--popover: #F0ECE2;
--popover-foreground: #1E1B18;
--primary: #A63D2F; /* terracotta */
--primary-foreground: #FDFCFA;
--secondary: #F5F2EE; /* parchment-deep */
--secondary-foreground: #050404;
--muted: #C5BDB4; /* warm-gray */
--muted-foreground: #2A2624; /* ink-muted */
--primary-foreground: #F7F4EB;
--secondary: #EDE8DC; /* parchment-deep */
--secondary-foreground: #1E1B18;
--muted: #CCC6BB; /* warm-gray */
--muted-foreground: #6B655E; /* ink-muted */
--accent: #6E8264; /* sage */
--accent-foreground: #FDFCFA;
--destructive: #B91C1C;
--border: #C5BDB433; /* warm-gray/20 */
--input: #C5BDB433;
--accent-foreground: #F7F4EB;
--destructive: #8B2C20; /* terracotta 深色变体 */
--border: #CCC6BB33; /* warm-gray/20 */
--input: #CCC6BB33;
--ring: #A63D2F; /* terracotta */
--chart-1: #A63D2F;
--chart-2: #6E8264;
--chart-3: #C46B5E;
--chart-4: #A3B59B;
--chart-5: #2A2624;
--chart-4: #CCC6BB;
--chart-5: #6B655E;
--radius: 0.75rem;
--sidebar: #F5F2EE;
--sidebar-foreground: #050404;
--sidebar: #EDE8DC;
--sidebar-foreground: #1E1B18;
--sidebar-primary: #A63D2F;
--sidebar-primary-foreground: #FDFCFA;
--sidebar-primary-foreground: #F7F4EB;
--sidebar-accent: #FAF9F7;
--sidebar-accent-foreground: #050404;
--sidebar-border: #C5BDB433;
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

+44
View File
@@ -0,0 +1,44 @@
<svg viewBox="0 0 200 200" xmlns="http://www.w3.org/2000/svg">
<!-- favicon — 印章图标 -->
<rect width="200" height="200" rx="12" ry="12" fill="#FDFCFA"/>
<!-- 外框 — 手刻质感 -->
<path
d="M 16,14
C 50,11 140,11 184,14
C 187,50 187,140 184,186
C 140,189 50,189 16,186
C 13,140 13,50 16,14 Z"
fill="none"
stroke="#A63D2F"
stroke-width="7.5"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- 内框 — 细线 -->
<path
d="M 33,31
C 60,29 140,29 167,31
C 169,60 169,140 167,169
C 140,171 60,171 33,169
C 31,140 31,60 33,31 Z"
fill="none"
stroke="#A63D2F"
stroke-width="2.2"
stroke-linecap="round"
/>
<!-- 「随」字 -->
<text
x="100"
y="118"
text-anchor="middle"
dominant-baseline="central"
font-family="'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', serif"
font-size="100"
font-weight="900"
fill="#A63D2F"
letter-spacing="0.02em"
></text>
</svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+2 -1
View File
@@ -1,6 +1,7 @@
import type { Metadata } from "next";
import Header from "@/components/Header";
import Footer from "@/components/Footer";
import TrackingProvider from "@/components/TrackingProvider";
import { Noto_Serif_SC, Noto_Sans_SC, Cormorant_Garamond, Geist } from "next/font/google";
import "./globals.css";
import { cn } from "@/lib/utils";
@@ -81,7 +82,7 @@ export default function RootLayout({
</a>
<Header />
<main id="main-content" className="flex-1">
{children}
<TrackingProvider>{children}</TrackingProvider>
</main>
<Footer />
</body>
+2
View File
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { getPostBySlug, getPublishedPosts } from "@/lib/store";
import PostContent from "@/components/PostContent";
import ViewTracker from "@/components/ViewTracker";
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
@@ -68,6 +69,7 @@ export default async function PostPage({ params }: { params: Promise<{ slug: str
<>
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
<PostContent post={post} prevPost={prevPost} nextPost={nextPost} />
<ViewTracker slug={post.slug} />
</>
);
}
+5 -2
View File
@@ -6,7 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { formatDate, readingTimeLabel, viewCountLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
import { ChevronLeft, ChevronRight } from "lucide-react";
@@ -159,7 +159,7 @@ export default function BlogList({ posts }: BlogListProps) {
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
{paged.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-all duration-300">
<article className="relative py-8 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-xl transition-colors duration-300">
<div className="flex flex-col md:flex-row md:items-start gap-3 md:gap-6">
<div className="shrink-0 md:w-36 md:pt-1">
<time className="font-sans text-sm text-ink-muted tabular-nums">
@@ -170,6 +170,9 @@ export default function BlogList({ posts }: BlogListProps) {
<span className="hidden md:block font-sans text-sm text-ink-muted">
{readingTimeLabel(post.readingTime)}
</span>
<span className="font-sans text-sm text-ink-muted">
{viewCountLabel(post.viewCount)}
</span>
</div>
</div>
<div className="flex-1 min-w-0">
-21
View File
@@ -72,27 +72,6 @@ export default function Footer() {
</a>
</div>
</div>
<div>
<h4 className="font-sans text-xs text-ink-muted tracking-widest uppercase mb-3">
</h4>
<div className="flex flex-col gap-2">
{/* <a
href="http://gitea.asui.xyz/huxu"
target="_blank"
rel="noopener noreferrer"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
Gitea
</a>
<a
href="mailto:arieshuxu@163.com"
className="font-sans text-sm text-ink-muted hover:text-terracotta transition-colors duration-300"
>
Email
</a> */}
</div>
</div>
</div>
</div>
+32 -5
View File
@@ -20,14 +20,41 @@ export default function Header() {
<header className="sticky top-0 z-50 backdrop-blur-md bg-parchment/80 border-b border-warm-gray/20">
<div className="mx-auto px-page max-w-5xl">
<nav className="flex items-center justify-between h-16" aria-label="主导航">
{/* Logo */}
<Link href="/" className="group flex items-center gap-2">
<span className="font-display text-2xl font-semibold tracking-wide text-ink group-hover:text-terracotta transition-colors duration-300">
{/* Logo — 印章风格 */}
<Link href="/" className="group flex items-center gap-2.5" aria-label="随 · 首页">
{/* 印章图标 */}
<svg
width="36"
height="36"
viewBox="0 0 200 200"
xmlns="http://www.w3.org/2000/svg"
className="shrink-0"
aria-hidden="true"
>
<rect width="200" height="200" rx="12" ry="12" fill="#F7F4EB" />
<path
d="M 16,14 C 50,11 140,11 184,14 C 187,50 187,140 184,186 C 140,189 50,189 16,186 C 13,140 13,50 16,14 Z"
fill="none" stroke="#A63D2F" strokeWidth="7.5" strokeLinecap="round" strokeLinejoin="round"
/>
<path
d="M 33,31 C 60,29 140,29 167,31 C 169,60 169,140 167,169 C 140,171 60,171 33,169 C 31,140 31,60 33,31 Z"
fill="none" stroke="#A63D2F" strokeWidth="2.2" strokeLinecap="round"
/>
<text
x="100" y="118" textAnchor="middle" dominantBaseline="central"
fontFamily="'Noto Serif SC', 'Source Han Serif SC', 'Songti SC', serif"
fontSize="100" fontWeight="900" fill="#A63D2F" letterSpacing="0.02em"
></text>
</svg>
{/* 文字标识 */}
<span className="hidden sm:flex flex-col leading-none">
<span className="font-display text-lg font-semibold tracking-wide text-ink group-hover:text-terracotta transition-colors duration-300">
</span>
<span className="hidden sm:inline font-sans text-xs text-ink-muted tracking-widest uppercase">
<span className="font-sans text-[10px] text-ink-muted tracking-[0.2em] mt-0.5">
asui.xyz
</span>
</span>
</Link>
{/* Desktop Nav */}
@@ -100,7 +127,7 @@ export default function Header() {
aria-current={isActive ? "page" : undefined}
className={`
block py-3 px-2 font-sans text-sm tracking-wide border-b border-warm-gray/10 transition-colors duration-300
${isActive ? "text-terracotta" : "text-ink-muted"}
${isActive ? "text-terracotta" : "text-ink-muted hover:text-ink"}
`}
>
{item.label}
+4 -4
View File
@@ -84,10 +84,10 @@ export default function HeroSection() {
</div>
{/* 装饰分隔线 */}
<div className="hero-divider mt-20 flex items-center gap-4 text-warm-gray origin-center">
<div className="h-px flex-1 bg-gradient-to-r from-warm-gray/20 to-transparent" />
<span className="font-display text-sm italic"></span>
<div className="h-px flex-1 bg-gradient-to-l from-warm-gray/20 to-transparent" />
<div className="hero-divider mt-20 flex items-center gap-4 text-warm-gray">
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-warm-gray" />
<span className="font-display text-sm italic shrink-0"></span>
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-warm-gray" />
</div>
</section>
);
+5 -3
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { formatDate, readingTimeLabel, viewCountLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
gsap.registerPlugin(ScrollTrigger);
@@ -102,6 +102,8 @@ export default function PostContent({
<time>{formatDate(post.date)}</time>
<span className="w-1 h-1 rounded-full bg-warm-gray" />
<span>{readingTimeLabel(post.readingTime)}</span>
<span className="w-1 h-1 rounded-full bg-warm-gray" />
<span>{viewCountLabel(post.viewCount)}</span>
</div>
</header>
@@ -152,7 +154,7 @@ export default function PostContent({
{prevPost ? (
<Link
href={`/posts/${prevPost.slug}`}
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-all duration-300"
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-colors duration-300"
>
<span className="font-sans text-xs text-ink-muted block mb-1"></span>
<span className="font-display text-base text-ink group-hover:text-terracotta transition-colors duration-300">
@@ -165,7 +167,7 @@ export default function PostContent({
{nextPost ? (
<Link
href={`/posts/${nextPost.slug}`}
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-all duration-300 text-right"
className="post-nav group p-5 rounded-xl border border-warm-gray/10 hover:border-terracotta/20 hover:bg-cream transition-colors duration-300 text-right"
>
<span className="font-sans text-xs text-ink-muted block mb-1"></span>
<span className="font-display text-base text-ink group-hover:text-terracotta transition-colors duration-300">
+6 -4
View File
@@ -4,7 +4,7 @@ import Link from "next/link";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import type { PublicPost } from "@/lib/store";
import { formatDate, readingTimeLabel } from "@/lib/utils";
import { formatDate, readingTimeLabel, viewCountLabel } from "@/lib/utils";
import { useGsapAnimation } from "./useGsapAnimation";
gsap.registerPlugin(ScrollTrigger);
@@ -12,7 +12,7 @@ gsap.registerPlugin(ScrollTrigger);
function FeaturedCard({ post }: { post: PublicPost }) {
return (
<Link href={`/posts/${post.slug}`} className="group block featured-card">
<article className="relative p-7 md:p-10 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-all duration-500">
<article className="relative p-7 md:p-10 rounded-2xl bg-cream border border-warm-gray/10 hover:border-terracotta/20 hover:shadow-lg hover:shadow-terracotta/5 transition-[border-color,box-shadow,background-color] duration-500">
<span className="inline-block font-sans text-sm tracking-widest text-terracotta uppercase mb-4">
{post.category}
</span>
@@ -26,8 +26,10 @@ function FeaturedCard({ post }: { post: PublicPost }) {
<time>{formatDate(post.date)}</time>
<span className="w-1 h-1 rounded-full bg-warm-gray" />
<span>{readingTimeLabel(post.readingTime)}</span>
<span className="w-1 h-1 rounded-full bg-warm-gray" />
<span>{viewCountLabel(post.viewCount)}</span>
</div>
<div className="absolute top-7 right-7 md:top-10 md:right-10 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all duration-300">
<div className="absolute top-7 right-7 md:top-10 md:right-10 opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-[opacity,transform] duration-300">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" stroke="currentColor" strokeWidth="1.5" className="text-terracotta">
<path d="M5 10h10M11 6l4 4-4 4" />
</svg>
@@ -86,7 +88,7 @@ export function RecentList({ posts }: { posts: PublicPost[] }) {
<div className="space-y-0">
{posts.map((post) => (
<Link key={post.slug} href={`/posts/${post.slug}`} className="recent-item group block">
<article className="flex items-baseline gap-6 py-7 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-lg transition-all duration-300">
<article className="flex items-baseline gap-6 py-7 border-b border-warm-gray/10 hover:bg-cream -mx-4 px-4 rounded-lg transition-colors duration-300">
<time className="shrink-0 font-sans text-sm text-ink-muted tabular-nums w-28 pt-0.5">
{formatDate(post.date)}
</time>
+28
View File
@@ -0,0 +1,28 @@
"use client";
import { usePathname } from "next/navigation";
import VisitTracker from "./VisitTracker";
/**
* 访问追踪包裹组件。
* 放在根 layout 中,自动追踪所有前台页面访问。
*/
export default function TrackingProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
return (
<>
{!pathname.startsWith("/admin") && !pathname.startsWith("/api") && (
<VisitTracker
path={pathname}
postSlug={
pathname.startsWith("/posts/")
? pathname.replace("/posts/", "").split("?")[0]
: undefined
}
/>
)}
{children}
</>
);
}
+38
View File
@@ -0,0 +1,38 @@
"use client";
import { useEffect, useRef } from "react";
interface ViewTrackerProps {
slug: string;
}
/**
* 文章浏览计数客户端组件。
* 挂载时通过 localStorage 判断今日是否已计数,
* 避免同一用户在同一天多次刷新重复计数。
*/
export default function ViewTracker({ slug }: ViewTrackerProps) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
const today = new Date().toISOString().slice(0, 10);
const key = `viewed-${slug}-${today}`;
if (typeof window !== "undefined" && localStorage.getItem(key)) {
return;
}
fetch(`/api/views/${encodeURIComponent(slug)}`, { method: "POST" })
.then(() => {
localStorage.setItem(key, "1");
})
.catch(() => {
// 静默失败,不影响页面体验
});
}, [slug]);
return null;
}
+46
View File
@@ -0,0 +1,46 @@
"use client";
import { useEffect, useRef } from "react";
interface VisitTrackerProps {
/** 当前页面路径,如 "/blog" 或 "/posts/my-post" */
path: string;
/** 如果是文章页,传入 slug */
postSlug?: string;
}
/**
* 访客记录组件。
* 使用 sessionStorage 去重,同一会话内同一页面只记一次。
*/
export default function VisitTracker({ path, postSlug }: VisitTrackerProps) {
const fired = useRef(false);
useEffect(() => {
if (fired.current) return;
fired.current = true;
const key = `visit-${path}`;
if (typeof window !== "undefined" && sessionStorage.getItem(key)) {
return;
}
fetch("/api/visits", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
path,
postSlug: postSlug ?? undefined,
referrer: document.referrer || undefined,
}),
})
.then(() => {
sessionStorage.setItem(key, "1");
})
.catch(() => {
// 静默失败
});
}, [path, postSlug]);
return null;
}
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from "@/components/ui/select";
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
export type PostFormData = Omit<Post, "id" | "createdAt" | "updatedAt">;
export type PostFormData = Omit<Post, "id" | "createdAt" | "updatedAt" | "viewCount">;
interface PostFormProps {
mode: "create" | "edit";
+1 -1
View File
@@ -6,7 +6,7 @@ import type { Post } from "@/lib/store";
*
* 这里只声明可由用户填写的字段,id/createdAt/updatedAt 由 store 生成。
*/
export type SeedPost = Omit<Post, "id" | "createdAt" | "updatedAt">;
export type SeedPost = Omit<Post, "id" | "createdAt" | "updatedAt" | "viewCount">;
export const seedPosts: SeedPost[] = [
{
+135 -3
View File
@@ -27,6 +27,7 @@ export interface Post {
/** 可选封面图,前台卡片可在有值时展示。 */
coverImage?: string;
readingTime: number;
viewCount: number;
featured: boolean;
status: "draft" | "published";
createdAt: string;
@@ -73,6 +74,7 @@ function toPost(row: {
tags: string;
coverImage: string | null;
readingTime: number;
viewCount: number;
featured: boolean;
status: string;
createdAt: string;
@@ -83,6 +85,7 @@ function toPost(row: {
coverImage: row.coverImage ?? undefined,
tags: JSON.parse(row.tags) as string[],
status: row.status as Post["status"],
viewCount: row.viewCount ?? 0,
};
}
@@ -158,16 +161,18 @@ export async function getStats(): Promise<{
featured: number;
categories: number;
tags: number;
totalViews: number;
}> {
const [total, published, draft, featured, categories, tags] = await Promise.all([
const [total, published, draft, featured, categories, tags, viewSum] = await Promise.all([
prisma.post.count(),
prisma.post.count({ where: { status: "published" } }),
prisma.post.count({ where: { status: "draft" } }),
prisma.post.count({ where: { featured: true } }),
prisma.category.count(),
prisma.tag.count(),
prisma.post.aggregate({ _sum: { viewCount: true } }),
]);
return { total, published, draft, featured, categories, tags };
return { total, published, draft, featured, categories, tags, totalViews: viewSum._sum.viewCount ?? 0 };
}
/** 前台用:只返回已发布文章,按日期倒序。 */
@@ -191,6 +196,22 @@ export async function getPostBySlug(slug: string): Promise<PublicPost | undefine
return row ? (toPost(row) as PublicPost) : undefined;
}
/** 给已发布文章的浏览量 +1。返回更新后的数值,未找到返回 null。 */
export async function incrementViewCount(slug: string): Promise<number | null> {
const row = await prisma.post.findFirst({
where: { slug, status: "published" },
select: { id: true },
});
if (!row) return null;
const updated = await prisma.post.update({
where: { id: row.id },
data: { viewCount: { increment: 1 } },
select: { viewCount: true },
});
return updated.viewCount;
}
/** 按分类名过滤已发布文章。 */
export async function getPostsByCategory(category: string): Promise<PublicPost[]> {
const rows = await prisma.post.findMany({
@@ -233,7 +254,7 @@ export async function getPublicCategories(): Promise<(Category & { count: number
}
export async function createPost(
data: Omit<Post, "id" | "createdAt" | "updatedAt">
data: Omit<Post, "id" | "createdAt" | "updatedAt" | "viewCount"> & { viewCount?: number }
): Promise<Post> {
const sanitized = sanitizePostContent(data);
const now = new Date().toISOString();
@@ -249,6 +270,7 @@ export async function createPost(
tags: JSON.stringify(sanitized.tags ?? []),
coverImage: sanitized.coverImage ?? null,
readingTime: sanitized.readingTime ?? 5,
viewCount: sanitized.viewCount ?? 0,
featured: sanitized.featured ?? false,
status: sanitized.status ?? "draft",
createdAt: now,
@@ -344,6 +366,116 @@ export async function deleteTag(id: string): Promise<boolean> {
}
}
// ── PageViews ──
export interface PageViewRecord {
id: string;
path: string;
postSlug: string | null;
ip: string;
userAgent: string;
device: string;
referrer: string | null;
createdAt: string;
}
/** 从 UA 推测设备类型。 */
function parseDevice(ua: string): string {
const uaLower = ua.toLowerCase();
if (/bot|crawler|spider|scraper|googlebot|bingbot/i.test(uaLower)) return "bot";
if (/ipad|tablet|kindle|playbook|silk/i.test(uaLower)) return "tablet";
if (/mobile|android|iphone|ipod|blackberry|opera mini|iemobile/i.test(uaLower)) return "mobile";
return "desktop";
}
/** 脱敏 IP:只保留前两段,如 "192.168.x.x"。 */
function maskIp(ip: string): string {
const parts = ip.replace(/^::ffff:/, "").split(".");
if (parts.length === 4) return `${parts[0]}.${parts[1]}.x.x`;
// IPv6: 只保留前 4 组
const v6parts = ip.split(":");
if (v6parts.length >= 4) return `${v6parts.slice(0, 4).join(":")}::x`;
return "unknown";
}
/** 截断 UA,避免单条过长。 */
function truncateUA(ua: string, maxLen = 200): string {
return ua.length > maxLen ? ua.slice(0, maxLen) + "..." : ua;
}
/** 记录一次页面访问。 */
export async function recordPageView(data: {
path: string;
postSlug?: string;
ip: string;
userAgent: string;
referrer?: string;
}): Promise<void> {
await prisma.pageView.create({
data: {
id: generateId(),
path: data.path,
postSlug: data.postSlug ?? null,
ip: maskIp(data.ip),
userAgent: truncateUA(data.userAgent),
device: parseDevice(data.userAgent),
referrer: data.referrer?.slice(0, 500) ?? null,
createdAt: new Date().toISOString(),
},
});
}
/** 获取最近 N 条访问记录(后台用)。 */
export async function getRecentPageViews(limit = 20): Promise<PageViewRecord[]> {
return prisma.pageView.findMany({
orderBy: { createdAt: "desc" },
take: limit,
});
}
/** 分页查询访问记录(后台用)。 */
export async function getPageViewsPaginated(
page = 1,
pageSize = 30
): Promise<{ data: PageViewRecord[]; total: number; page: number; pageSize: number; totalPages: number }> {
const [data, total] = await Promise.all([
prisma.pageView.findMany({
orderBy: { createdAt: "desc" },
skip: (page - 1) * pageSize,
take: pageSize,
}),
prisma.pageView.count(),
]);
return { data, total, page, pageSize, totalPages: Math.ceil(total / pageSize) };
}
/** 获取访问统计概览(后台用)。 */
export async function getPageViewStats(): Promise<{
totalViews: number;
todayViews: number;
uniqueIps: number;
deviceBreakdown: { device: string; count: number }[];
}> {
const today = new Date().toISOString().slice(0, 10);
const [totalViews, todayViews, allViews] = await Promise.all([
prisma.pageView.count(),
prisma.pageView.count({ where: { createdAt: { startsWith: today } } }),
prisma.pageView.findMany({ select: { ip: true, device: true } }),
]);
const uniqueIps = new Set(allViews.map((v) => v.ip)).size;
const deviceMap = new Map<string, number>();
for (const v of allViews) {
deviceMap.set(v.device, (deviceMap.get(v.device) || 0) + 1);
}
const deviceBreakdown = [...deviceMap.entries()]
.map(([device, count]) => ({ device, count }))
.sort((a, b) => b.count - a.count);
return { totalViews, todayViews, uniqueIps, deviceBreakdown };
}
// ── Auto seed ──
/**
+9
View File
@@ -24,3 +24,12 @@ export function formatDate(dateStr: string): string {
export function readingTimeLabel(minutes: number): string {
return `${minutes} 分钟阅读`;
}
/** 浏览量标签,如 "1,234 次浏览"。 */
export function viewCountLabel(count: number): string {
if (count >= 10000) {
const w = (count / 10000).toFixed(1).replace(/\.0$/, "");
return `${w} 万次浏览`;
}
return `${count.toLocaleString("zh-CN")} 次浏览`;
}