-
-
-
-
+
+
setOpen(false)} />
+
+
+ {/* 移动端顶部条 */}
+
+
+
+ sundynix{' '}
+ admin
+
+
+
+
+
+
+
)
}
diff --git a/admin/src/components/admin-sidebar.tsx b/admin/src/components/admin-sidebar.tsx
new file mode 100644
index 0000000..e925725
--- /dev/null
+++ b/admin/src/components/admin-sidebar.tsx
@@ -0,0 +1,123 @@
+import { Link, NavLink, useNavigate } from 'react-router-dom'
+import {
+ BarChart3,
+ FileText,
+ Home,
+ LayoutDashboard,
+ LogOut,
+ Moon,
+ Sun,
+ X,
+} from 'lucide-react'
+import { logout } from '@/api/auth'
+import { useTheme } from '@/components/theme-provider'
+import { LogoMark } from '@/components/logo'
+import { cn } from '@/lib/utils'
+
+const NAV = [
+ { to: '/', label: '仪表盘', icon: LayoutDashboard, end: true },
+ { to: '/posts', label: '文章', icon: FileText, end: false },
+ { to: '/visits', label: '访问统计', icon: BarChart3, end: false },
+]
+
+interface Props {
+ open: boolean
+ onClose: () => void
+}
+
+export function AdminSidebar({ open, onClose }: Props) {
+ const navigate = useNavigate()
+ const { resolved, setTheme } = useTheme()
+
+ const itemCls = ({ isActive }: { isActive: boolean }) =>
+ cn(
+ 'flex items-center gap-3 rounded-lg px-3 py-2.5 text-[14px] transition-colors',
+ isActive
+ ? 'bg-accent-soft font-medium text-accent-ink'
+ : 'text-ink-2 hover:bg-ground hover:text-ink',
+ )
+
+ const bottomBtnCls =
+ 'flex w-full items-center gap-3 rounded-lg px-3 py-2.5 text-[14px] text-ink-2 transition-colors hover:bg-ground hover:text-ink'
+
+ return (
+ <>
+ {open && (
+
+ )}
+
+ >
+ )
+}
diff --git a/admin/src/components/confirm-dialog.tsx b/admin/src/components/confirm-dialog.tsx
new file mode 100644
index 0000000..a278c98
--- /dev/null
+++ b/admin/src/components/confirm-dialog.tsx
@@ -0,0 +1,63 @@
+import { cn } from '@/lib/utils'
+
+interface Props {
+ open: boolean
+ title: string
+ description?: string
+ confirmText?: string
+ danger?: boolean
+ loading?: boolean
+ onConfirm: () => void
+ onCancel: () => void
+}
+
+export function ConfirmDialog({
+ open,
+ title,
+ description,
+ confirmText = '确认',
+ danger,
+ loading,
+ onConfirm,
+ onCancel,
+}: Props) {
+ if (!open) return null
+ return (
+
+
e.stopPropagation()}
+ >
+
{title}
+ {description && (
+
+ {description}
+
+ )}
+
+
+
+
+
+
+ )
+}
diff --git a/admin/src/components/login/ai-banner.tsx b/admin/src/components/login/ai-banner.tsx
new file mode 100644
index 0000000..ee0f73d
--- /dev/null
+++ b/admin/src/components/login/ai-banner.tsx
@@ -0,0 +1,168 @@
+import { motion } from 'framer-motion'
+
+/** 登录背景:左青色数据流 + 右紫色神经网络 + 中央连线(呼应 logo)。搬自参考项目。 */
+export default function AIBanner() {
+ const dataParticles = Array.from({ length: 100 }).map((_, i) => ({
+ id: `dp-${i}`,
+ y: 10 + Math.random() * 80,
+ size: Math.random() > 0.8 ? 6 : Math.random() * 3 + 1.5,
+ isSquare: Math.random() > 0.6,
+ duration: Math.random() * 2 + 1.5,
+ delay: Math.random() * 3,
+ opacity: Math.random() * 0.7 + 0.3,
+ }))
+
+ const neuralNodes = [
+ { x: 10, y: 50 }, { x: 30, y: 25 }, { x: 35, y: 75 },
+ { x: 55, y: 45 }, { x: 75, y: 15 }, { x: 70, y: 85 },
+ { x: 90, y: 55 }, { x: 80, y: 40 }, { x: 50, y: 90 },
+ { x: 20, y: 95 }, { x: 95, y: 20 }, { x: 45, y: 10 },
+ { x: 85, y: 75 }, { x: 15, y: 20 },
+ ]
+ const neuralLines = [
+ [0, 1], [0, 2], [1, 3], [2, 3], [1, 4], [3, 4],
+ [3, 6], [2, 5], [3, 5], [5, 6], [4, 7], [6, 7],
+ [2, 8], [5, 8], [0, 9], [2, 9], [4, 10], [7, 10],
+ [1, 11], [4, 11], [6, 12], [5, 12], [0, 13], [1, 13],
+ ]
+
+ return (
+
+
+
+
+
+ {dataParticles.map((p) => (
+
+ ))}
+
+ {Array.from({ length: 20 }).map((_, i) => (
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ )
+}
diff --git a/admin/src/components/login/ai-core-effect.tsx b/admin/src/components/login/ai-core-effect.tsx
new file mode 100644
index 0000000..03f2efd
--- /dev/null
+++ b/admin/src/components/login/ai-core-effect.tsx
@@ -0,0 +1,305 @@
+import { useState } from 'react'
+import { motion, AnimatePresence } from 'framer-motion'
+import { Cpu, ArrowRight } from 'lucide-react'
+import AIBanner from './ai-banner'
+
+/** 登录前的 boot 过渡:点击芯片 → 神经链接进度 → 解锁。搬自参考项目。 */
+export default function AICoreEffect({ onUnlock }: { onUnlock: () => void }) {
+ const [synced, setSynced] = useState(false)
+ const [progress, setProgress] = useState(0)
+
+ const handleSync = () => {
+ if (synced || progress > 0) return
+ let p = 0
+ const interval = setInterval(() => {
+ p += 1
+ setProgress(p)
+ if (p >= 100) {
+ clearInterval(interval)
+ setSynced(true)
+ setTimeout(() => onUnlock(), 800)
+ }
+ }, 25)
+ }
+
+ return (
+
+ {!synced && (
+
+
+
+
+ 0 ? 'scale-[1.05] opacity-100' : 'scale-100 opacity-90'}`}
+ >
+
+
+
+
+
+
+
+
+
+
0 ? 'text-fuchsia-500' : 'text-cyan-500/30'}`}
+ animate={{ rotate: progress > 0 ? 360 : 0 }}
+ transition={{ duration: 10, repeat: Infinity, ease: 'linear' }}
+ >
+
+
+
+
+
+
+
+
+ {progress === 0
+ ? 'Core Synchronization Required'
+ : progress < 100
+ ? 'Establishing Neural Link...'
+ : 'Link Established'}
+
+
+ {progress === 0 && (
+
+ INITIATE BOOT
+ SEQUENCE
+
+ )}
+
+ {progress > 0 && (
+
+ {progress}%
+
+ )}
+
+
+
+ )}
+
+ {synced && (
+
+ )}
+
+ )
+}
diff --git a/admin/src/components/login/login-characters.tsx b/admin/src/components/login/login-characters.tsx
new file mode 100644
index 0000000..1b0387c
--- /dev/null
+++ b/admin/src/components/login/login-characters.tsx
@@ -0,0 +1,424 @@
+import { useState, useEffect, useRef } from 'react'
+
+interface PupilProps {
+ size?: number
+ maxDistance?: number
+ pupilColor?: string
+ forceLookX?: number
+ forceLookY?: number
+}
+
+const Pupil = ({
+ size = 12,
+ maxDistance = 5,
+ pupilColor = 'black',
+ forceLookX,
+ forceLookY,
+}: PupilProps) => {
+ const [mouseX, setMouseX] = useState(0)
+ const [mouseY, setMouseY] = useState(0)
+ const ref = useRef
(null)
+
+ useEffect(() => {
+ const h = (e: MouseEvent) => {
+ setMouseX(e.clientX)
+ setMouseY(e.clientY)
+ }
+ window.addEventListener('mousemove', h)
+ return () => window.removeEventListener('mousemove', h)
+ }, [])
+
+ const calc = () => {
+ if (!ref.current) return { x: 0, y: 0 }
+ if (forceLookX !== undefined && forceLookY !== undefined)
+ return { x: forceLookX, y: forceLookY }
+ const r = ref.current.getBoundingClientRect()
+ const cx = r.left + r.width / 2
+ const cy = r.top + r.height / 2
+ const dx = mouseX - cx
+ const dy = mouseY - cy
+ const dist = Math.min(Math.sqrt(dx * dx + dy * dy), maxDistance)
+ const a = Math.atan2(dy, dx)
+ return { x: Math.cos(a) * dist, y: Math.sin(a) * dist }
+ }
+ const p = calc()
+ return (
+
+ )
+}
+
+interface EyeBallProps {
+ size?: number
+ pupilSize?: number
+ maxDistance?: number
+ eyeColor?: string
+ pupilColor?: string
+ isBlinking?: boolean
+ forceLookX?: number
+ forceLookY?: number
+}
+
+const EyeBall = ({
+ size = 48,
+ pupilSize = 16,
+ maxDistance = 10,
+ eyeColor = 'white',
+ pupilColor = 'black',
+ isBlinking = false,
+ forceLookX,
+ forceLookY,
+}: EyeBallProps) => {
+ const [mouseX, setMouseX] = useState(0)
+ const [mouseY, setMouseY] = useState(0)
+ const ref = useRef(null)
+
+ useEffect(() => {
+ const h = (e: MouseEvent) => {
+ setMouseX(e.clientX)
+ setMouseY(e.clientY)
+ }
+ window.addEventListener('mousemove', h)
+ return () => window.removeEventListener('mousemove', h)
+ }, [])
+
+ const calc = () => {
+ if (!ref.current) return { x: 0, y: 0 }
+ if (forceLookX !== undefined && forceLookY !== undefined)
+ return { x: forceLookX, y: forceLookY }
+ const r = ref.current.getBoundingClientRect()
+ const cx = r.left + r.width / 2
+ const cy = r.top + r.height / 2
+ const dx = mouseX - cx
+ const dy = mouseY - cy
+ const dist = Math.min(Math.sqrt(dx * dx + dy * dy), maxDistance)
+ const a = Math.atan2(dy, dx)
+ return { x: Math.cos(a) * dist, y: Math.sin(a) * dist }
+ }
+ const p = calc()
+ return (
+
+ {!isBlinking && (
+
+ )}
+
+ )
+}
+
+interface Props {
+ isTyping?: boolean
+ showPassword?: boolean
+ passwordLength?: number
+}
+
+export default function LoginCharacters({
+ isTyping = false,
+ showPassword = false,
+ passwordLength = 0,
+}: Props) {
+ const [mouseX, setMouseX] = useState(0)
+ const [mouseY, setMouseY] = useState(0)
+ const [isPrimaryBlink, setIsPrimaryBlink] = useState(false)
+ const [isDarkBlink, setIsDarkBlink] = useState(false)
+ const [isLooking, setIsLooking] = useState(false)
+ const [isPrimaryPeek, setIsPrimaryPeek] = useState(false)
+ const primaryRef = useRef(null)
+ const darkRef = useRef(null)
+ const yellowRef = useRef(null)
+ const orangeRef = useRef(null)
+
+ useEffect(() => {
+ const h = (e: MouseEvent) => {
+ setMouseX(e.clientX)
+ setMouseY(e.clientY)
+ }
+ window.addEventListener('mousemove', h)
+ return () => window.removeEventListener('mousemove', h)
+ }, [])
+
+ useEffect(() => {
+ const schedule = (): ReturnType =>
+ setTimeout(
+ () => {
+ setIsPrimaryBlink(true)
+ setTimeout(() => {
+ setIsPrimaryBlink(false)
+ schedule()
+ }, 150)
+ },
+ Math.random() * 4000 + 3000,
+ )
+ const t = schedule()
+ return () => clearTimeout(t)
+ }, [])
+
+ useEffect(() => {
+ const schedule = (): ReturnType =>
+ setTimeout(
+ () => {
+ setIsDarkBlink(true)
+ setTimeout(() => {
+ setIsDarkBlink(false)
+ schedule()
+ }, 150)
+ },
+ Math.random() * 4000 + 3000,
+ )
+ const t = schedule()
+ return () => clearTimeout(t)
+ }, [])
+
+ useEffect(() => {
+ if (isTyping) {
+ setIsLooking(true)
+ const t = setTimeout(() => setIsLooking(false), 800)
+ return () => clearTimeout(t)
+ } else setIsLooking(false)
+ }, [isTyping])
+
+ useEffect(() => {
+ if (passwordLength > 0 && showPassword) {
+ const t = setTimeout(
+ () => {
+ setIsPrimaryPeek(true)
+ setTimeout(() => setIsPrimaryPeek(false), 800)
+ },
+ Math.random() * 3000 + 2000,
+ )
+ return () => clearTimeout(t)
+ } else setIsPrimaryPeek(false)
+ }, [passwordLength, showPassword, isPrimaryPeek])
+
+ const calcPos = (ref: React.RefObject) => {
+ if (!ref.current) return { faceX: 0, faceY: 0, bodySkew: 0 }
+ const r = ref.current.getBoundingClientRect()
+ const cx = r.left + r.width / 2
+ const cy = r.top + r.height / 3
+ const dx = mouseX - cx
+ const dy = mouseY - cy
+ return {
+ faceX: Math.max(-15, Math.min(15, dx / 20)),
+ faceY: Math.max(-10, Math.min(10, dy / 30)),
+ bodySkew: Math.max(-6, Math.min(6, -dx / 120)),
+ }
+ }
+
+ const pp = calcPos(primaryRef)
+ const bp = calcPos(darkRef)
+ const yp = calcPos(yellowRef)
+ const op = calcPos(orangeRef)
+ const hiding = passwordLength > 0 && !showPassword
+ const showing = passwordLength > 0 && showPassword
+
+ return (
+
+ )
+}
diff --git a/admin/src/components/toast.tsx b/admin/src/components/toast.tsx
new file mode 100644
index 0000000..93111be
--- /dev/null
+++ b/admin/src/components/toast.tsx
@@ -0,0 +1,61 @@
+import {
+ createContext,
+ useCallback,
+ useContext,
+ useState,
+ type ReactNode,
+} from 'react'
+import { CheckCircle2, XCircle } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+type ToastType = 'success' | 'error'
+interface ToastItem {
+ id: number
+ message: string
+ type: ToastType
+}
+
+const ToastCtx = createContext<(message: string, type?: ToastType) => void>(
+ () => {},
+)
+
+let seq = 0
+
+export function ToastProvider({ children }: { children: ReactNode }) {
+ const [items, setItems] = useState([])
+
+ const toast = useCallback((message: string, type: ToastType = 'success') => {
+ const id = ++seq
+ setItems((l) => [...l, { id, message, type }])
+ setTimeout(() => setItems((l) => l.filter((t) => t.id !== id)), 3000)
+ }, [])
+
+ return (
+
+ {children}
+
+ {items.map((t) => (
+
+ {t.type === 'success' ? (
+
+ ) : (
+
+ )}
+ {t.message}
+
+ ))}
+
+
+ )
+}
+
+export function useToast() {
+ return useContext(ToastCtx)
+}
diff --git a/admin/src/index.css b/admin/src/index.css
index 5ddc07e..45f1ab1 100644
--- a/admin/src/index.css
+++ b/admin/src/index.css
@@ -1,4 +1,5 @@
@import "tailwindcss";
+@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@@ -71,3 +72,43 @@ body {
background: var(--accent-soft);
color: var(--accent-ink);
}
+
+/* 全局隐藏滚动条(保留滚动功能) */
+* {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+*::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+}
+
+/* Markdown 预览排版 — 对齐设计 token */
+.prose-site {
+ --tw-prose-body: var(--ink-2);
+ --tw-prose-headings: var(--ink);
+ --tw-prose-links: var(--accent-ink);
+ --tw-prose-bold: var(--ink);
+ --tw-prose-counters: var(--ink-3);
+ --tw-prose-bullets: var(--hairline-strong);
+ --tw-prose-hr: var(--hairline);
+ --tw-prose-quotes: var(--ink-2);
+ --tw-prose-quote-borders: var(--accent);
+ --tw-prose-code: var(--accent-ink);
+ --tw-prose-pre-bg: var(--term-bg);
+ --tw-prose-pre-code: var(--term-ink);
+ --tw-prose-th-borders: var(--hairline-strong);
+ --tw-prose-td-borders: var(--hairline);
+ --tw-prose-captions: var(--ink-3);
+}
+.prose-site :where(code):not(:where(pre code))::before,
+.prose-site :where(code):not(:where(pre code))::after {
+ content: none;
+}
+.prose-site :where(code):not(:where(pre code)) {
+ background: var(--code-bg);
+ border-radius: 4px;
+ padding: 2px 6px;
+ font-weight: 500;
+}
diff --git a/admin/src/main.tsx b/admin/src/main.tsx
index 9d0f590..5c628b7 100644
--- a/admin/src/main.tsx
+++ b/admin/src/main.tsx
@@ -2,15 +2,18 @@ import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { BrowserRouter } from 'react-router-dom'
import { ThemeProvider } from '@/components/theme-provider'
+import { ToastProvider } from '@/components/toast'
import App from '@/App'
import '@/index.css'
createRoot(document.getElementById('root')!).render(
-
-
-
+
+
+
+
+
,
)
diff --git a/admin/src/pages/dashboard.tsx b/admin/src/pages/dashboard.tsx
new file mode 100644
index 0000000..c95a663
--- /dev/null
+++ b/admin/src/pages/dashboard.tsx
@@ -0,0 +1,114 @@
+import { useEffect, useState } from 'react'
+import { Link } from 'react-router-dom'
+import { CheckCircle2, FileEdit, FileText, Plus } from 'lucide-react'
+import { fetchStats, type Stats } from '@/api/posts'
+
+export default function DashboardPage() {
+ const [stats, setStats] = useState(null)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ let cancelled = false
+ fetchStats()
+ .then((s) => {
+ if (!cancelled) setStats(s)
+ })
+ .catch((e: Error) => {
+ if (!cancelled) setError(e.message)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [])
+
+ const cards = [
+ { label: '文章总数', value: stats?.total, icon: FileText },
+ { label: '已发布', value: stats?.published, icon: CheckCircle2 },
+ { label: '草稿', value: stats?.draft, icon: FileEdit },
+ ]
+
+ return (
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {cards.map((c) => (
+
+
+ {c.label}
+
+
+
+ {c.value ?? '—'}
+
+
+ ))}
+
+
+
+
+
最近更新
+
+ 全部文章 →
+
+
+
+ {stats && stats.recent.length > 0 ? (
+ stats.recent.map((post) => (
+
+
+ {post.title}
+
+
+ {post.published_at ? (
+
+ 已发布
+
+ ) : (
+
+ 草稿
+
+ )}
+
+ {post.updated_at.slice(0, 10)}
+
+
+
+ ))
+ ) : (
+
+ {stats ? '还没有文章,点右上角新建' : '加载中…'}
+
+ )}
+
+
+
+ )
+}
diff --git a/admin/src/pages/login.tsx b/admin/src/pages/login.tsx
index 9475020..92a6a57 100644
--- a/admin/src/pages/login.tsx
+++ b/admin/src/pages/login.tsx
@@ -1,13 +1,19 @@
import { useState, type FormEvent } from 'react'
import { Navigate, useNavigate } from 'react-router-dom'
+import { Eye, EyeOff, Loader2 } from 'lucide-react'
import { getToken } from '@/api/client'
import { login } from '@/api/auth'
-import { LogoMark } from '@/components/logo'
+import AIBanner from '@/components/login/ai-banner'
+import AICoreEffect from '@/components/login/ai-core-effect'
+import LoginCharacters from '@/components/login/login-characters'
export default function LoginPage() {
const navigate = useNavigate()
+ const [booted, setBooted] = useState(false)
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
+ const [showPwd, setShowPwd] = useState(false)
+ const [pwdFocused, setPwdFocused] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
@@ -15,6 +21,10 @@ export default function LoginPage() {
const onSubmit = async (e: FormEvent) => {
e.preventDefault()
+ if (!username || !password) {
+ setError('请输入账号和密码')
+ return
+ }
setError('')
setLoading(true)
try {
@@ -27,58 +37,150 @@ export default function LoginPage() {
}
}
+ const fieldCls =
+ 'h-12 w-full rounded-xl border border-slate-200 bg-slate-50/50 px-3.5 text-[14px] text-slate-900 outline-none transition-all placeholder:text-slate-400 focus:border-[#22d3ee] focus:bg-white focus:ring-2 focus:ring-cyan-500/20'
+
return (
-
)
}
diff --git a/admin/src/pages/visits.tsx b/admin/src/pages/visits.tsx
new file mode 100644
index 0000000..6af6c7c
--- /dev/null
+++ b/admin/src/pages/visits.tsx
@@ -0,0 +1,117 @@
+import { useEffect, useState } from 'react'
+import { Eye, Users } from 'lucide-react'
+import { fetchVisits, type VisitStats } from '@/api/visits'
+import { cn } from '@/lib/utils'
+
+const RANGES = [7, 30, 90]
+
+export default function VisitsPage() {
+ const [days, setDays] = useState(30)
+ const [data, setData] = useState(null)
+ const [error, setError] = useState('')
+
+ useEffect(() => {
+ let cancelled = false
+ fetchVisits(days)
+ .then((d) => {
+ if (!cancelled) setData(d)
+ })
+ .catch((e: Error) => {
+ if (!cancelled) setError(e.message)
+ })
+ return () => {
+ cancelled = true
+ }
+ }, [days])
+
+ // 补全连续日期,缺失天补 0,趋势图才连贯
+ const map = new Map((data?.daily ?? []).map((r) => [r.date, r]))
+ const series = Array.from({ length: days }, (_, i) => {
+ const d = new Date()
+ d.setDate(d.getDate() - (days - 1 - i))
+ const date = d.toISOString().slice(0, 10)
+ return map.get(date) ?? { date, uv: 0, pv: 0 }
+ })
+ const maxUv = Math.max(1, ...series.map((s) => s.uv))
+
+ const cards = [
+ { label: '今日访客 UV', value: data?.today_uv, icon: Users },
+ { label: '今日访问 PV', value: data?.today_pv, icon: Eye },
+ { label: `近 ${days} 天访客`, value: data?.total_uv, icon: Users },
+ { label: `近 ${days} 天访问`, value: data?.total_pv, icon: Eye },
+ ]
+
+ return (
+
+
+
+
访问统计
+
+ 用户端访问足迹,按 IP 去重
+
+
+
+ {RANGES.map((r) => (
+
+ ))}
+
+
+
+ {error && (
+
+ {error}
+
+ )}
+
+
+ {cards.map((c) => (
+
+
+ {c.label}
+
+
+
+ {c.value ?? '—'}
+
+
+ ))}
+
+
+
+
+
每日访客趋势
+ UV
+
+
+ {series.map((s) => (
+
+ ))}
+
+
+ {series[0]?.date.slice(5)}
+ {series[series.length - 1]?.date.slice(5)}
+
+
+
+ )
+}
diff --git a/server/internal/handler/admin_post.go b/server/internal/handler/admin_post.go
index bdc68ac..77f0cef 100644
--- a/server/internal/handler/admin_post.go
+++ b/server/internal/handler/admin_post.go
@@ -156,3 +156,23 @@ func (h *AdminPostHandler) Delete(c *gin.Context) {
}
resp.OK(c, nil)
}
+
+// Stats GET /api/admin/stats — 概览统计(总数 / 已发布 / 草稿 / 最近文章)
+func (h *AdminPostHandler) Stats(c *gin.Context) {
+ var total, published int64
+ if err := h.db.Model(&model.Post{}).Count(&total).Error; err != nil {
+ resp.ServerError(c, "查询失败")
+ return
+ }
+ h.db.Model(&model.Post{}).Where("published_at IS NOT NULL").Count(&published)
+
+ var recent []model.PostListItem
+ h.db.Model(&model.Post{}).Order("updated_at DESC").Limit(5).Find(&recent)
+
+ resp.OK(c, gin.H{
+ "total": total,
+ "published": published,
+ "draft": total - published,
+ "recent": recent,
+ })
+}
diff --git a/server/internal/handler/visit.go b/server/internal/handler/visit.go
new file mode 100644
index 0000000..5932198
--- /dev/null
+++ b/server/internal/handler/visit.go
@@ -0,0 +1,89 @@
+package handler
+
+import (
+ "strconv"
+ "time"
+
+ "github.com/gin-gonic/gin"
+ "gorm.io/gorm"
+ "gorm.io/gorm/clause"
+
+ "git.sundynix.cn/Blizzard/sundynix-site/server/internal/model"
+ "git.sundynix.cn/Blizzard/sundynix-site/server/internal/pkg/resp"
+)
+
+// VisitHandler 访问足迹:用户端埋点 + 管理端统计。
+type VisitHandler struct {
+ db *gorm.DB
+}
+
+func NewVisitHandler(db *gorm.DB) *VisitHandler {
+ return &VisitHandler{db: db}
+}
+
+// Track POST /api/track — 用户端埋点上报(免登录)。
+// 按 (今天, ClientIP) upsert:已存在则 pv+1,否则插入 pv=1。
+func (h *VisitHandler) Track(c *gin.Context) {
+ ip := c.ClientIP()
+ if ip == "" {
+ ip = "unknown"
+ }
+ visit := model.Visit{
+ Date: time.Now().Format("2006-01-02"),
+ IP: ip,
+ PV: 1,
+ }
+ h.db.Clauses(clause.OnConflict{
+ Columns: []clause.Column{{Name: "date"}, {Name: "ip"}},
+ DoUpdates: clause.Assignments(map[string]any{
+ "pv": gorm.Expr("pv + 1"),
+ "updated_at": time.Now(),
+ }),
+ }).Create(&visit)
+ resp.OK(c, nil)
+}
+
+type dailyRow struct {
+ Date string `json:"date"`
+ UV int64 `json:"uv"`
+ PV int64 `json:"pv"`
+}
+
+// Stats GET /api/admin/visits?days=30 — 近 N 天每日 UV/PV。
+func (h *VisitHandler) Stats(c *gin.Context) {
+ days := 30
+ if v := c.Query("days"); v != "" {
+ if n, err := strconv.Atoi(v); err == nil && n > 0 && n <= 365 {
+ days = n
+ }
+ }
+ since := time.Now().AddDate(0, 0, -(days - 1)).Format("2006-01-02")
+ today := time.Now().Format("2006-01-02")
+
+ var rows []dailyRow
+ h.db.Model(&model.Visit{}).
+ Select("date, count(*) as uv, coalesce(sum(pv), 0) as pv").
+ Where("date >= ?", since).
+ Group("date").
+ Order("date").
+ Scan(&rows)
+
+ var totalUV, totalPV, todayUV, todayPV int64
+ for _, r := range rows {
+ totalUV += r.UV
+ totalPV += r.PV
+ if r.Date == today {
+ todayUV = r.UV
+ todayPV = r.PV
+ }
+ }
+
+ resp.OK(c, gin.H{
+ "daily": rows,
+ "total_uv": totalUV,
+ "total_pv": totalPV,
+ "today_uv": todayUV,
+ "today_pv": todayPV,
+ "days": days,
+ })
+}
diff --git a/server/internal/model/visit.go b/server/internal/model/visit.go
new file mode 100644
index 0000000..da8eddf
--- /dev/null
+++ b/server/internal/model/visit.go
@@ -0,0 +1,10 @@
+package model
+
+// Visit 用户端访问记录,按 (date, ip) 去重:每行代表某 IP 某天来过(UV);
+// pv 记该 IP 当天访问次数。表名 sundynix_visit。
+type Visit struct {
+ BaseModel
+ Date string `gorm:"size:10;uniqueIndex:uk_date_ip" json:"date"`
+ IP string `gorm:"size:64;uniqueIndex:uk_date_ip" json:"ip"`
+ PV int64 `json:"pv"`
+}
diff --git a/server/internal/router/router.go b/server/internal/router/router.go
index e39d4ac..098376e 100644
--- a/server/internal/router/router.go
+++ b/server/internal/router/router.go
@@ -30,6 +30,8 @@ func New(db *gorm.DB) (*gin.Engine, error) {
api.GET("/posts/:slug", posts.Get)
api.GET("/releases/latest", handler.NewReleaseHandler().Latest)
+
+ api.POST("/track", handler.NewVisitHandler(db).Track)
}
// RSS 订阅源(页脚 RSS 链接指向这里)
@@ -42,6 +44,8 @@ func New(db *gorm.DB) (*gin.Engine, error) {
adminAPI := api.Group("/admin", middleware.Auth())
{
posts := handler.NewAdminPostHandler(db)
+ adminAPI.GET("/stats", posts.Stats)
+ adminAPI.GET("/visits", handler.NewVisitHandler(db).Stats)
adminAPI.GET("/posts", posts.List)
adminAPI.POST("/posts", posts.Create)
adminAPI.GET("/posts/:id", posts.Get)
diff --git a/server/internal/store/db.go b/server/internal/store/db.go
index fac58df..81e04ca 100644
--- a/server/internal/store/db.go
+++ b/server/internal/store/db.go
@@ -34,7 +34,7 @@ func Open(dsn string) (*gorm.DB, error) {
if err != nil {
return nil, err
}
- if err := db.AutoMigrate(&model.Post{}); err != nil {
+ if err := db.AutoMigrate(&model.Post{}, &model.Visit{}); err != nil {
return nil, err
}
if err := seed(db); err != nil {
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 1216b6a..70159b3 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -1,3 +1,4 @@
+import { useEffect } from 'react'
import { Route, Routes } from 'react-router-dom'
import { SiteLayout } from '@/components/layout/site-layout'
import HomePage from '@/pages/home'
@@ -7,6 +8,13 @@ import DownloadPage from '@/pages/download'
import NotFoundPage from '@/pages/not-found'
export default function App() {
+ // 访问埋点:每个会话上报一次(按 IP 在后端去重)
+ useEffect(() => {
+ if (sessionStorage.getItem('sundynix-tracked')) return
+ sessionStorage.setItem('sundynix-tracked', '1')
+ fetch('/api/track', { method: 'POST' }).catch(() => {})
+ }, [])
+
return (
}>
diff --git a/web/src/index.css b/web/src/index.css
index a4b5caa..5728aef 100644
--- a/web/src/index.css
+++ b/web/src/index.css
@@ -82,6 +82,17 @@ body {
color: var(--accent-ink);
}
+/* 全局隐藏滚动条(保留滚动功能) */
+* {
+ scrollbar-width: none;
+ -ms-overflow-style: none;
+}
+*::-webkit-scrollbar {
+ width: 0;
+ height: 0;
+ display: none;
+}
+
/* 品牌渐变文字(logo 同源:青→蓝→紫),克制使用 */
.text-brand-gradient {
background: linear-gradient(