diff --git a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue index ea6a1f1..bff6268 100644 --- a/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue +++ b/apps/app-frontend/src/components/ui/skin-site/SkinSiteAdminView.vue @@ -5,6 +5,7 @@ import { computed, ref } from 'vue' import { skinSiteUser } from '@/composables/skin-site-session' import AdminBroadcastPage from './admin/AdminBroadcastPage.vue' +import AdminLauncherPage from './admin/AdminLauncherPage.vue' import AdminPlayersPage from './admin/AdminPlayersPage.vue' import AdminReviewPage from './admin/AdminReviewPage.vue' import AdminSummaryPage from './admin/AdminSummaryPage.vue' @@ -162,6 +163,7 @@ const current = ref('summary') +

{{ current }}

diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminLauncherPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminLauncherPage.vue new file mode 100644 index 0000000..5bf3987 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminLauncherPage.vue @@ -0,0 +1,277 @@ + + diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index 0824a3c..5f322ce 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -1142,3 +1142,151 @@ export function skinSiteRegenerateReviewInvite( { method: 'POST' }, ) } + +export interface SkinSiteLauncherRelease { + id: number + version: string + channel: 'release' | 'beta' + notes: string + forceUpdate: boolean + uploadedBy: string + uploadedAt: number + publishedAt: number | null + active: boolean +} + +export interface SkinSiteLauncherAdminState { + latestUrl: string + releases: SkinSiteLauncherRelease[] +} + +export function skinSiteLauncherAdminState(): Promise< + SkinSiteResponse +> { + return skinSiteRequest('launcher/admin') +} + +export function skinSiteActivateLauncherRelease( + id: number, +): Promise> { + return skinSiteRequest('launcher/admin/' + id + '/activate', { + method: 'POST', + }) +} + +export interface SkinSiteLauncherUploadStatus { + id: string + chunkSize: number + size: number + received: number + receivedChunks: number[] + uploadConcurrency: number + state: 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled' + release?: SkinSiteLauncherRelease | null + errorMessage?: string | null +} + +const LAUNCHER_MAX_UPLOAD_BYTES = 4 * 1024 ** 3 +const LAUNCHER_MAX_CONCURRENCY = 6 + +function launcherUploadError(message: string, code = 0): SkinSiteResponse { + return { success: false, timestamp: Date.now(), errorCode: code, errorMessage: message } +} + +export async function skinSiteUploadLauncherRelease( + file: File, + forceUpdate: boolean, + onProgress?: (percent: number) => void, +): Promise> { + if (!file.name.toLowerCase().endsWith('.zip')) { + return launcherUploadError('请选择 ZIP 格式的发布包。') + } + if (file.size < 1 || file.size > LAUNCHER_MAX_UPLOAD_BYTES) { + return launcherUploadError('请选择不超过 4 GiB 的完整发布 ZIP。') + } + + const uploadId = crypto.randomUUID() + let result = await skinSiteRequest('launcher/admin/uploads', { + method: 'POST', + body: { + uploadId, + filename: file.name, + size: file.size, + forceUpdate, + }, + }) + + const requestStatus = (path: string, method: 'GET' | 'POST' = 'GET') => + skinSiteRequest(path, { method }) + + const chunkOnce = async (path: string, part: Blob) => { + const form = new FormData() + form.append('file', part, 'chunk.bin') + return skinSiteRequest(path, { method: 'POST', form }) + } + + let guard = 0 + while (guard++ < 200) { + if (!result.success) return result + const status = result.payload + onProgress?.((status.received / file.size) * 100) + + if (status.state === 'ready') { + return { + success: true, + timestamp: result.timestamp, + payload: status.release as SkinSiteLauncherRelease, + } + } + if (status.state === 'failed' || status.state === 'cancelled') { + return launcherUploadError(status.errorMessage || '发布包处理失败。') + } + + if (status.state === 'uploading' && status.received < file.size) { + const received = new Set(status.receivedChunks) + const missing: number[] = [] + for (let i = 0; i < Math.ceil(file.size / status.chunkSize); i++) { + if (!received.has(i)) missing.push(i) + } + + let cursor = 0 + let uploadError: SkinSiteResponse | null = null + const worker = async () => { + while (!uploadError && cursor < missing.length) { + const index = missing[cursor++] + const start = index * status.chunkSize + const part = file.slice(start, Math.min(start + status.chunkSize, file.size)) + let ack = await chunkOnce('launcher/admin/uploads/' + uploadId + '/chunks/' + index, part) + for (let retry = 0; retry < 2 && !ack.success; retry++) { + await new Promise((r) => setTimeout(r, 400 * (retry + 1))) + ack = await chunkOnce('launcher/admin/uploads/' + uploadId + '/chunks/' + index, part) + } + if (!ack.success) { + uploadError = ack + return + } + } + } + await Promise.all( + Array.from( + { + length: Math.min( + status.uploadConcurrency || 1, + LAUNCHER_MAX_CONCURRENCY, + missing.length || 1, + ), + }, + worker, + ), + ) + if (uploadError) return uploadError + result = await requestStatus('launcher/admin/uploads/' + uploadId) + } else if (status.state === 'uploading') { + result = await requestStatus('launcher/admin/uploads/' + uploadId + '/complete', 'POST') + } else { + await new Promise((r) => setTimeout(r, 800)) + result = await requestStatus('launcher/admin/uploads/' + uploadId) + } + } + return launcherUploadError('上传超时,请刷新版本列表确认结果。') +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 987c2eb..065b42c 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -11929,5 +11929,28 @@ "app.skin-site.admin.review.invite-not-sent": {"message": "Email not sent."}, "app.skin-site.admin.review.prev": {"message": "Previous"}, "app.skin-site.admin.review.next": {"message": "Next"}, - "app.skin-site.admin.review.page": {"message": "Page {page}"} + "app.skin-site.admin.review.page": {"message": "Page {page}"}, + "app.skin-site.admin.launcher.title": {"message": "Launcher releases"}, + "app.skin-site.admin.launcher.loading": {"message": "Loading…"}, + "app.skin-site.admin.launcher.failed": {"message": "Could not load releases."}, + "app.skin-site.admin.launcher.empty": {"message": "No releases uploaded yet."}, + "app.skin-site.admin.launcher.latest-url": {"message": "Latest URL"}, + "app.skin-site.admin.launcher.version": {"message": "Version"}, + "app.skin-site.admin.launcher.channel": {"message": "Channel"}, + "app.skin-site.admin.launcher.uploader": {"message": "Uploaded by"}, + "app.skin-site.admin.launcher.uploaded-at": {"message": "Uploaded"}, + "app.skin-site.admin.launcher.published-at": {"message": "Published"}, + "app.skin-site.admin.launcher.force-update": {"message": "Forced"}, + "app.skin-site.admin.launcher.active": {"message": "Active"}, + "app.skin-site.admin.launcher.activate": {"message": "Activate"}, + "app.skin-site.admin.launcher.activated": {"message": "Release activated."}, + "app.skin-site.admin.launcher.upload": {"message": "Upload release"}, + "app.skin-site.admin.launcher.upload-hint": {"message": "Choose a release ZIP (max 4 GiB)."}, + "app.skin-site.admin.launcher.uploading": {"message": "Uploading…"}, + "app.skin-site.admin.launcher.upload-done": {"message": "Release uploaded."}, + "app.skin-site.admin.launcher.force-flag": {"message": "Force update"}, + "app.skin-site.admin.launcher.prev": {"message": "Previous"}, + "app.skin-site.admin.launcher.next": {"message": "Next"}, + "app.skin-site.admin.launcher.page": {"message": "Page {page}"}, + "app.skin-site.admin.launcher.total": {"message": "{count} releases"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index f056b0c..7026430 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -11760,5 +11760,28 @@ "app.skin-site.admin.review.invite-not-sent": {"message": "邮件未发送。"}, "app.skin-site.admin.review.prev": {"message": "上一页"}, "app.skin-site.admin.review.next": {"message": "下一页"}, - "app.skin-site.admin.review.page": {"message": "第 {page} 页"} + "app.skin-site.admin.review.page": {"message": "第 {page} 页"}, + "app.skin-site.admin.launcher.title": {"message": "启动器版本"}, + "app.skin-site.admin.launcher.loading": {"message": "加载中…"}, + "app.skin-site.admin.launcher.failed": {"message": "无法加载版本列表。"}, + "app.skin-site.admin.launcher.empty": {"message": "尚未上传任何版本。"}, + "app.skin-site.admin.launcher.latest-url": {"message": "最新版本地址"}, + "app.skin-site.admin.launcher.version": {"message": "版本"}, + "app.skin-site.admin.launcher.channel": {"message": "渠道"}, + "app.skin-site.admin.launcher.uploader": {"message": "上传者"}, + "app.skin-site.admin.launcher.uploaded-at": {"message": "上传时间"}, + "app.skin-site.admin.launcher.published-at": {"message": "发布时间"}, + "app.skin-site.admin.launcher.force-update": {"message": "强制更新"}, + "app.skin-site.admin.launcher.active": {"message": "当前版本"}, + "app.skin-site.admin.launcher.activate": {"message": "设为当前"}, + "app.skin-site.admin.launcher.activated": {"message": "已设为当前版本。"}, + "app.skin-site.admin.launcher.upload": {"message": "上传版本"}, + "app.skin-site.admin.launcher.upload-hint": {"message": "选择发布包 ZIP(最大 4 GiB)。"}, + "app.skin-site.admin.launcher.uploading": {"message": "上传中…"}, + "app.skin-site.admin.launcher.upload-done": {"message": "版本已上传。"}, + "app.skin-site.admin.launcher.force-flag": {"message": "强制更新"}, + "app.skin-site.admin.launcher.prev": {"message": "上一页"}, + "app.skin-site.admin.launcher.next": {"message": "下一页"}, + "app.skin-site.admin.launcher.page": {"message": "第 {page} 页"}, + "app.skin-site.admin.launcher.total": {"message": "共 {count} 个版本"} }