Compare commits
2 Commits
2f122d0a96
...
cc09a177ef
| Author | SHA1 | Date | |
|---|---|---|---|
| cc09a177ef | |||
| 0de4c9832c |
@@ -110,13 +110,16 @@ client/src/
|
|||||||
│ ├── format.ts # 金额/日期格式化
|
│ ├── format.ts # 金额/日期格式化
|
||||||
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
|
│ ├── request.ts # HTTP 请求封装 (401 自动重登录+队列重试)
|
||||||
│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight)
|
│ ├── system.ts # 系统信息 (statusBarHeight, capsuleRight)
|
||||||
│ └── app-ready.ts # App 启动就绪机制 (waitForReady)
|
│ ├── app-ready.ts # App 启动就绪机制 (waitForReady)
|
||||||
|
│ └── tracker.ts # 前端埋点 (页面访问/操作/错误)
|
||||||
├── config.ts # 全局配置 (API_BASE)
|
├── config.ts # 全局配置 (API_BASE)
|
||||||
└── static/icons/ # tabBar 图标 (PNG)
|
└── static/icons/ # tabBar 图标 (PNG)
|
||||||
|
|
||||||
server/src/
|
server/src/
|
||||||
├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册)
|
├── index.ts # Express 入口 (CORS, JSON, 静态文件, 路由注册)
|
||||||
├── middleware/auth.ts # HMAC token 验证 (timingSafeEqual)
|
├── middleware/
|
||||||
|
│ ├── auth.ts # HMAC token 验证 (timingSafeEqual)
|
||||||
|
│ └── logger.ts # 请求日志 (记录到 logs/ 目录)
|
||||||
├── routes/
|
├── routes/
|
||||||
│ ├── auth.ts # 登录 / token 签发 (返回用户信息)
|
│ ├── auth.ts # 登录 / token 签发 (返回用户信息)
|
||||||
│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换)
|
│ ├── transaction.ts # 交易 CRUD (支持 group_id 视图切换)
|
||||||
@@ -130,6 +133,7 @@ server/src/
|
|||||||
│ ├── feedback.ts # 用户反馈 (提交/查询/处理)
|
│ ├── feedback.ts # 用户反馈 (提交/查询/处理)
|
||||||
│ ├── config.ts # 系统配置 (公开读/管理员写)
|
│ ├── config.ts # 系统配置 (公开读/管理员写)
|
||||||
│ ├── admin.ts # 管理后台 (仪表盘/用户管理)
|
│ ├── admin.ts # 管理后台 (仪表盘/用户管理)
|
||||||
|
│ ├── track.ts # 埋点数据接收
|
||||||
│ └── backup.ts # 备份 API
|
│ └── backup.ts # 备份 API
|
||||||
├── utils/
|
├── utils/
|
||||||
│ ├── backup.ts # Node.js 数据库备份 (gzip)
|
│ ├── backup.ts # Node.js 数据库备份 (gzip)
|
||||||
|
|||||||
12
DEV.md
12
DEV.md
@@ -75,6 +75,18 @@ res.status(500).json({ code: 50000, message: '服务器错误' })
|
|||||||
|
|
||||||
**原因**:前端 `request` 函数检查 `data.code === 0` 判断成功,不返回标准格式会导致前端误判为失败。
|
**原因**:前端 `request` 函数检查 `data.code === 0` 判断成功,不返回标准格式会导致前端误判为失败。
|
||||||
|
|
||||||
|
### 埋点和日志
|
||||||
|
|
||||||
|
**后端请求日志**:所有请求自动记录到 `logs/YYYY-MM-DD.log`,包含请求方法、URL、状态码、耗时、IP、用户ID。
|
||||||
|
|
||||||
|
**前端埋点**:使用 `@/utils/tracker` 记录关键操作:
|
||||||
|
```typescript
|
||||||
|
import { trackAction, trackError, trackApiError } from '@/utils/tracker'
|
||||||
|
|
||||||
|
trackAction('save_transaction', { amount: 100 }) // 用户操作
|
||||||
|
trackError(new Error('xxx'), { context: 'xxx' }) // 错误上报
|
||||||
|
```
|
||||||
|
|
||||||
### 页面数据加载模式
|
### 页面数据加载模式
|
||||||
|
|
||||||
```typescript
|
```typescript
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { useGroupStore } from '@/stores/group'
|
|||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
import { saveLoginResult } from '@/utils/request'
|
import { saveLoginResult } from '@/utils/request'
|
||||||
import { markReady } from '@/utils/app-ready'
|
import { markReady } from '@/utils/app-ready'
|
||||||
|
import { setupErrorTracking, trackAction, trackError } from '@/utils/tracker'
|
||||||
|
|
||||||
/** 登录重试(最多3次) */
|
/** 登录重试(最多3次) */
|
||||||
async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise<boolean> {
|
async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise<boolean> {
|
||||||
@@ -15,6 +16,7 @@ async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise
|
|||||||
return true
|
return true
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(`[Auth] Login attempt ${i + 1} failed:`, e)
|
console.error(`[Auth] Login attempt ${i + 1} failed:`, e)
|
||||||
|
trackError(e as Error, { context: 'login', attempt: i + 1 })
|
||||||
if (i < retries - 1) {
|
if (i < retries - 1) {
|
||||||
// 等待1秒后重试
|
// 等待1秒后重试
|
||||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||||
@@ -25,6 +27,10 @@ async function loginWithRetry(loginFn: () => Promise<any>, retries = 3): Promise
|
|||||||
}
|
}
|
||||||
|
|
||||||
onLaunch(() => {
|
onLaunch(() => {
|
||||||
|
// 初始化错误追踪
|
||||||
|
setupErrorTracking()
|
||||||
|
trackAction('app_launch')
|
||||||
|
|
||||||
const groupStore = useGroupStore()
|
const groupStore = useGroupStore()
|
||||||
const configStore = useConfigStore()
|
const configStore = useConfigStore()
|
||||||
groupStore.init()
|
groupStore.init()
|
||||||
|
|||||||
46
client/src/api/logs.ts
Normal file
46
client/src/api/logs.ts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import { request } from '@/utils/request'
|
||||||
|
|
||||||
|
/** 日志文件信息 */
|
||||||
|
export interface LogFileInfo {
|
||||||
|
name: string
|
||||||
|
date: string
|
||||||
|
size: number
|
||||||
|
modified: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日志统计 */
|
||||||
|
export interface LogStats {
|
||||||
|
today: {
|
||||||
|
total: number
|
||||||
|
errors: number
|
||||||
|
slow: number
|
||||||
|
warns: number
|
||||||
|
}
|
||||||
|
daily: Array<{
|
||||||
|
date: string
|
||||||
|
total: number
|
||||||
|
errors: number
|
||||||
|
slow: number
|
||||||
|
}>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取日志文件列表 */
|
||||||
|
export function getLogFiles(): Promise<LogFileInfo[]> {
|
||||||
|
return request({ url: '/logs/files' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取日志统计 */
|
||||||
|
export function getLogStats(): Promise<LogStats> {
|
||||||
|
return request({ url: '/logs/stats' })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 查询日志 */
|
||||||
|
export function queryLogs(params: {
|
||||||
|
date: string
|
||||||
|
level?: string
|
||||||
|
keyword?: string
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
}): Promise<{ lines: string[]; total: number; page: number; pageSize: number }> {
|
||||||
|
return request({ url: `/logs/${params.date}`, data: { ...params, date: undefined } })
|
||||||
|
}
|
||||||
@@ -131,6 +131,14 @@
|
|||||||
"navigationBarTitleText": "系统配置",
|
"navigationBarTitleText": "系统配置",
|
||||||
"enablePullDownRefresh": true
|
"enablePullDownRefresh": true
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "pages/admin/logs",
|
||||||
|
"style": {
|
||||||
|
"navigationStyle": "custom",
|
||||||
|
"navigationBarTitleText": "系统日志",
|
||||||
|
"enablePullDownRefresh": true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"globalStyle": {
|
"globalStyle": {
|
||||||
|
|||||||
@@ -82,6 +82,11 @@
|
|||||||
<text class="entry-text">系统配置</text>
|
<text class="entry-text">系统配置</text>
|
||||||
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
</view>
|
</view>
|
||||||
|
<view class="entry-item" @tap="goLogs">
|
||||||
|
<Icon name="edit" :size="32" color="#FF8C69" />
|
||||||
|
<text class="entry-text">系统日志</text>
|
||||||
|
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
|
||||||
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -147,6 +152,7 @@ function goUsers() { uni.navigateTo({ url: '/pages/admin/users' }) }
|
|||||||
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
|
function goNotificationManage() { uni.navigateTo({ url: '/pages/admin/notifications' }) }
|
||||||
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
|
function goFeedbackManage() { uni.navigateTo({ url: '/pages/admin/feedback' }) }
|
||||||
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
|
function goConfig() { uni.navigateTo({ url: '/pages/admin/config' }) }
|
||||||
|
function goLogs() { uni.navigateTo({ url: '/pages/admin/logs' }) }
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
|||||||
462
client/src/pages/admin/logs.vue
Normal file
462
client/src/pages/admin/logs.vue
Normal file
@@ -0,0 +1,462 @@
|
|||||||
|
<template>
|
||||||
|
<view class="page">
|
||||||
|
<view class="header-fixed">
|
||||||
|
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
|
||||||
|
<view class="nav-bar">
|
||||||
|
<view class="nav-back" @tap="goBack">
|
||||||
|
<Icon name="arrowLeft" :size="40" color="#2D1B1B" />
|
||||||
|
</view>
|
||||||
|
<text class="nav-title">系统日志</text>
|
||||||
|
<view class="nav-placeholder"></view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 今日统计 -->
|
||||||
|
<view class="stats-card" v-if="stats">
|
||||||
|
<text class="card-title">今日概况</text>
|
||||||
|
<view class="stats-grid">
|
||||||
|
<view class="stat-item">
|
||||||
|
<text class="stat-value">{{ stats.today.total }}</text>
|
||||||
|
<text class="stat-label">总请求</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item error">
|
||||||
|
<text class="stat-value">{{ stats.today.errors }}</text>
|
||||||
|
<text class="stat-label">错误</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item warn">
|
||||||
|
<text class="stat-value">{{ stats.today.warns }}</text>
|
||||||
|
<text class="stat-label">警告</text>
|
||||||
|
</view>
|
||||||
|
<view class="stat-item slow">
|
||||||
|
<text class="stat-value">{{ stats.today.slow }}</text>
|
||||||
|
<text class="stat-label">慢请求</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 最近7天趋势 -->
|
||||||
|
<view class="stats-card" v-if="stats && stats.daily.length > 0">
|
||||||
|
<text class="card-title">最近7天</text>
|
||||||
|
<view class="daily-list">
|
||||||
|
<view v-for="day in stats.daily" :key="day.date" class="daily-item" @tap="selectDate(day.date)">
|
||||||
|
<text class="daily-date">{{ day.date.slice(5) }}</text>
|
||||||
|
<view class="daily-bar">
|
||||||
|
<view class="bar-fill" :style="{ width: getBarWidth(day.total) + '%' }"></view>
|
||||||
|
</view>
|
||||||
|
<text class="daily-count">{{ day.total }}</text>
|
||||||
|
<text v-if="day.errors > 0" class="daily-errors">{{ day.errors }} 错误</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 日志查询 -->
|
||||||
|
<view class="query-section">
|
||||||
|
<view class="query-header">
|
||||||
|
<text class="section-title">日志查询</text>
|
||||||
|
<view class="date-picker" @tap="showDatePicker = true">
|
||||||
|
<Icon name="calendar" :size="28" color="#8B7E7E" />
|
||||||
|
<text class="date-text">{{ selectedDate }}</text>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 筛选条件 -->
|
||||||
|
<view class="filter-row">
|
||||||
|
<view class="filter-tabs">
|
||||||
|
<view class="filter-tab" :class="{ active: filterLevel === 'all' }" @tap="filterLevel = 'all'">全部</view>
|
||||||
|
<view class="filter-tab" :class="{ active: filterLevel === 'error' }" @tap="filterLevel = 'error'">错误</view>
|
||||||
|
<view class="filter-tab" :class="{ active: filterLevel === 'warn' }" @tap="filterLevel = 'warn'">警告</view>
|
||||||
|
<view class="filter-tab" :class="{ active: filterLevel === 'info' }" @tap="filterLevel = 'info'">信息</view>
|
||||||
|
</view>
|
||||||
|
<input class="search-input" v-model="keyword" placeholder="搜索关键词" @confirm="loadLogs" />
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<!-- 日志列表 -->
|
||||||
|
<scroll-view class="log-list" scroll-y @scrolltolower="loadMore">
|
||||||
|
<view v-for="(line, index) in logLines" :key="index" class="log-line" :class="getLineClass(line)">
|
||||||
|
<text class="log-text" user-select>{{ line }}</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="loading" class="loading-box">
|
||||||
|
<text class="loading-text">加载中...</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="!loading && logLines.length === 0" class="empty-box">
|
||||||
|
<text class="empty-text">暂无日志</text>
|
||||||
|
</view>
|
||||||
|
|
||||||
|
<view v-if="noMore && logLines.length > 0" class="no-more">
|
||||||
|
<text class="no-more-text">共 {{ total }} 条</text>
|
||||||
|
</view>
|
||||||
|
</scroll-view>
|
||||||
|
</view>
|
||||||
|
</view>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, onMounted, watch } from 'vue'
|
||||||
|
import { onShow, onPullDownRefresh } from '@dcloudio/uni-app'
|
||||||
|
import { waitForReady } from '@/utils/app-ready'
|
||||||
|
import { statusBarHeight } from '@/utils/system'
|
||||||
|
import { getLogStats, queryLogs } from '@/api/logs'
|
||||||
|
import Icon from '@/components/Icon/Icon.vue'
|
||||||
|
import type { LogStats } from '@/api/logs'
|
||||||
|
|
||||||
|
const stats = ref<LogStats | null>(null)
|
||||||
|
const selectedDate = ref(getTodayStr())
|
||||||
|
const filterLevel = ref('all')
|
||||||
|
const keyword = ref('')
|
||||||
|
const logLines = ref<string[]>([])
|
||||||
|
const loading = ref(false)
|
||||||
|
const page = ref(1)
|
||||||
|
const total = ref(0)
|
||||||
|
|
||||||
|
const noMore = computed(() => logLines.value.length >= total.value && total.value > 0)
|
||||||
|
|
||||||
|
function getTodayStr(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await waitForReady()
|
||||||
|
await Promise.all([loadStats(), loadLogs()])
|
||||||
|
})
|
||||||
|
|
||||||
|
onShow(() => {
|
||||||
|
loadStats()
|
||||||
|
})
|
||||||
|
|
||||||
|
onPullDownRefresh(async () => {
|
||||||
|
await Promise.all([loadStats(), loadLogs()])
|
||||||
|
uni.stopPullDownRefresh()
|
||||||
|
})
|
||||||
|
|
||||||
|
async function loadStats() {
|
||||||
|
try {
|
||||||
|
stats.value = await getLogStats()
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadLogs(append = false) {
|
||||||
|
if (loading.value) return
|
||||||
|
if (!append) {
|
||||||
|
page.value = 1
|
||||||
|
logLines.value = []
|
||||||
|
}
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const data = await queryLogs({
|
||||||
|
date: selectedDate.value,
|
||||||
|
level: filterLevel.value === 'all' ? undefined : filterLevel.value,
|
||||||
|
keyword: keyword.value || undefined,
|
||||||
|
page: page.value,
|
||||||
|
pageSize: 100
|
||||||
|
})
|
||||||
|
if (append) {
|
||||||
|
logLines.value = [...logLines.value, ...data.lines]
|
||||||
|
} else {
|
||||||
|
logLines.value = data.lines
|
||||||
|
}
|
||||||
|
total.value = data.total
|
||||||
|
} catch {
|
||||||
|
uni.showToast({ title: '加载失败', icon: 'none' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadMore() {
|
||||||
|
if (noMore.value || loading.value) return
|
||||||
|
page.value++
|
||||||
|
loadLogs(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectDate(date: string) {
|
||||||
|
selectedDate.value = date
|
||||||
|
loadLogs()
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBarWidth(count: number): number {
|
||||||
|
if (!stats.value) return 0
|
||||||
|
const max = Math.max(...stats.value.daily.map(d => d.total), 1)
|
||||||
|
return (count / max) * 100
|
||||||
|
}
|
||||||
|
|
||||||
|
function getLineClass(line: string): string {
|
||||||
|
if (line.includes(' ERROR ')) return 'error'
|
||||||
|
if (line.includes(' WARN ')) return 'warn'
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 筛选条件变化时重新加载
|
||||||
|
watch([filterLevel], () => loadLogs())
|
||||||
|
|
||||||
|
function goBack() {
|
||||||
|
uni.navigateBack()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
@import '@/styles/mixins.scss';
|
||||||
|
|
||||||
|
.page {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: $bg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-fixed {
|
||||||
|
@include sticky-header;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-bar { @include status-bar; }
|
||||||
|
|
||||||
|
.nav-bar {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: $space-sm $space-xl $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-back { @include nav-back; }
|
||||||
|
|
||||||
|
.nav-title {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.nav-placeholder { width: 88rpx; }
|
||||||
|
|
||||||
|
.stats-card {
|
||||||
|
margin: $space-md $space-xl;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
box-shadow: $shadow-md;
|
||||||
|
padding: $space-lg;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, 1fr);
|
||||||
|
gap: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-sm;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
background: $bg;
|
||||||
|
|
||||||
|
&.error { background: #FFF0F0; }
|
||||||
|
&.warn { background: #FFF8E1; }
|
||||||
|
&.slow { background: #FFF3E0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-size: $font-2xl;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
display: block;
|
||||||
|
|
||||||
|
.error & { color: $danger; }
|
||||||
|
.warn & { color: #F57C00; }
|
||||||
|
.slow & { color: #FF9800; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-sec;
|
||||||
|
display: block;
|
||||||
|
margin-top: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-sm;
|
||||||
|
padding: $space-xs 0;
|
||||||
|
|
||||||
|
&:active { opacity: 0.7; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-date {
|
||||||
|
width: 80rpx;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-bar {
|
||||||
|
flex: 1;
|
||||||
|
height: 16rpx;
|
||||||
|
background: $border;
|
||||||
|
border-radius: 8rpx;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bar-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, $primary, #FFB89A);
|
||||||
|
border-radius: 8rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-count {
|
||||||
|
width: 80rpx;
|
||||||
|
text-align: right;
|
||||||
|
font-family: 'Fredoka', sans-serif;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.daily-errors {
|
||||||
|
font-size: $font-xs;
|
||||||
|
color: $danger;
|
||||||
|
width: 100rpx;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-section {
|
||||||
|
margin: $space-md $space-xl;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: $font-lg;
|
||||||
|
font-weight: 600;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-picker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: $space-xs;
|
||||||
|
padding: $space-xs $space-md;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
|
||||||
|
&:active { background: $bg; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.date-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-md;
|
||||||
|
margin-bottom: $space-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: $space-xs;
|
||||||
|
background: $surface-warm;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
padding: $space-xs;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-tab {
|
||||||
|
padding: $space-xs $space-md;
|
||||||
|
border-radius: $radius-md;
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-sec;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background: $surface;
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
height: 64rpx;
|
||||||
|
padding: 0 $space-md;
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-lg;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
font-size: $font-md;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-list {
|
||||||
|
height: calc(100vh - 700rpx);
|
||||||
|
background: $surface;
|
||||||
|
border-radius: $radius-xl;
|
||||||
|
border: 2rpx solid $border;
|
||||||
|
padding: $space-sm;
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-line {
|
||||||
|
padding: $space-xs $space-sm;
|
||||||
|
border-bottom: 1rpx solid $border;
|
||||||
|
|
||||||
|
&:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
&.error {
|
||||||
|
background: #FFF5F5;
|
||||||
|
border-left: 4rpx solid $danger;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warn {
|
||||||
|
background: #FFFBF0;
|
||||||
|
border-left: 4rpx solid #F57C00;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.log-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
font-family: 'Courier New', monospace;
|
||||||
|
color: $text;
|
||||||
|
word-break: break-all;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-box, .empty-box {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: $space-xl 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-text, .empty-text {
|
||||||
|
font-size: $font-md;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more {
|
||||||
|
text-align: center;
|
||||||
|
padding: $space-md 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-more-text {
|
||||||
|
font-size: $font-sm;
|
||||||
|
color: $text-muted;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bar-fill { transition: none; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -11,7 +11,7 @@ export const useUserStore = defineStore('user', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const slogan = computed(() => {
|
const slogan = computed(() => {
|
||||||
return userInfo.value?.slogan || '记账小能手'
|
return userInfo.value?.slogan
|
||||||
})
|
})
|
||||||
|
|
||||||
const avatarUrl = computed(() => {
|
const avatarUrl = computed(() => {
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { API_BASE } from '@/config'
|
import { API_BASE } from '@/config'
|
||||||
import type { LoginResult } from '@/api/auth'
|
import type { LoginResult } from '@/api/auth'
|
||||||
|
import { trackApiError } from './tracker'
|
||||||
|
|
||||||
interface RequestOptions {
|
interface RequestOptions {
|
||||||
url: string
|
url: string
|
||||||
@@ -88,6 +89,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
if (data.code === 0) {
|
if (data.code === 0) {
|
||||||
resolve(data.data)
|
resolve(data.data)
|
||||||
} else {
|
} else {
|
||||||
|
// 埋点:API 业务错误
|
||||||
|
trackApiError(options.url, statusCode, data.message || '请求失败')
|
||||||
// "用户不存在" 不弹 toast(首次登录竞态)
|
// "用户不存在" 不弹 toast(首次登录竞态)
|
||||||
if (data.code !== 40400 || data.message !== '用户不存在') {
|
if (data.code !== 40400 || data.message !== '用户不存在') {
|
||||||
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
uni.showToast({ title: data.message || '请求失败', icon: 'none' })
|
||||||
@@ -96,6 +99,8 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
fail: (err: any) => {
|
fail: (err: any) => {
|
||||||
|
// 埋点:网络错误
|
||||||
|
trackApiError(options.url, 0, '网络错误')
|
||||||
uni.showToast({ title: '网络错误', icon: 'none' })
|
uni.showToast({ title: '网络错误', icon: 'none' })
|
||||||
reject(err)
|
reject(err)
|
||||||
}
|
}
|
||||||
|
|||||||
140
client/src/utils/tracker.ts
Normal file
140
client/src/utils/tracker.ts
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
/**
|
||||||
|
* 前端埋点工具
|
||||||
|
* 记录用户关键操作和错误信息
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { request } from './request'
|
||||||
|
|
||||||
|
interface TrackEvent {
|
||||||
|
event: string
|
||||||
|
page?: string
|
||||||
|
data?: Record<string, any>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 上报队列(批量上报,减少请求) */
|
||||||
|
let eventQueue: TrackEvent[] = []
|
||||||
|
let flushTimer: number | null = null
|
||||||
|
const FLUSH_INTERVAL = 5000 // 5秒上报一次
|
||||||
|
const MAX_QUEUE_SIZE = 20
|
||||||
|
|
||||||
|
/** 获取当前页面路径 */
|
||||||
|
function getCurrentPage(): string {
|
||||||
|
const pages = getCurrentPages()
|
||||||
|
if (pages.length > 0) {
|
||||||
|
return `/${pages[pages.length - 1].route}`
|
||||||
|
}
|
||||||
|
return '/unknown'
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 批量上报事件 */
|
||||||
|
async function flushEvents() {
|
||||||
|
if (eventQueue.length === 0) return
|
||||||
|
|
||||||
|
const events = [...eventQueue]
|
||||||
|
eventQueue = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
await request({
|
||||||
|
url: '/track',
|
||||||
|
method: 'POST',
|
||||||
|
data: { events }
|
||||||
|
})
|
||||||
|
} catch {
|
||||||
|
// 上报失败不处理,避免影响用户体验
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 添加事件到队列 */
|
||||||
|
function addToQueue(event: TrackEvent) {
|
||||||
|
eventQueue.push(event)
|
||||||
|
|
||||||
|
// 队列满时立即上报
|
||||||
|
if (eventQueue.length >= MAX_QUEUE_SIZE) {
|
||||||
|
flushEvents()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置定时上报
|
||||||
|
if (!flushTimer) {
|
||||||
|
flushTimer = setTimeout(() => {
|
||||||
|
flushTimer = null
|
||||||
|
flushEvents()
|
||||||
|
}, FLUSH_INTERVAL) as unknown as number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪页面访问
|
||||||
|
*/
|
||||||
|
export function trackPageView(pagePath?: string) {
|
||||||
|
addToQueue({
|
||||||
|
event: 'page_view',
|
||||||
|
page: pagePath || getCurrentPage()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪用户操作
|
||||||
|
* @param action 操作名称,如 'click_save', 'submit_form'
|
||||||
|
* @param data 附加数据
|
||||||
|
*/
|
||||||
|
export function trackAction(action: string, data?: Record<string, any>) {
|
||||||
|
addToQueue({
|
||||||
|
event: 'action',
|
||||||
|
page: getCurrentPage(),
|
||||||
|
data: { action, ...data }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪错误
|
||||||
|
*/
|
||||||
|
export function trackError(error: Error | string, extra?: Record<string, any>) {
|
||||||
|
const errorInfo = typeof error === 'string'
|
||||||
|
? { message: error }
|
||||||
|
: { message: error.message, stack: error.stack?.slice(0, 500) }
|
||||||
|
|
||||||
|
addToQueue({
|
||||||
|
event: 'error',
|
||||||
|
page: getCurrentPage(),
|
||||||
|
data: { ...errorInfo, ...extra }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 追踪 API 请求失败
|
||||||
|
*/
|
||||||
|
export function trackApiError(url: string, status: number, message: string) {
|
||||||
|
addToQueue({
|
||||||
|
event: 'api_error',
|
||||||
|
page: getCurrentPage(),
|
||||||
|
data: { url, status, message }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 设置全局错误监听
|
||||||
|
*/
|
||||||
|
export function setupErrorTracking() {
|
||||||
|
// Vue 错误处理
|
||||||
|
uni.onError((err: string) => {
|
||||||
|
trackError(err, { type: 'global' })
|
||||||
|
})
|
||||||
|
|
||||||
|
// 未处理的 Promise rejection
|
||||||
|
// #ifdef H5
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.addEventListener('unhandledrejection', (event) => {
|
||||||
|
trackError(String(event.reason), { type: 'unhandledrejection' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// #endif
|
||||||
|
}
|
||||||
|
|
||||||
|
export default {
|
||||||
|
trackPageView,
|
||||||
|
trackAction,
|
||||||
|
trackError,
|
||||||
|
trackApiError,
|
||||||
|
setupErrorTracking
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import rateLimit from 'express-rate-limit'
|
|||||||
import path from 'path'
|
import path from 'path'
|
||||||
import fs from 'fs'
|
import fs from 'fs'
|
||||||
import { authMiddleware } from './middleware/auth'
|
import { authMiddleware } from './middleware/auth'
|
||||||
|
import { requestLogger } from './middleware/logger'
|
||||||
import { initDatabase } from './db/init'
|
import { initDatabase } from './db/init'
|
||||||
import pool from './db/connection'
|
import pool from './db/connection'
|
||||||
import authRoutes from './routes/auth'
|
import authRoutes from './routes/auth'
|
||||||
@@ -20,6 +21,8 @@ import filterRoutes from './routes/filter'
|
|||||||
import adminRoutes from './routes/admin'
|
import adminRoutes from './routes/admin'
|
||||||
import feedbackRoutes from './routes/feedback'
|
import feedbackRoutes from './routes/feedback'
|
||||||
import configRoutes from './routes/config'
|
import configRoutes from './routes/config'
|
||||||
|
import trackRoutes from './routes/track'
|
||||||
|
import logsRoutes from './routes/logs'
|
||||||
import { backupDatabase } from './utils/backup'
|
import { backupDatabase } from './utils/backup'
|
||||||
|
|
||||||
// Warn if token secret is using default fallback
|
// Warn if token secret is using default fallback
|
||||||
@@ -48,6 +51,9 @@ app.use(cors({
|
|||||||
}))
|
}))
|
||||||
app.use(express.json({ limit: '10kb' }))
|
app.use(express.json({ limit: '10kb' }))
|
||||||
|
|
||||||
|
// 请求日志
|
||||||
|
app.use(requestLogger)
|
||||||
|
|
||||||
// 限流:登录接口 — 每分钟最多 10 次
|
// 限流:登录接口 — 每分钟最多 10 次
|
||||||
const authLimiter = rateLimit({
|
const authLimiter = rateLimit({
|
||||||
windowMs: 60 * 1000,
|
windowMs: 60 * 1000,
|
||||||
@@ -153,6 +159,8 @@ app.use('/api/filters', apiLimiter, filterRoutes)
|
|||||||
app.use('/api/admin', apiLimiter, adminRoutes)
|
app.use('/api/admin', apiLimiter, adminRoutes)
|
||||||
app.use('/api/feedback', apiLimiter, feedbackRoutes)
|
app.use('/api/feedback', apiLimiter, feedbackRoutes)
|
||||||
app.use('/api/config', apiLimiter, configRoutes)
|
app.use('/api/config', apiLimiter, configRoutes)
|
||||||
|
app.use('/api/track', trackRoutes)
|
||||||
|
app.use('/api/logs', apiLimiter, logsRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async (_req, res) => {
|
app.get('/api/health', async (_req, res) => {
|
||||||
try {
|
try {
|
||||||
|
|||||||
84
server/src/middleware/logger.ts
Normal file
84
server/src/middleware/logger.ts
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { Request, Response, NextFunction } from 'express'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// 日志目录
|
||||||
|
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs')
|
||||||
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
|
fs.mkdirSync(LOG_DIR, { recursive: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 获取当前日期字符串 YYYY-MM-DD */
|
||||||
|
function getDateStr(): string {
|
||||||
|
const d = new Date()
|
||||||
|
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 格式化时间 HH:mm:ss.SSS */
|
||||||
|
function formatTime(date: Date): string {
|
||||||
|
return `${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}:${String(date.getSeconds()).padStart(2, '0')}.${String(date.getMilliseconds()).padStart(3, '0')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 写入日志文件 */
|
||||||
|
function writeLog(level: string, message: string, meta?: Record<string, any>) {
|
||||||
|
const dateStr = getDateStr()
|
||||||
|
const logFile = path.join(LOG_DIR, `${dateStr}.log`)
|
||||||
|
const time = formatTime(new Date())
|
||||||
|
const metaStr = meta ? ` ${JSON.stringify(meta)}` : ''
|
||||||
|
const line = `[${time}] ${level} ${message}${metaStr}\n`
|
||||||
|
|
||||||
|
fs.appendFile(logFile, line, (err) => {
|
||||||
|
if (err) console.error('[Logger] Write error:', err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 请求日志中间件 */
|
||||||
|
export function requestLogger(req: Request, res: Response, next: NextFunction) {
|
||||||
|
const startTime = Date.now()
|
||||||
|
const { method, url, ip } = req
|
||||||
|
|
||||||
|
// 提取用户 ID(如果有 auth 中间件设置)
|
||||||
|
const userId = (req as any).userId
|
||||||
|
|
||||||
|
// 响应结束时记录日志
|
||||||
|
res.on('finish', () => {
|
||||||
|
const duration = Date.now() - startTime
|
||||||
|
const { statusCode } = res
|
||||||
|
|
||||||
|
const meta = {
|
||||||
|
method,
|
||||||
|
url,
|
||||||
|
status: statusCode,
|
||||||
|
duration: `${duration}ms`,
|
||||||
|
ip: ip || req.headers['x-forwarded-for'] || 'unknown',
|
||||||
|
userId: userId || null,
|
||||||
|
userAgent: req.headers['user-agent']?.slice(0, 100) || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusCode >= 400) {
|
||||||
|
writeLog('ERROR', `${method} ${url} ${statusCode} ${duration}ms`, meta)
|
||||||
|
} else if (duration > 1000) {
|
||||||
|
writeLog('WARN', `Slow request: ${method} ${url} ${duration}ms`, meta)
|
||||||
|
} else {
|
||||||
|
writeLog('INFO', `${method} ${url} ${statusCode} ${duration}ms`, meta)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
next()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 错误日志 */
|
||||||
|
export function logError(message: string, error: Error | any, meta?: Record<string, any>) {
|
||||||
|
writeLog('ERROR', message, {
|
||||||
|
...meta,
|
||||||
|
error: error?.message || String(error),
|
||||||
|
stack: error?.stack?.slice(0, 500) || ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 业务日志 */
|
||||||
|
export function logInfo(message: string, meta?: Record<string, any>) {
|
||||||
|
writeLog('INFO', message, meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default { requestLogger, logError, logInfo }
|
||||||
145
server/src/routes/logs.ts
Normal file
145
server/src/routes/logs.ts
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import fs from 'fs'
|
||||||
|
import path from 'path'
|
||||||
|
import { requireAdmin } from '../middleware/requireAdmin'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
const LOG_DIR = path.resolve(process.env.LOG_DIR || './logs')
|
||||||
|
|
||||||
|
/** 获取日志列表(最近7天) */
|
||||||
|
router.get('/files', requireAdmin, (_req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
|
return res.json({ code: 0, data: [] })
|
||||||
|
}
|
||||||
|
|
||||||
|
const files = fs.readdirSync(LOG_DIR)
|
||||||
|
.filter(f => f.endsWith('.log'))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 7) // 最多返回7天
|
||||||
|
|
||||||
|
const fileList = files.map(f => {
|
||||||
|
const filePath = path.join(LOG_DIR, f)
|
||||||
|
const stat = fs.statSync(filePath)
|
||||||
|
return {
|
||||||
|
name: f,
|
||||||
|
date: f.replace('.log', ''),
|
||||||
|
size: stat.size,
|
||||||
|
modified: stat.mtime.toISOString()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({ code: 0, data: fileList })
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logs] List error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取日志列表失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 获取日志统计 */
|
||||||
|
router.get('/stats', requireAdmin, (_req, res) => {
|
||||||
|
try {
|
||||||
|
if (!fs.existsSync(LOG_DIR)) {
|
||||||
|
return res.json({ code: 0, data: { today: { total: 0, errors: 0, slow: 0 }, files: [] } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const today = new Date()
|
||||||
|
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, '0')}-${String(today.getDate()).padStart(2, '0')}`
|
||||||
|
const todayFile = path.join(LOG_DIR, `${dateStr}.log`)
|
||||||
|
|
||||||
|
let todayStats = { total: 0, errors: 0, slow: 0, warns: 0 }
|
||||||
|
|
||||||
|
if (fs.existsSync(todayFile)) {
|
||||||
|
const content = fs.readFileSync(todayFile, 'utf-8')
|
||||||
|
const lines = content.split('\n').filter(l => l.trim())
|
||||||
|
todayStats.total = lines.length
|
||||||
|
todayStats.errors = lines.filter(l => l.includes(' ERROR ')).length
|
||||||
|
todayStats.slow = lines.filter(l => l.includes('Slow request')).length
|
||||||
|
todayStats.warns = lines.filter(l => l.includes(' WARN ')).length
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最近7天统计
|
||||||
|
const files = fs.readdirSync(LOG_DIR)
|
||||||
|
.filter(f => f.endsWith('.log'))
|
||||||
|
.sort()
|
||||||
|
.reverse()
|
||||||
|
.slice(0, 7)
|
||||||
|
|
||||||
|
const dailyStats = files.map(f => {
|
||||||
|
const filePath = path.join(LOG_DIR, f)
|
||||||
|
const content = fs.readFileSync(filePath, 'utf-8')
|
||||||
|
const lines = content.split('\n').filter(l => l.trim())
|
||||||
|
return {
|
||||||
|
date: f.replace('.log', ''),
|
||||||
|
total: lines.length,
|
||||||
|
errors: lines.filter(l => l.includes(' ERROR ')).length,
|
||||||
|
slow: lines.filter(l => l.includes('Slow request')).length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
today: todayStats,
|
||||||
|
daily: dailyStats
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logs] Stats error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '获取日志统计失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
/** 查询指定日期的日志 */
|
||||||
|
router.get('/:date', requireAdmin, (req, res) => {
|
||||||
|
try {
|
||||||
|
const { date } = req.params
|
||||||
|
const { level, keyword, page = '1', pageSize = '100' } = req.query
|
||||||
|
|
||||||
|
// 日期格式校验
|
||||||
|
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '日期格式无效' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const logFile = path.join(LOG_DIR, `${date}.log`)
|
||||||
|
if (!fs.existsSync(logFile)) {
|
||||||
|
return res.json({ code: 0, data: { lines: [], total: 0 } })
|
||||||
|
}
|
||||||
|
|
||||||
|
const content = fs.readFileSync(logFile, 'utf-8')
|
||||||
|
let lines = content.split('\n').filter(l => l.trim())
|
||||||
|
|
||||||
|
// 按级别过滤
|
||||||
|
if (level && level !== 'all') {
|
||||||
|
lines = lines.filter(l => l.includes(` ${level.toUpperCase()} `))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按关键词过滤
|
||||||
|
if (keyword && typeof keyword === 'string') {
|
||||||
|
const kw = keyword.toLowerCase()
|
||||||
|
lines = lines.filter(l => l.toLowerCase().includes(kw))
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = lines.length
|
||||||
|
const pNum = Math.max(1, parseInt(page as string) || 1)
|
||||||
|
const pSize = Math.min(500, Math.max(1, parseInt(pageSize as string) || 100))
|
||||||
|
const offset = (pNum - 1) * pSize
|
||||||
|
const paged = lines.slice(offset, offset + pSize)
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
code: 0,
|
||||||
|
data: {
|
||||||
|
lines: paged,
|
||||||
|
total,
|
||||||
|
page: pNum,
|
||||||
|
pageSize: pSize
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[Logs] Query error:', err)
|
||||||
|
res.status(500).json({ code: 50000, message: '查询日志失败' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
30
server/src/routes/track.ts
Normal file
30
server/src/routes/track.ts
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
import { Router } from 'express'
|
||||||
|
import { logInfo } from '../middleware/logger'
|
||||||
|
|
||||||
|
const router = Router()
|
||||||
|
|
||||||
|
/** 接收前端埋点数据 */
|
||||||
|
router.post('/', (req, res) => {
|
||||||
|
try {
|
||||||
|
const { events } = req.body
|
||||||
|
|
||||||
|
if (!Array.isArray(events)) {
|
||||||
|
return res.status(400).json({ code: 40001, message: '无效的埋点数据' })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录埋点事件
|
||||||
|
for (const event of events) {
|
||||||
|
logInfo(`[Track] ${event.event}`, {
|
||||||
|
page: event.page,
|
||||||
|
data: event.data,
|
||||||
|
userId: (req as any).userId || null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json({ code: 0 })
|
||||||
|
} catch {
|
||||||
|
res.json({ code: 0 }) // 埋点失败不影响业务
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
Reference in New Issue
Block a user