Refactor GitHub tag fetching and mirror management

Replaces legacy tag fetching logic in napcat-common with a new mirror.ts module that centralizes GitHub mirror configuration, selection, and tag retrieval. Updates helper.ts to use the new mirror system and semver comparison, and exports compareSemVer for broader use. Updates workflows and scripts to generate and propagate build version information, and improves build status comment formatting for PRs. Also updates release workflow to use a new OpenAI key and model.
This commit is contained in:
手瓜一十雪 2026-01-03 14:42:24 +08:00
parent 2d3f4e696b
commit 8eb1aa2fb4
18 changed files with 2199 additions and 451 deletions

View File

@ -24,6 +24,10 @@ function escapeCodeBlock (text: string): string {
return text.replace(/```/g, '\\`\\`\\`');
}
function getTimeString (): string {
return new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
}
// ============== 状态图标 ==============
export function getStatusIcon (status: BuildStatus): string {
@ -34,30 +38,66 @@ export function getStatusIcon (status: BuildStatus): string {
return '⏳ 构建中...';
case 'cancelled':
return '⚪ 已取消';
default:
case 'failure':
return '❌ 失败';
default:
return '❓ 未知';
}
}
function getStatusEmoji (status: BuildStatus): string {
switch (status) {
case 'success': return '✅';
case 'pending': return '⏳';
case 'cancelled': return '⚪';
case 'failure': return '❌';
default: return '❓';
}
}
// ============== 构建中评论 ==============
export function generateBuildingComment (prSha: string, targets: string[]): string {
const time = new Date().toISOString().replace('T', ' ').substring(0, 19) + ' UTC';
const time = getTimeString();
const shortSha = formatSha(prSha);
const lines: string[] = [
COMMENT_MARKER,
'## 🔨 构建状态',
'',
'| 构建目标 | 状态 |',
'| :--- | :--- |',
...targets.map(name => `| ${name} | ⏳ 构建中... |`),
'<div align="center">',
'',
'# 🔨 NapCat 构建中',
'',
'![Building](https://img.shields.io/badge/状态-构建中-yellow?style=for-the-badge&logo=github-actions&logoColor=white)',
'',
'</div>',
'',
'---',
'',
`📝 **提交**: \`${formatSha(prSha)}\``,
`🕐 **开始时间**: ${time}`,
'## 📦 构建目标',
'',
'> 构建进行中,请稍候...',
'| 包名 | 状态 | 说明 |',
'| :--- | :---: | :--- |',
...targets.map(name => `| \`${name}\` | ⏳ | 正在构建... |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
`| 📝 提交 | \`${shortSha}\` |`,
`| 🕐 开始时间 | ${time} |`,
'',
'---',
'',
'<div align="center">',
'',
'> ⏳ **构建进行中,请稍候...**',
'>',
'> 构建完成后将自动更新此评论',
'',
'</div>',
];
return lines.join('\n');
@ -69,60 +109,123 @@ export function generateResultComment (
targets: BuildTarget[],
prSha: string,
runId: string,
repository: string
repository: string,
version?: string
): string {
// 链接到 run 详情页,页面底部有 Artifacts 下载区域
const runUrl = `https://github.com/${repository}/actions/runs/${runId}`;
const shortSha = formatSha(prSha);
const time = getTimeString();
const allSuccess = targets.every(t => t.status === 'success');
const anyCancelled = targets.some(t => t.status === 'cancelled');
const anyFailure = targets.some(t => t.status === 'failure');
const headerIcon = allSuccess
? '✅ 构建成功'
: anyCancelled
? '⚪ 构建已取消'
: '❌ 构建失败';
// 状态徽章
let statusBadge: string;
let headerTitle: string;
if (allSuccess) {
statusBadge = '![Success](https://img.shields.io/badge/状态-构建成功-success?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ✅ NapCat 构建成功';
} else if (anyCancelled && !anyFailure) {
statusBadge = '![Cancelled](https://img.shields.io/badge/状态-已取消-lightgrey?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ⚪ NapCat 构建已取消';
} else {
statusBadge = '![Failed](https://img.shields.io/badge/状态-构建失败-critical?style=for-the-badge&logo=github-actions&logoColor=white)';
headerTitle = '# ❌ NapCat 构建失败';
}
const downloadLink = (target: BuildTarget) => {
if (target.status !== 'success') return '—';
if (target.downloadUrl) {
return `[📦 下载](${target.downloadUrl})`;
return `[📥 下载](${target.downloadUrl})`;
}
// 回退到 run 详情页
return `[📦 下载](${runUrl}#artifacts)`;
return `[📥 下载](${runUrl}#artifacts)`;
};
const lines: string[] = [
COMMENT_MARKER,
`## ${headerIcon}`,
'',
'| 构建目标 | 状态 | 下载 |',
'| :--- | :--- | :--- |',
...targets.map(t => `| ${t.name} | ${getStatusIcon(t.status)} | ${downloadLink(t)} |`),
'<div align="center">',
'',
headerTitle,
'',
statusBadge,
'',
'</div>',
'',
'---',
'',
`📝 **提交**: \`${formatSha(prSha)}\``,
`🔗 **构建日志**: [查看详情](${runUrl})`,
'## 📦 构建产物',
'',
'| 包名 | 状态 | 下载 |',
'| :--- | :---: | :---: |',
...targets.map(t => `| \`${t.name}\` | ${getStatusEmoji(t.status)} ${t.status === 'success' ? '成功' : t.status === 'failure' ? '失败' : t.status === 'cancelled' ? '已取消' : '未知'} | ${downloadLink(t)} |`),
'',
'---',
'',
'## 📋 构建信息',
'',
`| 项目 | 值 |`,
`| :--- | :--- |`,
...(version ? [`| 🏷️ 版本号 | \`${version}\` |`] : []),
`| 📝 提交 | \`${shortSha}\` |`,
`| 🔗 构建日志 | [查看详情](${runUrl}) |`,
`| 🕐 完成时间 | ${time} |`,
];
// 添加错误详情
const failedTargets = targets.filter(t => t.status === 'failure' && t.error);
if (failedTargets.length > 0) {
lines.push('', '---', '', '## ⚠️ 错误详情');
lines.push('', '---', '', '## ⚠️ 错误详情', '');
for (const target of failedTargets) {
lines.push('', `### ${target.name} 构建错误`, '```', escapeCodeBlock(target.error!), '```');
lines.push(
`<details>`,
`<summary>🔴 <b>${target.name}</b> 构建错误</summary>`,
'',
'```',
escapeCodeBlock(target.error!),
'```',
'',
'</details>',
''
);
}
}
// 添加底部提示
lines.push('---', '');
if (allSuccess) {
lines.push('', '> 🎉 所有构建均已成功完成,可点击上方下载链接获取构建产物进行测试。');
} else if (anyCancelled) {
lines.push('', '> ⚪ 构建已被取消,可能是由于新的提交触发了新的构建。');
lines.push(
'<div align="center">',
'',
'> 🎉 **所有构建均已成功完成!**',
'>',
'> 点击上方下载链接获取构建产物进行测试',
'',
'</div>'
);
} else if (anyCancelled && !anyFailure) {
lines.push(
'<div align="center">',
'',
'> ⚪ **构建已被取消**',
'>',
'> 可能是由于新的提交触发了新的构建',
'',
'</div>'
);
} else {
lines.push('', '> ⚠️ 部分构建失败,请查看上方错误详情或点击构建日志查看完整输出。');
lines.push(
'<div align="center">',
'',
'> ⚠️ **部分构建失败**',
'>',
'> 请查看上方错误详情或点击构建日志查看完整输出',
'',
'</div>'
);
}
return lines.join('\n');
}

View File

@ -6,6 +6,7 @@
* - PR_NUMBER: PR
* - PR_SHA: PR SHA
* - RUN_ID: GitHub Actions Run ID
* - NAPCAT_VERSION: 构建版本号
* - FRAMEWORK_STATUS: Framework
* - FRAMEWORK_ERROR: Framework
* - SHELL_STATUS: Shell
@ -30,6 +31,7 @@ async function main (): Promise<void> {
const prNumber = parseInt(getEnv('PR_NUMBER', true), 10);
const prSha = getEnv('PR_SHA') || 'unknown';
const runId = getEnv('RUN_ID', true);
const version = getEnv('NAPCAT_VERSION') || '';
const { owner, repo } = getRepository();
const frameworkStatus = parseStatus(getEnv('FRAMEWORK_STATUS'));
@ -39,6 +41,7 @@ async function main (): Promise<void> {
console.log(`PR: #${prNumber}`);
console.log(`SHA: ${prSha}`);
console.log(`Version: ${version}`);
console.log(`Run: ${runId}`);
console.log(`Framework: ${frameworkStatus}${frameworkError ? ` (${frameworkError})` : ''}`);
console.log(`Shell: ${shellStatus}${shellError ? ` (${shellError})` : ''}\n`);
@ -76,7 +79,7 @@ async function main (): Promise<void> {
},
];
const comment = generateResultComment(targets, prSha, runId, repository);
const comment = generateResultComment(targets, prSha, runId, repository, version);
await github.createOrUpdateComment(owner, repo, prNumber, comment, COMMENT_MARKER);
}

View File

@ -13,11 +13,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i
@ -39,11 +55,27 @@ jobs:
steps:
- name: Clone Main Repository
uses: actions/checkout@v4
with:
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Use Node.js 20.X
uses: actions/setup-node@v4
with:
node-version: 20.x
- name: Generate Version
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${GITHUB_SHA::7}"
VERSION="${BASE_VERSION}-main.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: |
npm i -g pnpm
pnpm i

View File

@ -126,6 +126,7 @@ jobs:
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
# 这样即使 PR 中修改了脚本,也不会被执行
@ -143,16 +144,36 @@ jobs:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 获取最新 release tag 并生成版本号
- name: Generate Version
id: version
working-directory: workspace
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
SHORT_SHA="${SHORT_SHA::7}"
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
# 执行构建,使用 base 分支的脚本处理 workspace 中的代码
- name: Build
id: build
working-directory: workspace
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts framework
continue-on-error: true # 允许失败,后续更新评论时处理
@ -177,6 +198,8 @@ jobs:
outputs:
status: ${{ steps.build.outcome }} # 构建结果success/failure
error: ${{ steps.build.outputs.error }} # 错误信息(如有)
version: ${{ steps.version.outputs.version }} # 构建版本号
version: ${{ steps.version.outputs.version }} # 构建版本号
steps:
# 【安全】先从 base 分支 checkout 构建脚本
- name: Checkout scripts from base
@ -193,16 +216,37 @@ jobs:
repository: ${{ needs.check-build.outputs.pr_head_repo }}
ref: ${{ needs.check-build.outputs.pr_sha }}
path: workspace
fetch-depth: 0 # 需要完整历史来获取 tags
- name: Setup Node.js 24
uses: actions/setup-node@v4
with:
node-version: 24
# 获取最新 release tag 并生成版本号
- name: Generate Version
id: version
working-directory: workspace
run: |
# 获取最近的 release tag (格式: vX.X.X)
LATEST_TAG=$(git describe --tags --abbrev=0 --match "v[0-9]*.[0-9]*.[0-9]*" 2>/dev/null || echo "v0.0.0")
# 去掉 v 前缀
BASE_VERSION="${LATEST_TAG#v}"
SHORT_SHA="${{ needs.check-build.outputs.pr_sha }}"
SHORT_SHA="${SHORT_SHA::7}"
VERSION="${BASE_VERSION}-pr.${{ needs.check-build.outputs.pr_number }}.${{ github.run_number }}+${SHORT_SHA}"
echo "NAPCAT_VERSION=${VERSION}" >> $GITHUB_ENV
echo "version=${VERSION}" >> $GITHUB_OUTPUT
echo "Latest tag: ${LATEST_TAG}"
echo "Build version: ${VERSION}"
# 执行构建
- name: Build
id: build
working-directory: workspace
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
NAPCAT_VERSION: ${{ env.NAPCAT_VERSION }}
run: node --experimental-strip-types ../_scripts/.github/scripts/pr-build-run.ts shell
continue-on-error: true
@ -244,6 +288,8 @@ jobs:
PR_NUMBER: ${{ needs.check-build.outputs.pr_number }}
PR_SHA: ${{ needs.check-build.outputs.pr_sha }}
RUN_ID: ${{ github.run_id }}
# 构建版本号
NAPCAT_VERSION: ${{ needs.build-framework.outputs.version || needs.build-shell.outputs.version || '' }}
# 获取构建状态,如果 job 被跳过则标记为 cancelled
FRAMEWORK_STATUS: ${{ needs.build-framework.outputs.status || 'cancelled' }}
FRAMEWORK_ERROR: ${{ needs.build-framework.outputs.error }}

View File

@ -4,13 +4,16 @@ on:
workflow_dispatch:
push:
tags:
- '*'
- 'v[0-9]*.[0-9]*.[0-9]*'
- 'v[0-9]*.[0-9]*.[0-9]*-*'
- 'v[0-9]*.[0-9]*.[0-9]*+*'
- 'v[0-9]*.[0-9]*.[0-9]*-*+*'
permissions: write-all
env:
OPENROUTER_API_URL: https://91vip.futureppo.top/v1/chat/completions
OPENROUTER_MODEL: "kimi-k2-0905-turbo"
OPENROUTER_MODEL: "Antigravity/gemini-3-flash-preview"
RELEASE_NAME: "NapCat"
jobs:
@ -24,6 +27,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Framework
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@ -49,6 +54,8 @@ jobs:
with:
node-version: 20.x
- name: Build NapCat.Shell
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
npm i -g pnpm
pnpm i
@ -171,7 +178,7 @@ jobs:
- name: Generate release note via OpenRouter
env:
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
OPENAI_KEY: ${{ secrets.OPENAI_KEY }}
OPENROUTER_API_URL: ${{ env.OPENROUTER_API_URL }}
OPENROUTER_MODEL: ${{ env.OPENROUTER_MODEL }}
GITHUB_OWNER: "NapNeKo" # 替换成你的 repo owner
@ -233,7 +240,7 @@ jobs:
# 调用 OpenRouter
if RESPONSE=$(curl -s -X POST "$OPENROUTER_API_URL" \
-H "Authorization: Bearer $OPENROUTER_API_KEY" \
-H "Authorization: Bearer $OPENAI_KEY" \
-H "Content-Type: application/json" \
-d "$BODY"); then
echo "=== raw response ==="

View File

@ -2,7 +2,11 @@ import path from 'node:path';
import fs from 'fs';
import os from 'node:os';
import { QQVersionConfigType, QQLevel } from './types';
import { RequestUtil } from './request';
import { compareSemVer } from './version';
import { getAllGitHubTags as getAllTagsFromMirror } from './mirror';
// 导出 compareSemVer 供其他模块使用
export { compareSemVer } from './version';
export async function solveProblem<T extends (...arg: any[]) => any> (func: T, ...args: Parameters<T>): Promise<ReturnType<T> | undefined> {
return new Promise<ReturnType<T> | undefined>((resolve) => {
@ -213,56 +217,19 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
return undefined;
}
const baseUrl = 'https://github.com/NapNeko/NapCatQQ.git/info/refs?service=git-upload-pack';
const urls = [
'https://j.1win.ggff.net/' + baseUrl,
'https://git.yylx.win/' + baseUrl,
'https://ghfile.geekertao.top/' + baseUrl,
'https://gh-proxy.net/' + baseUrl,
'https://ghm.078465.xyz/' + baseUrl,
'https://gitproxy.127731.xyz/' + baseUrl,
'https://jiashu.1win.eu.org/' + baseUrl,
baseUrl,
];
// ============== GitHub Tags 获取 ==============
// 使用 mirror 模块统一管理镜像
async function testUrl (url: string): Promise<boolean> {
try {
await PromiseTimer(RequestUtil.HttpGetText(url), 5000);
return true;
} catch {
return false;
}
}
async function findAvailableUrl (): Promise<string | null> {
for (const url of urls) {
if (await testUrl(url)) {
return url;
}
}
return null;
}
export async function getAllTags (): Promise<string[]> {
const availableUrl = await findAvailableUrl();
if (!availableUrl) {
throw new Error('No available URL for fetching tags');
}
const raw = await RequestUtil.HttpGetText(availableUrl);
return raw
.split('\n')
.map(line => {
const match = line.match(/refs\/tags\/(.+)$/);
return match ? match[1] : null;
})
.filter(tag => tag !== null && !tag!.endsWith('^{}')) as string[];
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
}
export async function getLatestTag (): Promise<string> {
const tags = await getAllTags();
const { tags } = await getAllTags();
tags.sort((a, b) => compareVersion(a, b));
// 使用 SemVer 规范排序
tags.sort((a, b) => compareSemVer(a, b));
const latest = tags.at(-1);
if (!latest) {
@ -271,22 +238,3 @@ export async function getLatestTag (): Promise<string> {
// 去掉开头的 v
return latest.replace(/^v/, '');
}
function compareVersion (a: string, b: string): number {
const normalize = (v: string) =>
v.replace(/^v/, '') // 去掉开头的 v
.split('.')
.map(n => parseInt(n) || 0);
const pa = normalize(a);
const pb = normalize(b);
const len = Math.max(pa.length, pb.length);
for (let i = 0; i < len; i++) {
const na = pa[i] || 0;
const nb = pb[i] || 0;
if (na !== nb) return na - nb;
}
return 0;
}

View File

@ -0,0 +1,898 @@
/**
* GitHub
*
*
* 镜像源测试时间: 2026-01-03
* 测试通过: 55/61
*/
import https from 'https';
import http from 'http';
import { RequestUtil } from './request';
import { PromiseTimer } from './helper';
// ============== 镜像源列表 ==============
/**
* GitHub
* release assets
* 使
*
* 测试时间: 2026-01-03
* 301/302
* 使 30
*/
export const GITHUB_FILE_MIRRORS = [
// 延迟 < 800ms 的最快镜像
'https://github.chenc.dev/', // 666ms
'https://ghproxy.cfd/', // 719ms - 支持重定向
'https://github.tbedu.top/', // 760ms
'https://ghps.cc/', // 768ms
'https://gh.llkk.cc/', // 774ms
'https://ghproxy.cc/', // 777ms
'https://gh.monlor.com/', // 779ms
'https://cdn.akaere.online/', // 784ms
// 延迟 800-1000ms 的快速镜像
'https://gh.idayer.com/', // 869ms
'https://gh-proxy.net/', // 885ms
'https://ghpxy.hwinzniej.top/', // 890ms
'https://github-proxy.memory-echoes.cn/', // 896ms
'https://git.yylx.win/', // 917ms
'https://gitproxy.mrhjx.cn/', // 950ms
'https://jiashu.1win.eu.org/', // 954ms
'https://ghproxy.cn/', // 981ms
// 延迟 1000-1500ms 的中速镜像
'https://gh.fhjhy.top/', // 1014ms
'https://gp.zkitefly.eu.org/', // 1015ms
'https://gh-proxy.com/', // 1022ms
'https://hub.gitmirror.com/', // 1027ms
'https://ghfile.geekertao.top/', // 1029ms
'https://j.1lin.dpdns.org/', // 1037ms
'https://ghproxy.imciel.com/', // 1047ms
'https://github-proxy.teach-english.tech/', // 1047ms
'https://gh.927223.xyz/', // 1071ms
'https://github.ednovas.xyz/', // 1099ms
'https://ghf.xn--eqrr82bzpe.top/',// 1122ms
'https://gh.dpik.top/', // 1131ms
'https://gh.jasonzeng.dev/', // 1139ms
'https://gh.xxooo.cf/', // 1157ms
'https://gh.bugdey.us.kg/', // 1228ms
'https://ghm.078465.xyz/', // 1289ms
'https://j.1win.ggff.net/', // 1329ms
'https://tvv.tw/', // 1393ms
'https://gh.chjina.com/', // 1446ms
'https://gitproxy.127731.xyz/', // 1458ms
// 延迟 1500-2500ms 的较慢镜像
'https://gh.inkchills.cn/', // 1617ms
'https://ghproxy.cxkpro.top/', // 1651ms
'https://gh.sixyin.com/', // 1686ms
'https://github.geekery.cn/', // 1734ms
'https://git.669966.xyz/', // 1824ms
'https://gh.5050net.cn/', // 1858ms
'https://gh.felicity.ac.cn/', // 1903ms
'https://gh.ddlc.top/', // 2056ms
'https://cf.ghproxy.cc/', // 2058ms
'https://gitproxy.click/', // 2068ms
'https://github.dpik.top/', // 2313ms
'https://gh.zwnes.xyz/', // 2434ms
'https://ghp.keleyaa.com/', // 2440ms
'https://gh.wsmdn.dpdns.org/', // 2744ms
// 延迟 > 2500ms 的慢速镜像(作为备用)
'https://ghproxy.monkeyray.net/', // 3023ms
'https://fastgit.cc/', // 3369ms
'https://cdn.gh-proxy.com/', // 3394ms
'https://gh.catmak.name/', // 4119ms
'https://gh.noki.icu/', // 5990ms
'', // 原始 URL无镜像
];
/**
* GitHub API
* 访 GitHub API
* 使 API API
*
* API
* 使 getLatestReleaseTag API
*/
export const GITHUB_API_MIRRORS = [
'https://api.github.com',
// 目前没有可用的公共 API 代理镜像
];
/**
* GitHub Raw
* 访 raw.githubusercontent.com
* raw
*/
export const GITHUB_RAW_MIRRORS = [
'https://raw.githubusercontent.com',
// 测试确认支持 raw 文件的镜像
'https://github.chenc.dev/https://raw.githubusercontent.com',
'https://ghproxy.cfd/https://raw.githubusercontent.com',
'https://gh.llkk.cc/https://raw.githubusercontent.com',
'https://ghproxy.cc/https://raw.githubusercontent.com',
'https://gh-proxy.net/https://raw.githubusercontent.com',
];
// ============== 镜像配置接口 ==============
export interface MirrorConfig {
/** 文件下载镜像(用于 release assets */
fileMirrors: string[];
/** API 镜像 */
apiMirrors: string[];
/** Raw 文件镜像 */
rawMirrors: string[];
/** 超时时间(毫秒) */
timeout: number;
/** 是否启用镜像 */
enabled: boolean;
/** 自定义镜像(优先使用) */
customMirror?: string;
}
// ============== 默认配置 ==============
const defaultConfig: MirrorConfig = {
fileMirrors: GITHUB_FILE_MIRRORS,
apiMirrors: GITHUB_API_MIRRORS,
rawMirrors: GITHUB_RAW_MIRRORS,
timeout: 10000, // 10秒超时平衡速度和可靠性
enabled: true,
customMirror: undefined,
};
let currentConfig: MirrorConfig = { ...defaultConfig };
// ============== 懒加载镜像测速缓存 ==============
interface MirrorTestResult {
mirror: string;
latency: number;
success: boolean;
}
// 缓存的快速镜像列表(按延迟排序)
let cachedFastMirrors: string[] | null = null;
// 测速是否正在进行
let mirrorTestingPromise: Promise<string[]> | null = null;
// 缓存过期时间30分钟
const MIRROR_CACHE_TTL = 30 * 60 * 1000;
let cacheTimestamp: number = 0;
/**
* 使 HEAD
* release
*/
async function testMirrorLatency (mirror: string, timeout: number = 5000): Promise<MirrorTestResult> {
// 使用一个实际存在的小文件来测试README 或小型 release asset
// 用 HEAD 请求,不下载实际内容
const testUrl = 'https://github.com/NapNeko/NapCatQQ/releases/latest';
const url = buildMirrorUrl(testUrl, mirror);
const start = Date.now();
return new Promise<MirrorTestResult>((resolve) => {
try {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const req = client.request({
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'HEAD',
timeout,
headers: {
'User-Agent': 'NapCat-Mirror-Test',
},
}, (res) => {
const statusCode = res.statusCode || 0;
// 2xx 或 3xx 都算成功3xx 说明镜像工作正常,会重定向)
const isValid = statusCode >= 200 && statusCode < 400;
resolve({
mirror,
latency: Date.now() - start,
success: isValid,
});
});
req.on('error', () => {
resolve({
mirror,
latency: Infinity,
success: false,
});
});
req.on('timeout', () => {
req.destroy();
resolve({
mirror,
latency: Infinity,
success: false,
});
});
req.end();
} catch {
resolve({
mirror,
latency: Infinity,
success: false,
});
}
});
}
/**
*
* 使
*/
export async function getFastMirrors (forceRefresh: boolean = false): Promise<string[]> {
// 检查缓存是否有效
const now = Date.now();
if (!forceRefresh && cachedFastMirrors && (now - cacheTimestamp) < MIRROR_CACHE_TTL) {
return cachedFastMirrors;
}
// 如果已经在测速中,等待结果
if (mirrorTestingPromise) {
return mirrorTestingPromise;
}
// 开始测速
mirrorTestingPromise = performMirrorTest();
try {
const result = await mirrorTestingPromise;
cachedFastMirrors = result;
cacheTimestamp = now;
return result;
} finally {
mirrorTestingPromise = null;
}
}
/**
*
*
*/
async function performMirrorTest (): Promise<string[]> {
// 开始镜像测速
const timeout = 8000; // 测速超时 8 秒
// 并行测试所有镜像
const mirrors = currentConfig.fileMirrors.filter(m => m);
const results = await Promise.all(
mirrors.map(m => testMirrorLatency(m, timeout))
);
// 过滤成功的镜像并按延迟排序
const successfulMirrors = results
.filter(r => r.success)
.sort((a, b) => a.latency - b.latency)
.map(r => r.mirror);
// 至少返回原始 URL
if (successfulMirrors.length === 0) {
return [''];
}
return successfulMirrors;
}
/**
*
*/
export function clearMirrorCache (): void {
cachedFastMirrors = null;
cacheTimestamp = 0;
}
/**
*
*/
export function getMirrorCacheStatus (): { cached: boolean; count: number; age: number; } {
return {
cached: cachedFastMirrors !== null,
count: cachedFastMirrors?.length ?? 0,
age: cachedFastMirrors ? Date.now() - cacheTimestamp : 0,
};
}
// ============== 配置管理 ==============
/**
*
*/
export function getMirrorConfig (): MirrorConfig {
return { ...currentConfig };
}
/**
*
*/
export function setMirrorConfig (config: Partial<MirrorConfig>): void {
currentConfig = { ...currentConfig, ...config };
}
/**
*
*/
export function resetMirrorConfig (): void {
currentConfig = { ...defaultConfig };
}
/**
*
*/
export function setCustomMirror (mirror: string): void {
currentConfig.customMirror = mirror;
}
// ============== URL 工具函数 ==============
/**
* URL
* @param originalUrl URL
* @param mirror
*/
export function buildMirrorUrl (originalUrl: string, mirror: string): string {
if (!mirror) return originalUrl;
// 如果镜像已经包含完整域名,直接拼接
if (mirror.endsWith('/')) {
return mirror + originalUrl;
}
return mirror + '/' + originalUrl;
}
/**
* URL HTTP GET
* @param url URL
* @param timeout
*/
export async function testUrl (url: string, timeout: number = 5000): Promise<boolean> {
try {
await PromiseTimer(RequestUtil.HttpGetText(url), timeout);
return true;
} catch {
return false;
}
}
/**
* URL HTTP HEAD
* Content-TypeContent-Length
*/
export async function testUrlHead (url: string, timeout: number = 5000): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const req = client.request({
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'HEAD',
timeout,
headers: {
'User-Agent': 'NapCat-Mirror-Test',
},
}, (res) => {
const statusCode = res.statusCode || 0;
const contentType = (res.headers['content-type'] as string) || '';
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
// 验证条件:
// 1. 状态码 2xx 或 3xx
// 2. Content-Type 不应该是 text/html表示错误页面
// 3. 对于 .zip 文件Content-Length 应该 > 1MB避免获取到错误页面
const isValidStatus = statusCode >= 200 && statusCode < 400;
const isNotHtmlError = !contentType.includes('text/html');
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
resolve(isValidStatus && isNotHtmlError && isValidSize);
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
req.end();
});
}
/**
* URL
*
*/
export interface UrlValidationResult {
valid: boolean;
statusCode?: number;
contentType?: string;
contentLength?: number;
error?: string;
}
export async function validateUrl (url: string, timeout: number = 5000): Promise<UrlValidationResult> {
return new Promise<UrlValidationResult>((resolve) => {
const urlObj = new URL(url);
const isHttps = urlObj.protocol === 'https:';
const client = isHttps ? https : http;
const req = client.request({
hostname: urlObj.hostname,
port: urlObj.port || (isHttps ? 443 : 80),
path: urlObj.pathname + urlObj.search,
method: 'HEAD',
timeout,
headers: {
'User-Agent': 'NapCat-Mirror-Test',
},
}, (res) => {
const statusCode = res.statusCode || 0;
const contentType = (res.headers['content-type'] as string) || '';
const contentLength = parseInt((res.headers['content-length'] as string) || '0', 10);
// 验证条件
const isValidStatus = statusCode >= 200 && statusCode < 400;
const isNotHtmlError = !contentType.includes('text/html');
const isValidSize = url.endsWith('.zip') ? contentLength > 1024 * 1024 : true;
if (!isValidStatus) {
resolve({
valid: false,
statusCode,
contentType,
contentLength,
error: `HTTP ${statusCode}`,
});
} else if (!isNotHtmlError) {
resolve({
valid: false,
statusCode,
contentType,
contentLength,
error: '返回了 HTML 页面而非文件',
});
} else if (!isValidSize) {
resolve({
valid: false,
statusCode,
contentType,
contentLength,
error: `文件过小 (${contentLength} bytes),可能是错误页面`,
});
} else {
resolve({
valid: true,
statusCode,
contentType,
contentLength,
});
}
});
req.on('error', (e: Error) => resolve({
valid: false,
error: e.message,
}));
req.on('timeout', () => {
req.destroy();
resolve({
valid: false,
error: 'Timeout',
});
});
req.end();
});
}
// ============== 查找可用 URL ==============
/**
* URL
* 使
* @param originalUrl GitHub URL
* @param options
*/
export async function findAvailableDownloadUrl (
originalUrl: string,
options: {
mirrors?: string[];
timeout?: number;
customMirror?: string;
testMethod?: 'head' | 'get';
/** 是否使用详细验证(验证 Content-Type 和 Content-Length */
validateContent?: boolean;
/** 期望的最小文件大小(字节),用于验证 */
minFileSize?: number;
/** 是否使用懒加载的快速镜像列表 */
useFastMirrors?: boolean;
} = {}
): Promise<string> {
const {
timeout = currentConfig.timeout,
customMirror = currentConfig.customMirror,
testMethod = 'head',
validateContent = true, // 默认启用内容验证
minFileSize,
useFastMirrors = true, // 默认使用快速镜像列表
} = options;
// 获取镜像列表
let mirrors = options.mirrors;
if (!mirrors) {
if (useFastMirrors) {
// 使用懒加载的快速镜像列表
mirrors = await getFastMirrors();
} else {
mirrors = currentConfig.fileMirrors;
}
}
// 使用增强验证或简单测试
const testWithValidation = async (url: string): Promise<boolean> => {
if (validateContent) {
const result = await validateUrl(url, timeout);
// 额外检查文件大小
if (result.valid && minFileSize && result.contentLength && result.contentLength < minFileSize) {
return false;
}
return result.valid;
}
return testMethod === 'head' ? testUrlHead(url, timeout) : testUrl(url, timeout);
};
// 1. 如果设置了自定义镜像,优先使用
if (customMirror) {
const customUrl = buildMirrorUrl(originalUrl, customMirror);
if (await testWithValidation(customUrl)) {
return customUrl;
}
}
// 2. 先测试原始 URL
if (await testWithValidation(originalUrl)) {
return originalUrl;
}
// 3. 测试镜像源(已按延迟排序)
let testedCount = 0;
for (const mirror of mirrors) {
if (!mirror) continue; // 跳过空字符串
const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
testedCount++;
if (await testWithValidation(mirrorUrl)) {
return mirrorUrl;
}
}
throw new Error(`所有下载源都不可用(已测试 ${testedCount} 个镜像),请检查网络连接或配置自定义镜像`);
}
// ============== 版本和 Release 相关(减少 API 依赖) ==============
/**
*
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-.]+))?(?:\+([0-9A-Za-z-.]+))?$/;
/**
*
*/
function parseSemVerSimple (version: string): { major: number; minor: number; patch: number; prerelease: string; } | null {
const match = version.match(SEMVER_REGEX);
if (!match) return null;
return {
major: parseInt(match[1] ?? '0', 10),
minor: parseInt(match[2] ?? '0', 10),
patch: parseInt(match[3] ?? '0', 10),
prerelease: match[4] || '',
};
}
/**
*
*/
function compareSemVerSimple (a: string, b: string): number {
const pa = parseSemVerSimple(a);
const pb = parseSemVerSimple(b);
if (!pa && !pb) return 0;
if (!pa) return -1;
if (!pb) return 1;
if (pa.major !== pb.major) return pa.major - pb.major;
if (pa.minor !== pb.minor) return pa.minor - pb.minor;
if (pa.patch !== pb.patch) return pa.patch - pb.patch;
// 预发布版本排在正式版本前面
if (pa.prerelease && !pb.prerelease) return -1;
if (!pa.prerelease && pb.prerelease) return 1;
return pa.prerelease.localeCompare(pb.prerelease);
}
/**
* tags release tag
* GitHub API
*/
export async function getLatestReleaseTag (owner: string, repo: string): Promise<string> {
const result = await getAllGitHubTags(owner, repo);
// 过滤出符合 semver 的 tags
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
if (releaseTags.length === 0) {
throw new Error('未找到有效的 release tag');
}
// 按版本号排序,取最新的
releaseTags.sort(compareSemVerSimple);
const latest = releaseTags[releaseTags.length - 1];
if (!latest) {
throw new Error('未找到有效的 release tag');
}
return latest;
}
/**
* GitHub release URL
* API tag asset
*/
export function buildReleaseDownloadUrl (
owner: string,
repo: string,
tag: string,
assetName: string
): string {
return `https://github.com/${owner}/${repo}/releases/download/${tag}/${assetName}`;
}
/**
* GitHub release 使 API
*
*
* 1. git refs tags
* 2. URL API
* 3. changelog 使 API
*/
export async function getGitHubRelease (
owner: string,
repo: string,
tag: string = 'latest',
options: {
/** 需要获取的 asset 名称列表 */
assetNames?: string[];
/** 是否需要获取 changelog需要调用 API */
fetchChangelog?: boolean;
} = {}
): Promise<{
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
body?: string;
}> {
const { assetNames = [], fetchChangelog = false } = options;
// 1. 获取实际的 tag 名称
let actualTag: string;
if (tag === 'latest') {
actualTag = await getLatestReleaseTag(owner, repo);
} else {
actualTag = tag;
}
// 2. 构建 assets 列表(不需要 API
const assets = assetNames.map(name => ({
name,
browser_download_url: buildReleaseDownloadUrl(owner, repo, actualTag, name),
}));
// 3. 如果不需要 changelog 且有 assetNames直接返回
if (!fetchChangelog && assetNames.length > 0) {
return {
tag_name: actualTag,
assets,
body: undefined,
};
}
// 4. 需要更多信息时,尝试调用 API作为备选
const endpoint = `https://api.github.com/repos/${owner}/${repo}/releases/tags/${actualTag}`;
for (const apiBase of currentConfig.apiMirrors) {
try {
const url = endpoint.replace('https://api.github.com', apiBase);
const response = await PromiseTimer(
RequestUtil.HttpGetJson<any>(url, 'GET', undefined, {
'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json',
}),
currentConfig.timeout
);
return response;
} catch {
continue;
}
}
// 5. API 全部失败,但如果有 assetNames仍然返回构建的 URL
if (assetNames.length > 0) {
return {
tag_name: actualTag,
assets,
body: undefined,
};
}
throw new Error('无法获取 release 信息,所有 API 源都不可用');
}
// ============== Tags 缓存 ==============
interface TagsCache {
tags: string[];
mirror: string;
timestamp: number;
}
// 缓存 tags 结果5 分钟有效)
const TAGS_CACHE_TTL = 5 * 60 * 1000;
const tagsCache: Map<string, TagsCache> = new Map();
/**
* GitHub tags
* 使
*/
export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> {
const cacheKey = `${owner}/${repo}`;
// 检查缓存
const cached = tagsCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < TAGS_CACHE_TTL) {
return { tags: cached.tags, mirror: cached.mirror };
}
const baseUrl = `https://github.com/${owner}/${repo}.git/info/refs?service=git-upload-pack`;
// 解析 tags 的辅助函数
const parseTags = (raw: string): string[] => {
return raw
.split('\n')
.map((line: string) => {
const match = line.match(/refs\/tags\/(.+)$/);
return match ? match[1] : undefined;
})
.filter((tag): tag is string => tag !== undefined && !tag.endsWith('^{}'));
};
// 尝试从 URL 获取 tags
const fetchFromUrl = async (url: string): Promise<string[] | null> => {
try {
const raw = await PromiseTimer(
RequestUtil.HttpGetText(url),
currentConfig.timeout
);
// 检查返回内容是否有效(不是 HTML 错误页面)
if (raw.includes('<!DOCTYPE') || raw.includes('<html')) {
return null;
}
const tags = parseTags(raw);
if (tags.length > 0) {
return tags;
}
return null;
} catch {
return null;
}
};
// 获取快速镜像列表(懒加载,首次调用会测速,已按延迟排序)
let fastMirrors: string[] = [];
try {
fastMirrors = await getFastMirrors();
} catch (e) {
// 忽略错误,继续使用空列表
}
// 构建 URL 列表(快速镜像 + 原始 URL
const mirrorUrls = fastMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m }));
mirrorUrls.push({ url: baseUrl, mirror: 'github.com' }); // 添加原始 URL
// 按顺序尝试每个镜像(已按延迟排序),成功即返回
for (const { url, mirror } of mirrorUrls) {
const tags = await fetchFromUrl(url);
if (tags && tags.length > 0) {
// 缓存结果
tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() });
return { tags, mirror };
}
}
// 如果快速镜像都失败,回退到原始镜像列表
const allMirrors = currentConfig.fileMirrors.filter(m => m);
for (const mirror of allMirrors) {
// 跳过已经尝试过的镜像
if (fastMirrors.includes(mirror)) continue;
const url = buildMirrorUrl(baseUrl, mirror);
const tags = await fetchFromUrl(url);
if (tags && tags.length > 0) {
// 缓存结果
tagsCache.set(cacheKey, { tags, mirror, timestamp: Date.now() });
return { tags, mirror };
}
}
throw new Error('无法获取 tags所有源都不可用');
}
// ============== Action Artifacts 支持 ==============
export interface ActionArtifact {
id: number;
name: string;
size_in_bytes: number;
created_at: string;
expires_at: string;
archive_download_url: string;
}
/**
* GitHub Action artifacts
* nightly/dev
*/
export async function getLatestActionArtifacts (
owner: string,
repo: string,
workflow: string = 'build.yml',
branch: string = 'main'
): Promise<ActionArtifact[]> {
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=1`;
try {
const runsResponse = await RequestUtil.HttpGetJson<{
workflow_runs: Array<{ id: number; }>;
}>(endpoint, 'GET', undefined, {
'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json',
});
const workflowRuns = runsResponse.workflow_runs;
if (!workflowRuns || workflowRuns.length === 0) {
throw new Error('No successful workflow runs found');
}
const firstRun = workflowRuns[0];
if (!firstRun) {
throw new Error('No workflow run found');
}
const runId = firstRun.id;
const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${runId}/artifacts`;
const artifactsResponse = await RequestUtil.HttpGetJson<{
artifacts: ActionArtifact[];
}>(artifactsEndpoint, 'GET', undefined, {
'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json',
});
return artifactsResponse.artifacts || [];
} catch {
return [];
}
}

