feat(analytics): 在访问统计中增加同比趋势显示
- 在访问概览 API 中新增昨日、上周、上月 PV 字段,用于计算趋势 - 在前端统计卡片中显示今日、本周、本月访问量的同比变化百分比 - 支持 163 邮箱作为邮件通知服务提供商,更新相关文档和设置界面
This commit is contained in:
@@ -88,8 +88,11 @@ export type VisitOverviewResponse = {
|
|||||||
totalPv: number;
|
totalPv: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
todayPv: number;
|
todayPv: number;
|
||||||
|
yesterdayPv?: number;
|
||||||
weekPv: number;
|
weekPv: number;
|
||||||
|
lastWeekPv?: number;
|
||||||
monthPv: number;
|
monthPv: number;
|
||||||
|
lastMonthPv?: number;
|
||||||
last30Days?: {
|
last30Days?: {
|
||||||
date: string;
|
date: string;
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -44,6 +44,29 @@
|
|||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: var(--text-primary);
|
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 {
|
.domain-table-wrapper {
|
||||||
@@ -191,4 +214,4 @@
|
|||||||
.stats-grid {
|
.stats-grid {
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,15 +25,48 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="stats-item">
|
<div class="stats-item">
|
||||||
<div class="stats-label">今日访问量</div>
|
<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>
|
||||||
<div class="stats-item">
|
<div class="stats-item">
|
||||||
<div class="stats-label">本周访问量</div>
|
<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>
|
||||||
<div class="stats-item">
|
<div class="stats-item">
|
||||||
<div class="stats-label">本月访问量</div>
|
<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>
|
||||||
<div class="stats-item">
|
<div class="stats-item">
|
||||||
<div class="stats-label">有访问记录的页面数</div>
|
<div class="stats-label">有访问记录的页面数</div>
|
||||||
@@ -198,11 +231,33 @@ const overview = ref<VisitOverviewResponse>({
|
|||||||
totalPv: 0,
|
totalPv: 0,
|
||||||
totalPages: 0,
|
totalPages: 0,
|
||||||
todayPv: 0,
|
todayPv: 0,
|
||||||
|
yesterdayPv: 0,
|
||||||
weekPv: 0,
|
weekPv: 0,
|
||||||
|
lastWeekPv: 0,
|
||||||
monthPv: 0,
|
monthPv: 0,
|
||||||
|
lastMonthPv: 0,
|
||||||
last30Days: [],
|
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 rawItems = ref<VisitPageItem[]>([]);
|
||||||
const items = computed<VisitPageItem[]>(() => {
|
const items = computed<VisitPageItem[]>(() => {
|
||||||
const list = rawItems.value.slice();
|
const list = rawItems.value.slice();
|
||||||
@@ -390,8 +445,11 @@ async function loadData() {
|
|||||||
totalPv: overviewRes.totalPv,
|
totalPv: overviewRes.totalPv,
|
||||||
totalPages: overviewRes.totalPages,
|
totalPages: overviewRes.totalPages,
|
||||||
todayPv: overviewRes.todayPv ?? 0,
|
todayPv: overviewRes.todayPv ?? 0,
|
||||||
|
yesterdayPv: overviewRes.yesterdayPv ?? 0,
|
||||||
weekPv: overviewRes.weekPv ?? 0,
|
weekPv: overviewRes.weekPv ?? 0,
|
||||||
|
lastWeekPv: overviewRes.lastWeekPv ?? 0,
|
||||||
monthPv: overviewRes.monthPv ?? 0,
|
monthPv: overviewRes.monthPv ?? 0,
|
||||||
|
lastMonthPv: overviewRes.lastMonthPv ?? 0,
|
||||||
last30Days: Array.isArray(overviewRes.last30Days)
|
last30Days: Array.isArray(overviewRes.last30Days)
|
||||||
? overviewRes.last30Days
|
? overviewRes.last30Days
|
||||||
: [],
|
: [],
|
||||||
|
|||||||
@@ -305,6 +305,7 @@
|
|||||||
@change="onProviderChange"
|
@change="onProviderChange"
|
||||||
>
|
>
|
||||||
<option value="qq">QQ 邮箱</option>
|
<option value="qq">QQ 邮箱</option>
|
||||||
|
<option value="163">163 邮箱</option>
|
||||||
<option value="custom">自定义 SMTP</option>
|
<option value="custom">自定义 SMTP</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
@@ -361,6 +362,10 @@
|
|||||||
请登录 QQ 邮箱网页版,在【设置 - 账户】中开启 POP3/SMTP
|
请登录 QQ 邮箱网页版,在【设置 - 账户】中开启 POP3/SMTP
|
||||||
服务并生成授权码。
|
服务并生成授权码。
|
||||||
</div>
|
</div>
|
||||||
|
<div v-else-if="smtpProvider === '163'" class="form-hint">
|
||||||
|
注意:163 邮箱必须使用授权码,而非登录密码。<br />
|
||||||
|
请登录 163 邮箱网页版,在【设置 - POP3/SMTP/IMAP】中开启服务并生成授权码。
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="divider"></div>
|
<div class="divider"></div>
|
||||||
@@ -816,6 +821,10 @@ function onProviderChange() {
|
|||||||
smtpHost.value = "smtp.qq.com";
|
smtpHost.value = "smtp.qq.com";
|
||||||
smtpPort.value = 465;
|
smtpPort.value = 465;
|
||||||
smtpSecure.value = true;
|
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") {
|
if (emailNotifyRes.smtp.host === "smtp.qq.com") {
|
||||||
smtpProvider.value = "qq";
|
smtpProvider.value = "qq";
|
||||||
|
} else if (emailNotifyRes.smtp.host === "smtp.163.com") {
|
||||||
|
smtpProvider.value = "163";
|
||||||
} else {
|
} else {
|
||||||
smtpProvider.value = "custom";
|
smtpProvider.value = "custom";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,8 +5,11 @@ type VisitOverview = {
|
|||||||
totalPv: number;
|
totalPv: number;
|
||||||
totalPages: number;
|
totalPages: number;
|
||||||
todayPv: number;
|
todayPv: number;
|
||||||
|
yesterdayPv: number;
|
||||||
weekPv: number;
|
weekPv: number;
|
||||||
|
lastWeekPv: number;
|
||||||
monthPv: number;
|
monthPv: number;
|
||||||
|
lastMonthPv: number;
|
||||||
last30Days: {
|
last30Days: {
|
||||||
date: string;
|
date: string;
|
||||||
total: number;
|
total: number;
|
||||||
@@ -101,9 +104,31 @@ export const getVisitOverview = async (
|
|||||||
const monthStartDate = new Date(Date.UTC(year, month, 1));
|
const monthStartDate = new Date(Date.UTC(year, month, 1));
|
||||||
const monthStartKey = toKey(monthStartDate);
|
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;
|
let earliestDate = startDate30;
|
||||||
if (monthStartKey < earliestDate) {
|
if (toKey(lastMonthStartDate) < earliestDate) {
|
||||||
earliestDate = monthStartKey;
|
earliestDate = toKey(lastMonthStartDate);
|
||||||
|
}
|
||||||
|
if (toKey(lastWeekStartDate) < earliestDate) {
|
||||||
|
earliestDate = toKey(lastWeekStartDate);
|
||||||
}
|
}
|
||||||
|
|
||||||
let dailySql =
|
let dailySql =
|
||||||
@@ -140,28 +165,39 @@ export const getVisitOverview = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const todayKey = toKey(now);
|
const todayKey = toKey(now);
|
||||||
|
const yesterdayKey = toKey(new Date(now.getTime() - 24 * 60 * 60 * 1000));
|
||||||
|
|
||||||
const weekStartDate = (() => {
|
// weekStartDate is already calculated above
|
||||||
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 weekStartKey = toKey(weekStartDate);
|
const weekStartKey = toKey(weekStartDate);
|
||||||
|
|
||||||
let todayPv = dailyMap.get(todayKey) || 0;
|
let todayPv = dailyMap.get(todayKey) || 0;
|
||||||
|
let yesterdayPv = dailyMap.get(yesterdayKey) || 0;
|
||||||
let weekPv = 0;
|
let weekPv = 0;
|
||||||
|
let lastWeekPv = 0;
|
||||||
let monthPv = 0;
|
let monthPv = 0;
|
||||||
|
let lastMonthPv = 0;
|
||||||
|
|
||||||
|
// Calculate Week PV
|
||||||
{
|
{
|
||||||
let cursor = new Date(weekStartDate.getTime());
|
let cursor = new Date(weekStartDate.getTime());
|
||||||
while (cursor.getTime() <= now.getTime()) {
|
while (cursor.getTime() <= now.getTime()) {
|
||||||
const key = toKey(cursor);
|
const key = toKey(cursor);
|
||||||
weekPv += dailyMap.get(key) || 0;
|
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());
|
let cursor = new Date(monthStartDate.getTime());
|
||||||
while (cursor.getTime() <= now.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) {
|
if (todayPv > totalPv) {
|
||||||
todayPv = totalPv;
|
todayPv = totalPv;
|
||||||
}
|
}
|
||||||
@@ -195,8 +241,11 @@ export const getVisitOverview = async (
|
|||||||
totalPv,
|
totalPv,
|
||||||
totalPages,
|
totalPages,
|
||||||
todayPv,
|
todayPv,
|
||||||
|
yesterdayPv,
|
||||||
weekPv,
|
weekPv,
|
||||||
|
lastWeekPv,
|
||||||
monthPv,
|
monthPv,
|
||||||
|
lastMonthPv,
|
||||||
last30Days
|
last30Days
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,15 +3,33 @@
|
|||||||
目前支持进行通知的邮箱有:
|
目前支持进行通知的邮箱有:
|
||||||
|
|
||||||
- QQ 邮箱
|
- QQ 邮箱
|
||||||
|
- 163 邮箱
|
||||||
|
- 自定义 SMTP
|
||||||
|
|
||||||
后续会添加其他邮箱服务,可以提交反馈。
|
后续会添加其他邮箱服务,可以提交反馈。
|
||||||
|
|
||||||
## QQ 邮箱
|
## 邮箱服务配置
|
||||||
|
|
||||||
|
### QQ 邮箱
|
||||||
|
|
||||||
1. 登录 QQ 邮箱,进入 `设置 > 账户`
|
1. 登录 QQ 邮箱,进入 `设置 > 账户`
|
||||||
2. 开启 `POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务`,并获取授权码
|
2. 开启 `POP3/IMAP/SMTP/Exchange/CardDAV/CalDAV 服务`,并获取授权码
|
||||||
3. 在管理后台设置中配置 QQ 邮箱账号和授权码
|
3. 在管理后台设置中配置 QQ 邮箱账号和授权码
|
||||||
|
|
||||||
|
### 163 邮箱
|
||||||
|
|
||||||
|
1. 登录 163 邮箱,进入 `设置`
|
||||||
|
2. 开启 `POP3/SMTP/IMAP`,并获取授权码
|
||||||
|
3. 在管理后台设置中配置 163 邮箱账号和授权码
|
||||||
|
|
||||||
|
### 自定义 SMTP 邮箱
|
||||||
|
|
||||||
|
配置自己的 SMTP 邮箱需要以下信息:
|
||||||
|
|
||||||
|
- SMTP 服务器地址
|
||||||
|
- SMTP 服务器端口
|
||||||
|
- 发件邮箱账号
|
||||||
|
- 邮箱密码或授权码
|
||||||
|
|
||||||
## 邮件通知配置
|
## 邮件通知配置
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user