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

@@ -582,6 +582,19 @@ def get_post_info():
except Exception as e:
return jsonify({"code": 2003, "data": str(e)})
@app.route('/api/hot_topics', methods=['GET'])
def get_hot_topics():
try:
rows = db.session.query(
Hashtag.name,
db.func.count(Hashtag.name).label('count')
).group_by(Hashtag.name).order_by(db.func.count(Hashtag.name).desc()).limit(5).all()
data = [{"name": name, "count": int(count)} for name, count in rows]
return jsonify({"code": 1000, "data": {"list": data}})
except Exception as e:
return jsonify({"code": 2003, "data": str(e)})
# --- 彩蛋 ---
@app.route('/api/teapot', methods=['GET'])
def return_418():

View File

@@ -55,6 +55,11 @@ export interface StaticsData {
images: number;
}
export interface HotTopicItem {
name: string;
count: number;
}
export const testApiStatus = async (): Promise<boolean> => {
try {
const response = await fetch('/api/test');
@@ -123,6 +128,23 @@ const notifyInvalidIdentity = () => {
}
};
export const getHotTopics = async (): Promise<HotTopicItem[]> => {
try {
const response = await fetch('/api/hot_topics');
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const json = await response.json();
if (json.code === 1000 && json.data && Array.isArray(json.data.list)) {
return json.data.list as HotTopicItem[];
}
throw new Error('Invalid response code or missing data');
} catch (error) {
console.error('Failed to fetch hot topics:', error);
throw error;
}
};
const handlePostApiCode = (json: any) => {
if (json && json.code === 2004) {
notifyInvalidIdentity();

View File

@@ -12,6 +12,7 @@ import {
useToastController,
Toast,
ToastTitle,
Divider,
} from '@fluentui/react-components';
import {
CheckmarkCircle20Filled,
@@ -19,8 +20,8 @@ import {
ArrowClockwise20Regular
} from '@fluentui/react-icons';
import { useLayout } from '../context/LayoutContext';
import { testApiStatus, getStatics } from '../api';
import type { StaticsData } from '../api';
import { testApiStatus, getStatics, getHotTopics } from '../api';
import type { StaticsData, HotTopicItem } from '../api';
const useStyles = makeStyles({
container: {
@@ -71,31 +72,55 @@ const useStyles = makeStyles({
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 StatusDisplay: React.FC = () => {
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 refreshStatics = useCallback(async (isManual: boolean = false) => {
const refreshWidgets = useCallback(async (isManual: boolean = false) => {
setIsStaticsLoading(true);
setIsTopicsLoading(true);
try {
const data = await getStatics();
setStatics(data);
const [staticsData, topicsData] = await Promise.all([getStatics(), getHotTopics()]);
setStatics(staticsData);
setTopics(topicsData);
if (isManual) {
dispatchToast(
<Toast>
<ToastTitle></ToastTitle>
<ToastTitle></ToastTitle>
</Toast>,
{ intent: 'success' }
);
@@ -103,15 +128,17 @@ const StatusDisplay: React.FC = () => {
} catch (error) {
console.error('Failed to refresh statics:', error);
setStatics(null); // 失败时重置数据
setTopics([]);
} finally {
setIsStaticsLoading(false);
setIsTopicsLoading(false);
}
}, [dispatchToast]);
useEffect(() => {
// 初始加载
checkStatus();
refreshStatics(false);
refreshWidgets(false);
// 每 10 秒测试一次后端 API 状态
const testInterval = setInterval(checkStatus, 10000);
@@ -119,20 +146,20 @@ const StatusDisplay: React.FC = () => {
return () => {
clearInterval(testInterval);
};
}, [checkStatus, refreshStatics]);
}, [checkStatus, refreshWidgets]);
// Listen for global refresh trigger
useEffect(() => {
if (refreshTrigger > 0) {
refreshStatics(false);
refreshWidgets(false);
}
}, [refreshTrigger, refreshStatics]);
}, [refreshTrigger, refreshWidgets]);
useEffect(() => {
if (staticsRefreshTrigger > 0) {
refreshStatics(false);
refreshWidgets(false);
}
}, [staticsRefreshTrigger, refreshStatics]);
}, [staticsRefreshTrigger, refreshWidgets]);
return (
<div className={styles.container}>
@@ -164,16 +191,6 @@ const StatusDisplay: React.FC = () => {
<Card className={styles.card}>
<CardHeader
header={<Text className={styles.headerText}></Text>}
action={
<Button
className={styles.refreshButton}
appearance="subtle"
icon={<ArrowClockwise20Regular fontSize={16} />}
onClick={() => refreshStatics(true)}
disabled={isStaticsLoading}
title="刷新统计数据"
/>
}
/>
<div className={styles.statsContainer}>
{isStaticsLoading && !statics ? (
@@ -198,8 +215,41 @@ const StatusDisplay: React.FC = () => {
)}
</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 StatusDisplay;
export default Widgets;

View File

@@ -4,7 +4,7 @@ import { Outlet } from 'react-router-dom';
import Header from './components/Header';
import Sidebar from './components/Sidebar';
import Footer from './components/Footer';
import StatusDisplay from '../components/StatusDisplay';
import Widgets from '../components/Widgets';
import { LayoutProvider, useLayout } from '../context/LayoutContext';
const useStyles = makeStyles({
@@ -66,7 +66,7 @@ const LayoutContent = ({ toasterId, overlays }: { toasterId: string; overlays?:
<Outlet />
</main>
<aside className={styles.rightPanel}>
<StatusDisplay />
<Widgets />
</aside>
</div>
<Footer />