mirror of
https://github.com/Gloridust/WechatOnCloud.git
synced 2026-06-16 19:53:53 +08:00
7293285d1a
管理界面此前看不到面板版本,也无从知道有没有新版可升。现在: - 构建时把版本号烤进面板镜像: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>
119 lines
5.5 KiB
TypeScript
119 lines
5.5 KiB
TypeScript
// 面板版本与更新检测。
|
||
// 构建时把版本号烤进镜像(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 → gloridust;ghcr.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();
|
||
}
|