Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions src/components/ReleaseCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ interface DownloadLink {
size: number;
downloadCount: number;
isSourceCode?: boolean;
assetId?: number;
}

interface ReleaseCardProps {
Expand Down Expand Up @@ -364,6 +365,9 @@ const ReleaseCard: React.FC<ReleaseCardProps> = 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 (
Expand Down Expand Up @@ -393,6 +397,11 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
</span>
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-text-tertiary flex-shrink-0">
{isAssetUpdated && (
<span className="text-[10px] px-1 py-px rounded bg-brand-violet/10 text-brand-violet font-medium whitespace-nowrap">
{t('资产已更新', 'Asset updated')}
</span>
)}
{link.size > 0 && (
<span>{formatFileSize(link.size)}</span>
)}
Expand Down Expand Up @@ -426,6 +435,11 @@ const ReleaseCard: React.FC<ReleaseCardProps> = memo(({
</span>
</div>
<div className="flex items-center space-x-2 text-xs text-gray-500 dark:text-text-tertiary flex-shrink-0">
{isAssetUpdated && (
<span className="text-[10px] px-1 py-px rounded bg-brand-violet/10 text-brand-violet font-medium whitespace-nowrap">
{t('资产已更新', 'Asset updated')}
</span>
)}
{link.size > 0 && (
<span>{formatFileSize(link.size)}</span>
)}
Expand Down
56 changes: 38 additions & 18 deletions src/components/ReleaseTimeline.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
import {
effectiveReleaseTime,
findReleasesWithChangedAssets,
latestEffectiveRelease,
shouldShowAssetsUpdatedIndicator,
} from '../utils/releaseAssets';

Expand Down Expand Up @@ -169,15 +170,16 @@ 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 => {
links.push({
name: asset.name,
url: asset.browser_download_url,
size: asset.size,
downloadCount: asset.download_count
downloadCount: asset.download_count,
assetId: asset.id,
});
});
}
Expand Down Expand Up @@ -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]);

// 根据视图模式计算分页
Expand Down Expand Up @@ -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 (
<div key={repository.id} className="ui-card overflow-hidden">
Expand Down Expand Up @@ -1272,14 +1290,16 @@ export const ReleaseTimeline: React.FC = () => {
<p className="text-xs text-gray-400 dark:text-text-tertiary truncate">
{t('最新:', 'Latest:')} {latestRelease.tag_name}
</p>
<p className="text-xs text-gray-400 dark:text-text-quaternary whitespace-nowrap flex items-center justify-end gap-1">
{formatDistanceToNow(new Date(latestEffectiveTime!), { addSuffix: true, locale: language === 'zh' ? zhCN : undefined })}
{latestAssetsUpdated && (
<span className="text-[10px] px-1 py-px rounded bg-brand-violet/10 text-brand-violet font-medium">
{t('资产已更新', 'Assets updated')}
</span>
)}
</p>
{latestEffectiveTime && (
<p className="text-xs text-gray-400 dark:text-text-quaternary whitespace-nowrap flex items-center justify-end gap-1">
{formatDistanceToNow(new Date(latestEffectiveTime), { addSuffix: true, locale: language === 'zh' ? zhCN : undefined })}
{latestAssetsUpdated && (
<span className="text-[10px] px-1 py-px rounded bg-brand-violet/10 text-brand-violet font-medium">
{t('资产已更新', 'Assets updated')}
</span>
)}
</p>
)}
</>
)}
</div>
Expand Down
10 changes: 9 additions & 1 deletion src/store/useAppStore.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),不依赖已读状态
Expand All @@ -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);

Expand Down
2 changes: 2 additions & 0 deletions src/types/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
69 changes: 68 additions & 1 deletion src/utils/releaseAssets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import type { Release, ReleaseAsset } from '../types';
import {
assetFingerprint,
assetsFingerprint,
changedAssetIds,
effectiveReleaseTime,
findReleasesWithChangedAssets,
hasAssetsChanged,
latestEffectiveRelease,
hasAssetsUpdatedAfterPublish,
shouldShowAssetsUpdatedIndicator,
} from './releaseAssets';
Expand Down Expand Up @@ -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> = {}): Release => ({
id: 1,
Expand All @@ -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', () => {
Expand Down Expand Up @@ -174,6 +201,46 @@ describe('effectiveReleaseTime', () => {
});
});

describe('latestEffectiveRelease', () => {
const makeRelease = (overrides: Partial<Release> = {}): 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> = {}): Release => ({
id: 1,
Expand Down
56 changes: 53 additions & 3 deletions src/utils/releaseAssets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 处理);
Expand All @@ -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),
}];
});
}

Expand All @@ -73,6 +95,34 @@ export function effectiveReleaseTime(release: Pick<Release, 'published_at' | 'as
return new Date(latest).toISOString();
}

/**
* 在一组 Release 中找到有效更新时间最新的条目。
*
* 仓库分类视图展示的是多个 Release,但仓库容器的更新时间应反映
* 所有可见条目中的最新发布时间或资产更新时间,而不是只看最新发布的版本。
* 对发布时间无效的条目跳过,避免单条损坏数据阻断整个仓库分组的渲染。
*/
export function latestEffectiveRelease<T extends Pick<Release, 'published_at' | 'assets'>>(
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;
}

/**
* 判断是否存在发布时间之后更新过的资产。
* 使用时间值比较,而不是比较不同格式的时间字符串,避免时区或毫秒精度差异造成误判。
Expand Down