feat(analytics): 在访问统计中增加同比趋势显示

- 在访问概览 API 中新增昨日、上周、上月 PV 字段,用于计算趋势
- 在前端统计卡片中显示今日、本周、本月访问量的同比变化百分比
- 支持 163 邮箱作为邮件通知服务提供商,更新相关文档和设置界面
This commit is contained in:
anghunk
2026-02-05 16:13:05 +08:00
parent 65c0c20040
commit 5a0ecdaa91
6 changed files with 176 additions and 14 deletions

View File

@@ -88,8 +88,11 @@ export type VisitOverviewResponse = {
totalPv: number;
totalPages: number;
todayPv: number;
yesterdayPv?: number;
weekPv: number;
lastWeekPv?: number;
monthPv: number;
lastMonthPv?: number;
last30Days?: {
date: string;
total: number;

View File

@@ -44,6 +44,29 @@
font-size: 26px;
font-weight: 600;
color: var(--text-primary);
display: flex;
align-items: baseline;
}
.trend {
display: inline-flex;
align-items: center;
margin-left: 8px;
font-size: 14px;
font-weight: 500;
&.up {
color: #10b981;
}
&.down {
color: #ef4444;
}
}
.trend-arrow {
margin-right: 2px;
font-family: sans-serif;
}
.domain-table-wrapper {
@@ -191,4 +214,4 @@
.stats-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
}

View File

@@ -25,15 +25,48 @@
</div>
<div class="stats-item">
<div class="stats-label">今日访问量</div>
<div class="stats-value">{{ overview.todayPv }}</div>
<div class="stats-value">
{{ overview.todayPv }}
<span
v-if="overview.yesterdayPv !== undefined"
class="trend"
:class="percentageChange >= 0 ? 'up' : 'down'"
:title="`对比昨日 ${percentageChange >= 0 ? '增加' : '减少'} ${Math.abs(percentageChange).toFixed(1)}%`"
>
<span class="trend-arrow">{{ percentageChange >= 0 ? '↑' : '↓' }}</span>
{{ Math.abs(percentageChange).toFixed(1) }}%
</span>
</div>
</div>
<div class="stats-item">
<div class="stats-label">本周访问量</div>
<div class="stats-value">{{ overview.weekPv }}</div>
<div class="stats-value">
{{ overview.weekPv }}
<span
v-if="overview.lastWeekPv !== undefined"
class="trend"
:class="weekPercentageChange >= 0 ? 'up' : 'down'"
:title="`对比上周 ${weekPercentageChange >= 0 ? '增加' : '减少'} ${Math.abs(weekPercentageChange).toFixed(1)}%`"
>
<span class="trend-arrow">{{ weekPercentageChange >= 0 ? '↑' : '↓' }}</span>
{{ Math.abs(weekPercentageChange).toFixed(1) }}%
</span>
</div>
</div>
<div class="stats-item">
<div class="stats-label">本月访问量</div>
<div class="stats-value">{{ overview.monthPv }}</div>
<div class="stats-value">
{{ overview.monthPv }}
<span
v-if="overview.lastMonthPv !== undefined"
class="trend"
:class="monthPercentageChange >= 0 ? 'up' : 'down'"
:title="`对比上月 ${monthPercentageChange >= 0 ? '增加' : '减少'} ${Math.abs(monthPercentageChange).toFixed(1)}%`"
>
<span class="trend-arrow">{{ monthPercentageChange >= 0 ? '↑' : '↓' }}</span>
{{ Math.abs(monthPercentageChange).toFixed(1) }}%
</span>
</div>
</div>
<div class="stats-item">
<div class="stats-label">有访问记录的页面数</div>
@@ -198,11 +231,33 @@ const overview = ref<VisitOverviewResponse>({
totalPv: 0,
totalPages: 0,
todayPv: 0,
yesterdayPv: 0,
weekPv: 0,
lastWeekPv: 0,
monthPv: 0,
lastMonthPv: 0,
last30Days: [],
});
function calculateChange(current: number, previous: number) {
if (previous === 0) {
return current > 0 ? 100 : 0;
}
return ((current - previous) / previous) * 100;
}
const percentageChange = computed(() => {
return calculateChange(overview.value.todayPv, overview.value.yesterdayPv || 0);
});
const weekPercentageChange = computed(() => {
return calculateChange(overview.value.weekPv, overview.value.lastWeekPv || 0);
});
const monthPercentageChange = computed(() => {
return calculateChange(overview.value.monthPv, overview.value.lastMonthPv || 0);
});
const rawItems = ref<VisitPageItem[]>([]);
const items = computed<VisitPageItem[]>(() => {
const list = rawItems.value.slice();
@@ -390,8 +445,11 @@ async function loadData() {
totalPv: overviewRes.totalPv,
totalPages: overviewRes.totalPages,
todayPv: overviewRes.todayPv ?? 0,
yesterdayPv: overviewRes.yesterdayPv ?? 0,
weekPv: overviewRes.weekPv ?? 0,
lastWeekPv: overviewRes.lastWeekPv ?? 0,
monthPv: overviewRes.monthPv ?? 0,
lastMonthPv: overviewRes.lastMonthPv ?? 0,
last30Days: Array.isArray(overviewRes.last30Days)
? overviewRes.last30Days
: [],

View File

@@ -305,6 +305,7 @@
@change="onProviderChange"
>
<option value="qq">QQ 邮箱</option>
<option value="163">163 邮箱</option>
<option value="custom">自定义 SMTP</option>
</select>
</div>
@@ -361,6 +362,10 @@
请登录 QQ 邮箱网页版设置 - 账户中开启 POP3/SMTP
服务并生成授权码
</div>
<div v-else-if="smtpProvider === '163'" class="form-hint">
注意163 邮箱必须使用授权码而非登录密码<br />
请登录 163 邮箱网页版设置 - POP3/SMTP/IMAP中开启服务并生成授权码
</div>
</div>
<div class="divider"></div>
@@ -816,6 +821,10 @@ function onProviderChange() {
smtpHost.value = "smtp.qq.com";
smtpPort.value = 465;
smtpSecure.value = true;
} else if (smtpProvider.value === "163") {
smtpHost.value = "smtp.163.com";
smtpPort.value = 465;
smtpSecure.value = true;
}
}
@@ -893,6 +902,8 @@ async function load() {
if (emailNotifyRes.smtp.host === "smtp.qq.com") {
smtpProvider.value = "qq";
} else if (emailNotifyRes.smtp.host === "smtp.163.com") {
smtpProvider.value = "163";
} else {
smtpProvider.value = "custom";
}

View File

@@ -5,8 +5,11 @@ type VisitOverview = {
totalPv: number;
totalPages: number;
todayPv: number;
yesterdayPv: number;
weekPv: number;
lastWeekPv: number;
monthPv: number;
lastMonthPv: number;
last30Days: {
date: string;
total: number;
@@ -101,9 +104,31 @@ export const getVisitOverview = async (
const monthStartDate = new Date(Date.UTC(year, month, 1));
const monthStartKey = toKey(monthStartDate);
// Calculate date ranges for last month and last week queries
const lastMonthStartDate = new Date(Date.UTC(year, month - 1, 1));
const lastMonthEndDate = new Date(monthStartDate.getTime() - 24 * 60 * 60 * 1000);
// For last week, we need the start date of last week.
// weekStartDate is the start of current week.
// So lastWeekStartDate is weekStartDate - 7 days.
const weekStartDate = (() => {
const d = new Date(Date.UTC(year, month, day));
const weekday = d.getUTCDay();
const offset = (weekday + 6) % 7;
return new Date(d.getTime() - offset * 24 * 60 * 60 * 1000);
})();
const lastWeekStartDate = new Date(weekStartDate.getTime() - 7 * 24 * 60 * 60 * 1000);
const lastWeekEndDate = new Date(weekStartDate.getTime() - 24 * 60 * 60 * 1000);
// We need to fetch enough history for last month.
// The earliest date could be either startDate30 or lastMonthStartDate.
let earliestDate = startDate30;
if (monthStartKey < earliestDate) {
earliestDate = monthStartKey;
if (toKey(lastMonthStartDate) < earliestDate) {
earliestDate = toKey(lastMonthStartDate);
}
if (toKey(lastWeekStartDate) < earliestDate) {
earliestDate = toKey(lastWeekStartDate);
}
let dailySql =
@@ -140,28 +165,39 @@ export const getVisitOverview = async (
}
const todayKey = toKey(now);
const yesterdayKey = toKey(new Date(now.getTime() - 24 * 60 * 60 * 1000));
const weekStartDate = (() => {
const d = new Date(Date.UTC(year, month, day));
const weekday = d.getUTCDay();
const offset = (weekday + 6) % 7;
return new Date(d.getTime() - offset * 24 * 60 * 60 * 1000);
})();
// weekStartDate is already calculated above
const weekStartKey = toKey(weekStartDate);
let todayPv = dailyMap.get(todayKey) || 0;
let yesterdayPv = dailyMap.get(yesterdayKey) || 0;
let weekPv = 0;
let lastWeekPv = 0;
let monthPv = 0;
let lastMonthPv = 0;
// Calculate Week PV
{
let cursor = new Date(weekStartDate.getTime());
while (cursor.getTime() <= now.getTime()) {
const key = toKey(cursor);
weekPv += dailyMap.get(key) || 0;
cursor = new Date(cursor.getTime() - 0 + 24 * 60 * 60 * 1000);
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
}
}
// Calculate Last Week PV
{
let cursor = new Date(lastWeekStartDate.getTime());
while (cursor.getTime() <= lastWeekEndDate.getTime()) {
const key = toKey(cursor);
lastWeekPv += dailyMap.get(key) || 0;
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
}
}
// Calculate Month PV
{
let cursor = new Date(monthStartDate.getTime());
while (cursor.getTime() <= now.getTime()) {
@@ -171,6 +207,16 @@ export const getVisitOverview = async (
}
}
// Calculate Last Month PV
{
let cursor = new Date(lastMonthStartDate.getTime());
while (cursor.getTime() <= lastMonthEndDate.getTime()) {
const key = toKey(cursor);
lastMonthPv += dailyMap.get(key) || 0;
cursor = new Date(cursor.getTime() + 24 * 60 * 60 * 1000);
}
}
if (todayPv > totalPv) {
todayPv = totalPv;
}
@@ -195,8 +241,11 @@ export const getVisitOverview = async (
totalPv,
totalPages,
todayPv,
yesterdayPv,
weekPv,
lastWeekPv,
monthPv,
lastMonthPv,
last30Days
};

View File

@@ -3,15 +3,33 @@
目前支持进行通知的邮箱有:
- QQ 邮箱
- 163 邮箱
- 自定义 SMTP
后续会添加其他邮箱服务,可以提交反馈。
## QQ 邮箱
## 邮箱服务配置
### QQ 邮箱
1. 登录 QQ 邮箱,进入 `设置 > 账户`
2. 开启 `POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务`,并获取授权码
3. 在管理后台设置中配置 QQ 邮箱账号和授权码
### 163 邮箱
1. 登录 163 邮箱,进入 `设置`
2. 开启 `POP3/SMTP/IMAP`,并获取授权码
3. 在管理后台设置中配置 163 邮箱账号和授权码
### 自定义 SMTP 邮箱
配置自己的 SMTP 邮箱需要以下信息:
- SMTP 服务器地址
- SMTP 服务器端口
- 发件邮箱账号
- 邮箱密码或授权码
## 邮件通知配置