你好,我是Sui
diff --git a/src/app/admin/layout.tsx b/src/app/admin/layout.tsx
index a7de3fe..85790df 100644
--- a/src/app/admin/layout.tsx
+++ b/src/app/admin/layout.tsx
@@ -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 }) {
diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx
index 423aa27..93038c8 100644
--- a/src/app/admin/page.tsx
+++ b/src/app/admin/page.tsx
@@ -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 = {
+ 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(null);
const [recentPosts, setRecentPosts] = useState([]);
+ const [visitStats, setVisitStats] = useState(null);
+ const [recentVisits, setRecentVisits] = useState([]);
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 加载中...
;
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() {
{/* Stats */}
-
+
{statItems.map((s) => (
-
+
{s.value}
-
{s.label}
+
{s.label}
))}
+ {/* Visit overview */}
+ {visitStats && (
+
+
访问概览
+
+
+
+ {visitStats.totalViews.toLocaleString("zh-CN")}
+
+
页面访问
+
+
+
+ {visitStats.todayViews.toLocaleString("zh-CN")}
+
+
今日访问
+
+
+
+ {visitStats.uniqueIps}
+
+
独立 IP
+
+ {visitStats.deviceBreakdown.slice(0, 3).map((d) => (
+
+
{d.count}
+
+ {DEVICE_LABELS[d.device] || d.device}
+
+
+ ))}
+
+
+ {/* Recent visits */}
+
最近访问
+
+ {recentVisits.map((v) => (
+
+
+ {DEVICE_LABELS[v.device] || v.device}
+
+
+ {v.path}{v.postSlug ? ` · ${v.postSlug}` : ""}
+
+
+ {v.ip}
+
+
+ {timeAgo(v.createdAt)}
+
+
+ ))}
+ {recentVisits.length === 0 && (
+
暂无访问记录
+ )}
+
+
+ )}
+
{/* Recent posts */}
最近文章
diff --git a/src/app/admin/visits/page.tsx b/src/app/admin/visits/page.tsx
new file mode 100644
index 0000000..088fc04
--- /dev/null
+++ b/src/app/admin/visits/page.tsx
@@ -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
= {
+ desktop: "桌面",
+ mobile: "手机",
+ tablet: "平板",
+ bot: "爬虫",
+ unknown: "未知",
+};
+
+const DEVICE_COLORS: Record = {
+ 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([]);
+ 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 (
+
+
+
浏览记录
+
+ 共 {total.toLocaleString("zh-CN")} 条记录
+
+
+
+ {loading ? (
+
加载中...
+ ) : visits.length === 0 ? (
+
暂无浏览记录
+ ) : (
+ <>
+
+
+
+
+
+ | 页面 |
+ 设备 |
+ IP |
+ 来源 |
+ 时间 |
+
+
+
+ {visits.map((v) => (
+
+ |
+
+
+ {v.path}
+
+ {v.postSlug && (
+ 文章
+ )}
+
+ |
+
+
+ {DEVICE_LABELS[v.device] || v.device}
+
+ |
+
+ {v.ip}
+ |
+
+ {v.referrer || "-"}
+ |
+
+ {new Date(v.createdAt).toLocaleString("zh-CN", {
+ month: "2-digit", day: "2-digit", hour: "2-digit", minute: "2-digit",
+ })}
+ |
+
+ ))}
+
+
+
+
+
+ {/* Pagination */}
+ {totalPages > 1 && (
+
+
+
+ {page} / {totalPages}
+
+
+
+ )}
+ >
+ )}
+
+ );
+}
diff --git a/src/app/api/views/[slug]/route.ts b/src/app/api/views/[slug]/route.ts
new file mode 100644
index 0000000..a727222
--- /dev/null
+++ b/src/app/api/views/[slug]/route.ts
@@ -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 });
+}
diff --git a/src/app/api/visits/route.ts b/src/app/api/visits/route.ts
new file mode 100644
index 0000000..4092457
--- /dev/null
+++ b/src/app/api/visits/route.ts
@@ -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 });
+ }
+}
diff --git a/src/app/api/visits/stats/route.ts b/src/app/api/visits/stats/route.ts
new file mode 100644
index 0000000..236d131
--- /dev/null
+++ b/src/app/api/visits/stats/route.ts
@@ -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,
+ });
+}
diff --git a/src/app/favicon.ico b/src/app/favicon.ico
index 718d6fe..2556d93 100644
Binary files a/src/app/favicon.ico and b/src/app/favicon.ico differ
diff --git a/src/app/globals.css b/src/app/globals.css
index 5ec1260..42c859b 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -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;
diff --git a/src/app/icon.png b/src/app/icon.png
new file mode 100644
index 0000000..2f07fc9
Binary files /dev/null and b/src/app/icon.png differ
diff --git a/src/app/icon.svg b/src/app/icon.svg
new file mode 100644
index 0000000..e37c300
--- /dev/null
+++ b/src/app/icon.svg
@@ -0,0 +1,44 @@
+
diff --git a/src/app/layout.tsx b/src/app/layout.tsx
index fe58633..15874f5 100644
--- a/src/app/layout.tsx
+++ b/src/app/layout.tsx
@@ -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({
- {children}
+ {children}