View File

@ -3,11 +3,11 @@ import http from 'node:http';
export class RequestUtil {
// 适用于获取服务器下发cookies时获取仅GET
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string }> {
static async HttpsGetCookies (url: string): Promise<{ [key: string]: string; }> {
const client = url.startsWith('https') ? https : http;
return new Promise((resolve, reject) => {
const req = client.get(url, (res) => {
const cookies: { [key: string]: string } = {};
const cookies: { [key: string]: string; } = {};
res.on('data', () => { }); // Necessary to consume the stream
res.on('end', () => {
@ -27,7 +27,7 @@ export class RequestUtil {
});
}
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string }): Promise<{ [key: string]: string }> {
private static async handleRedirect (res: http.IncomingMessage, url: string, cookies: { [key: string]: string; }): Promise<{ [key: string]: string; }> {
if (res.statusCode === 301 || res.statusCode === 302) {
if (res.headers.location) {
const redirectUrl = new URL(res.headers.location, url);
@ -39,7 +39,7 @@ export class RequestUtil {
return cookies;
}
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string }) {
private static extractCookies (setCookieHeaders: string[], cookies: { [key: string]: string; }) {
setCookieHeaders.forEach((cookie) => {
const parts = cookie.split(';')[0]?.split('=');
if (parts) {
@ -53,9 +53,10 @@ export class RequestUtil {
}
// 请求和回复都是JSON data传原始内容 自动编码json
static async HttpGetJson<T>(url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true): Promise<T> {
// 支持 301/302 重定向(最多 5 次)
static async HttpGetJson<T> (url: string, method: string = 'GET', data?: any, headers: {
[key: string]: string;
} = {}, isJsonRet: boolean = true, isArgJson: boolean = true, maxRedirects: number = 5): Promise<T> {
const option = new URL(url);
const protocol = url.startsWith('https://') ? https : http;
const options = {
@ -71,6 +72,20 @@ export class RequestUtil {
// },
return new Promise((resolve, reject) => {
const req = protocol.request(options, (res: http.IncomingMessage) => {
// 处理重定向
if ((res.statusCode === 301 || res.statusCode === 302 || res.statusCode === 307 || res.statusCode === 308) && res.headers.location) {
if (maxRedirects <= 0) {
reject(new Error('Too many redirects'));
return;
}
const redirectUrl = new URL(res.headers.location, url).href;
// 递归跟随重定向
this.HttpGetJson<T>(redirectUrl, method, data, headers, isJsonRet, isArgJson, maxRedirects - 1)
.then(resolve)
.catch(reject);
return;
}
let responseBody = '';
res.on('data', (chunk: string | Buffer) => {
responseBody += chunk.toString();
@ -109,7 +124,7 @@ export class RequestUtil {
}
// 请求返回都是原始内容
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string } = {}) {
static async HttpGetText (url: string, method: string = 'GET', data?: any, headers: { [key: string]: string; } = {}) {
return this.HttpGetJson<string>(url, method, data, headers, false, false);
}
}

View File

@ -1,2 +1,118 @@
// @ts-ignore
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || 'alpha';
export const napCatVersion = (typeof import.meta?.env !== 'undefined' && import.meta.env.VITE_NAPCAT_VERSION) || '1.0.0-dev';
/**
* SemVer 2.0
* 格式: 主版本号.次版本号.修订号[-][+]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
export interface SemVerInfo {
valid: boolean;
normalized: string;
major: number;
minor: number;
patch: number;
prerelease: string | null;
buildmetadata: string | null;
}
/**
* SemVer 2.0
* @param version - ( v )
* @returns SemVer
*/
export function parseSemVer (version: string | undefined | null): SemVerInfo {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1]!, 10);
const minor = parseInt(match[2]!, 10);
const patch = parseInt(match[3]!, 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
// 构建标准化版本号(不带 v 前缀)
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
/**
* SemVer 2.0
* @param version -
* @returns
*/
export function isValidSemVer (version: string | undefined | null): boolean {
return parseSemVer(version).valid;
}
/**
* SemVer
* @param v1 - 1
* @param v2 - 2
* @returns -1 (v1 < v2), 0 (v1 == v2), 1 (v1 > v2)
*/
export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
const a = parseSemVer(v1);
const b = parseSemVer(v2);
if (!a.valid || !b.valid) {
return 0;
}
// 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
// 比较次版本号
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
// 比较修订号
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
// 有先行版本号的版本优先级较低
if (a.prerelease && !b.prerelease) return -1;
if (!a.prerelease && b.prerelease) return 1;
// 两者都有先行版本号时,按字典序比较
if (a.prerelease && b.prerelease) {
const aParts = a.prerelease.split('.');
const bParts = b.prerelease.split('.');
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i++) {
const aPart = aParts[i];
const bPart = bParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
// 数字 vs 数字
if (!isNaN(aNum) && !isNaN(bNum)) {
if (aNum !== bNum) return aNum > bNum ? 1 : -1;
continue;
}
// 数字优先级低于字符串
if (!isNaN(aNum)) return -1;
if (!isNaN(bNum)) return 1;
// 字符串 vs 字符串
if (aPart !== bPart) return aPart > bPart ? 1 : -1;
}
}
return 0;
}
/**
*
*/
export const napCatVersionInfo = parseSemVer(napCatVersion);

View File

@ -6,8 +6,49 @@ import { fileURLToPath } from 'url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
/**
* SemVer 2.0 正则表达式
* 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
/**
* Validate version format according to SemVer 2.0 specification
* @param {string} version - The version string to validate (with or without 'v' prefix)
* @returns {{ valid: boolean, normalized: string, major: number, minor: number, patch: number, prerelease: string|null, buildmetadata: string|null }}
*/
function validateVersion (version) {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1], 10);
const minor = parseInt(match[2], 10);
const patch = parseInt(match[3], 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
// 构建标准化版本号(不带 v 前缀)
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
/**
* NapCat Vite Plugin: fetches latest GitHub tag (not release) and injects into import.meta.env
*
* 版本号来源优先级:
* 1. 环境变量 NAPCAT_VERSION (用于 CI 构建)
* 2. 缓存的 GitHub tag
* 3. GitHub API 获取最新 tag
* 4. 兆底版本号: 1.0.0-dev
*/
export default function vitePluginNapcatVersion () {
const pluginDir = path.resolve(__dirname, 'dist');
@ -16,6 +57,9 @@ export default function vitePluginNapcatVersion () {
const repo = 'NapCatQQ';
const maxAgeMs = 24 * 60 * 60 * 1000; // cache 1 day
const githubToken = process.env.GITHUB_TOKEN;
// CI 构建时可通过环境变量直接指定版本号
const envVersion = process.env.NAPCAT_VERSION;
const fallbackVersion = '1.0.0-dev';
fs.mkdirSync(pluginDir, { recursive: true });
@ -58,7 +102,14 @@ export default function vitePluginNapcatVersion () {
try {
const json = JSON.parse(data);
if (Array.isArray(json) && json[0]?.name) {
resolve(json[0].name.replace(/^v/, ''));
const tagName = json[0].name;
const { valid, normalized } = validateVersion(tagName);
if (valid) {
resolve(normalized);
} else {
console.warn(`[vite-plugin-napcat-version] Invalid tag format: ${tagName}, expected vX.X.X`);
reject(new Error(`Invalid tag format: ${tagName}, expected vX.X.X`));
}
} else reject(new Error('Invalid GitHub tag response'));
} catch (e) {
reject(e);
@ -71,6 +122,17 @@ export default function vitePluginNapcatVersion () {
}
async function getVersion () {
// 优先使用环境变量指定的版本号 (CI 构建)
if (envVersion) {
const { valid, normalized } = validateVersion(envVersion);
if (valid) {
console.log(`[vite-plugin-napcat-version] Using version from NAPCAT_VERSION env: ${normalized}`);
return normalized;
} else {
console.warn(`[vite-plugin-napcat-version] Invalid NAPCAT_VERSION format: ${envVersion}, falling back to fetch`);
}
}
const cached = readCache();
if (cached) return cached;
try {
@ -79,7 +141,7 @@ export default function vitePluginNapcatVersion () {
return tag;
} catch (e) {
console.warn('[vite-plugin-napcat-version] Failed to fetch tag:', e.message);
return cached ?? '0.0.0';
return cached ?? fallbackVersion;
}
}
@ -115,3 +177,6 @@ export default function vitePluginNapcatVersion () {
},
};
}
// Export validateVersion for external use
export { validateVersion };

View File

@ -3,7 +3,8 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index';
import { getLatestTag } from 'napcat-common/src/helper';
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
export const GetNapCatVersion: RequestHandler = (_, res) => {
const data = WebUiDataRuntime.GetNapCatVersion();
@ -15,7 +16,121 @@ export const getLatestTagHandler: RequestHandler = async (_, res) => {
const latestTag = await getLatestTag();
sendSuccess(res, latestTag);
} catch (error) {
res.status(500).json({ error: 'Failed to fetch latest tag' });
res.status(500).json({ error: 'Failed to fetch latest tag', details: (error as Error).message });
}
};
/**
*
*/
export interface VersionInfo {
tag: string;
type: 'release' | 'prerelease' | 'action';
/** Action artifact 专用字段 */
artifactId?: number;
artifactName?: string;
createdAt?: string;
expiresAt?: string;
size?: number;
}
/**
* release + action artifacts
*
*/
export const getAllReleasesHandler: RequestHandler = async (req, res) => {
try {
const page = parseInt(req.query['page'] as string) || 1;
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
const includeActions = req.query['includeActions'] !== 'false';
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
let tags: string[] = [];
let usedMirror = '';
try {
const result = await getAllTags();
tags = result.tags;
usedMirror = result.mirror;
} catch {
// 如果获取 tags 失败,返回空列表而不是抛出错误
tags = [];
}
// 解析版本信息
const versions: VersionInfo[] = tags.map(tag => {
// 检查是否是预发布版本
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
return {
tag,
type: isPrerelease ? 'prerelease' : 'release',
};
});
// 使用语义化版本排序(最新的在前)
versions.sort((a, b) => -compareSemVer(a.tag, b.tag));
// 获取 Action Artifacts如果请求
let actionVersions: VersionInfo[] = [];
if (includeActions) {
try {
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
actionVersions = artifacts
.filter(a => a.name.includes('NapCat'))
.map(a => ({
tag: `action-${a.id}`,
type: 'action' as const,
artifactId: a.id,
artifactName: a.name,
createdAt: a.created_at,
expiresAt: a.expires_at,
size: a.size_in_bytes,
}));
} catch {
// 忽略 action artifacts 获取失败
}
}
// 合并版本列表action 在最前面)
let allVersions = [...actionVersions, ...versions];
// 按类型过滤
if (typeFilter && typeFilter !== 'all') {
if (typeFilter === 'release') {
allVersions = allVersions.filter(v => v.type === 'release' || v.type === 'prerelease');
} else if (typeFilter === 'action') {
allVersions = allVersions.filter(v => v.type === 'action');
}
}
// 搜索过滤
if (searchQuery) {
allVersions = allVersions.filter(v => {
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
return tagMatch || nameMatch;
});
}
// 分页
const total = allVersions.length;
const totalPages = Math.ceil(total / pageSize);
const start = (page - 1) * pageSize;
const end = start + pageSize;
const paginatedVersions = allVersions.slice(start, end);
sendSuccess(res, {
versions: paginatedVersions,
pagination: {
page,
pageSize,
total,
totalPages,
},
mirror: usedMirror
});
} catch (error) {
res.status(500).json({ error: 'Failed to fetch releases' });
}
};

View File

@ -8,14 +8,17 @@ import { webUiPathWrapper } from '../../index';
import { NapCatPathWrapper } from '@/napcat-common/src/path';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
import {
getGitHubRelease,
findAvailableDownloadUrl
} from '@/napcat-common/src/mirror';
interface Release {
tag_name: string;
assets: Array<{
name: string;
browser_download_url: string;
}>;
body?: string;
// 更新请求体接口
interface UpdateRequestBody {
/** 要更新到的版本 tag如 "v4.9.9",不传则更新到最新版本 */
targetVersion?: string;
/** 是否强制更新(即使是降级也更新) */
force?: boolean;
}
// 更新配置文件接口
@ -69,74 +72,7 @@ function scanFilesRecursively (dirPath: string, basePath: string = dirPath): Arr
return files;
}
// 镜像源列表参考ffmpeg下载实现
const mirrorUrls = [
'https://j.1win.ggff.net/',
'https://git.yylx.win/',
'https://ghfile.geekertao.top/',
'https://gh-proxy.net/',
'https://ghm.078465.xyz/',
'https://gitproxy.127731.xyz/',
'https://jiashu.1win.eu.org/',
'', // 原始URL
];
/**
* URL是否可用
*/
async function testUrl (url: string): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const req = https.get(url, { timeout: 5000 }, (res) => {
const statusCode = res.statusCode || 0;
if (statusCode >= 200 && statusCode < 300) {
req.destroy();
resolve(true);
} else {
req.destroy();
resolve(false);
}
});
req.on('error', () => resolve(false));
req.on('timeout', () => {
req.destroy();
resolve(false);
});
});
}
/**
* URL
*/
function buildMirrorUrl (originalUrl: string, mirror: string): string {
if (!mirror) return originalUrl;
return mirror + originalUrl;
}
/**
* URL
*/
async function findAvailableUrl (originalUrl: string): Promise<string> {
console.log('Testing download URLs...');
// 先测试原始URL
if (await testUrl(originalUrl)) {
console.log('Using original URL:', originalUrl);
return originalUrl;
}
// 测试镜像源
for (const mirror of mirrorUrls) {
const mirrorUrl = buildMirrorUrl(originalUrl, mirror);
console.log('Testing mirror:', mirrorUrl);
if (await testUrl(mirrorUrl)) {
console.log('Using mirror URL:', mirrorUrl);
return mirrorUrl;
}
}
throw new Error('所有下载源都不可用');
}
// 注:镜像配置已迁移到 @/napcat-common/src/mirror 模块统一管理
/**
*
@ -184,24 +120,73 @@ async function downloadFile (url: string, dest: string): Promise<void> {
});
}
export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
try {
// 获取最新release信息
const latestRelease = await getLatestRelease() as Release;
// 从请求体获取目标版本(可选)
const { targetVersion, force } = req.body as UpdateRequestBody;
// 确定要下载的文件名
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
const shellZipAsset = latestRelease.assets.find(asset => asset.name === ReleaseName);
// 确定目标版本 tag
// 如果指定了版本,使用指定版本;否则使用 'latest'
const targetTag = targetVersion || 'latest';
console.log(`[NapCat Update] Target version: ${targetTag}`);
// 使用 mirror 模块获取 release 信息(不依赖 API
// 通过 assetNames 参数直接构建下载 URL避免调用 GitHub API
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
fetchChangelog: false, // 不需要 changelog避免 API 调用
});
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
if (!shellZipAsset) {
throw new Error(`未找到${ReleaseName}文件`);
}
// 检查是否需要强制更新(降级警告)
const currentVersion = WebUiDataRuntime.GetNapCatVersion();
console.log(`[NapCat Update] Current version: ${currentVersion}, Target version: ${release.tag_name}`);
if (!force && currentVersion) {
// 简单的版本比较(可选的降级保护)
const parseVersion = (v: string): [number, number, number] => {
const match = v.match(/^v?(\d+)\.(\d+)\.(\d+)/);
if (!match) return [0, 0, 0];
return [parseInt(match[1] || '0'), parseInt(match[2] || '0'), parseInt(match[3] || '0')];
};
const [currMajor, currMinor, currPatch] = parseVersion(currentVersion);
const [targetMajor, targetMinor, targetPatch] = parseVersion(release.tag_name);
const isDowngrade =
targetMajor < currMajor ||
(targetMajor === currMajor && targetMinor < currMinor) ||
(targetMajor === currMajor && targetMinor === currMinor && targetPatch < currPatch);
if (isDowngrade) {
console.log(`[NapCat Update] Downgrade from ${currentVersion} to ${release.tag_name}, force=${force}`);
// 不阻止降级,只是记录日志
}
}
console.log(`[NapCat Update] Updating to version: ${release.tag_name}`);
// 创建临时目录
const tempDir = path.join(webUiPathWrapper.binaryPath, './temp');
if (!fs.existsSync(tempDir)) {
fs.mkdirSync(tempDir, { recursive: true });
}
// 查找可用的下载URL
const downloadUrl = await findAvailableUrl(shellZipAsset.browser_download_url);
// 使用 mirror 模块查找可用的下载 URL
// 启用内容验证,确保返回的是有效文件而非错误页面
const downloadUrl = await findAvailableDownloadUrl(shellZipAsset.browser_download_url, {
validateContent: true, // 验证 Content-Type 和状态码
minFileSize: 1024 * 1024, // 最小 1MB确保不是错误页面
timeout: 10000, // 10秒超时
});
console.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
// 下载zip
const zipPath = path.join(tempDir, 'napcat-latest.zip');
@ -264,10 +249,10 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
// 如果有替换失败的文件,创建更新配置文件
if (failedFiles.length > 0) {
const updateConfig: UpdateConfig = {
version: latestRelease.tag_name,
version: release.tag_name,
updateTime: new Date().toISOString(),
files: failedFiles,
changelog: latestRelease.body || ''
changelog: release.body || ''
};
// 保存更新配置文件
@ -283,7 +268,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
sendSuccess(res, {
status: 'completed',
message,
newVersion: latestRelease.tag_name,
newVersion: release.tag_name,
failedFilesCount: failedFiles.length
});
@ -298,28 +283,7 @@ export const UpdateNapCatHandler: RequestHandler = async (_req, res) => {
}
};
async function getLatestRelease (): Promise<Release> {
return new Promise((resolve, reject) => {
https.get('https://api.github.com/repos/NapNeko/NapCatQQ/releases/latest', {
headers: { 'User-Agent': 'NapCat-WebUI' }
}, (res) => {
let data = '';
res.on('data', chunk => data += chunk);
res.on('end', () => {
try {
const release = JSON.parse(data) as Release;
console.log('Release info:', {
tag_name: release.tag_name,
assets: release.assets?.map(a => ({ name: a.name, url: a.browser_download_url }))
});
resolve(release);
} catch (e) {
reject(e);
}
});
}).on('error', reject);
});
}
// 注getLatestRelease 已移除,现在使用 mirror 模块的 getGitHubRelease
/**
*

View File

@ -1,5 +1,5 @@
import { Router } from 'express';
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler } from '../api/BaseInfo';
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo';
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
import { GetProxyHandler } from '../api/Proxy';
@ -8,6 +8,7 @@ const router = Router();
router.get('/QQVersion', QQVersionHandler);
router.get('/GetNapCatVersion', GetNapCatVersion);
router.get('/getLatestTag', getLatestTagHandler);
router.get('/getAllReleases', getAllReleasesHandler);
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
router.get('/proxy', GetProxyHandler);
router.get('/Theme', GetThemeConfigHandler);

View File

@ -10,18 +10,19 @@ import {
import React from 'react';
export interface ModalProps {
content: React.ReactNode
title?: React.ReactNode
size?: React.ComponentProps<typeof NextUIModal>['size']
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior']
onClose?: () => void
onConfirm?: () => void
onCancel?: () => void
backdrop?: 'opaque' | 'blur' | 'transparent'
showCancel?: boolean
dismissible?: boolean
confirmText?: string
cancelText?: string
content: React.ReactNode;
title?: React.ReactNode;
size?: React.ComponentProps<typeof NextUIModal>['size'];
scrollBehavior?: React.ComponentProps<typeof NextUIModal>['scrollBehavior'];
onClose?: () => void;
onConfirm?: () => void;
onCancel?: () => void;
backdrop?: 'opaque' | 'blur' | 'transparent';
showCancel?: boolean;
dismissible?: boolean;
confirmText?: string;
cancelText?: string;
hideFooter?: boolean;
}
const Modal: React.FC<ModalProps> = React.memo((props) => {
@ -33,6 +34,7 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
dismissible,
confirmText = '确定',
cancelText = '取消',
hideFooter = false,
onClose,
onConfirm,
onCancel,
@ -62,29 +64,31 @@ const Modal: React.FC<ModalProps> = React.memo((props) => {
<ModalHeader className='flex flex-col gap-1'>{title}</ModalHeader>
)}
<ModalBody className='break-all'>{content}</ModalBody>
<ModalFooter>
{showCancel && (
{!hideFooter && (
<ModalFooter>
{showCancel && (
<Button
color='primary'
variant='light'
onPress={() => {
onCancel?.();
nativeClose();
}}
>
{cancelText}
</Button>
)}
<Button
color='primary'
variant='light'
onPress={() => {
onCancel?.();
onConfirm?.();
nativeClose();
}}
>
{cancelText}
{confirmText}
</Button>
)}
<Button
color='primary'
onPress={() => {
onConfirm?.();
nativeClose();
}}
>
{confirmText}
</Button>
</ModalFooter>
</ModalFooter>
)}
</>
)}
</ModalContent>

View File

@ -3,17 +3,24 @@ import { Card, CardBody, CardHeader } from '@heroui/card';
import { Chip } from '@heroui/chip';
import { Spinner } from '@heroui/spinner';
import { Tooltip } from '@heroui/tooltip';
import { useLocalStorage } from '@uidotdev/usehooks';
import { Select, SelectItem } from '@heroui/select';
import { Switch } from '@heroui/switch';
import { Pagination } from '@heroui/pagination';
import { Tabs, Tab } from '@heroui/tabs';
import { Input } from '@heroui/input';
import { useLocalStorage, useDebounce } from '@uidotdev/usehooks';
import { useRequest } from 'ahooks';
import clsx from 'clsx';
import { FaCircleInfo, FaQq } from 'react-icons/fa6';
import { IoLogoChrome, IoLogoOctocat } from 'react-icons/io';
import { IoLogoChrome, IoLogoOctocat, IoSearch } from 'react-icons/io5';
import { RiMacFill } from 'react-icons/ri';
import { useState } from 'react';
import { useState, useCallback } from 'react';
import key from '@/const/key';
import WebUIManager from '@/controllers/webui_manager';
import useDialog from '@/hooks/use-dialog';
import Modal from '@/components/modal';
import { hasNewVersion, compareVersion } from '@/utils/version';
export interface SystemInfoItemProps {
@ -22,6 +29,8 @@ export interface SystemInfoItemProps {
value?: React.ReactNode;
endContent?: React.ReactNode;
hasBackground?: boolean;
onClick?: () => void;
clickable?: boolean;
}
const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
@ -30,14 +39,20 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
icon,
endContent,
hasBackground = false,
onClick,
clickable = false,
}) => {
return (
<div className={clsx(
'flex text-sm gap-3 py-2 items-center transition-colors',
hasBackground
? 'text-white/90'
: 'text-default-600 dark:text-gray-300'
)}>
<div
className={clsx(
'flex text-sm gap-3 py-2 items-center transition-colors',
hasBackground
? 'text-white/90'
: 'text-default-600 dark:text-gray-300',
clickable && 'cursor-pointer hover:bg-default-100/50 dark:hover:bg-default-800/30 rounded-lg -mx-2 px-2'
)}
onClick={onClick}
>
<div className="text-lg opacity-70">{icon}</div>
<div className='w-24 font-medium'>{title}</div>
<div className={clsx(
@ -53,155 +68,6 @@ export interface NewVersionTipProps {
currentVersion?: string;
}
// const NewVersionTip = (props: NewVersionTipProps) => {
// const { currentVersion } = props;
// const dialog = useDialog();
// const { data: releaseData, error } = useRequest(() =>
// request.get<GithubRelease[]>(
// 'https://api.github.com/repos/NapNeko/NapCatQQ/releases'
// )
// );
// if (error) {
// return (
// <Tooltip content='检查新版本失败'>
// <Button
// isIconOnly
// radius='full'
// color='primary'
// variant='shadow'
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
// onPress={() => {
// dialog.alert({
// title: '检查新版本失败',
// content: error.message,
// });
// }}
// >
// <FaInfo />
// </Button>
// </Tooltip>
// );
// }
// const latestVersion = releaseData?.data?.[0]?.tag_name;
// if (!latestVersion || !currentVersion) {
// return null;
// }
// if (compareVersion(latestVersion, currentVersion) <= 0) {
// return null;
// }
// const middleVersions: GithubRelease[] = [];
// for (let i = 0; i < releaseData.data.length; i++) {
// const versionInfo = releaseData.data[i];
// if (compareVersion(versionInfo.tag_name, currentVersion) > 0) {
// middleVersions.push(versionInfo);
// } else {
// break;
// }
// }
// const AISummaryComponent = () => {
// const {
// data: aiSummaryData,
// loading: aiSummaryLoading,
// error: aiSummaryError,
// run: runAiSummary,
// } = useRequest(
// (version) =>
// request.get<ServerResponse<string | null>>(
// `https://release.nc.152710.xyz/?version=${version}`,
// {
// timeout: 30000,
// }
// ),
// {
// manual: true,
// }
// );
// useEffect(() => {
// runAiSummary(currentVersion);
// }, [currentVersion, runAiSummary]);
// if (aiSummaryLoading) {
// return (
// <div className='flex justify-center py-1'>
// <Spinner size='sm' />
// </div>
// );
// }
// if (aiSummaryError) {
// return <div className='text-center text-primary-500'>AI 摘要获取失败</div>;
// }
// return <span className='text-default-700'>{aiSummaryData?.data.data}</span>;
// };
// return (
// <Tooltip content='有新版本可用'>
// <Button
// isIconOnly
// radius='full'
// color='primary'
// variant='shadow'
// className='!w-5 !h-5 !min-w-0 text-small shadow-md'
// onPress={() => {
// dialog.confirm({
// title: '有新版本可用',
// content: (
// <div className='space-y-2'>
// <div className='text-sm space-x-2'>
// <span>当前版本</span>
// <Chip color='primary' variant='flat'>
// v{currentVersion}
// </Chip>
// </div>
// <div className='text-sm space-x-2'>
// <span>最新版本</span>
// <Chip color='primary'>{latestVersion}</Chip>
// </div>
// <div className='p-2 rounded-md bg-content2 text-sm'>
// <div className='text-primary-400 font-bold flex items-center gap-1 mb-1'>
// <BsStars />
// <span>AI总结</span>
// </div>
// <AISummaryComponent />
// </div>
// <div className='text-sm space-y-2 !mt-4'>
// {middleVersions.map((versionInfo) => (
// <div
// key={versionInfo.tag_name}
// className='p-4 bg-content1 rounded-md shadow-small'
// >
// <TailwindMarkdown content={versionInfo.body} />
// </div>
// ))}
// </div>
// </div>
// ),
// scrollBehavior: 'inside',
// size: '3xl',
// confirmText: '前往下载',
// onConfirm () {
// window.open(
// 'https://github.com/NapNeko/NapCatQQ/releases',
// '_blank',
// 'noopener'
// );
// },
// });
// }}
// >
// <FaInfo />
// </Button>
// </Tooltip>
// );
// };
// 更新状态类型
type UpdateStatus = 'idle' | 'updating' | 'success' | 'error';
@ -213,18 +79,29 @@ const UpdateDialogContent: React.FC<{
errorMessage?: string;
}> = ({ currentVersion, latestVersion, status, errorMessage }) => {
return (
<div className='space-y-4'>
{/* 版本信息 */}
<div className='space-y-2'>
<div className='text-sm space-x-2'>
<span></span>
<Chip color='primary' variant='flat'>
<div className='space-y-6'>
{/* 版本对比 */}
<div className="flex items-center justify-between px-6 py-8 bg-default-50 dark:bg-default-100/5 rounded-xl border border-default-100 dark:border-default-100/10">
<div className="flex flex-col items-center gap-2">
<span className="text-xs text-default-500 font-medium uppercase tracking-wider"></span>
<Chip size="lg" variant="flat" color="default" classNames={{ content: "font-mono font-bold text-lg" }}>
v{currentVersion}
</Chip>
</div>
<div className='text-sm space-x-2'>
<span></span>
<Chip color='primary'>v{latestVersion}</Chip>
<div className="flex flex-col items-center text-primary-500 px-4">
<div className="p-2 rounded-full bg-primary-50 dark:bg-primary-900/20">
<svg className="w-6 h-6 animate-pulse" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 8l4 4m0 0l-4 4m4-4H3" />
</svg>
</div>
</div>
<div className="flex flex-col items-center gap-2">
<span className="text-xs text-primary-500 font-medium uppercase tracking-wider"></span>
<Chip size="lg" color="primary" variant="shadow" classNames={{ content: "font-mono font-bold text-lg" }}>
v{latestVersion}
</Chip>
</div>
</div>
@ -300,7 +177,8 @@ const NewVersionTip = (props: NewVersionTipProps) => {
});
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
if (error || !latestVersion || !currentVersion || latestVersion === currentVersion) {
// 使用 SemVer 规范比较版本号
if (error || !latestVersion || !currentVersion || !hasNewVersion(currentVersion, latestVersion)) {
return null;
}
@ -380,11 +258,381 @@ const NewVersionTip = (props: NewVersionTipProps) => {
);
};
// 版本信息类型
interface VersionInfo {
tag: string;
type: 'release' | 'prerelease' | 'action';
artifactId?: number;
artifactName?: string;
createdAt?: string;
expiresAt?: string;
size?: number;
}
// 版本选择对话框内容
interface VersionSelectDialogProps {
currentVersion: string;
onClose: () => void;
}
const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
currentVersion,
onClose,
}) => {
const dialog = useDialog();
const [selectedVersion, setSelectedVersion] = useState<VersionInfo | null>(null);
const [forceUpdate, setForceUpdate] = useState(false);
const [updateStatus, setUpdateStatus] = useState<UpdateStatus>('idle');
const [errorMessage, setErrorMessage] = useState<string>('');
const [currentPage, setCurrentPage] = useState(1);
const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300);
const pageSize = 15;
// 获取所有可用版本(带分页、过滤和搜索)
const { data: releasesData, loading: releasesLoading, error: releasesError } = useRequest(
() => WebUIManager.getAllReleases({
page: currentPage,
pageSize,
includeActions: true,
type: activeTab,
search: debouncedSearch
}),
{
refreshDeps: [currentPage, activeTab, debouncedSearch],
}
);
// 版本列表已在后端过滤,直接使用
const filteredVersions = releasesData?.versions || [];
// 检查是否是降级(使用语义化版本比较)
const isDowngrade = useCallback((targetTag: string): boolean => {
if (!currentVersion || !targetTag) return false;
// Action 版本不算降级
if (targetTag.startsWith('action-')) return false;
return compareVersion(targetTag, currentVersion) < 0;
}, [currentVersion]);
const selectedVersionTag = selectedVersion?.tag || '';
const isSelectedDowngrade = isDowngrade(selectedVersionTag);
const handleUpdate = async () => {
if (!selectedVersion) return;
if (isSelectedDowngrade && !forceUpdate) {
dialog.confirm({
title: '确认降级',
content: (
<div className='space-y-2'>
<p className='text-warning-600'>
<strong>v{currentVersion}</strong> <strong>{selectedVersionTag}</strong>
</p>
<p className='text-sm text-default-500'>
</p>
</div>
),
confirmText: '确认降级',
cancelText: '取消',
onConfirm: () => performUpdate(true),
});
return;
}
await performUpdate(forceUpdate);
};
const performUpdate = async (force: boolean) => {
if (!selectedVersion) return;
setUpdateStatus('updating');
setErrorMessage('');
try {
await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force);
setUpdateStatus('success');
} catch (err) {
console.error('Update failed:', err);
const errMsg = err instanceof Error ? err.message : '未知错误';
setErrorMessage(errMsg);
setUpdateStatus('error');
}
};
// 处理分页变化
const handlePageChange = (page: number) => {
setCurrentPage(page);
};
if (updateStatus === 'success') {
return (
<div className='flex flex-col items-center justify-center gap-3 py-4'>
<div className='w-12 h-12 rounded-full bg-success-100 dark:bg-success-900/40 flex items-center justify-center'>
<svg className='w-6 h-6 text-success-600 dark:text-success-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg>
</div>
<div className='text-center'>
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
{selectedVersionTag}
</p>
<p className='text-xs text-default-500 mt-1'>
NapCat
</p>
</div>
</div>
);
}
if (updateStatus === 'error') {
return (
<div className='flex flex-col items-center justify-center gap-3 py-4'>
<div className='w-12 h-12 rounded-full bg-danger-100 dark:bg-danger-900/40 flex items-center justify-center'>
<svg className='w-6 h-6 text-danger-600 dark:text-danger-400' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M6 18L18 6M6 6l12 12' />
</svg>
</div>
<div className='text-center'>
<p className='text-sm font-medium text-danger-600 dark:text-danger-400'>
</p>
<p className='text-xs text-default-500 mt-1'>
{errorMessage || '请稍后重试'}
</p>
</div>
</div>
);
}
if (updateStatus === 'updating') {
return (
<div className='flex flex-col items-center justify-center gap-3 py-6'>
<Spinner size='lg' color='primary' />
<div className='text-center'>
<p className='text-sm font-medium text-primary-600 dark:text-primary-400'>
{selectedVersionTag}...
</p>
<p className='text-xs text-default-500 mt-1'>
</p>
</div>
</div>
);
}
const pagination = releasesData?.pagination;
return (
<div className='space-y-4'>
{/* 当前版本 */}
<div className='flex items-center justify-between'>
<div className='flex items-center gap-2'>
<span className='text-sm text-default-600'>:</span>
<Chip color='primary' variant='flat' size='sm'>
v{currentVersion}
</Chip>
</div>
{releasesData?.mirror && (
<div className='text-xs text-default-400 flex items-center gap-1'>
<span className='w-2 h-2 rounded-full bg-success-500'></span>
: {releasesData.mirror}
</div>
)}
</div>
{/* 版本类型切换 */}
<Tabs
selectedKey={activeTab}
onSelectionChange={(key) => {
setActiveTab(key as 'release' | 'action');
setCurrentPage(1);
setSelectedVersion(null);
setSearchQuery('');
}}
size='sm'
color='primary'
variant='underlined'
classNames={{
tabList: 'gap-4',
}}
>
<Tab key='release' title='正式版本' />
<Tab key='action' title='临时版本 (Action)' />
</Tabs>
{/* 搜索框 */}
<Input
placeholder='搜索版本号...'
size='sm'
value={searchQuery}
onValueChange={(value) => {
setSearchQuery(value);
setCurrentPage(1);
setSelectedVersion(null);
}}
startContent={<IoSearch className='text-default-400' />}
isClearable
onClear={() => setSearchQuery('')}
classNames={{
inputWrapper: 'h-9',
}}
/>
{/* 版本选择 */}
<div className='space-y-2'>
<div className='flex items-center justify-between'>
<label className='text-sm font-medium text-default-700'></label>
{releasesData?.pagination && (
<span className='text-xs text-default-400'>
{releasesData.pagination.total}
</span>
)}
</div>
{releasesLoading ? (
<div className='flex items-center gap-2 py-2'>
<Spinner size='sm' />
<span className='text-sm text-default-500'>...</span>
</div>
) : releasesError ? (
<div className='text-sm text-danger-500'>
: {releasesError.message}
</div>
) : filteredVersions.length === 0 ? (
<div className='text-sm text-default-500 py-4 text-center'>
{searchQuery ? `未找到匹配 "${searchQuery}" 的版本` : '暂无可用版本'}
</div>
) : (
<Select
label='选择版本'
placeholder='请选择要更新的版本'
selectedKeys={selectedVersion ? [selectedVersionTag] : []}
onSelectionChange={(keys) => {
const selectedTag = Array.from(keys)[0] as string;
const version = filteredVersions.find(v => v.tag === selectedTag);
setSelectedVersion(version || null);
}}
classNames={{
trigger: 'h-10',
}}
>
{filteredVersions.map((version) => {
const isCurrent = version.tag.replace(/^v/, '') === currentVersion;
const downgrade = isDowngrade(version.tag);
return (
<SelectItem
key={version.tag}
textValue={version.tag}
>
<div className='flex items-center gap-2'>
<span>{version.tag}</span>
{version.type === 'prerelease' && (
<Chip size='sm' color='secondary' variant='flat'></Chip>
)}
{version.type === 'action' && (
<Chip size='sm' color='default' variant='flat'></Chip>
)}
{isCurrent && (
<Chip size='sm' color='success' variant='flat'></Chip>
)}
{downgrade && !isCurrent && version.type !== 'action' && (
<Chip size='sm' color='warning' variant='flat'></Chip>
)}
</div>
</SelectItem>
);
})}
</Select>
)}
</div>
{/* Action 版本提示 */}
{activeTab === 'action' && (
<div className='p-3 rounded-lg bg-default-50 dark:bg-default-100/10 border border-default-200/50'>
<p className='text-xs text-default-500'>
GitHub Actions
{selectedVersion?.expiresAt && (
<span className='block mt-1 text-warning-600'>
{new Date(selectedVersion.expiresAt).toLocaleDateString()}
</span>
)}
</p>
</div>
)}
{/* 降级警告 */}
{selectedVersion && isSelectedDowngrade && (
<div className='p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
<div className='flex items-start gap-2'>
<svg className='w-5 h-5 text-warning-600 dark:text-warning-400 flex-shrink-0 mt-0.5' fill='none' viewBox='0 0 24 24' stroke='currentColor'>
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' />
</svg>
<div>
<p className='text-sm font-medium text-warning-700 dark:text-warning-400'>
</p>
<p className='text-xs text-warning-600/80 dark:text-warning-500 mt-1'>
</p>
</div>
</div>
<div className='mt-3 flex items-center gap-2'>
<Switch
size='sm'
isSelected={forceUpdate}
onValueChange={setForceUpdate}
/>
<span className='text-xs text-warning-700 dark:text-warning-400'>
</span>
</div>
</div>
)}
{/* 分页 */}
{pagination && pagination.totalPages > 1 && (
<div className='flex justify-center'>
<Pagination
total={pagination.totalPages}
page={currentPage}
onChange={handlePageChange}
size='sm'
showControls
/>
</div>
)}
{/* 操作按钮 */}
<div className='flex justify-end gap-2 pt-4 border-t border-default-100 dark:border-default-100/10'>
<button
className='px-4 py-2 text-sm rounded-lg bg-default-100 hover:bg-default-200 transition-colors'
onClick={onClose}
>
</button>
<button
className={clsx(
'px-4 py-2 text-sm rounded-lg transition-colors text-white shadow-sm',
selectedVersion && (!isSelectedDowngrade || forceUpdate)
? 'bg-primary-500 hover:bg-primary-600 shadow-primary-500/20'
: 'bg-default-300 cursor-not-allowed'
)}
disabled={!selectedVersion || (isSelectedDowngrade && !forceUpdate)}
onClick={handleUpdate}
>
{isSelectedDowngrade ? '确认降级更新' : '更新到此版本'}
</button>
</div>
</div>
);
};
interface NapCatVersionProps {
hasBackground?: boolean;
}
const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false }) => {
const [isVersionModalOpen, setIsVersionModalOpen] = useState(false);
const {
data: packageData,
loading: packageLoading,
@ -397,26 +645,55 @@ const NapCatVersion: React.FC<NapCatVersionProps> = ({ hasBackground = false })
const currentVersion = packageData?.version;
// 点击版本号时显示版本选择对话框
const handleVersionClick = useCallback(() => {
if (!currentVersion) return;
setIsVersionModalOpen(true);
}, [currentVersion]);
return (
<SystemInfoItem
title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={
packageError
? (
`错误:${packageError.message}`
)
: packageLoading
<>
<SystemInfoItem
title='NapCat 版本'
icon={<IoLogoOctocat className='text-xl' />}
hasBackground={hasBackground}
value={
packageError
? (
<Spinner size='sm' />
`错误:${packageError.message}`
)
: (
currentVersion
)
}
endContent={<NewVersionTip currentVersion={currentVersion} />}
/>
: packageLoading
? (
<Spinner size='sm' />
)
: (
<Tooltip content='点击管理版本'>
<span
className='cursor-pointer hover:text-primary-500 transition-colors underline decoration-dashed underline-offset-2'
onClick={handleVersionClick}
>
{currentVersion}
</span>
</Tooltip>
)
}
endContent={<NewVersionTip currentVersion={currentVersion} />}
/>
{isVersionModalOpen && (
<Modal
title='版本管理'
size='lg'
hideFooter={true}
onClose={() => setIsVersionModalOpen(false)}
content={
<VersionSelectDialogContent
currentVersion={currentVersion || ''}
onClose={() => setIsVersionModalOpen(false)}
/>
}
/>
)}
</>
);
};

View File

@ -54,11 +54,68 @@ export default class WebUIManager {
return data.data;
}
/**
*
*/
static readonly VersionTypes = {
RELEASE: 'release',
PRERELEASE: 'prerelease',
ACTION: 'action',
} as const;
/**
*
*/
public static async getAllReleases (options: {
page?: number;
pageSize?: number;
includeActions?: boolean;
type?: 'release' | 'action' | 'all';
search?: string;
} = {}) {
const { page = 1, pageSize = 20, includeActions = true, type = 'all', search = '' } = options;
const { data } = await serverRequest.get<ServerResponse<{
versions: Array<{
tag: string;
type: 'release' | 'prerelease' | 'action';
artifactId?: number;
artifactName?: string;
createdAt?: string;
expiresAt?: string;
size?: number;
}>;
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
mirror?: string;
}>>('/base/getAllReleases', {
params: { page, pageSize, includeActions, type, search },
});
return data.data;
}
public static async UpdateNapCat () {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update',
{},
{ timeout: 60000 } // 1分钟超时
{ timeout: 120000 } // 2分钟超时
);
return data;
}
/**
*
* @param targetVersion tag "v4.9.9" "action-123456"
* @param force
*/
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update',
{ targetVersion, force },
{ timeout: 120000 } // 2分钟超时
);
return data;
}

View File

@ -3,7 +3,7 @@ import axios from 'axios';
import key from '@/const/key';
export const serverRequest = axios.create({
timeout: 5000,
timeout: 30000, // 30秒获取版本列表可能较慢
});
export const request = axios.create({

View File

@ -1,22 +1,59 @@
/**
*
* SemVer 2.0
* 格式: 主版本号.次版本号.修订号[-][+]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
interface SemVerInfo {
valid: boolean;
normalized: string;
major: number;
minor: number;
patch: number;
prerelease: string | null;
buildmetadata: string | null;
}
/**
*
* @param version
* @returns SemVer
*/
export const parseSemVer = (version: string | undefined | null): SemVerInfo => {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1]!, 10);
const minor = parseInt(match[2]!, 10);
const patch = parseInt(match[3]!, 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
};
/**
* ()
* @param version
* @returns
*/
export const versionToNumber = (version: string): number => {
const finalVersionString = version.replace(/^v/, '');
const versionArray = finalVersionString.split('.');
const versionNumber =
parseInt(versionArray[2]) +
parseInt(versionArray[1]) * 100 +
parseInt(versionArray[0]) * 10000;
return versionNumber;
const info = parseSemVer(version);
return info.patch + info.minor * 100 + info.major * 10000;
};
/**
*
* (SemVer 2.0 )
* @param version1 1
* @param version2 2
* @returns
@ -24,13 +61,73 @@ export const versionToNumber = (version: string): number => {
* 1: version1 > version2
* -1: version1 < version2
*/
export const compareVersion = (version1: string, version2: string): number => {
const versionNumber1 = versionToNumber(version1);
const versionNumber2 = versionToNumber(version2);
export const compareVersion = (version1: string, version2: string): -1 | 0 | 1 => {
const a = parseSemVer(version1);
const b = parseSemVer(version2);
if (versionNumber1 === versionNumber2) {
if (!a.valid || !b.valid) {
return 0;
}
return versionNumber1 > versionNumber2 ? 1 : -1;
// 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
// 比较次版本号
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
// 比较修订号
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
// 有先行版本号的版本优先级较低
if (a.prerelease && !b.prerelease) return -1;
if (!a.prerelease && b.prerelease) return 1;
// 两者都有先行版本号时,按规则比较
if (a.prerelease && b.prerelease) {
const aParts = a.prerelease.split('.');
const bParts = b.prerelease.split('.');
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i++) {
const aPart = aParts[i];
const bPart = bParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
// 数字 vs 数字
if (!isNaN(aNum) && !isNaN(bNum)) {
if (aNum !== bNum) return aNum > bNum ? 1 : -1;
continue;
}
// 数字优先级低于字符串
if (!isNaN(aNum)) return -1;
if (!isNaN(bNum)) return 1;
// 字符串 vs 字符串
if (aPart !== bPart) return aPart > bPart ? 1 : -1;
}
}
return 0;
};
/**
*
* ()
*
* @param currentVersion
* @param latestVersion (release tag)
* @returns
*/
export const hasNewVersion = (currentVersion: string, latestVersion: string): boolean => {
const current = parseSemVer(currentVersion);
const latest = parseSemVer(latestVersion);
if (!current.valid || !latest.valid) {
return false;
}
// 使用 compareVersion 比较
return compareVersion(latestVersion, currentVersion) > 0;
};