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('上传超时,请刷新版本列表确认结果。')
|
||||||
|
}
|
||||||
@@ -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"}
|
||||||
}
|
}
|
||||||
@@ -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} 个版本"}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user