Add hot topics widget

This commit is contained in:
LeonspaceX
2026-01-30 15:50:20 +08:00
parent d787785660
commit c30803f073
4 changed files with 112 additions and 27 deletions

View File

@@ -0,0 +1,255 @@
// v1的时候就连统计信息也5秒获取一次不妥所以改成逻辑触发刷新
import React, { useState, useEffect, useCallback } from 'react';
import {
makeStyles,
tokens,
Card,
CardHeader,
Text,
Spinner,
Badge,
Button,
useToastController,
Toast,
ToastTitle,
Divider,
} from '@fluentui/react-components';
import {
CheckmarkCircle20Filled,
DismissCircle20Filled,
ArrowClockwise20Regular
} from '@fluentui/react-icons';
import { useLayout } from '../context/LayoutContext';
import { testApiStatus, getStatics, getHotTopics } from '../api';
import type { StaticsData, HotTopicItem } from '../api';
const useStyles = makeStyles({
container: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalS,
width: '100%',
},
card: {
width: '100%',
},
statusContainer: {
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
paddingBottom: tokens.spacingVerticalM,
},
statusText: {
display: 'flex',
alignItems: 'center',
gap: tokens.spacingHorizontalSNudge,
},
online: {
color: tokens.colorStatusSuccessForeground1,
fontSize: tokens.fontSizeBase200,
},
offline: {
color: tokens.colorStatusDangerForeground1,
fontSize: tokens.fontSizeBase200,
},
statsContainer: {
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
paddingBottom: tokens.spacingVerticalM,
display: 'flex',
flexDirection: 'column',
gap: '4px',
},
statRow: {
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
},
refreshButton: {
minWidth: 'auto',
padding: '2px',
},
labelText: {
fontSize: tokens.fontSizeBase200,
},
headerText: {
fontSize: tokens.fontSizeBase300,
fontWeight: tokens.fontWeightSemibold,
},
topicList: {
padding: `${tokens.spacingVerticalS} ${tokens.spacingHorizontalM}`,
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalS,
},
topicRow: {
display: 'flex',
flexDirection: 'column',
gap: tokens.spacingVerticalXXS,
padding: `${tokens.spacingVerticalS} 0`,
},
topicName: {
fontSize: tokens.fontSizeBase300,
color: tokens.colorNeutralForeground1,
},
topicCount: {
fontSize: tokens.fontSizeBase200,
color: tokens.colorNeutralForeground3,
},
});
const Widgets: React.FC = () => {
const styles = useStyles();
const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout();
const { dispatchToast } = useToastController(toasterId);
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
const [statics, setStatics] = useState<StaticsData | null>(null);
const [topics, setTopics] = useState<HotTopicItem[]>([]);
const [isTestLoading, setIsTestLoading] = useState<boolean>(true);
const [isStaticsLoading, setIsStaticsLoading] = useState<boolean>(true);
const [isTopicsLoading, setIsTopicsLoading] = useState<boolean>(true);
const checkStatus = useCallback(async () => {
const online = await testApiStatus();
setIsApiOnline(online);
setIsTestLoading(false);
}, []);
const refreshWidgets = useCallback(async (isManual: boolean = false) => {
setIsStaticsLoading(true);
setIsTopicsLoading(true);
try {
const [staticsData, topicsData] = await Promise.all([getStatics(), getHotTopics()]);
setStatics(staticsData);
setTopics(topicsData);
if (isManual) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'success' }
);
}
} catch (error) {
console.error('Failed to refresh statics:', error);
setStatics(null); // 失败时重置数据
setTopics([]);
} finally {
setIsStaticsLoading(false);
setIsTopicsLoading(false);
}
}, [dispatchToast]);
useEffect(() => {
// 初始加载
checkStatus();
refreshWidgets(false);
// 每 10 秒测试一次后端 API 状态
const testInterval = setInterval(checkStatus, 10000);
return () => {
clearInterval(testInterval);
};
}, [checkStatus, refreshWidgets]);
// Listen for global refresh trigger
useEffect(() => {
if (refreshTrigger > 0) {
refreshWidgets(false);
}
}, [refreshTrigger, refreshWidgets]);
useEffect(() => {
if (staticsRefreshTrigger > 0) {
refreshWidgets(false);
}
}, [staticsRefreshTrigger, refreshWidgets]);
return (
<div className={styles.container}>
<Card className={styles.card}>
<CardHeader
header={<Text className={styles.headerText}></Text>}
/>
<div className={styles.statusContainer}>
{isTestLoading && isApiOnline === null ? (
<Spinner size="tiny" label="检查中..." />
) : (
<div className={styles.statusText}>
{isApiOnline ? (
<>
<CheckmarkCircle20Filled fontSize={16} className={styles.online} />
<Text className={styles.online}>线</Text>
</>
) : (
<>
<DismissCircle20Filled fontSize={16} className={styles.offline} />
<Text className={styles.offline}>线</Text>
</>
)}
</div>
)}
</div>
</Card>
<Card className={styles.card}>
<CardHeader
header={<Text className={styles.headerText}></Text>}
/>
<div className={styles.statsContainer}>
{isStaticsLoading && !statics ? (
<Spinner size="tiny" label="加载中..." />
) : statics ? (
<>
<div className={styles.statRow}>
<Text className={styles.labelText}>稿:</Text>
<Badge size="small" appearance="outline" color="brand">{statics.posts}</Badge>
</div>
<div className={styles.statRow}>
<Text className={styles.labelText}>:</Text>
<Badge size="small" appearance="outline" color="brand">{statics.comments}</Badge>
</div>
<div className={styles.statRow}>
<Text className={styles.labelText}>:</Text>
<Badge size="small" appearance="outline" color="brand">{statics.images}</Badge>
</div>
</>
) : (
<Text className={styles.labelText} italic></Text>
)}
</div>
</Card>
<Card className={styles.card}>
<CardHeader
header={<Text className={styles.headerText}># </Text>}
action={
<Button
className={styles.refreshButton}
appearance="subtle"
icon={<ArrowClockwise20Regular fontSize={16} />}
onClick={() => refreshWidgets(true)}
disabled={isTopicsLoading || isStaticsLoading}
title="刷新小组件数据"
/>
}
/>
<div className={styles.topicList}>
{isTopicsLoading ? (
<Spinner size="tiny" label="加载中..." />
) : topics.length === 0 ? (
<Text className={styles.labelText} italic></Text>
) : (
topics.map((topic, index) => (
<React.Fragment key={`${topic.name}-${index}`}>
<div className={styles.topicRow}>
<Text className={styles.topicName}>#{topic.name}</Text>
<Text className={styles.topicCount}>{topic.count}稿</Text>
</div>
{index < topics.length - 1 && <Divider />}
</React.Fragment>
))
)}
</div>
</Card>
</div>
);
};
export default Widgets;