diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml index 21a2ff1..3f489a8 100644 --- a/.gitea/workflows/docker-build.yml +++ b/.gitea/workflows/docker-build.yml @@ -1,6 +1,8 @@ name: Docker Build and Push # 仅 main 分支 push 触发, 其他分支不触发 +# 每次主分支提交: 构建前端 + 生成 OTA 升级包 (签名) → 打进 Docker 镜像 → 部署 +# OTA 随镜像发布: 容器内 OTA_PACKAGE_DIR=/app/ota-package 指向打包的 ota-package/ on: push: branches: @@ -13,6 +15,35 @@ jobs: steps: - uses: https://gitea.com/actions/checkout@v4 + - name: Setup Node + uses: https://gitea.com/actions/setup-node@v4 + with: + node-version: 22 + + - name: Install deps + run: npm ci --no-audit --no-fund + + - name: Build frontend + # dist/ 供 OTA 打包用 (Dockerfile 内也会构建一份) + run: npm run build + + - name: Install minisign + run: sudo apt-get update && sudo apt-get install -y minisign + + - name: Build & sign OTA package + # 每次主分支提交生成 OTA 升级包, sequence 默认时间戳 (单调递增) + # 产物 ota-package/ (tar.gz + latest.json + .minisig) 进入 docker build context, 由 Dockerfile COPY 进镜像 + env: + MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} + OTA_NOTES: ${{ github.sha }} + run: | + set -euo pipefail + node scripts/build-ota.mjs --out ota-package --notes ${OTA_NOTES} + echo "===== latest.json =====" + cat ota-package/latest.json + echo "===== bundle sha256 =====" + sha256sum ota-package/iboard-ota.tar.gz + - name: Build Docker image # DATABASE_URL 仅在 prisma generate 阶段需要, 可用任何合法占位串 run: docker build -t iboard:latest --build-arg DATABASE_URL=mysql://build:build@127.0.0.1:3306/build . @@ -28,6 +59,7 @@ jobs: - name: Run container # DATABASE_URL 通过 gitea Actions secret 注入, 不进镜像层 + # OTA_PACKAGE_DIR 已在 Dockerfile 设为 /app/ota-package run: | docker run -d --name iboard -p 3001:3001 \ -e DATABASE_URL="${{ secrets.DATABASE_URL }}" \ @@ -35,4 +67,4 @@ jobs: iboard:latest env: DATABASE_URL: ${{ secrets.DATABASE_URL }} - JWT_SECRET: ${{ secrets.JWT_SECRET }} \ No newline at end of file + JWT_SECRET: ${{ secrets.JWT_SECRET }} diff --git a/.gitea/workflows/ota-build.yml b/.gitea/workflows/ota-build.yml index 01df4d0..d293808 100644 --- a/.gitea/workflows/ota-build.yml +++ b/.gitea/workflows/ota-build.yml @@ -1,8 +1,9 @@ name: OTA Frontend Build & Sign # 前端 OTA 升级包构建 + 签名 + 上传 artifact -# 触发: 手动 (workflow_dispatch), 由"我"页/发版前点一下 -# 输入: sequence / notes (可覆盖默认) +# 触发: 仅手动 (workflow_dispatch), 发版前用 +# - 主分支 push 的自动 OTA 生成已并入 docker-build.yml (随镜像发布), 本 workflow 不再监听 push 避免重复构建 +# 输入: sequence / notes / min_binary (可覆盖默认) # Secret 依赖: MINISIGN_PRIVATE_KEY (整段 .key 文件内容, 含首行 untrusted comment) # 注意: 此 workflow 不会修改 src-tauri/tauri.conf.json 里的 pubkey, # 换密钥请手动改 conf 并 commit. @@ -50,12 +51,14 @@ jobs: - name: Build & sign OTA package env: MINISIGN_PRIVATE_KEY: ${{ secrets.MINISIGN_PRIVATE_KEY }} + OTA_NOTES: ${{ inputs.notes }} run: | set -euo pipefail OUT="ota-package" ARGS="--out ${OUT}" + # inputs 留空时 → 不传 --sequence/--min-binary, 脚本默认 sequence=时间戳, min-binary=tauri.conf version if [ -n "${{ inputs.sequence }}" ]; then ARGS="${ARGS} --sequence ${{ inputs.sequence }}"; fi - if [ -n "${{ inputs.notes }}" ]; then ARGS="${ARGS} --notes ${{ inputs.notes }}"; fi + if [ -n "${OTA_NOTES}" ]; then ARGS="${ARGS} --notes ${OTA_NOTES}"; fi if [ -n "${{ inputs.min_binary }}" ]; then ARGS="${ARGS} --min-binary ${{ inputs.min_binary }}"; fi node scripts/build-ota.mjs ${ARGS} echo "===== latest.json =====" diff --git a/.gitignore b/.gitignore index 2cca39d..3076d28 100644 --- a/.gitignore +++ b/.gitignore @@ -36,7 +36,8 @@ dist/* /.env .reasonix/* # OTA 升级: 产物 (运行时生成, 部署时单独管理) + 私钥 (绝不能提交) -ota-package/ +ota-package/* +!ota-package/README.md # apizero 文件缓存 (运行时按需生成) cache/apizero/ diff --git a/Dockerfile b/Dockerfile index 9e22894..d9d71c5 100644 --- a/Dockerfile +++ b/Dockerfile @@ -51,6 +51,11 @@ COPY --from=builder /app/prisma.config.ts ./prisma.config.ts COPY --from=builder /app/content ./content COPY --from=builder /app/public ./public +# OTA 升级包: CI (docker-build.yml) 生成的 ota-package/, 随镜像发布 +# 本地构建时只有占位文件 (ota-package/README.md), server 找不到 latest.json 则 OTA 返回"无更新" +COPY ota-package ./ota-package +ENV OTA_PACKAGE_DIR=/app/ota-package + # 健康检查 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget -qO- http://127.0.0.1:3001/api/health || exit 1 diff --git a/ota-package/README.md b/ota-package/README.md new file mode 100644 index 0000000..334b8e2 --- /dev/null +++ b/ota-package/README.md @@ -0,0 +1,8 @@ +# ota-package/ 目录占位文件 + +本目录是 OTA 升级包输出目录(git 忽略): + +- **CI(docker-build.yml)**:每次主分支提交运行 `scripts/build-ota.mjs` 生成 `iboard-ota.tar.gz` + `latest.json` + `*.minisig`,随 Docker 镜像发布到容器内 `OTA_PACKAGE_DIR=/app/ota-package`。 +- **本地手动构建**:只有本占位文件,Dockerfile 的 `COPY ota-package ./ota-package` 不会失败;容器内 `readLatest()` 找不到 `latest.json` 时 OTA 检查返回"无更新包"。 + +本文件保留在 git 中是为了让 `COPY` 在本地构建时不报错;真实 OTA 产物(含私钥 hotswap.key)永不提交。 diff --git a/src/hooks/useRefreshOnVisible.js b/src/hooks/useRefreshOnVisible.js new file mode 100644 index 0000000..673b4b0 --- /dev/null +++ b/src/hooks/useRefreshOnVisible.js @@ -0,0 +1,33 @@ +import { useEffect, useRef } from 'react' + +// 页面从后台恢复到前台时调用 refetch (Tauri 移动端/桌面通用, 不引库) +// - visibilitychange: Tauri 移动端 (iOS WKWebView / Android WebView) 唯一可靠的前后台机制, +// 回前台时 document.visibilityState 必然变为 'visible' +// - window focus: 桌面端 (WebView2) 兜底, 部分场景不派发 DOM focus 但原生窗口焦点事件可靠 +// 用法: const load = useCallback(...); useRefreshOnVisible(load) +// 回调通过 ref 持有, effect 只绑定一次, 不随每次渲染重新订阅 +export default function useRefreshOnVisible(refetch) { + const fnRef = useRef(refetch) + fnRef.current = refetch + + useEffect(() => { + // 回前台时 visibilitychange 与 focus 会接连触发, 用 300ms 窗口节流避免重复请求 + let lastTs = 0 + const fire = () => { + const now = Date.now() + if (now - lastTs < 300) return + lastTs = now + fnRef.current?.() + } + const onVisibility = () => { + if (document.visibilityState === 'visible') fire() + } + const onFocus = () => fire() + document.addEventListener('visibilitychange', onVisibility) + window.addEventListener('focus', onFocus) + return () => { + document.removeEventListener('visibilitychange', onVisibility) + window.removeEventListener('focus', onFocus) + } + }, []) +} diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 0187cb6..4d15280 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -3,6 +3,7 @@ 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 跨源同 (指标, 周期) 数据差异超阈值 (按时间倒序) @@ -82,6 +83,26 @@ function AlertSection({ onCount }) { }, [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 @@ -152,6 +173,25 @@ function NewsSection() { }, [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 diff --git a/src/pages/MonitorPage.jsx b/src/pages/MonitorPage.jsx index 001b50a..60a16c2 100644 --- a/src/pages/MonitorPage.jsx +++ b/src/pages/MonitorPage.jsx @@ -3,6 +3,7 @@ import { useNavigate } from 'react-router-dom' import { NavBar, Card, Empty, DotLoading } from 'antd-mobile' import { api } from '../api/client.js' import SwipeTabs from '../components/SwipeTabs.jsx' +import useRefreshOnVisible from '../hooks/useRefreshOnVisible.js' // 监控页: 两个横向分类 // - 主机监控: 实时数据来自 komari (服务端实时代理, 不落库), 点击节点看 24h 时序 @@ -74,6 +75,9 @@ function ServerView() { return () => clearInterval(timer) }, [load]) + // 后台回前台时立即静默刷新 (不闪 loading), 优于等下一轮 30s 轮询 + useRefreshOnVisible(() => load(true)) + return (