更新首页
This commit is contained in:
+157
-9
@@ -1,16 +1,164 @@
|
||||
import { ErrorBlock, NavBar } from 'antd-mobile'
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { NavBar, List, Card, Empty, DotLoading } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
|
||||
// 首页: 报警模块 + 新闻模块
|
||||
// - 报警: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值 (按时间倒序)
|
||||
// - 新闻: mx_finance_search_news 每日 3 点拉取国内外经济政治新闻 (按时间倒序)
|
||||
// - 两个模块均无限滚动, 触底自动加载下一页
|
||||
function fmtDateTime(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes())
|
||||
}
|
||||
function fmtDate(s) {
|
||||
if (!s) return ''
|
||||
const d = new Date(s)
|
||||
if (isNaN(d.getTime())) return s
|
||||
const pad = (n) => String(n).padStart(2, '0')
|
||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
||||
}
|
||||
|
||||
// 首页: 暂留占位, 后续接入监控告警 / 个性化推荐
|
||||
// 使用 antd-mobile ErrorBlock 仿原生空状态页
|
||||
export default function HomePage() {
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null}>首页</NavBar>
|
||||
<ErrorBlock
|
||||
status="empty"
|
||||
title="首页"
|
||||
description="后续接入: 监控告警 / 个性化推荐 / 收藏的指标"
|
||||
/>
|
||||
<div className="home-content">
|
||||
<AlertSection />
|
||||
<NewsSection />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 报警模块
|
||||
function AlertSection() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (loading || done) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('alerts load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loading, done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-title">报警</div>
|
||||
{items.length === 0 && !loading ? (
|
||||
<Empty description="暂无报警" />
|
||||
) : (
|
||||
<div className="section-list">
|
||||
{items.map((a) => (
|
||||
<Card key={a.id} className="alert-card">
|
||||
<div className="alert-title">
|
||||
<span className="alert-name">{a.indicatorName}</span>
|
||||
<span className="alert-period">{a.period}</span>
|
||||
</div>
|
||||
<div className="alert-diff">
|
||||
{a.sourceA} = {a.valueA} vs {a.sourceB} = {a.valueB}
|
||||
<span className="alert-ratio"> 差异 {(a.diffRatio * 100).toFixed(1)}%</span>
|
||||
</div>
|
||||
<div className="alert-time">{fmtDateTime(a.createdAt)}</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 新闻模块
|
||||
function NewsSection() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (loading || done) return
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/news?limit=10' + (cursorRef.current ? '&cursor=' + encodeURIComponent(cursorRef.current) : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('news load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [loading, done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
useEffect(() => {
|
||||
if (done || items.length === 0) return
|
||||
const el = sentinelRef.current
|
||||
if (!el) return
|
||||
const io = new IntersectionObserver((entries) => {
|
||||
if (entries[0].isIntersecting) fetchPage()
|
||||
}, { rootMargin: '200px' })
|
||||
io.observe(el)
|
||||
return () => io.disconnect()
|
||||
}, [items.length, done, fetchPage])
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-title">新闻</div>
|
||||
{items.length === 0 && !loading ? (
|
||||
<Empty description="暂无新闻" />
|
||||
) : (
|
||||
<div className="section-list">
|
||||
{items.map((n) => (
|
||||
<Card key={n.id} className="news-card">
|
||||
<div className="news-title">{n.title}</div>
|
||||
{n.summary && <div className="news-summary">{n.summary}</div>}
|
||||
<div className="news-meta">
|
||||
<span className="news-source">{n.source || '未知来源'}</span>
|
||||
<span className="news-time">{fmtDate(n.publishedAt)}</span>
|
||||
<span className="news-rank">#{n.impactRank}</span>
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
<div ref={sentinelRef} className="scroll-sentinel">
|
||||
{loading ? <DotLoading /> : (done ? '— 已加载全部 —' : '')}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
+21
-1
@@ -194,4 +194,24 @@ input, textarea, select { font: inherit; }
|
||||
.me-email { font-size: 13px; opacity: .85; }
|
||||
.me-list { margin-top: 12px; }
|
||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||
.me-logout { color: var(--color-danger); }
|
||||
.me-logout { color: var(--color-danger); }
|
||||
/* 首页模块: 报警 + 新闻 */
|
||||
.home-content { padding: 0 0 16px; }
|
||||
.home-section { padding: 0 12px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; padding: 14px 4px 8px; color: var(--color-text); }
|
||||
.section-list { display: flex; flex-direction: column; gap: 8px; }
|
||||
.alert-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.alert-card .adm-card-body { padding: 10px 12px; }
|
||||
.alert-title { display: flex; justify-content: space-between; font-weight: 600; font-size: 14px; }
|
||||
.alert-name { color: var(--color-danger); }
|
||||
.alert-period { color: var(--color-muted); font-weight: 400; }
|
||||
.alert-diff { font-size: 12px; color: var(--color-text); margin-top: 4px; }
|
||||
.alert-ratio { color: var(--color-danger); font-weight: 600; margin-left: 6px; }
|
||||
.alert-time { font-size: 11px; color: var(--color-muted); margin-top: 4px; }
|
||||
.news-card.adm-card { border-radius: 10px; margin: 0; }
|
||||
.news-card .adm-card-body { padding: 10px 12px; }
|
||||
.news-title { font-size: 14px; font-weight: 600; line-height: 1.4; color: var(--color-text); }
|
||||
.news-summary { font-size: 12px; color: var(--color-muted); margin-top: 4px; line-height: 1.5; max-height: 3.6em; overflow: hidden; }
|
||||
.news-meta { display: flex; gap: 10px; font-size: 11px; color: var(--color-muted); margin-top: 6px; }
|
||||
.news-rank { color: var(--color-primary); font-weight: 600; }
|
||||
.scroll-sentinel { text-align: center; padding: 14px 0 4px; color: var(--color-muted); font-size: 12px; }
|
||||
|
||||
Reference in New Issue
Block a user