diff --git a/client/src/components/StarBackground.vue b/client/src/components/StarBackground.vue index 6d58c4b..fbe9901 100644 --- a/client/src/components/StarBackground.vue +++ b/client/src/components/StarBackground.vue @@ -7,21 +7,31 @@ import { onMounted, onUnmounted, ref } from 'vue' const canvasRef = ref(null) -// 配置参数 +// --- 配置参数 --- const config = { - starCount: 150, // 星星数量 - meteorChance: 0.005, // 流星出现概率 (每帧) - clickWaveColor: 'rgba(100, 200, 255, 0.4)', // 点击波纹颜色 + starCount: 600, // 大幅增加星星数量 + rotationSpeed: 0.0002, // 减慢旋转速度,更具呼吸感 + clickMeteorChance: 1.0 // 点击触发流星概率 } // 类型定义 interface Star { x: number y: number - radius: number - alpha: number - fadingOut: boolean - speed: number + + // 轨道参数 + orbitRadius: number + angle: number + angularSpeed: number + + // 属性参数 + radius: number // 显示半径 + + // 闪烁参数 + alpha: number // 当前透明度 + baseAlpha: number // 基础透明度 + twinkleSpeed: number + twinklePhase: number } interface Meteor { @@ -30,6 +40,8 @@ interface Meteor { length: number speed: number angle: number + alpha: number + width: number } interface Wave { @@ -37,6 +49,8 @@ interface Wave { y: number radius: number alpha: number + lineWidth: number + hue: number } interface Particle { @@ -46,213 +60,257 @@ interface Particle { vy: number life: number color: string + size: number } +// 状态 let ctx: CanvasRenderingContext2D | null = null let animationFrameId: number let width = 0 let height = 0 +let centerX = 0 +let centerY = 0 -// 状态存储 +// 对象池 const stars: Star[] = [] const meteors: Meteor[] = [] const waves: Wave[] = [] const particles: Particle[] = [] -// 初始化星星 +// 初始化星星 (星系分布 + 大小差异化) const initStars = () => { stars.length = 0 + const maxRadius = Math.sqrt(width * width + height * height) * 0.8 + for (let i = 0; i < config.starCount; i++) { - stars.push({ - x: Math.random() * width, - y: Math.random() * height, - radius: Math.random() * 1.5, - alpha: Math.random(), - fadingOut: Math.random() > 0.5, - speed: Math.random() * 0.02 + 0.005 - }) - } -} + // 随机轨道半径 + const orbitRadius = Math.random() * maxRadius + const angle = Math.random() * Math.PI * 2 -// 绘制并更新星星 -const drawStars = () => { - if (!ctx) return + // 模拟不同大小的星星分布 + // 80% 是微小的背景星,15% 是中等星星,5% 是亮眼的大星 + const sizeRandom = Math.random() + let radius, baseAlpha - ctx.fillStyle = '#FFF' - stars.forEach(star => { - // 闪烁逻辑 - if (star.fadingOut) { - star.alpha -= star.speed - if (star.alpha < 0) { - star.alpha = 0 - star.fadingOut = false - } + if (sizeRandom > 0.95) { + // 大星 (Hero Stars) + radius = Math.random() * 1.5 + 1.2 + baseAlpha = Math.random() * 0.4 + 0.6 // 比较亮 + } else if (sizeRandom > 0.8) { + // 中星 + radius = Math.random() * 0.8 + 0.6 + baseAlpha = Math.random() * 0.3 + 0.3 } else { - star.alpha += star.speed - if (star.alpha > 1) { - star.alpha = 1 - star.fadingOut = true - } + // 微星 (Background Dust) + radius = Math.random() * 0.4 + 0.2 + baseAlpha = Math.random() * 0.3 + 0.1 } - if (ctx) { - ctx.globalAlpha = star.alpha - ctx.beginPath() - ctx.arc(star.x, star.y, star.radius, 0, Math.PI * 2) - ctx.fill() - } - }) - if (ctx) { - ctx.globalAlpha = 1 - } -} + // 旋转速度微调:外圈通常比内圈慢(或快,取决于想要的视觉效果),这里给一点随机性 + const speedVariation = (Math.random() - 0.5) * 0.0001 + const angularSpeed = config.rotationSpeed + speedVariation -// 生成流星 -const spawnMeteor = () => { - if (Math.random() < config.meteorChance) { - const startX = Math.random() * width - // 稍微倾斜的角度 - meteors.push({ - x: startX, - y: 0, - length: Math.random() * 80 + 20, - speed: Math.random() * 10 + 5, - angle: Math.PI / 4 // 45度角 + stars.push({ + x: 0, y: 0, + orbitRadius, + angle, + angularSpeed, + radius, + alpha: baseAlpha, + baseAlpha, + twinkleSpeed: Math.random() * 0.02 + 0.005, + twinklePhase: Math.random() * Math.PI * 2 }) } } -// 绘制并更新流星 -const drawMeteors = () => { - if (!ctx) return +// 渲染循环 +const render = () => { + if (!ctx || !canvasRef.value) return - ctx.strokeStyle = 'rgba(255, 255, 255, 0.8)' - ctx.lineWidth = 1.5 + // 1. 清空画布 + ctx.clearRect(0, 0, width, height) + + // 2. 绘制星星 + stars.forEach(star => { + // A. 更新角度 (纯自转,不再跟随鼠标) + star.angle += star.angularSpeed + + // B. 计算位置 + star.x = centerX + Math.cos(star.angle) * star.orbitRadius + star.y = centerY + Math.sin(star.angle) * star.orbitRadius + + // C. 闪烁计算 (呼吸感) + star.twinklePhase += star.twinkleSpeed + const twinkle = Math.sin(star.twinklePhase) * 0.2 + star.alpha = star.baseAlpha + twinkle + + // 限制 alpha 范围 + if (star.alpha < 0) star.alpha = 0 + if (star.alpha > 1) star.alpha = 1 + + // 绘制 + ctx!.beginPath() + ctx!.arc(star.x, star.y, star.radius, 0, Math.PI * 2) + + // 只给大星星加一点辉光,增强设计感 + if (star.radius > 1.2) { + ctx!.shadowBlur = 6 + ctx!.shadowColor = `rgba(255, 255, 255, ${star.alpha * 0.5})` + } else { + ctx!.shadowBlur = 0 + } + + ctx!.fillStyle = `rgba(255, 255, 255, ${star.alpha})` + ctx!.fill() + }) + + ctx.shadowBlur = 0 // 重置辉光 + + // 3. 绘制流星 + updateAndDrawMeteors() + + // 4. 绘制波纹 (Shockwave) + updateAndDrawWaves() + + // 5. 绘制粒子 (Sparks) + updateAndDrawParticles() + + animationFrameId = requestAnimationFrame(render) +} + +// --- 辅助绘制函数 --- + +const updateAndDrawMeteors = () => { + if (Math.random() < 0.002) createMeteor() // 稍微降低自然流星频率,减少干扰 for (let i = meteors.length - 1; i >= 0; i--) { const m = meteors[i] + if (!ctx) break - // 计算尾巴终点 const endX = m.x - m.length * Math.cos(m.angle) const endY = m.y - m.length * Math.sin(m.angle) - // 绘制流星 const gradient = ctx.createLinearGradient(m.x, m.y, endX, endY) - gradient.addColorStop(0, 'rgba(255,255,255,1)') - gradient.addColorStop(1, 'rgba(255,255,255,0)') - ctx.strokeStyle = gradient + gradient.addColorStop(0, `rgba(255, 255, 255, ${m.alpha})`) + gradient.addColorStop(0.2, `rgba(200, 230, 255, ${m.alpha * 0.8})`) + gradient.addColorStop(1, 'rgba(255, 255, 255, 0)') ctx.beginPath() ctx.moveTo(m.x, m.y) ctx.lineTo(endX, endY) + ctx.lineWidth = m.width + ctx.strokeStyle = gradient + ctx.lineCap = 'round' ctx.stroke() - // 移动 m.x += m.speed * Math.cos(m.angle) m.y += m.speed * Math.sin(m.angle) - // 移除出屏幕的流星 - if (m.x > width || m.y > height) { - meteors.splice(i, 1) + if (m.x > width + 100 || m.x < -100 || m.y > height + 100 || m.y < -100) { + m.alpha -= 0.02 + if (m.alpha <= 0) meteors.splice(i, 1) } } } -// 绘制并更新波纹(点击效果) -const drawWaves = () => { - if (!ctx) return - +const updateAndDrawWaves = () => { for (let i = waves.length - 1; i >= 0; i--) { const w = waves[i] + if (!ctx) break + ctx.beginPath() ctx.arc(w.x, w.y, w.radius, 0, Math.PI * 2) - ctx.strokeStyle = config.clickWaveColor - ctx.globalAlpha = w.alpha - ctx.lineWidth = 2 + ctx.strokeStyle = `hsla(${w.hue}, 80%, 70%, ${w.alpha})` + ctx.lineWidth = w.lineWidth ctx.stroke() - ctx.globalAlpha = 1 - w.radius += 2 - w.alpha -= 0.02 + w.radius += 2.5 + w.alpha *= 0.95 + w.lineWidth *= 0.95 - if (w.alpha <= 0) { - waves.splice(i, 1) - } + if (w.alpha < 0.01) waves.splice(i, 1) } } -// 绘制并更新粒子(点击爆炸效果) -const drawParticles = () => { - if (!ctx) return - +const updateAndDrawParticles = () => { for (let i = particles.length - 1; i >= 0; i--) { const p = particles[i] + if (!ctx) break + ctx.fillStyle = p.color ctx.beginPath() - ctx.arc(p.x, p.y, Math.random() * 2, 0, Math.PI * 2) + ctx.arc(p.x, p.y, p.size, 0, Math.PI * 2) ctx.fill() p.x += p.vx p.y += p.vy - p.life -= 0.02 + p.vy += 0.08 + p.vx *= 0.96 + p.vy *= 0.96 - if (p.life <= 0) { - particles.splice(i, 1) - } + p.life -= 0.02 + p.size *= 0.95 + + if (p.life <= 0) particles.splice(i, 1) } } -// 动画主循环 -const animate = () => { - if (!ctx || !canvasRef.value) return - - // 清空画布 (使用带有透明度的黑色可以制造拖尾效果,这里我们直接清空保持清晰) - ctx.clearRect(0, 0, width, height) - - // 绘制层级 - drawStars() - spawnMeteor() - drawMeteors() - drawWaves() - drawParticles() - - animationFrameId = requestAnimationFrame(animate) +const createMeteor = (force = false) => { + const startX = Math.random() * width + const angle = Math.PI / 4 + (Math.random() - 0.5) * 0.3 + meteors.push({ + x: startX, + y: -50, + length: Math.random() * 100 + 80, + speed: Math.random() * 15 + 12, + angle: angle, + alpha: 1, + width: Math.random() * 2 + 0.5 + }) } -// 点击事件处理 +// 交互处理 const handleClick = (e: MouseEvent) => { - // 添加扩散波纹 + const hue = Math.floor(Math.random() * 360) + waves.push({ x: e.clientX, y: e.clientY, - radius: 0, - alpha: 1 + radius: 10, + alpha: 1, + lineWidth: 4, + hue: hue }) - // 添加爆炸粒子 - const particleCount = 8 + const particleCount = 16 for (let i = 0; i < particleCount; i++) { - const angle = (Math.PI * 2 / particleCount) * i - const speed = Math.random() * 3 + 1 + const angle = (Math.PI * 2 / particleCount) * i + Math.random() * 0.5 + const speed = Math.random() * 5 + 2 particles.push({ x: e.clientX, y: e.clientY, vx: Math.cos(angle) * speed, vy: Math.sin(angle) * speed, life: 1.0, - color: `rgba(200, 230, 255, ${Math.random()})` + color: `hsla(${hue}, ${80 + Math.random() * 20}%, 70%, 1)`, + size: Math.random() * 3 + 1 }) } + + createMeteor(true) } -// 窗口大小调整 const handleResize = () => { if (canvasRef.value) { width = window.innerWidth height = window.innerHeight canvasRef.value.width = width canvasRef.value.height = height - // 重新分布星星,或者只在必要时调整 + + centerX = width / 2 + centerY = height / 2 + initStars() } } @@ -262,13 +320,11 @@ onMounted(() => { ctx = canvasRef.value.getContext('2d') handleResize() - // 初始化 - initStars() - animate() - - // 监听全局点击和resize window.addEventListener('resize', handleResize) + // Removed mousemove listener for pure atmospheric effect window.addEventListener('click', handleClick) + + render() } }) @@ -280,7 +336,6 @@ onUnmounted(() => { diff --git a/client/src/pages/admin/Logs.vue b/client/src/pages/admin/Logs.vue index 621d22a..38a5468 100644 --- a/client/src/pages/admin/Logs.vue +++ b/client/src/pages/admin/Logs.vue @@ -22,6 +22,7 @@ ID 操作人 + 操作描述 IP地址 归属地 路径 @@ -35,6 +36,7 @@ {{ log.id }} {{ log.username }} + {{ log.action || `${log.method} ${log.path}` }} {{ log.ip }} {{ log.region || 'Unknown' }} {{ log.path }} @@ -238,6 +240,15 @@ onMounted(() => { white-space: nowrap; } +.action-description { + color: #d4b383; + font-weight: 500; + max-width: 180px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + .method-badge { display: inline-block; padding: 0.125rem 0.375rem; diff --git a/client/src/pages/admin/PostForm.vue b/client/src/pages/admin/PostForm.vue index d585063..029f10a 100644 --- a/client/src/pages/admin/PostForm.vue +++ b/client/src/pages/admin/PostForm.vue @@ -17,6 +17,15 @@
+ @@ -159,6 +168,14 @@
+ + + @@ -172,6 +189,7 @@ import 'md-editor-v3/lib/style.css' import { useToast } from '../../composables/useToast' import { createPost, updatePost, fetchPost, fetchCategories, fetchColumns, fetchTags, Tag } from '../../services/api' import CustomSelect from '../../components/CustomSelect.vue' +import AccessLogModal from '../../components/admin/AccessLogModal.vue' const router = useRouter() const route = useRoute() @@ -188,6 +206,12 @@ const isSubmitting = ref(false) const isEditing = computed(() => !!route.params.id) const errors = reactive>({}) +// Access log modal state +const isAccessLogModalOpen = ref(false) +const openAccessLogModal = () => { + isAccessLogModalOpen.value = true +} + // Data sources const categories = ref<{value: number, label: string}[]>([]) const columns = ref<{value: number, label: string}[]>([]) diff --git a/client/src/pages/admin/dashboard/Analytics.vue b/client/src/pages/admin/dashboard/Analytics.vue index 4ae25f2..d09e798 100644 --- a/client/src/pages/admin/dashboard/Analytics.vue +++ b/client/src/pages/admin/dashboard/Analytics.vue @@ -307,33 +307,50 @@ const allProvinces = [ '云南', '西藏', '陕西', '甘肃', '青海', '宁夏', '新疆', '台湾', '香港', '澳门' ] +// 所有省份完整名称列表(用于匹配地图数据) +const allProvincesFullNames = [ + '北京市', '天津市', '河北省', '山西省', '内蒙古自治区', '辽宁省', '吉林省', '黑龙江省', + '上海市', '江苏省', '浙江省', '安徽省', '福建省', '江西省', '山东省', '河南省', + '湖北省', '湖南省', '广东省', '广西壮族自治区', '海南省', '重庆市', '四川省', '贵州省', + '云南省', '西藏自治区', '陕西省', '甘肃省', '青海省', '宁夏回族自治区', '新疆维吾尔自治区', '台湾省', '香港特别行政区', '澳门特别行政区' +] + // 将数据转换为地图需要的格式,没有值的省份默认为0 const getMapData = () => { // 创建省份数据映射 const dataMap = new Map() // 如果有数据,先填充实际数据 + // 后端现在直接返回完整的省份名称(如"浙江省"、"北京市"等) if (stats.value.userRegions && stats.value.userRegions.length > 0) { stats.value.userRegions.forEach((item: any) => { - let provinceName = item.Region - // 标准化省份名称(移除"省"、"市"、"自治区"等后缀) - provinceName = provinceName.replace(/省|市|自治区|特别行政区|壮族自治区|维吾尔自治区|回族自治区/g, '') + // 后端返回的字段名是 Region 和 Count(首字母大写) + const provinceName = item.Region || item.region + const count = Number(item.Count || item.count || 0) - // 特殊处理 - if (provinceName === '内蒙古') provinceName = '内蒙古' - if (provinceName === '广西') provinceName = '广西' - if (provinceName === '西藏') provinceName = '西藏' - if (provinceName === '宁夏') provinceName = '宁夏' - if (provinceName === '新疆') provinceName = '新疆' + if (!provinceName) { + console.warn('Missing province name in item:', item) + return + } - dataMap.set(provinceName, item.Count || 0) + // 直接使用后端返回的完整省份名称,不需要任何处理 + dataMap.set(provinceName, count) + console.log(`Set province data: ${provinceName} = ${count}`) }) } // 为所有省份生成数据,没有值的默认为0 - return allProvinces.map(province => { - return [province, dataMap.get(province) || 0] + // ECharts map 类型需要对象数组格式: [{name: '浙江省', value: 1}] + const result = allProvincesFullNames.map(province => { + const count = dataMap.get(province) ?? 0 + return { + name: province, + value: count + } }) + + console.log('Map data result:', result) + return result } const regionOption = computed(() => { @@ -352,7 +369,15 @@ const regionOption = computed(() => { } const mapData = getMapData() - const maxValue = mapData.length > 0 ? Math.max(...mapData.map((d: any) => d[1] || 0)) : 1 + // 计算最大值,支持对象格式 {name, value} 和数组格式 [name, value] + const maxValue = mapData.length > 0 ? Math.max(...mapData.map((d: any) => { + if (typeof d === 'object' && 'value' in d) { + return d.value || 0 + } else if (Array.isArray(d)) { + return d[1] || 0 + } + return 0 + })) : 1 return { tooltip: { diff --git a/client/src/services/api.ts b/client/src/services/api.ts index 954bc4e..9d037cd 100644 --- a/client/src/services/api.ts +++ b/client/src/services/api.ts @@ -147,6 +147,7 @@ export interface OperationLog { region?: string path: string method: string + action?: string params: string status: number duration: number @@ -1157,8 +1158,18 @@ export const getAccessLogs = async (page: number = 1, pageSize: number = 10): Pr } } +// 文章访问记录类型(来自 user_access_logs 表) +export interface PostAccessLog { + id: number + userId: number + ip: string + region: string + articleId: number + createdAt: string +} + // 获取指定文章的访问记录 -export const getPostAccessLogs = async (postId: number, page: number = 1, pageSize: number = 20): Promise> => { +export const getPostAccessLogs = async (postId: number, page: number = 1, pageSize: number = 20): Promise> => { try { const response = await fetch(`${API_BASE}/admin/posts/${postId}/access-logs?page=${page}&pageSize=${pageSize}`, { headers: getAuthHeaders() diff --git a/server/handlers/dashboard.go b/server/handlers/dashboard.go index d6f78d8..5541a52 100644 --- a/server/handlers/dashboard.go +++ b/server/handlers/dashboard.go @@ -40,8 +40,8 @@ func GetDashboardStats(c *gin.Context) { uvTrend = []repositories.UVTrendData{} } - // 3. 用户画像-地域分布 - userRegions, err := repositories.GetUserRegions(startDate, endDate) + // 3. 用户画像-地域分布(使用 user_access_logs 表) + userRegions, err := repositories.GetUserRegionsFromUserAccessLogs(startDate, endDate) if err != nil { // 即使出错,也返回所有34个省份的默认数据(Count为0) userRegions = repositories.GetDefaultUserRegions() diff --git a/server/handlers/log.go b/server/handlers/log.go index 2ea5741..b6e8539 100644 --- a/server/handlers/log.go +++ b/server/handlers/log.go @@ -208,8 +208,8 @@ func AdminGetPostAccessLogs(c *gin.Context) { } } - // 获取文章的访问记录 - logs, total, err := repositories.GetPostAccessLogs(postID, page, pageSize) + // 获取文章的访问记录(从 user_access_logs 表) + logs, total, err := repositories.GetPostAccessLogsFromUserAccessLogs(postID, page, pageSize) if err != nil { utils.ServerError(c, err) return @@ -219,15 +219,12 @@ func AdminGetPostAccessLogs(c *gin.Context) { var logList []gin.H for _, log := range logs { logList = append(logList, gin.H{ - "id": log.ID, - "ip": log.IP, - "userAgent": log.UserAgent, - "path": log.Path, - "method": log.Method, - "statusCode": log.StatusCode, - "responseTime": log.ResponseTime, - "region": log.Region, - "createdAt": time.Unix(log.CreatedAt, 0).Format("2006-01-02 15:04:05"), + "id": log.ID, + "userId": log.UserID, + "ip": log.UserIP, + "region": log.UserLocation, + "articleId": log.ArticleID, + "createdAt": time.Unix(log.AccessTime, 0).Format("2006-01-02 15:04:05"), }) } diff --git a/server/models/operation_log.go b/server/models/operation_log.go index 19fb6f3..ea62b22 100644 --- a/server/models/operation_log.go +++ b/server/models/operation_log.go @@ -47,6 +47,7 @@ type OperationLogResponse struct { Region string `json:"region"` // IP归属地 Path string `json:"path"` Method string `json:"method"` + Action string `json:"action"` // 操作描述 Params string `json:"params"` Status int `json:"status"` Duration int `json:"duration"` diff --git a/server/repositories/column_repository.go b/server/repositories/column_repository.go index 4bbd8b0..3c59703 100644 --- a/server/repositories/column_repository.go +++ b/server/repositories/column_repository.go @@ -150,9 +150,9 @@ func DeleteColumn(id uint) error { func GetPostsByColumnID(columnID uint) ([]models.Post, error) { var posts []models.Post err := config.DB.Model(&models.Post{}). - Select("posts.*, categories.name as category_name"). + Preload("Category"). + Preload("Tags"). Joins("JOIN column_posts cp ON posts.id = cp.post_id"). - Joins("LEFT JOIN categories ON posts.category_id = categories.id"). Where("cp.column_id = ? AND posts.deleted_at = ? AND posts.is_published = ?", columnID, 0, 1). Order("cp.sort_order ASC, posts.created_at DESC"). Find(&posts).Error diff --git a/server/repositories/log_repository.go b/server/repositories/log_repository.go index a4e0b48..e018215 100644 --- a/server/repositories/log_repository.go +++ b/server/repositories/log_repository.go @@ -135,9 +135,37 @@ func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64, // 如果指定了文章ID,筛选该文章的访问记录 if postID != nil { - // 路径格式可能是 /api/posts/{id} 或 /blog/{id} 等 - // 使用 LIKE 匹配包含文章ID的路径 - query = query.Where("path LIKE ?", fmt.Sprintf("%%/posts/%d%%", *postID)) + // 路径格式可能是: + // - /api/posts/{id} (API调用) + // - /blog/{id} (前端页面访问) + // - /posts/{id} (其他可能的格式) + // 使用精确的匹配,避免匹配到其他ID(如123匹配到1234) + // 使用LIKE模式,确保ID后面是 / 或 ? 或 字符串结束 + postIDStr := fmt.Sprintf("%d", *postID) + // 匹配精确的路径格式,避免部分匹配 + // 例如:/api/posts/123 或 /api/posts/123/ 或 /api/posts/123?xxx + // 但不匹配 /api/posts/1234 + // 使用精确的 LIKE 匹配,确保只匹配正确的文章ID + // 匹配格式:/api/posts/{id}、/api/posts/{id}/、/api/posts/{id}?xxx + // 匹配格式:/blog/{id}、/blog/{id}/、/blog/{id}?xxx + // 匹配格式:/posts/{id}、/posts/{id}/、/posts/{id}?xxx + query = query.Where( + "(path = ? OR path LIKE ? OR path LIKE ? OR "+ + "path = ? OR path LIKE ? OR path LIKE ? OR "+ + "path = ? OR path LIKE ? OR path LIKE ?)", + // /api/posts/{id} 格式(3个参数) + fmt.Sprintf("/api/posts/%s", postIDStr), + fmt.Sprintf("/api/posts/%s/%%", postIDStr), + fmt.Sprintf("/api/posts/%s?%%", postIDStr), + // /blog/{id} 格式(3个参数) + fmt.Sprintf("/blog/%s", postIDStr), + fmt.Sprintf("/blog/%s/%%", postIDStr), + fmt.Sprintf("/blog/%s?%%", postIDStr), + // /posts/{id} 格式(3个参数) + fmt.Sprintf("/posts/%s", postIDStr), + fmt.Sprintf("/posts/%s/%%", postIDStr), + fmt.Sprintf("/posts/%s?%%", postIDStr), + ) } var total int64 @@ -155,8 +183,9 @@ func GetAccessLogs(page, pageSize int, postID *int) ([]models.AccessLog, int64, return logs, total, err } -// GetPostAccessLogs 获取指定文章的访问记录 -func GetPostAccessLogs(postID int, page, pageSize int) ([]models.AccessLog, int64, error) { +// GetPostAccessLogsFromAccessLogs 从 access_logs 表获取指定文章的访问记录(已废弃,应使用 user_access_logs) +// 保留此函数以保持向后兼容,但建议使用 user_access_log_repository.GetPostAccessLogs +func GetPostAccessLogsFromAccessLogs(postID int, page, pageSize int) ([]models.AccessLog, int64, error) { return GetAccessLogs(page, pageSize, &postID) } @@ -192,9 +221,11 @@ var allProvinces = []string{ "云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆", "台湾", "香港", "澳门", } -// GetUserRegions 获取用户地域分布(使用 access_logs 表的 region 字段) +// GetUserRegionsFromAccessLogs 获取用户地域分布(使用 access_logs 表的 region 字段) // 返回所有34个省份的数据,没有数据的省份 Count 设为 0 -func GetUserRegions(startDate, endDate string) ([]struct { +// 注意:此函数查询的是所有访问记录,包括静态资源、API调用等 +// 建议使用 GetUserRegionsFromUserAccessLogs 获取更准确的用户访问统计 +func GetUserRegionsFromAccessLogs(startDate, endDate string) ([]struct { Region string Count int }, error) { diff --git a/server/repositories/operation_log_repository.go b/server/repositories/operation_log_repository.go index 6eedeba..ed25116 100644 --- a/server/repositories/operation_log_repository.go +++ b/server/repositories/operation_log_repository.go @@ -6,6 +6,7 @@ import ( "github.com/niangaodev/art-code/config" "github.com/niangaodev/art-code/models" + "github.com/niangaodev/art-code/utils" ) // CreateOperationLog 创建操作日志 @@ -51,6 +52,9 @@ func GetOperationLogs(page, pageSize int) ([]models.OperationLog, int64, error) // BuildOperationLogResponse 构建操作日志响应 func BuildOperationLogResponse(log *models.OperationLog) *models.OperationLogResponse { + // 生成操作描述 + action := utils.GetOperationAction(log.Path, log.Method) + return &models.OperationLogResponse{ ID: log.ID, UserID: log.UserID, @@ -59,6 +63,7 @@ func BuildOperationLogResponse(log *models.OperationLog) *models.OperationLogRes Region: log.Region, Path: log.Path, Method: log.Method, + Action: action, Params: log.Params, Status: log.Status, Duration: log.Duration, diff --git a/server/repositories/user_access_log_repository.go b/server/repositories/user_access_log_repository.go index 449b026..e5da6fc 100644 --- a/server/repositories/user_access_log_repository.go +++ b/server/repositories/user_access_log_repository.go @@ -2,6 +2,8 @@ package repositories import ( "log" + "regexp" + "strings" "time" "github.com/niangaodev/art-code/config" @@ -70,3 +72,140 @@ func GetTopArticlesByAccess(limit int) ([]struct { return results, err } + +// GetPostAccessLogsFromUserAccessLogs 获取指定文章的访问记录(从 user_access_logs 表) +func GetPostAccessLogsFromUserAccessLogs(postID int, page, pageSize int) ([]models.UserAccessLog, int64, error) { + query := config.DB.Model(&models.UserAccessLog{}). + Where("deleted_at = ?", 0). + Where("article_id = ?", postID) + + var total int64 + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + var logs []models.UserAccessLog + offset := (page - 1) * pageSize + err := query.Order("access_time DESC"). + Limit(pageSize). + Offset(offset). + Find(&logs).Error + + return logs, total, err +} + +// GetUserRegionsFromUserAccessLogs 获取用户地域分布(使用 user_access_logs 表的 user_location 字段) +// 返回所有34个省份的数据,没有数据的省份 Count 设为 0 +func GetUserRegionsFromUserAccessLogs(startDate, endDate string) ([]struct { + Region string + Count int +}, error) { + query := config.DB.Model(&models.UserAccessLog{}). + Select("COALESCE(NULLIF(user_location, ''), 'Unknown') as region, COUNT(DISTINCT user_ip) as count"). + Where("deleted_at = ?", 0) + + if startDate != "" { + startUnix := parseDateToUnix(startDate, false) + query = query.Where("access_time >= ?", startUnix) + } + + if endDate != "" { + endUnix := parseDateToUnix(endDate, true) + query = query.Where("access_time <= ?", endUnix) + } + + var results []struct { + Region string + Count int + } + err := query.Group("region"). + Order("count DESC"). + Limit(50). + Scan(&results).Error + + if err != nil { + return nil, err + } + + // 解析归属地,提取省份信息并聚合 + // 注意:现在返回完整的省份名称(如"浙江省"、"北京市"等),不进行任何处理 + provinceMap := make(map[string]int) + for _, r := range results { + province := extractProvinceFromRegion(r.Region) + if province != "未知" { + provinceMap[province] += r.Count + log.Printf("Extracted province: %s from region: %s, count: %d", province, r.Region, r.Count) + } else { + log.Printf("Failed to extract province from region: %s", r.Region) + } + } + + // 定义完整的省份名称列表(包含"省"、"市"、"自治区"等后缀) + allProvincesFullNames := []string{ + "北京市", "天津市", "河北省", "山西省", "内蒙古自治区", "辽宁省", "吉林省", "黑龙江省", + "上海市", "江苏省", "浙江省", "安徽省", "福建省", "江西省", "山东省", "河南省", + "湖北省", "湖南省", "广东省", "广西壮族自治区", "海南省", "重庆市", "四川省", "贵州省", + "云南省", "西藏自治区", "陕西省", "甘肃省", "青海省", "宁夏回族自治区", "新疆维吾尔自治区", "台湾省", "香港特别行政区", "澳门特别行政区", + } + + // 为所有省份生成数据,没有数据的设为0 + var finalResults []struct { + Region string + Count int + } + for _, province := range allProvincesFullNames { + count := provinceMap[province] + finalResults = append(finalResults, struct { + Region string + Count int + }{ + Region: province, + Count: count, + }) + } + + return finalResults, nil +} + +// 所有省份列表(34个)- 用于用户访问日志统计 +var allProvincesForUserAccess = []string{ + "北京", "天津", "河北", "山西", "内蒙古", "辽宁", "吉林", "黑龙江", + "上海", "江苏", "浙江", "安徽", "福建", "江西", "山东", "河南", + "湖北", "湖南", "广东", "广西", "海南", "重庆", "四川", "贵州", + "云南", "西藏", "陕西", "甘肃", "青海", "宁夏", "新疆", "台湾", "香港", "澳门", +} + +// extractProvinceFromRegion 从归属地字符串中提取省份信息 +// 格式: 国家|省份|城市|ISP +// 例如: 中国|浙江省|杭州市|电信 -> 浙江省 +// 注意:返回完整的省份名称,包括"省"、"市"、"自治区"等后缀,以匹配地图数据 +func extractProvinceFromRegion(region string) string { + if region == "" || region == "Unknown" || region == "Internal" { + return "未知" + } + + // 使用正则表达式验证格式: 国家|省份|城市|ISP + // 匹配格式: 至少包含"国家|省份"两部分 + regionPattern := regexp.MustCompile(`^[^|]+\|[^|]+`) + if !regionPattern.MatchString(region) { + log.Printf("Invalid region format (regex validation failed): %s", region) + return "未知" + } + + parts := strings.Split(region, "|") + // 格式应该是: 国家|省份|城市|ISP (至少需要2个部分) + if len(parts) < 2 { + log.Printf("Invalid region format (insufficient parts): %s, parts: %v", region, parts) + return "未知" + } + + province := strings.TrimSpace(parts[1]) // 省份在索引1 + if province == "" || province == "0" { + log.Printf("Invalid province in region: %s, province part: %s", region, province) + return "未知" + } + + // 直接返回完整的省份名称,不移除"省"、"市"、"自治区"等后缀 + // 这样可以直接匹配地图数据中的省份名称 + return province +} diff --git a/server/utils/operation_mapper.go b/server/utils/operation_mapper.go new file mode 100644 index 0000000..be44cd8 --- /dev/null +++ b/server/utils/operation_mapper.go @@ -0,0 +1,345 @@ +package utils + +import ( + "strings" +) + +// GetOperationAction 根据路径和方法生成友好的中文操作描述 +func GetOperationAction(path, method string) string { + // 统一转换为小写以便匹配 + pathLower := strings.ToLower(path) + methodUpper := strings.ToUpper(method) + + // 用户管理 + if strings.Contains(pathLower, "/api/admin/users") { + if methodUpper == "GET" { + if strings.Contains(pathLower, "/users/") && !strings.HasSuffix(pathLower, "/users") { + return "查看用户详情" + } + return "查看用户列表" + } + if methodUpper == "POST" { + return "创建用户" + } + if methodUpper == "PUT" { + return "更新用户" + } + if methodUpper == "DELETE" { + return "删除用户" + } + } + + // 角色管理 + if strings.Contains(pathLower, "/api/admin/roles") { + if methodUpper == "GET" { + if strings.Contains(pathLower, "/permissions") { + return "查看权限列表" + } + return "查看角色列表" + } + if methodUpper == "POST" { + return "创建角色" + } + if methodUpper == "PUT" { + if strings.Contains(pathLower, "/permissions") { + return "更新角色权限" + } + return "更新角色" + } + if methodUpper == "DELETE" { + return "删除角色" + } + } + + // 文章管理 + if strings.Contains(pathLower, "/api/admin/posts") { + if methodUpper == "GET" { + if strings.Contains(pathLower, "/history") { + return "查看文章历史" + } + if strings.Contains(pathLower, "/access-logs") { + return "查看文章访问日志" + } + return "查看文章列表" + } + if methodUpper == "POST" { + return "创建文章" + } + if methodUpper == "PUT" { + return "更新文章" + } + if methodUpper == "PATCH" { + if strings.Contains(pathLower, "/status") { + return "切换文章状态" + } + return "更新文章" + } + if methodUpper == "DELETE" { + return "删除文章" + } + } + + // 分类管理 + if strings.Contains(pathLower, "/api/admin/categories") { + if methodUpper == "GET" { + return "查看分类列表" + } + if methodUpper == "POST" { + return "创建分类" + } + if methodUpper == "PUT" { + return "更新分类" + } + if methodUpper == "DELETE" { + return "删除分类" + } + } + + // 专栏管理 + if strings.Contains(pathLower, "/api/admin/columns") { + if methodUpper == "GET" { + return "查看专栏列表" + } + if methodUpper == "POST" { + if strings.Contains(pathLower, "/posts") { + return "添加文章到专栏" + } + return "创建专栏" + } + if methodUpper == "PUT" { + return "更新专栏" + } + if methodUpper == "DELETE" { + if strings.Contains(pathLower, "/posts/") { + return "从专栏移除文章" + } + return "删除专栏" + } + } + + // 标签管理 + if strings.Contains(pathLower, "/api/admin/tags") { + if methodUpper == "GET" { + return "查看标签列表" + } + if methodUpper == "POST" { + return "创建标签" + } + if methodUpper == "PUT" { + return "更新标签" + } + if methodUpper == "DELETE" { + return "删除标签" + } + } + + // 代码片段管理 + if strings.Contains(pathLower, "/api/admin/snippets") { + if methodUpper == "GET" { + return "查看代码片段列表" + } + if methodUpper == "POST" { + return "创建代码片段" + } + if methodUpper == "PUT" { + return "更新代码片段" + } + if methodUpper == "DELETE" { + return "删除代码片段" + } + } + + // 作品管理 + if strings.Contains(pathLower, "/api/admin/works") { + if methodUpper == "GET" { + return "查看作品列表" + } + if methodUpper == "POST" { + return "创建作品" + } + if methodUpper == "PUT" { + return "更新作品" + } + if methodUpper == "DELETE" { + return "删除作品" + } + } + + // 系统设置管理 + if strings.Contains(pathLower, "/api/admin/settings") { + if methodUpper == "GET" { + return "查看系统设置" + } + if methodUpper == "POST" { + return "创建系统设置" + } + if methodUpper == "PUT" { + return "更新系统设置" + } + if methodUpper == "DELETE" { + return "删除系统设置" + } + } + + // 关于页面管理 + if strings.Contains(pathLower, "/api/admin/about") { + if methodUpper == "GET" { + return "查看关于页面" + } + if methodUpper == "POST" { + return "创建关于页面" + } + if methodUpper == "PUT" { + return "更新关于页面" + } + if methodUpper == "DELETE" { + return "删除关于页面" + } + } + + // 客户评价管理 + if strings.Contains(pathLower, "/api/admin/testimonials") { + if methodUpper == "POST" { + return "创建客户评价" + } + if methodUpper == "PUT" { + return "更新客户评价" + } + if methodUpper == "DELETE" { + return "删除客户评价" + } + } + + // 合作伙伴管理 + if strings.Contains(pathLower, "/api/admin/partners") { + if methodUpper == "POST" { + return "创建合作伙伴" + } + if methodUpper == "PUT" { + return "更新合作伙伴" + } + if methodUpper == "DELETE" { + return "删除合作伙伴" + } + } + + // 咨询管理 + if strings.Contains(pathLower, "/api/admin/inquiries") { + if methodUpper == "GET" { + return "查看咨询列表" + } + if methodUpper == "PUT" { + if strings.Contains(pathLower, "/status") { + return "更新咨询状态" + } + return "更新咨询" + } + } + + // 邮箱后缀配置 + if strings.Contains(pathLower, "/api/admin/email-suffixes") { + if methodUpper == "GET" { + return "查看邮箱后缀列表" + } + if methodUpper == "POST" { + return "创建邮箱后缀" + } + if methodUpper == "PUT" { + return "更新邮箱后缀" + } + if methodUpper == "DELETE" { + return "删除邮箱后缀" + } + } + + // 搜索记录管理 + if strings.Contains(pathLower, "/api/admin/search-logs") { + if methodUpper == "GET" { + return "查看搜索记录列表" + } + if methodUpper == "DELETE" { + return "删除搜索记录" + } + } + + // 附件管理 + if strings.Contains(pathLower, "/api/admin/attachments") { + if methodUpper == "GET" { + return "查看附件列表" + } + if methodUpper == "POST" { + if strings.Contains(pathLower, "/upload") { + return "上传附件" + } + return "创建附件" + } + if methodUpper == "PUT" { + return "更新附件" + } + if methodUpper == "DELETE" { + return "删除附件" + } + } + + // 附件分类管理 + if strings.Contains(pathLower, "/api/admin/attachment-categories") { + if methodUpper == "GET" { + return "查看附件分类列表" + } + if methodUpper == "POST" { + return "创建附件分类" + } + if methodUpper == "PUT" { + return "更新附件分类" + } + if methodUpper == "DELETE" { + return "删除附件分类" + } + } + + // OSS配置管理 + if strings.Contains(pathLower, "/api/admin/oss-configs") { + if methodUpper == "GET" { + return "查看OSS配置列表" + } + if methodUpper == "POST" { + return "创建OSS配置" + } + if methodUpper == "PUT" { + return "更新OSS配置" + } + if methodUpper == "DELETE" { + return "删除OSS配置" + } + } + + // 操作日志管理 + if strings.Contains(pathLower, "/api/admin/operation-logs") { + if methodUpper == "GET" { + return "查看操作日志" + } + } + + // 访问日志管理 + if strings.Contains(pathLower, "/api/admin/access-logs") { + if methodUpper == "GET" { + return "查看访问日志" + } + } + + // 仪表盘 + if strings.Contains(pathLower, "/api/admin/dashboard") { + if methodUpper == "GET" { + if strings.Contains(pathLower, "/stats") { + return "查看仪表盘统计" + } + if strings.Contains(pathLower, "/activities") { + return "查看最近活动" + } + return "查看仪表盘" + } + } + + // 如果无法匹配,返回原始路径和方法作为后备 + return methodUpper + " " + path +}