feat(analytics): 在访问统计中增加同比趋势显示
- 在访问概览 API 中新增昨日、上周、上月 PV 字段,用于计算趋势 - 在前端统计卡片中显示今日、本周、本月访问量的同比变化百分比 - 支持 163 邮箱作为邮件通知服务提供商,更新相关文档和设置界面
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
: [],
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
|
||||
@@ -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 服务器端口
|
||||
- 发件邮箱账号
|
||||
- 邮箱密码或授权码
|
||||
|
||||
## 邮件通知配置
|
||||
|
||||
|
||||
Reference in New Issue
Block a user