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

@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session' import { skinSiteUser } from '@/composables/skin-site-session'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue' import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminPlayersPage from './admin/AdminPlayersPage.vue' import AdminPlayersPage from './admin/AdminPlayersPage.vue'
import AdminReviewPage from './admin/AdminReviewPage.vue' import AdminReviewPage from './admin/AdminReviewPage.vue'
import AdminSummaryPage from './admin/AdminSummaryPage.vue' import AdminSummaryPage from './admin/AdminSummaryPage.vue'
@@ -162,6 +163,7 @@ const current = ref<AdminKey>('summary')
<AdminUsersPage v-else-if="current === 'users'" /> <AdminUsersPage v-else-if="current === 'users'" />
<AdminPlayersPage v-else-if="current === 'players'" /> <AdminPlayersPage v-else-if="current === 'players'" />
<AdminReviewPage v-else-if="current === 'review'" /> <AdminReviewPage v-else-if="current === 'review'" />
<AdminLauncherPage v-else-if="current === 'launcher'" />
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p> <p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
</section> </section>
</div> </div>
@@ -0,0 +1,277 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateLauncherRelease,
type SkinSiteLauncherAdminState,
skinSiteLauncherAdminState,
type SkinSiteLauncherRelease,
skinSiteUploadLauncherRelease,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.launcher.title', defaultMessage: 'Launcher releases' },
loading: { id: 'app.skin-site.admin.launcher.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.launcher.failed',
defaultMessage: 'Could not load releases.',
},
empty: { id: 'app.skin-site.admin.launcher.empty', defaultMessage: 'No releases uploaded yet.' },
latestUrl: { id: 'app.skin-site.admin.launcher.latest-url', defaultMessage: 'Latest URL' },
version: { id: 'app.skin-site.admin.launcher.version', defaultMessage: 'Version' },
channel: { id: 'app.skin-site.admin.launcher.channel', defaultMessage: 'Channel' },
uploader: { id: 'app.skin-site.admin.launcher.uploader', defaultMessage: 'Uploaded by' },
uploadedAt: { id: 'app.skin-site.admin.launcher.uploaded-at', defaultMessage: 'Uploaded' },
publishedAt: { id: 'app.skin-site.admin.launcher.published-at', defaultMessage: 'Published' },
forceUpdate: { id: 'app.skin-site.admin.launcher.force-update', defaultMessage: 'Forced' },
active: { id: 'app.skin-site.admin.launcher.active', defaultMessage: 'Active' },
activate: { id: 'app.skin-site.admin.launcher.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.launcher.activated', defaultMessage: 'Release activated.' },
upload: { id: 'app.skin-site.admin.launcher.upload', defaultMessage: 'Upload release' },
uploadHint: {
id: 'app.skin-site.admin.launcher.upload-hint',
defaultMessage: 'Choose a release ZIP (max 4 GiB).',
},
uploading: { id: 'app.skin-site.admin.launcher.uploading', defaultMessage: 'Uploading…' },
uploadDone: {
id: 'app.skin-site.admin.launcher.upload-done',
defaultMessage: 'Release uploaded.',
},
forceFlag: {
id: 'app.skin-site.admin.launcher.force-flag',
defaultMessage: 'Force update',
},
prev: { id: 'app.skin-site.admin.launcher.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.launcher.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.launcher.page', defaultMessage: 'Page {page}' },
total: { id: 'app.skin-site.admin.launcher.total', defaultMessage: '{count} releases' },
})
const PAGE_SIZE = 10
const state = ref<SkinSiteLauncherAdminState | null>(null)
const loading = ref(true)
const failed = ref(false)
const offset = ref(0)
const uploading = ref(false)
const uploadPercent = ref(0)
const forceUpdate = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const activatingId = ref<number | null>(null)
const releases = computed(() => state.value?.releases ?? [])
const total = computed(() => releases.value.length)
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
const hasPrev = computed(() => offset.value > 0)
const hasNext = computed(() => offset.value + PAGE_SIZE < total.value)
const visible = computed(() => releases.value.slice(offset.value, offset.value + PAGE_SIZE))
function formatDateTime(ts?: number | null): string {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteLauncherAdminState()
if (!result.success) throw new Error(result.errorMessage)
state.value = result.payload
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
function prevPage() {
if (hasPrev.value) offset.value = Math.max(0, offset.value - PAGE_SIZE)
}
function nextPage() {
if (hasNext.value) offset.value += PAGE_SIZE
}
async function activate(release: SkinSiteLauncherRelease) {
if (activatingId.value !== null) return
activatingId.value = release.id
try {
const result = await skinSiteActivateLauncherRelease(release.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.activated) })
await load()
} catch (error) {
handleError(error)
} finally {
activatingId.value = null
}
}
function pickFile() {
fileInput.value?.click()
}
async function onFileChosen(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file || uploading.value) return
uploading.value = true
uploadPercent.value = 0
try {
const result = await skinSiteUploadLauncherRelease(
file,
forceUpdate.value,
(p) => (uploadPercent.value = Math.round(p)),
)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.uploadDone) })
offset.value = 0
await load()
} catch (error) {
handleError(error)
} finally {
uploading.value = false
uploadPercent.value = 0
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
</div>
<div
v-if="state && state.latestUrl"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.latestUrl) }}
</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">{{ state.latestUrl }}</code>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
ref="fileInput"
type="file"
accept=".zip,application/zip"
class="hidden"
@change="onFileChosen"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="uploading"
@click="pickFile"
>
{{ formatMessage(messages.upload) }}
</button>
<label class="flex items-center gap-2 text-xs text-contrast">
<input v-model="forceUpdate" type="checkbox" :disabled="uploading" />
{{ formatMessage(messages.forceFlag) }}
</label>
<span class="text-xs text-secondary">{{ formatMessage(messages.uploadHint) }}</span>
</div>
<div v-if="uploading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.uploading) }} {{ uploadPercent }}%
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!total" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="release in visible"
:key="release.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ release.version }}
<span class="text-xs font-normal text-secondary">#{{ release.id }}</span>
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.uploader) }}: {{ release.uploadedBy }} ·
{{ formatDateTime(release.uploadedAt) }}
</span>
</div>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ release.channel }}
</span>
<span
v-if="release.forceUpdate"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.forceUpdate) }}
</span>
<span
v-if="release.active"
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.active) }}
</span>
<button
v-if="!release.active"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="activatingId !== null"
@click="activate(release)"
>
{{ formatMessage(messages.activate) }}
</button>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</div>
</template>
@@ -1142,3 +1142,151 @@ export function skinSiteRegenerateReviewInvite(
{ method: 'POST' }, { 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('上传超时,请刷新版本列表确认结果。')
}
+24 -1
View File
@@ -11929,5 +11929,28 @@
"app.skin-site.admin.review.invite-not-sent": {"message": "Email not sent."}, "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.prev": {"message": "Previous"},
"app.skin-site.admin.review.next": {"message": "Next"}, "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"}
} }
+24 -1
View File
@@ -11760,5 +11760,28 @@
"app.skin-site.admin.review.invite-not-sent": {"message": "邮件未发送。"}, "app.skin-site.admin.review.invite-not-sent": {"message": "邮件未发送。"},
"app.skin-site.admin.review.prev": {"message": "上一页"}, "app.skin-site.admin.review.prev": {"message": "上一页"},
"app.skin-site.admin.review.next": {"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} 个版本"}
} }