feat(V0.2): OTA 版本与判新口径 + Tab 结构调整
1) 版本对齐分支 - package.json / tauri.conf.json / Cargo.toml / Cargo.lock: 0.1.0 -> 0.2.0, OTA 包版本变为 0.2.0-ota - 新增 scripts/ci/check-version.mjs: 分支 dev-x.y 与 package.json 版本不一致时 CI 直接失败 - docker-build.yml / ota-build.yml: min_binary_version 与前端版本解耦, 固定 0.1.0 (现有 APK 原生层仍是 0.1.0, 抬高会导致旧 APK 收不到更新) 2) 我的页 OTA「开发版本」开关 (默认关, 持久化 localStorage.iboard_ota_dev_mode) - 关: 按版本号逐字段比较 (src/utils/version.js), 服务端 version 更高才算新 - 开: 按 build sequence 判新 (hotswap checkUpdate), 每次构建都能推到设备 - Web 端: 开发口径退化为版本不同即算新; 生产口径严格比较大小 3) 打卡 Tab 移到最左并默认选中, 移除特效开关 - BottomNav: 打卡/宏观/监控/我; 删除 home 图标 - App: / 与 404 均重定向到 /checkin - RingProgress/Celebrate: 删除 effectsEnabled/setEffectsEnabled, 特效恒开 - HabitView: 去掉头部「特效开/关」按钮 4) 取消首页 Tab, 报警/新闻并入宏观 - 新增 pages/macro/AlertsView.jsx、NewsView.jsx; 删除 HomePage.jsx - MacroPage: 6 个子 Tab (油价/经济数据/策略研究/经济文章/报警/新闻), 报警角标保留 - 新闻详情 /news/:id -> /macro/news/:id (宏观 Tab 保持高亮) 5) 文档: 需求文档补 F-OT 需求点与已决策项; 设计文档新增 §10 变更记录与判新设计; 界面设计.html 11 屏 (Tab 4 项/去掉特效开关/宏观合并/我的 OTA 开关 关+开 两态)
This commit is contained in:
+10
-9
@@ -1,8 +1,6 @@
|
||||
import { Routes, Route, Navigate, useLocation } from 'react-router-dom'
|
||||
import MobileShell from './layouts/MobileShell.jsx'
|
||||
import LoginPage from './pages/LoginPage.jsx'
|
||||
import HomePage from './pages/HomePage.jsx'
|
||||
import NewsDetail from './pages/news/NewsDetail.jsx'
|
||||
import MacroPage from './pages/MacroPage.jsx'
|
||||
import MonitorPage from './pages/MonitorPage.jsx'
|
||||
import ServerDetail from './pages/monitor/ServerDetail.jsx'
|
||||
@@ -10,11 +8,14 @@ import MePage from './pages/MePage.jsx'
|
||||
import CheckinPage from './pages/checkin/CheckinPage.jsx'
|
||||
import { useAuth } from './auth/AuthContext.jsx'
|
||||
|
||||
// 已登录访问 /login 自动回首页
|
||||
// 默认落地页: 打卡 (V0.2 起打卡是第一个 Tab 且为默认选中)
|
||||
const HOME_PATH = '/checkin'
|
||||
|
||||
// 已登录访问 /login 自动回默认页
|
||||
function RedirectIfAuthed({ children }) {
|
||||
const { user, loading } = useAuth()
|
||||
if (loading) return null
|
||||
if (user) return <Navigate to="/" replace />
|
||||
if (user) return <Navigate to={HOME_PATH} replace />
|
||||
return children
|
||||
}
|
||||
|
||||
@@ -32,16 +33,16 @@ export default function App() {
|
||||
<Routes>
|
||||
<Route path="/login" element={<RedirectIfAuthed><LoginPage /></RedirectIfAuthed>} />
|
||||
<Route element={<RequireAuth><MobileShell /></RequireAuth>}>
|
||||
<Route path="/" element={<HomePage />} />
|
||||
<Route path="/news/:id" element={<NewsDetail />} />
|
||||
{/* 打卡为默认首页 */}
|
||||
<Route path="/" element={<Navigate to={HOME_PATH} replace />} />
|
||||
<Route path="/checkin" element={<CheckinPage />} />
|
||||
{/* 宏观: 油价 / 经济数据 / 策略研究 / 经济文章 / 报警 / 新闻 (V0.2 原首页并入) */}
|
||||
<Route path="/macro/*" element={<MacroPage />} />
|
||||
<Route path="/monitor" element={<MonitorPage />} />
|
||||
<Route path="/monitor/server/:uuid" element={<ServerDetail />} />
|
||||
{/* V0.2 打卡 / 日历 / 清单 */}
|
||||
<Route path="/checkin" element={<CheckinPage />} />
|
||||
<Route path="/me" element={<MePage />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/" replace />} />
|
||||
<Route path="*" element={<Navigate to={HOME_PATH} replace />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
+99
-17
@@ -5,7 +5,7 @@
|
||||
// 设计原则:
|
||||
// - Web 端只能"查 + 提示", 不能真下载应用, 避免误导
|
||||
// - Tauri 端走 hotswap 插件的官方 API, 签名校验/回滚/资源替换全交给插件
|
||||
import { getApiBase, getToken } from './client.js'
|
||||
import { getApiBase } from './client.js'
|
||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||
|
||||
// 判断是否在 Tauri WebView 内 (有 __TAURI_INTERNALS__ 即 Tauri 环境)
|
||||
@@ -33,6 +33,47 @@ export function initOta() {
|
||||
.catch((e) => console.warn('[ota] notifyReady failed:', e))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// 判新模式 (V0.2)
|
||||
// 「开发版本」开关 (我的页 OTA 组内, 默认关闭), 值持久化在设备端 localStorage:
|
||||
// 开关 关闭 (默认 / 生产口径): 用**版本号**判新 —— 取服务端 manifest 的 version,
|
||||
// 与当前运行版本按数字字段逐段比较大小 (0.2.0 > 0.1.0)。
|
||||
// 这样即使某个旧版本分支被重新构建 (sequence 变大但版本号更小), 也不会把新设备拉回旧版。
|
||||
// 开关 打开 (开发口径): 用 **build sequence** 判新 —— 直接走插件 checkUpdate(),
|
||||
// 只要服务端 sequence 比本地大就算新, 每次提交都能推到设备上, 便于联调。
|
||||
// ---------------------------------------------------------------------------
|
||||
const OTA_DEV_MODE_KEY = 'iboard_ota_dev_mode'
|
||||
|
||||
export function getOtaDevMode() {
|
||||
try {
|
||||
return localStorage.getItem(OTA_DEV_MODE_KEY) === '1'
|
||||
} catch { /* ignore */ }
|
||||
return false
|
||||
}
|
||||
|
||||
export function setOtaDevMode(v) {
|
||||
try { localStorage.setItem(OTA_DEV_MODE_KEY, v ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 版本号 → 数字字段数组 / 比较函数 (纯函数放在 utils, 便于单测)
|
||||
import { compareVersion, parseVersionFields } from '../utils/version.js'
|
||||
|
||||
export { compareVersion, parseVersionFields }
|
||||
|
||||
// 拉服务端最新 manifest (不分平台)
|
||||
// /api/ota/check/0: current_sequence 传 0, 只要有 OTA 包就一定返回 200 + manifest
|
||||
export async function fetchOtaManifest() {
|
||||
try {
|
||||
const res = await fetch(resolveUrl('/api/ota/check/0'), { method: 'GET' })
|
||||
if (res.status === 204) return null
|
||||
if (!res.ok) return null
|
||||
return await res.json()
|
||||
} catch (e) {
|
||||
console.warn('[ota] manifest 拉取失败:', e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 检查更新
|
||||
// 返回统一结构, 调用方不关心是 Tauri 还是 web:
|
||||
// {
|
||||
@@ -40,34 +81,71 @@ export function initOta() {
|
||||
// reason: 'no_package' | 'same_version' | 'ota_disabled' | 'unsupported' | 'ok',
|
||||
// info: { version, sequence, notes, mandatory, bundle_size }
|
||||
// }
|
||||
export async function checkOtaUpdate() {
|
||||
// opts.devMode: true=按 build sequence 判新, false=按版本号判新 (默认)
|
||||
export async function checkOtaUpdate(opts) {
|
||||
const devMode = !!(opts && opts.devMode)
|
||||
if (!inTauri) {
|
||||
return checkOtaUpdateWeb()
|
||||
return checkOtaUpdateWeb(devMode)
|
||||
}
|
||||
return checkOtaUpdateTauri()
|
||||
return checkOtaUpdateTauri(devMode)
|
||||
}
|
||||
|
||||
async function checkOtaUpdateTauri() {
|
||||
// 走 hotswap 插件 (内部会带 sequence / platform / arch 等参数)
|
||||
const { checkUpdate } = await import('tauri-plugin-hotswap-api')
|
||||
const r = await checkUpdate()
|
||||
if (!r.available) {
|
||||
return { available: false, reason: 'no_package', info: r }
|
||||
// 取"当前运行版本": 已应用 OTA 覆盖包则用覆盖包版本, 否则用内置包版本
|
||||
async function currentRunningVersion() {
|
||||
try {
|
||||
const v = await getOtaVersionInfo()
|
||||
if (v?.active && v.version) return v.version
|
||||
} catch { /* ignore */ }
|
||||
return APP_VERSION
|
||||
}
|
||||
|
||||
// 版本号口径判新 (Tauri + Web 共用): 服务端 version 比当前运行版本大才算新
|
||||
async function checkByVersion() {
|
||||
const [manifest, current] = await Promise.all([fetchOtaManifest(), currentRunningVersion()])
|
||||
if (!manifest || !manifest.version) {
|
||||
return { available: false, reason: 'no_package', info: null }
|
||||
}
|
||||
if (compareVersion(manifest.version, current) <= 0) {
|
||||
return { available: false, reason: 'same_version', info: manifest }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
reason: 'ok',
|
||||
info: {
|
||||
version: r.version,
|
||||
sequence: r.sequence,
|
||||
notes: r.notes,
|
||||
mandatory: r.mandatory,
|
||||
bundle_size: r.bundle_size,
|
||||
version: manifest.version,
|
||||
sequence: manifest.sequence,
|
||||
notes: manifest.notes || '',
|
||||
mandatory: !!manifest.mandatory,
|
||||
bundle_size: manifest.bundle_size || 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function checkOtaUpdateWeb() {
|
||||
async function checkOtaUpdateTauri(devMode) {
|
||||
// 开发口径: sequence 判新, 直接走插件 (插件内部用当前 sequence 请求 /api/ota/check/:seq)
|
||||
if (devMode) {
|
||||
const { checkUpdate } = await import('tauri-plugin-hotswap-api')
|
||||
const r = await checkUpdate()
|
||||
if (!r.available) {
|
||||
return { available: false, reason: 'no_package', info: r }
|
||||
}
|
||||
return {
|
||||
available: true,
|
||||
reason: 'ok',
|
||||
info: {
|
||||
version: r.version,
|
||||
sequence: r.sequence,
|
||||
notes: r.notes,
|
||||
mandatory: r.mandatory,
|
||||
bundle_size: r.bundle_size,
|
||||
},
|
||||
}
|
||||
}
|
||||
// 生产口径: 版本号判新
|
||||
return checkByVersion()
|
||||
}
|
||||
|
||||
async function checkOtaUpdateWeb(devMode) {
|
||||
// Web 端不能下载, 只能拿到版本号/构建号做比对, 提示用户刷新
|
||||
try {
|
||||
const res = await fetch(resolveUrl('/api/ota/manifest-web'), { method: 'GET' })
|
||||
@@ -76,7 +154,11 @@ async function checkOtaUpdateWeb() {
|
||||
}
|
||||
const data = await res.json()
|
||||
if (!data.available) return { available: false, reason: 'no_package', info: data }
|
||||
if (data.build_id && data.build_id === BUILD_ID) {
|
||||
// Web 端拿不到本地 sequence:
|
||||
// 开发口径 (devMode) → 只要服务端版本与本地不同就算新 (近似 sequence 语义, 含回退)
|
||||
// 生产口径 → 严格比较版本号大小, 只有更高版本才算新
|
||||
const cmp = compareVersion(data.version, APP_VERSION)
|
||||
if (devMode ? cmp === 0 : cmp <= 0) {
|
||||
return { available: false, reason: 'same_version', info: data }
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -4,24 +4,23 @@ import TabIcon from './TabIcon.jsx'
|
||||
|
||||
// 底部 Tab 配置: key 既是路由前缀, 又是 icon 名
|
||||
// 使用 antd-mobile TabBar: 自带 safe-area 处理, 仿原生 iOS/Android 风格
|
||||
// V0.2: 打卡置最左并为默认 Tab; 原「首页」的报警/新闻并入「宏观」, 不再有首页 Tab
|
||||
const TABS = [
|
||||
{ key: '/', name: 'home', label: '首页' },
|
||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||
{ key: '/macro', name: 'macro', label: '宏观' },
|
||||
{ key: '/monitor', name: 'monitor', label: '监控' },
|
||||
{ key: '/checkin', name: 'checkin', label: '打卡' },
|
||||
{ key: '/me', name: 'me', label: '我' },
|
||||
]
|
||||
|
||||
// 根据当前路径计算激活的 tab key (精确匹配 '/' , 其他前缀匹配)
|
||||
// 根据当前路径计算激活的 tab key (精确匹配 + 前缀匹配)
|
||||
function activeKeyFromPath(pathname) {
|
||||
for (const t of TABS) {
|
||||
if (t.key === '/') {
|
||||
if (pathname === '/') return t.key
|
||||
} else if (pathname === t.key || pathname.startsWith(t.key + '/')) {
|
||||
if (pathname === t.key || pathname.startsWith(t.key + '/')) {
|
||||
return t.key
|
||||
}
|
||||
}
|
||||
return '/'
|
||||
// 未知路径兜底到第一个 Tab (打卡)
|
||||
return TABS[0].key
|
||||
}
|
||||
|
||||
// 移动端底部 Tab 导航: antd-mobile TabBar, 内置安全区
|
||||
|
||||
@@ -69,7 +69,7 @@ export default function RingProgress({
|
||||
|
||||
// 完成特效 (F-CK-07, U-02/U-03/U-07)
|
||||
// props: fire 计数器(每次 +1 触发一次), color 主色
|
||||
// 尊重"特效开关": localStorage iboard_celebrate === '0' 时不播放
|
||||
// V0.2 设计变更: 去掉特效开关, 达标特效**恒开** (没有任何关闭入口)
|
||||
export function Celebrate({ fire = 0, color = '#1677ff' }) {
|
||||
const [burst, setBurst] = useState(0)
|
||||
const [visible, setVisible] = useState(false)
|
||||
@@ -80,7 +80,6 @@ export function Celebrate({ fire = 0, color = '#1677ff' }) {
|
||||
if (fire === prev.current) return
|
||||
prev.current = fire
|
||||
if (fire <= 0) return
|
||||
if (!effectsEnabled()) return
|
||||
setBurst((n) => n + 1)
|
||||
setVisible(true)
|
||||
haptic()
|
||||
@@ -113,24 +112,6 @@ export function Celebrate({ fire = 0, color = '#1677ff' }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 特效全局开关 (默认开)
|
||||
export function effectsEnabled() {
|
||||
try {
|
||||
if (localStorage.getItem('iboard_celebrate') === '0') return false
|
||||
} catch { /* ignore */ }
|
||||
// 系统"减弱动效"偏好时降级
|
||||
if (typeof window !== 'undefined' && window.matchMedia) {
|
||||
try {
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return false
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
export function setEffectsEnabled(v) {
|
||||
try { localStorage.setItem('iboard_celebrate', v ? '1' : '0') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
// 轻量触感反馈 (Web Vibration API; Tauri Android WebView 支持)
|
||||
export function haptic(ms = 18) {
|
||||
try {
|
||||
|
||||
@@ -2,13 +2,7 @@
|
||||
// props: name 字符串, active 布尔
|
||||
// 返回对应的 24x24 SVG 节点
|
||||
const ICONS = {
|
||||
home: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M3 11l9-7 9 7" />
|
||||
<path d="M5 10v10h14V10" />
|
||||
{active ? <path d="M10 20v-5h4v5" fill="currentColor" stroke="none" /> : null}
|
||||
</svg>
|
||||
),
|
||||
// V0.2: 打卡移到最左, 原「首页」已并入宏观, home 图标不再使用
|
||||
macro: (active) => (
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={active ? 2.2 : 1.8} strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="3" width="7" height="9" rx="1" fill={active ? 'currentColor' : 'none'} fillOpacity={active ? 0.15 : 0} />
|
||||
|
||||
@@ -1,231 +0,0 @@
|
||||
import { useEffect, useState, useRef, useCallback } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { NavBar, Card, Empty, DotLoading, Badge } from 'antd-mobile'
|
||||
import { api } from '../api/client.js'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import useRefreshOnVisible from '../hooks/useRefreshOnVisible.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())
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const [active, setActive] = useState('news') // 默认选中新闻
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
const handleAlertCount = useCallback((n) => setAlertCount(n), [])
|
||||
|
||||
const tabs = [
|
||||
{
|
||||
key: 'alerts',
|
||||
label: (
|
||||
<span className="tab-label">
|
||||
报警
|
||||
{alertCount > 0 && <Badge content={alertCount} />}
|
||||
</span>
|
||||
),
|
||||
render: () => <AlertSection onCount={handleAlertCount} />,
|
||||
},
|
||||
{ key: 'news', label: '新闻', render: () => <NewsSection /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="page-with-navbar">
|
||||
<NavBar back={null} />
|
||||
<div className="home-content">
|
||||
<SwipeTabs tabs={tabs} active={active} onChange={setActive} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 报警模块
|
||||
function AlertSection({ onCount }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const inFlightRef = useRef(false)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('alerts load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done, onCount])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/alerts?limit=10')
|
||||
setItems(data.items || [])
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('alerts reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onCount])
|
||||
useRefreshOnVisible(reload)
|
||||
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 inFlightRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
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 {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/news?limit=10')
|
||||
setItems(data.items || [])
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('news reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
useRefreshOnVisible(reload)
|
||||
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" onClick={() => navigate('/news/' + n.id)}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
+25
-9
@@ -1,25 +1,32 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate, useLocation, Routes, Route, Navigate } from 'react-router-dom'
|
||||
import { NavBar } from 'antd-mobile'
|
||||
import { useNavigate, useLocation, Routes, Route } from 'react-router-dom'
|
||||
import { Badge, NavBar } from 'antd-mobile'
|
||||
import SwipeTabs from '../components/SwipeTabs.jsx'
|
||||
import DataView from './macro/DataView.jsx'
|
||||
import StrategyView from './macro/StrategyView.jsx'
|
||||
import ArticlesView from './macro/ArticlesView.jsx'
|
||||
import ArticleDetail from './macro/ArticleDetail.jsx'
|
||||
import OilPage from './macro/OilPage.jsx'
|
||||
import AlertsView from './macro/AlertsView.jsx'
|
||||
import NewsView from './macro/NewsView.jsx'
|
||||
import NewsDetail from './news/NewsDetail.jsx'
|
||||
|
||||
// 宏观: 顶栏 4 tab (油价 / 经济数据 / 策略研究 / 经济文章), 默认 油价
|
||||
// 宏观: 顶栏 6 tab, 默认 油价
|
||||
// /macro → 显示 oil tab (本组件 activeFromPath 决定)
|
||||
// /macro/oil → 油价 (新增)
|
||||
// /macro/oil → 油价
|
||||
// /macro/data → 经济数据
|
||||
// /macro/strategy → 策略研究
|
||||
// /macro/articles → 经济文章
|
||||
// /macro/articles/:slug → 文章详情
|
||||
// /macro/alerts → 报警 (V0.2: 原「首页」tab 合并进来)
|
||||
// /macro/news → 新闻 (V0.2: 原「首页」tab 合并进来)
|
||||
// 详情: /macro/data/:name · /macro/strategy/:name · /macro/articles/:slug · /macro/news/:id
|
||||
const TABS = [
|
||||
{ key: 'oil', label: '油价', path: '/macro/oil' },
|
||||
{ key: 'data', label: '经济数据', path: '/macro/data' },
|
||||
{ key: 'strategy', label: '策略研究', path: '/macro/strategy' },
|
||||
{ key: 'articles', label: '经济文章', path: '/macro/articles' },
|
||||
{ key: 'alerts', label: '报警', path: '/macro/alerts' },
|
||||
{ key: 'news', label: '新闻', path: '/macro/news' },
|
||||
]
|
||||
|
||||
function activeFromPath(pathname) {
|
||||
@@ -27,7 +34,9 @@ function activeFromPath(pathname) {
|
||||
if (pathname.startsWith('/macro/articles')) return 'articles'
|
||||
if (pathname.startsWith('/macro/strategy')) return 'strategy'
|
||||
if (pathname.startsWith('/macro/data')) return 'data'
|
||||
// 父路由 /macro 或 / 走 Tab 列表第一个 (油价)
|
||||
if (pathname.startsWith('/macro/alerts')) return 'alerts'
|
||||
if (pathname.startsWith('/macro/news')) return 'news'
|
||||
// 父路由 /macro 走 Tab 列表第一个 (油价)
|
||||
return 'oil'
|
||||
}
|
||||
|
||||
@@ -35,15 +44,21 @@ export default function MacroPage() {
|
||||
const location = useLocation()
|
||||
const navigate = useNavigate()
|
||||
const active = activeFromPath(location.pathname)
|
||||
// 报警角标: 由 AlertsView 回传总数
|
||||
const [alertCount, setAlertCount] = useState(0)
|
||||
|
||||
const tabs = TABS.map((t) => ({
|
||||
key: t.key,
|
||||
label: t.label,
|
||||
label: t.key === 'alerts'
|
||||
? <span className="tab-label">报警{alertCount > 0 && <Badge content={alertCount} />}</span>
|
||||
: t.label,
|
||||
render: () => {
|
||||
if (t.key === 'oil') return <OilPage />
|
||||
if (t.key === 'data') return <DataView />
|
||||
if (t.key === 'strategy') return <StrategyView />
|
||||
if (t.key === 'articles') return <ArticlesView />
|
||||
if (t.key === 'alerts') return <AlertsView onCount={setAlertCount} />
|
||||
if (t.key === 'news') return <NewsView />
|
||||
return null
|
||||
},
|
||||
}))
|
||||
@@ -53,13 +68,14 @@ export default function MacroPage() {
|
||||
if (next) navigate(next.path)
|
||||
}
|
||||
|
||||
// 详情页 /macro/articles/:slug 和 /macro/data/:name 单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy)\/[^/]+$/)) {
|
||||
// 详情页单独走 React Router 渲染, 不参与滑屏
|
||||
if (location.pathname.match(/^\/macro\/(articles|data|strategy|news)\/[^/]+$/)) {
|
||||
return (
|
||||
<Routes>
|
||||
<Route path="data/:name" element={<DataView />} />
|
||||
<Route path="strategy/:name" element={<StrategyView />} />
|
||||
<Route path="articles/:slug" element={<ArticleDetail />} />
|
||||
<Route path="news/:id" element={<NewsDetail />} />
|
||||
</Routes>
|
||||
)
|
||||
}
|
||||
|
||||
+31
-3
@@ -1,15 +1,17 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Avatar, Button, List, NavBar, ProgressBar, Toast } from 'antd-mobile'
|
||||
import { Avatar, Button, List, NavBar, ProgressBar, Switch, Toast } from 'antd-mobile'
|
||||
import { useAuth } from '../auth/AuthContext.jsx'
|
||||
import { APP_VERSION, BUILD_ID } from '../version.js'
|
||||
import {
|
||||
applyOtaUpdate,
|
||||
checkOtaUpdate,
|
||||
getCurrentVersionInfo,
|
||||
getOtaDevMode,
|
||||
getOtaVersionInfo,
|
||||
reloadApp,
|
||||
rollbackOta,
|
||||
setOtaDevMode,
|
||||
} from '../api/ota.js'
|
||||
|
||||
// "我" 页: 顶部 NavBar + 用户头像卡片 + 信息列表 + OTA 升级区
|
||||
@@ -29,6 +31,8 @@ export default function MePage() {
|
||||
const [otaVersion, setOtaVersion] = useState(null) // hotswap 当前激活的覆盖包版本
|
||||
const [progress, setProgress] = useState({ received: 0, total: 0 })
|
||||
const [error, setError] = useState('')
|
||||
// 判新模式: false(默认)=版本号比较, true=build sequence 比较; 值持久化在设备端
|
||||
const [devMode, setDevMode] = useState(() => getOtaDevMode())
|
||||
|
||||
// 进入页面时拉一次 hotswap 状态 (Tauri 内才需要, web 下 null 也无所谓)
|
||||
useEffect(() => {
|
||||
@@ -43,7 +47,7 @@ export default function MePage() {
|
||||
setError('')
|
||||
setOtaState('checking')
|
||||
try {
|
||||
const r = await checkOtaUpdate()
|
||||
const r = await checkOtaUpdate({ devMode })
|
||||
if (r.available) {
|
||||
setOtaInfo(r.info)
|
||||
setOtaState('available')
|
||||
@@ -130,6 +134,30 @@ export default function MePage() {
|
||||
<List.Item extra={otaVersion?.active ? 'v' + (otaVersion.version || '?') : '内置'}>
|
||||
运行资源
|
||||
</List.Item>
|
||||
{/* 判新口径开关: 默认关(版本号比较), 打开后按 build sequence 比较 */}
|
||||
<List.Item
|
||||
extra={
|
||||
<Switch
|
||||
checked={devMode}
|
||||
onChange={(v) => {
|
||||
setDevMode(v)
|
||||
setOtaDevMode(v)
|
||||
// 切换口径后旧检查结果失效, 重置避免误导
|
||||
setOtaState('idle')
|
||||
setOtaInfo(null)
|
||||
setError('')
|
||||
Toast.show({ content: v ? '开发版本: 按构建号判新' : '正式口径: 按版本号判新' })
|
||||
}}
|
||||
/>
|
||||
}
|
||||
description={
|
||||
devMode
|
||||
? '按 build sequence 判新:每次构建都算新版本,便于开发联调'
|
||||
: '按版本号判新:仅当服务端版本号更高时才提示更新'
|
||||
}
|
||||
>
|
||||
开发版本
|
||||
</List.Item>
|
||||
<List.Item>
|
||||
<div className="me-ota-actions">
|
||||
<Button
|
||||
@@ -209,4 +237,4 @@ export default function MePage() {
|
||||
<div className="me-version">v{APP_VERSION} · build {BUILD_ID}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import {
|
||||
Button, Dialog, DotLoading, Empty, Input, Popup, Selector, Stepper, Toast,
|
||||
} from 'antd-mobile'
|
||||
import RingProgress, { Celebrate, effectsEnabled, setEffectsEnabled } from '../../components/RingProgress.jsx'
|
||||
import RingProgress, { Celebrate } from '../../components/RingProgress.jsx'
|
||||
import { checkinApi } from '../../api/checkin.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { toDateStr, dayLabel } from './date.js'
|
||||
@@ -25,7 +25,6 @@ export default function HabitView({ date, onDateChange }) {
|
||||
const [bursts, setBursts] = useState({}) // { habitId: 触发计数 }
|
||||
const [form, setForm] = useState(EMPTY_FORM)
|
||||
const [showForm, setShowForm] = useState(false)
|
||||
const [effects, setEffects] = useState(effectsEnabled())
|
||||
|
||||
const load = useCallback((silent) => {
|
||||
if (!silent) setErr('')
|
||||
@@ -145,13 +144,6 @@ export default function HabitView({ date, onDateChange }) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="habit-head-right">
|
||||
<Button size="mini" fill="outline" color="primary" onClick={() => setEffects((v) => {
|
||||
const next = !v
|
||||
setEffectsEnabled(next)
|
||||
return next
|
||||
})}>
|
||||
{effects ? '特效开' : '特效关'}
|
||||
</Button>
|
||||
<Button size="mini" color="primary" onClick={() => { setForm(EMPTY_FORM); setShowForm(true) }}>
|
||||
+ 新建
|
||||
</Button>
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Card, DotLoading, Empty } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { fmtDateTime } from '../../utils/format.js'
|
||||
|
||||
// 报警 (V0.2 从「首页」并入「宏观」Tab)
|
||||
// - 数据: World Bank 与妙想 MCP 跨源同 (指标, 周期) 数据差异超阈值, 按时间倒序
|
||||
// - 无限滚动: 触底自动加载下一页
|
||||
// - onCount: 把总数回传给父级 (宏观 Tab 上显示角标)
|
||||
export default function AlertsView({ onCount }) {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const inFlightRef = useRef(false)
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const url = '/alerts?limit=10' + (cursorRef.current ? '&cursor=' + cursorRef.current : '')
|
||||
const data = await api(url)
|
||||
setItems((prev) => prev.concat(data.items || []))
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
if (!data.nextCursor) setDone(true)
|
||||
} catch (e) {
|
||||
console.error('alerts load error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done, onCount])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/alerts?limit=10')
|
||||
setItems(data.items || [])
|
||||
onCount?.(data.total ?? (data.items?.length || 0))
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('alerts reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [onCount])
|
||||
useRefreshOnVisible(reload)
|
||||
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])
|
||||
|
||||
if (items.length === 0 && !loading) {
|
||||
return (
|
||||
<section className="home-section">
|
||||
<Empty description="暂无报警" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { Card, DotLoading, Empty } from 'antd-mobile'
|
||||
import { api } from '../../api/client.js'
|
||||
import useRefreshOnVisible from '../../hooks/useRefreshOnVisible.js'
|
||||
import { fmtDate } from '../../utils/format.js'
|
||||
|
||||
// 新闻 (V0.2 从「首页」并入「宏观」Tab)
|
||||
// - 数据: mx_finance_search_news 每日 3 点拉取国内外经济政治新闻, 按时间倒序
|
||||
// - 无限滚动: 触底自动加载下一页
|
||||
// - 详情: /macro/news/:id (挂在宏观 Tab 下, 保持底部 Tab 高亮)
|
||||
export default function NewsView() {
|
||||
const [items, setItems] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [done, setDone] = useState(false)
|
||||
const cursorRef = useRef(null)
|
||||
const sentinelRef = useRef(null)
|
||||
const inFlightRef = useRef(false)
|
||||
const navigate = useNavigate()
|
||||
|
||||
const fetchPage = useCallback(async () => {
|
||||
if (inFlightRef.current || done) return
|
||||
inFlightRef.current = true
|
||||
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 {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [done])
|
||||
|
||||
useEffect(() => { fetchPage() }, [])
|
||||
// 后台回前台: 重置游标重拉第一页, 新数据到了再更新视图
|
||||
const reload = useCallback(async () => {
|
||||
if (inFlightRef.current) return
|
||||
inFlightRef.current = true
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await api('/news?limit=10')
|
||||
setItems(data.items || [])
|
||||
cursorRef.current = data.nextCursor
|
||||
setDone(!data.nextCursor)
|
||||
} catch (e) {
|
||||
console.error('news reload error', e)
|
||||
setDone(true)
|
||||
} finally {
|
||||
inFlightRef.current = false
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
useRefreshOnVisible(reload)
|
||||
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])
|
||||
|
||||
if (items.length === 0 && !loading) {
|
||||
return (
|
||||
<section className="home-section">
|
||||
<Empty description="暂无新闻" />
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<section className="home-section">
|
||||
<div className="section-list">
|
||||
{items.map((n) => (
|
||||
<Card key={n.id} className="news-card" onClick={() => navigate('/macro/news/' + n.id)}>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -210,12 +210,9 @@ input, textarea, select { font: inherit; }
|
||||
.me-list .adm-list-item { padding-left: 16px; padding-right: 16px; }
|
||||
.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; flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; }
|
||||
.home-content .adm-tabs-content { --content-padding: 0; }
|
||||
.home-content .tab-label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.home-section { padding: 0 12px 16px; }
|
||||
.section-title { font-size: 16px; font-weight: 700; padding: 14px 4px 8px; color: var(--color-text); }
|
||||
/* 资讯模块: 报警 + 新闻 (V0.2 起从「首页」并入「宏观」Tab) */
|
||||
.tab-label { display: inline-flex; align-items: center; gap: 4px; }
|
||||
.home-section { padding: 12px 12px 16px; }
|
||||
.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; }
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
// 通用格式化工具
|
||||
export 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())
|
||||
}
|
||||
|
||||
export 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())
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
// 版本号比较 (纯函数, 无浏览器依赖, 方便单测)
|
||||
//
|
||||
// 口径: 把版本号按"非数字"切成数字字段, 逐段比较数值大小, 缺位补 0
|
||||
// "0.2.0-ota" → [0, 2, 0]
|
||||
// "v1.10.3" → [1, 10, 3]
|
||||
// "0.2" 与 "0.2.0" 视为相等
|
||||
|
||||
export function parseVersionFields(v) {
|
||||
return String(v || '')
|
||||
.trim()
|
||||
.split(/[^0-9]+/)
|
||||
.filter((s) => s !== '')
|
||||
.map((s) => Number(s))
|
||||
}
|
||||
|
||||
// a > b 返回 1, a < b 返回 -1, 相等返回 0
|
||||
export function compareVersion(a, b) {
|
||||
const fa = parseVersionFields(a)
|
||||
const fb = parseVersionFields(b)
|
||||
const n = Math.max(fa.length, fb.length)
|
||||
for (let i = 0; i < n; i++) {
|
||||
const x = fa[i] || 0
|
||||
const y = fb[i] || 0
|
||||
if (x !== y) return x > y ? 1 : -1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
Reference in New Issue
Block a user