Compare commits

..

No commits in common. "8a232d8c68051d39a3a98db6f9faa4fa5426df0e" and "5284e0ac5aa7d9c8cbfaffc6562fdb88357c448f" have entirely different histories.

13 changed files with 453 additions and 544 deletions

View File

@ -220,13 +220,13 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
// ============== GitHub Tags 获取 ============== // ============== GitHub Tags 获取 ==============
// 使用 mirror 模块统一管理镜像 // 使用 mirror 模块统一管理镜像
export async function getAllTags (mirror?: string): Promise<{ tags: string[], mirror: string; }> { export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
return getAllTagsFromMirror('NapNeko', 'NapCatQQ', mirror); return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
} }
export async function getLatestTag (mirror?: string): Promise<string> { export async function getLatestTag (): Promise<string> {
const { tags } = await getAllTags(mirror); const { tags } = await getAllTags();
// 使用 SemVer 规范排序 // 使用 SemVer 规范排序
tags.sort((a, b) => compareSemVer(a, b)); tags.sort((a, b) => compareSemVer(a, b));

View File

@ -23,50 +23,66 @@ import { PromiseTimer } from './helper';
* 使 30 * 使 30
*/ */
export const GITHUB_FILE_MIRRORS = [ export const GITHUB_FILE_MIRRORS = [
'https://github.chenc.dev/', // 延迟 < 800ms 的最快镜像
'https://ghproxy.cfd/', 'https://github.chenc.dev/', // 666ms
'https://github.tbedu.top/', 'https://ghproxy.cfd/', // 719ms - 支持重定向
'https://ghproxy.cc/', 'https://github.tbedu.top/', // 760ms
'https://gh.monlor.com/', 'https://ghps.cc/', // 768ms
'https://cdn.akaere.online/', 'https://gh.llkk.cc/', // 774ms
'https://gh.idayer.com/', 'https://ghproxy.cc/', // 777ms
'https://gh.llkk.cc/', 'https://gh.monlor.com/', // 779ms
'https://ghpxy.hwinzniej.top/', 'https://cdn.akaere.online/', // 784ms
'https://github-proxy.memory-echoes.cn/', // 延迟 800-1000ms 的快速镜像
'https://git.yylx.win/', 'https://gh.idayer.com/', // 869ms
'https://gitproxy.mrhjx.cn/', 'https://gh-proxy.net/', // 885ms
'https://gh.fhjhy.top/', 'https://ghpxy.hwinzniej.top/', // 890ms
'https://gp.zkitefly.eu.org/', 'https://github-proxy.memory-echoes.cn/', // 896ms
'https://gh-proxy.com/', 'https://git.yylx.win/', // 917ms
'https://ghfile.geekertao.top/', 'https://gitproxy.mrhjx.cn/', // 950ms
'https://j.1lin.dpdns.org/', 'https://jiashu.1win.eu.org/', // 954ms
'https://ghproxy.imciel.com/', 'https://ghproxy.cn/', // 981ms
'https://github-proxy.teach-english.tech/', // 延迟 1000-1500ms 的中速镜像
'https://gh.927223.xyz/', 'https://gh.fhjhy.top/', // 1014ms
'https://github.ednovas.xyz/', 'https://gp.zkitefly.eu.org/', // 1015ms
'https://ghf.xn--eqrr82bzpe.top/', 'https://gh-proxy.com/', // 1022ms
'https://gh.dpik.top/', 'https://hub.gitmirror.com/', // 1027ms
'https://gh.jasonzeng.dev/', 'https://ghfile.geekertao.top/', // 1029ms
'https://gh.xxooo.cf/', 'https://j.1lin.dpdns.org/', // 1037ms
'https://gh.bugdey.us.kg/', 'https://ghproxy.imciel.com/', // 1047ms
'https://ghm.078465.xyz/', 'https://github-proxy.teach-english.tech/', // 1047ms
'https://j.1win.ggff.net/', 'https://gh.927223.xyz/', // 1071ms
'https://tvv.tw/', 'https://github.ednovas.xyz/', // 1099ms
'https://gitproxy.127731.xyz/', 'https://ghf.xn--eqrr82bzpe.top/',// 1122ms
'https://gh.inkchills.cn/', 'https://gh.dpik.top/', // 1131ms
'https://ghproxy.cxkpro.top/', 'https://gh.jasonzeng.dev/', // 1139ms
'https://gh.sixyin.com/', 'https://gh.xxooo.cf/', // 1157ms
'https://github.geekery.cn/', 'https://gh.bugdey.us.kg/', // 1228ms
'https://git.669966.xyz/', 'https://ghm.078465.xyz/', // 1289ms
'https://gh.5050net.cn/', 'https://j.1win.ggff.net/', // 1329ms
'https://gh.felicity.ac.cn/', 'https://tvv.tw/', // 1393ms
'https://github.dpik.top/', 'https://gh.chjina.com/', // 1446ms
'https://ghp.keleyaa.com/', 'https://gitproxy.127731.xyz/', // 1458ms
'https://gh.wsmdn.dpdns.org/', // 延迟 1500-2500ms 的较慢镜像
'https://ghproxy.monkeyray.net/', 'https://gh.inkchills.cn/', // 1617ms
'https://fastgit.cc/', 'https://ghproxy.cxkpro.top/', // 1651ms
'https://gh.catmak.name/', 'https://gh.sixyin.com/', // 1686ms
'https://gh.noki.icu/', '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无镜像 '', // 原始 URL无镜像
]; ];
@ -93,6 +109,7 @@ export const GITHUB_RAW_MIRRORS = [
// 测试确认支持 raw 文件的镜像 // 测试确认支持 raw 文件的镜像
'https://github.chenc.dev/https://raw.githubusercontent.com', 'https://github.chenc.dev/https://raw.githubusercontent.com',
'https://ghproxy.cfd/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://ghproxy.cc/https://raw.githubusercontent.com',
'https://gh-proxy.net/https://raw.githubusercontent.com', 'https://gh-proxy.net/https://raw.githubusercontent.com',
]; ];
@ -568,7 +585,7 @@ export async function findAvailableDownloadUrl (
let testedCount = 0; let testedCount = 0;
for (const mirror of mirrors) { for (const mirror of mirrors) {
if (!mirror) continue; // 跳过空字符串 if (!mirror) continue; // 跳过空字符串
// 特殊处理 nightly.link URL // 特殊处理 nightly.link URL
let mirrorUrl: string; let mirrorUrl: string;
if (originalUrl.includes('nightly.link')) { if (originalUrl.includes('nightly.link')) {
@ -577,7 +594,7 @@ export async function findAvailableDownloadUrl (
} else { } else {
mirrorUrl = buildMirrorUrl(originalUrl, mirror); mirrorUrl = buildMirrorUrl(originalUrl, mirror);
} }
testedCount++; testedCount++;
if (await testWithValidation(mirrorUrl)) { if (await testWithValidation(mirrorUrl)) {
return mirrorUrl; return mirrorUrl;
@ -633,15 +650,8 @@ function compareSemVerSimple (a: string, b: string): number {
* tags release tag * tags release tag
* GitHub API * GitHub API
*/ */
// Update definitions validation locally first if possible. export async function getLatestReleaseTag (owner: string, repo: string): Promise<string> {
// I'll assume valid typescript. const result = await getAllGitHubTags(owner, repo);
// I will split this into two tool calls to avoid complexity.
// 1. Update mirror.ts
// 2. Update UpdateNapCat.ts
// This tool call: Update mirror.ts
export async function getLatestReleaseTag (owner: string, repo: string, mirror?: string): Promise<string> {
const result = await getAllGitHubTags(owner, repo, mirror);
// 过滤出符合 semver 的 tags // 过滤出符合 semver 的 tags
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag)); const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
@ -691,8 +701,6 @@ export async function getGitHubRelease (
assetNames?: string[]; assetNames?: string[];
/** 是否需要获取 changelog需要调用 API */ /** 是否需要获取 changelog需要调用 API */
fetchChangelog?: boolean; fetchChangelog?: boolean;
/** 指定镜像 */
mirror?: string;
} = {} } = {}
): Promise<{ ): Promise<{
tag_name: string; tag_name: string;
@ -702,16 +710,15 @@ export async function getGitHubRelease (
}>; }>;
body?: string; body?: string;
}> { }> {
const { assetNames = [], fetchChangelog = false, mirror } = options; const { assetNames = [], fetchChangelog = false } = options;
// 1. 获取实际的 tag 名称 // 1. 获取实际的 tag 名称
let actualTag: string; let actualTag: string;
if (tag === 'latest') { if (tag === 'latest') {
actualTag = await getLatestReleaseTag(owner, repo, mirror); actualTag = await getLatestReleaseTag(owner, repo);
} else { } else {
actualTag = tag; actualTag = tag;
} }
// ...
// 2. 构建 assets 列表(不需要 API // 2. 构建 assets 列表(不需要 API
const assets = assetNames.map(name => ({ const assets = assetNames.map(name => ({
@ -775,8 +782,8 @@ const tagsCache: Map<string, TagsCache> = new Map();
* GitHub tags * GitHub tags
* 使 * 使
*/ */
export async function getAllGitHubTags (owner: string, repo: string, mirror?: string): Promise<{ tags: string[], mirror: string; }> { export async function getAllGitHubTags (owner: string, repo: string): Promise<{ tags: string[], mirror: string; }> {
const cacheKey = `${owner}/${repo}/${mirror || 'auto'}`; const cacheKey = `${owner}/${repo}`;
// 检查缓存 // 检查缓存
const cached = tagsCache.get(cacheKey); const cached = tagsCache.get(cacheKey);
@ -798,7 +805,7 @@ export async function getAllGitHubTags (owner: string, repo: string, mirror?: st
}; };
// 尝试从 URL 获取 tags // 尝试从 URL 获取 tags
const fetchFromUrl = async (url: string, usedMirror: string): Promise<{ tags: string[], mirror: string; } | null> => { const fetchFromUrl = async (url: string, mirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
try { try {
const raw = await PromiseTimer( const raw = await PromiseTimer(
RequestUtil.HttpGetText(url), RequestUtil.HttpGetText(url),
@ -806,55 +813,79 @@ export async function getAllGitHubTags (owner: string, repo: string, mirror?: st
); );
// 检查返回内容是否有效(不是 HTML 错误页面) // 检查返回内容是否有效(不是 HTML 错误页面)
if (raw.includes('refs/tags')) { if (raw.includes('<!DOCTYPE') || raw.includes('<html')) {
return { tags: parseTags(raw), mirror: usedMirror }; return null;
} }
const tags = parseTags(raw);
if (tags.length > 0) {
return { tags, mirror };
}
return null;
} catch { } catch {
// 忽略错误 return null;
} }
return null;
}; };
// 准备镜像列表 // 获取快速镜像列表
let mirrors: string[] = []; let fastMirrors: string[] = [];
if (mirror) { try {
// 如果指定了镜像,只使用该镜像 fastMirrors = await getFastMirrors();
mirrors = [mirror]; } catch {
} else { // 忽略错误
// 否则使用 auto 逻辑
mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
} }
// 并行请求 // 构建 URL 列表(取前 5 个快速镜像 + 原始 URL 并行请求)
const promises = mirrors.map(m => { const topMirrors = fastMirrors.slice(0, 5);
const url = m ? buildMirrorUrl(baseUrl, m) : baseUrl; const mirrorUrls = [
return fetchFromUrl(url, m || 'https://github.com'); { url: baseUrl, mirror: 'github.com' }, // 原始 URL
}); ...topMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m })),
];
// 并行请求所有镜像,使用 Promise.any 获取第一个成功的结果
try { try {
const result = await Promise.any(promises.filter(p => p !== null) as Promise<{ tags: string[], mirror: string; } | null>[]); const result = await Promise.any(
mirrorUrls.map(async ({ url, mirror }) => {
const res = await fetchFromUrl(url, mirror);
if (res) return res;
throw new Error('Failed');
})
);
// 缓存结果
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
return result;
} catch {
// Promise.any 全部失败,回退到顺序尝试剩余镜像
}
// 回退:顺序尝试剩余镜像
const remainingMirrors = fastMirrors.slice(5).filter(m => m);
for (const mirror of remainingMirrors) {
const url = buildMirrorUrl(baseUrl, mirror);
const result = await fetchFromUrl(url, mirror);
if (result) { if (result) {
tagsCache.set(cacheKey, { tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
tags: result.tags,
mirror: result.mirror,
timestamp: Date.now(),
});
return result; return result;
} }
} catch {
// all failed
} }
if (mirror) { // 最后尝试所有镜像
throw new Error(`指定镜像 ${mirror} 获取 tags 失败`); const allMirrors = currentConfig.fileMirrors.filter(m => m && !fastMirrors.includes(m));
for (const mirror of allMirrors) {
const url = buildMirrorUrl(baseUrl, mirror);
const result = await fetchFromUrl(url, mirror);
if (result) {
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
return result;
}
} }
throw new Error('无法获取 tags所有镜像源都不可用'); throw new Error('无法获取 tags所有源都不可用');
} }
// ============== Action Artifacts 支持 ============== // ============== Action Artifacts 支持 ==============
// ActionArtifact 接口定义
export interface ActionArtifact { export interface ActionArtifact {
id: number; id: number;
name: string; name: string;
@ -864,14 +895,12 @@ export interface ActionArtifact {
archive_download_url: string; archive_download_url: string;
workflow_run_id?: number; workflow_run_id?: number;
head_sha?: string; head_sha?: string;
workflow_title?: string;
} }
// ============== Action Artifacts 缓存 ============== // ============== Action Artifacts 缓存 ==============
interface ArtifactsCache { interface ArtifactsCache {
artifacts: ActionArtifact[]; artifacts: ActionArtifact[];
mirror: string;
timestamp: number; timestamp: number;
} }
@ -891,115 +920,68 @@ export function clearArtifactsCache (): void {
* api.github.com 使 * api.github.com 使
* 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow} * 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
*/ */
async function getWorkflowRunsFromHtml ( async function getWorkflowRunsFromHtml (
owner: string, owner: string,
repo: string, repo: string,
workflow: string = 'build.yml', workflow: string = 'build.yml',
maxRuns: number = 10, maxRuns: number = 10
mirror?: string ): Promise<Array<{ id: number; created_at: string; }>> {
): Promise<{ runs: Array<{ id: number; created_at: string; title: string; }>; mirror: string; }> {
const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`; const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
// 尝试使用镜像获取 HTML // 尝试使用镜像获取 HTML
// 如果指定了 mirror则只使用该 mirror const mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
let mirrors: string[] = [];
if (mirror) { for (const mirror of mirrors) {
mirrors = [mirror];
} else {
mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
}
for (const mirrorItem of mirrors) {
try { try {
const allRuns: Array<{ id: number; created_at: string; title: string; }> = []; const url = mirror ? buildMirrorUrl(baseUrl, mirror) : baseUrl;
const foundIds = new Set<number>();
let page = 1; const html = await PromiseTimer(
const maxPages = 10; // 防止无限请求最多翻10页约250个条目 RequestUtil.HttpGetText(url),
10000
while (allRuns.length < maxRuns && page <= maxPages) { );
const pageUrl = page > 1 ? `${baseUrl}?page=${page}` : baseUrl;
const url = mirrorItem ? buildMirrorUrl(pageUrl, mirrorItem) : pageUrl; // 从 HTML 中提取 run IDs 和时间
// 格式: href="/NapNeko/NapCatQQ/actions/runs/20676123968"
const html = await PromiseTimer( // 时间格式: <relative-time datetime="2026-01-03T10:37:29Z"
RequestUtil.HttpGetText(url), const runPattern = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"`, 'gi');
10000 const timePattern = /<relative-time\s+datetime="([^"]+)"/gi;
);
// 提取所有时间
// 使用 Block 分割策略,更稳健地关联 ID 和时间 const times: string[] = [];
const rows = html.split('<div class="Box-row'); let timeMatch;
let foundOnThisPage = 0; while ((timeMatch = timePattern.exec(html)) !== null) {
times.push(timeMatch[1]);
for (const row of rows) {
// 提取 Run ID 和 Status
// <a href="/NapNeko/NapCatQQ/actions/runs/20799940346" ... aria-label="completed successfully: ...">
const runMatch = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"[^>]*aria-label="([^"]*)"`, 'i').exec(row);
if (!runMatch || !runMatch[1] || !runMatch[2]) continue;
const id = parseInt(runMatch[1]);
const ariaLabel = runMatch[2];
const ariaLabelLower = ariaLabel.toLowerCase();
// 只需要判断 completed
if (ariaLabelLower.includes('completed')) {
if (!foundIds.has(id)) {
// 提取时间 (取 Block 内的第一个 relative-time)
const timeMatch = /<relative-time\s+datetime="([^"]+)"/.exec(row);
if (timeMatch && timeMatch[1]) {
foundIds.add(id);
foundOnThisPage++;
// 优先从 markdown-title class 提取标题
let title = '';
const titleMatch = /class="[^"]*markdown-title[^"]*"[^>]*>([\s\S]*?)<\/span>/i.exec(row);
if (titleMatch && titleMatch[1]) {
title = titleMatch[1].trim();
}
// 如果没找到,回退到 aria-label 逻辑
if (!title) {
title = ariaLabel;
const prefixMatch = /^(completed successfully:\s*)/i.exec(title);
if (prefixMatch) {
title = title.substring(prefixMatch[0].length);
}
}
allRuns.push({
id,
created_at: timeMatch[1],
title: title.trim()
});
}
}
}
}
// 如果本页没有找到任何 completed 的 run但页面可能不为空或者页面内容太少可能是最后一页或错误
// 这里简化判断: 如果本页没提取到任何有效数据,就认为没有更多数据了
if (foundOnThisPage === 0) {
// 也要考虑到可能是页面解析失败或者全是 failed 状态
// 检查是否有翻页按钮可能更复杂,暂时假设如果一整页都没有 successful run可能后面也没有了或者我们已经获取够多了
// 为了稳健,如果本页没找到,且 allRuns 还没满,尝试下一页 (除非页面很小说明是空页)
if (rows.length < 2) { // 只有 split 的第一个空元素
break;
}
}
// 分页逻辑:总是尝试下一页,直到满足 maxRuns
page++;
} }
if (allRuns.length > 0) { const runs: Array<{ id: number; created_at: string; }> = [];
return { runs: allRuns, mirror: mirrorItem || 'https://github.com' }; const foundIds = new Set<number>();
let timeIndex = 0;
let match;
while ((match = runPattern.exec(html)) !== null && runs.length < maxRuns) {
const id = parseInt(match[1]);
if (!foundIds.has(id)) {
foundIds.add(id);
// 尝试获取对应的时间,每个 run 通常有两个时间(桌面和移动端显示)
// 所以每找到一个 run跳过两个时间
const created_at = times[timeIndex] || new Date().toISOString();
timeIndex += 2; // 跳过两个时间(桌面端和移动端各一个)
runs.push({
id,
created_at,
});
}
}
if (runs.length > 0) {
return runs;
} }
} catch { } catch {
continue; continue;
} }
} }
return { runs: [], mirror: '' }; return [];
} }
/** /**
@ -1014,49 +996,122 @@ async function getArtifactsFromNightlyLink (
owner: string, owner: string,
repo: string, repo: string,
workflow: string = 'build.yml', workflow: string = 'build.yml',
_branch: string = 'main', branch: string = 'main',
maxRuns: number = 10, maxRuns: number = 10
mirror?: string ): Promise<ActionArtifact[]> {
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> { let workflowRuns: Array<{ id: number; head_sha?: string; created_at: string; }> = [];
// 策略: 优先使用 nightly.link更稳定无需认证+ HTML 解析
// 策略1: 优先尝试 GitHub API
try { try {
// 以前尝试使用 GitHub API现在弃用完全使用 HTML 解析逻辑 const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
// 并获取 workflow // 直接从 HTML 页面解析
const { runs: workflowRuns, mirror: runsMirror } = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns, mirror); const runsResponse = await PromiseTimer(
RequestUtil.HttpGetJson<{
workflow_runs: Array<{ id: number; head_sha: string; created_at: string; }>;
}>(endpoint, 'GET', undefined, {
'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json',
}),
10000
);
if (workflowRuns.length === 0) { if (runsResponse.workflow_runs && runsResponse.workflow_runs.length > 0) {
return { artifacts: [], mirror: runsMirror }; workflowRuns = runsResponse.workflow_runs;
} }
// 直接拼接 nightly.link URL
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
const artifacts: ActionArtifact[] = [];
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
// 如果 HTML 解析使用的 mirror 是 github.com则 nightly.link 使用默认配置
// 如果使用了镜像,可能需要特殊的 nightly.link 镜像,或者这里仅记录 HTML 来源镜像
// 实际上 nightly.link 本身就是一个服务,我们使用配置中的 nightlyLinkMirrors
const baseNightlyMirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
for (const run of workflowRuns) {
for (const artifactName of artifactNames) {
artifacts.push({
id: run.id,
name: artifactName,
size_in_bytes: 0,
created_at: run.created_at,
expires_at: new Date(new Date(run.created_at).getTime() + 3 * 24 * 60 * 60 * 1000).toISOString(),
archive_download_url: `${baseNightlyMirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
workflow_run_id: run.id,
workflow_title: run.title,
});
}
}
return { artifacts, mirror: runsMirror };
} catch { } catch {
return { artifacts: [], mirror: '' }; // API 请求失败,继续尝试 HTML 解析
} }
// 策略2: API 失败时,从 HTML 页面解析
if (workflowRuns.length === 0) {
workflowRuns = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns);
}
if (workflowRuns.length === 0) {
return [];
}
// 直接拼接 nightly.link URL
// 格式: https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
const artifacts: ActionArtifact[] = [];
const artifactNames = ['NapCat.Framework', 'NapCat.Shell']; // 已知的 artifact 名称
for (const run of workflowRuns) {
for (const artifactName of artifactNames) {
const mirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
artifacts.push({
id: run.id,
name: artifactName,
size_in_bytes: 0,
created_at: run.created_at,
expires_at: new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString(),
archive_download_url: `${mirror}/${owner}/${repo}/actions/runs/${run.id}/${artifactName}.zip`,
workflow_run_id: run.id,
head_sha: run.head_sha,
});
}
}
return artifacts;
}
/**
* GitHub API artifacts
*/
async function getArtifactsFromAPI (
owner: string,
repo: string,
workflow: string = 'build.yml',
branch: string = 'main',
maxRuns: number = 10
): Promise<ActionArtifact[]> {
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
const runsResponse = await PromiseTimer(
RequestUtil.HttpGetJson<{
workflow_runs: Array<{ id: number; head_sha: string; created_at: string; }>;
}>(endpoint, 'GET', undefined, {
'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json',
}),
10000
);
const workflowRuns = runsResponse.workflow_runs;
if (!workflowRuns || workflowRuns.length === 0) {
throw new Error('No successful workflow runs found');
}
// 获取所有 runs 的 artifacts
const allArtifacts: ActionArtifact[] = [];
for (const run of workflowRuns) {
try {
const artifactsEndpoint = `https://api.github.com/repos/${owner}/${repo}/actions/runs/${run.id}/artifacts`;
const artifactsResponse = await PromiseTimer(
RequestUtil.HttpGetJson<{
artifacts: ActionArtifact[];
}>(artifactsEndpoint, 'GET', undefined, {
'User-Agent': 'NapCat',
'Accept': 'application/vnd.github.v3+json',
}),
10000
);
if (artifactsResponse.artifacts) {
// 为每个 artifact 添加 run 信息
for (const artifact of artifactsResponse.artifacts) {
artifact.workflow_run_id = run.id;
artifact.head_sha = run.head_sha;
allArtifacts.push(artifact);
}
}
} catch {
// 单个 run 获取失败,继续下一个
}
}
return allArtifacts;
} }
/** /**
@ -1066,41 +1121,48 @@ async function getArtifactsFromNightlyLink (
* *
* 1. 10 * 1. 10
* 2. nightly.link * 2. nightly.link
* 3. GitHub API HTML * 3. 退 GitHub API
*/ */
export async function getLatestActionArtifacts ( export async function getLatestActionArtifacts (
owner: string, owner: string,
repo: string, repo: string,
workflow: string = 'build.yml', workflow: string = 'build.yml',
branch: string = 'main', branch: string = 'main',
maxRuns: number = 10, maxRuns: number = 10
mirror?: string ): Promise<ActionArtifact[]> {
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> { const cacheKey = `${owner}/${repo}/${workflow}/${branch}`;
const cacheKey = `${owner}/${repo}/${workflow}/${branch}/${mirror || 'auto'}`;
// 检查缓存 // 检查缓存
const cached = artifactsCache.get(cacheKey); const cached = artifactsCache.get(cacheKey);
if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) { if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
return { artifacts: cached.artifacts, mirror: cached.mirror }; return cached.artifacts;
} }
let result: { artifacts: ActionArtifact[], mirror: string; } = { artifacts: [], mirror: '' }; let artifacts: ActionArtifact[] = [];
// 策略: 优先使用 nightly.link更稳定无需认证+ HTML 解析 // 策略1: 优先使用 nightly.link更稳定无需认证
try { try {
result = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns, mirror); artifacts = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns);
} catch { } catch {
// 获取失败 // nightly.link 获取失败
}
// 策略2: 回退到 GitHub API
if (artifacts.length === 0) {
try {
artifacts = await getArtifactsFromAPI(owner, repo, workflow, branch, maxRuns);
} catch {
// API 获取失败
}
} }
// 缓存结果(即使为空也缓存,避免频繁请求) // 缓存结果(即使为空也缓存,避免频繁请求)
if (result.artifacts.length > 0) { if (artifacts.length > 0) {
artifactsCache.set(cacheKey, { artifactsCache.set(cacheKey, {
artifacts: result.artifacts, artifacts,
mirror: result.mirror,
timestamp: Date.now(), timestamp: Date.now(),
}); });
} }
return result; return artifacts;
} }

