Files
v2/front/src/components/Widgets.tsx
2026-01-30 15:50:20 +08:00

256 lines
7.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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;