feat: 管理端网站监控,浏览量写入独立 monitor.db

请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-31 07:55:45 +08:00
parent 16b6a18096
commit 429956c594
54 changed files with 5504 additions and 146 deletions

View File

@@ -13,6 +13,7 @@ import UserLink from './UserLink';
import ArticleOutline from './ArticleOutline';
import PostAuthorCard from './PostAuthorCard';
import AsideCheckInStrip from './AsideCheckInStrip';
import ShowcaseAsideWidget from './ShowcaseAsideWidget';
export type PostDetailAside = {
author?: User | null;
@@ -114,6 +115,8 @@ export default function RightPanel({
const renderWidget = (widget: AsideWidget) => {
switch (widget.id) {
case 'showcase':
return <ShowcaseAsideWidget key="showcase" />;
case 'friend_links':
return (
<div key="friend_links" className="widget-card widget-card--friend-links">

View File

@@ -0,0 +1,72 @@
import { useEffect, useState } from 'react';
import { Globe2 } from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import type { CommunityShowcaseItem } from '../api/types';
/** 右侧栏:开源展柜精简列表 */
export default function ShowcaseAsideWidget() {
const nav = useNavigate();
const [items, setItems] = useState<CommunityShowcaseItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
let cancelled = false;
api.communityShowcase()
.then((r) => {
if (!cancelled) setItems(Array.isArray(r.items) ? r.items : []);
})
.catch(() => {
if (!cancelled) setItems([]);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => { cancelled = true; };
}, []);
return (
<div className="widget-card widget-card--showcase">
<div className="widget-card-head widget-card-head--split">
<span className="widget-card-head-main">
<Globe2 className="widget-card-icon widget-card-icon--showcase" aria-hidden />
<button type="button" className="widget-friend-links-title" onClick={() => nav('/showcase')}>
</button>
</span>
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
</button>
</div>
<div className="widget-card-body widget-card-body--friend-links">
{loading ? (
<div className="widget-empty"></div>
) : items.length === 0 ? (
<div className="widget-empty">
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
</button>
</div>
) : (
<>
<ul className="widget-friend-links-list">
{items.slice(0, 8).map((item) => (
<li key={item.site_url}>
<a href={item.site_url} target="_blank" rel="noopener noreferrer" title={item.site_name || item.site_url}>
{item.site_name || '未命名站点'}
</a>
</li>
))}
</ul>
{items.length > 8 && (
<button type="button" className="widget-friend-links-more" onClick={() => nav('/showcase')}>
{items.length}
</button>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import {
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2,
Home, Star, LayoutDashboard, FolderGit2, FolderKanban, ArrowLeft, FileText, Link2, Globe2,
} from 'lucide-react';
import { useNavigate, useLocation, useSearchParams, Link } from 'react-router-dom';
import type { Board } from '../api/types';
@@ -30,6 +30,7 @@ function resolveMenuKey(pathname: string, activeBoard: number, keyword = ''): st
if (pathname.startsWith('/favorites')) return 'favorites';
if (pathname.startsWith('/projects')) return 'projects';
if (pathname.startsWith('/links')) return 'links';
if (pathname.startsWith('/showcase')) return 'showcase';
if (pathname.startsWith('/page/')) return 'pages';
if (pathname.startsWith('/admin')) return 'admin';
return activeBoard === 0 ? 'all' : String(activeBoard);
@@ -67,7 +68,8 @@ export default function Sidebar({
const { navPages } = useSitePages();
const { limits } = useForumLimits();
const showFriendLinksNav = limits.nav_show_friend_links !== false;
const showSiteSection = navPages.length > 0 || showFriendLinksNav;
const showShowcaseNav = !!limits.nav_show_showcase;
const showSiteSection = navPages.length > 0 || showFriendLinksNav || showShowcaseNav;
const keyword = params.get('keyword') || '';
const menuKey = resolveMenuKey(loc.pathname, activeBoard, keyword);
@@ -197,6 +199,7 @@ export default function Sidebar({
<div className="sidebar-section sidebar-section--spaced"></div>
<nav className="sidebar-nav">
{showFriendLinksNav && navItem('links', '友情链接', <Link2 aria-hidden />, () => nav('/links'))}
{showShowcaseNav && navItem('showcase', '开源展柜', <Globe2 aria-hidden />, () => nav('/showcase'))}
{navPages.map(p => (
navItem(`page-${p.slug}`, p.title, <FileText aria-hidden />, () => nav(pagePath(p.slug, limits)))
))}

View File

@@ -9,7 +9,7 @@ function FooterSep() {
return <span className="site-footer__sep" aria-hidden>·</span>;
}
/** 站点页脚:版权、Sitemap、备案号 */
/** 站点页脚:版权、友链/展柜入口、单页、备案号 */
export default function SiteFooter() {
const { branding } = useSiteBranding();
const { footerPages } = useSitePages();
@@ -17,6 +17,10 @@ export default function SiteFooter() {
const year = new Date().getFullYear();
const icp = branding.icp_beian?.trim() || '';
const icpURL = branding.icp_beian_url?.trim() || 'https://beian.miit.gov.cn/';
const showFriendLinks = limits.footer_show_friend_links !== false;
const showShowcase = !!limits.footer_show_showcase;
const hasNavBeforePages = showFriendLinks || showShowcase;
const hasNavBeforeIcp = hasNavBeforePages || footerPages.length > 0;
return (
<footer className="site-footer">
@@ -34,20 +38,26 @@ export default function SiteFooter() {
</div>
<nav className="site-footer__nav" aria-label="站点链接">
{limits.footer_show_friend_links !== false && (
{showFriendLinks && (
<span className="site-footer__friend">
<Link to="/links"></Link>
</span>
)}
{showShowcase && (
<span className="site-footer__friend">
{showFriendLinks && <FooterSep />}
<Link to="/showcase"></Link>
</span>
)}
{footerPages.map((p, i) => (
<span key={p.slug} className="site-footer__friend">
{(limits.footer_show_friend_links !== false || i > 0) && <FooterSep />}
{(hasNavBeforePages || i > 0) && <FooterSep />}
<Link to={pagePath(p.slug, limits)}>{p.title}</Link>
</span>
))}
{icp && (
<>
{(limits.footer_show_friend_links !== false || footerPages.length > 0) && <FooterSep />}
{hasNavBeforeIcp && <FooterSep />}
<a
href={icpURL}
target="_blank"

View File

@@ -18,6 +18,10 @@ const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
label: '友情链接',
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
},
showcase: {
label: '开源展柜',
hint: '展示精选公网部署;关闭后仍可直接访问 /showcase',
},
};
type Props = {

View File

@@ -12,7 +12,7 @@ const EMPTY_COMMUNITY: CommunityConfig = {
instance_id: '',
};
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存) */
/** 仪表盘页脚:自愿社区上报开关(默认关,即时保存;枢纽站不显示 */
export default function CommunitySupportStrip() {
const [community, setCommunity] = useState<CommunityConfig>(EMPTY_COMMUNITY);
const [saving, setSaving] = useState(false);
@@ -58,6 +58,11 @@ export default function CommunitySupportStrip() {
}
};
// 官网 / 枢纽站本身就是收报方,无需「支持开源」上报条
if (!ready || community.hub_enabled) {
return null;
}
return (
<div className="admin-community-support-strip" role="group" aria-label="支持姜十三开源">
<div className="admin-community-support-strip-main">

View File

@@ -0,0 +1,72 @@
import { useMemo, useState } from 'react';
import china from '@svg-maps/china';
import { cn } from '@/lib/utils';
import { buildChinaRegionCountMap, heatFill, emptyFill } from './monitorMapUtils';
type Loc = { id: string; name: string; path: string };
type RegionStat = {
country: string;
region?: string;
region_iso?: string;
count: number;
};
type Props = {
regions: RegionStat[];
className?: string;
};
/** 中国省区地图:按 BIN 解析出的省/区填色 */
export default function MonitorChinaMap({ regions, className }: Props) {
const [hover, setHover] = useState<{ name: string; count: number } | null>(null);
const counts = useMemo(() => buildChinaRegionCountMap(regions), [regions]);
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
const hasData = max > 0;
const locations = (china as { locations: Loc[]; viewBox: string }).locations;
const viewBox = (china as { viewBox: string }).viewBox;
return (
<div className={cn('admin-monitor-svg-wrap', className)}>
<svg
className="admin-monitor-svg-map"
viewBox={viewBox}
role="img"
aria-label="中国访客地图"
>
{locations.map((loc) => {
const count = counts[loc.id] || 0;
return (
<path
key={loc.id}
d={loc.path}
fill={heatFill(count, max, hasData)}
stroke="hsl(var(--background, 0 0% 100%))"
strokeWidth={0.6}
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
onMouseEnter={() => setHover({ name: loc.name, count })}
onMouseLeave={() => setHover(null)}
>
<title>
{loc.name}
{count > 0 ? ` · ${count}` : ''}
</title>
</path>
);
})}
</svg>
{hover && (
<div className="admin-monitor-map-tip" role="status">
<strong>{hover.name}</strong>
<span>{hover.count > 0 ? hover.count : '—'}</span>
</div>
)}
{!hasData && (
<div className="admin-monitor-map-empty-overlay">
<p>访</p>
<p> IP2LOCATION-LITE-DB3.BIN /</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,121 @@
import { useMemo, useState } from 'react';
import world from '@svg-maps/world';
import { cn } from '@/lib/utils';
import {
buildCountMap,
countryLabelZh,
emptyFill,
heatFill,
type GeoCountMap,
} from './monitorMapUtils';
type Loc = { id: string; name: string; path: string };
type Props = {
items: { country: string; count: number }[];
className?: string;
/** 裁切到东亚(中国模式) */
focusChina?: boolean;
};
const CHINA_VIEWBOX = '680 260 220 200';
export default function MonitorWorldMap({ items, className, focusChina }: Props) {
const [hover, setHover] = useState<{ code: string; name: string; count: number } | null>(null);
const counts = useMemo(() => buildCountMap(items), [items]);
const max = useMemo(() => Math.max(0, ...Object.values(counts)), [counts]);
const hasData = max > 0;
const locations = (world as { locations: Loc[]; viewBox: string }).locations;
const viewBox = focusChina ? CHINA_VIEWBOX : (world as { viewBox: string }).viewBox;
const filteredCounts: GeoCountMap = useMemo(() => {
if (!focusChina) return counts;
const allow = new Set(['CN', 'HK', 'MO', 'TW']);
const m: GeoCountMap = {};
for (const [k, v] of Object.entries(counts)) {
if (allow.has(k)) m[k] = v;
}
return m;
}, [counts, focusChina]);
const focusMax = useMemo(
() => Math.max(0, ...Object.values(filteredCounts)),
[filteredCounts],
);
const focusHas = focusMax > 0;
return (
<div className={cn('admin-monitor-svg-wrap', className)}>
<svg
className="admin-monitor-svg-map"
viewBox={viewBox}
role="img"
aria-label={focusChina ? '中国及周边访客地图' : '世界访客地图'}
>
{locations.map((loc) => {
const code = loc.id.toUpperCase();
if (focusChina) {
// 中国模式下非本区域保持极淡灰,本区域按数据着色
const inRegion = code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW'
|| code === 'JP' || code === 'KR' || code === 'MN' || code === 'KP'
|| code === 'RU' || code === 'IN' || code === 'VN' || code === 'LA'
|| code === 'MM' || code === 'BT' || code === 'NP' || code === 'KZ'
|| code === 'KG' || code === 'TJ' || code === 'UZ' || code === 'AF'
|| code === 'PK' || code === 'PH' || code === 'MY' || code === 'TH'
|| code === 'KH' || code === 'BD' || code === 'LK';
if (!inRegion) return null;
}
const count = filteredCounts[code] || (focusChina ? 0 : counts[code]) || 0;
const useMax = focusChina ? focusMax : max;
const useHas = focusChina ? focusHas : hasData;
const isCore = !focusChina || code === 'CN' || code === 'HK' || code === 'MO' || code === 'TW';
const fill = isCore
? heatFill(count, useMax, useHas)
: emptyFill(useHas);
return (
<path
key={loc.id}
d={loc.path}
data-code={code}
fill={fill}
stroke="hsl(var(--background, 0 0% 100%))"
strokeWidth={focusChina ? 0.35 : 0.4}
className={cn('admin-monitor-svg-path', count > 0 && 'has-data')}
onMouseEnter={() => setHover({
code,
name: countryLabelZh(code, loc.name),
count: focusChina && !isCore ? 0 : count,
})}
onMouseLeave={() => setHover(null)}
>
<title>
{countryLabelZh(code, loc.name)}
{count > 0 ? ` · ${count}` : ''}
</title>
</path>
);
})}
</svg>
{hover && (
<div className="admin-monitor-map-tip" role="status">
<strong>{hover.name}</strong>
<span>{hover.count > 0 ? hover.count : '—'}</span>
</div>
)}
{!hasData && !focusChina && (
<div className="admin-monitor-map-empty-overlay">
<p></p>
<p> GeoIP CDN </p>
</div>
)}
{focusChina && !focusHas && (
<div className="admin-monitor-map-empty-overlay">
<p>访</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,130 @@
/** 监控地图共用:国家码 → 数量色阶(本站绿色) */
export type GeoCountMap = Record<string, number>;
const EMPTY = 'var(--j13-border, #e5e7eb)';
const BASE = 'hsl(var(--muted, 210 20% 94%))';
/** 由浅到深的绿色阶 */
const GREEN_STEPS = [
'color-mix(in srgb, var(--j13-green) 28%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 45%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 62%, hsl(var(--card)))',
'color-mix(in srgb, var(--j13-green) 80%, hsl(var(--card)))',
'var(--j13-green)',
];
export function buildCountMap(items: { country: string; count: number }[]): GeoCountMap {
const m: GeoCountMap = {};
for (const it of items) {
const code = (it.country || '').toUpperCase();
if (!code) continue;
m[code] = (m[code] || 0) + it.count;
}
return m;
}
/** MaxMind region_iso / 中英文省名 → @svg-maps/china 的 location id */
const CN_ISO_TO_SVG: Record<string, string> = {
AH: 'anhui', BJ: 'beijing', CQ: 'chongqing', FJ: 'fujian', GS: 'gansu',
GD: 'guangdong', GX: 'guangxi-zhuang', GZ: 'guizhou', HI: 'hainan', HE: 'hebei',
HL: 'heilongjiang', HA: 'henan', HB: 'hubei', HN: 'hunan', JS: 'jiangsu',
JX: 'jiangxi', JL: 'jilin', LN: 'liaoning', NM: 'nei-mongol', NX: 'ningxia-hui',
QH: 'quinghai', SN: 'shaanxi', SD: 'shandong', SH: 'shanghai', SX: 'shanxi',
SC: 'sichuan', TJ: 'tianjin', XJ: 'xinjiang-uygur', XZ: 'xizang', YN: 'yunnan',
ZJ: 'zhejiang', HK: 'hong-kong', MO: 'macau',
};
const CN_NAME_TO_SVG: Record<string, string> = {
: 'anhui', anhui: 'anhui',
: 'beijing', beijing: 'beijing',
: 'chongqing', chongqing: 'chongqing',
: 'fujian', fujian: 'fujian',
: 'gansu', gansu: 'gansu',
广: 'guangdong', guangdong: 'guangdong',
广西: 'guangxi-zhuang', '广西壮族自治区': 'guangxi-zhuang', 'guangxi zhuang': 'guangxi-zhuang', guangxi: 'guangxi-zhuang',
: 'guizhou', guizhou: 'guizhou',
: 'hainan', hainan: 'hainan',
: 'hebei', hebei: 'hebei',
: 'heilongjiang', heilongjiang: 'heilongjiang',
: 'henan', henan: 'henan',
: 'hubei', hubei: 'hubei',
: 'hunan', hunan: 'hunan',
: 'jiangsu', jiangsu: 'jiangsu',
西: 'jiangxi', jiangxi: 'jiangxi',
: 'jilin', jilin: 'jilin',
: 'liaoning', liaoning: 'liaoning',
: 'nei-mongol', '内蒙古自治区': 'nei-mongol', 'nei mongol': 'nei-mongol', 'inner mongolia': 'nei-mongol',
: 'ningxia-hui', '宁夏回族自治区': 'ningxia-hui', 'ningxia hui': 'ningxia-hui', ningxia: 'ningxia-hui',
: 'quinghai', qinghai: 'quinghai', quinghai: 'quinghai',
西: 'shaanxi', shaanxi: 'shaanxi',
: 'shandong', shandong: 'shandong',
: 'shanghai', shanghai: 'shanghai',
西: 'shanxi', shanxi: 'shanxi',
: 'sichuan', sichuan: 'sichuan',
: 'tianjin', tianjin: 'tianjin',
: 'xinjiang-uygur', '新疆维吾尔自治区': 'xinjiang-uygur', 'xinjiang uygur': 'xinjiang-uygur', xinjiang: 'xinjiang-uygur',
西: 'xizang', '西藏自治区': 'xizang', xizang: 'xizang', tibet: 'xizang',
: 'yunnan', yunnan: 'yunnan',
: 'zhejiang', zhejiang: 'zhejiang',
: 'hong-kong', 'hong kong': 'hong-kong',
: 'macau', macau: 'macau', macao: 'macau',
};
export function chinaRegionToSvgId(regionISO?: string, regionName?: string): string | null {
const iso = (regionISO || '').trim().toUpperCase().replace(/^CN-/, '');
if (iso && CN_ISO_TO_SVG[iso]) return CN_ISO_TO_SVG[iso];
const name = (regionName || '').trim().toLowerCase();
if (!name) return null;
if (CN_NAME_TO_SVG[name]) return CN_NAME_TO_SVG[name];
// 尝试去掉「省/市/自治区」后缀后再匹配中文键
const zh = (regionName || '').trim()
.replace(/(壮族|回族|维吾尔)?自治区$/, '')
.replace(/(省|市)$/, '');
if (CN_NAME_TO_SVG[zh]) return CN_NAME_TO_SVG[zh];
return null;
}
/** 将省级统计聚合为 svg-maps/china 的 id → count */
export function buildChinaRegionCountMap(
regions: { country: string; region?: string; region_iso?: string; count: number }[],
): GeoCountMap {
const m: GeoCountMap = {};
for (const it of regions) {
const c = (it.country || '').toUpperCase();
if (c && c !== 'CN' && c !== 'HK' && c !== 'MO' && c !== 'TW') continue;
const id = chinaRegionToSvgId(it.region_iso, it.region);
if (!id) continue;
m[id] = (m[id] || 0) + it.count;
}
return m;
}
export function heatFill(count: number, max: number, hasAnyData: boolean): string {
if (!hasAnyData) return BASE;
if (!count || max <= 0) return BASE;
const t = Math.log1p(count) / Math.log1p(max);
const idx = Math.min(GREEN_STEPS.length - 1, Math.floor(t * GREEN_STEPS.length));
return GREEN_STEPS[Math.max(0, idx)];
}
export function emptyFill(hasAnyData: boolean): string {
return hasAnyData ? BASE : EMPTY;
}
export const COUNTRY_NAMES_ZH: Record<string, string> = {
CN: '中国', US: '美国', JP: '日本', KR: '韩国', HK: '中国香港', TW: '中国台湾', MO: '中国澳门',
SG: '新加坡', DE: '德国', GB: '英国', FR: '法国', RU: '俄罗斯', AU: '澳大利亚',
CA: '加拿大', IN: '印度', BR: '巴西', NL: '荷兰', IT: '意大利', ES: '西班牙',
MY: '马来西亚', TH: '泰国', VN: '越南', ID: '印尼', PH: '菲律宾', MX: '墨西哥',
TR: '土耳其', SA: '沙特', AE: '阿联酋', ZA: '南非', NZ: '新西兰', SE: '瑞典',
NO: '挪威', FI: '芬兰', DK: '丹麦', PL: '波兰', CH: '瑞士', AT: '奥地利',
BE: '比利时', IE: '爱尔兰', PT: '葡萄牙', AR: '阿根廷', CL: '智利', CO: '哥伦比亚',
PK: '巴基斯坦', BD: '孟加拉', EG: '埃及', NG: '尼日利亚', KE: '肯尼亚',
UA: '乌克兰', CZ: '捷克', RO: '罗马尼亚', HU: '匈牙利', GR: '希腊', IL: '以色列',
};
export function countryLabelZh(code: string, fallbackName?: string) {
const c = code.toUpperCase();
return COUNTRY_NAMES_ZH[c] || fallbackName || c;
}