View File

@ -65,11 +65,9 @@ export function compareSemVer (v1: string, v2: string): -1 | 0 | 1 {
const a = parseSemVer(v1); const a = parseSemVer(v1);
const b = parseSemVer(v2); const b = parseSemVer(v2);
if (!a.valid && !b.valid) { if (!a.valid || !b.valid) {
return v1.localeCompare(v2) as -1 | 0 | 1; return 0;
} }
if (!a.valid) return -1;
if (!b.valid) return 1;
// 比较主版本号 // 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1; if (a.major !== b.major) return a.major > b.major ? 1 : -1;

View File

@ -3,8 +3,6 @@ import { NapCatPathWrapper } from '@/napcat-common/src/path';
import { LogWrapper } from '@/napcat-core/helper/log'; import { LogWrapper } from '@/napcat-core/helper/log';
import { connectToNamedPipe } from './pipe'; import { connectToNamedPipe } from './pipe';
import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data'; import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { AuthHelper } from '@/napcat-webui-backend/src/helper/SignToken';
import { webUiRuntimePort } from '@/napcat-webui-backend/index';
import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api'; import { createProcessManager, type IProcessManager, type IWorkerProcess } from './process-api';
import path from 'path'; import path from 'path';
import { fileURLToPath } from 'url'; import { fileURLToPath } from 'url';
@ -20,13 +18,6 @@ const ENV = {
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1', isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
} as const; } as const;
// Worker 消息类型
interface WorkerMessage {
type: 'restart' | 'restart-prepare' | 'shutdown';
secretKey?: string;
port?: number;
}
// 初始化日志 // 初始化日志
const pathWrapper = new NapCatPathWrapper(); const pathWrapper = new NapCatPathWrapper();
const logger = new LogWrapper(pathWrapper.logsPath); const logger = new LogWrapper(pathWrapper.logsPath);
@ -36,7 +27,6 @@ let processManager: IProcessManager | null = null;
let currentWorker: IWorkerProcess | null = null; let currentWorker: IWorkerProcess | null = null;
let isElectron = false; let isElectron = false;
let isRestarting = false; let isRestarting = false;
let isShuttingDown = false;
/** /**
* *
@ -72,24 +62,17 @@ function isProcessAlive (pid: number): boolean {
function forceKillProcess (pid: number): void { function forceKillProcess (pid: number): void {
try { try {
process.kill(pid, 'SIGKILL'); process.kill(pid, 'SIGKILL');
logger.log(`[NapCat] [Process] 已强制终止进程 ${pid}`);
} catch (error) { } catch (error) {
// SIGKILL 失败,在 Windows 上使用 taskkill 兜底 logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
if (process.platform === 'win32') {
try {
require('child_process').execSync(`taskkill /F /PID ${pid}`, { stdio: 'ignore' });
} catch {
logger.logError(`[NapCat] [Process] 强制终止进程失败: PID ${pid}`);
}
} else {
logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
}
} }
} }
/** /**
* Worker * Worker
*/ */
export async function restartWorker (secretKey?: string, port?: number): Promise<void> { export async function restartWorker (): Promise<void> {
logger.log('[NapCat] [Process] 正在重启Worker进程...');
isRestarting = true; isRestarting = true;
if (!currentWorker) { if (!currentWorker) {
@ -100,6 +83,7 @@ export async function restartWorker (secretKey?: string, port?: number): Promise
} }
const workerPid = currentWorker.pid; const workerPid = currentWorker.pid;
logger.log(`[NapCat] [Process] 准备关闭Worker进程PID: ${workerPid}`);
// 1. 通知旧进程准备重启(旧进程会自行退出) // 1. 通知旧进程准备重启(旧进程会自行退出)
currentWorker.postMessage({ type: 'restart-prepare' }); currentWorker.postMessage({ type: 'restart-prepare' });
@ -120,35 +104,47 @@ export async function restartWorker (secretKey?: string, port?: number): Promise
currentWorker?.once('exit', () => { currentWorker?.once('exit', () => {
clearTimeout(timeout); clearTimeout(timeout);
logger.log('[NapCat] [Process] Worker进程已正常退出');
resolve(); resolve();
}); });
}); });
// 3. 二次确认进程是否真的被终止(兜底检查) // 3. 二次确认进程是否真的被终止(兜底检查)
if (workerPid && isProcessAlive(workerPid)) { if (workerPid) {
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉`); logger.log(`[NapCat] [Process] 检查进程 ${workerPid} 是否已终止...`);
forceKillProcess(workerPid);
await new Promise(resolve => setTimeout(resolve, 1000));
if (isProcessAlive(workerPid)) { if (isProcessAlive(workerPid)) {
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`); logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉(兜底)`);
forceKillProcess(workerPid);
// 等待 1 秒后再次检查
await new Promise(resolve => setTimeout(resolve, 1000));
if (isProcessAlive(workerPid)) {
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
} else {
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已被强制终止`);
}
} else {
logger.log(`[NapCat] [Process] 进程 ${workerPid} 已确认终止`);
} }
} }
// 4. 等待后启动新进程 // 4. 等待 3 秒后启动新进程
logger.log('[NapCat] [Process] Worker进程已关闭等待 3 秒后启动新进程...');
await new Promise(resolve => setTimeout(resolve, 3000)); await new Promise(resolve => setTimeout(resolve, 3000));
// 5. 启动新进程(重启模式不传递快速登录参数,传递密钥和端口) // 5. 启动新进程(重启模式不传递快速登录参数
await startWorker(false, secretKey, port); await startWorker(false);
isRestarting = false; isRestarting = false;
logger.log('[NapCat] [Process] Worker进程重启完成');
} }
/** /**
* Worker * Worker
* @param passQuickLogin true false * @param passQuickLogin true false
* @param secretKey WebUI JWT
* @param preferredPort 使 WebUI
*/ */
async function startWorker (passQuickLogin: boolean = true, secretKey?: string, preferredPort?: number): Promise<void> { async function startWorker (passQuickLogin: boolean = true): Promise<void> {
if (!processManager) { if (!processManager) {
throw new Error('进程管理器未初始化'); throw new Error('进程管理器未初始化');
} }
@ -174,8 +170,6 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
env: { env: {
...process.env, ...process.env,
NAPCAT_WORKER_PROCESS: '1', NAPCAT_WORKER_PROCESS: '1',
...(secretKey ? { NAPCAT_WEBUI_JWT_SECRET_KEY: secretKey } : {}),
...(preferredPort ? { NAPCAT_WEBUI_PREFERRED_PORT: String(preferredPort) } : {}),
}, },
stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'], stdio: isElectron ? 'pipe' : ['inherit', 'pipe', 'pipe', 'ipc'],
}); });
@ -198,14 +192,14 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
// 监听子进程消息 // 监听子进程消息
child.on('message', (msg: unknown) => { child.on('message', (msg: unknown) => {
logger.log(`[NapCat] [${processType}] 收到Worker消息:`, msg);
// 处理重启请求 // 处理重启请求
if (typeof msg === 'object' && msg !== null && 'type' in msg) { if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
const message = msg as WorkerMessage; logger.log(`[NapCat] [${processType}] 收到重启请求正在重启Worker进程...`);
if (message.type === 'restart') { restartWorker().catch(e => {
restartWorker(message.secretKey, message.port).catch(e => { logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e); });
});
}
} }
}); });
@ -214,9 +208,11 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
const exitCode = typeof code === 'number' ? code : 0; const exitCode = typeof code === 'number' ? code : 0;
if (exitCode !== 0) { if (exitCode !== 0) {
logger.logError(`[NapCat] [${processType}] Worker进程退出退出码: ${exitCode}`); logger.logError(`[NapCat] [${processType}] Worker进程退出退出码: ${exitCode}`);
} else {
logger.log(`[NapCat] [${processType}] Worker进程正常退出`);
} }
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起 // 如果不是由于主动重启引起的退出,尝试自动重新拉起(保留快速登录参数)
if (!isRestarting && !isShuttingDown) { if (!isRestarting) {
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出正在尝试重新拉起...`); logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出正在尝试重新拉起...`);
startWorker(true).catch(e => { startWorker(true).catch(e => {
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e); logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
@ -224,20 +220,8 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
} }
}); });
// 等待进程成功 spawn child.on('spawn', () => {
await new Promise<void>((resolve, reject) => { logger.log(`[NapCat] [${processType}] Worker进程已生成`);
const onSpawn = () => {
child.off('error', onError);
resolve();
};
const onError = (...args: unknown[]) => {
const err = args[0] as Error;
logger.logError(`[NapCat] [${processType}] Worker进程启动失败:`, err);
child.off('spawn', onSpawn);
reject(err);
};
child.once('spawn', onSpawn);
child.once('error', onError);
}); });
} }
@ -245,19 +229,25 @@ async function startWorker (passQuickLogin: boolean = true, secretKey?: string,
* Master * Master
*/ */
async function startMasterProcess (): Promise<void> { async function startMasterProcess (): Promise<void> {
const processType = getProcessTypeName();
logger.log(`[NapCat] [${processType}] Master进程启动PID: ${process.pid}`);
// 连接命名管道(可通过环境变量禁用) // 连接命名管道(可通过环境变量禁用)
if (!ENV.isPipeDisabled) { if (!ENV.isPipeDisabled) {
await connectToNamedPipe(logger).catch(e => await connectToNamedPipe(logger).catch(e =>
logger.logError('命名管道连接失败', e) logger.logError('命名管道连接失败', e)
); );
} else {
logger.log(`[NapCat] [${processType}] 命名管道已禁用 (NAPCAT_DISABLE_PIPE=1)`);
} }
// 启动 Worker 进程 // 启动 Worker 进程
await startWorker(); await startWorker();
// 优雅关闭处理 // 优雅关闭处理
const shutdown = () => { const shutdown = (signal: string) => {
isShuttingDown = true; logger.log(`[NapCat] [Process] 收到${signal}信号,正在关闭...`);
if (currentWorker) { if (currentWorker) {
currentWorker.postMessage({ type: 'shutdown' }); currentWorker.postMessage({ type: 'shutdown' });
setTimeout(() => { setTimeout(() => {
@ -269,8 +259,8 @@ async function startMasterProcess (): Promise<void> {
} }
}; };
process.on('SIGINT', () => shutdown()); process.on('SIGINT', () => shutdown('SIGINT'));
process.on('SIGTERM', () => shutdown()); process.on('SIGTERM', () => shutdown('SIGTERM'));
} }
/** /**
@ -281,13 +271,20 @@ async function startWorkerProcess (): Promise<void> {
throw new Error('进程管理器未初始化'); throw new Error('进程管理器未初始化');
} }
const processType = getProcessTypeName();
logger.log(`[NapCat] [${processType}] Worker进程启动PID: ${process.pid}`);
// 监听来自父进程的消息 // 监听来自父进程的消息
processManager.onParentMessage((msg: unknown) => { processManager.onParentMessage((msg: unknown) => {
if (typeof msg === 'object' && msg !== null && 'type' in msg) { if (typeof msg === 'object' && msg !== null && 'type' in msg) {
if (msg.type === 'restart-prepare' || msg.type === 'shutdown') { if (msg.type === 'restart-prepare') {
logger.log(`[NapCat] [${processType}] 收到重启准备信号,正在主动退出...`);
setTimeout(() => { setTimeout(() => {
process.exit(0); process.exit(0);
}, 100); }, 100);
} else if (msg.type === 'shutdown') {
logger.log(`[NapCat] [${processType}] 收到关闭信号,正在退出...`);
process.exit(0);
} }
} }
}); });
@ -295,11 +292,7 @@ async function startWorkerProcess (): Promise<void> {
// 注册重启进程函数到 WebUI // 注册重启进程函数到 WebUI
WebUiDataRuntime.setRestartProcessCall(async () => { WebUiDataRuntime.setRestartProcessCall(async () => {
try { try {
const success = processManager!.sendToParent({ const success = processManager!.sendToParent({ type: 'restart' });
type: 'restart',
secretKey: AuthHelper.getSecretKey(),
port: webUiRuntimePort,
});
if (success) { if (success) {
return { result: true, message: '进程重启请求已发送' }; return { result: true, message: '进程重启请求已发送' };
@ -325,6 +318,7 @@ async function startWorkerProcess (): Promise<void> {
async function main (): Promise<void> { async function main (): Promise<void> {
// 单进程模式:直接启动核心 // 单进程模式:直接启动核心
if (ENV.isMultiProcessDisabled) { if (ENV.isMultiProcessDisabled) {
logger.log('[NapCat] [SingleProcess] 多进程模式已禁用,直接启动核心');
await NCoreInitShell(); await NCoreInitShell();
return; return;
} }
@ -334,6 +328,8 @@ async function main (): Promise<void> {
processManager = result.manager; processManager = result.manager;
isElectron = result.isElectron; isElectron = result.isElectron;
logger.log(`[NapCat] [Process] 检测到 ${isElectron ? 'Electron' : 'Node.js'} 环境`);
// 根据进程类型启动 // 根据进程类型启动
if (ENV.isWorkerProcess) { if (ENV.isWorkerProcess) {
await startWorkerProcess(); await startWorkerProcess();

View File

@ -25,7 +25,6 @@ export interface IWorkerProcess {
kill (): boolean; kill (): boolean;
on (event: string, listener: (...args: unknown[]) => void): void; on (event: string, listener: (...args: unknown[]) => void): void;
once (event: string, listener: (...args: unknown[]) => void): void; once (event: string, listener: (...args: unknown[]) => void): void;
off (event: string, listener: (...args: unknown[]) => void): void;
} }
/** /**
@ -61,7 +60,7 @@ class ElectronProcessManager implements IProcessManager {
const child: any = this.utilityProcess.fork(modulePath, args, options); const child: any = this.utilityProcess.fork(modulePath, args, options);
return { return {
get pid () { return child.pid as number | undefined; }, pid: child.pid as number | undefined,
stdout: child.stdout as Readable | null, stdout: child.stdout as Readable | null,
stderr: child.stderr as Readable | null, stderr: child.stderr as Readable | null,
@ -80,10 +79,6 @@ class ElectronProcessManager implements IProcessManager {
once (event: string, listener: (...args: unknown[]) => void): void { once (event: string, listener: (...args: unknown[]) => void): void {
child.once(event, listener); child.once(event, listener);
}, },
off (event: string, listener: (...args: unknown[]) => void): void {
child.off(event, listener);
},
}; };
} }
@ -118,7 +113,7 @@ class NodeProcessManager implements IProcessManager {
const child = this.forkFn(modulePath, args, options as any); const child = this.forkFn(modulePath, args, options as any);
return { return {
get pid () { return child.pid; }, pid: child.pid,
stdout: child.stdout, stdout: child.stdout,
stderr: child.stderr, stderr: child.stderr,
@ -139,10 +134,6 @@ class NodeProcessManager implements IProcessManager {
once (event: string, listener: (...args: unknown[]) => void): void { once (event: string, listener: (...args: unknown[]) => void): void {
child.once(event, listener); child.once(event, listener);
}, },
off (event: string, listener: (...args: unknown[]) => void): void {
child.off(event, listener);
},
}; };
} }
@ -173,9 +164,6 @@ export async function createProcessManager (): Promise<{
if (isElectron) { if (isElectron) {
// @ts-ignore - electron 运行时存在但类型声明可能缺失 // @ts-ignore - electron 运行时存在但类型声明可能缺失
const electron = await import('electron'); const electron = await import('electron');
if (electron.app && !electron.app.isReady()) {
await electron.app.whenReady();
}
return { return {
manager: new ElectronProcessManager(electron.utilityProcess), manager: new ElectronProcessManager(electron.utilityProcess),
isElectron: true, isElectron: true,

View File

@ -72,19 +72,7 @@ export function setPendingTokenToSend (token: string | null) {
export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> { export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
try { try {
await tryUseHost(parsedConfig.host); await tryUseHost(parsedConfig.host);
const preferredPort = parseInt(process.env['NAPCAT_WEBUI_PREFERRED_PORT'] || '', 10); const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
let port: number;
if (preferredPort > 0) {
try {
port = await tryUsePort(preferredPort, parsedConfig.host, 0, true);
} catch {
port = await tryUsePort(parsedConfig.port, parsedConfig.host);
}
} else {
port = await tryUsePort(parsedConfig.port, parsedConfig.host);
}
return [parsedConfig.host, port, parsedConfig.token]; return [parsedConfig.host, port, parsedConfig.token];
} catch (error) { } catch (error) {
console.log('host或port不可用', error); console.log('host或port不可用', error);
@ -368,7 +356,7 @@ async function tryUseHost (host: string): Promise<string> {
}); });
} }
async function tryUsePort (port: number, host: string, tryCount: number = 0, singleTry: boolean = false): Promise<number> { async function tryUsePort (port: number, host: string, tryCount: number = 0): Promise<number> {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
try { try {
const server = net.createServer(); const server = net.createServer();
@ -379,12 +367,9 @@ async function tryUsePort (port: number, host: string, tryCount: number = 0, sin
server.on('error', (err: any) => { server.on('error', (err: any) => {
if (err.code === 'EADDRINUSE') { if (err.code === 'EADDRINUSE') {
if (singleTry) { if (tryCount < MAX_PORT_TRY) {
// 只尝试一次,端口被占用则直接失败 // 使用循环代替递归
reject(new Error(`端口 ${port} 已被占用`)); resolve(tryUsePort(port + 1, host, tryCount + 1));
} else if (tryCount < MAX_PORT_TRY) {
// 递归尝试下一个端口
resolve(tryUsePort(port + 1, host, tryCount + 1, false));
} else { } else {
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`)); reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
} }

View File

@ -4,7 +4,7 @@ import { WebUiDataRuntime } from '@/napcat-webui-backend/src/helper/Data';
import { sendSuccess } from '@/napcat-webui-backend/src/utils/response'; import { sendSuccess } from '@/napcat-webui-backend/src/utils/response';
import { WebUiConfig } from '@/napcat-webui-backend/index'; import { WebUiConfig } from '@/napcat-webui-backend/index';
import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper'; import { getLatestTag, getAllTags, compareSemVer } from 'napcat-common/src/helper';
import { getLatestActionArtifacts, getMirrorConfig } from '@/napcat-common/src/mirror'; import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types'; import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
export const GetNapCatVersion: RequestHandler = (_, res) => { export const GetNapCatVersion: RequestHandler = (_, res) => {
@ -35,7 +35,6 @@ export interface VersionInfo {
size?: number; size?: number;
workflowRunId?: number; workflowRunId?: number;
headSha?: string; headSha?: string;
workflowTitle?: string;
} }
/** /**
@ -48,17 +47,11 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
const pageSize = parseInt(req.query['pageSize'] as string) || 20; const pageSize = parseInt(req.query['pageSize'] as string) || 20;
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all' const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim(); const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
const mirror = req.query['mirror'] as string | undefined;
let versions: VersionInfo[] = []; let versions: VersionInfo[] = [];
let actionVersions: VersionInfo[] = []; let actionVersions: VersionInfo[] = [];
let usedMirror = ''; let usedMirror = '';
// If mirror is specified, report it as used (will be confirmed by actual fetching response)
if (mirror) {
usedMirror = mirror;
}
// 懒加载:只获取需要的版本类型 // 懒加载:只获取需要的版本类型
const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release'; const needReleases = !typeFilter || typeFilter === 'all' || typeFilter === 'release';
const needActions = typeFilter === 'action' || typeFilter === 'all'; const needActions = typeFilter === 'action' || typeFilter === 'all';
@ -66,12 +59,9 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
// 获取正式版本(仅当需要时) // 获取正式版本(仅当需要时)
if (needReleases) { if (needReleases) {
try { try {
const result = await getAllTags(mirror); const result = await getAllTags();
// 如果没有指定镜像,使用实际上使用的镜像 usedMirror = result.mirror;
if (!mirror) {
usedMirror = result.mirror;
}
versions = result.tags.map(tag => { versions = result.tags.map(tag => {
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag); const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
return { return {
@ -91,19 +81,14 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
// 获取 Action Artifacts仅当需要时 // 获取 Action Artifacts仅当需要时
if (needActions) { if (needActions) {
try { try {
const { artifacts, mirror: actionMirror } = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main', 10, mirror); const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
// 根据当前工作环境自动过滤对应的 artifact 类型 // 根据当前工作环境自动过滤对应的 artifact 类型
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework; const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell'; const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
// 如果没有指定镜像,且 action 实际上用了一个镜像(自动选择的),更新 usedMirror
if (!mirror && actionMirror) {
usedMirror = actionMirror;
}
actionVersions = artifacts actionVersions = artifacts
.filter(a => a && a.name === targetArtifactName) .filter(a => a.name === targetArtifactName)
.map(a => ({ .map(a => ({
tag: `action-${a.id}`, tag: `action-${a.id}`,
type: 'action' as const, type: 'action' as const,
@ -114,7 +99,6 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
size: a.size_in_bytes, size: a.size_in_bytes,
workflowRunId: a.workflow_run_id, workflowRunId: a.workflow_run_id,
headSha: a.head_sha, headSha: a.head_sha,
workflowTitle: a.workflow_title,
})); }));
} catch { } catch {
// 获取失败时返回空列表 // 获取失败时返回空列表
@ -130,9 +114,7 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
allVersions = allVersions.filter(v => { allVersions = allVersions.filter(v => {
const tagMatch = v.tag.toLowerCase().includes(searchQuery); const tagMatch = v.tag.toLowerCase().includes(searchQuery);
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery); const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
const titleMatch = v.workflowTitle?.toLowerCase().includes(searchQuery); return tagMatch || nameMatch;
const shaMatch = v.headSha?.toLowerCase().includes(searchQuery);
return tagMatch || nameMatch || titleMatch || shaMatch;
}); });
} }
@ -173,8 +155,3 @@ export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
await WebUiConfig.UpdateTheme(theme); await WebUiConfig.UpdateTheme(theme);
sendSuccess(res, { message: '更新成功' }); sendSuccess(res, { message: '更新成功' });
}; };
export const GetMirrorsHandler: RequestHandler = (_, res) => {
const config = getMirrorConfig();
sendSuccess(res, { mirrors: config.fileMirrors });
};

View File

@ -20,8 +20,6 @@ interface UpdateRequestBody {
targetVersion?: string; targetVersion?: string;
/** 是否强制更新(即使是降级也更新) */ /** 是否强制更新(即使是降级也更新) */
force?: boolean; force?: boolean;
/** 指定使用的镜像 */
mirror?: string;
} }
// 更新配置文件接口 // 更新配置文件接口
@ -126,7 +124,7 @@ async function downloadFile (url: string, dest: string): Promise<void> {
export const UpdateNapCatHandler: RequestHandler = async (req, res) => { export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
try { try {
// 从请求体获取目标版本(可选) // 从请求体获取目标版本(可选)
const { targetVersion, force, mirror } = req.body as UpdateRequestBody; const { targetVersion, force } = req.body as UpdateRequestBody;
// 确定要下载的文件名 // 确定要下载的文件名
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip'; const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
@ -152,21 +150,20 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
// 根据当前工作环境确定 artifact 名称 // 根据当前工作环境确定 artifact 名称
const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell const artifactName = ReleaseName.replace('.zip', ''); // NapCat.Framework 或 NapCat.Shell
// Action artifacts 通过 nightly.link 下载 // Action artifacts 通过 nightly.link 下载
// 格式https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip // 格式https://nightly.link/{owner}/{repo}/actions/runs/{run_id}/{artifact_name}.zip
const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`; const baseUrl = `https://nightly.link/NapNeko/NapCatQQ/actions/runs/${runId}/${artifactName}.zip`;
actualVersion = targetTag; actualVersion = targetTag;
webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`); webUiLogger?.log(`[NapCat Update] Action artifact URL: ${baseUrl}`);
// 使用 mirror 模块查找可用的 nightly.link 镜像 // 使用 mirror 模块查找可用的 nightly.link 镜像
try { try {
downloadUrl = await findAvailableDownloadUrl(baseUrl, { downloadUrl = await findAvailableDownloadUrl(baseUrl, {
validateContent: true, validateContent: true,
minFileSize: 1024 * 1024, minFileSize: 1024 * 1024,
timeout: 10000, timeout: 10000,
customMirror: mirror,
}); });
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`); webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
} catch (error) { } catch (error) {
@ -181,7 +178,6 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, { const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'], assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
fetchChangelog: false, // 不需要 changelog避免 API 调用 fetchChangelog: false, // 不需要 changelog避免 API 调用
mirror,
}); });
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName); const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
@ -197,7 +193,6 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
validateContent: true, // 验证 Content-Type 和状态码 validateContent: true, // 验证 Content-Type 和状态码
minFileSize: 1024 * 1024, // 最小 1MB确保不是错误页面 minFileSize: 1024 * 1024, // 最小 1MB确保不是错误页面
timeout: 10000, // 10秒超时 timeout: 10000, // 10秒超时
customMirror: mirror,
}); });
} }

