feat: 新增皮肤站管理台启动器版本页
Sync source to CNB / Sync Git ref (push) Skipped

This commit is contained in:
AxTps committed 2026-10-17 22:54:43 +08:00
1 parent 1c671943b7
commit ba43b9d5bc
5 files changed
+475 -2

No files matched your search

@@ -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<SkinSiteLauncherAdminState>
> {
return skinSiteRequest<SkinSiteLauncherAdminState>('launcher/admin')
}
export function skinSiteActivateLauncherRelease(
id: number,
): Promise<SkinSiteResponse<SkinSiteLauncherRelease>> {
return skinSiteRequest<SkinSiteLauncherRelease>('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<never> {
return { success: false, timestamp: Date.now(), errorCode: code, errorMessage: message }
}
export async function skinSiteUploadLauncherRelease(
file: File,
forceUpdate: boolean,
onProgress?: (percent: number) => void,
): Promise<SkinSiteResponse<SkinSiteLauncherRelease>> {
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<SkinSiteLauncherUploadStatus>('launcher/admin/uploads', {
method: 'POST',
body: {
uploadId,
filename: file.name,
size: file.size,
forceUpdate,
},
})
const requestStatus = (path: string, method: 'GET' | 'POST' = 'GET') =>
skinSiteRequest<SkinSiteLauncherUploadStatus>(path, { method })
const chunkOnce = async (path: string, part: Blob) => {
const form = new FormData()
form.append('file', part, 'chunk.bin')
return skinSiteRequest<SkinSiteLauncherUploadStatus>(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<never> | 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('上传超时,请刷新版本列表确认结果。')
}