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: except Exception as e:
return jsonify({"code": 2003, "data": str(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']) @app.route('/api/teapot', methods=['GET'])
def return_418(): def return_418():

View File

@@ -55,6 +55,11 @@ export interface StaticsData {
images: number; images: number;
} }
export interface HotTopicItem {
name: string;
count: number;
}
export const testApiStatus = async (): Promise<boolean> => { export const testApiStatus = async (): Promise<boolean> => {
try { try {
const response = await fetch('/api/test'); 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) => { const handlePostApiCode = (json: any) => {
if (json && json.code === 2004) { if (json && json.code === 2004) {
notifyInvalidIdentity(); notifyInvalidIdentity();

View File

@@ -12,6 +12,7 @@ import {
useToastController, useToastController,
Toast, Toast,
ToastTitle, ToastTitle,
Divider,
} from '@fluentui/react-components'; } from '@fluentui/react-components';
import { import {
CheckmarkCircle20Filled, CheckmarkCircle20Filled,
@@ -19,8 +20,8 @@ import {
ArrowClockwise20Regular ArrowClockwise20Regular
} from '@fluentui/react-icons'; } from '@fluentui/react-icons';
import { useLayout } from '../context/LayoutContext'; import { useLayout } from '../context/LayoutContext';
import { testApiStatus, getStatics } from '../api'; import { testApiStatus, getStatics, getHotTopics } from '../api';
import type { StaticsData } from '../api'; import type { StaticsData, HotTopicItem } from '../api';
const useStyles = makeStyles({ const useStyles = makeStyles({
container: { container: {
@@ -71,31 +72,55 @@ const useStyles = makeStyles({
headerText: { headerText: {
fontSize: tokens.fontSizeBase300, fontSize: tokens.fontSizeBase300,
fontWeight: tokens.fontWeightSemibold, 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 styles = useStyles();
const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout(); const { toasterId, refreshTrigger, staticsRefreshTrigger } = useLayout();
const { dispatchToast } = useToastController(toasterId); const { dispatchToast } = useToastController(toasterId);
const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null); const [isApiOnline, setIsApiOnline] = useState<boolean | null>(null);
const [statics, setStatics] = useState<StaticsData | null>(null); const [statics, setStatics] = useState<StaticsData | null>(null);
const [topics, setTopics] = useState<HotTopicItem[]>([]);
const [isTestLoading, setIsTestLoading] = useState<boolean>(true); const [isTestLoading, setIsTestLoading] = useState<boolean>(true);
const [isStaticsLoading, setIsStaticsLoading] = useState<boolean>(true); const [isStaticsLoading, setIsStaticsLoading] = useState<boolean>(true);
const [isTopicsLoading, setIsTopicsLoading] = useState<boolean>(true);
const checkStatus = useCallback(async () => { const checkStatus = useCallback(async () => {
const online = await testApiStatus(); const online = await testApiStatus();
setIsApiOnline(online); setIsApiOnline(online);
setIsTestLoading(false); setIsTestLoading(false);
}, []); }, []);
const refreshStatics = useCallback(async (isManual: boolean = false) => { const refreshWidgets = useCallback(async (isManual: boolean = false) => {
setIsStaticsLoading(true); setIsStaticsLoading(true);
setIsTopicsLoading(true);
try { try {
const data = await getStatics(); const [staticsData, topicsData] = await Promise.all([getStatics(), getHotTopics()]);
setStatics(data); setStatics(staticsData);
setTopics(topicsData);
if (isManual) { if (isManual) {
dispatchToast( dispatchToast(
<Toast> <Toast>
<ToastTitle></ToastTitle> <ToastTitle></ToastTitle>
</Toast>, </Toast>,
{ intent: 'success' } { intent: 'success' }
); );
@@ -103,15 +128,17 @@ const StatusDisplay: React.FC = () => {
} catch (error) { } catch (error) {
console.error('Failed to refresh statics:', error); console.error('Failed to refresh statics:', error);
setStatics(null); // 失败时重置数据 setStatics(null); // 失败时重置数据
setTopics([]);
} finally { } finally {
setIsStaticsLoading(false); setIsStaticsLoading(false);
setIsTopicsLoading(false);
} }
}, [dispatchToast]); }, [dispatchToast]);
useEffect(() => { useEffect(() => {
// 初始加载 // 初始加载
checkStatus(); checkStatus();
refreshStatics(false); refreshWidgets(false);
// 每 10 秒测试一次后端 API 状态 // 每 10 秒测试一次后端 API 状态
const testInterval = setInterval(checkStatus, 10000); const testInterval = setInterval(checkStatus, 10000);
@@ -119,20 +146,20 @@ const StatusDisplay: React.FC = () => {
return () => { return () => {
clearInterval(testInterval); clearInterval(testInterval);
}; };
}, [checkStatus, refreshStatics]); }, [checkStatus, refreshWidgets]);
// Listen for global refresh trigger // Listen for global refresh trigger
useEffect(() => { useEffect(() => {
if (refreshTrigger > 0) { if (refreshTrigger > 0) {
refreshStatics(false); refreshWidgets(false);
} }
}, [refreshTrigger, refreshStatics]); }, [refreshTrigger, refreshWidgets]);
useEffect(() => { useEffect(() => {
if (staticsRefreshTrigger > 0) { if (staticsRefreshTrigger > 0) {
refreshStatics(false); refreshWidgets(false);
} }
}, [staticsRefreshTrigger, refreshStatics]); }, [staticsRefreshTrigger, refreshWidgets]);
return ( return (
<div className={styles.container}> <div className={styles.container}>
@@ -164,16 +191,6 @@ const StatusDisplay: React.FC = () => {
<Card className={styles.card}> <Card className={styles.card}>
<CardHeader <CardHeader
header={<Text className={styles.headerText}></Text>} 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}> <div className={styles.statsContainer}>
{isStaticsLoading && !statics ? ( {isStaticsLoading && !statics ? (
@@ -198,8 +215,41 @@ const StatusDisplay: React.FC = () => {
)} )}
</div> </div>
</Card> </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> </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 Header from './components/Header';
import Sidebar from './components/Sidebar'; import Sidebar from './components/Sidebar';
import Footer from './components/Footer'; import Footer from './components/Footer';
import StatusDisplay from '../components/StatusDisplay'; import Widgets from '../components/Widgets';
import { LayoutProvider, useLayout } from '../context/LayoutContext'; import { LayoutProvider, useLayout } from '../context/LayoutContext';
const useStyles = makeStyles({ const useStyles = makeStyles({
@@ -66,7 +66,7 @@ const LayoutContent = ({ toasterId, overlays }: { toasterId: string; overlays?:
<Outlet /> <Outlet />
</main> </main>
<aside className={styles.rightPanel}> <aside className={styles.rightPanel}>
<StatusDisplay /> <Widgets />
</aside> </aside>
</div> </div>
<Footer /> <Footer />