Add hot topics widget
This commit is contained in:
13
back/main.py
13
back/main.py
@@ -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():
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
@@ -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 />
|
||||
|
||||
Reference in New Issue
Block a user