feat: 管理端网站监控,浏览量写入独立 monitor.db
请求日志按日 JSONL;page_views 不进主库,避免统计数据撑大 jiang13.db。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -40,6 +40,7 @@ const AdminReportsPage = lazyWithRetry(() => import('./pages/admin/AdminReportsP
|
||||
const AdminUsersPage = lazyWithRetry(() => import('./pages/admin/AdminUsersPage'));
|
||||
const AdminBadgesPage = lazyWithRetry(() => import('./pages/admin/AdminBadgesPage'));
|
||||
const AdminMediaPage = lazyWithRetry(() => import('./pages/admin/AdminMediaPage'));
|
||||
const AdminMonitorPage = lazyWithRetry(() => import('./pages/admin/AdminMonitorPage'));
|
||||
const AdminPagesPage = lazyWithRetry(() => import('./pages/admin/AdminPagesPage'));
|
||||
const AdminSitePageEditPage = lazyWithRetry(() => import('./pages/admin/AdminSitePageEditPage'));
|
||||
const AdminLinksPage = lazyWithRetry(() => import('./pages/admin/AdminLinksPage'));
|
||||
@@ -70,6 +71,7 @@ const router = createBrowserRouter(
|
||||
<Route path="users" element={<Suspense fallback={<PageLoader />}><AdminUsersPage /></Suspense>} />
|
||||
<Route path="badges" element={<Suspense fallback={<PageLoader />}><AdminBadgesPage /></Suspense>} />
|
||||
<Route path="media" element={<Suspense fallback={<PageLoader />}><AdminMediaPage /></Suspense>} />
|
||||
<Route path="monitor" element={<Suspense fallback={<PageLoader />}><AdminMonitorPage /></Suspense>} />
|
||||
<Route path="settings" element={<Suspense fallback={<PageLoader />}><AdminSettingsPage /></Suspense>} />
|
||||
<Route path="*" element={<Suspense fallback={<PageLoader />}><NotFoundPage title="后台页面不存在" /></Suspense>} />
|
||||
</Route>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem } from './types';
|
||||
import type { User, UserPublic, UserActivityStats, Board, PostItem, Comment, RecentComment, RecentUser, ForumStats, TagCount, AdminDashboard, AdminSettings, ForumLimits, ForumLimitsPublic, PostDetailResponse, PostRevision, CommentRevision, MailConfig, OIDCConfig, OAuthClient, OAuthClientInput, GiteaProject, GiteaSyncConfig, StorageConfig, MediaListResult, SiteBranding, RegisterConfig, PrivateMessage, MessageConversation, PostReport, ReportReason, ReportStatus, BadgeDef, PointLedger, CheckInStatus, LotteryStatus, SitePage, SitePageSummary, PollView, PostLotteryView, FriendLinkApply, CommunityConfig, CommunityInstance, CommunityShowcaseItem, MonitorConfig, MonitorOverview, MonitorGeoResult, MonitorStatItem, MonitorRealtime, MonitorLogItem } from './types';
|
||||
|
||||
const BASE = '';
|
||||
|
||||
@@ -25,6 +25,27 @@ export const api = {
|
||||
me: () => request<{ user: User | null }>('/api/me'),
|
||||
stats: () => request<ForumStats>('/api/stats'),
|
||||
forumLimits: () => request<ForumLimitsPublic>('/api/forum-limits'),
|
||||
/** 前台路由 pageview;失败静默(204 无 body) */
|
||||
monitorPageview: (body: { path: string; referrer?: string }) => {
|
||||
const payload = JSON.stringify(body);
|
||||
try {
|
||||
if (typeof navigator !== 'undefined' && typeof navigator.sendBeacon === 'function') {
|
||||
const blob = new Blob([payload], { type: 'application/json' });
|
||||
if (navigator.sendBeacon('/api/monitor/pageview', blob)) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* 回退 fetch */
|
||||
}
|
||||
return fetch('/api/monitor/pageview', {
|
||||
method: 'POST',
|
||||
credentials: 'omit',
|
||||
keepalive: true,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: payload,
|
||||
}).then(() => undefined).catch(() => undefined);
|
||||
},
|
||||
siteBranding: () => request<SiteBranding>('/api/site-branding'),
|
||||
pages: () => request<{ pages: SitePageSummary[] }>('/api/pages'),
|
||||
page: (slug: string) => request<{ page: SitePage }>(`/api/pages/${encodeURIComponent(slug)}`),
|
||||
@@ -67,6 +88,32 @@ export const api = {
|
||||
request<{ message: string; community: CommunityConfig; heartbeat_error?: string }>('/api/admin/settings/community', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminMonitorSettings: () => request<MonitorConfig>('/api/admin/settings/monitor'),
|
||||
adminUpdateMonitorSettings: (body: MonitorConfig) =>
|
||||
request<{ message: string; monitor: MonitorConfig }>('/api/admin/settings/monitor', {
|
||||
method: 'PUT', body: JSON.stringify(body),
|
||||
}),
|
||||
adminMonitorOverview: () => request<MonitorOverview>('/api/admin/monitor/overview'),
|
||||
adminMonitorGeo: (range = '30d') =>
|
||||
request<MonitorGeoResult>(`/api/admin/monitor/geo?range=${encodeURIComponent(range)}`),
|
||||
adminMonitorStats: (dim: string, range = '30d') =>
|
||||
request<{ dim: string; range: string; items: MonitorStatItem[] }>(
|
||||
`/api/admin/monitor/stats?dim=${encodeURIComponent(dim)}&range=${encodeURIComponent(range)}`,
|
||||
),
|
||||
adminMonitorLogs: (params: { page?: number; size?: number; method?: string; path?: string; status?: string; ip?: string }) => {
|
||||
const q = new URLSearchParams();
|
||||
if (params.page) q.set('page', String(params.page));
|
||||
if (params.size) q.set('size', String(params.size));
|
||||
if (params.method) q.set('method', params.method);
|
||||
if (params.path) q.set('path', params.path);
|
||||
if (params.status) q.set('status', params.status);
|
||||
if (params.ip) q.set('ip', params.ip);
|
||||
const qs = q.toString();
|
||||
return request<{ items: MonitorLogItem[]; total: number; page: number; size: number }>(
|
||||
`/api/admin/monitor/logs${qs ? `?${qs}` : ''}`,
|
||||
);
|
||||
},
|
||||
adminMonitorRealtime: () => request<MonitorRealtime>('/api/admin/monitor/realtime'),
|
||||
adminCommunityInstances: () =>
|
||||
request<{ hub_enabled: boolean; instances: CommunityInstance[] }>('/api/admin/community/instances'),
|
||||
adminFeatureCommunityInstance: (instanceId: string, body: { featured: boolean; featured_note?: string }) =>
|
||||
@@ -74,6 +121,20 @@ export const api = {
|
||||
`/api/admin/community/instances/${encodeURIComponent(instanceId)}/feature`,
|
||||
{ method: 'PUT', body: JSON.stringify(body) },
|
||||
),
|
||||
adminUpdateShowcaseEntry: (body: {
|
||||
nav_show_showcase?: boolean;
|
||||
footer_show_showcase?: boolean;
|
||||
aside_show_showcase?: boolean;
|
||||
}) =>
|
||||
request<{
|
||||
message: string;
|
||||
nav_show_showcase: boolean;
|
||||
footer_show_showcase: boolean;
|
||||
aside_show_showcase: boolean;
|
||||
}>('/api/admin/community/showcase-entry', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(body),
|
||||
}),
|
||||
communityShowcase: () =>
|
||||
request<{ items: CommunityShowcaseItem[] }>('/api/community/showcase'),
|
||||
adminPosts: (params: { page?: number; keyword?: string; status?: string }) => {
|
||||
|
||||
@@ -179,6 +179,14 @@ export interface Comment {
|
||||
reply_target?: Comment;
|
||||
}
|
||||
|
||||
export interface AdminDashboardTraffic {
|
||||
enabled: boolean;
|
||||
today_pv: number;
|
||||
today_uv: number;
|
||||
yesterday_pv: number;
|
||||
total_pv: number;
|
||||
}
|
||||
|
||||
export interface AdminDashboard {
|
||||
users: number;
|
||||
posts: number;
|
||||
@@ -189,9 +197,10 @@ export interface AdminDashboard {
|
||||
pending_reports?: number;
|
||||
pending_friend_links?: number;
|
||||
recent_posts: PostItem[];
|
||||
traffic?: AdminDashboardTraffic;
|
||||
}
|
||||
|
||||
export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links';
|
||||
export type AsideWidgetId = 'tag_cloud' | 'recent_comments' | 'recent_users' | 'friend_links' | 'showcase';
|
||||
|
||||
export interface AsideWidget {
|
||||
id: AsideWidgetId;
|
||||
@@ -203,6 +212,7 @@ export const DEFAULT_ASIDE_WIDGETS: AsideWidget[] = [
|
||||
{ id: 'recent_comments', enabled: false },
|
||||
{ id: 'recent_users', enabled: false },
|
||||
{ id: 'friend_links', enabled: true },
|
||||
{ id: 'showcase', enabled: false },
|
||||
];
|
||||
|
||||
export interface ForumLimits {
|
||||
@@ -231,12 +241,18 @@ export interface ForumLimits {
|
||||
aside_show_recent_comments: boolean;
|
||||
/** 右侧栏友情链接 */
|
||||
aside_show_friend_links: boolean;
|
||||
/** 右侧栏开源展柜 */
|
||||
aside_show_showcase: boolean;
|
||||
/** 右侧栏可选组件顺序与开关 */
|
||||
aside_widgets: AsideWidget[];
|
||||
/** 左侧栏「站点」展示友情链接入口 */
|
||||
nav_show_friend_links: boolean;
|
||||
/** 页脚展示友情链接入口 */
|
||||
footer_show_friend_links: boolean;
|
||||
/** 左侧栏「站点」展示开源展柜入口 */
|
||||
nav_show_showcase: boolean;
|
||||
/** 页脚展示开源展柜入口 */
|
||||
footer_show_showcase: boolean;
|
||||
/** 首页列表样式:title 仅标题 / thumbnail 缩略图 */
|
||||
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
||||
/** 伪静态(固定链接)开关 */
|
||||
@@ -262,12 +278,17 @@ export interface ForumLimitsPublic {
|
||||
aside_show_tag_cloud: boolean;
|
||||
aside_show_recent_comments: boolean;
|
||||
aside_show_friend_links: boolean;
|
||||
aside_show_showcase: boolean;
|
||||
aside_widgets: AsideWidget[];
|
||||
nav_show_friend_links: boolean;
|
||||
footer_show_friend_links: boolean;
|
||||
nav_show_showcase: boolean;
|
||||
footer_show_showcase: boolean;
|
||||
feed_list_style: 'title' | 'excerpt' | 'thumbnail';
|
||||
permalink_enabled: boolean;
|
||||
permalink_ext: string;
|
||||
/** 是否上报前台路由 pageview(与后台监控采集开关同步) */
|
||||
monitor_pageview: boolean;
|
||||
}
|
||||
|
||||
export interface FriendLink {
|
||||
@@ -393,6 +414,109 @@ export interface CommunityConfig {
|
||||
instance_id: string;
|
||||
}
|
||||
|
||||
/** 网站监控设置 */
|
||||
export interface MonitorConfig {
|
||||
enabled: boolean;
|
||||
retention_days: number;
|
||||
access_log_retention_days: number;
|
||||
exclude_rules: string[];
|
||||
default_exclude_rules?: string[];
|
||||
trust_proxy: boolean;
|
||||
access_log_dir?: string;
|
||||
ip2location_v4_path?: string;
|
||||
ip2location_v6_path?: string;
|
||||
ip2location_v4_available?: boolean;
|
||||
ip2location_v6_available?: boolean;
|
||||
geoip_available: boolean;
|
||||
geoip_country_path?: string;
|
||||
geoip_asn_path?: string;
|
||||
geoip_country_available?: boolean;
|
||||
geoip_asn_available?: boolean;
|
||||
}
|
||||
|
||||
export interface MonitorOverview {
|
||||
enabled: boolean;
|
||||
pageviews: number;
|
||||
visitors: number;
|
||||
unique_ips: number;
|
||||
traffic: number;
|
||||
bots: number;
|
||||
requests: number;
|
||||
status_4xx: number;
|
||||
status_5xx: number;
|
||||
}
|
||||
|
||||
export interface MonitorGeoItem {
|
||||
country: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MonitorRegionItem {
|
||||
country: string;
|
||||
region: string;
|
||||
region_iso: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MonitorCityItem {
|
||||
country: string;
|
||||
region: string;
|
||||
city: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MonitorASNItem {
|
||||
asn: number;
|
||||
as_org: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MonitorGeoResult {
|
||||
range: string;
|
||||
countries: MonitorGeoItem[];
|
||||
regions: MonitorRegionItem[];
|
||||
cities: MonitorCityItem[];
|
||||
asns: MonitorASNItem[];
|
||||
has_data: boolean;
|
||||
}
|
||||
|
||||
export interface MonitorStatItem {
|
||||
key: string;
|
||||
count: number;
|
||||
}
|
||||
|
||||
export interface MonitorRealtimePoint {
|
||||
minute: string;
|
||||
count: number;
|
||||
bytes: number;
|
||||
}
|
||||
|
||||
export interface MonitorRealtime {
|
||||
enabled: boolean;
|
||||
requests_1m: number;
|
||||
traffic_1m: number;
|
||||
hourly_series: MonitorRealtimePoint[];
|
||||
}
|
||||
|
||||
export interface MonitorLogItem {
|
||||
id: number;
|
||||
created_at: string;
|
||||
method: string;
|
||||
path: string;
|
||||
status: number;
|
||||
bytes: number;
|
||||
duration_ms: number;
|
||||
ip: string;
|
||||
ua: string;
|
||||
referer: string;
|
||||
country: string;
|
||||
region?: string;
|
||||
city?: string;
|
||||
asn?: number;
|
||||
as_org?: string;
|
||||
is_bot: boolean;
|
||||
}
|
||||
|
||||
export interface CommunityInstance {
|
||||
instance_id: string;
|
||||
site_url: string;
|
||||
|
||||
@@ -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">
|
||||
|
||||
72
frontend/src/components/ShowcaseAsideWidget.tsx
Normal file
72
frontend/src/components/ShowcaseAsideWidget.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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)))
|
||||
))}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -18,6 +18,10 @@ const WIDGET_META: Record<AsideWidgetId, { label: string; hint: string }> = {
|
||||
label: '友情链接',
|
||||
hint: '关闭后不在右侧栏展示,友链仍可在「友情链接」页面查看与申请',
|
||||
},
|
||||
showcase: {
|
||||
label: '开源展柜',
|
||||
hint: '展示精选公网部署;关闭后仍可直接访问 /showcase',
|
||||
},
|
||||
};
|
||||
|
||||
type Props = {
|
||||
|
||||
@@ -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">
|
||||
|
||||
72
frontend/src/components/admin/MonitorChinaMap.tsx
Normal file
72
frontend/src/components/admin/MonitorChinaMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
121
frontend/src/components/admin/MonitorWorldMap.tsx
Normal file
121
frontend/src/components/admin/MonitorWorldMap.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
130
frontend/src/components/admin/monitorMapUtils.ts
Normal file
130
frontend/src/components/admin/monitorMapUtils.ts
Normal 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;
|
||||
}
|
||||
@@ -20,12 +20,16 @@ const DEFAULT_LIMITS: ForumLimitsPublic = {
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: DEFAULT_ASIDE_WIDGETS,
|
||||
nav_show_friend_links: true,
|
||||
footer_show_friend_links: true,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
monitor_pageview: false,
|
||||
};
|
||||
|
||||
let cached: ForumLimitsPublic | null = null;
|
||||
|
||||
41
frontend/src/hooks/useMonitorPageview.ts
Normal file
41
frontend/src/hooks/useMonitorPageview.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import { useEffect, useRef } from 'react';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { api } from '../api/client';
|
||||
import { useForumLimits } from './useForumLimits';
|
||||
|
||||
const DEDUPE_MS = 800;
|
||||
|
||||
function shouldSkipPath(pathname: string) {
|
||||
const p = pathname.toLowerCase();
|
||||
return (
|
||||
p === '/admin' ||
|
||||
p.startsWith('/admin/') ||
|
||||
p === '/login' ||
|
||||
p === '/register' ||
|
||||
p === '/forgot-password' ||
|
||||
p.startsWith('/oauth')
|
||||
);
|
||||
}
|
||||
|
||||
/** 主站布局:路由变化时上报第一方 pageview(采集关闭则不请求) */
|
||||
export function useMonitorPageview() {
|
||||
const loc = useLocation();
|
||||
const { limits } = useForumLimits();
|
||||
const lastRef = useRef<{ key: string; at: number }>({ key: '', at: 0 });
|
||||
const enabled = !!limits.monitor_pageview;
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) return;
|
||||
if (shouldSkipPath(loc.pathname)) return;
|
||||
|
||||
const path = `${loc.pathname}${loc.search || ''}`;
|
||||
const now = Date.now();
|
||||
if (lastRef.current.key === path && now - lastRef.current.at < DEDUPE_MS) {
|
||||
return;
|
||||
}
|
||||
lastRef.current = { key: path, at: now };
|
||||
|
||||
const referrer = typeof document !== 'undefined' ? document.referrer || '' : '';
|
||||
void api.monitorPageview({ path, referrer });
|
||||
}, [enabled, loc.pathname, loc.search]);
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState, useCallback, useRef } from 'react';
|
||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2,
|
||||
LayoutDashboard, FolderKanban, FileText, MessageSquare, Flag, Users, Images, Settings, ArrowLeft, Moon, Sun, Menu, X, Award, Link2, BookOpen, Globe2, Activity,
|
||||
} from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
@@ -38,6 +38,8 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
label: '概览',
|
||||
items: [
|
||||
{ to: '/admin/dashboard', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ to: '/admin/monitor', label: '网站监控', icon: Activity },
|
||||
{ to: '/admin/community', label: '公网实例', icon: Globe2, hubOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -56,7 +58,6 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
{ to: '/admin/users', label: '用户管理', icon: Users },
|
||||
{ to: '/admin/badges', label: '徽章管理', icon: Award },
|
||||
{ to: '/admin/links', label: '友情链接', icon: Link2, badgeKey: 'links' },
|
||||
{ to: '/admin/community', label: '公网实例', icon: Globe2, hubOnly: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -34,6 +34,7 @@ import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { useMonitorPageview } from '../hooks/useMonitorPageview';
|
||||
import SiteBrandMark from '../components/SiteBrandMark';
|
||||
import SiteFooter from '../components/SiteFooter';
|
||||
import { userPath } from '../utils/userPath';
|
||||
@@ -43,6 +44,7 @@ export default function MainLayout() {
|
||||
const { user, loading: authLoading, logout } = useAuth();
|
||||
const { theme, toggle } = useTheme();
|
||||
const { branding } = useSiteBranding();
|
||||
useMonitorPageview();
|
||||
const isMobile = useMediaQuery('(max-width: 768px)');
|
||||
const hideAside = useMediaQuery('(max-width: 1100px)');
|
||||
const nav = useNavigate();
|
||||
|
||||
@@ -1,30 +1,80 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { Globe2, ExternalLink, Star } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api/client';
|
||||
import type { CommunityInstance } from '../../api/types';
|
||||
import type { CommunityInstance, ForumLimits } from '../../api/types';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import { formatTime } from '../../utils/content';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||
import {
|
||||
mergeForumLimitsWithAsideWidgets,
|
||||
normalizeAsideWidgets,
|
||||
resolveAsideWidgets,
|
||||
} from '../../utils/asideWidgets';
|
||||
|
||||
type EntryFlags = {
|
||||
nav: boolean;
|
||||
footer: boolean;
|
||||
aside: boolean;
|
||||
};
|
||||
|
||||
/** 后台:公网实例列表 + 开源展柜入口位置 */
|
||||
export default function AdminCommunityPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [hubEnabled, setHubEnabled] = useState(false);
|
||||
const [list, setList] = useState<CommunityInstance[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [featuringId, setFeaturingId] = useState<string | null>(null);
|
||||
const [forumLimits, setForumLimits] = useState<ForumLimits | null>(null);
|
||||
const [entrySaving, setEntrySaving] = useState(false);
|
||||
|
||||
const entryFlags = useMemo<EntryFlags>(() => {
|
||||
const widgets = resolveAsideWidgets({
|
||||
aside_widgets: forumLimits?.aside_widgets,
|
||||
aside_show_tag_cloud: forumLimits?.aside_show_tag_cloud ?? false,
|
||||
aside_show_recent_comments: forumLimits?.aside_show_recent_comments ?? false,
|
||||
aside_show_friend_links: forumLimits?.aside_show_friend_links ?? true,
|
||||
aside_show_showcase: forumLimits?.aside_show_showcase ?? false,
|
||||
});
|
||||
return {
|
||||
nav: !!forumLimits?.nav_show_showcase,
|
||||
footer: !!forumLimits?.footer_show_showcase,
|
||||
aside: widgets.find(w => w.id === 'showcase')?.enabled ?? false,
|
||||
};
|
||||
}, [forumLimits]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
setLoading(true);
|
||||
api.adminCommunityInstances()
|
||||
.then((r) => {
|
||||
setHubEnabled(!!r.hub_enabled);
|
||||
setList(Array.isArray(r.instances) ? r.instances : []);
|
||||
Promise.all([
|
||||
api.adminCommunityInstances(),
|
||||
api.adminSettings().catch(() => null),
|
||||
])
|
||||
.then(([instancesRes, settings]) => {
|
||||
setHubEnabled(!!instancesRes.hub_enabled);
|
||||
setList(Array.isArray(instancesRes.instances) ? instancesRes.instances : []);
|
||||
if (settings?.limits) {
|
||||
const loaded = normalizeAsideWidgets(
|
||||
resolveAsideWidgets({
|
||||
aside_widgets: settings.limits.aside_widgets,
|
||||
aside_show_tag_cloud: settings.limits.aside_show_tag_cloud ?? false,
|
||||
aside_show_recent_comments: settings.limits.aside_show_recent_comments ?? false,
|
||||
aside_show_friend_links: settings.limits.aside_show_friend_links ?? true,
|
||||
aside_show_showcase: settings.limits.aside_show_showcase ?? false,
|
||||
}),
|
||||
);
|
||||
setForumLimits(mergeForumLimitsWithAsideWidgets({
|
||||
...settings.limits,
|
||||
nav_show_showcase: !!settings.limits.nav_show_showcase,
|
||||
footer_show_showcase: !!settings.limits.footer_show_showcase,
|
||||
aside_show_showcase: !!settings.limits.aside_show_showcase,
|
||||
}, loaded));
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
setList([]);
|
||||
@@ -32,6 +82,66 @@ export default function AdminCommunityPage() {
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready]);
|
||||
|
||||
const patchEntryVisibility = async (patch: Partial<EntryFlags>) => {
|
||||
if (entrySaving) return;
|
||||
const prev = entryFlags;
|
||||
const nextFlags = { ...prev, ...patch };
|
||||
setForumLimits((fl) => {
|
||||
if (!fl) return fl;
|
||||
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
|
||||
w.id === 'showcase' ? { ...w, enabled: nextFlags.aside } : w
|
||||
));
|
||||
return mergeForumLimitsWithAsideWidgets({
|
||||
...fl,
|
||||
nav_show_showcase: nextFlags.nav,
|
||||
footer_show_showcase: nextFlags.footer,
|
||||
aside_show_showcase: nextFlags.aside,
|
||||
}, widgets);
|
||||
});
|
||||
setEntrySaving(true);
|
||||
try {
|
||||
const body: {
|
||||
nav_show_showcase?: boolean;
|
||||
footer_show_showcase?: boolean;
|
||||
aside_show_showcase?: boolean;
|
||||
} = {};
|
||||
if (patch.nav !== undefined) body.nav_show_showcase = patch.nav;
|
||||
if (patch.footer !== undefined) body.footer_show_showcase = patch.footer;
|
||||
if (patch.aside !== undefined) body.aside_show_showcase = patch.aside;
|
||||
const r = await api.adminUpdateShowcaseEntry(body);
|
||||
setForumLimits((base) => {
|
||||
if (!base) return base;
|
||||
const widgets = normalizeAsideWidgets(base.aside_widgets).map(w => (
|
||||
w.id === 'showcase' ? { ...w, enabled: r.aside_show_showcase } : w
|
||||
));
|
||||
return mergeForumLimitsWithAsideWidgets({
|
||||
...base,
|
||||
nav_show_showcase: r.nav_show_showcase,
|
||||
footer_show_showcase: r.footer_show_showcase,
|
||||
aside_show_showcase: r.aside_show_showcase,
|
||||
}, widgets);
|
||||
});
|
||||
invalidateForumLimitsCache();
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
setForumLimits((fl) => {
|
||||
if (!fl) return fl;
|
||||
const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => (
|
||||
w.id === 'showcase' ? { ...w, enabled: prev.aside } : w
|
||||
));
|
||||
return mergeForumLimitsWithAsideWidgets({
|
||||
...fl,
|
||||
nav_show_showcase: prev.nav,
|
||||
footer_show_showcase: prev.footer,
|
||||
aside_show_showcase: prev.aside,
|
||||
}, widgets);
|
||||
});
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setEntrySaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleFeatured = async (row: CommunityInstance) => {
|
||||
if (featuringId) return;
|
||||
setFeaturingId(row.instance_id);
|
||||
@@ -66,7 +176,7 @@ export default function AdminCommunityPage() {
|
||||
<p className="admin-page-desc">
|
||||
接收自愿上报的心跳;设为精选后会出现在
|
||||
{' '}
|
||||
<Link to="/showcase" className="admin-inline-link" target="_blank" rel="noopener noreferrer">公开展柜</Link>
|
||||
<Link to="/showcase" className="admin-inline-link" target="_blank" rel="noopener noreferrer">开源部署展柜</Link>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,6 +194,39 @@ export default function AdminCommunityPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{hubEnabled && (
|
||||
<div className="admin-card admin-links-entry-card" style={{ marginBottom: 16 }}>
|
||||
<div className="admin-card-head">展柜入口</div>
|
||||
<p className="admin-card-desc">
|
||||
控制「开源展柜」出现在何处;关闭后仍可直接访问 /showcase。右侧栏开关与「系统设置 → 右侧栏组件」同源。
|
||||
</p>
|
||||
<div className="admin-card-body admin-links-entry-body">
|
||||
{(
|
||||
[
|
||||
{ key: 'nav' as const, label: '左侧栏(站点)', on: entryFlags.nav },
|
||||
{ key: 'aside' as const, label: '右侧栏', on: entryFlags.aside },
|
||||
{ key: 'footer' as const, label: '页脚', on: entryFlags.footer },
|
||||
]
|
||||
).map(item => (
|
||||
<div key={item.key} className="admin-links-entry-row">
|
||||
<span id={`admin-showcase-entry-${item.key}`}>{item.label}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={item.on}
|
||||
aria-labelledby={`admin-showcase-entry-${item.key}`}
|
||||
disabled={entrySaving}
|
||||
className={cn('admin-settings-switch', item.on && 'is-on')}
|
||||
onClick={() => void patchEntryVisibility({ [item.key]: !item.on })}
|
||||
>
|
||||
<span className="admin-settings-switch-ui" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<span>实例列表</span>
|
||||
|
||||
@@ -1,14 +1,38 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FileText, Flag, MessageSquare, Link2 } from 'lucide-react';
|
||||
import { FileText, Flag, MessageSquare, Link2, Activity } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { AdminDashboard } from '../../api/types';
|
||||
import type { AdminDashboard, AdminDashboardTraffic } from '../../api/types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import CommunitySupportStrip from '../../components/admin/CommunitySupportStrip';
|
||||
|
||||
function formatDashNum(n: number) {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n ?? 0);
|
||||
}
|
||||
|
||||
function pvTrendLabel(today: number, yesterday: number) {
|
||||
if (yesterday <= 0) {
|
||||
return today > 0 ? '昨日无数据' : '较昨日 —';
|
||||
}
|
||||
const delta = ((today - yesterday) / yesterday) * 100;
|
||||
if (Math.abs(delta) < 0.5) return '与昨日持平';
|
||||
const abs = Math.abs(delta).toFixed(0);
|
||||
return delta > 0 ? `较昨日 ↑ ${abs}%` : `较昨日 ↓ ${abs}%`;
|
||||
}
|
||||
|
||||
function trendTone(today: number, yesterday: number): 'up' | 'down' | 'flat' {
|
||||
if (yesterday <= 0) return 'flat';
|
||||
const delta = today - yesterday;
|
||||
if (delta > 0) return 'up';
|
||||
if (delta < 0) return 'down';
|
||||
return 'flat';
|
||||
}
|
||||
|
||||
export default function AdminDashboardPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
@@ -32,20 +56,16 @@ export default function AdminDashboardPage() {
|
||||
const pendingReports = data.pending_reports ?? 0;
|
||||
const pendingFriendLinks = data.pending_friend_links ?? 0;
|
||||
const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks;
|
||||
|
||||
const stats = [
|
||||
{ label: '注册用户', value: data.users },
|
||||
{ label: '帖子总数', value: data.posts },
|
||||
{ label: '板块数量', value: data.boards },
|
||||
{ label: '评论总数', value: data.comments },
|
||||
];
|
||||
const traffic: AdminDashboardTraffic = data.traffic ?? {
|
||||
enabled: false, today_pv: 0, today_uv: 0, yesterday_pv: 0, total_pv: 0,
|
||||
};
|
||||
|
||||
const queues = [
|
||||
{
|
||||
key: 'posts',
|
||||
label: '待审帖子',
|
||||
count: pendingPosts,
|
||||
hint: '新帖与修改待审核',
|
||||
hint: '新帖与修改',
|
||||
to: '/admin/posts',
|
||||
icon: FileText,
|
||||
},
|
||||
@@ -53,7 +73,7 @@ export default function AdminDashboardPage() {
|
||||
key: 'comments',
|
||||
label: '待审评论',
|
||||
count: pendingComments,
|
||||
hint: '评论与回复待审核',
|
||||
hint: '评论与回复',
|
||||
to: '/admin/comments',
|
||||
icon: MessageSquare,
|
||||
},
|
||||
@@ -61,7 +81,7 @@ export default function AdminDashboardPage() {
|
||||
key: 'reports',
|
||||
label: '待处理举报',
|
||||
count: pendingReports,
|
||||
hint: '用户举报需人工处理',
|
||||
hint: '用户举报',
|
||||
to: '/admin/reports',
|
||||
icon: Flag,
|
||||
},
|
||||
@@ -69,60 +89,121 @@ export default function AdminDashboardPage() {
|
||||
key: 'links',
|
||||
label: '待审友链',
|
||||
count: pendingFriendLinks,
|
||||
hint: '用户提交的友情链接申请',
|
||||
hint: '友链申请',
|
||||
to: '/admin/links',
|
||||
icon: Link2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page-head">
|
||||
<h1>仪表盘</h1>
|
||||
<p>优先处理待办,再查看运行概览</p>
|
||||
</div>
|
||||
const scale = [
|
||||
{ label: '注册用户', value: data.users },
|
||||
{ label: '帖子总数', value: data.posts },
|
||||
{ label: '板块数量', value: data.boards },
|
||||
{ label: '评论总数', value: data.comments },
|
||||
];
|
||||
|
||||
<section className="admin-queue-section" aria-label="待处理事项">
|
||||
<div className="admin-section-label">
|
||||
<span>待处理</span>
|
||||
{pendingTotal > 0 ? (
|
||||
<Badge variant="orange">{pendingTotal} 项</Badge>
|
||||
) : (
|
||||
<span className="admin-section-muted">暂无积压</span>
|
||||
const tone = trendTone(traffic.today_pv, traffic.yesterday_pv);
|
||||
|
||||
return (
|
||||
<div className="admin-page admin-dash-page">
|
||||
<div className="admin-page-head admin-dash-head">
|
||||
<div className="admin-dash-head-title">
|
||||
<h1>仪表盘</h1>
|
||||
{pendingTotal > 0 && (
|
||||
<Badge variant="orange">{pendingTotal} 项待处理</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-queue-grid">
|
||||
{queues.map(q => {
|
||||
const Icon = q.icon;
|
||||
const hasWork = q.count > 0;
|
||||
return (
|
||||
<button
|
||||
key={q.key}
|
||||
type="button"
|
||||
className={cn('admin-queue-card', hasWork && 'has-work')}
|
||||
onClick={() => nav(q.to)}
|
||||
>
|
||||
<div className="admin-queue-card-top">
|
||||
<Icon size={18} aria-hidden />
|
||||
<span className="admin-queue-count">{q.count}</span>
|
||||
</div>
|
||||
<div className="admin-queue-label">{q.label}</div>
|
||||
<div className="admin-queue-hint">{q.hint}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
<p>处理待办,并一眼看到今日访问</p>
|
||||
</div>
|
||||
|
||||
<section className="admin-stat-section" aria-label="运行概览">
|
||||
<div className="admin-section-label">
|
||||
<span>运行概览</span>
|
||||
<div className="admin-dash-bento">
|
||||
<section className="admin-dash-traffic" aria-label="今日访问">
|
||||
<div className="admin-dash-traffic-top">
|
||||
<div className="admin-dash-section-label">
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2>今日访问</h2>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link admin-dash-link"
|
||||
onClick={() => nav('/admin/monitor')}
|
||||
>
|
||||
<Activity size={14} aria-hidden />
|
||||
网站监控 →
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!traffic.enabled ? (
|
||||
<div className="admin-dash-traffic-empty">
|
||||
<p>访问采集尚未开启,流量统计暂不可用。</p>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-text-link"
|
||||
onClick={() => nav('/admin/monitor?tab=settings')}
|
||||
>
|
||||
前往开启 →
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="admin-dash-traffic-pv">{formatDashNum(traffic.today_pv)}</div>
|
||||
<div className="admin-dash-traffic-meta">
|
||||
<span>今日访问 (PV)</span>
|
||||
<span className="admin-dash-traffic-uv">今日访客 (UV) {formatDashNum(traffic.today_uv)}</span>
|
||||
</div>
|
||||
<div className={cn('admin-dash-traffic-trend', `is-${tone}`)}>
|
||||
{pvTrendLabel(traffic.today_pv, traffic.yesterday_pv)}
|
||||
</div>
|
||||
<p className="admin-dash-traffic-total">
|
||||
累计访问 {formatDashNum(traffic.total_pv)}
|
||||
<span className="admin-dash-traffic-hint">(受日志保留天数影响)</span>
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="admin-dash-queue" aria-label="待处理事项">
|
||||
<div className="admin-dash-section-label admin-dash-queue-head">
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2>待处理</h2>
|
||||
{pendingTotal === 0 && (
|
||||
<span className="admin-section-muted">暂无积压</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-dash-queue-grid">
|
||||
{queues.map((q) => {
|
||||
const Icon = q.icon;
|
||||
const hasWork = q.count > 0;
|
||||
return (
|
||||
<button
|
||||
key={q.key}
|
||||
type="button"
|
||||
className={cn('admin-dash-queue-card', hasWork && 'has-work')}
|
||||
onClick={() => nav(q.to)}
|
||||
>
|
||||
<div className="admin-dash-queue-card-top">
|
||||
<Icon size={16} aria-hidden />
|
||||
<span className="admin-dash-queue-count">{q.count}</span>
|
||||
</div>
|
||||
<div className="admin-dash-queue-label">{q.label}</div>
|
||||
<div className="admin-dash-queue-hint">{q.hint}</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="admin-dash-scale" aria-label="内容规模">
|
||||
<div className="admin-dash-section-label">
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2>内容规模</h2>
|
||||
</div>
|
||||
<div className="admin-stat-grid">
|
||||
{stats.map(s => (
|
||||
<div key={s.label} className="admin-stat-card">
|
||||
<div className="admin-stat-value">{s.value}</div>
|
||||
<div className="admin-stat-label">{s.label}</div>
|
||||
<div className="admin-dash-metric-grid">
|
||||
{scale.map((s) => (
|
||||
<div key={s.label} className="admin-dash-metric-card">
|
||||
<div className="admin-dash-metric-value">{formatDashNum(s.value)}</div>
|
||||
<div className="admin-dash-metric-label">{s.label}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -148,7 +229,7 @@ export default function AdminDashboardPage() {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{data.recent_posts.map(p => (
|
||||
{data.recent_posts.map((p) => (
|
||||
<tr key={p.id}>
|
||||
<td>{p.id}</td>
|
||||
<td>
|
||||
|
||||
@@ -178,9 +178,12 @@ export default function AdminLinksPage() {
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: loadedAsideWidgets,
|
||||
nav_show_friend_links: true,
|
||||
footer_show_friend_links: true,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
@@ -350,14 +353,18 @@ export default function AdminLinksPage() {
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: nextFlags.aside,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: normalizeAsideWidgets([
|
||||
{ id: 'tag_cloud', enabled: false },
|
||||
{ id: 'recent_comments', enabled: false },
|
||||
{ id: 'recent_users', enabled: false },
|
||||
{ id: 'friend_links', enabled: nextFlags.aside },
|
||||
{ id: 'showcase', enabled: false },
|
||||
]),
|
||||
nav_show_friend_links: nextFlags.nav,
|
||||
footer_show_friend_links: nextFlags.footer,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
|
||||
707
frontend/src/pages/admin/AdminMonitorPage.tsx
Normal file
707
frontend/src/pages/admin/AdminMonitorPage.tsx
Normal file
@@ -0,0 +1,707 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Activity, AlertTriangle } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api/client';
|
||||
import type {
|
||||
MonitorASNItem,
|
||||
MonitorCityItem,
|
||||
MonitorConfig,
|
||||
MonitorGeoResult,
|
||||
MonitorLogItem,
|
||||
MonitorOverview,
|
||||
MonitorRealtime,
|
||||
MonitorStatItem,
|
||||
} from '../../api/types';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import { formatTime } from '../../utils/content';
|
||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||
import MonitorWorldMap from '../../components/admin/MonitorWorldMap';
|
||||
import MonitorChinaMap from '../../components/admin/MonitorChinaMap';
|
||||
import { countryLabelZh } from '../../components/admin/monitorMapUtils';
|
||||
|
||||
type TabKey = 'overview' | 'stats' | 'logs' | 'settings';
|
||||
type StatsDim = 'url' | 'referer' | 'browser' | 'os' | 'device' | 'status';
|
||||
type StatsRange = '1d' | '7d' | '30d' | '90d';
|
||||
type MapMode = 'world' | 'china';
|
||||
type RankMode = 'city' | 'asn';
|
||||
|
||||
function countryLabel(code: string) {
|
||||
return countryLabelZh(code);
|
||||
}
|
||||
|
||||
function formatBytes(n: number) {
|
||||
if (!n || n < 0) return '0 B';
|
||||
const u = ['B', 'KB', 'MB', 'GB', 'TB'];
|
||||
let v = n;
|
||||
let i = 0;
|
||||
while (v >= 1024 && i < u.length - 1) {
|
||||
v /= 1024;
|
||||
i++;
|
||||
}
|
||||
return `${v < 10 && i > 0 ? v.toFixed(1) : Math.round(v)} ${u[i]}`;
|
||||
}
|
||||
|
||||
function formatNum(n: number) {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
||||
if (n >= 10_000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n ?? 0);
|
||||
}
|
||||
|
||||
function MiniSparkline({ series }: { series: { count: number }[] }) {
|
||||
const max = Math.max(1, ...series.map((p) => p.count));
|
||||
const w = 240;
|
||||
const h = 48;
|
||||
const pts = series.map((p, i) => {
|
||||
const x = series.length <= 1 ? 0 : (i / (series.length - 1)) * w;
|
||||
const y = h - (p.count / max) * (h - 4) - 2;
|
||||
return `${x},${y}`;
|
||||
}).join(' ');
|
||||
return (
|
||||
<svg className="admin-monitor-spark" viewBox={`0 0 ${w} ${h}`} width="100%" height={h} aria-hidden>
|
||||
<polyline fill="none" stroke="var(--j13-green)" strokeWidth="2" points={pts} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
const CHINA_CODES = new Set(['CN', 'HK', 'TW', 'MO']);
|
||||
|
||||
function geoCountries(geo: MonitorGeoResult | null) {
|
||||
return geo?.countries || [];
|
||||
}
|
||||
|
||||
function GeoRankTable({
|
||||
cities,
|
||||
asns,
|
||||
mode,
|
||||
mapMode,
|
||||
rankMode,
|
||||
onRankMode,
|
||||
}: {
|
||||
cities: MonitorCityItem[];
|
||||
asns: MonitorASNItem[];
|
||||
mode: MapMode;
|
||||
rankMode: RankMode;
|
||||
onRankMode: (m: RankMode) => void;
|
||||
}) {
|
||||
const cityRows = mode === 'china'
|
||||
? cities.filter((i) => CHINA_CODES.has((i.country || '').toUpperCase()))
|
||||
: cities;
|
||||
const rows = rankMode === 'city' ? cityRows : asns;
|
||||
const emptyHint = rankMode === 'city'
|
||||
? '放置 IP2Location DB3 BIN 后可见城市排行'
|
||||
: '放置 GeoLite2-ASN.mmdb 后可见运营商排行';
|
||||
|
||||
return (
|
||||
<div className="admin-monitor-rank">
|
||||
<div className="admin-monitor-seg" role="group" aria-label="排行维度">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-monitor-seg-btn', rankMode === 'city' && 'active')}
|
||||
onClick={() => onRankMode('city')}
|
||||
>
|
||||
城市
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-monitor-seg-btn', rankMode === 'asn' && 'active')}
|
||||
onClick={() => onRankMode('asn')}
|
||||
>
|
||||
运营商
|
||||
</button>
|
||||
</div>
|
||||
{rows.length === 0 ? (
|
||||
<div className="admin-monitor-geo-empty">
|
||||
<p>暂无来源数据</p>
|
||||
<p className="admin-monitor-muted">{emptyHint}</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="admin-monitor-geo-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{rankMode === 'city' ? '城市' : '运营商'}</th>
|
||||
<th>数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rankMode === 'city'
|
||||
? cityRows.slice(0, 12).map((item, idx) => (
|
||||
<tr key={`${item.country}-${item.city}-${idx}`}>
|
||||
<td title={[item.region, item.city].filter(Boolean).join(' · ')}>
|
||||
{item.city}
|
||||
{item.region ? <span className="admin-monitor-muted"> · {item.region}</span> : null}
|
||||
</td>
|
||||
<td>{formatNum(item.count)}</td>
|
||||
</tr>
|
||||
))
|
||||
: asns.slice(0, 12).map((item) => (
|
||||
<tr key={item.asn}>
|
||||
<td title={item.as_org || `AS${item.asn}`}>
|
||||
{item.as_org || `AS${item.asn}`}
|
||||
<span className="admin-monitor-muted"> · AS{item.asn}</span>
|
||||
</td>
|
||||
<td>{formatNum(item.count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function parseMonitorTab(raw: string | null): TabKey {
|
||||
if (raw === 'stats' || raw === 'logs' || raw === 'settings' || raw === 'overview') {
|
||||
return raw;
|
||||
}
|
||||
return 'overview';
|
||||
}
|
||||
|
||||
export default function AdminMonitorPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [tab, setTab] = useState<TabKey>(() => parseMonitorTab(searchParams.get('tab')));
|
||||
const [overview, setOverview] = useState<MonitorOverview | null>(null);
|
||||
const [geo, setGeo] = useState<MonitorGeoResult | null>(null);
|
||||
const [realtime, setRealtime] = useState<MonitorRealtime | null>(null);
|
||||
const [mapMode, setMapMode] = useState<MapMode>('world');
|
||||
const [rankMode, setRankMode] = useState<RankMode>('city');
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [statsDim, setStatsDim] = useState<StatsDim>('url');
|
||||
const [statsRange, setStatsRange] = useState<StatsRange>('30d');
|
||||
const [statsItems, setStatsItems] = useState<MonitorStatItem[]>([]);
|
||||
const [statsLoading, setStatsLoading] = useState(false);
|
||||
|
||||
const [logs, setLogs] = useState<MonitorLogItem[]>([]);
|
||||
const [logsTotal, setLogsTotal] = useState(0);
|
||||
const [logsPage, setLogsPage] = useState(1);
|
||||
const [logMethod, setLogMethod] = useState('');
|
||||
const [logPath, setLogPath] = useState('');
|
||||
const [logStatus, setLogStatus] = useState('');
|
||||
const [logIP, setLogIP] = useState('');
|
||||
const [logsLoading, setLogsLoading] = useState(false);
|
||||
|
||||
const [settings, setSettings] = useState<MonitorConfig | null>(null);
|
||||
const [excludeText, setExcludeText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const next = parseMonitorTab(searchParams.get('tab'));
|
||||
setTab((prev) => (prev === next ? prev : next));
|
||||
}, [searchParams]);
|
||||
|
||||
const selectTab = (key: TabKey) => {
|
||||
setTab(key);
|
||||
const next = new URLSearchParams(searchParams);
|
||||
if (key === 'overview') next.delete('tab');
|
||||
else next.set('tab', key);
|
||||
setSearchParams(next, { replace: true });
|
||||
};
|
||||
|
||||
const loadOverview = useCallback(async () => {
|
||||
const [ov, g, rt] = await Promise.all([
|
||||
api.adminMonitorOverview(),
|
||||
api.adminMonitorGeo('30d'),
|
||||
api.adminMonitorRealtime(),
|
||||
]);
|
||||
setOverview(ov);
|
||||
setGeo(g);
|
||||
setRealtime(rt);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready) return;
|
||||
setLoading(true);
|
||||
loadOverview()
|
||||
.catch(() => notify.error('加载监控概览失败'))
|
||||
.finally(() => setLoading(false));
|
||||
}, [ready, loadOverview]);
|
||||
|
||||
// 概览页实时轮询
|
||||
useEffect(() => {
|
||||
if (!ready || tab !== 'overview') return;
|
||||
const id = window.setInterval(() => {
|
||||
api.adminMonitorRealtime().then(setRealtime).catch(() => {});
|
||||
api.adminMonitorOverview().then(setOverview).catch(() => {});
|
||||
}, 5000);
|
||||
return () => window.clearInterval(id);
|
||||
}, [ready, tab]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || tab !== 'stats') return;
|
||||
setStatsLoading(true);
|
||||
api.adminMonitorStats(statsDim, statsRange)
|
||||
.then((r) => setStatsItems(r.items || []))
|
||||
.catch(() => notify.error('加载统计失败'))
|
||||
.finally(() => setStatsLoading(false));
|
||||
}, [ready, tab, statsDim, statsRange]);
|
||||
|
||||
const loadLogs = useCallback(async (page = 1) => {
|
||||
setLogsLoading(true);
|
||||
try {
|
||||
const r = await api.adminMonitorLogs({
|
||||
page,
|
||||
size: 20,
|
||||
method: logMethod || undefined,
|
||||
path: logPath || undefined,
|
||||
status: logStatus || undefined,
|
||||
ip: logIP || undefined,
|
||||
});
|
||||
setLogs(r.items || []);
|
||||
setLogsTotal(r.total || 0);
|
||||
setLogsPage(r.page || page);
|
||||
} catch {
|
||||
notify.error('加载请求日志失败');
|
||||
} finally {
|
||||
setLogsLoading(false);
|
||||
}
|
||||
}, [logMethod, logPath, logStatus, logIP]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || tab !== 'logs') return;
|
||||
loadLogs(1);
|
||||
}, [ready, tab, loadLogs]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || tab !== 'settings') return;
|
||||
api.adminMonitorSettings()
|
||||
.then((cfg) => {
|
||||
setSettings(cfg);
|
||||
setExcludeText((cfg.exclude_rules || []).join('\n'));
|
||||
})
|
||||
.catch(() => notify.error('加载设置失败'));
|
||||
}, [ready, tab]);
|
||||
|
||||
const enabled = overview?.enabled ?? settings?.enabled ?? false;
|
||||
|
||||
const metrics = useMemo(() => {
|
||||
if (!overview) return [];
|
||||
return [
|
||||
{ label: '浏览量', value: formatNum(overview.pageviews) },
|
||||
{ label: '访客数', value: formatNum(overview.visitors) },
|
||||
{ label: '独立 IP', value: formatNum(overview.unique_ips) },
|
||||
{ label: '流量', value: formatBytes(overview.traffic) },
|
||||
{ label: '蜘蛛', value: formatNum(overview.bots) },
|
||||
{ label: '请求数', value: formatNum(overview.requests) },
|
||||
{ label: '4xx', value: formatNum(overview.status_4xx) },
|
||||
{ label: '5xx', value: formatNum(overview.status_5xx) },
|
||||
];
|
||||
}, [overview]);
|
||||
|
||||
const saveSettings = async () => {
|
||||
if (!settings) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const rules = excludeText.split(/\r?\n/).map((s) => s.trim()).filter(Boolean);
|
||||
const r = await api.adminUpdateMonitorSettings({
|
||||
...settings,
|
||||
exclude_rules: rules,
|
||||
});
|
||||
setSettings(r.monitor);
|
||||
setExcludeText((r.monitor.exclude_rules || []).join('\n'));
|
||||
invalidateForumLimitsCache();
|
||||
notify.success(r.message);
|
||||
const ov = await api.adminMonitorOverview();
|
||||
setOverview(ov);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!ready || (loading && tab === 'overview')) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
|
||||
const tabs: { key: TabKey; label: string }[] = [
|
||||
{ key: 'overview', label: '概览' },
|
||||
{ key: 'stats', label: '访问统计' },
|
||||
{ key: 'logs', label: '请求日志' },
|
||||
{ key: 'settings', label: '设置' },
|
||||
];
|
||||
|
||||
const dims: { key: StatsDim; label: string }[] = [
|
||||
{ key: 'url', label: 'URL' },
|
||||
{ key: 'referer', label: '来源' },
|
||||
{ key: 'browser', label: '浏览器' },
|
||||
{ key: 'os', label: '系统' },
|
||||
{ key: 'device', label: '设备' },
|
||||
{ key: 'status', label: '状态码' },
|
||||
];
|
||||
|
||||
const ranges: { key: StatsRange; label: string }[] = [
|
||||
{ key: '1d', label: '今日' },
|
||||
{ key: '7d', label: '7 日' },
|
||||
{ key: '30d', label: '30 日' },
|
||||
{ key: '90d', label: '90 日' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="admin-page admin-monitor-page">
|
||||
<div className="admin-page-head">
|
||||
<div>
|
||||
<h1 className="admin-page-title">
|
||||
<Activity size={22} aria-hidden />
|
||||
网站监控
|
||||
</h1>
|
||||
<p className="admin-page-desc">
|
||||
浏览量/访客来自前台路由 pageview;请求数与日志来自服务端访问采集(含 /api)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-tabs" role="tablist" aria-label="监控分类">
|
||||
{tabs.map((t) => (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === t.key}
|
||||
className={cn('admin-tab', tab === t.key && 'active')}
|
||||
onClick={() => selectTab(t.key)}
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{!enabled && tab !== 'settings' && (
|
||||
<div className="admin-monitor-banner" role="status">
|
||||
<AlertTriangle size={16} aria-hidden />
|
||||
<span>访问采集尚未开启,指标可能为空。</span>
|
||||
<button type="button" className="admin-text-link" onClick={() => selectTab('settings')}>
|
||||
前往设置开启
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'overview' && overview && (
|
||||
<>
|
||||
<section className="admin-card admin-monitor-today" aria-label="今日状态">
|
||||
<div className="admin-monitor-today-head">
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2 className="admin-monitor-section-title">今日状态</h2>
|
||||
</div>
|
||||
<div className="admin-monitor-today-grid">
|
||||
{metrics.map((m) => (
|
||||
<div key={m.label} className="admin-monitor-today-item">
|
||||
<div className="admin-monitor-today-label">{m.label}</div>
|
||||
<div className="admin-monitor-today-value">{m.value}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-monitor-main">
|
||||
<section className="admin-card admin-monitor-map-panel">
|
||||
<div className="admin-card-head admin-monitor-map-head">
|
||||
<div className="admin-monitor-today-head" style={{ marginBottom: 0 }}>
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2 className="admin-monitor-section-title">访客地图(30 日)</h2>
|
||||
</div>
|
||||
<div className="admin-monitor-seg" role="group" aria-label="地图范围">
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-monitor-seg-btn', mapMode === 'world' && 'active')}
|
||||
onClick={() => setMapMode('world')}
|
||||
>
|
||||
世界
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={cn('admin-monitor-seg-btn', mapMode === 'china' && 'active')}
|
||||
onClick={() => setMapMode('china')}
|
||||
>
|
||||
中国
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card-body admin-monitor-map-body">
|
||||
<div className="admin-monitor-map-visual">
|
||||
{mapMode === 'world' ? (
|
||||
<MonitorWorldMap items={geoCountries(geo)} />
|
||||
) : (
|
||||
<MonitorChinaMap regions={geo?.regions || []} />
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-monitor-map-side">
|
||||
<GeoRankTable
|
||||
cities={geo?.cities || []}
|
||||
asns={geo?.asns || []}
|
||||
mode={mapMode}
|
||||
rankMode={rankMode}
|
||||
onRankMode={setRankMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div className="admin-monitor-rt-stack">
|
||||
<section className="admin-card admin-monitor-rt-card">
|
||||
<div className="admin-monitor-today-head">
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2 className="admin-monitor-section-title">实时请求数(1 分钟)</h2>
|
||||
</div>
|
||||
<div className="admin-monitor-rt-value">{formatNum(realtime?.requests_1m || 0)}</div>
|
||||
</section>
|
||||
<section className="admin-card admin-monitor-rt-card">
|
||||
<div className="admin-monitor-today-head">
|
||||
<span className="admin-monitor-section-bar" aria-hidden />
|
||||
<h2 className="admin-monitor-section-title">实时流量(1 分钟)</h2>
|
||||
</div>
|
||||
<div className="admin-monitor-rt-value">{formatBytes(realtime?.traffic_1m || 0)}</div>
|
||||
<p className="admin-monitor-muted admin-monitor-rt-spark-label">近 1 小时请求</p>
|
||||
<MiniSparkline series={realtime?.hourly_series || []} />
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === 'stats' && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-card-head">
|
||||
<div className="admin-tabs" style={{ marginBottom: 0 }}>
|
||||
{dims.map((d) => (
|
||||
<button
|
||||
key={d.key}
|
||||
type="button"
|
||||
className={cn('admin-tab', statsDim === d.key && 'active')}
|
||||
onClick={() => setStatsDim(d.key)}
|
||||
>
|
||||
{d.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="admin-tabs" style={{ marginBottom: 0 }}>
|
||||
{ranges.map((r) => (
|
||||
<button
|
||||
key={r.key}
|
||||
type="button"
|
||||
className={cn('admin-tab', statsRange === r.key && 'active')}
|
||||
onClick={() => setStatsRange(r.key)}
|
||||
>
|
||||
{r.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{statsLoading ? (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
) : statsItems.length === 0 ? (
|
||||
<p className="admin-empty">暂无统计数据</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style={{ width: 48 }}>#</th>
|
||||
<th>名称</th>
|
||||
<th style={{ width: 100 }}>数量</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{statsItems.map((item, idx) => (
|
||||
<tr key={`${item.key}-${idx}`}>
|
||||
<td>{idx + 1}</td>
|
||||
<td className="admin-table-mono" title={item.key}>{item.key}</td>
|
||||
<td>{formatNum(item.count)}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'logs' && (
|
||||
<div className="admin-card">
|
||||
<div className="admin-form-row admin-monitor-log-filters">
|
||||
<Input value={logMethod} onChange={(e) => setLogMethod(e.target.value)} placeholder="方法 GET" aria-label="方法" style={{ width: 100 }} />
|
||||
<Input value={logPath} onChange={(e) => setLogPath(e.target.value)} placeholder="路径包含…" aria-label="路径" />
|
||||
<Input value={logStatus} onChange={(e) => setLogStatus(e.target.value)} placeholder="状态码" aria-label="状态码" style={{ width: 90 }} />
|
||||
<Input value={logIP} onChange={(e) => setLogIP(e.target.value)} placeholder="IP 包含…" aria-label="IP" style={{ width: 140 }} />
|
||||
<Button type="button" variant="outline" onClick={() => loadLogs(1)}>筛选</Button>
|
||||
</div>
|
||||
<div className="admin-card-body" style={{ padding: 0 }}>
|
||||
{logsLoading ? (
|
||||
<div className="flex justify-center py-12"><Spinner /></div>
|
||||
) : logs.length === 0 ? (
|
||||
<p className="admin-empty">暂无请求日志</p>
|
||||
) : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>时间</th>
|
||||
<th>方法</th>
|
||||
<th>路径</th>
|
||||
<th>状态</th>
|
||||
<th>IP</th>
|
||||
<th>耗时</th>
|
||||
<th>地区</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{logs.map((row) => {
|
||||
const geoBits = [
|
||||
row.country ? countryLabel(row.country) : '',
|
||||
row.city || '',
|
||||
row.as_org || '',
|
||||
].filter(Boolean);
|
||||
return (
|
||||
<tr key={row.id}>
|
||||
<td>{formatTime(row.created_at)}</td>
|
||||
<td>{row.method}</td>
|
||||
<td className="admin-table-mono" title={row.path}>{row.path}</td>
|
||||
<td>{row.status}</td>
|
||||
<td className="admin-table-mono">
|
||||
{row.ip || '—'}
|
||||
{row.is_bot ? ' · bot' : ''}
|
||||
{(row.city || row.as_org) ? (
|
||||
<div className="admin-monitor-muted" style={{ fontSize: 12 }}>
|
||||
{[row.city, row.as_org].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td>{row.duration_ms}ms</td>
|
||||
<td title={geoBits.join(' · ')}>{row.country ? countryLabel(row.country) : '—'}</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{logsTotal > 20 && (
|
||||
<div className="admin-form-row" style={{ justifyContent: 'flex-end' }}>
|
||||
<Button type="button" variant="outline" disabled={logsPage <= 1} onClick={() => loadLogs(logsPage - 1)}>上一页</Button>
|
||||
<span className="admin-monitor-muted">第 {logsPage} 页 / 共 {logsTotal} 条</span>
|
||||
<Button type="button" variant="outline" disabled={logsPage * 20 >= logsTotal} onClick={() => loadLogs(logsPage + 1)}>下一页</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === 'settings' && settings && (
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">采集设置</div>
|
||||
<div className="admin-card-body admin-monitor-settings">
|
||||
<label className="admin-monitor-switch-row">
|
||||
<div>
|
||||
<strong>启用访问采集</strong>
|
||||
<p className="admin-monitor-muted">
|
||||
默认关闭。开启后:前台路由上报浏览量/访客;服务端记录请求日志与请求数(含 API)。后台与登录页不上报 pageview。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.enabled}
|
||||
onCheckedChange={(v) => setSettings({ ...settings, enabled: v })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-monitor-field">
|
||||
<span>浏览量保留天数(独立 monitor.db,1–365)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.retention_days}
|
||||
onChange={(e) => setSettings({ ...settings, retention_days: Number(e.target.value) || 30 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-monitor-field">
|
||||
<span>请求日志保留天数(JSONL,1–365)</span>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={365}
|
||||
value={settings.access_log_retention_days ?? 7}
|
||||
onChange={(e) => setSettings({ ...settings, access_log_retention_days: Number(e.target.value) || 7 })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-monitor-switch-row">
|
||||
<div>
|
||||
<strong>信任代理头</strong>
|
||||
<p className="admin-monitor-muted">从 X-Forwarded-For / CF-Connecting-IP 等读取真实 IP</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={settings.trust_proxy}
|
||||
onCheckedChange={(v) => setSettings({ ...settings, trust_proxy: v })}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="admin-monitor-field">
|
||||
<span>排除规则(每行一条:路径前缀或扩展名如 .js)</span>
|
||||
<textarea
|
||||
className="admin-monitor-textarea"
|
||||
rows={8}
|
||||
value={excludeText}
|
||||
onChange={(e) => setExcludeText(e.target.value)}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
style={{ marginTop: 8 }}
|
||||
onClick={() => setExcludeText((settings.default_exclude_rules || []).join('\n'))}
|
||||
>
|
||||
恢复推荐排除规则
|
||||
</Button>
|
||||
</label>
|
||||
|
||||
<div className="admin-monitor-geo-note">
|
||||
<strong>GeoIP(地理与运营商)</strong>
|
||||
<p className="admin-monitor-muted">
|
||||
国家/省/市由 IP2Location DB3 BIN 解析;ASN 由 GeoLite2-ASN.mmdb 解析(Docker 镜像不内置,需放入数据目录):
|
||||
</p>
|
||||
<ul className="admin-monitor-muted" style={{ margin: '8px 0 0', paddingLeft: 18 }}>
|
||||
<li>
|
||||
IPv4 BIN:<code>{settings.ip2location_v4_path || 'data/IP2LOCATION-LITE-DB3.BIN'}</code>
|
||||
{' · '}{settings.ip2location_v4_available ? '已加载' : '未检测到'}
|
||||
</li>
|
||||
<li>
|
||||
IPv6 BIN:<code>{settings.ip2location_v6_path || 'data/IP2LOCATION-LITE-DB3.IPV6.BIN'}</code>
|
||||
{' · '}{settings.ip2location_v6_available ? '已加载' : '未检测到'}
|
||||
</li>
|
||||
<li>
|
||||
ASN:<code>{settings.geoip_asn_path || 'data/GeoLite2-ASN.mmdb'}</code>
|
||||
{' · '}{settings.geoip_asn_available ? '已加载' : '未检测到'}
|
||||
</li>
|
||||
<li>
|
||||
Country 兜底:<code>{settings.geoip_country_path || 'data/GeoLite2-Country.mmdb'}</code>
|
||||
{' · '}{settings.geoip_country_available ? '已加载' : '未检测到(可选)'}
|
||||
</li>
|
||||
</ul>
|
||||
<p className="admin-monitor-muted" style={{ marginTop: 8 }}>
|
||||
请求日志目录:<code>{settings.access_log_dir || 'data/logs/access'}</code>
|
||||
(按日 JSONL 文件,删除过期文件即可释放磁盘)
|
||||
</p>
|
||||
<p className="admin-monitor-muted" style={{ marginTop: 8 }}>
|
||||
CDN 国家头(如 CF-IPCountry)仅在本地库无国家码时补全。管理端展示完整客户端 IP。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Button type="button" onClick={saveSettings} disabled={saving}>
|
||||
{saving ? '保存中…' : '保存设置'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -298,9 +298,12 @@ export default function AdminSettingsPage() {
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_show_showcase: false,
|
||||
aside_widgets: loadedAsideWidgets,
|
||||
nav_show_friend_links: true,
|
||||
footer_show_friend_links: true,
|
||||
nav_show_showcase: false,
|
||||
footer_show_showcase: false,
|
||||
feed_list_style: 'title',
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
@@ -1676,7 +1679,7 @@ export default function AdminSettingsPage() {
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">数据备份</div>
|
||||
<div className="admin-card-body admin-settings-backup-body">
|
||||
<p>导出当前 SQLite 数据库副本,便于迁移或灾难恢复。</p>
|
||||
<p>导出当前主库 SQLite 副本,便于迁移或灾难恢复。不含 <code>monitor.db</code> 浏览量。</p>
|
||||
<p className="admin-settings-backup-name">
|
||||
文件名:<code>jiang13_backup_YYYYMMDD_HHMMSS.db</code>
|
||||
</p>
|
||||
|
||||
@@ -7210,6 +7210,7 @@ a.waline-comment-author:hover {
|
||||
.widget-card-icon--hot { color: #e74c3c; }
|
||||
.widget-card-icon--notice { color: #3498db; }
|
||||
.widget-card-icon--links { color: #2d6a4f; }
|
||||
.widget-card-icon--showcase { color: #1d4ed8; }
|
||||
.widget-card-icon--users { color: #8b5cf6; }
|
||||
|
||||
.widget-card-head--split {
|
||||
@@ -11199,6 +11200,216 @@ button.profile-stat:hover strong {
|
||||
.admin-page-head { margin-bottom: 20px; }
|
||||
.admin-page-head h1 { font-size: 22px; font-weight: 700; margin-bottom: 4px; }
|
||||
.admin-page-head p { font-size: 13px; color: hsl(var(--muted-foreground)); }
|
||||
|
||||
/* ========== 仪表盘 bento ========== */
|
||||
.admin-dash-head-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.admin-dash-head-title h1 { margin-bottom: 0; }
|
||||
.admin-dash-section-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.admin-dash-section-label h2 {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-dash-bento {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.15fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
margin-bottom: 20px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.admin-dash-traffic {
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--card));
|
||||
padding: 18px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 220px;
|
||||
}
|
||||
.admin-dash-traffic-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
.admin-dash-traffic-top .admin-dash-section-label { margin-bottom: 0; }
|
||||
.admin-dash-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 13px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.admin-dash-traffic-pv {
|
||||
font-size: 48px;
|
||||
font-weight: 700;
|
||||
color: var(--j13-green);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.1;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.admin-dash-traffic-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 16px;
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.admin-dash-traffic-uv {
|
||||
color: hsl(var(--foreground));
|
||||
font-weight: 500;
|
||||
}
|
||||
.admin-dash-traffic-trend {
|
||||
margin-top: 10px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.admin-dash-traffic-trend.is-up { color: var(--j13-green); }
|
||||
.admin-dash-traffic-trend.is-down { color: hsl(24 90% 42%); }
|
||||
.admin-dash-traffic-total {
|
||||
margin-top: auto;
|
||||
padding-top: 16px;
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
.admin-dash-traffic-hint { opacity: 0.85; margin-left: 4px; }
|
||||
.admin-dash-traffic-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
padding: 12px 0;
|
||||
}
|
||||
|
||||
.admin-dash-queue {
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 12px;
|
||||
background: hsl(var(--card));
|
||||
padding: 18px 20px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 220px;
|
||||
}
|
||||
.admin-dash-queue-head { margin-bottom: 12px; }
|
||||
.admin-dash-queue-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
}
|
||||
.admin-dash-queue-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
text-align: left;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--j13-border);
|
||||
background: hsl(var(--background));
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.admin-dash-queue-card:hover {
|
||||
border-color: color-mix(in srgb, var(--j13-green) 45%, var(--j13-border));
|
||||
background: var(--j13-green-bg);
|
||||
}
|
||||
.admin-dash-queue-card.has-work {
|
||||
border-color: hsl(24 80% 55% / 0.45);
|
||||
background: hsl(24 90% 48% / 0.06);
|
||||
}
|
||||
.admin-dash-queue-card.has-work:hover {
|
||||
border-color: hsl(24 80% 50%);
|
||||
background: hsl(24 90% 48% / 0.1);
|
||||
}
|
||||
.admin-dash-queue-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.admin-dash-queue-count {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
line-height: 1;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.admin-dash-queue-card.has-work .admin-dash-queue-count {
|
||||
color: hsl(24 90% 40%);
|
||||
}
|
||||
.dark .admin-dash-queue-card.has-work .admin-dash-queue-count {
|
||||
color: hsl(24 95% 68%);
|
||||
}
|
||||
.admin-dash-queue-label {
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.admin-dash-queue-hint {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.admin-dash-scale {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.admin-dash-metric-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.admin-dash-metric-card {
|
||||
padding: 16px 18px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--j13-border);
|
||||
background: hsl(var(--card));
|
||||
}
|
||||
.admin-dash-metric-value {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--foreground));
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.admin-dash-metric-label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.admin-dash-bento {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.admin-dash-traffic-pv {
|
||||
font-size: 40px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.admin-dash-metric-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.admin-section-label {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
margin-bottom: 10px; font-size: 13px; font-weight: 600;
|
||||
@@ -15091,3 +15302,339 @@ button.post-poll__option,
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
/* ========== 网站监控 ========== */
|
||||
.admin-monitor-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 16px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid color-mix(in srgb, var(--j13-green) 30%, var(--j13-border));
|
||||
background: var(--j13-green-bg);
|
||||
font-size: 13px;
|
||||
color: var(--color-text-2);
|
||||
}
|
||||
|
||||
.admin-monitor-section-bar {
|
||||
display: inline-block;
|
||||
width: 3px;
|
||||
height: 14px;
|
||||
border-radius: 2px;
|
||||
background: var(--j13-green);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.admin-monitor-today-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-monitor-section-title {
|
||||
margin: 0;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
|
||||
.admin-monitor-today {
|
||||
padding: 16px 20px 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
.admin-monitor-today-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 8px 12px;
|
||||
}
|
||||
.admin-monitor-today-item {
|
||||
min-width: 0;
|
||||
text-align: left;
|
||||
padding: 4px 8px;
|
||||
}
|
||||
.admin-monitor-today-label {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.admin-monitor-today-value {
|
||||
font-size: 22px;
|
||||
font-weight: 700;
|
||||
color: var(--j13-green);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.2;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.admin-monitor-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(240px, 0.75fr);
|
||||
gap: 16px;
|
||||
align-items: stretch;
|
||||
}
|
||||
.admin-monitor-map-panel .admin-card-head,
|
||||
.admin-monitor-map-head {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.admin-monitor-map-body {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.4fr) minmax(160px, 0.7fr);
|
||||
gap: 16px;
|
||||
min-height: 320px;
|
||||
padding-top: 12px;
|
||||
}
|
||||
.admin-monitor-map-visual {
|
||||
position: relative;
|
||||
min-height: 280px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, hsl(var(--muted)) 35%, hsl(var(--card)));
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-monitor-map-side {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
max-height: 360px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.admin-monitor-seg {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--j13-border);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: hsl(var(--background));
|
||||
}
|
||||
.admin-monitor-seg-btn {
|
||||
height: 28px;
|
||||
padding: 0 12px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--muted-foreground));
|
||||
font-size: 12px;
|
||||
font-family: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-monitor-seg-btn + .admin-monitor-seg-btn {
|
||||
border-left: 1px solid var(--j13-border);
|
||||
}
|
||||
.admin-monitor-seg-btn.active {
|
||||
background: var(--j13-green);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-monitor-svg-wrap {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
.admin-monitor-svg-map {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-height: 280px;
|
||||
}
|
||||
.admin-monitor-svg-path {
|
||||
cursor: default;
|
||||
transition: fill 0.15s ease, opacity 0.15s ease;
|
||||
}
|
||||
.admin-monitor-svg-path.has-data {
|
||||
cursor: pointer;
|
||||
}
|
||||
.admin-monitor-svg-path.has-data:hover {
|
||||
opacity: 0.88;
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
.admin-monitor-map-tip {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
bottom: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, hsl(var(--card)) 92%, transparent);
|
||||
border: 1px solid var(--j13-border);
|
||||
font-size: 12px;
|
||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.06);
|
||||
pointer-events: none;
|
||||
}
|
||||
.admin-monitor-map-tip strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-monitor-map-tip span {
|
||||
color: var(--j13-green);
|
||||
font-variant-numeric: tabular-nums;
|
||||
font-weight: 600;
|
||||
}
|
||||
.admin-monitor-map-empty-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
background: color-mix(in srgb, hsl(var(--card)) 55%, transparent);
|
||||
pointer-events: none;
|
||||
padding: 16px;
|
||||
}
|
||||
|
||||
.admin-monitor-map-side .admin-monitor-seg {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.admin-monitor-rank .admin-monitor-geo-empty {
|
||||
margin-top: 4px;
|
||||
}
|
||||
.admin-monitor-geo-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-monitor-geo-table th,
|
||||
.admin-monitor-geo-table td {
|
||||
padding: 8px 10px;
|
||||
text-align: left;
|
||||
}
|
||||
.admin-monitor-geo-table th:last-child,
|
||||
.admin-monitor-geo-table td:last-child {
|
||||
text-align: right;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.admin-monitor-geo-table thead th {
|
||||
background: var(--j13-green);
|
||||
color: #fff;
|
||||
font-weight: 600;
|
||||
font-size: 12px;
|
||||
}
|
||||
.admin-monitor-geo-table thead th:first-child {
|
||||
border-radius: 6px 0 0 0;
|
||||
}
|
||||
.admin-monitor-geo-table thead th:last-child {
|
||||
border-radius: 0 6px 0 0;
|
||||
}
|
||||
.admin-monitor-geo-table tbody tr:nth-child(even) {
|
||||
background: var(--j13-green-bg);
|
||||
}
|
||||
.admin-monitor-geo-table tbody td {
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
color: hsl(var(--foreground));
|
||||
}
|
||||
.admin-monitor-geo-empty {
|
||||
padding: 24px 8px;
|
||||
text-align: center;
|
||||
font-size: 13px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
}
|
||||
|
||||
.admin-monitor-rt-stack {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-monitor-rt-card {
|
||||
padding: 16px 20px 20px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.admin-monitor-rt-value {
|
||||
font-size: 40px;
|
||||
font-weight: 700;
|
||||
color: var(--j13-green);
|
||||
font-variant-numeric: tabular-nums;
|
||||
line-height: 1.15;
|
||||
margin: 8px 0 4px;
|
||||
}
|
||||
.admin-monitor-rt-spark-label {
|
||||
margin: 16px 0 6px;
|
||||
}
|
||||
.admin-monitor-spark {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
}
|
||||
.admin-monitor-muted {
|
||||
font-size: 12px;
|
||||
color: hsl(var(--muted-foreground));
|
||||
line-height: 1.5;
|
||||
}
|
||||
.admin-monitor-log-filters {
|
||||
border-bottom: 1px solid var(--j13-border);
|
||||
}
|
||||
.admin-monitor-settings {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
.admin-monitor-switch-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
.admin-monitor-field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.admin-monitor-textarea {
|
||||
width: 100%;
|
||||
min-height: 140px;
|
||||
padding: 10px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--j13-border);
|
||||
background: hsl(var(--background));
|
||||
color: inherit;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
resize: vertical;
|
||||
}
|
||||
.admin-monitor-geo-note code {
|
||||
display: inline-block;
|
||||
margin: 0 4px;
|
||||
padding: 1px 6px;
|
||||
border-radius: 4px;
|
||||
background: var(--j13-green-bg);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-monitor-today-grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
@media (max-width: 960px) {
|
||||
.admin-monitor-main,
|
||||
.admin-monitor-map-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.admin-monitor-rt-value {
|
||||
font-size: 32px;
|
||||
}
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.admin-monitor-today-grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.dark .admin-monitor-today-value,
|
||||
.dark .admin-monitor-rt-value {
|
||||
color: var(--j13-green);
|
||||
}
|
||||
.dark .admin-monitor-map-visual {
|
||||
background: color-mix(in srgb, hsl(var(--muted)) 40%, hsl(var(--card)));
|
||||
}
|
||||
.dark .admin-monitor-geo-table thead th {
|
||||
background: var(--j13-green);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
|
||||
17
frontend/src/types/svg-maps.d.ts
vendored
Normal file
17
frontend/src/types/svg-maps.d.ts
vendored
Normal file
@@ -0,0 +1,17 @@
|
||||
declare module '@svg-maps/world' {
|
||||
const map: {
|
||||
label: string;
|
||||
viewBox: string;
|
||||
locations: Array<{ id: string; name: string; path: string }>;
|
||||
};
|
||||
export default map;
|
||||
}
|
||||
|
||||
declare module '@svg-maps/china' {
|
||||
const map: {
|
||||
label: string;
|
||||
viewBox: string;
|
||||
locations: Array<{ id: string; name: string; path: string }>;
|
||||
};
|
||||
export default map;
|
||||
}
|
||||
@@ -1,19 +1,29 @@
|
||||
import type { AsideWidget, AsideWidgetId, ForumLimits, ForumLimitsPublic } from '../api/types';
|
||||
import { DEFAULT_ASIDE_WIDGETS } from '../api/types';
|
||||
|
||||
const ASIDE_WIDGET_IDS: AsideWidgetId[] = ['tag_cloud', 'recent_comments', 'recent_users', 'friend_links'];
|
||||
const ASIDE_WIDGET_IDS: AsideWidgetId[] = [
|
||||
'tag_cloud',
|
||||
'recent_comments',
|
||||
'recent_users',
|
||||
'friend_links',
|
||||
'showcase',
|
||||
];
|
||||
|
||||
/** 从 limits 解析右侧栏组件列表(兼容仅有布尔开关的旧数据) */
|
||||
export function resolveAsideWidgets(
|
||||
limits: Pick<ForumLimitsPublic, 'aside_widgets' | 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'>,
|
||||
limits: Partial<Pick<
|
||||
ForumLimitsPublic,
|
||||
'aside_widgets' | 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links' | 'aside_show_showcase'
|
||||
>>,
|
||||
): AsideWidget[] {
|
||||
if (limits.aside_widgets?.length) {
|
||||
return normalizeAsideWidgets(limits.aside_widgets);
|
||||
}
|
||||
return [
|
||||
{ id: 'tag_cloud', enabled: limits.aside_show_tag_cloud },
|
||||
{ id: 'recent_comments', enabled: limits.aside_show_recent_comments },
|
||||
{ id: 'friend_links', enabled: limits.aside_show_friend_links },
|
||||
{ id: 'tag_cloud', enabled: !!limits.aside_show_tag_cloud },
|
||||
{ id: 'recent_comments', enabled: !!limits.aside_show_recent_comments },
|
||||
{ id: 'friend_links', enabled: limits.aside_show_friend_links !== false },
|
||||
{ id: 'showcase', enabled: !!limits.aside_show_showcase },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -33,12 +43,16 @@ export function normalizeAsideWidgets(widgets: AsideWidget[]): AsideWidget[] {
|
||||
}
|
||||
|
||||
/** 将 aside_widgets 同步回 ForumLimits 布尔字段 */
|
||||
export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<ForumLimits, 'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links'> {
|
||||
export function syncAsideBoolsFromWidgets(widgets: AsideWidget[]): Pick<
|
||||
ForumLimits,
|
||||
'aside_show_tag_cloud' | 'aside_show_recent_comments' | 'aside_show_friend_links' | 'aside_show_showcase'
|
||||
> {
|
||||
const normalized = normalizeAsideWidgets(widgets);
|
||||
return {
|
||||
aside_show_tag_cloud: normalized.find(w => w.id === 'tag_cloud')?.enabled ?? false,
|
||||
aside_show_recent_comments: normalized.find(w => w.id === 'recent_comments')?.enabled ?? false,
|
||||
aside_show_friend_links: normalized.find(w => w.id === 'friend_links')?.enabled ?? true,
|
||||
aside_show_showcase: normalized.find(w => w.id === 'showcase')?.enabled ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -64,6 +78,7 @@ export function isAsideWidgetEnabled(widgets: AsideWidget[], id: AsideWidgetId):
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: false,
|
||||
aside_show_showcase: false,
|
||||
}).find(w => w.id === id)?.enabled ?? false;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user