View File

@ -4,10 +4,6 @@ import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/napcat-web
export class AuthHelper { export class AuthHelper {
private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2); private static readonly secretKey = process.env['NAPCAT_WEBUI_JWT_SECRET_KEY'] || Math.random().toString(36).slice(2);
public static getSecretKey (): string {
return AuthHelper.secretKey;
}
/** /**
* *
* @param hash * @param hash

View File

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

View File

@ -45,7 +45,7 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
return ( return (
<div <div
className={clsx( className={clsx(
'flex text-sm gap-3 py-2 items-baseline transition-colors', 'flex text-sm gap-3 py-2 items-center transition-colors',
hasBackground hasBackground
? 'text-white/90' ? 'text-white/90'
: 'text-default-600 dark:text-gray-300', : 'text-default-600 dark:text-gray-300',
@ -53,13 +53,13 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
)} )}
onClick={onClick} onClick={onClick}
> >
<div className="text-lg opacity-70 self-center">{icon}</div> <div className="text-lg opacity-70">{icon}</div>
<div className='w-24 font-medium'>{title}</div> <div className='w-24 font-medium'>{title}</div>
<div className={clsx( <div className={clsx(
'text-xs font-mono flex-1', 'text-xs font-mono flex-1',
hasBackground ? 'text-white/80' : 'text-default-500' hasBackground ? 'text-white/80' : 'text-default-500'
)}>{value}</div> )}>{value}</div>
<div className="self-center">{endContent}</div> <div>{endContent}</div>
</div> </div>
); );
}; };
@ -84,11 +84,9 @@ const UpdateDialogContent: React.FC<{
<div className="flex flex-col sm:flex-row items-center justify-between gap-4 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 sm:flex-row items-center justify-between gap-4 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 min-w-0 w-full sm:w-auto"> <div className="flex flex-col items-center gap-2 min-w-0 w-full sm:w-auto">
<span className="text-xs text-default-500 font-medium uppercase tracking-wider"></span> <span className="text-xs text-default-500 font-medium uppercase tracking-wider"></span>
<Tooltip content={`v${currentVersion}`}> <Chip size="lg" variant="flat" color="default" classNames={{ content: "font-mono font-bold text-base sm:text-lg break-all whitespace-normal text-center h-auto py-1" }}>
<Chip size="md" variant="flat" color="default" classNames={{ content: "font-mono font-bold text-sm truncate max-w-[120px] sm:max-w-[160px]" }}> v{currentVersion}
v{currentVersion} </Chip>
</Chip>
</Tooltip>
</div> </div>
<div className="flex flex-col items-center text-primary-500 px-4 shrink-0"> <div className="flex flex-col items-center text-primary-500 px-4 shrink-0">
@ -101,11 +99,9 @@ const UpdateDialogContent: React.FC<{
<div className="flex flex-col items-center gap-2 min-w-0 w-full sm:w-auto"> <div className="flex flex-col items-center gap-2 min-w-0 w-full sm:w-auto">
<span className="text-xs text-primary-500 font-medium uppercase tracking-wider"></span> <span className="text-xs text-primary-500 font-medium uppercase tracking-wider"></span>
<Tooltip content={`v${latestVersion}`}> <Chip size="lg" color="primary" variant="shadow" classNames={{ content: "font-mono font-bold text-base sm:text-lg break-all whitespace-normal text-center h-auto py-1" }}>
<Chip size="md" color="primary" variant="shadow" classNames={{ content: "font-mono font-bold text-sm truncate max-w-[120px] sm:max-w-[160px]" }}> v{latestVersion}
v{latestVersion} </Chip>
</Chip>
</Tooltip>
</div> </div>
</div> </div>
@ -140,21 +136,13 @@ const UpdateDialogContent: React.FC<{
</p> </p>
</div> </div>
<div className='mt-2 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='mt-2 p-3 rounded-lg bg-warning-50/50 dark:bg-warning-900/20 border border-warning-200/50 dark:border-warning-700/30'>
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1 justify-center'> <p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1'>
<svg className='w-4 h-4' fill='none' viewBox='0 0 24 24' stroke='currentColor'> <svg className='w-4 h-4' 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' /> <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> </svg>
<span> NapCat </span> <span> NapCat</span>
</p> </p>
</div> </div>
<div className='flex gap-3 justify-center mt-2 w-full'>
<button
className='px-4 py-2 text-sm rounded-lg bg-primary-500 hover:bg-primary-600 text-white shadow-sm transition-colors shadow-primary-500/20 w-full'
onClick={() => WebUIManager.restart()}
>
</button>
</div>
</div> </div>
)} )}
@ -281,7 +269,6 @@ interface VersionInfo {
size?: number; size?: number;
workflowRunId?: number; workflowRunId?: number;
headSha?: string; headSha?: string;
workflowTitle?: string;
} }
// 版本选择对话框内容 // 版本选择对话框内容
@ -303,14 +290,6 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
const [activeTab, setActiveTab] = useState<'release' | 'action'>('release'); const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const debouncedSearch = useDebounce(searchQuery, 300); const debouncedSearch = useDebounce(searchQuery, 300);
const [selectedMirror, setSelectedMirror] = useState<string | undefined>(undefined);
const { data: mirrorsData } = useRequest(WebUIManager.getMirrors, {
cacheKey: 'napcat-mirrors',
staleTime: 60 * 60 * 1000,
});
const mirrors = mirrorsData?.mirrors || [];
const pageSize = 15; const pageSize = 15;
// 获取所有可用版本(带分页、过滤和搜索) // 获取所有可用版本(带分页、过滤和搜索)
@ -320,16 +299,15 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
page: currentPage, page: currentPage,
pageSize, pageSize,
type: activeTab, type: activeTab,
search: debouncedSearch, search: debouncedSearch
mirror: selectedMirror
}), }),
{ {
refreshDeps: [currentPage, activeTab, debouncedSearch, selectedMirror], refreshDeps: [currentPage, activeTab, debouncedSearch],
} }
); );
// 版本列表已在后端过滤,直接使用 // 版本列表已在后端过滤,直接使用
const filteredVersions = (releasesData?.versions || []) as VersionInfo[]; const filteredVersions = releasesData?.versions || [];
// 检查是否是降级(使用语义化版本比较) // 检查是否是降级(使用语义化版本比较)
const isDowngrade = useCallback((targetTag: string): boolean => { const isDowngrade = useCallback((targetTag: string): boolean => {
@ -342,22 +320,6 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
const selectedVersionTag = selectedVersion?.tag || ''; const selectedVersionTag = selectedVersion?.tag || '';
const isSelectedDowngrade = isDowngrade(selectedVersionTag); const isSelectedDowngrade = isDowngrade(selectedVersionTag);
const performUpdate = async (force: boolean) => {
if (!selectedVersion) return;
setUpdateStatus('updating');
setErrorMessage('');
try {
await WebUIManager.UpdateNapCatToVersion(selectedVersionTag, force, selectedMirror);
setUpdateStatus('success');
} catch (err) {
console.error('Update failed:', err);
const errMsg = err instanceof Error ? err.message : '未知错误';
setErrorMessage(errMsg);
setUpdateStatus('error');
}
};
const handleUpdate = async () => { const handleUpdate = async () => {
if (!selectedVersion) return; if (!selectedVersion) return;
@ -384,6 +346,22 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
await performUpdate(forceUpdate); 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) => { const handlePageChange = (page: number) => {
setCurrentPage(page); setCurrentPage(page);
@ -397,30 +375,13 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' /> <path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
</svg> </svg>
</div> </div>
<div className='text-center w-full'> <div className='text-center'>
<p className='text-sm font-medium text-success-600 dark:text-success-400'> <p className='text-sm font-medium text-success-600 dark:text-success-400'>
{selectedVersionTag} {selectedVersionTag}
</p> </p>
<p className='text-xs text-default-500 mt-1 mb-6'> <p className='text-xs text-default-500 mt-1'>
NapCat NapCat
</p> </p>
<div className='flex gap-3 justify-center'>
<button
className='px-4 py-2 text-sm rounded-lg bg-default-100 hover:bg-default-200 transition-colors text-default-700'
onClick={onClose}
>
</button>
<button
className='px-4 py-2 text-sm rounded-lg bg-primary-500 hover:bg-primary-600 text-white shadow-sm transition-colors shadow-primary-500/20'
onClick={async () => {
await WebUIManager.restart();
onClose();
}}
>
</button>
</div>
</div> </div>
</div> </div>
); );
@ -502,46 +463,23 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
<Tab key='action' title='临时版本 (Action)' /> <Tab key='action' title='临时版本 (Action)' />
</Tabs> </Tabs>
<div className="flex gap-2"> {/* 搜索框 */}
{/* 搜索框 */} <Input
<Input placeholder='搜索版本号...'
placeholder='搜索版本号...' size='sm'
size='sm' value={searchQuery}
value={searchQuery} onValueChange={(value) => {
onValueChange={(value) => { setSearchQuery(value);
setSearchQuery(value); setCurrentPage(1);
setCurrentPage(1); setSelectedVersion(null);
setSelectedVersion(null); }}
}} startContent={<IoSearch className='text-default-400' />}
startContent={<IoSearch className='text-default-400' />} isClearable
isClearable onClear={() => setSearchQuery('')}
onClear={() => setSearchQuery('')} classNames={{
classNames={{ inputWrapper: 'h-9',
inputWrapper: 'h-9', }}
base: 'flex-1' />
}}
/>
{/* 镜像选择 */}
<Select
placeholder="自动选择 (默认)"
selectedKeys={selectedMirror ? [selectedMirror] : ['default']}
onSelectionChange={(keys) => {
const m = Array.from(keys)[0] as string;
setSelectedMirror(m === 'default' ? undefined : m);
}}
size="sm"
className="w-48"
classNames={{ trigger: 'h-9 min-h-9' }}
aria-label="选择镜像源"
>
{['default', ...mirrors].map(m => (
<SelectItem key={m} textValue={m === 'default' ? '自动选择' : m}>
{m === 'default' ? '自动选择 (默认)' : m}
</SelectItem>
))}
</Select>
</div>
{/* 版本选择 */} {/* 版本选择 */}
<div className='space-y-2'> <div className='space-y-2'>
@ -590,12 +528,7 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
> >
<div className='flex flex-col gap-0.5'> <div className='flex flex-col gap-0.5'>
<div className='flex items-center gap-2'> <div className='flex items-center gap-2'>
<span className="truncate max-w-[300px]"> <span>{version.type === 'action' && version.artifactName ? version.artifactName : version.tag}</span>
{version.type === 'action'
? (version.workflowTitle || version.artifactName || version.tag)
: version.tag
}
</span>
{version.type === 'prerelease' && ( {version.type === 'prerelease' && (
<Chip size='sm' color='secondary' variant='flat'></Chip> <Chip size='sm' color='secondary' variant='flat'></Chip>
)} )}
@ -610,11 +543,10 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
)} )}
</div> </div>
{version.type === 'action' && ( {version.type === 'action' && (
<div className='text-xs text-default-400 flex items-center gap-2'> <div className='text-xs text-default-400'>
<span className='font-mono bg-default-100 dark:bg-default-100/10 px-1 rounded'>{version.tag}</span> {version.headSha && <span className='font-mono'>{version.headSha.slice(0, 7)}</span>}
{version.headSha && <span className='font-mono' title={version.headSha}>{version.headSha.slice(0, 7)}</span>} {version.createdAt && <span className='ml-2'>{new Date(version.createdAt).toLocaleString()}</span>}
{version.createdAt && <span>{new Date(version.createdAt).toLocaleString()}</span>} {version.size && <span className='ml-2'>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
{version.size && <span>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
</div> </div>
)} )}
</div> </div>
@ -636,7 +568,6 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
</span> </span>
)} )}
</p> </p>
</div> </div>
)} )}

