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:
|
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():
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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 />
|
||||||
|
|||||||
Reference in New Issue
Block a user