diff --git a/scripts/diag-news.cjs b/scripts/diag-news.cjs new file mode 100644 index 0000000..c81b3ea --- /dev/null +++ b/scripts/diag-news.cjs @@ -0,0 +1,33 @@ +// 临时诊断: 检查 News 表 content/summary 的换行与长度情况 (用完即删) +require('dotenv').config() +const mysql = require('mysql2/promise') + +function parseUrl(url) { + const u = new URL(url) + return { host: u.hostname, port: Number(u.port) || 3306, user: decodeURIComponent(u.username), password: decodeURIComponent(u.password), database: u.pathname.replace(/^\//, '') } +} + +async function main() { + const conn = await mysql.createConnection(parseUrl(process.env.DATABASE_URL)) + const [rows] = await conn.execute( + 'SELECT id, title, summary, content, CHAR_LENGTH(content) AS clen, CHAR_LENGTH(summary) AS slen FROM `News` ORDER BY id DESC LIMIT 10' + ) + console.log('总条数检查:') + const [[cnt]] = await conn.execute('SELECT COUNT(*) AS c, SUM(content IS NULL) AS nullContent FROM `News`') + console.log(JSON.stringify(cnt)) + for (const r of rows) { + const content = r.content || '' + const summary = r.summary || '' + const lf = (content.match(/\n/g) || []).length + const cr = (content.match(/\r/g) || []).length + const sLf = (summary.match(/\n/g) || []).length + console.log('---') + console.log('id=' + r.id + ' clen=' + r.clen + ' slen=' + r.slen + ' contentLF=' + lf + ' contentCR=' + cr + ' summaryLF=' + sLf) + console.log('title: ' + r.title) + console.log('summary: ' + JSON.stringify(summary.slice(0, 120))) + console.log('content(hex首200): ' + content.slice(0, 200).replace(/[^\x20-\x7E\u4e00-\u9fa5]/g, (c) => '\\x' + c.charCodeAt(0).toString(16))) + console.log('content(带转义首300): ' + JSON.stringify(content.slice(0, 300))) + } + await conn.end() +} +main().catch((e) => { console.error(e); process.exit(1) }) diff --git a/scripts/fetchers/news.fetcher.js b/scripts/fetchers/news.fetcher.js index 0ec476a..e2b0f4f 100644 --- a/scripts/fetchers/news.fetcher.js +++ b/scripts/fetchers/news.fetcher.js @@ -55,6 +55,12 @@ function parseNewsSheet(sheet, impactBase = 0) { const publishedAt = parsePublishedAt(r[2]) if (!publishedAt) continue const body = String(r[1] || '').trim() + // 数据源段落间用全角空格连接, 入库前统一转成换行, 保证详情页有段落感 + .replace(/\u3000+/g, '\n') + .replace(/\u00a0/g, ' ') + .replace(/[ \t]+\n/g, '\n') + .replace(/\n{3,}/g, '\n\n') + .trim() out.push({ title, summary: body.slice(0, 180), diff --git a/src/layouts/MobileShell.jsx b/src/layouts/MobileShell.jsx index 286dffe..9b07fa1 100644 --- a/src/layouts/MobileShell.jsx +++ b/src/layouts/MobileShell.jsx @@ -1,4 +1,5 @@ -import { Outlet } from 'react-router-dom' +import { useEffect, useState } from 'react' +import { Outlet, useLocation } from 'react-router-dom' import { SafeArea } from 'antd-mobile' import BottomNav from '../components/BottomNav.jsx' @@ -8,11 +9,23 @@ import BottomNav from '../components/BottomNav.jsx' // - 底部 TabBar: antd-mobile TabBar 自带底部安全区 // 解决: 状态栏遮挡/底部 TabBar 遮挡/左右贴边 export default function MobileShell() { + const location = useLocation() + // pathname 变化时给页面容器加动画 class (仅播放入场动画, 不重挂载子树, + // 避免破坏 MacroPage 等页内子路由的挂载状态与滚动位置) + const [animating, setAnimating] = useState(false) + useEffect(() => { + setAnimating(true) + const t = setTimeout(() => setAnimating(false), 320) + return () => clearTimeout(t) + }, [location.pathname]) + return (
- +
+ +
diff --git a/src/pages/MonitorPage.jsx b/src/pages/MonitorPage.jsx index 78b32c1..001b50a 100644 --- a/src/pages/MonitorPage.jsx +++ b/src/pages/MonitorPage.jsx @@ -51,17 +51,21 @@ export default function MonitorPage() { ) } +// 主机监控列表模块级缓存: 切走再进入时先展示上次数据, 拉取成功后再静默更新 +let cachedServers = null + // 主机监控: komari 实时节点列表 (30s 轮询, 静默刷新) function ServerView() { - const [items, setItems] = useState(null) // null = 加载中 + const [items, setItems] = useState(cachedServers) // 有缓存直接先展示, 无缓存为 null 进入加载态 const [err, setErr] = useState('') const navigate = useNavigate() const load = useCallback((silent) => { - if (!silent) { setErr(''); setItems(null) } + if (!silent && cachedServers === null) setItems(null) // 仅无缓存时显示加载中 + if (!silent) setErr('') api('/servers') - .then((data) => { setErr(''); setItems(data.items || []) }) - .catch((e) => { if (!silent) { setErr(e.message); setItems([]) } }) + .then((data) => { cachedServers = data.items || []; setErr(''); setItems(data.items || []) }) + .catch((e) => { if (!silent) { setErr(e.message); if (cachedServers === null) setItems([]) } }) }, []) useEffect(() => { @@ -76,7 +80,7 @@ function ServerView() { {err && (
{err} - 重试 + load(false)}>重试
)} {items !== null && !err && items.length === 0 && } diff --git a/src/pages/news/NewsDetail.jsx b/src/pages/news/NewsDetail.jsx index a60235d..8cdcb86 100644 --- a/src/pages/news/NewsDetail.jsx +++ b/src/pages/news/NewsDetail.jsx @@ -13,6 +13,18 @@ function fmtDate(s) { return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate()) } +// 正文规范化: 数据源(妙想 MCP)的段落间用全角空格 \u3000 连接而非换行, +// 这里统一转为换行, 保证详情页有段落感; 换行本身存储/传输是完好的 +function normalizeContent(s) { + if (!s) return s + return s + .replace(/\u3000+/g, '\n') // 连续全角空格(段落分隔) → 换行 + .replace(/\u00a0/g, ' ') // 不换行空格 → 普通空格 + .replace(/[ \t]+\n/g, '\n') // 行尾多余空白 + .replace(/\n{3,}/g, '\n\n') // 压缩连续空行 + .trim() +} + export default function NewsDetail() { const { id } = useParams() const navigate = useNavigate() @@ -46,7 +58,7 @@ export default function NewsDetail() { )}
- {item.content ? item.content : item.summary} + {normalizeContent(item.content ? item.content : item.summary)} {!item.content &&
(本条暂无全文,仅摘要)
}
diff --git a/src/styles/globals.css b/src/styles/globals.css index e738236..39dbfcd 100644 --- a/src/styles/globals.css +++ b/src/styles/globals.css @@ -58,6 +58,15 @@ input, textarea, select { font: inherit; } -webkit-overflow-scrolling: touch; } +/* 页面切换动画: pathname 变化时整页淡入 + 轻微上移 (由 MobileShell 控制 class) */ +.page-transition.active { + animation: page-in 0.28s ease; +} +@keyframes page-in { + from { opacity: 0; transform: translateY(14px); } + to { opacity: 1; transform: translateY(0); } +} + /* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Me/Article) */ .page-with-navbar { display: flex; flex-direction: column; min-height: 100%; } .page-with-navbar .adm-error-block { flex: 1; } @@ -214,7 +223,7 @@ input, textarea, select { font: inherit; } .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-summary { font-size: 12px; color: var(--color-muted); margin-top: 4px; line-height: 1.5; word-break: break-word; overflow-wrap: anywhere; } .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; } .news-card:active .adm-card-body { background: #f5f5f5; }