View File

@ -29,7 +29,7 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
<div <div
className={clsx( className={clsx(
'py-1.5 text-sm transition-colors', 'py-1.5 text-sm transition-colors',
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between items-center', size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between',
)} )}
> >
<div className={clsx( <div className={clsx(

View File

@ -72,9 +72,8 @@ export default class WebUIManager {
pageSize?: number; pageSize?: number;
type?: 'release' | 'action' | 'all'; type?: 'release' | 'action' | 'all';
search?: string; search?: string;
mirror?: string;
} = {}) { } = {}) {
const { page = 1, pageSize = 20, type = 'release', search = '', mirror } = options; const { page = 1, pageSize = 20, type = 'release', search = '' } = options;
const { data } = await serverRequest.get<ServerResponse<{ const { data } = await serverRequest.get<ServerResponse<{
versions: Array<{ versions: Array<{
tag: string; tag: string;
@ -95,21 +94,15 @@ export default class WebUIManager {
}; };
mirror?: string; mirror?: string;
}>>('/base/getAllReleases', { }>>('/base/getAllReleases', {
params: { page, pageSize, type, search, mirror }, params: { page, pageSize, type, search },
}); });
return data.data; return data.data;
} }
public static async getMirrors () { public static async UpdateNapCat () {
const { data } =
await serverRequest.get<ServerResponse<{ mirrors: string[]; }>>('/base/getMirrors');
return data.data;
}
public static async UpdateNapCat (mirror?: string) {
const { data } = await serverRequest.post<ServerResponse<any>>( const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update', '/UpdateNapCat/update',
{ mirror }, {},
{ timeout: 120000 } // 2分钟超时 { timeout: 120000 } // 2分钟超时
); );
return data; return data;
@ -119,12 +112,11 @@ export default class WebUIManager {
* *
* @param targetVersion tag "v4.9.9" "action-123456" * @param targetVersion tag "v4.9.9" "action-123456"
* @param force * @param force
* @param mirror 使
*/ */
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false, mirror?: string) { public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) {
const { data } = await serverRequest.post<ServerResponse<any>>( const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update', '/UpdateNapCat/update',
{ targetVersion, force, mirror }, { targetVersion, force },
{ timeout: 120000 } // 2分钟超时 { timeout: 120000 } // 2分钟超时
); );
return data; return data;
@ -150,16 +142,6 @@ export default class WebUIManager {
return data.data; return data.data;
} }
public static async restart () {
const { data } = await serverRequest.post<ServerResponse<any>>('/Process/Restart');
return data.data;
}
public static async getAllUsers (): Promise<any> {
const { data } = await serverRequest.get<ServerResponse<any>>('/QQLogin/GetAllUsers');
return data.data;
}
public static async getLogList () { public static async getLogList () {
const { data } = const { data } =
await serverRequest.get<ServerResponse<string[]>>('/Log/GetLogList'); await serverRequest.get<ServerResponse<string[]>>('/Log/GetLogList');