diff --git a/src/components/ReleaseCard.tsx b/src/components/ReleaseCard.tsx index 831a5f54..e4c04a83 100644 --- a/src/components/ReleaseCard.tsx +++ b/src/components/ReleaseCard.tsx @@ -24,6 +24,7 @@ interface DownloadLink { size: number; downloadCount: number; isSourceCode?: boolean; + assetId?: number; } interface ReleaseCardProps { @@ -364,6 +365,9 @@ const ReleaseCard: React.FC = memo(({ const isRpcEnabled = rpcDownloadConfig.enabled; const isDownloading = downloadingRef.current[link.url]; const isDownloaded = downloadedRef.current[link.url]; + const isAssetUpdated = isUnread + && link.assetId !== undefined + && release.updated_asset_ids?.includes(link.assetId) === true; if (isRpcEnabled) { return ( @@ -393,6 +397,11 @@ const ReleaseCard: React.FC = memo(({
+ {isAssetUpdated && ( + + {t('资产已更新', 'Asset updated')} + + )} {link.size > 0 && ( {formatFileSize(link.size)} )} @@ -426,6 +435,11 @@ const ReleaseCard: React.FC = memo(({
+ {isAssetUpdated && ( + + {t('资产已更新', 'Asset updated')} + + )} {link.size > 0 && ( {formatFileSize(link.size)} )} diff --git a/src/components/ReleaseTimeline.tsx b/src/components/ReleaseTimeline.tsx index 363a7dc6..d5f9f35b 100644 --- a/src/components/ReleaseTimeline.tsx +++ b/src/components/ReleaseTimeline.tsx @@ -25,6 +25,7 @@ import { import { effectiveReleaseTime, findReleasesWithChangedAssets, + latestEffectiveRelease, shouldShowAssetsUpdatedIndicator, } from '../utils/releaseAssets'; @@ -169,7 +170,7 @@ export const ReleaseTimeline: React.FC = () => { }; const getDownloadLinks = useCallback((release: Release) => { - const links: Array<{ name: string; url: string; size: number; downloadCount: number; isSourceCode?: boolean }> = []; + const links: Array<{ name: string; url: string; size: number; downloadCount: number; isSourceCode?: boolean; assetId?: number }> = []; if (release.assets && release.assets.length > 0) { release.assets.forEach(asset => { @@ -177,7 +178,8 @@ export const ReleaseTimeline: React.FC = () => { name: asset.name, url: asset.browser_download_url, size: asset.size, - downloadCount: asset.download_count + downloadCount: asset.download_count, + assetId: asset.id, }); }); } @@ -346,10 +348,24 @@ export const ReleaseTimeline: React.FC = () => { } }); - // 按最新发布时间排序仓库组 - return Array.from(groups.values()).sort((a, b) => - new Date(b.latestRelease.published_at).getTime() - new Date(a.latestRelease.published_at).getTime() - ); + // 仓库容器的更新时间应覆盖所有可见 Release 的发布时间和资产更新时间。 + // latestRelease 仍用于展示“最新版本”标签,避免改变版本标签的语义。 + return Array.from(groups.values()) + .map(group => ({ + ...group, + latestUpdatedRelease: latestEffectiveRelease( + group.releases.map(({ release }) => release), + ), + })) + .sort((a, b) => { + const aTime = a.latestUpdatedRelease + ? new Date(effectiveReleaseTime(a.latestUpdatedRelease)).getTime() + : -Infinity; + const bTime = b.latestUpdatedRelease + ? new Date(effectiveReleaseTime(b.latestUpdatedRelease)).getTime() + : -Infinity; + return bTime - aTime; + }); }, [filteredReleases]); // 根据视图模式计算分页 @@ -1232,12 +1248,14 @@ export const ReleaseTimeline: React.FC = () => { }) ) : ( // 仓库分类视图 - paginatedRepositoryGroups.map(({ repository, releases, latestRelease }) => { + paginatedRepositoryGroups.map(({ repository, releases, latestRelease, latestUpdatedRelease }) => { const isExpanded = expandedRepositories.has(repository.id); const hasUnread = releases.some(({ release }) => isReleaseUnread(release.id)); - const latestEffectiveTime = latestRelease ? effectiveReleaseTime(latestRelease) : null; - const latestAssetsUpdated = latestRelease !== null - && shouldShowAssetsUpdatedIndicator(latestRelease, isReleaseUnread(latestRelease.id)); + const latestEffectiveTime = latestUpdatedRelease + ? effectiveReleaseTime(latestUpdatedRelease) + : null; + const latestAssetsUpdated = latestUpdatedRelease !== null + && shouldShowAssetsUpdatedIndicator(latestUpdatedRelease, isReleaseUnread(latestUpdatedRelease.id)); return (
@@ -1272,14 +1290,16 @@ export const ReleaseTimeline: React.FC = () => {

{t('最新:', 'Latest:')} {latestRelease.tag_name}

-

- {formatDistanceToNow(new Date(latestEffectiveTime!), { addSuffix: true, locale: language === 'zh' ? zhCN : undefined })} - {latestAssetsUpdated && ( - - {t('资产已更新', 'Assets updated')} - - )} -

+ {latestEffectiveTime && ( +

+ {formatDistanceToNow(new Date(latestEffectiveTime), { addSuffix: true, locale: language === 'zh' ? zhCN : undefined })} + {latestAssetsUpdated && ( + + {t('资产已更新', 'Assets updated')} + + )} +

+ )} )}
diff --git a/src/store/useAppStore.test.ts b/src/store/useAppStore.test.ts index 09a23f50..c4c5e488 100644 --- a/src/store/useAppStore.test.ts +++ b/src/store/useAppStore.test.ts @@ -158,7 +158,14 @@ describe('useAppStore release add/upsert actions', () => { // 刷新:GitHub 返回同 id 但资产已被替换(updated_at 晚于 published_at) const latest = makeRelease(1, { published_at: publishedAt, - assets: [{ ...assetAtPublish, updated_at: '2026-01-05T00:00:00.000Z' }], + assets: [ + { ...assetAtPublish, updated_at: '2026-01-05T00:00:00.000Z' }, + { + ...assetAtPublish, + id: 999, + name: 'new-app.zip', + }, + ], }); // 复用 handleRefresh 的共享筛选逻辑(findReleasesWithChangedAssets),不依赖已读状态 @@ -169,6 +176,7 @@ describe('useAppStore release add/upsert actions', () => { // 资产更新后:无论之前是否已读,都重置为未读并展示"资产已更新" const merged = useAppStore.getState().releases.find(r => r.id === 1)!; expect(merged.is_read).toBe(false); + expect(merged.updated_asset_ids).toEqual([101, 999]); expect(isUnread()).toBe(true); expect(shouldShowAssetsUpdatedIndicator(merged, isUnread())).toBe(true); diff --git a/src/types/index.ts b/src/types/index.ts index 2de66a81..b8d97dbf 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -65,6 +65,8 @@ export interface Release { full_name: string; name: string; }; + /** IDs of assets added or changed during the latest refresh. */ + updated_asset_ids?: number[]; is_read?: boolean; } diff --git a/src/utils/releaseAssets.test.ts b/src/utils/releaseAssets.test.ts index a8aa77c7..dc7faa07 100644 --- a/src/utils/releaseAssets.test.ts +++ b/src/utils/releaseAssets.test.ts @@ -3,9 +3,11 @@ import type { Release, ReleaseAsset } from '../types'; import { assetFingerprint, assetsFingerprint, + changedAssetIds, effectiveReleaseTime, findReleasesWithChangedAssets, hasAssetsChanged, + latestEffectiveRelease, hasAssetsUpdatedAfterPublish, shouldShowAssetsUpdatedIndicator, } from './releaseAssets'; @@ -95,6 +97,29 @@ describe('hasAssetsChanged', () => { }); }); +describe('changedAssetIds', () => { + it('returns added and fingerprint-changed asset IDs only', () => { + const current = [ + makeAsset({ id: 1, updated_at: '2026-01-01T00:00:00.000Z' }), + makeAsset({ id: 2, updated_at: '2026-01-01T00:00:00.000Z' }), + ]; + const incoming = [ + makeAsset({ id: 1, updated_at: '2026-01-02T00:00:00.000Z' }), + makeAsset({ id: 2, updated_at: '2026-01-01T00:00:00.000Z' }), + makeAsset({ id: 3, updated_at: '2026-01-01T00:00:00.000Z' }), + ]; + + expect(changedAssetIds(current, incoming)).toEqual([1, 3]); + }); + + it('does not mark removed assets because they have no visible asset row', () => { + const current = [makeAsset({ id: 1 }), makeAsset({ id: 2 })]; + const incoming = [makeAsset({ id: 1 })]; + + expect(changedAssetIds(current, incoming)).toEqual([]); + }); +}); + describe('findReleasesWithChangedAssets', () => { const makeRelease = (overrides: Partial = {}): Release => ({ id: 1, @@ -111,7 +136,9 @@ describe('findReleasesWithChangedAssets', () => { it('returns releases whose assets fingerprint changed against local', () => { const local = [makeRelease({ id: 1, assets: [makeAsset({ updated_at: '2026-01-01T00:00:00.000Z' })] })]; const incoming = [makeRelease({ id: 1, assets: [makeAsset({ updated_at: '2026-01-05T00:00:00.000Z' })] })]; - expect(findReleasesWithChangedAssets(incoming, local).map(r => r.id)).toEqual([1]); + const updated = findReleasesWithChangedAssets(incoming, local); + expect(updated.map(r => r.id)).toEqual([1]); + expect(updated[0].updated_asset_ids).toEqual([1]); }); it('skips releases with unchanged assets', () => { @@ -174,6 +201,46 @@ describe('effectiveReleaseTime', () => { }); }); +describe('latestEffectiveRelease', () => { + const makeRelease = (overrides: Partial = {}): Release => ({ + id: 1, + tag_name: 'v1', + name: 'Release 1', + body: null, + published_at: '2026-01-01T00:00:00.000Z', + html_url: 'https://github.com/owner/repo/releases/tag/v1', + assets: [], + repository: { id: 1, full_name: 'owner/repo', name: 'repo' }, + ...overrides, + }); + + it('selects an older release when its asset was updated most recently', () => { + const latestPublished = makeRelease({ + id: 1, + published_at: '2026-02-01T00:00:00.000Z', + }); + const olderReleaseWithUpdatedAsset = makeRelease({ + id: 2, + published_at: '2026-01-01T00:00:00.000Z', + assets: [makeAsset({ updated_at: '2026-03-01T00:00:00.000Z' })], + }); + + expect(latestEffectiveRelease([latestPublished, olderReleaseWithUpdatedAsset])).toBe(olderReleaseWithUpdatedAsset); + }); + + it('uses the newest published release when no asset is newer', () => { + const olderRelease = makeRelease({ id: 1, published_at: '2026-01-01T00:00:00.000Z' }); + const latestRelease = makeRelease({ id: 2, published_at: '2026-02-01T00:00:00.000Z' }); + + expect(latestEffectiveRelease([olderRelease, latestRelease])).toBe(latestRelease); + }); + + it('returns null for empty or invalid releases', () => { + expect(latestEffectiveRelease([])).toBeNull(); + expect(latestEffectiveRelease([makeRelease({ published_at: 'not-a-date' })])).toBeNull(); + }); +}); + describe('hasAssetsUpdatedAfterPublish', () => { const makeRelease = (overrides: Partial = {}): Release => ({ id: 1, diff --git a/src/utils/releaseAssets.ts b/src/utils/releaseAssets.ts index 3ddf5bac..4d70107b 100644 --- a/src/utils/releaseAssets.ts +++ b/src/utils/releaseAssets.ts @@ -35,6 +35,24 @@ export function hasAssetsChanged( return assetsFingerprint(current) !== assetsFingerprint(incoming); } +/** + * 返回相对本地资产集合新增或发生变化的资产 ID。 + * 资产被删除时没有可展示的资产行,因此不会出现在返回值中;删除仍由 + * hasAssetsChanged 识别并触发 Release 整体更新。 + */ +export function changedAssetIds( + current: ReleaseAsset[] | undefined, + incoming: ReleaseAsset[] | undefined, +): number[] { + const currentById = new Map((current || []).map(asset => [asset.id, asset])); + return (incoming || []) + .filter(asset => { + const previous = currentById.get(asset.id); + return !previous || assetFingerprint(previous) !== assetFingerprint(asset); + }) + .map(asset => asset.id); +} + /** * 从最新拉取的 Release 中筛出“资产相对本地已变化”的条目。 * 只比对本地已存在 id 的 Release(新增条目由调用方 addReleases 处理); @@ -46,10 +64,14 @@ export function findReleasesWithChangedAssets( currentReleases: Release[] ): Release[] { const byId = new Map(currentReleases.map(r => [r.id, r])); - return (latestReleases || []).filter((latest) => { + return (latestReleases || []).flatMap((latest) => { const local = byId.get(latest.id); - if (!local) return false; - return assetsFingerprint(local.assets) !== assetsFingerprint(latest.assets); + if (!local || !hasAssetsChanged(local.assets, latest.assets)) return []; + + return [{ + ...latest, + updated_asset_ids: changedAssetIds(local.assets, latest.assets), + }]; }); } @@ -73,6 +95,34 @@ export function effectiveReleaseTime(release: Pick>( + releases: readonly T[], +): T | null { + let latestRelease: T | null = null; + let latestTime = -Infinity; + + for (const release of releases) { + if (Number.isNaN(new Date(release.published_at).getTime())) { + continue; + } + + const effectiveTime = new Date(effectiveReleaseTime(release)).getTime(); + if (!Number.isNaN(effectiveTime) && effectiveTime > latestTime) { + latestRelease = release; + latestTime = effectiveTime; + } + } + + return latestRelease; +} + /** * 判断是否存在发布时间之后更新过的资产。 * 使用时间值比较,而不是比较不同格式的时间字符串,避免时区或毫秒精度差异造成误判。