Refactor GitHub tag fetching and mirror management

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

View File

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

View File

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

View File

@@ -54,11 +54,68 @@ export default class WebUIManager {
return data.data;
}
/**
* 版本信息接口
*/
static readonly VersionTypes = {
RELEASE: 'release',
PRERELEASE: 'prerelease',
ACTION: 'action',
} as const;
/**
* 获取所有可用的版本列表(支持分页、过滤和搜索)
*/
public static async getAllReleases (options: {
page?: number;
pageSize?: number;
includeActions?: boolean;
type?: 'release' | 'action' | 'all';
search?: string;
} = {}) {
const { page = 1, pageSize = 20, includeActions = true, type = 'all', search = '' } = options;
const { data } = await serverRequest.get<ServerResponse<{
versions: Array<{
tag: string;
type: 'release' | 'prerelease' | 'action';
artifactId?: number;
artifactName?: string;
createdAt?: string;
expiresAt?: string;
size?: number;
}>;
pagination: {
page: number;
pageSize: number;
total: number;
totalPages: number;
};
mirror?: string;
}>>('/base/getAllReleases', {
params: { page, pageSize, includeActions, type, search },
});
return data.data;
}
public static async UpdateNapCat () {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update',
{},
{ timeout: 60000 } // 1分钟超时
{ timeout: 120000 } // 2分钟超时
);
return data;
}
/**
* 更新到指定版本
* @param targetVersion 目标版本 tag如 "v4.9.9" 或 "action-123456"
* @param force 是否强制更新(允许降级)
*/
public static async UpdateNapCatToVersion (targetVersion: string, force: boolean = false) {
const { data } = await serverRequest.post<ServerResponse<any>>(
'/UpdateNapCat/update',
{ targetVersion, force },
{ timeout: 120000 } // 2分钟超时
);
return data;
}

View File

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

View File

@@ -1,22 +1,59 @@
/**
* 版本号转为数字
* SemVer 2.0 正则表达式
* 格式: 主版本号.次版本号.修订号[-先行版本号][+版本编译信息]
* 参考: https://semver.org/lang/zh-CN/
*/
const SEMVER_REGEX = /^v?(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$/;
interface SemVerInfo {
valid: boolean;
normalized: string;
major: number;
minor: number;
patch: number;
prerelease: string | null;
buildmetadata: string | null;
}
/**
* 解析版本号
* @param version 版本字符串
* @returns SemVer 解析结果
*/
export const parseSemVer = (version: string | undefined | null): SemVerInfo => {
if (!version || typeof version !== 'string') {
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
}
const match = version.trim().match(SEMVER_REGEX);
if (match) {
const major = parseInt(match[1]!, 10);
const minor = parseInt(match[2]!, 10);
const patch = parseInt(match[3]!, 10);
const prerelease = match[4] || null;
const buildmetadata = match[5] || null;
let normalized = `${major}.${minor}.${patch}`;
if (prerelease) normalized += `-${prerelease}`;
if (buildmetadata) normalized += `+${buildmetadata}`;
return { valid: true, normalized, major, minor, patch, prerelease, buildmetadata };
}
return { valid: false, normalized: '1.0.0-dev', major: 1, minor: 0, patch: 0, prerelease: 'dev', buildmetadata: null };
};
/**
* 版本号转为数字 (兼容旧代码)
* @param version 版本号
* @returns 版本号数字
*/
export const versionToNumber = (version: string): number => {
const finalVersionString = version.replace(/^v/, '');
const versionArray = finalVersionString.split('.');
const versionNumber =
parseInt(versionArray[2]) +
parseInt(versionArray[1]) * 100 +
parseInt(versionArray[0]) * 10000;
return versionNumber;
const info = parseSemVer(version);
return info.patch + info.minor * 100 + info.major * 10000;
};
/**
* 比较版本号
* 比较版本号 (SemVer 2.0 规范)
* @param version1 版本号1
* @param version2 版本号2
* @returns 比较结果
@@ -24,13 +61,73 @@ export const versionToNumber = (version: string): number => {
* 1: version1 > version2
* -1: version1 < version2
*/
export const compareVersion = (version1: string, version2: string): number => {
const versionNumber1 = versionToNumber(version1);
const versionNumber2 = versionToNumber(version2);
export const compareVersion = (version1: string, version2: string): -1 | 0 | 1 => {
const a = parseSemVer(version1);
const b = parseSemVer(version2);
if (versionNumber1 === versionNumber2) {
if (!a.valid || !b.valid) {
return 0;
}
return versionNumber1 > versionNumber2 ? 1 : -1;
// 比较主版本号
if (a.major !== b.major) return a.major > b.major ? 1 : -1;
// 比较次版本号
if (a.minor !== b.minor) return a.minor > b.minor ? 1 : -1;
// 比较修订号
if (a.patch !== b.patch) return a.patch > b.patch ? 1 : -1;
// 有先行版本号的版本优先级较低
if (a.prerelease && !b.prerelease) return -1;
if (!a.prerelease && b.prerelease) return 1;
// 两者都有先行版本号时,按规则比较
if (a.prerelease && b.prerelease) {
const aParts = a.prerelease.split('.');
const bParts = b.prerelease.split('.');
const len = Math.max(aParts.length, bParts.length);
for (let i = 0; i < len; i++) {
const aPart = aParts[i];
const bPart = bParts[i];
if (aPart === undefined) return -1;
if (bPart === undefined) return 1;
const aNum = /^\d+$/.test(aPart) ? parseInt(aPart, 10) : NaN;
const bNum = /^\d+$/.test(bPart) ? parseInt(bPart, 10) : NaN;
// 数字 vs 数字
if (!isNaN(aNum) && !isNaN(bNum)) {
if (aNum !== bNum) return aNum > bNum ? 1 : -1;
continue;
}
// 数字优先级低于字符串
if (!isNaN(aNum)) return -1;
if (!isNaN(bNum)) return 1;
// 字符串 vs 字符串
if (aPart !== bPart) return aPart > bPart ? 1 : -1;
}
}
return 0;
};
/**
* 判断是否有新版本可用
* 只比较正式版本 (不带先行版本号的)
* 当前版本是先行版本时,与相同基础版本的正式版相比认为需要更新
* @param currentVersion 当前版本
* @param latestVersion 最新版本 (release tag)
* @returns 是否有新版本
*/
export const hasNewVersion = (currentVersion: string, latestVersion: string): boolean => {
const current = parseSemVer(currentVersion);
const latest = parseSemVer(latestVersion);
if (!current.valid || !latest.valid) {
return false;
}
// 使用 compareVersion 比较
return compareVersion(latestVersion, currentVersion) > 0;
};