feat: 浏览量统计、浏览记录追踪、UI色彩优化
- 新增 Post.viewCount 字段,文章详情/列表/精选卡片显示浏览量 - 新增 PageView 模型记录访客IP(脱敏)、设备类型、来源 - 后台新增浏览记录独立页面及侧边栏菜单 - 仪表盘新增访问概览:页面访问/今日/独立IP/设备分布 - 整体色彩体系优化:降低黑白对比度、统一暖色调 - UI细节修复:focus-visible、Footer空列、mobile hover、transition-all等10+项
This commit is contained in:
@@ -18,6 +18,7 @@ model Post {
|
|||||||
tags String // JSON 序列化的 string[]
|
tags String // JSON 序列化的 string[]
|
||||||
coverImage String?
|
coverImage String?
|
||||||
readingTime Int @default(5)
|
readingTime Int @default(5)
|
||||||
|
viewCount Int @default(0)
|
||||||
featured Boolean @default(false)
|
featured Boolean @default(false)
|
||||||
status String @default("draft")
|
status String @default("draft")
|
||||||
createdAt String
|
createdAt String
|
||||||
@@ -34,3 +35,14 @@ model Tag {
|
|||||||
id String @id
|
id String @id
|
||||||
name String @unique
|
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 |
@@ -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 |
@@ -57,7 +57,7 @@ export default function AboutPage() {
|
|||||||
<GsapReveal
|
<GsapReveal
|
||||||
variant="fade-up"
|
variant="fade-up"
|
||||||
stagger={0.1}
|
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>
|
<p>
|
||||||
你好,我是<span className="text-ink font-medium">Sui</span>
|
你好,我是<span className="text-ink font-medium">Sui</span>
|
||||||
|
|||||||
@@ -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/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/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/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 }) {
|
export default function AdminLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
|||||||
+111
-5
@@ -12,11 +12,50 @@ interface Stats {
|
|||||||
featured: number;
|
featured: number;
|
||||||
categories: number;
|
categories: number;
|
||||||
tags: 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() {
|
export default function DashboardPage() {
|
||||||
const [stats, setStats] = useState<Stats | null>(null);
|
const [stats, setStats] = useState<Stats | null>(null);
|
||||||
const [recentPosts, setRecentPosts] = useState<Post[]>([]);
|
const [recentPosts, setRecentPosts] = useState<Post[]>([]);
|
||||||
|
const [visitStats, setVisitStats] = useState<VisitStats | null>(null);
|
||||||
|
const [recentVisits, setRecentVisits] = useState<VisitRecord[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
|
|
||||||
@@ -24,9 +63,12 @@ export default function DashboardPage() {
|
|||||||
Promise.all([
|
Promise.all([
|
||||||
safeFetch("/api/stats", undefined, toast).then((r) => r.json()),
|
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()),
|
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);
|
setStats(s);
|
||||||
setRecentPosts(postsResult.data ?? postsResult);
|
setRecentPosts(postsResult.data ?? postsResult);
|
||||||
|
setVisitStats(visitResult.stats);
|
||||||
|
setRecentVisits(visitResult.views);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}).catch(() => setLoading(false));
|
}).catch(() => setLoading(false));
|
||||||
}, [toast]);
|
}, [toast]);
|
||||||
@@ -34,8 +76,9 @@ export default function DashboardPage() {
|
|||||||
if (loading) return <div className="font-sans text-muted-foreground">加载中...</div>;
|
if (loading) return <div className="font-sans text-muted-foreground">加载中...</div>;
|
||||||
|
|
||||||
const statItems = stats ? [
|
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.published, color: "text-accent" },
|
||||||
|
{ label: "文章总数", value: stats.total, color: "text-foreground" },
|
||||||
{ label: "草稿", value: stats.draft, color: "text-primary" },
|
{ label: "草稿", value: stats.draft, color: "text-primary" },
|
||||||
{ label: "精选", value: stats.featured, color: "text-primary" },
|
{ label: "精选", value: stats.featured, color: "text-primary" },
|
||||||
{ label: "分类", value: stats.categories, color: "text-foreground" },
|
{ label: "分类", value: stats.categories, color: "text-foreground" },
|
||||||
@@ -52,15 +95,78 @@ export default function DashboardPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Stats */}
|
{/* 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) => (
|
{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-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>
|
||||||
))}
|
))}
|
||||||
</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 */}
|
{/* Recent posts */}
|
||||||
<h2 className="font-display text-xl font-medium mb-4">最近文章</h2>
|
<h2 className="font-display text-xl font-medium mb-4">最近文章</h2>
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -7,22 +7,21 @@
|
|||||||
/* ── Design Tokens ── */
|
/* ── Design Tokens ── */
|
||||||
@theme inline {
|
@theme inline {
|
||||||
/* Parchment & earth palette — ink-wash tones */
|
/* Parchment & earth palette — ink-wash tones */
|
||||||
--color-parchment: #FDFCFA;
|
--color-parchment: #F7F4EB;
|
||||||
--color-parchment-deep: #F5F2EE;
|
--color-parchment-deep: #EDE8DC;
|
||||||
--color-ink: #050404;
|
--color-ink: #1E1B18;
|
||||||
--color-ink-light: #121010;
|
--color-ink-light: #1E1B18;
|
||||||
--color-ink-muted: #2A2624;
|
--color-ink-muted: #6B655E;
|
||||||
--color-terracotta: #A63D2F;
|
--color-terracotta: #A63D2F;
|
||||||
--color-terracotta-light: #C46B5E;
|
--color-terracotta-light: #C46B5E;
|
||||||
--color-sage: #6E8264;
|
--color-sage: #6E8264;
|
||||||
--color-sage-light: #A3B59B;
|
--color-warm-gray: #CCC6BB;
|
||||||
--color-warm-gray: #C5BDB4;
|
--color-cream: #F0ECE2;
|
||||||
--color-cream: #FAF9F7;
|
|
||||||
|
|
||||||
/* Typography — 宋式 serif priority。
|
/* Typography — 宋式 serif priority。
|
||||||
next/font 注入的 CSS 变量优先,回退到本地系统宋体。 */
|
next/font 注入的 CSS 变量优先,回退到本地系统宋体。 */
|
||||||
--font-display: var(--font-noto-serif), "Cormorant Garamond", "Source Han Serif SC", "Songti SC", serif;
|
--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-sans: var(--font-sans);
|
||||||
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
--font-mono: "JetBrains Mono", "Fira Code", ui-monospace, monospace;
|
||||||
|
|
||||||
@@ -93,7 +92,7 @@ body::before {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
z-index: 9999;
|
z-index: 0;
|
||||||
opacity: 0.03;
|
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");
|
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 {
|
.prose-literary a:hover {
|
||||||
text-decoration-color: var(--color-terracotta);
|
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 {
|
.prose-literary img {
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin: 2em 0;
|
margin: 2em 0;
|
||||||
@@ -255,34 +259,34 @@ body::before {
|
|||||||
|
|
||||||
:root {
|
:root {
|
||||||
/* 水墨纸质风格 — 映射到 shadcn CSS 变量 */
|
/* 水墨纸质风格 — 映射到 shadcn CSS 变量 */
|
||||||
--background: #FDFCFA; /* parchment */
|
--background: #F7F4EB; /* parchment */
|
||||||
--foreground: #050404; /* ink */
|
--foreground: #1E1B18; /* ink */
|
||||||
--card: #FAF9F7; /* cream */
|
--card: #F0ECE2; /* cream */
|
||||||
--card-foreground: #050404;
|
--card-foreground: #1E1B18;
|
||||||
--popover: #FAF9F7;
|
--popover: #F0ECE2;
|
||||||
--popover-foreground: #050404;
|
--popover-foreground: #1E1B18;
|
||||||
--primary: #A63D2F; /* terracotta */
|
--primary: #A63D2F; /* terracotta */
|
||||||
--primary-foreground: #FDFCFA;
|
--primary-foreground: #F7F4EB;
|
||||||
--secondary: #F5F2EE; /* parchment-deep */
|
--secondary: #EDE8DC; /* parchment-deep */
|
||||||
--secondary-foreground: #050404;
|
--secondary-foreground: #1E1B18;
|
||||||
--muted: #C5BDB4; /* warm-gray */
|
--muted: #CCC6BB; /* warm-gray */
|
||||||
--muted-foreground: #2A2624; /* ink-muted */
|
--muted-foreground: #6B655E; /* ink-muted */
|
||||||
--accent: #6E8264; /* sage */
|
--accent: #6E8264; /* sage */
|
||||||
--accent-foreground: #FDFCFA;
|
--accent-foreground: #F7F4EB;
|
||||||
--destructive: #B91C1C;
|
--destructive: #8B2C20; /* terracotta 深色变体 */
|
||||||
--border: #C5BDB433; /* warm-gray/20 */
|
--border: #CCC6BB33; /* warm-gray/20 */
|
||||||
--input: #C5BDB433;
|
--input: #CCC6BB33;
|
||||||
--ring: #A63D2F; /* terracotta */
|
--ring: #A63D2F; /* terracotta */
|
||||||
--chart-1: #A63D2F;
|
--chart-1: #A63D2F;
|
||||||
--chart-2: #6E8264;
|
--chart-2: #6E8264;
|
||||||
--chart-3: #C46B5E;
|
--chart-3: #C46B5E;
|
||||||
--chart-4: #A3B59B;
|
--chart-4: #CCC6BB;
|
||||||
--chart-5: #2A2624;
|
--chart-5: #6B655E;
|
||||||
--radius: 0.75rem;
|
--radius: 0.75rem;
|
||||||
--sidebar: #F5F2EE;
|
--sidebar: #EDE8DC;
|
||||||
--sidebar-foreground: #050404;
|
--sidebar-foreground: #1E1B18;
|
||||||
--sidebar-primary: #A63D2F;
|
--sidebar-primary: #A63D2F;
|
||||||
--sidebar-primary-foreground: #FDFCFA;
|
--sidebar-primary-foreground: #F7F4EB;
|
||||||
--sidebar-accent: #FAF9F7;
|
--sidebar-accent: #FAF9F7;
|
||||||
--sidebar-accent-foreground: #050404;
|
--sidebar-accent-foreground: #050404;
|
||||||
--sidebar-border: #C5BDB433;
|
--sidebar-border: #C5BDB433;
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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
@@ -1,6 +1,7 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
import Header from "@/components/Header";
|
import Header from "@/components/Header";
|
||||||
import Footer from "@/components/Footer";
|
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 { Noto_Serif_SC, Noto_Sans_SC, Cormorant_Garamond, Geist } from "next/font/google";
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
@@ -81,7 +82,7 @@ export default function RootLayout({
|
|||||||
</a>
|
</a>
|
||||||
<Header />
|
<Header />
|
||||||
<main id="main-content" className="flex-1">
|
<main id="main-content" className="flex-1">
|
||||||
{children}
|
<TrackingProvider>{children}</TrackingProvider>
|
||||||
</main>
|
</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import type { Metadata } from "next";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } from "next/navigation";
|
||||||
import { getPostBySlug, getPublishedPosts } from "@/lib/store";
|
import { getPostBySlug, getPublishedPosts } from "@/lib/store";
|
||||||
import PostContent from "@/components/PostContent";
|
import PostContent from "@/components/PostContent";
|
||||||
|
import ViewTracker from "@/components/ViewTracker";
|
||||||
|
|
||||||
const SITE_URL = process.env.NEXT_PUBLIC_SITE_URL || "https://asui.xyz";
|
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) }} />
|
<script type="application/ld+json" dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }} />
|
||||||
<PostContent post={post} prevPost={prevPost} nextPost={nextPost} />
|
<PostContent post={post} prevPost={prevPost} nextPost={nextPost} />
|
||||||
|
<ViewTracker slug={post.slug} />
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
import type { PublicPost } from "@/lib/store";
|
import type { PublicPost } from "@/lib/store";
|
||||||
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
import { formatDate, readingTimeLabel, viewCountLabel } from "@/lib/utils";
|
||||||
import { useGsapAnimation } from "./useGsapAnimation";
|
import { useGsapAnimation } from "./useGsapAnimation";
|
||||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
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">
|
<div ref={listRef as React.RefObject<HTMLDivElement>} className="space-y-0">
|
||||||
{paged.map((post) => (
|
{paged.map((post) => (
|
||||||
<Link key={post.slug} href={`/posts/${post.slug}`} className="blog-list-item group block">
|
<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="flex flex-col md:flex-row md:items-start gap-3 md:gap-6">
|
||||||
<div className="shrink-0 md:w-36 md:pt-1">
|
<div className="shrink-0 md:w-36 md:pt-1">
|
||||||
<time className="font-sans text-sm text-ink-muted tabular-nums">
|
<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">
|
<span className="hidden md:block font-sans text-sm text-ink-muted">
|
||||||
{readingTimeLabel(post.readingTime)}
|
{readingTimeLabel(post.readingTime)}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="font-sans text-sm text-ink-muted">
|
||||||
|
{viewCountLabel(post.viewCount)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
|
|||||||
@@ -72,27 +72,6 @@ export default function Footer() {
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -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">
|
<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">
|
<div className="mx-auto px-page max-w-5xl">
|
||||||
<nav className="flex items-center justify-between h-16" aria-label="主导航">
|
<nav className="flex items-center justify-between h-16" aria-label="主导航">
|
||||||
{/* Logo */}
|
{/* Logo — 印章风格 */}
|
||||||
<Link href="/" className="group flex items-center gap-2">
|
<Link href="/" className="group flex items-center gap-2.5" aria-label="随 · 首页">
|
||||||
<span className="font-display text-2xl font-semibold tracking-wide text-ink group-hover:text-terracotta transition-colors duration-300">
|
{/* 印章图标 */}
|
||||||
|
<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>
|
||||||
<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
|
asui.xyz
|
||||||
</span>
|
</span>
|
||||||
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Desktop Nav */}
|
{/* Desktop Nav */}
|
||||||
@@ -100,7 +127,7 @@ export default function Header() {
|
|||||||
aria-current={isActive ? "page" : undefined}
|
aria-current={isActive ? "page" : undefined}
|
||||||
className={`
|
className={`
|
||||||
block py-3 px-2 font-sans text-sm tracking-wide border-b border-warm-gray/10 transition-colors duration-300
|
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}
|
{item.label}
|
||||||
|
|||||||
@@ -84,10 +84,10 @@ export default function HeroSection() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 装饰分隔线 */}
|
{/* 装饰分隔线 */}
|
||||||
<div className="hero-divider mt-20 flex items-center gap-4 text-warm-gray origin-center">
|
<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-warm-gray/20 to-transparent" />
|
<div className="h-px flex-1 bg-gradient-to-r from-transparent to-warm-gray" />
|
||||||
<span className="font-display text-sm italic">精选文章</span>
|
<span className="font-display text-sm italic shrink-0">精选文章</span>
|
||||||
<div className="h-px flex-1 bg-gradient-to-l from-warm-gray/20 to-transparent" />
|
<div className="h-px flex-1 bg-gradient-to-l from-transparent to-warm-gray" />
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
import type { PublicPost } from "@/lib/store";
|
import type { PublicPost } from "@/lib/store";
|
||||||
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
import { formatDate, readingTimeLabel, viewCountLabel } from "@/lib/utils";
|
||||||
import { useGsapAnimation } from "./useGsapAnimation";
|
import { useGsapAnimation } from "./useGsapAnimation";
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
@@ -102,6 +102,8 @@ export default function PostContent({
|
|||||||
<time>{formatDate(post.date)}</time>
|
<time>{formatDate(post.date)}</time>
|
||||||
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
||||||
<span>{readingTimeLabel(post.readingTime)}</span>
|
<span>{readingTimeLabel(post.readingTime)}</span>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
||||||
|
<span>{viewCountLabel(post.viewCount)}</span>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
@@ -152,7 +154,7 @@ export default function PostContent({
|
|||||||
{prevPost ? (
|
{prevPost ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/posts/${prevPost.slug}`}
|
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-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">
|
<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 ? (
|
{nextPost ? (
|
||||||
<Link
|
<Link
|
||||||
href={`/posts/${nextPost.slug}`}
|
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-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">
|
<span className="font-display text-base text-ink group-hover:text-terracotta transition-colors duration-300">
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import Link from "next/link";
|
|||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
import { ScrollTrigger } from "gsap/ScrollTrigger";
|
||||||
import type { PublicPost } from "@/lib/store";
|
import type { PublicPost } from "@/lib/store";
|
||||||
import { formatDate, readingTimeLabel } from "@/lib/utils";
|
import { formatDate, readingTimeLabel, viewCountLabel } from "@/lib/utils";
|
||||||
import { useGsapAnimation } from "./useGsapAnimation";
|
import { useGsapAnimation } from "./useGsapAnimation";
|
||||||
|
|
||||||
gsap.registerPlugin(ScrollTrigger);
|
gsap.registerPlugin(ScrollTrigger);
|
||||||
@@ -12,7 +12,7 @@ gsap.registerPlugin(ScrollTrigger);
|
|||||||
function FeaturedCard({ post }: { post: PublicPost }) {
|
function FeaturedCard({ post }: { post: PublicPost }) {
|
||||||
return (
|
return (
|
||||||
<Link href={`/posts/${post.slug}`} className="group block featured-card">
|
<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">
|
<span className="inline-block font-sans text-sm tracking-widest text-terracotta uppercase mb-4">
|
||||||
{post.category}
|
{post.category}
|
||||||
</span>
|
</span>
|
||||||
@@ -26,8 +26,10 @@ function FeaturedCard({ post }: { post: PublicPost }) {
|
|||||||
<time>{formatDate(post.date)}</time>
|
<time>{formatDate(post.date)}</time>
|
||||||
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
||||||
<span>{readingTimeLabel(post.readingTime)}</span>
|
<span>{readingTimeLabel(post.readingTime)}</span>
|
||||||
|
<span className="w-1 h-1 rounded-full bg-warm-gray" />
|
||||||
|
<span>{viewCountLabel(post.viewCount)}</span>
|
||||||
</div>
|
</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">
|
<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" />
|
<path d="M5 10h10M11 6l4 4-4 4" />
|
||||||
</svg>
|
</svg>
|
||||||
@@ -86,7 +88,7 @@ export function RecentList({ posts }: { posts: PublicPost[] }) {
|
|||||||
<div className="space-y-0">
|
<div className="space-y-0">
|
||||||
{posts.map((post) => (
|
{posts.map((post) => (
|
||||||
<Link key={post.slug} href={`/posts/${post.slug}`} className="recent-item group block">
|
<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">
|
<time className="shrink-0 font-sans text-sm text-ink-muted tabular-nums w-28 pt-0.5">
|
||||||
{formatDate(post.date)}
|
{formatDate(post.date)}
|
||||||
</time>
|
</time>
|
||||||
|
|||||||
@@ -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}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ import {
|
|||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
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 {
|
interface PostFormProps {
|
||||||
mode: "create" | "edit";
|
mode: "create" | "edit";
|
||||||
|
|||||||
+1
-1
@@ -6,7 +6,7 @@ import type { Post } from "@/lib/store";
|
|||||||
*
|
*
|
||||||
* 这里只声明可由用户填写的字段,id/createdAt/updatedAt 由 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[] = [
|
export const seedPosts: SeedPost[] = [
|
||||||
{
|
{
|
||||||
|
|||||||
+135
-3
@@ -27,6 +27,7 @@ export interface Post {
|
|||||||
/** 可选封面图,前台卡片可在有值时展示。 */
|
/** 可选封面图,前台卡片可在有值时展示。 */
|
||||||
coverImage?: string;
|
coverImage?: string;
|
||||||
readingTime: number;
|
readingTime: number;
|
||||||
|
viewCount: number;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
status: "draft" | "published";
|
status: "draft" | "published";
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -73,6 +74,7 @@ function toPost(row: {
|
|||||||
tags: string;
|
tags: string;
|
||||||
coverImage: string | null;
|
coverImage: string | null;
|
||||||
readingTime: number;
|
readingTime: number;
|
||||||
|
viewCount: number;
|
||||||
featured: boolean;
|
featured: boolean;
|
||||||
status: string;
|
status: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -83,6 +85,7 @@ function toPost(row: {
|
|||||||
coverImage: row.coverImage ?? undefined,
|
coverImage: row.coverImage ?? undefined,
|
||||||
tags: JSON.parse(row.tags) as string[],
|
tags: JSON.parse(row.tags) as string[],
|
||||||
status: row.status as Post["status"],
|
status: row.status as Post["status"],
|
||||||
|
viewCount: row.viewCount ?? 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,16 +161,18 @@ export async function getStats(): Promise<{
|
|||||||
featured: number;
|
featured: number;
|
||||||
categories: number;
|
categories: number;
|
||||||
tags: 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(),
|
||||||
prisma.post.count({ where: { status: "published" } }),
|
prisma.post.count({ where: { status: "published" } }),
|
||||||
prisma.post.count({ where: { status: "draft" } }),
|
prisma.post.count({ where: { status: "draft" } }),
|
||||||
prisma.post.count({ where: { featured: true } }),
|
prisma.post.count({ where: { featured: true } }),
|
||||||
prisma.category.count(),
|
prisma.category.count(),
|
||||||
prisma.tag.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;
|
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[]> {
|
export async function getPostsByCategory(category: string): Promise<PublicPost[]> {
|
||||||
const rows = await prisma.post.findMany({
|
const rows = await prisma.post.findMany({
|
||||||
@@ -233,7 +254,7 @@ export async function getPublicCategories(): Promise<(Category & { count: number
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function createPost(
|
export async function createPost(
|
||||||
data: Omit<Post, "id" | "createdAt" | "updatedAt">
|
data: Omit<Post, "id" | "createdAt" | "updatedAt" | "viewCount"> & { viewCount?: number }
|
||||||
): Promise<Post> {
|
): Promise<Post> {
|
||||||
const sanitized = sanitizePostContent(data);
|
const sanitized = sanitizePostContent(data);
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
@@ -249,6 +270,7 @@ export async function createPost(
|
|||||||
tags: JSON.stringify(sanitized.tags ?? []),
|
tags: JSON.stringify(sanitized.tags ?? []),
|
||||||
coverImage: sanitized.coverImage ?? null,
|
coverImage: sanitized.coverImage ?? null,
|
||||||
readingTime: sanitized.readingTime ?? 5,
|
readingTime: sanitized.readingTime ?? 5,
|
||||||
|
viewCount: sanitized.viewCount ?? 0,
|
||||||
featured: sanitized.featured ?? false,
|
featured: sanitized.featured ?? false,
|
||||||
status: sanitized.status ?? "draft",
|
status: sanitized.status ?? "draft",
|
||||||
createdAt: now,
|
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 ──
|
// ── Auto seed ──
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -24,3 +24,12 @@ export function formatDate(dateStr: string): string {
|
|||||||
export function readingTimeLabel(minutes: number): string {
|
export function readingTimeLabel(minutes: number): string {
|
||||||
return `${minutes} 分钟阅读`;
|
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")} 次浏览`;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user