feat: ship portal and android release updates

This commit is contained in:
yoyuzh
2026-04-05 13:57:13 +08:00
parent 52b5bbfe8e
commit ed837f5ec9
46 changed files with 1507 additions and 189 deletions

View File

@@ -352,18 +352,17 @@ export default function Overview() {
<div>
<h3 className="text-2xl font-semibold text-white"> APK </h3>
<p className="mt-2 max-w-xl text-sm leading-6 text-slate-300">
Android OSS
Android
</p>
</div>
<div className="flex flex-wrap gap-3 text-xs text-slate-400">
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"></span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1">OSS </span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"></span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"></span>
<span className="rounded-full border border-white/10 bg-white/[0.04] px-3 py-1"></span>
</div>
</div>
<a
href={APK_DOWNLOAD_PATH}
download="yoyuzh-portal.apk"
className="inline-flex h-11 shrink-0 items-center justify-center rounded-xl bg-[#336EFF] px-6 text-sm font-medium text-white shadow-md shadow-[#336EFF]/20 transition-colors hover:bg-[#2958cc]"
>
APK

View File

@@ -4,11 +4,13 @@ import { test } from 'node:test';
import {
APK_DOWNLOAD_PATH,
APK_DOWNLOAD_PUBLIC_URL,
formatApkPublishedAtLabel,
getDesktopOverviewSectionColumns,
getDesktopOverviewStretchSection,
getMobileOverviewApkEntryMode,
getOverviewLoadErrorMessage,
getOverviewStorageQuotaLabel,
isAndroidReleaseNewer,
shouldShowOverviewApkDownload,
} from './overview-state';
@@ -26,9 +28,9 @@ test('generic overview failures stay generic when not coming right after login',
);
});
test('overview exposes a stable apk download path for oss hosting', () => {
assert.equal(APK_DOWNLOAD_PATH, '/downloads/yoyuzh-portal.apk');
assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk');
test('overview exposes a backend download endpoint for apk delivery', () => {
assert.equal(APK_DOWNLOAD_PATH, 'https://api.yoyuzh.xyz/api/app/android/download');
assert.equal(APK_DOWNLOAD_PUBLIC_URL, 'https://api.yoyuzh.xyz/api/app/android/download');
});
test('overview hides the apk download entry inside the native app shell', () => {
@@ -64,3 +66,30 @@ test('overview storage quota label uses the real quota instead of a fixed 50 GB
assert.equal(getOverviewStorageQuotaLabel(50 * 1024 * 1024 * 1024), '已使用 / 共 50 GB');
assert.equal(getOverviewStorageQuotaLabel(100 * 1024 * 1024 * 1024), '已使用 / 共 100 GB');
});
test('apk published time is formatted into a readable update label', () => {
assert.match(formatApkPublishedAtLabel('2026-04-03T08:33:54Z') ?? '', /04[/-]03 16:33/);
assert.equal(formatApkPublishedAtLabel(null), null);
});
test('android update check compares numeric versionCode first', () => {
assert.equal(isAndroidReleaseNewer({
currentVersionCode: '260931807',
releaseVersionCode: '260931807',
}), false);
assert.equal(isAndroidReleaseNewer({
currentVersionCode: '260931807',
releaseVersionCode: '260931808',
}), true);
});
test('android update check falls back to versionName comparison', () => {
assert.equal(isAndroidReleaseNewer({
currentVersionName: '2026.04.03.1807',
releaseVersionName: '2026.04.03.1807',
}), false);
assert.equal(isAndroidReleaseNewer({
currentVersionName: '2026.04.03.1807',
releaseVersionName: '2026.04.03.1810',
}), true);
});

View File

@@ -1,7 +1,73 @@
import { isNativeAppShellLocation } from '@/src/lib/app-shell';
export const APK_DOWNLOAD_PATH = '/downloads/yoyuzh-portal.apk';
export const APK_DOWNLOAD_PUBLIC_URL = 'https://yoyuzh.xyz/downloads/yoyuzh-portal.apk';
export const APK_DOWNLOAD_PATH = 'https://api.yoyuzh.xyz/api/app/android/download';
export const APK_DOWNLOAD_PUBLIC_URL = 'https://api.yoyuzh.xyz/api/app/android/download';
function normalizeVersionParts(value: string | null | undefined) {
if (!value) {
return [];
}
return value
.split(/[^0-9A-Za-z]+/)
.filter(Boolean)
.map((part) => (/^\d+$/.test(part) ? Number(part) : part.toLowerCase()));
}
function compareVersionParts(left: Array<number | string>, right: Array<number | string>) {
const length = Math.max(left.length, right.length);
for (let index = 0; index < length; index += 1) {
const leftPart = left[index] ?? 0;
const rightPart = right[index] ?? 0;
if (leftPart === rightPart) {
continue;
}
if (typeof leftPart === 'number' && typeof rightPart === 'number') {
return leftPart > rightPart ? 1 : -1;
}
return String(leftPart).localeCompare(String(rightPart), 'en');
}
return 0;
}
export function isAndroidReleaseNewer({
currentVersionCode,
currentVersionName,
releaseVersionCode,
releaseVersionName,
}: {
currentVersionCode?: string | null;
currentVersionName?: string | null;
releaseVersionCode?: string | null;
releaseVersionName?: string | null;
}) {
if (currentVersionCode && releaseVersionCode && /^\d+$/.test(currentVersionCode) && /^\d+$/.test(releaseVersionCode)) {
return BigInt(releaseVersionCode) > BigInt(currentVersionCode);
}
if (currentVersionName && releaseVersionName) {
return compareVersionParts(normalizeVersionParts(currentVersionName), normalizeVersionParts(releaseVersionName)) < 0;
}
return true;
}
export function formatApkPublishedAtLabel(publishedAt: string | null) {
if (!publishedAt) {
return null;
}
return new Intl.DateTimeFormat('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
}).format(new Date(publishedAt));
}
function formatOverviewStorageSize(size: number) {
if (size <= 0) {