mirror of
https://github.com/NapNeko/NapCatQQ.git
synced 2026-01-18 14:30:29 +00:00
Compare commits
5 Commits
5284e0ac5a
...
8a232d8c68
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8a232d8c68 | ||
|
|
7216755430 | ||
|
|
0c91f9c66b | ||
|
|
e8855a59b0 | ||
|
|
5de2664af4 |
@ -220,13 +220,13 @@ export function parseAppidFromMajor (nodeMajor: string): string | undefined {
|
||||
// ============== GitHub Tags 获取 ==============
|
||||
// 使用 mirror 模块统一管理镜像
|
||||
|
||||
export async function getAllTags (): Promise<{ tags: string[], mirror: string; }> {
|
||||
return getAllTagsFromMirror('NapNeko', 'NapCatQQ');
|
||||
export async function getAllTags (mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||||
return getAllTagsFromMirror('NapNeko', 'NapCatQQ', mirror);
|
||||
}
|
||||
|
||||
|
||||
export async function getLatestTag (): Promise<string> {
|
||||
const { tags } = await getAllTags();
|
||||
export async function getLatestTag (mirror?: string): Promise<string> {
|
||||
const { tags } = await getAllTags(mirror);
|
||||
|
||||
// 使用 SemVer 规范排序
|
||||
tags.sort((a, b) => compareSemVer(a, b));
|
||||
|
||||
@ -23,66 +23,50 @@ import { PromiseTimer } from './helper';
|
||||
* 懒加载测速:首次使用时自动测速,缓存 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
|
||||
'https://github.chenc.dev/',
|
||||
'https://ghproxy.cfd/',
|
||||
'https://github.tbedu.top/',
|
||||
'https://ghproxy.cc/',
|
||||
'https://gh.monlor.com/',
|
||||
'https://cdn.akaere.online/',
|
||||
'https://gh.idayer.com/',
|
||||
'https://gh.llkk.cc/',
|
||||
'https://ghpxy.hwinzniej.top/',
|
||||
'https://github-proxy.memory-echoes.cn/',
|
||||
'https://git.yylx.win/',
|
||||
'https://gitproxy.mrhjx.cn/',
|
||||
'https://gh.fhjhy.top/',
|
||||
'https://gp.zkitefly.eu.org/',
|
||||
'https://gh-proxy.com/',
|
||||
'https://ghfile.geekertao.top/',
|
||||
'https://j.1lin.dpdns.org/',
|
||||
'https://ghproxy.imciel.com/',
|
||||
'https://github-proxy.teach-english.tech/',
|
||||
'https://gh.927223.xyz/',
|
||||
'https://github.ednovas.xyz/',
|
||||
'https://ghf.xn--eqrr82bzpe.top/',
|
||||
'https://gh.dpik.top/',
|
||||
'https://gh.jasonzeng.dev/',
|
||||
'https://gh.xxooo.cf/',
|
||||
'https://gh.bugdey.us.kg/',
|
||||
'https://ghm.078465.xyz/',
|
||||
'https://j.1win.ggff.net/',
|
||||
'https://tvv.tw/',
|
||||
'https://gitproxy.127731.xyz/',
|
||||
'https://gh.inkchills.cn/',
|
||||
'https://ghproxy.cxkpro.top/',
|
||||
'https://gh.sixyin.com/',
|
||||
'https://github.geekery.cn/',
|
||||
'https://git.669966.xyz/',
|
||||
'https://gh.5050net.cn/',
|
||||
'https://gh.felicity.ac.cn/',
|
||||
'https://github.dpik.top/',
|
||||
'https://ghp.keleyaa.com/',
|
||||
'https://gh.wsmdn.dpdns.org/',
|
||||
'https://ghproxy.monkeyray.net/',
|
||||
'https://fastgit.cc/',
|
||||
'https://gh.catmak.name/',
|
||||
'https://gh.noki.icu/',
|
||||
'', // 原始 URL(无镜像)
|
||||
];
|
||||
|
||||
@ -109,7 +93,6 @@ export const GITHUB_RAW_MIRRORS = [
|
||||
// 测试确认支持 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',
|
||||
];
|
||||
@ -650,8 +633,15 @@ function compareSemVerSimple (a: string, b: string): number {
|
||||
* 从 tags 列表中获取最新的 release tag
|
||||
* 不依赖 GitHub API
|
||||
*/
|
||||
export async function getLatestReleaseTag (owner: string, repo: string): Promise<string> {
|
||||
const result = await getAllGitHubTags(owner, repo);
|
||||
// Update definitions validation locally first if possible.
|
||||
// I'll assume valid typescript.
|
||||
// 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
|
||||
const releaseTags = result.tags.filter(tag => SEMVER_REGEX.test(tag));
|
||||
@ -701,6 +691,8 @@ export async function getGitHubRelease (
|
||||
assetNames?: string[];
|
||||
/** 是否需要获取 changelog(需要调用 API) */
|
||||
fetchChangelog?: boolean;
|
||||
/** 指定镜像 */
|
||||
mirror?: string;
|
||||
} = {}
|
||||
): Promise<{
|
||||
tag_name: string;
|
||||
@ -710,15 +702,16 @@ export async function getGitHubRelease (
|
||||
}>;
|
||||
body?: string;
|
||||
}> {
|
||||
const { assetNames = [], fetchChangelog = false } = options;
|
||||
const { assetNames = [], fetchChangelog = false, mirror } = options;
|
||||
|
||||
// 1. 获取实际的 tag 名称
|
||||
let actualTag: string;
|
||||
if (tag === 'latest') {
|
||||
actualTag = await getLatestReleaseTag(owner, repo);
|
||||
actualTag = await getLatestReleaseTag(owner, repo, mirror);
|
||||
} else {
|
||||
actualTag = tag;
|
||||
}
|
||||
// ...
|
||||
|
||||
// 2. 构建 assets 列表(不需要 API)
|
||||
const assets = assetNames.map(name => ({
|
||||
@ -782,8 +775,8 @@ 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}`;
|
||||
export async function getAllGitHubTags (owner: string, repo: string, mirror?: string): Promise<{ tags: string[], mirror: string; }> {
|
||||
const cacheKey = `${owner}/${repo}/${mirror || 'auto'}`;
|
||||
|
||||
// 检查缓存
|
||||
const cached = tagsCache.get(cacheKey);
|
||||
@ -805,7 +798,7 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
|
||||
};
|
||||
|
||||
// 尝试从 URL 获取 tags
|
||||
const fetchFromUrl = async (url: string, mirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
|
||||
const fetchFromUrl = async (url: string, usedMirror: string): Promise<{ tags: string[], mirror: string; } | null> => {
|
||||
try {
|
||||
const raw = await PromiseTimer(
|
||||
RequestUtil.HttpGetText(url),
|
||||
@ -813,79 +806,55 @@ export async function getAllGitHubTags (owner: string, repo: string): Promise<{
|
||||
);
|
||||
|
||||
// 检查返回内容是否有效(不是 HTML 错误页面)
|
||||
if (raw.includes('<!DOCTYPE') || raw.includes('<html')) {
|
||||
return null;
|
||||
if (raw.includes('refs/tags')) {
|
||||
return { tags: parseTags(raw), mirror: usedMirror };
|
||||
}
|
||||
|
||||
const tags = parseTags(raw);
|
||||
if (tags.length > 0) {
|
||||
return { tags, mirror };
|
||||
}
|
||||
return null;
|
||||
} catch {
|
||||
return null;
|
||||
// 忽略错误
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
// 获取快速镜像列表
|
||||
let fastMirrors: string[] = [];
|
||||
try {
|
||||
fastMirrors = await getFastMirrors();
|
||||
} catch {
|
||||
// 忽略错误
|
||||
// 准备镜像列表
|
||||
let mirrors: string[] = [];
|
||||
if (mirror) {
|
||||
// 如果指定了镜像,只使用该镜像
|
||||
mirrors = [mirror];
|
||||
} else {
|
||||
// 否则使用 auto 逻辑
|
||||
mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
|
||||
}
|
||||
|
||||
// 构建 URL 列表(取前 5 个快速镜像 + 原始 URL 并行请求)
|
||||
const topMirrors = fastMirrors.slice(0, 5);
|
||||
const mirrorUrls = [
|
||||
{ url: baseUrl, mirror: 'github.com' }, // 原始 URL
|
||||
...topMirrors.filter(m => m).map(m => ({ url: buildMirrorUrl(baseUrl, m), mirror: m })),
|
||||
];
|
||||
// 并行请求
|
||||
const promises = mirrors.map(m => {
|
||||
const url = m ? buildMirrorUrl(baseUrl, m) : baseUrl;
|
||||
return fetchFromUrl(url, m || 'https://github.com');
|
||||
});
|
||||
|
||||
// 并行请求所有镜像,使用 Promise.any 获取第一个成功的结果
|
||||
try {
|
||||
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);
|
||||
const result = await Promise.any(promises.filter(p => p !== null) as Promise<{ tags: string[], mirror: string; } | null>[]);
|
||||
if (result) {
|
||||
tagsCache.set(cacheKey, { tags: result.tags, mirror: result.mirror, timestamp: Date.now() });
|
||||
tagsCache.set(cacheKey, {
|
||||
tags: result.tags,
|
||||
mirror: result.mirror,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
return result;
|
||||
}
|
||||
} catch {
|
||||
// all failed
|
||||
}
|
||||
|
||||
// 最后尝试所有镜像
|
||||
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;
|
||||
}
|
||||
if (mirror) {
|
||||
throw new Error(`指定镜像 ${mirror} 获取 tags 失败`);
|
||||
}
|
||||
|
||||
throw new Error('无法获取 tags,所有源都不可用');
|
||||
throw new Error('无法获取 tags,所有镜像源都不可用');
|
||||
}
|
||||
|
||||
// ============== Action Artifacts 支持 ==============
|
||||
|
||||
// ActionArtifact 接口定义
|
||||
export interface ActionArtifact {
|
||||
id: number;
|
||||
name: string;
|
||||
@ -895,12 +864,14 @@ export interface ActionArtifact {
|
||||
archive_download_url: string;
|
||||
workflow_run_id?: number;
|
||||
head_sha?: string;
|
||||
workflow_title?: string;
|
||||
}
|
||||
|
||||
// ============== Action Artifacts 缓存 ==============
|
||||
|
||||
interface ArtifactsCache {
|
||||
artifacts: ActionArtifact[];
|
||||
mirror: string;
|
||||
timestamp: number;
|
||||
}
|
||||
|
||||
@ -920,68 +891,115 @@ export function clearArtifactsCache (): void {
|
||||
* 当 api.github.com 不可用时使用
|
||||
* 页面格式: https://github.com/{owner}/{repo}/actions/workflows/{workflow}
|
||||
*/
|
||||
|
||||
async function getWorkflowRunsFromHtml (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
maxRuns: number = 10
|
||||
): Promise<Array<{ id: number; created_at: string; }>> {
|
||||
maxRuns: number = 10,
|
||||
mirror?: string
|
||||
): Promise<{ runs: Array<{ id: number; created_at: string; title: string; }>; mirror: string; }> {
|
||||
const baseUrl = `https://github.com/${owner}/${repo}/actions/workflows/${workflow}`;
|
||||
|
||||
// 尝试使用镜像获取 HTML
|
||||
const mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
|
||||
// 如果指定了 mirror,则只使用该 mirror
|
||||
let mirrors: string[] = [];
|
||||
if (mirror) {
|
||||
mirrors = [mirror];
|
||||
} else {
|
||||
mirrors = ['', ...currentConfig.fileMirrors.filter(m => m)];
|
||||
}
|
||||
|
||||
for (const mirror of mirrors) {
|
||||
for (const mirrorItem of mirrors) {
|
||||
try {
|
||||
const url = mirror ? buildMirrorUrl(baseUrl, mirror) : baseUrl;
|
||||
|
||||
const html = await PromiseTimer(
|
||||
RequestUtil.HttpGetText(url),
|
||||
10000
|
||||
);
|
||||
|
||||
// 从 HTML 中提取 run IDs 和时间
|
||||
// 格式: href="/NapNeko/NapCatQQ/actions/runs/20676123968"
|
||||
// 时间格式: <relative-time datetime="2026-01-03T10:37:29Z"
|
||||
const runPattern = new RegExp(`href="/${owner}/${repo}/actions/runs/(\\d+)"`, 'gi');
|
||||
const timePattern = /<relative-time\s+datetime="([^"]+)"/gi;
|
||||
|
||||
// 提取所有时间
|
||||
const times: string[] = [];
|
||||
let timeMatch;
|
||||
while ((timeMatch = timePattern.exec(html)) !== null) {
|
||||
times.push(timeMatch[1]);
|
||||
}
|
||||
|
||||
const runs: Array<{ id: number; created_at: string; }> = [];
|
||||
const allRuns: Array<{ id: number; created_at: string; title: string; }> = [];
|
||||
const foundIds = new Set<number>();
|
||||
let timeIndex = 0;
|
||||
let page = 1;
|
||||
const maxPages = 10; // 防止无限请求,最多翻10页(约250个条目)
|
||||
|
||||
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,
|
||||
});
|
||||
while (allRuns.length < maxRuns && page <= maxPages) {
|
||||
const pageUrl = page > 1 ? `${baseUrl}?page=${page}` : baseUrl;
|
||||
const url = mirrorItem ? buildMirrorUrl(pageUrl, mirrorItem) : pageUrl;
|
||||
|
||||
const html = await PromiseTimer(
|
||||
RequestUtil.HttpGetText(url),
|
||||
10000
|
||||
);
|
||||
|
||||
// 使用 Block 分割策略,更稳健地关联 ID 和时间
|
||||
const rows = html.split('<div class="Box-row');
|
||||
let foundOnThisPage = 0;
|
||||
|
||||
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 (runs.length > 0) {
|
||||
return runs;
|
||||
if (allRuns.length > 0) {
|
||||
return { runs: allRuns, mirror: mirrorItem || 'https://github.com' };
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
return [];
|
||||
return { runs: [], mirror: '' };
|
||||
}
|
||||
|
||||
/**
|
||||
@ -996,122 +1014,49 @@ async function getArtifactsFromNightlyLink (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
branch: string = 'main',
|
||||
maxRuns: number = 10
|
||||
): Promise<ActionArtifact[]> {
|
||||
let workflowRuns: Array<{ id: number; head_sha?: string; created_at: string; }> = [];
|
||||
|
||||
// 策略1: 优先尝试 GitHub API
|
||||
_branch: string = 'main',
|
||||
maxRuns: number = 10,
|
||||
mirror?: string
|
||||
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||||
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||||
try {
|
||||
const endpoint = `https://api.github.com/repos/${owner}/${repo}/actions/workflows/${workflow}/runs?branch=${branch}&status=success&per_page=${maxRuns}`;
|
||||
// 以前尝试使用 GitHub API,现在弃用,完全使用 HTML 解析逻辑
|
||||
// 并获取 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 (runsResponse.workflow_runs && runsResponse.workflow_runs.length > 0) {
|
||||
workflowRuns = runsResponse.workflow_runs;
|
||||
if (workflowRuns.length === 0) {
|
||||
return { artifacts: [], mirror: runsMirror };
|
||||
}
|
||||
} catch {
|
||||
// API 请求失败,继续尝试 HTML 解析
|
||||
}
|
||||
|
||||
// 策略2: API 失败时,从 HTML 页面解析
|
||||
if (workflowRuns.length === 0) {
|
||||
workflowRuns = await getWorkflowRunsFromHtml(owner, repo, workflow, maxRuns);
|
||||
}
|
||||
// 直接拼接 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 名称
|
||||
|
||||
if (workflowRuns.length === 0) {
|
||||
return [];
|
||||
}
|
||||
// 如果 HTML 解析使用的 mirror 是 github.com(空),则 nightly.link 使用默认配置
|
||||
// 如果使用了镜像,可能需要特殊的 nightly.link 镜像,或者这里仅记录 HTML 来源镜像
|
||||
// 实际上 nightly.link 本身就是一个服务,我们使用配置中的 nightlyLinkMirrors
|
||||
const baseNightlyMirror = currentConfig.nightlyLinkMirrors[0] || 'https://nightly.link';
|
||||
|
||||
// 直接拼接 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);
|
||||
}
|
||||
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,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
// 单个 run 获取失败,继续下一个
|
||||
}
|
||||
}
|
||||
return { artifacts, mirror: runsMirror };
|
||||
|
||||
return allArtifacts;
|
||||
} catch {
|
||||
return { artifacts: [], mirror: '' };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@ -1121,48 +1066,41 @@ async function getArtifactsFromAPI (
|
||||
* 策略:
|
||||
* 1. 检查缓存(10分钟有效)
|
||||
* 2. 优先尝试从 nightly.link 获取(无需认证,更稳定)
|
||||
* 3. 如果失败,回退到 GitHub API
|
||||
* 3. 这里的实现已经完全移除了对 GitHub API 的依赖,直接解析 HTML
|
||||
*/
|
||||
export async function getLatestActionArtifacts (
|
||||
owner: string,
|
||||
repo: string,
|
||||
workflow: string = 'build.yml',
|
||||
branch: string = 'main',
|
||||
maxRuns: number = 10
|
||||
): Promise<ActionArtifact[]> {
|
||||
const cacheKey = `${owner}/${repo}/${workflow}/${branch}`;
|
||||
maxRuns: number = 10,
|
||||
mirror?: string
|
||||
): Promise<{ artifacts: ActionArtifact[], mirror: string; }> {
|
||||
const cacheKey = `${owner}/${repo}/${workflow}/${branch}/${mirror || 'auto'}`;
|
||||
|
||||
// 检查缓存
|
||||
const cached = artifactsCache.get(cacheKey);
|
||||
if (cached && (Date.now() - cached.timestamp) < ARTIFACTS_CACHE_TTL) {
|
||||
return cached.artifacts;
|
||||
return { artifacts: cached.artifacts, mirror: cached.mirror };
|
||||
}
|
||||
|
||||
let artifacts: ActionArtifact[] = [];
|
||||
let result: { artifacts: ActionArtifact[], mirror: string; } = { artifacts: [], mirror: '' };
|
||||
|
||||
// 策略1: 优先使用 nightly.link(更稳定,无需认证)
|
||||
// 策略: 优先使用 nightly.link(更稳定,无需认证)+ HTML 解析
|
||||
try {
|
||||
artifacts = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns);
|
||||
result = await getArtifactsFromNightlyLink(owner, repo, workflow, branch, maxRuns, mirror);
|
||||
} catch {
|
||||
// nightly.link 获取失败
|
||||
}
|
||||
|
||||
// 策略2: 回退到 GitHub API
|
||||
if (artifacts.length === 0) {
|
||||
try {
|
||||
artifacts = await getArtifactsFromAPI(owner, repo, workflow, branch, maxRuns);
|
||||
} catch {
|
||||
// API 获取失败
|
||||
}
|
||||
// 获取失败
|
||||
}
|
||||
|
||||
// 缓存结果(即使为空也缓存,避免频繁请求)
|
||||
if (artifacts.length > 0) {
|
||||
if (result.artifacts.length > 0) {
|
||||
artifactsCache.set(cacheKey, {
|
||||
artifacts,
|
||||
artifacts: result.artifacts,
|
||||
mirror: result.mirror,
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
}
|
||||
|
||||
return artifacts;
|
||||
return result;
|
||||
}
|
||||
|
||||
@ -65,9 +65,11 @@ 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.valid && !b.valid) {
|
||||
return v1.localeCompare(v2) as -1 | 0 | 1;
|
||||
}
|
||||
if (!a.valid) return -1;
|
||||
if (!b.valid) return 1;
|
||||
|
||||
// 比较主版本号
|
||||
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
|
||||
|
||||
@ -3,6 +3,8 @@ import { NapCatPathWrapper } from '@/napcat-common/src/path';
|
||||
import { LogWrapper } from '@/napcat-core/helper/log';
|
||||
import { connectToNamedPipe } from './pipe';
|
||||
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 path from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
@ -18,6 +20,13 @@ const ENV = {
|
||||
isPipeDisabled: process.env['NAPCAT_DISABLE_PIPE'] === '1',
|
||||
} as const;
|
||||
|
||||
// Worker 消息类型
|
||||
interface WorkerMessage {
|
||||
type: 'restart' | 'restart-prepare' | 'shutdown';
|
||||
secretKey?: string;
|
||||
port?: number;
|
||||
}
|
||||
|
||||
// 初始化日志
|
||||
const pathWrapper = new NapCatPathWrapper();
|
||||
const logger = new LogWrapper(pathWrapper.logsPath);
|
||||
@ -27,6 +36,7 @@ let processManager: IProcessManager | null = null;
|
||||
let currentWorker: IWorkerProcess | null = null;
|
||||
let isElectron = false;
|
||||
let isRestarting = false;
|
||||
let isShuttingDown = false;
|
||||
|
||||
/**
|
||||
* 获取进程类型名称(用于日志)
|
||||
@ -62,17 +72,24 @@ function isProcessAlive (pid: number): boolean {
|
||||
function forceKillProcess (pid: number): void {
|
||||
try {
|
||||
process.kill(pid, 'SIGKILL');
|
||||
logger.log(`[NapCat] [Process] 已强制终止进程 ${pid}`);
|
||||
} catch (error) {
|
||||
logger.logError(`[NapCat] [Process] 强制终止进程失败:`, error);
|
||||
// SIGKILL 失败,在 Windows 上使用 taskkill 兜底
|
||||
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 进程
|
||||
*/
|
||||
export async function restartWorker (): Promise<void> {
|
||||
logger.log('[NapCat] [Process] 正在重启Worker进程...');
|
||||
export async function restartWorker (secretKey?: string, port?: number): Promise<void> {
|
||||
isRestarting = true;
|
||||
|
||||
if (!currentWorker) {
|
||||
@ -83,7 +100,6 @@ export async function restartWorker (): Promise<void> {
|
||||
}
|
||||
|
||||
const workerPid = currentWorker.pid;
|
||||
logger.log(`[NapCat] [Process] 准备关闭Worker进程,PID: ${workerPid}`);
|
||||
|
||||
// 1. 通知旧进程准备重启(旧进程会自行退出)
|
||||
currentWorker.postMessage({ type: 'restart-prepare' });
|
||||
@ -104,47 +120,35 @@ export async function restartWorker (): Promise<void> {
|
||||
|
||||
currentWorker?.once('exit', () => {
|
||||
clearTimeout(timeout);
|
||||
logger.log('[NapCat] [Process] Worker进程已正常退出');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
// 3. 二次确认进程是否真的被终止(兜底检查)
|
||||
if (workerPid) {
|
||||
logger.log(`[NapCat] [Process] 检查进程 ${workerPid} 是否已终止...`);
|
||||
|
||||
if (workerPid && isProcessAlive(workerPid)) {
|
||||
logger.logWarn(`[NapCat] [Process] 进程 ${workerPid} 仍在运行,尝试强制杀掉`);
|
||||
forceKillProcess(workerPid);
|
||||
await new Promise(resolve => setTimeout(resolve, 1000));
|
||||
if (isProcessAlive(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} 已确认终止`);
|
||||
logger.logError(`[NapCat] [Process] 进程 ${workerPid} 无法终止,可能需要手动处理`);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. 等待 3 秒后启动新进程
|
||||
logger.log('[NapCat] [Process] Worker进程已关闭,等待 3 秒后启动新进程...');
|
||||
// 4. 等待后启动新进程
|
||||
await new Promise(resolve => setTimeout(resolve, 3000));
|
||||
|
||||
// 5. 启动新进程(重启模式不传递快速登录参数)
|
||||
await startWorker(false);
|
||||
// 5. 启动新进程(重启模式不传递快速登录参数,传递密钥和端口)
|
||||
await startWorker(false, secretKey, port);
|
||||
isRestarting = false;
|
||||
logger.log('[NapCat] [Process] Worker进程重启完成');
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动 Worker 进程
|
||||
* @param passQuickLogin 是否传递快速登录参数,默认为 true,重启时为 false
|
||||
* @param secretKey WebUI JWT 密钥
|
||||
* @param preferredPort 优先使用的 WebUI 端口
|
||||
*/
|
||||
async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
||||
async function startWorker (passQuickLogin: boolean = true, secretKey?: string, preferredPort?: number): Promise<void> {
|
||||
if (!processManager) {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
@ -170,6 +174,8 @@ async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
||||
env: {
|
||||
...process.env,
|
||||
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'],
|
||||
});
|
||||
@ -192,14 +198,14 @@ async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
||||
|
||||
// 监听子进程消息
|
||||
child.on('message', (msg: unknown) => {
|
||||
logger.log(`[NapCat] [${processType}] 收到Worker消息:`, msg);
|
||||
|
||||
// 处理重启请求
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg && msg.type === 'restart') {
|
||||
logger.log(`[NapCat] [${processType}] 收到重启请求,正在重启Worker进程...`);
|
||||
restartWorker().catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
|
||||
const message = msg as WorkerMessage;
|
||||
if (message.type === 'restart') {
|
||||
restartWorker(message.secretKey, message.port).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重启Worker进程失败:`, e);
|
||||
});
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@ -208,11 +214,9 @@ async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
||||
const exitCode = typeof code === 'number' ? code : 0;
|
||||
if (exitCode !== 0) {
|
||||
logger.logError(`[NapCat] [${processType}] Worker进程退出,退出码: ${exitCode}`);
|
||||
} else {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程正常退出`);
|
||||
}
|
||||
// 如果不是由于主动重启引起的退出,尝试自动重新拉起(保留快速登录参数)
|
||||
if (!isRestarting) {
|
||||
// 如果不是由于主动重启或关闭引起的退出,尝试自动重新拉起
|
||||
if (!isRestarting && !isShuttingDown) {
|
||||
logger.logWarn(`[NapCat] [${processType}] Worker进程意外退出,正在尝试重新拉起...`);
|
||||
startWorker(true).catch(e => {
|
||||
logger.logError(`[NapCat] [${processType}] 重新拉起Worker进程失败:`, e);
|
||||
@ -220,8 +224,20 @@ async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
||||
}
|
||||
});
|
||||
|
||||
child.on('spawn', () => {
|
||||
logger.log(`[NapCat] [${processType}] Worker进程已生成`);
|
||||
// 等待进程成功 spawn
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -229,25 +245,19 @@ async function startWorker (passQuickLogin: boolean = true): Promise<void> {
|
||||
* 启动 Master 进程
|
||||
*/
|
||||
async function startMasterProcess (): Promise<void> {
|
||||
const processType = getProcessTypeName();
|
||||
logger.log(`[NapCat] [${processType}] Master进程启动,PID: ${process.pid}`);
|
||||
|
||||
// 连接命名管道(可通过环境变量禁用)
|
||||
if (!ENV.isPipeDisabled) {
|
||||
await connectToNamedPipe(logger).catch(e =>
|
||||
logger.logError('命名管道连接失败', e)
|
||||
);
|
||||
} else {
|
||||
logger.log(`[NapCat] [${processType}] 命名管道已禁用 (NAPCAT_DISABLE_PIPE=1)`);
|
||||
}
|
||||
|
||||
// 启动 Worker 进程
|
||||
await startWorker();
|
||||
|
||||
// 优雅关闭处理
|
||||
const shutdown = (signal: string) => {
|
||||
logger.log(`[NapCat] [Process] 收到${signal}信号,正在关闭...`);
|
||||
|
||||
const shutdown = () => {
|
||||
isShuttingDown = true;
|
||||
if (currentWorker) {
|
||||
currentWorker.postMessage({ type: 'shutdown' });
|
||||
setTimeout(() => {
|
||||
@ -259,8 +269,8 @@ async function startMasterProcess (): Promise<void> {
|
||||
}
|
||||
};
|
||||
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
process.on('SIGINT', () => shutdown());
|
||||
process.on('SIGTERM', () => shutdown());
|
||||
}
|
||||
|
||||
/**
|
||||
@ -271,20 +281,13 @@ async function startWorkerProcess (): Promise<void> {
|
||||
throw new Error('进程管理器未初始化');
|
||||
}
|
||||
|
||||
const processType = getProcessTypeName();
|
||||
logger.log(`[NapCat] [${processType}] Worker进程启动,PID: ${process.pid}`);
|
||||
|
||||
// 监听来自父进程的消息
|
||||
processManager.onParentMessage((msg: unknown) => {
|
||||
if (typeof msg === 'object' && msg !== null && 'type' in msg) {
|
||||
if (msg.type === 'restart-prepare') {
|
||||
logger.log(`[NapCat] [${processType}] 收到重启准备信号,正在主动退出...`);
|
||||
if (msg.type === 'restart-prepare' || msg.type === 'shutdown') {
|
||||
setTimeout(() => {
|
||||
process.exit(0);
|
||||
}, 100);
|
||||
} else if (msg.type === 'shutdown') {
|
||||
logger.log(`[NapCat] [${processType}] 收到关闭信号,正在退出...`);
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
});
|
||||
@ -292,7 +295,11 @@ async function startWorkerProcess (): Promise<void> {
|
||||
// 注册重启进程函数到 WebUI
|
||||
WebUiDataRuntime.setRestartProcessCall(async () => {
|
||||
try {
|
||||
const success = processManager!.sendToParent({ type: 'restart' });
|
||||
const success = processManager!.sendToParent({
|
||||
type: 'restart',
|
||||
secretKey: AuthHelper.getSecretKey(),
|
||||
port: webUiRuntimePort,
|
||||
});
|
||||
|
||||
if (success) {
|
||||
return { result: true, message: '进程重启请求已发送' };
|
||||
@ -318,7 +325,6 @@ async function startWorkerProcess (): Promise<void> {
|
||||
async function main (): Promise<void> {
|
||||
// 单进程模式:直接启动核心
|
||||
if (ENV.isMultiProcessDisabled) {
|
||||
logger.log('[NapCat] [SingleProcess] 多进程模式已禁用,直接启动核心');
|
||||
await NCoreInitShell();
|
||||
return;
|
||||
}
|
||||
@ -328,8 +334,6 @@ async function main (): Promise<void> {
|
||||
processManager = result.manager;
|
||||
isElectron = result.isElectron;
|
||||
|
||||
logger.log(`[NapCat] [Process] 检测到 ${isElectron ? 'Electron' : 'Node.js'} 环境`);
|
||||
|
||||
// 根据进程类型启动
|
||||
if (ENV.isWorkerProcess) {
|
||||
await startWorkerProcess();
|
||||
|
||||
@ -25,6 +25,7 @@ export interface IWorkerProcess {
|
||||
kill (): boolean;
|
||||
on (event: string, listener: (...args: unknown[]) => void): void;
|
||||
once (event: string, listener: (...args: unknown[]) => void): void;
|
||||
off (event: string, listener: (...args: unknown[]) => void): void;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -60,7 +61,7 @@ class ElectronProcessManager implements IProcessManager {
|
||||
const child: any = this.utilityProcess.fork(modulePath, args, options);
|
||||
|
||||
return {
|
||||
pid: child.pid as number | undefined,
|
||||
get pid () { return child.pid as number | undefined; },
|
||||
stdout: child.stdout as Readable | null,
|
||||
stderr: child.stderr as Readable | null,
|
||||
|
||||
@ -79,6 +80,10 @@ class ElectronProcessManager implements IProcessManager {
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
|
||||
off (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.off(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -113,7 +118,7 @@ class NodeProcessManager implements IProcessManager {
|
||||
const child = this.forkFn(modulePath, args, options as any);
|
||||
|
||||
return {
|
||||
pid: child.pid,
|
||||
get pid () { return child.pid; },
|
||||
stdout: child.stdout,
|
||||
stderr: child.stderr,
|
||||
|
||||
@ -134,6 +139,10 @@ class NodeProcessManager implements IProcessManager {
|
||||
once (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.once(event, listener);
|
||||
},
|
||||
|
||||
off (event: string, listener: (...args: unknown[]) => void): void {
|
||||
child.off(event, listener);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -164,6 +173,9 @@ export async function createProcessManager (): Promise<{
|
||||
if (isElectron) {
|
||||
// @ts-ignore - electron 运行时存在但类型声明可能缺失
|
||||
const electron = await import('electron');
|
||||
if (electron.app && !electron.app.isReady()) {
|
||||
await electron.app.whenReady();
|
||||
}
|
||||
return {
|
||||
manager: new ElectronProcessManager(electron.utilityProcess),
|
||||
isElectron: true,
|
||||
|
||||
@ -72,7 +72,19 @@ export function setPendingTokenToSend (token: string | null) {
|
||||
export async function InitPort (parsedConfig: WebUiConfigType): Promise<[string, number, string]> {
|
||||
try {
|
||||
await tryUseHost(parsedConfig.host);
|
||||
const port = await tryUsePort(parsedConfig.port, parsedConfig.host);
|
||||
const preferredPort = parseInt(process.env['NAPCAT_WEBUI_PREFERRED_PORT'] || '', 10);
|
||||
|
||||
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];
|
||||
} catch (error) {
|
||||
console.log('host或port不可用', error);
|
||||
@ -356,7 +368,7 @@ async function tryUseHost (host: string): Promise<string> {
|
||||
});
|
||||
}
|
||||
|
||||
async function tryUsePort (port: number, host: string, tryCount: number = 0): Promise<number> {
|
||||
async function tryUsePort (port: number, host: string, tryCount: number = 0, singleTry: boolean = false): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
try {
|
||||
const server = net.createServer();
|
||||
@ -367,9 +379,12 @@ async function tryUsePort (port: number, host: string, tryCount: number = 0): Pr
|
||||
|
||||
server.on('error', (err: any) => {
|
||||
if (err.code === 'EADDRINUSE') {
|
||||
if (tryCount < MAX_PORT_TRY) {
|
||||
// 使用循环代替递归
|
||||
resolve(tryUsePort(port + 1, host, tryCount + 1));
|
||||
if (singleTry) {
|
||||
// 只尝试一次,端口被占用则直接失败
|
||||
reject(new Error(`端口 ${port} 已被占用`));
|
||||
} else if (tryCount < MAX_PORT_TRY) {
|
||||
// 递归尝试下一个端口
|
||||
resolve(tryUsePort(port + 1, host, tryCount + 1, false));
|
||||
} else {
|
||||
reject(new Error(`端口尝试失败,达到最大尝试次数: ${MAX_PORT_TRY}`));
|
||||
}
|
||||
|
||||
@ -4,7 +4,7 @@ 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, getAllTags, compareSemVer } from 'napcat-common/src/helper';
|
||||
import { getLatestActionArtifacts } from '@/napcat-common/src/mirror';
|
||||
import { getLatestActionArtifacts, getMirrorConfig } from '@/napcat-common/src/mirror';
|
||||
import { NapCatCoreWorkingEnv } from '@/napcat-webui-backend/src/types';
|
||||
|
||||
export const GetNapCatVersion: RequestHandler = (_, res) => {
|
||||
@ -35,6 +35,7 @@ export interface VersionInfo {
|
||||
size?: number;
|
||||
workflowRunId?: number;
|
||||
headSha?: string;
|
||||
workflowTitle?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -47,11 +48,17 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
const pageSize = parseInt(req.query['pageSize'] as string) || 20;
|
||||
const typeFilter = req.query['type'] as string | undefined; // 'release' | 'action' | 'all'
|
||||
const searchQuery = (req.query['search'] as string || '').toLowerCase().trim();
|
||||
const mirror = req.query['mirror'] as string | undefined;
|
||||
|
||||
let versions: VersionInfo[] = [];
|
||||
let actionVersions: VersionInfo[] = [];
|
||||
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 needActions = typeFilter === 'action' || typeFilter === 'all';
|
||||
@ -59,8 +66,11 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
// 获取正式版本(仅当需要时)
|
||||
if (needReleases) {
|
||||
try {
|
||||
const result = await getAllTags();
|
||||
usedMirror = result.mirror;
|
||||
const result = await getAllTags(mirror);
|
||||
// 如果没有指定镜像,使用实际上使用的镜像
|
||||
if (!mirror) {
|
||||
usedMirror = result.mirror;
|
||||
}
|
||||
|
||||
versions = result.tags.map(tag => {
|
||||
const isPrerelease = /-(alpha|beta|rc|dev|pre|snapshot)/i.test(tag);
|
||||
@ -81,14 +91,19 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
// 获取 Action Artifacts(仅当需要时)
|
||||
if (needActions) {
|
||||
try {
|
||||
const artifacts = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main');
|
||||
const { artifacts, mirror: actionMirror } = await getLatestActionArtifacts('NapNeko', 'NapCatQQ', 'build.yml', 'main', 10, mirror);
|
||||
|
||||
// 根据当前工作环境自动过滤对应的 artifact 类型
|
||||
const isFramework = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework;
|
||||
const targetArtifactName = isFramework ? 'NapCat.Framework' : 'NapCat.Shell';
|
||||
|
||||
// 如果没有指定镜像,且 action 实际上用了一个镜像(自动选择的),更新 usedMirror
|
||||
if (!mirror && actionMirror) {
|
||||
usedMirror = actionMirror;
|
||||
}
|
||||
|
||||
actionVersions = artifacts
|
||||
.filter(a => a.name === targetArtifactName)
|
||||
.filter(a => a && a.name === targetArtifactName)
|
||||
.map(a => ({
|
||||
tag: `action-${a.id}`,
|
||||
type: 'action' as const,
|
||||
@ -99,6 +114,7 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
size: a.size_in_bytes,
|
||||
workflowRunId: a.workflow_run_id,
|
||||
headSha: a.head_sha,
|
||||
workflowTitle: a.workflow_title,
|
||||
}));
|
||||
} catch {
|
||||
// 获取失败时返回空列表
|
||||
@ -114,7 +130,9 @@ export const getAllReleasesHandler: RequestHandler = async (req, res) => {
|
||||
allVersions = allVersions.filter(v => {
|
||||
const tagMatch = v.tag.toLowerCase().includes(searchQuery);
|
||||
const nameMatch = v.artifactName?.toLowerCase().includes(searchQuery);
|
||||
return tagMatch || nameMatch;
|
||||
const titleMatch = v.workflowTitle?.toLowerCase().includes(searchQuery);
|
||||
const shaMatch = v.headSha?.toLowerCase().includes(searchQuery);
|
||||
return tagMatch || nameMatch || titleMatch || shaMatch;
|
||||
});
|
||||
}
|
||||
|
||||
@ -155,3 +173,8 @@ export const SetThemeConfigHandler: RequestHandler = async (req, res) => {
|
||||
await WebUiConfig.UpdateTheme(theme);
|
||||
sendSuccess(res, { message: '更新成功' });
|
||||
};
|
||||
|
||||
export const GetMirrorsHandler: RequestHandler = (_, res) => {
|
||||
const config = getMirrorConfig();
|
||||
sendSuccess(res, { mirrors: config.fileMirrors });
|
||||
};
|
||||
|
||||
@ -20,6 +20,8 @@ interface UpdateRequestBody {
|
||||
targetVersion?: string;
|
||||
/** 是否强制更新(即使是降级也更新) */
|
||||
force?: boolean;
|
||||
/** 指定使用的镜像 */
|
||||
mirror?: string;
|
||||
}
|
||||
|
||||
// 更新配置文件接口
|
||||
@ -124,7 +126,7 @@ async function downloadFile (url: string, dest: string): Promise<void> {
|
||||
export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
try {
|
||||
// 从请求体获取目标版本(可选)
|
||||
const { targetVersion, force } = req.body as UpdateRequestBody;
|
||||
const { targetVersion, force, mirror } = req.body as UpdateRequestBody;
|
||||
|
||||
// 确定要下载的文件名
|
||||
const ReleaseName = WebUiDataRuntime.getWorkingEnv() === NapCatCoreWorkingEnv.Framework ? 'NapCat.Framework.zip' : 'NapCat.Shell.zip';
|
||||
@ -164,6 +166,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
validateContent: true,
|
||||
minFileSize: 1024 * 1024,
|
||||
timeout: 10000,
|
||||
customMirror: mirror,
|
||||
});
|
||||
webUiLogger?.log(`[NapCat Update] Using download URL: ${downloadUrl}`);
|
||||
} catch (error) {
|
||||
@ -178,6 +181,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
const release = await getGitHubRelease('NapNeko', 'NapCatQQ', targetTag, {
|
||||
assetNames: [ReleaseName, 'NapCat.Framework.zip', 'NapCat.Shell.zip'],
|
||||
fetchChangelog: false, // 不需要 changelog,避免 API 调用
|
||||
mirror,
|
||||
});
|
||||
|
||||
const shellZipAsset = release.assets.find(asset => asset.name === ReleaseName);
|
||||
@ -193,6 +197,7 @@ export const UpdateNapCatHandler: RequestHandler = async (req, res) => {
|
||||
validateContent: true, // 验证 Content-Type 和状态码
|
||||
minFileSize: 1024 * 1024, // 最小 1MB,确保不是错误页面
|
||||
timeout: 10000, // 10秒超时
|
||||
customMirror: mirror,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -4,6 +4,10 @@ import type { WebUiCredentialJson, WebUiCredentialInnerJson } from '@/napcat-web
|
||||
export class AuthHelper {
|
||||
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 待签名的凭证字符串。
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { Router } from 'express';
|
||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler } from '../api/BaseInfo';
|
||||
import { GetThemeConfigHandler, GetNapCatVersion, QQVersionHandler, SetThemeConfigHandler, getLatestTagHandler, getAllReleasesHandler, GetMirrorsHandler } from '../api/BaseInfo';
|
||||
import { StatusRealTimeHandler } from '@/napcat-webui-backend/src/api/Status';
|
||||
import { GetProxyHandler } from '../api/Proxy';
|
||||
|
||||
@ -9,6 +9,7 @@ router.get('/QQVersion', QQVersionHandler);
|
||||
router.get('/GetNapCatVersion', GetNapCatVersion);
|
||||
router.get('/getLatestTag', getLatestTagHandler);
|
||||
router.get('/getAllReleases', getAllReleasesHandler);
|
||||
router.get('/getMirrors', GetMirrorsHandler);
|
||||
router.get('/GetSysStatusRealTime', StatusRealTimeHandler);
|
||||
router.get('/proxy', GetProxyHandler);
|
||||
router.get('/Theme', GetThemeConfigHandler);
|
||||
|
||||
@ -45,7 +45,7 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
return (
|
||||
<div
|
||||
className={clsx(
|
||||
'flex text-sm gap-3 py-2 items-center transition-colors',
|
||||
'flex text-sm gap-3 py-2 items-baseline transition-colors',
|
||||
hasBackground
|
||||
? 'text-white/90'
|
||||
: 'text-default-600 dark:text-gray-300',
|
||||
@ -53,13 +53,13 @@ const SystemInfoItem: React.FC<SystemInfoItemProps> = ({
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className="text-lg opacity-70">{icon}</div>
|
||||
<div className="text-lg opacity-70 self-center">{icon}</div>
|
||||
<div className='w-24 font-medium'>{title}</div>
|
||||
<div className={clsx(
|
||||
'text-xs font-mono flex-1',
|
||||
hasBackground ? 'text-white/80' : 'text-default-500'
|
||||
)}>{value}</div>
|
||||
<div>{endContent}</div>
|
||||
<div className="self-center">{endContent}</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -84,9 +84,11 @@ 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 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>
|
||||
<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" }}>
|
||||
v{currentVersion}
|
||||
</Chip>
|
||||
<Tooltip content={`v${currentVersion}`}>
|
||||
<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}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-center text-primary-500 px-4 shrink-0">
|
||||
@ -99,9 +101,11 @@ const UpdateDialogContent: React.FC<{
|
||||
|
||||
<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>
|
||||
<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" }}>
|
||||
v{latestVersion}
|
||||
</Chip>
|
||||
<Tooltip content={`v${latestVersion}`}>
|
||||
<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}
|
||||
</Chip>
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -136,13 +140,21 @@ const UpdateDialogContent: React.FC<{
|
||||
</p>
|
||||
</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'>
|
||||
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1'>
|
||||
<p className='text-xs text-warning-700 dark:text-warning-400 flex items-center gap-1 justify-center'>
|
||||
<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' />
|
||||
</svg>
|
||||
<span>请手动重启 NapCat,更新才会生效</span>
|
||||
<span>重启 NapCat 生效</span>
|
||||
</p>
|
||||
</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>
|
||||
)}
|
||||
|
||||
@ -269,6 +281,7 @@ interface VersionInfo {
|
||||
size?: number;
|
||||
workflowRunId?: number;
|
||||
headSha?: string;
|
||||
workflowTitle?: string;
|
||||
}
|
||||
|
||||
// 版本选择对话框内容
|
||||
@ -290,6 +303,14 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
const [activeTab, setActiveTab] = useState<'release' | 'action'>('release');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
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;
|
||||
|
||||
// 获取所有可用版本(带分页、过滤和搜索)
|
||||
@ -299,15 +320,16 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
page: currentPage,
|
||||
pageSize,
|
||||
type: activeTab,
|
||||
search: debouncedSearch
|
||||
search: debouncedSearch,
|
||||
mirror: selectedMirror
|
||||
}),
|
||||
{
|
||||
refreshDeps: [currentPage, activeTab, debouncedSearch],
|
||||
refreshDeps: [currentPage, activeTab, debouncedSearch, selectedMirror],
|
||||
}
|
||||
);
|
||||
|
||||
// 版本列表已在后端过滤,直接使用
|
||||
const filteredVersions = releasesData?.versions || [];
|
||||
const filteredVersions = (releasesData?.versions || []) as VersionInfo[];
|
||||
|
||||
// 检查是否是降级(使用语义化版本比较)
|
||||
const isDowngrade = useCallback((targetTag: string): boolean => {
|
||||
@ -320,6 +342,22 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
const selectedVersionTag = selectedVersion?.tag || '';
|
||||
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 () => {
|
||||
if (!selectedVersion) return;
|
||||
|
||||
@ -346,22 +384,6 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
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);
|
||||
@ -375,13 +397,30 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
<path strokeLinecap='round' strokeLinejoin='round' strokeWidth={2} d='M5 13l4 4L19 7' />
|
||||
</svg>
|
||||
</div>
|
||||
<div className='text-center'>
|
||||
<div className='text-center w-full'>
|
||||
<p className='text-sm font-medium text-success-600 dark:text-success-400'>
|
||||
更新到 {selectedVersionTag} 完成
|
||||
</p>
|
||||
<p className='text-xs text-default-500 mt-1'>
|
||||
<p className='text-xs text-default-500 mt-1 mb-6'>
|
||||
请重启 NapCat 以应用新版本
|
||||
</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>
|
||||
);
|
||||
@ -463,23 +502,46 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
<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="flex gap-2">
|
||||
{/* 搜索框 */}
|
||||
<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',
|
||||
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'>
|
||||
@ -528,7 +590,12 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
>
|
||||
<div className='flex flex-col gap-0.5'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<span>{version.type === 'action' && version.artifactName ? version.artifactName : version.tag}</span>
|
||||
<span className="truncate max-w-[300px]">
|
||||
{version.type === 'action'
|
||||
? (version.workflowTitle || version.artifactName || version.tag)
|
||||
: version.tag
|
||||
}
|
||||
</span>
|
||||
{version.type === 'prerelease' && (
|
||||
<Chip size='sm' color='secondary' variant='flat'>预发布</Chip>
|
||||
)}
|
||||
@ -543,10 +610,11 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
)}
|
||||
</div>
|
||||
{version.type === 'action' && (
|
||||
<div className='text-xs text-default-400'>
|
||||
{version.headSha && <span className='font-mono'>{version.headSha.slice(0, 7)}</span>}
|
||||
{version.createdAt && <span className='ml-2'>{new Date(version.createdAt).toLocaleString()}</span>}
|
||||
{version.size && <span className='ml-2'>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
|
||||
<div className='text-xs text-default-400 flex items-center gap-2'>
|
||||
<span className='font-mono bg-default-100 dark:bg-default-100/10 px-1 rounded'>{version.tag}</span>
|
||||
{version.headSha && <span className='font-mono' title={version.headSha}>{version.headSha.slice(0, 7)}</span>}
|
||||
{version.createdAt && <span>{new Date(version.createdAt).toLocaleString()}</span>}
|
||||
{version.size && <span>{(version.size / 1024 / 1024).toFixed(1)} MB</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@ -568,6 +636,7 @@ const VersionSelectDialogContent: React.FC<VersionSelectDialogProps> = ({
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@ -29,7 +29,7 @@ const SystemStatusItem: React.FC<SystemStatusItemProps> = ({
|
||||
<div
|
||||
className={clsx(
|
||||
'py-1.5 text-sm transition-colors',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between',
|
||||
size === 'lg' ? 'col-span-2' : 'col-span-1 flex justify-between items-center',
|
||||
)}
|
||||
>
|
||||
<div className={clsx(
|
||||
|
||||
@ -72,8 +72,9 @@ export default class WebUIManager {
|
||||
pageSize?: number;
|
||||
type?: 'release' | 'action' | 'all';
|
||||
search?: string;
|
||||
mirror?: string;
|
||||
} = {}) {
|
||||
const { page = 1, pageSize = 20, type = 'release', search = '' } = options;
|
||||
const { page = 1, pageSize = 20, type = 'release', search = '', mirror } = options;
|
||||
const { data } = await serverRequest.get<ServerResponse<{
|
||||
versions: Array<{
|
||||
tag: string;
|
||||
@ -94,15 +95,21 @@ export default class WebUIManager {
|
||||
};
|
||||
mirror?: string;
|
||||
}>>('/base/getAllReleases', {
|
||||
params: { page, pageSize, type, search },
|
||||
params: { page, pageSize, type, search, mirror },
|
||||
});
|
||||
return data.data;
|
||||
}
|
||||
|
||||
public static async UpdateNapCat () {
|
||||
public static async getMirrors () {
|
||||
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>>(
|
||||
'/UpdateNapCat/update',
|
||||
{},
|
||||
{ mirror },
|
||||
{ timeout: 120000 } // 2分钟超时
|
||||
);
|
||||
return data;
|
||||
@ -112,11 +119,12 @@ export default class WebUIManager {
|
||||
* 更新到指定版本
|
||||
* @param targetVersion 目标版本 tag,如 "v4.9.9" 或 "action-123456"
|
||||
* @param force 是否强制更新(允许降级)
|
||||
* @param mirror 指定使用的镜像
|
||||
*/
|
||||
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) {
|
||||
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false, mirror?: string) {
|
||||
const { data } = await serverRequest.post<ServerResponse<any>>(
|
||||
'/UpdateNapCat/update',
|
||||
{ targetVersion, force },
|
||||
{ targetVersion, force, mirror },
|
||||
{ timeout: 120000 } // 2分钟超时
|
||||
);
|
||||
return data;
|
||||
@ -142,6 +150,16 @@ export default class WebUIManager {
|
||||
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 () {
|
||||
const { data } =
|
||||
await serverRequest.get<ServerResponse<string[]>>('/Log/GetLogList');
|
||||
|
||||
Loading…
Reference in New Issue
Block a user