- 修复Redis认证 (配置密码) - 启动Python管理后台 (端口9531, 15个功能开关) - 统一版本号 0.2.7 - 更新docker-compose.yml (镜像版本/Redis URL/Admin服务)
162 lines
5.8 KiB
TypeScript
Executable File
162 lines
5.8 KiB
TypeScript
Executable File
import { getDb } from '../database/database';
|
|
import { formatLocalDate } from '../utils/time';
|
|
|
|
export interface AdminStats {
|
|
todaySearches: number;
|
|
todaySaves: number;
|
|
monthSearches: number;
|
|
monthSaves: number;
|
|
totalSearches: number;
|
|
totalSaves: number;
|
|
hotKeywords: Array<{ keyword: string; count: number }>;
|
|
trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }>;
|
|
cloudUsage: Array<{
|
|
cloudType: string;
|
|
nickname: string;
|
|
storageUsed: string;
|
|
storageTotal: string;
|
|
isActive: boolean;
|
|
}>;
|
|
topIps: Array<{ ip: string; ip_location: string | null; count: number }>;
|
|
provinceRankings: Array<{ province: string; count: number }>;
|
|
}
|
|
|
|
/**
|
|
* Get today's date string in the configured timezone (e.g. "2026-05-04").
|
|
* Delegates to shared formatLocalDate() in utils/time.ts.
|
|
*/
|
|
function todayLocalDate(): string {
|
|
return formatLocalDate();
|
|
}
|
|
|
|
/**
|
|
* Get the first day of the current month in the configured timezone.
|
|
*/
|
|
function monthStartLocalDate(): string {
|
|
return todayLocalDate().slice(0, 7) + '-01';
|
|
}
|
|
|
|
export function getStats(trendDays: number = 7): AdminStats {
|
|
const db = getDb();
|
|
|
|
// Use local timezone date — NOT UTC via toISOString()
|
|
const today = todayLocalDate();
|
|
const monthStart = monthStartLocalDate();
|
|
|
|
// IMPORTANT: created_at is stored as "YYYY-MM-DDTHH:mm:ss+08:00" (localTimestamp)
|
|
// SQLite's date() function would interpret the +08:00 timezone offset and
|
|
// convert to UTC, giving wrong date. Instead, use SUBSTR to get first 10 chars.
|
|
const todaySearchesRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?"
|
|
).get(today) as any;
|
|
|
|
const todaySavesRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?"
|
|
).get(today) as any;
|
|
|
|
const monthSearchesRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) >= ?"
|
|
).get(monthStart) as any;
|
|
|
|
const monthSavesRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) >= ?"
|
|
).get(monthStart) as any;
|
|
|
|
// Total searches
|
|
const totalSearchesRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM search_stats"
|
|
).get() as any;
|
|
|
|
// Total saves
|
|
const totalSavesRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM save_records"
|
|
).get() as any;
|
|
|
|
// Hot keywords
|
|
const hotKeywords = db.prepare(
|
|
'SELECT keyword, search_count as count FROM hot_keywords ORDER BY search_count DESC LIMIT 20'
|
|
).all() as Array<{ keyword: string; count: number }>;
|
|
|
|
// Trend data (configurable days, default 7)
|
|
const trendLen = Math.min(Math.max(trendDays, 1), 90);
|
|
const trendTrend: Array<{ date: string; searches: number; saves: number; searchDelta: number; saveDelta: number }> = [];
|
|
for (let i = trendLen - 1; i >= 0; i--) {
|
|
const d = new Date();
|
|
const target = new Date(d.getTime() - i * 86400000);
|
|
const dateStr = formatLocalDate(target);
|
|
const searchRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM search_stats WHERE SUBSTR(created_at, 1, 10) = ?"
|
|
).get(dateStr) as any;
|
|
const saveRow = db.prepare(
|
|
"SELECT COUNT(*) as count FROM save_records WHERE SUBSTR(created_at, 1, 10) = ?"
|
|
).get(dateStr) as any;
|
|
trendTrend.push({
|
|
date: dateStr,
|
|
searches: searchRow?.count || 0,
|
|
saves: saveRow?.count || 0,
|
|
searchDelta: 0,
|
|
saveDelta: 0,
|
|
});
|
|
}
|
|
// Compute day-over-day delta (absolute change from previous day)
|
|
for (let i = trendTrend.length - 1; i > 0; i--) {
|
|
const prev = trendTrend[i - 1];
|
|
const curr = trendTrend[i];
|
|
curr.searchDelta = curr.searches - prev.searches;
|
|
curr.saveDelta = curr.saves - prev.saves;
|
|
}
|
|
|
|
// Cloud usage
|
|
const cloudUsage = db.prepare(
|
|
'SELECT cloud_type as cloudType, nickname, storage_used as storageUsed, storage_total as storageTotal, is_active as isActive FROM cloud_configs ORDER BY id ASC'
|
|
).all() as Array<{
|
|
cloudType: string;
|
|
nickname: string;
|
|
storageUsed: string;
|
|
storageTotal: string;
|
|
isActive: boolean;
|
|
}>;
|
|
|
|
// Top IPs from save_records — correctly count total per IP, then get latest location
|
|
const topIps = db.prepare(
|
|
`SELECT ip_address as ip, COUNT(*) as count,
|
|
(SELECT ip_location FROM save_records s2
|
|
WHERE s2.ip_address = s1.ip_address AND s2.ip_location IS NOT NULL AND s2.ip_location != ''
|
|
ORDER BY s2.created_at DESC LIMIT 1) as ip_location
|
|
FROM save_records s1
|
|
WHERE ip_address IS NOT NULL AND ip_address != ''
|
|
GROUP BY ip_address
|
|
ORDER BY count DESC LIMIT 10`
|
|
).all() as Array<{ ip: string; ip_location: string | null; count: number }>;
|
|
|
|
// Province rankings — extract province from ip_location (first segment)
|
|
let provinceRankings: Array<{ province: string; count: number }> = [];
|
|
const locRows = db.prepare(
|
|
`SELECT ip_location FROM save_records WHERE ip_location IS NOT NULL AND ip_location != ''`
|
|
).all() as Array<{ ip_location: string }>;
|
|
const provMap = new Map<string, number>();
|
|
for (const row of locRows) {
|
|
const parts = row.ip_location.trim().split(/\s+/);
|
|
const province = parts[0] || '未知';
|
|
provMap.set(province, (provMap.get(province) || 0) + 1);
|
|
}
|
|
provinceRankings = Array.from(provMap.entries())
|
|
.map(([province, count]) => ({ province, count }))
|
|
.sort((a, b) => b.count - a.count)
|
|
.slice(0, 15);
|
|
|
|
return {
|
|
todaySearches: (todaySearchesRow as any)?.count || 0,
|
|
todaySaves: (todaySavesRow as any)?.count || 0,
|
|
monthSearches: (monthSearchesRow as any)?.count || 0,
|
|
monthSaves: (monthSavesRow as any)?.count || 0,
|
|
totalSearches: (totalSearchesRow as any)?.count || 0,
|
|
totalSaves: (totalSavesRow as any)?.count || 0,
|
|
hotKeywords,
|
|
trendTrend,
|
|
cloudUsage,
|
|
topIps,
|
|
provinceRankings,
|
|
};
|
|
}
|