feat(数据分析): 添加域名过滤功能并持久化存储
- 在评论、统计和访问分析页面添加域名过滤功能 - 使用localStorage持久化存储用户选择的域名过滤条件 - 新增30天访问趋势图表展示 - 优化API接口支持按域名过滤数据 - 添加每日访问统计记录功能
This commit is contained in:
@@ -33,32 +33,40 @@ function extractDomain(source: string | null | undefined): string | null {
|
||||
|
||||
export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const summaryRow = await c.env.CWD_DB.prepare(
|
||||
"SELECT COUNT(*) as total, SUM(CASE WHEN status = 'approved' THEN 1 ELSE 0 END) as approved, SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END) as pending, SUM(CASE WHEN status = 'rejected' THEN 1 ELSE 0 END) as rejected FROM Comment"
|
||||
).first<{
|
||||
total: number | null;
|
||||
approved: number | null;
|
||||
pending: number | null;
|
||||
rejected: number | null;
|
||||
}>();
|
||||
|
||||
const summary: StatusCounts = {
|
||||
total: summaryRow?.total || 0,
|
||||
approved: summaryRow?.approved || 0,
|
||||
pending: summaryRow?.pending || 0,
|
||||
rejected: summaryRow?.rejected || 0
|
||||
};
|
||||
const rawDomain = c.req.query('domain') || '';
|
||||
const domainFilter = rawDomain.trim().toLowerCase();
|
||||
|
||||
const { results } = await c.env.CWD_DB.prepare(
|
||||
'SELECT post_slug, url, status FROM Comment'
|
||||
'SELECT created, post_slug, url, status FROM Comment'
|
||||
).all<{
|
||||
created: number;
|
||||
post_slug: string;
|
||||
url: string | null;
|
||||
status: string;
|
||||
}>();
|
||||
|
||||
const summaryAll: StatusCounts = {
|
||||
total: 0,
|
||||
approved: 0,
|
||||
pending: 0,
|
||||
rejected: 0
|
||||
};
|
||||
|
||||
const summaryFiltered: StatusCounts = {
|
||||
total: 0,
|
||||
approved: 0,
|
||||
pending: 0,
|
||||
rejected: 0
|
||||
};
|
||||
|
||||
const domainMap = new Map<string, StatusCounts>();
|
||||
|
||||
const dailyMapAll = new Map<string, number>();
|
||||
const dailyMapFiltered = new Map<string, number>();
|
||||
|
||||
const now = Date.now();
|
||||
const thirtyDaysAgo = now - 29 * 24 * 60 * 60 * 1000;
|
||||
|
||||
for (const row of results) {
|
||||
const domain =
|
||||
extractDomain(row.post_slug) || extractDomain(row.url) || 'unknown';
|
||||
@@ -81,6 +89,45 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
} else if (row.status === 'rejected') {
|
||||
counts.rejected += 1;
|
||||
}
|
||||
|
||||
summaryAll.total += 1;
|
||||
if (row.status === 'approved') {
|
||||
summaryAll.approved += 1;
|
||||
} else if (row.status === 'pending') {
|
||||
summaryAll.pending += 1;
|
||||
} else if (row.status === 'rejected') {
|
||||
summaryAll.rejected += 1;
|
||||
}
|
||||
|
||||
const matchesFilter = domainFilter && domain === domainFilter;
|
||||
|
||||
if (matchesFilter) {
|
||||
summaryFiltered.total += 1;
|
||||
if (row.status === 'approved') {
|
||||
summaryFiltered.approved += 1;
|
||||
} else if (row.status === 'pending') {
|
||||
summaryFiltered.pending += 1;
|
||||
} else if (row.status === 'rejected') {
|
||||
summaryFiltered.rejected += 1;
|
||||
}
|
||||
}
|
||||
|
||||
if (row.created >= thirtyDaysAgo) {
|
||||
const d = new Date(row.created);
|
||||
const year = d.getUTCFullYear();
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
const key = `${year}-${month}-${day}`;
|
||||
|
||||
dailyMapAll.set(key, (dailyMapAll.get(key) || 0) + 1);
|
||||
|
||||
if (matchesFilter) {
|
||||
dailyMapFiltered.set(
|
||||
key,
|
||||
(dailyMapFiltered.get(key) || 0) + 1
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const domains: DomainCounts[] = Array.from(domainMap.entries())
|
||||
@@ -93,21 +140,7 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
}))
|
||||
.sort((a, b) => b.total - a.total);
|
||||
|
||||
const now = Date.now();
|
||||
const thirtyDaysAgo = now - 29 * 24 * 60 * 60 * 1000;
|
||||
|
||||
const { results: dailyRows } = await c.env.CWD_DB.prepare(
|
||||
"SELECT date(created / 1000, 'unixepoch') as day, COUNT(*) as total FROM Comment WHERE created >= ? GROUP BY day ORDER BY day ASC"
|
||||
)
|
||||
.bind(thirtyDaysAgo)
|
||||
.all<{ day: string; total: number }>();
|
||||
|
||||
const dailyMap = new Map<string, number>();
|
||||
for (const row of dailyRows) {
|
||||
if (row && row.day) {
|
||||
dailyMap.set(row.day, row.total || 0);
|
||||
}
|
||||
}
|
||||
const dailyMap = domainFilter ? dailyMapFiltered : dailyMapAll;
|
||||
|
||||
const last7Days: { date: string; total: number }[] = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
@@ -122,6 +155,8 @@ export const getStats = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
});
|
||||
}
|
||||
|
||||
const summary = domainFilter ? summaryFiltered : summaryAll;
|
||||
|
||||
return c.json({
|
||||
summary,
|
||||
domains,
|
||||
|
||||
@@ -4,6 +4,10 @@ import type { Bindings } from '../../bindings';
|
||||
type VisitOverview = {
|
||||
totalPv: number;
|
||||
totalPages: number;
|
||||
last30Days: {
|
||||
date: string;
|
||||
total: number;
|
||||
}[];
|
||||
};
|
||||
|
||||
type VisitPageItem = {
|
||||
@@ -14,24 +18,116 @@ type VisitPageItem = {
|
||||
lastVisitAt: string | null;
|
||||
};
|
||||
|
||||
function extractDomain(source: string | null | undefined): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
const value = source.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const getVisitOverview = async (
|
||||
c: Context<{ Bindings: Bindings }>
|
||||
) => {
|
||||
try {
|
||||
const rawDomain = c.req.query('domain') || '';
|
||||
const domainFilter = rawDomain.trim().toLowerCase();
|
||||
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
const row = await c.env.CWD_DB.prepare(
|
||||
'SELECT COUNT(*) as totalPages, COALESCE(SUM(pv), 0) as totalPv FROM page_stats'
|
||||
).first<{
|
||||
totalPages: number | null;
|
||||
totalPv: number | null;
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS page_visit_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, domain TEXT, count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
const { results } = await c.env.CWD_DB.prepare(
|
||||
'SELECT post_slug, post_title, post_url, pv, last_visit_at FROM page_stats'
|
||||
).all<{
|
||||
post_slug: string;
|
||||
post_title: string | null;
|
||||
post_url: string | null;
|
||||
pv: number;
|
||||
last_visit_at: string | null;
|
||||
}>();
|
||||
|
||||
let totalPv = 0;
|
||||
let totalPages = 0;
|
||||
|
||||
for (const row of results) {
|
||||
const domain =
|
||||
extractDomain(row.post_url) ||
|
||||
extractDomain(row.post_slug) ||
|
||||
null;
|
||||
|
||||
if (domainFilter && domain !== domainFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
totalPv += row.pv || 0;
|
||||
totalPages += 1;
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
const thirtyDaysAgo = new Date(now.getTime() - 29 * 24 * 60 * 60 * 1000);
|
||||
const startDate = thirtyDaysAgo.toISOString().slice(0, 10);
|
||||
|
||||
let dailySql =
|
||||
'SELECT date, domain, count FROM page_visit_daily WHERE date >= ?';
|
||||
const params: string[] = [startDate];
|
||||
|
||||
if (domainFilter) {
|
||||
dailySql += ' AND domain = ?';
|
||||
params.push(domainFilter);
|
||||
}
|
||||
|
||||
const { results: dailyRows } = await c.env.CWD_DB.prepare(dailySql)
|
||||
.bind(...params)
|
||||
.all<{
|
||||
date: string;
|
||||
domain: string | null;
|
||||
count: number;
|
||||
}>();
|
||||
|
||||
const dailyMap = new Map<string, number>();
|
||||
|
||||
for (const row of dailyRows) {
|
||||
if (!row || !row.date) {
|
||||
continue;
|
||||
}
|
||||
const key = row.date;
|
||||
const value = row.count || 0;
|
||||
dailyMap.set(key, (dailyMap.get(key) || 0) + value);
|
||||
}
|
||||
|
||||
const last30Days: { date: string; total: number }[] = [];
|
||||
for (let i = 29; i >= 0; i--) {
|
||||
const d = new Date(now.getTime() - i * 24 * 60 * 60 * 1000);
|
||||
const year = d.getUTCFullYear();
|
||||
const month = String(d.getUTCMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getUTCDate()).padStart(2, '0');
|
||||
const key = `${year}-${month}-${day}`;
|
||||
last30Days.push({
|
||||
date: key,
|
||||
total: dailyMap.get(key) || 0
|
||||
});
|
||||
}
|
||||
|
||||
const data: VisitOverview = {
|
||||
totalPv: row?.totalPv || 0,
|
||||
totalPages: row?.totalPages || 0
|
||||
totalPv,
|
||||
totalPages,
|
||||
last30Days
|
||||
};
|
||||
|
||||
return c.json(data);
|
||||
@@ -45,6 +141,9 @@ export const getVisitOverview = async (
|
||||
|
||||
export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const rawDomain = c.req.query('domain') || '';
|
||||
const domainFilter = rawDomain.trim().toLowerCase();
|
||||
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
|
||||
).run();
|
||||
@@ -59,13 +158,26 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
last_visit_at: string | null;
|
||||
}>();
|
||||
|
||||
const items: VisitPageItem[] = results.map((row) => ({
|
||||
postSlug: row.post_slug,
|
||||
postTitle: row.post_title,
|
||||
postUrl: row.post_url,
|
||||
pv: row.pv || 0,
|
||||
lastVisitAt: row.last_visit_at
|
||||
}));
|
||||
const items: VisitPageItem[] = [];
|
||||
|
||||
for (const row of results) {
|
||||
const domain =
|
||||
extractDomain(row.post_url) ||
|
||||
extractDomain(row.post_slug) ||
|
||||
null;
|
||||
|
||||
if (domainFilter && domain !== domainFilter) {
|
||||
continue;
|
||||
}
|
||||
|
||||
items.push({
|
||||
postSlug: row.post_slug,
|
||||
postTitle: row.post_title,
|
||||
postUrl: row.post_url,
|
||||
pv: row.pv || 0,
|
||||
lastVisitAt: row.last_visit_at
|
||||
});
|
||||
}
|
||||
|
||||
return c.json({ items });
|
||||
} catch (e: any) {
|
||||
@@ -75,4 +187,3 @@ export const getVisitPages = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -7,6 +7,25 @@ type TrackVisitBody = {
|
||||
postUrl?: string;
|
||||
};
|
||||
|
||||
function extractDomain(source: string | null | undefined): string | null {
|
||||
if (!source) {
|
||||
return null;
|
||||
}
|
||||
const value = source.trim();
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
if (!/^https?:\/\//i.test(value)) {
|
||||
return null;
|
||||
}
|
||||
try {
|
||||
const url = new URL(value);
|
||||
return url.hostname.toLowerCase();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
try {
|
||||
const body = (await c.req.json().catch(() => ({}))) as TrackVisitBody;
|
||||
@@ -23,7 +42,18 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
'CREATE TABLE IF NOT EXISTS page_stats (id INTEGER PRIMARY KEY AUTOINCREMENT, post_slug TEXT UNIQUE NOT NULL, post_title TEXT, post_url TEXT, pv INTEGER NOT NULL DEFAULT 0, last_visit_at TEXT, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
const now = new Date().toISOString();
|
||||
await c.env.CWD_DB.prepare(
|
||||
'CREATE TABLE IF NOT EXISTS page_visit_daily (id INTEGER PRIMARY KEY AUTOINCREMENT, date TEXT NOT NULL, domain TEXT, count INTEGER NOT NULL DEFAULT 0, created_at TEXT NOT NULL, updated_at TEXT NOT NULL)'
|
||||
).run();
|
||||
|
||||
const nowDate = new Date();
|
||||
const nowIso = nowDate.toISOString();
|
||||
const today = nowIso.slice(0, 10);
|
||||
|
||||
const domain =
|
||||
extractDomain(rawPostUrl) ||
|
||||
extractDomain(rawPostSlug) ||
|
||||
null;
|
||||
|
||||
const existing = await c.env.CWD_DB.prepare(
|
||||
'SELECT id, pv FROM page_stats WHERE post_slug = ?'
|
||||
@@ -40,9 +70,9 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
rawPostTitle || null,
|
||||
rawPostUrl || null,
|
||||
1,
|
||||
now,
|
||||
now,
|
||||
now
|
||||
nowIso,
|
||||
nowIso,
|
||||
nowIso
|
||||
)
|
||||
.run();
|
||||
} else {
|
||||
@@ -54,13 +84,49 @@ export const trackVisit = async (c: Context<{ Bindings: Bindings }>) => {
|
||||
rawPostTitle || null,
|
||||
rawPostUrl || null,
|
||||
newPv,
|
||||
now,
|
||||
now,
|
||||
nowIso,
|
||||
nowIso,
|
||||
existing.id
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
let dailyRow:
|
||||
| {
|
||||
id: number;
|
||||
count: number;
|
||||
}
|
||||
| null = null;
|
||||
|
||||
if (domain) {
|
||||
dailyRow = await c.env.CWD_DB.prepare(
|
||||
'SELECT id, count FROM page_visit_daily WHERE date = ? AND domain = ?'
|
||||
)
|
||||
.bind(today, domain)
|
||||
.first<{ id: number; count: number }>();
|
||||
} else {
|
||||
dailyRow = await c.env.CWD_DB.prepare(
|
||||
'SELECT id, count FROM page_visit_daily WHERE date = ? AND domain IS NULL'
|
||||
)
|
||||
.bind(today)
|
||||
.first<{ id: number; count: number }>();
|
||||
}
|
||||
|
||||
if (!dailyRow) {
|
||||
await c.env.CWD_DB.prepare(
|
||||
'INSERT INTO page_visit_daily (date, domain, count, created_at, updated_at) VALUES (?, ?, ?, ?, ?)'
|
||||
)
|
||||
.bind(today, domain, 1, nowIso, nowIso)
|
||||
.run();
|
||||
} else {
|
||||
const newCount = (dailyRow.count || 0) + 1;
|
||||
await c.env.CWD_DB.prepare(
|
||||
'UPDATE page_visit_daily SET count = ?, updated_at = ? WHERE id = ?'
|
||||
)
|
||||
.bind(newCount, nowIso, dailyRow.id)
|
||||
.run();
|
||||
}
|
||||
|
||||
return c.json({ success: true });
|
||||
} catch (e: any) {
|
||||
return c.json({ message: e.message || '记录访问数据失败' }, 500);
|
||||
|
||||
Reference in New Issue
Block a user