Compare commits
2 Commits
4f39f428bd
...
6bacda05f8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6bacda05f8 | ||
|
|
f2e53df25b |
33
scripts/diag-news.cjs
Normal file
33
scripts/diag-news.cjs
Normal file
@ -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) })
|
||||||
@ -55,6 +55,12 @@ function parseNewsSheet(sheet, impactBase = 0) {
|
|||||||
const publishedAt = parsePublishedAt(r[2])
|
const publishedAt = parsePublishedAt(r[2])
|
||||||
if (!publishedAt) continue
|
if (!publishedAt) continue
|
||||||
const body = String(r[1] || '').trim()
|
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({
|
out.push({
|
||||||
title,
|
title,
|
||||||
summary: body.slice(0, 180),
|
summary: body.slice(0, 180),
|
||||||
|
|||||||
@ -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 { SafeArea } from 'antd-mobile'
|
||||||
import BottomNav from '../components/BottomNav.jsx'
|
import BottomNav from '../components/BottomNav.jsx'
|
||||||
|
|
||||||
@ -8,11 +9,23 @@ import BottomNav from '../components/BottomNav.jsx'
|
|||||||
// - 底部 TabBar: antd-mobile TabBar 自带底部安全区
|
// - 底部 TabBar: antd-mobile TabBar 自带底部安全区
|
||||||
// 解决: 状态栏遮挡/底部 TabBar 遮挡/左右贴边
|
// 解决: 状态栏遮挡/底部 TabBar 遮挡/左右贴边
|
||||||
export default function MobileShell() {
|
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 (
|
return (
|
||||||
<div className="app-root">
|
<div className="app-root">
|
||||||
<SafeArea position="top" />
|
<SafeArea position="top" />
|
||||||
<main className="app-main">
|
<main className="app-main">
|
||||||
<Outlet />
|
<div className={'page-transition' + (animating ? ' active' : '')}>
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<BottomNav />
|
<BottomNav />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { useNavigate } from 'react-router-dom'
|
import { useNavigate } from 'react-router-dom'
|
||||||
import { Avatar, List, NavBar, Toast } from 'antd-mobile'
|
import { Avatar, List, NavBar, Toast } from 'antd-mobile'
|
||||||
import { useAuth } from '../auth/AuthContext.jsx'
|
import { useAuth } from '../auth/AuthContext.jsx'
|
||||||
|
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||||
|
|
||||||
// 我的页: 顶部 NavBar + 用户头像卡片 + 信息列表
|
// 我的页: 顶部 NavBar + 用户头像卡片 + 信息列表
|
||||||
// 使用 antd-mobile: NavBar / List / Avatar / Toast
|
// 使用 antd-mobile: NavBar / List / Avatar / Toast
|
||||||
@ -44,6 +45,7 @@ export default function MePage() {
|
|||||||
退出登录
|
退出登录
|
||||||
</List.Item>
|
</List.Item>
|
||||||
</List>
|
</List>
|
||||||
|
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,17 +51,21 @@ export default function MonitorPage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 主机监控列表模块级缓存: 切走再进入时先展示上次数据, 拉取成功后再静默更新
|
||||||
|
let cachedServers = null
|
||||||
|
|
||||||
// 主机监控: komari 实时节点列表 (30s 轮询, 静默刷新)
|
// 主机监控: komari 实时节点列表 (30s 轮询, 静默刷新)
|
||||||
function ServerView() {
|
function ServerView() {
|
||||||
const [items, setItems] = useState(null) // null = 加载中
|
const [items, setItems] = useState(cachedServers) // 有缓存直接先展示, 无缓存为 null 进入加载态
|
||||||
const [err, setErr] = useState('')
|
const [err, setErr] = useState('')
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
|
|
||||||
const load = useCallback((silent) => {
|
const load = useCallback((silent) => {
|
||||||
if (!silent) { setErr(''); setItems(null) }
|
if (!silent && cachedServers === null) setItems(null) // 仅无缓存时显示加载中
|
||||||
|
if (!silent) setErr('')
|
||||||
api('/servers')
|
api('/servers')
|
||||||
.then((data) => { setErr(''); setItems(data.items || []) })
|
.then((data) => { cachedServers = data.items || []; setErr(''); setItems(data.items || []) })
|
||||||
.catch((e) => { if (!silent) { setErr(e.message); setItems([]) } })
|
.catch((e) => { if (!silent) { setErr(e.message); if (cachedServers === null) setItems([]) } })
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -76,7 +80,7 @@ function ServerView() {
|
|||||||
{err && (
|
{err && (
|
||||||
<div className="error-banner">
|
<div className="error-banner">
|
||||||
{err}
|
{err}
|
||||||
<span className="retry-link" onClick={load}>重试</span>
|
<span className="retry-link" onClick={() => load(false)}>重试</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{items !== null && !err && items.length === 0 && <Empty description="暂无主机" />}
|
{items !== null && !err && items.length === 0 && <Empty description="暂无主机" />}
|
||||||
|
|||||||
@ -13,6 +13,18 @@ function fmtDate(s) {
|
|||||||
return d.getFullYear() + '-' + pad(d.getMonth() + 1) + '-' + pad(d.getDate())
|
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() {
|
export default function NewsDetail() {
|
||||||
const { id } = useParams()
|
const { id } = useParams()
|
||||||
const navigate = useNavigate()
|
const navigate = useNavigate()
|
||||||
@ -46,7 +58,7 @@ export default function NewsDetail() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="news-content">
|
<div className="news-content">
|
||||||
{item.content ? item.content : item.summary}
|
{normalizeContent(item.content ? item.content : item.summary)}
|
||||||
{!item.content && <div className="news-content-empty">(本条暂无全文,仅摘要)</div>}
|
{!item.content && <div className="news-content-empty">(本条暂无全文,仅摘要)</div>}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -58,6 +58,15 @@ input, textarea, select { font: inherit; }
|
|||||||
-webkit-overflow-scrolling: touch;
|
-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) */
|
/* 带有 antd-mobile NavBar 的页面 (Home/Monitor/Me/Article) */
|
||||||
.page-with-navbar { display: flex; flex-direction: column; min-height: 100%; }
|
.page-with-navbar { display: flex; flex-direction: column; min-height: 100%; }
|
||||||
.page-with-navbar .adm-error-block { flex: 1; }
|
.page-with-navbar .adm-error-block { flex: 1; }
|
||||||
@ -195,6 +204,7 @@ input, textarea, select { font: inherit; }
|
|||||||
.me-list { margin-top: 12px; }
|
.me-list { margin-top: 12px; }
|
||||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||||
.me-logout { color: var(--color-danger); }
|
.me-logout { color: var(--color-danger); }
|
||||||
|
.me-version { text-align: center; font-size: 12px; color: var(--color-muted); padding: 18px 0 8px; }
|
||||||
/* 首页模块: 报警 + 新闻 */
|
/* 首页模块: 报警 + 新闻 */
|
||||||
.home-content { padding: 0 0 16px; }
|
.home-content { padding: 0 0 16px; }
|
||||||
.home-content .adm-tabs-content { --content-padding: 0; }
|
.home-content .adm-tabs-content { --content-padding: 0; }
|
||||||
@ -213,7 +223,7 @@ input, textarea, select { font: inherit; }
|
|||||||
.news-card.adm-card { border-radius: 10px; margin: 0; }
|
.news-card.adm-card { border-radius: 10px; margin: 0; }
|
||||||
.news-card .adm-card-body { padding: 10px 12px; }
|
.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-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-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-rank { color: var(--color-primary); font-weight: 600; }
|
||||||
.news-card:active .adm-card-body { background: #f5f5f5; }
|
.news-card:active .adm-card-body { background: #f5f5f5; }
|
||||||
|
|||||||
11
src/version.js
Normal file
11
src/version.js
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
// 应用版本信息唯一汇聚点
|
||||||
|
// - APP_VERSION: 唯一手动管理点 = package.json 的 version 字段 (发版时改那里)
|
||||||
|
// - BUILD_ID: 每次编译自动生成 (vite.config.js define 注入 __BUILD_ID__), 无需手动维护
|
||||||
|
import pkg from '../package.json'
|
||||||
|
|
||||||
|
export const APP_VERSION = pkg.version || '0.0.0'
|
||||||
|
// __BUILD_ID__ 机制:
|
||||||
|
// - build 时: vite:define 编译期替换为字符串字面量
|
||||||
|
// - dev 时: vite 通过 /@vite/env 把值挂到 globalThis, 源码保持原样;
|
||||||
|
// typeof 防御避免 /@vite/env 未执行时 ReferenceError, 回退 'dev'
|
||||||
|
export const BUILD_ID = typeof __BUILD_ID__ !== 'undefined' ? __BUILD_ID__ : 'dev'
|
||||||
@ -1,12 +1,24 @@
|
|||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
|
||||||
|
// 每次编译生成唯一 build 号 (YYYYMMDD.HHmmss), 通过 define 注入到 __BUILD_ID__
|
||||||
|
// 版本号 APP_VERSION 唯一管理点在 package.json 的 version 字段 (手动修改)
|
||||||
|
function makeBuildId() {
|
||||||
|
const d = new Date()
|
||||||
|
const p = (n, w) => String(n).padStart(w || 2, '0')
|
||||||
|
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}.${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`
|
||||||
|
}
|
||||||
|
const BUILD_ID = makeBuildId()
|
||||||
|
|
||||||
// 移动端优先: 默认按移动视口开发, 同时兼容桌面浏览器
|
// 移动端优先: 默认按移动视口开发, 同时兼容桌面浏览器
|
||||||
// HMR 走 WebSocket; 某些受限 webview (VS Code Simple Browser / 老版 Android WebView) 不支持,
|
// HMR 走 WebSocket; 某些受限 webview (VS Code Simple Browser / 老版 Android WebView) 不支持,
|
||||||
// 此时 Vite 会自动降级为整页刷新兜底 (full-reload 与 HMR 共用同一 WS 通道)。
|
// 此时 Vite 会自动降级为整页刷新兜底 (full-reload 与 HMR 共用同一 WS 通道)。
|
||||||
// 注意: 不能设 hmr: false —— 那会连 WS 通道一起关掉, 文件变更不再触发任何刷新。
|
// 注意: 不能设 hmr: false —— 那会连 WS 通道一起关掉, 文件变更不再触发任何刷新。
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
|
define: {
|
||||||
|
__BUILD_ID__: JSON.stringify(BUILD_ID), // 构建时生成的唯一 build 号
|
||||||
|
},
|
||||||
server: {
|
server: {
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
port: 5173,
|
port: 5173,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user