diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 23cd012..8905972 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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 +} diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..f12957d Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/logo-full.svg b/public/logo-full.svg new file mode 100644 index 0000000..8b22997 --- /dev/null +++ b/public/logo-full.svg @@ -0,0 +1,55 @@ + + + + + + + + + + + + + + + + + + + + + + + + + asui.xyz + diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx index 4e99a82..2218234 100644 --- a/src/app/about/page.tsx +++ b/src/app/about/page.tsx @@ -57,7 +57,7 @@ export default function AboutPage() {

你好,我是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 ? ( +
暂无浏览记录
+ ) : ( + <> +
+
+ + + + + + + + + + + + {visits.map((v) => ( + + + + + + + + ))} + +
页面设备IP来源时间
+
+ + {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}