feat(panel): 显示构建版本号 + 自动检测新版(Docker Hub/GHCR 红点)

管理界面此前看不到面板版本,也无从知道有没有新版可升。现在:

- 构建时把版本号烤进面板镜像:Dockerfile 新增 ARG/ENV WOC_VERSION(放末尾不
  破坏依赖缓存);release.yml 用 git tag 注入(vX.Y.Z,手动触发为 dev-<sha>),
  仅面板镜像消费;build-local.sh 支持 --build-arg(默认 dev)。
- 后端 version.ts:best-effort 查询 Docker Hub 与 GHCR 上 woc-panel 的语义化
  标签取最大值,与当前版本比对;启动后 4s 首检 + 每 6h 复检 + 接口惰性触发,
  失败静默(离线/被墙/私有源不报错、不显红点)。命名空间从 WOC_WECHAT_IMAGE 推断。
- 接口:GET /api/version(任意登录用户读缓存)、POST /api/admin/version/check
  (管理员手动重查)。
- 前端:管理页「关于」卡显示当前版本/最新版/升级提示/检查更新/发布日志链接;
  侧栏「管理」入口在有新版时点红点(仅管理员)。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gloridust
2026-06-14 22:40:17 +08:00
Unverified
parent 8cf15923a6
commit 7293285d1a
9 changed files with 315 additions and 7 deletions
+12
View File
@@ -62,6 +62,16 @@ jobs:
username: ${{ vars.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# 解析烤进面板镜像的版本号:打 tag / 发 Release 时用 tag 名(vX.Y.Z),手动触发则用 dev-<短SHA>。
- name: Resolve build version
id: ver
run: |
if [ "${{ github.ref_type }}" = "tag" ]; then
echo "version=${{ github.ref_name }}" >> "$GITHUB_OUTPUT"
else
echo "version=dev-${GITHUB_SHA::7}" >> "$GITHUB_OUTPUT"
fi
- name: Docker metadata (tags + labels)
id: meta
uses: docker/metadata-action@v5
@@ -85,6 +95,8 @@ jobs:
context: ${{ matrix.context }}
file: ${{ matrix.dockerfile }}
platforms: linux/amd64,linux/arm64
# 仅面板镜像消费 WOC_VERSION(实例镜像不展示版本号),避免「未使用的 build-arg」告警。
build-args: ${{ matrix.name == 'panel' && format('WOC_VERSION={0}', steps.ver.outputs.version) || '' }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
+5 -1
View File
@@ -17,7 +17,11 @@ RUN npm install
COPY server/ ./
COPY --from=web /web/dist ./web-dist
ENV STATIC_DIR=/app/web-dist \
# 构建版本号:CI 用 git tag 注入(vX.Y.Z),本地构建默认 dev。烤进镜像 → 面板运行时显示真实版本并据此检测更新。
# 放在末尾:改版本号不会破坏上面的依赖安装缓存。
ARG WOC_VERSION=dev
ENV WOC_VERSION=${WOC_VERSION} \
STATIC_DIR=/app/web-dist \
PORT=8080
EXPOSE 8080
CMD ["npm", "run", "start"]
+16 -1
View File
@@ -73,6 +73,7 @@ import {
} from './docker.js';
import { createSession, getSession, destroySession, destroyUserSessions } from './sessions.js';
import { parseHost, parseAllowedHosts, isRequestHostAllowed } from './host-guard.js';
import { CURRENT_VERSION, versionInfo, ensureChecked, checkForUpdate, startUpdateChecker } from './version.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -172,6 +173,19 @@ app.get('/api/auth/me', async (req, reply) => {
return { user: publicUser(u) };
});
// ---------- 版本与更新检测 ----------
// 当前构建版本 + 缓存的「最新版」检测结果(后台每 6h 查一次 Docker Hub/GHCR)。任何登录用户可读。
app.get('/api/version', async (req, reply) => {
if (!requireAuth(req, reply)) return;
ensureChecked(); // 刚启动还没首检时,触发一次后台检查(不阻塞本次响应)
return versionInfo();
});
// 立即重新检查(管理员,用于「检查更新」按钮)。
app.post('/api/admin/version/check', async (req, reply) => {
if (!requireAdmin(req, reply)) return;
return await checkForUpdate();
});
// ---------- 自助改密 ----------
app.post('/api/account/password', async (req, reply) => {
const u = requireAuth(req, reply);
@@ -1083,4 +1097,5 @@ if (WATCHDOG_ENABLED) {
}
await app.listen({ port: PORT, host: HOST });
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)`);
console.log(`[panel] 监听 http://${HOST}:${PORT} (多实例反代已就绪)· 版本 ${CURRENT_VERSION}`);
startUpdateChecker(); // 后台检测新版(best-effort,失败静默)
+118
View File
@@ -0,0 +1,118 @@
// 面板版本与更新检测。
// 构建时把版本号烤进镜像(Dockerfile: ARG/ENV WOC_VERSION,由 CI 用 git tag 注入,本地构建为 dev);
// 运行时查询 Docker Hub 与 GHCR 上 woc-panel 的最新语义化标签,比对后给前端「有新版」红点。
// 全程 best-effort:离线 / 被墙 / 私有源拉取失败时不报错、不打扰,仅不显示红点(记 error 供「上次检查」提示)。
export const CURRENT_VERSION = (process.env.WOC_VERSION || 'dev').trim();
export interface VersionInfo {
current: string; // 当前构建版本(如 v1.2.0 / dev
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
hasUpdate: boolean; // 当前能解析为语义化版本且 latest > current 时为 true
checkedAt: number; // 上次检查时间戳(ms);0 = 尚未检查
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
error: string | null; // 检查失败原因(两个源都拉不到时)
}
// 镜像命名空间:从 WOC_WECHAT_IMAGE 推断(面板与实例镜像同账号)。
// 例 docker.io/gloridust/wechat-on-cloud:latest → gloridustghcr.io/gloridust/... 同理。
function imageOwner(): string {
const img = (process.env.WOC_WECHAT_IMAGE || 'docker.io/gloridust/wechat-on-cloud').split('@')[0];
const segs = img.split('/'); // [registry?, owner, image[:tag]]
return segs.length >= 2 ? segs[segs.length - 2] : 'gloridust';
}
const PANEL_REPO = process.env.WOC_PANEL_REPO || 'woc-panel';
function parseSemver(s: string): [number, number, number] | null {
const m = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(s.trim());
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
}
function cmpSemver(a: [number, number, number], b: [number, number, number]): number {
return a[0] - b[0] || a[1] - b[1] || a[2] - b[2];
}
// 从一堆标签里挑出最大的 x.y.z(忽略 latest / x / x.y 等非完整语义化标签)。
function maxSemver(tags: string[]): string | null {
let best: [number, number, number] | null = null;
for (const t of tags) {
const v = parseSemver(t);
if (v && (!best || cmpSemver(v, best) > 0)) best = v;
}
return best ? `${best[0]}.${best[1]}.${best[2]}` : null;
}
async function getJson(url: string, headers: Record<string, string>, timeoutMs: number): Promise<any> {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), timeoutMs);
try {
const res = await fetch(url, { headers: { 'user-agent': 'woc-panel-update-check', ...headers }, signal: ctrl.signal });
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} finally {
clearTimeout(t);
}
}
// Docker Hub 公共 API:免鉴权列标签。
async function dockerHubTags(owner: string): Promise<string[]> {
const d = await getJson(`https://hub.docker.com/v2/repositories/${owner}/${PANEL_REPO}/tags?page_size=100`, {}, 8000);
return Array.isArray(d?.results) ? d.results.map((r: any) => String(r?.name || '')) : [];
}
// GHCR 公共镜像:先取匿名 pull token,再走 registry v2 tags/list。
async function ghcrTags(owner: string): Promise<string[]> {
const tok = await getJson(`https://ghcr.io/token?scope=repository:${owner}/${PANEL_REPO}:pull&service=ghcr.io`, {}, 8000);
const d = await getJson(`https://ghcr.io/v2/${owner}/${PANEL_REPO}/tags/list`, { authorization: `Bearer ${tok?.token || ''}` }, 8000);
return Array.isArray(d?.tags) ? d.tags.map((t: any) => String(t)) : [];
}
let cache: VersionInfo = { current: CURRENT_VERSION, latest: null, hasUpdate: false, checkedAt: 0, source: null, error: null };
let inflight: Promise<VersionInfo> | null = null;
export function versionInfo(): VersionInfo {
return cache;
}
// 查询两个仓库(并行、互不阻塞),取全局最大语义化版本与当前版本比对。失败静默写入 error。
export function checkForUpdate(): Promise<VersionInfo> {
if (inflight) return inflight; // 合并并发请求,避免重复外呼
inflight = (async () => {
const owner = imageOwner();
const sources: string[] = [];
const tags: string[] = [];
const [hub, ghcr] = await Promise.allSettled([dockerHubTags(owner), ghcrTags(owner)]);
if (hub.status === 'fulfilled' && hub.value.length) {
tags.push(...hub.value);
sources.push('dockerhub');
}
if (ghcr.status === 'fulfilled' && ghcr.value.length) {
tags.push(...ghcr.value);
sources.push('ghcr');
}
const latestBare = maxSemver(tags);
const cur = parseSemver(CURRENT_VERSION);
const latestV = latestBare ? parseSemver(latestBare) : null;
const hasUpdate = !!(latestV && cur && cmpSemver(latestV, cur) > 0);
cache = {
current: CURRENT_VERSION,
latest: latestBare ? `v${latestBare}` : null,
hasUpdate,
checkedAt: Date.now(),
source: sources.join('+') || null,
error: tags.length ? null : '无法连接镜像仓库(Docker Hub / GHCR',
};
return cache;
})().finally(() => {
inflight = null;
});
return inflight;
}
// 尚未检查过则触发一次后台检查(不等待)。GET /api/version 调用,保证刚启动也能尽快填上缓存。
export function ensureChecked(): void {
if (!cache.checkedAt && !inflight) void checkForUpdate().catch(() => {});
}
// 启动后延迟首检(让监听就绪)+ 每 6 小时复检;定时器 unref,不阻止进程退出。
export function startUpdateChecker(): void {
setTimeout(() => void checkForUpdate().catch(() => {}), 4_000).unref();
setInterval(() => void checkForUpdate().catch(() => {}), 6 * 60 * 60 * 1000).unref();
}
+21 -2
View File
@@ -157,6 +157,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
const isAdmin = user?.role === 'admin';
const go = (p: string) => nav(p);
// 有新版时在「管理」入口点个红点(仅管理员,因为升级面板需管理员在宿主操作)。
// 依赖 loc.pathname:导航时复查一次(服务端有缓存、开销极小),保证刚启动时首检完成后红点能及时出现。
const [hasUpdate, setHasUpdate] = useState(false);
useEffect(() => {
if (!isAdmin) return;
api
.getVersion()
.then((v) => setHasUpdate(!!v.hasUpdate))
.catch(() => {});
}, [isAdmin, loc.pathname]);
return (
<aside className="sidebar">
<div className="sb-top">
@@ -196,9 +207,17 @@ function Sidebar({ collapsed, onToggleCollapsed }: { collapsed: boolean; onToggl
</div>
<div className="sb-footer">
<button className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')} onClick={() => go('/admin')} title={isAdmin ? '管理' : '设置'}>
<span className="sb-ic">{Icon.gear}</span>
<button
className={'sb-item' + (loc.pathname === '/admin' ? ' on' : '')}
onClick={() => go('/admin')}
title={isAdmin && hasUpdate ? '管理 · 有新版本可用' : isAdmin ? '管理' : '设置'}
>
<span className="sb-ic">
{Icon.gear}
{isAdmin && hasUpdate && <span className="sb-updot" />}
</span>
{!collapsed && <span className="sb-label">{isAdmin ? '管理' : '设置'}</span>}
{!collapsed && isAdmin && hasUpdate && <span className="sb-updot-text"></span>}
</button>
<button
className="sb-item"
+13
View File
@@ -75,6 +75,15 @@ export interface VolEntry {
mtime: number; // epoch ms
}
export interface VersionInfo {
current: string; // 当前构建版本(如 v1.2.0 / dev
latest: string | null; // 仓库上最新发布版(如 v1.2.1);查不到为 null
hasUpdate: boolean; // 有更高的语义化版本可用
checkedAt: number; // 上次检查时间戳(ms);0=尚未检查
source: string | null; // 数据来源:dockerhub / ghcr / dockerhub+ghcr
error: string | null; // 检查失败原因
}
// 原始二进制上传(File 直传 application/octet-stream),用于数据卷上传/解压/恢复
async function rawUpload(url: string, file: File): Promise<any> {
const res = await fetch(url, {
@@ -116,6 +125,10 @@ export const api = {
changePassword: (oldPassword: string, newPassword: string) =>
req('/api/account/password', { method: 'POST', body: JSON.stringify({ oldPassword, newPassword }) }),
// 版本与更新检测
getVersion: () => req<VersionInfo>('/api/version'),
checkUpdate: () => req<VersionInfo>('/api/admin/version/check', { method: 'POST' }),
// 子账号
listUsers: () => req<{ users: PanelUser[] }>('/api/admin/users'),
createUser: (username: string, password: string, allowedInstances: string[] = []) =>
+83 -1
View File
@@ -1,7 +1,7 @@
import { useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import Cropper from 'react-easy-crop';
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType } from '../api';
import { api, APP_LABELS, appProfile, type PanelUser, type InstanceWithStatus, type VolEntry, type AppType, type VersionInfo } from '../api';
import { InstanceIcon, ICON_CHOICES } from '../AppIcon';
import { useUI, PasswordInput } from '../ui';
import { useAuth } from '../auth';
@@ -80,6 +80,86 @@ function EmptyState({ icon, title, sub, action }: { icon: string; title: string;
);
}
const RELEASES_URL = 'https://github.com/Gloridust/WechatOnCloud/releases';
// 「关于」:显示真实构建版本号 + 检测新版(后台已每 6h 查 Docker Hub/GHCR;这里读缓存并可手动重查)。
function AboutSection({ isAdmin }: { isAdmin: boolean }) {
const { toast } = useUI();
const [info, setInfo] = useState<VersionInfo | null>(null);
const [checking, setChecking] = useState(false);
useEffect(() => {
api.getVersion().then(setInfo).catch(() => {});
}, []);
const check = async () => {
setChecking(true);
try {
const r = await api.checkUpdate();
setInfo(r);
if (r.hasUpdate) toast(`发现新版本 ${r.latest}`, 'ok');
else if (r.error) toast('检查失败:' + r.error, 'error');
else toast('已是最新版本', 'ok');
} catch (e: any) {
toast(e.message || '检查失败', 'error');
} finally {
setChecking(false);
}
};
return (
<>
<div className="section-row" style={{ marginTop: 22 }}>
<span className="section-title"></span>
</div>
<div className="inst-grid">
<div className="inst-card">
<div className="inst-head">
<span className="inst-name"> · WechatOnCloud</span>
{info?.hasUpdate && <span className="tag tag-warn"></span>}
</div>
<div className="inst-sub">
<b>{info?.current ?? '…'}</b>
{info?.hasUpdate && info.latest && (
<>
{' · '} <b>{info.latest}</b>
</>
)}
{info && !info.hasUpdate && info.latest && !info.error && <>{' · '}</>}
</div>
{info?.hasUpdate && (
<div className="ver-hint">
宿 <code>docker compose pull &amp;&amp; docker compose up -d</code>
</div>
)}
<div className="inst-actions">
{isAdmin && (
<button className="btn" disabled={checking} onClick={check}>
{checking ? '检查中…' : '检查更新'}
</button>
)}
<a className="btn" href={RELEASES_URL} target="_blank" rel="noreferrer">
</a>
{info?.hasUpdate && (
<a className="btn btn-primary" href={RELEASES_URL + '/latest'} target="_blank" rel="noreferrer">
</a>
)}
</div>
{info && (
<div className="muted small ver-checked">
{info.checkedAt ? `上次检查 ${fmtDate(info.checkedAt)}` : '尚未检查'}
{info.source && ` · 来源 ${info.source}`}
{info.error && ` · ${info.error}`}
</div>
)}
</div>
</div>
</>
);
}
export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: () => void; onChangePassword: () => void }) {
const nav = useNavigate();
const { user } = useAuth();
@@ -446,6 +526,8 @@ export default function Admin({ onOpenMenu, onChangePassword }: { onOpenMenu: ()
</div>
</div>
</div>
<AboutSection isAdmin={isAdmin} />
</main>
{creatingUser && (
+43
View File
@@ -970,6 +970,31 @@ button {
gap: 8px;
margin-top: 14px;
}
/* 「关于」版本卡:有新版时的升级提示 + 上次检查脚注 */
.ver-hint {
margin-top: 10px;
padding: 9px 11px;
font-size: 12.5px;
line-height: 1.55;
color: var(--text);
background: rgba(245 158 11 / 0.1);
border-radius: 10px;
}
.ver-hint code {
font-size: 12px;
padding: 1px 5px;
border-radius: 5px;
background: rgba(var(--shadow) / 0.08);
white-space: nowrap;
}
.ver-checked {
margin-top: 12px;
}
.inst-actions a.btn {
text-decoration: none;
display: inline-flex;
align-items: center;
}
.inst-enter {
flex: 1;
}
@@ -1371,6 +1396,7 @@ button {
color: var(--wx-green-dark);
}
.sb-ic {
position: relative;
flex: none;
width: 24px;
display: flex;
@@ -1378,6 +1404,23 @@ button {
justify-content: center;
color: var(--muted);
}
/* 「有新版」红点:贴在「管理」齿轮图标右上角,折叠/展开都可见 */
.sb-updot {
position: absolute;
top: -2px;
right: -1px;
width: 9px;
height: 9px;
border-radius: 50%;
background: #f5483b;
border: 2px solid var(--surface);
}
.sb-updot-text {
flex: none;
font-size: 11px;
font-weight: 700;
color: #f5483b;
}
.sb-label {
flex: 1;
min-width: 0;
+4 -2
View File
@@ -10,13 +10,15 @@ set -euo pipefail
OWNER="${WOC_IMAGE_OWNER:-gloridust}"
TAG="${WOC_VERSION:-latest}"
# 烤进面板镜像的版本号:设了 WOC_VERSION 就用它(如 v1.2.0),否则 dev(dev 不会触发「有新版」红点)。
VER="${WOC_VERSION:-dev}"
ROOT="$(cd "$(dirname "$0")/.." && pwd)"
PANEL_IMAGE="ghcr.io/${OWNER}/woc-panel:${TAG}"
WECHAT_IMAGE="ghcr.io/${OWNER}/wechat-on-cloud:${TAG}"
echo "==> 构建面板镜像 ${PANEL_IMAGE}"
docker build -t "${PANEL_IMAGE}" "${ROOT}/panel"
echo "==> 构建面板镜像 ${PANEL_IMAGE} (版本号 ${VER}"
docker build --build-arg "WOC_VERSION=${VER}" -t "${PANEL_IMAGE}" "${ROOT}/panel"
echo "==> 构建微信实例镜像 ${WECHAT_IMAGE}"
docker build -t "${WECHAT_IMAGE}" "${ROOT}/docker"