feat: 新增皮肤站管理台资源下载页
Sync source to CNB / Sync Git ref (push) Skipped

This commit is contained in:
AxTps committed 2026-10-18 07:37:15 +08:00
1 parent a85e88339f
commit 47c4a3973d
6 files changed
+746 -2

No files matched your search

@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
import AdminReviewPage from './admin/AdminReviewPage.vue'
@@ -164,6 +165,7 @@ const current = ref<AdminKey>('summary')
<AdminPlayersPage v-else-if="current === 'players'" />
<AdminReviewPage v-else-if="current === 'review'" />
<AdminLauncherPage v-else-if="current === 'launcher'" />
<AdminDownloadsPage v-else-if="current === 'downloads'" />
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
</section>
</div>
@@ -0,0 +1,576 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAddDownloadCategory,
skinSiteAddDownloadItem,
skinSiteBindDownloadCategory,
skinSiteDeleteDownloadCategory,
skinSiteDeleteDownloadItem,
skinSiteDownloadCategories,
type SkinSiteDownloadCategory,
type SkinSiteDownloadItem,
skinSiteDownloadItemCount,
skinSiteDownloadItems,
type SkinSiteDownloadRoute,
type SkinSiteDownloadStatus,
skinSiteUnbindDownloadCategory,
skinSiteUpdateDownloadItem,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.downloads.title', defaultMessage: 'Downloads' },
loading: { id: 'app.skin-site.admin.downloads.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.downloads.failed',
defaultMessage: 'Could not load downloads.',
},
empty: { id: 'app.skin-site.admin.downloads.empty', defaultMessage: 'No download items.' },
categories: { id: 'app.skin-site.admin.downloads.categories', defaultMessage: 'Categories' },
all: { id: 'app.skin-site.admin.downloads.all', defaultMessage: 'All items' },
addCategory: { id: 'app.skin-site.admin.downloads.add-category', defaultMessage: 'Add' },
categoryName: {
id: 'app.skin-site.admin.downloads.category-name',
defaultMessage: 'Category name',
},
removeCategory: {
id: 'app.skin-site.admin.downloads.remove-category',
defaultMessage: 'Remove',
},
total: { id: 'app.skin-site.admin.downloads.total', defaultMessage: '{count} items' },
addItem: { id: 'app.skin-site.admin.downloads.add-item', defaultMessage: 'New item' },
save: { id: 'app.skin-site.admin.downloads.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.downloads.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.downloads.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.downloads.remove', defaultMessage: 'Delete' },
filename: { id: 'app.skin-site.admin.downloads.filename', defaultMessage: 'File name' },
description: {
id: 'app.skin-site.admin.downloads.description',
defaultMessage: 'Description',
},
routes: { id: 'app.skin-site.admin.downloads.routes', defaultMessage: 'Download routes' },
routeName: { id: 'app.skin-site.admin.downloads.route-name', defaultMessage: 'Name' },
routeUrl: { id: 'app.skin-site.admin.downloads.route-url', defaultMessage: 'URL' },
addRoute: { id: 'app.skin-site.admin.downloads.add-route', defaultMessage: 'Add route' },
removeRoute: { id: 'app.skin-site.admin.downloads.remove-route', defaultMessage: 'Remove' },
statuses: { id: 'app.skin-site.admin.downloads.statuses', defaultMessage: 'Statuses' },
bindCategory: {
id: 'app.skin-site.admin.downloads.bind-category',
defaultMessage: 'Categories',
},
uploader: { id: 'app.skin-site.admin.downloads.uploader', defaultMessage: 'by {name}' },
created: { id: 'app.skin-site.admin.downloads.created', defaultMessage: 'Item created.' },
updated: { id: 'app.skin-site.admin.downloads.updated', defaultMessage: 'Item updated.' },
deleted: { id: 'app.skin-site.admin.downloads.deleted', defaultMessage: 'Item deleted.' },
categoryAdded: {
id: 'app.skin-site.admin.downloads.category-added',
defaultMessage: 'Category added.',
},
categoryDeleted: {
id: 'app.skin-site.admin.downloads.category-deleted',
defaultMessage: 'Category deleted.',
},
confirmDeleteItem: {
id: 'app.skin-site.admin.downloads.confirm-delete-item',
defaultMessage: 'Delete “{name}”? This cannot be undone.',
},
confirmDeleteCategory: {
id: 'app.skin-site.admin.downloads.confirm-delete-category',
defaultMessage: 'Delete category “{name}”?',
},
})
const STATUS_OPTIONS: SkinSiteDownloadStatus[] = ['OK', 'NEW', 'LEGACY', 'OUTDATED', 'REMOVAL']
const items = ref<SkinSiteDownloadItem[]>([])
const categories = ref<SkinSiteDownloadCategory[]>([])
const total = ref(0)
const loading = ref(true)
const failed = ref(false)
const activeCategory = ref<string>('')
const newCategoryName = ref('')
const categoryWorking = ref(false)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
filename: '',
description: '',
status: [] as SkinSiteDownloadStatus[],
routes: [{ name: '', url: '' }] as Array<{ name: string; url: string }>,
selectedCategories: [] as number[],
})
const canSave = computed(
() =>
form.value.filename.trim().length > 0 &&
form.value.routes.some((r) => r.url.trim().length > 0) &&
!saving.value,
)
function resetForm() {
form.value = {
filename: '',
description: '',
status: [],
routes: [{ name: '', url: '' }],
selectedCategories: [],
}
editingId.value = null
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(item: SkinSiteDownloadItem) {
form.value = {
filename: item.filename,
description: item.description,
status: [...item.status],
routes: item.routes.length
? item.routes.map((r) => ({ name: r.name ?? '', url: r.url }))
: [{ name: '', url: '' }],
selectedCategories: categories.value
.filter((c) => item.category.includes(c.name))
.map((c) => c.id),
}
editingId.value = item.id
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
function addRouteRow() {
form.value.routes.push({ name: '', url: '' })
}
function removeRouteRow(index: number) {
if (form.value.routes.length <= 1) return
form.value.routes.splice(index, 1)
}
function toggleStatus(status: SkinSiteDownloadStatus) {
const i = form.value.status.indexOf(status)
if (i >= 0) form.value.status.splice(i, 1)
else form.value.status.push(status)
}
async function loadCategories() {
try {
const result = await skinSiteDownloadCategories()
if (result.success) categories.value = result.payload ?? []
} catch (error) {
handleError(error)
}
}
async function loadItems() {
loading.value = true
failed.value = false
try {
const listResult = await skinSiteDownloadItems({
limit: 200,
offset: 0,
category: activeCategory.value || undefined,
})
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
const countResult = await skinSiteDownloadItemCount(activeCategory.value || undefined)
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function selectCategory(name: string) {
activeCategory.value = name
await loadItems()
}
async function addCategory() {
const name = newCategoryName.value.trim()
if (!name || categoryWorking.value) return
categoryWorking.value = true
try {
const result = await skinSiteAddDownloadCategory(name)
if (!result.success) throw new Error(result.errorMessage)
newCategoryName.value = ''
addNotification({ type: 'success', title: formatMessage(messages.categoryAdded) })
await loadCategories()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function removeCategory(category: SkinSiteDownloadCategory) {
if (categoryWorking.value) return
if (!window.confirm(formatMessage(messages.confirmDeleteCategory, { name: category.name })))
return
categoryWorking.value = true
try {
const result = await skinSiteDeleteDownloadCategory(category.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.categoryDeleted) })
if (activeCategory.value === category.name) activeCategory.value = ''
await loadCategories()
await loadItems()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
const routes: SkinSiteDownloadRoute[] = form.value.routes
.filter((r) => r.url.trim())
.map((r) => ({ name: r.name.trim() || null, url: r.url.trim() }))
const payload = {
routes,
filename: form.value.filename.trim(),
description: form.value.description,
status: form.value.status,
}
const result =
editingId.value === null
? await skinSiteAddDownloadItem(payload)
: await skinSiteUpdateDownloadItem(editingId.value, payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingId.value === null ? messages.created : messages.updated),
})
closeEditor()
await loadItems()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(item: SkinSiteDownloadItem) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDeleteItem, { name: item.filename }))) return
deletingId.value = item.id
try {
const result = await skinSiteDeleteDownloadItem(item.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadItems()
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
async function toggleItemCategory(item: SkinSiteDownloadItem, category: SkinSiteDownloadCategory) {
try {
const bound = item.category.includes(category.name)
const result = bound
? await skinSiteUnbindDownloadCategory(item.id, category.id)
: await skinSiteBindDownloadCategory(item.id, category.id)
if (!result.success) throw new Error(result.errorMessage)
await loadItems()
} catch (error) {
handleError(error)
}
}
onMounted(async () => {
await loadCategories()
await loadItems()
})
</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>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.addItem) }}
</button>
</div>
<div class="flex min-h-0 gap-4">
<aside class="flex w-56 shrink-0 flex-col gap-2">
<span class="text-xs font-bold tracking-wide text-secondary uppercase">
{{ formatMessage(messages.categories) }}
</span>
<button
type="button"
class="rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === ''
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory('')"
>
{{ formatMessage(messages.all) }}
</button>
<div v-for="category in categories" :key="category.id" class="flex items-center gap-1">
<button
type="button"
class="min-w-0 flex-1 truncate rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === category.name
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory(category.name)"
>
{{ category.name }}
</button>
<button
type="button"
class="shrink-0 rounded px-1 text-[10px] text-secondary hover:text-red"
:disabled="categoryWorking"
@click="removeCategory(category)"
>
×
</button>
</div>
<div class="flex items-center gap-1 pt-1">
<input
v-model="newCategoryName"
type="text"
:placeholder="formatMessage(messages.categoryName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="addCategory"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="categoryWorking || !newCategoryName.trim()"
@click="addCategory"
>
{{ formatMessage(messages.addCategory) }}
</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<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="!items.length" 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="item in items"
:key="item.id"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ item.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.filename }}
</span>
<span
v-for="s in item.status"
:key="s"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ s }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
</div>
<div v-if="item.description" class="text-xs text-secondary">
{{ item.description }}
</div>
<div class="flex flex-wrap gap-1">
<span
v-for="route in item.routes"
:key="route.url"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ route.name ? route.name + ': ' : '' }}{{ route.url }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-secondary">
{{ formatMessage(messages.bindCategory) }}:
</span>
<button
v-for="category in categories"
:key="category.id"
type="button"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="
item.category.includes(category.name)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleItemCategory(item, category)"
>
{{ category.name }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(item)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingId !== null"
@click="remove(item)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.filename) }}
</span>
<input
v-model="form.filename"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.description) }}
</span>
<textarea
v-model="form.description"
rows="3"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.statuses) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="status in STATUS_OPTIONS"
:key="status"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="
form.status.includes(status)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary'
"
@click="toggleStatus(status)"
>
{{ status }}
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.routes) }}
</span>
<div
v-for="(route, idx) in form.routes"
:key="idx"
class="flex flex-wrap items-center gap-2"
>
<input
v-model="route.name"
type="text"
:placeholder="formatMessage(messages.routeName)"
class="w-32 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="route.url"
type="text"
:placeholder="formatMessage(messages.routeUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red disabled:opacity-50"
:disabled="form.routes.length <= 1"
@click="removeRouteRow(idx)"
>
{{ formatMessage(messages.removeRoute) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addRouteRow"
>
{{ formatMessage(messages.addRoute) }}
</button>
</div>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
@@ -1290,3 +1290,104 @@ export async function skinSiteUploadLauncherRelease(
}
return launcherUploadError('上传超时,请刷新版本列表确认结果。')
}
export type SkinSiteDownloadStatus = 'OK' | 'NEW' | 'LEGACY' | 'OUTDATED' | 'REMOVAL'
export interface SkinSiteDownloadRoute {
name?: string | null
url: string
}
export interface SkinSiteDownloadItem {
id: number
uploader: string
url: string
routes: SkinSiteDownloadRoute[]
filename: string
description: string
status: SkinSiteDownloadStatus[]
category: string[]
}
export interface SkinSiteDownloadCategory {
id: number
name: string
}
export interface SkinSiteDownloadItemInput {
routes: SkinSiteDownloadRoute[]
filename: string
description: string
status: SkinSiteDownloadStatus[]
}
export function skinSiteDownloadItems(params?: {
limit?: number
offset?: number
category?: string
}): Promise<SkinSiteResponse<SkinSiteDownloadItem[]>> {
const query = new URLSearchParams({
limit: String(params?.limit ?? 200),
offset: String(params?.offset ?? 0),
})
if (params?.category?.trim()) query.set('category', params.category.trim())
return skinSiteRequest<SkinSiteDownloadItem[]>('download/list?' + query.toString())
}
export function skinSiteDownloadItemCount(category?: string): Promise<SkinSiteResponse<number>> {
const query = new URLSearchParams()
if (category?.trim()) query.set('category', category.trim())
const suffix = query.toString() ? '?' + query.toString() : ''
return skinSiteRequest<number>('download/count' + suffix)
}
export function skinSiteAddDownloadItem(
data: SkinSiteDownloadItemInput,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/add', { method: 'POST', body: data })
}
export function skinSiteUpdateDownloadItem(
id: number,
data: SkinSiteDownloadItemInput,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/update/' + id, { method: 'POST', body: data })
}
export function skinSiteDeleteDownloadItem(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/' + id, { method: 'DELETE' })
}
export function skinSiteDownloadCategories(): Promise<
SkinSiteResponse<SkinSiteDownloadCategory[]>
> {
return skinSiteRequest<SkinSiteDownloadCategory[]>('download/category/list')
}
export function skinSiteAddDownloadCategory(name: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/add/' + encodeURIComponent(name), {
method: 'POST',
})
}
export function skinSiteDeleteDownloadCategory(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/' + id, { method: 'DELETE' })
}
export function skinSiteBindDownloadCategory(
infoId: number,
categoryId: number,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/bind/' + infoId + '/' + categoryId, {
method: 'POST',
})
}
export function skinSiteUnbindDownloadCategory(
infoId: number,
categoryId: number,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('download/category/unbind/' + infoId + '/' + categoryId, {
method: 'POST',
})
}
+33 -1
View File
@@ -11952,5 +11952,37 @@
"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"}
"app.skin-site.admin.launcher.total": {"message": "{count} releases"},
"app.skin-site.admin.downloads.title": {"message": "Downloads"},
"app.skin-site.admin.downloads.loading": {"message": "Loading…"},
"app.skin-site.admin.downloads.failed": {"message": "Could not load downloads."},
"app.skin-site.admin.downloads.empty": {"message": "No download items."},
"app.skin-site.admin.downloads.categories": {"message": "Categories"},
"app.skin-site.admin.downloads.all": {"message": "All items"},
"app.skin-site.admin.downloads.add-category": {"message": "Add"},
"app.skin-site.admin.downloads.category-name": {"message": "Category name"},
"app.skin-site.admin.downloads.remove-category": {"message": "Remove"},
"app.skin-site.admin.downloads.total": {"message": "{count} items"},
"app.skin-site.admin.downloads.add-item": {"message": "New item"},
"app.skin-site.admin.downloads.save": {"message": "Save"},
"app.skin-site.admin.downloads.cancel": {"message": "Cancel"},
"app.skin-site.admin.downloads.edit": {"message": "Edit"},
"app.skin-site.admin.downloads.remove": {"message": "Delete"},
"app.skin-site.admin.downloads.filename": {"message": "File name"},
"app.skin-site.admin.downloads.description": {"message": "Description"},
"app.skin-site.admin.downloads.routes": {"message": "Download routes"},
"app.skin-site.admin.downloads.route-name": {"message": "Name"},
"app.skin-site.admin.downloads.route-url": {"message": "URL"},
"app.skin-site.admin.downloads.add-route": {"message": "Add route"},
"app.skin-site.admin.downloads.remove-route": {"message": "Remove"},
"app.skin-site.admin.downloads.statuses": {"message": "Statuses"},
"app.skin-site.admin.downloads.bind-category": {"message": "Categories"},
"app.skin-site.admin.downloads.uploader": {"message": "by {name}"},
"app.skin-site.admin.downloads.created": {"message": "Item created."},
"app.skin-site.admin.downloads.updated": {"message": "Item updated."},
"app.skin-site.admin.downloads.deleted": {"message": "Item deleted."},
"app.skin-site.admin.downloads.category-added": {"message": "Category added."},
"app.skin-site.admin.downloads.category-deleted": {"message": "Category deleted."},
"app.skin-site.admin.downloads.confirm-delete-item": {"message": "Delete “{name}”? This cannot be undone."},
"app.skin-site.admin.downloads.confirm-delete-category": {"message": "Delete category “{name}”?"}
}
+33 -1
View File
@@ -11783,5 +11783,37 @@
"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} 个版本"}
"app.skin-site.admin.launcher.total": {"message": "共 {count} 个版本"},
"app.skin-site.admin.downloads.title": {"message": "资源下载"},
"app.skin-site.admin.downloads.loading": {"message": "加载中…"},
"app.skin-site.admin.downloads.failed": {"message": "无法加载资源列表。"},
"app.skin-site.admin.downloads.empty": {"message": "没有下载条目。"},
"app.skin-site.admin.downloads.categories": {"message": "分类"},
"app.skin-site.admin.downloads.all": {"message": "全部条目"},
"app.skin-site.admin.downloads.add-category": {"message": "添加"},
"app.skin-site.admin.downloads.category-name": {"message": "分类名称"},
"app.skin-site.admin.downloads.remove-category": {"message": "移除"},
"app.skin-site.admin.downloads.total": {"message": "共 {count} 个条目"},
"app.skin-site.admin.downloads.add-item": {"message": "新建条目"},
"app.skin-site.admin.downloads.save": {"message": "保存"},
"app.skin-site.admin.downloads.cancel": {"message": "取消"},
"app.skin-site.admin.downloads.edit": {"message": "编辑"},
"app.skin-site.admin.downloads.remove": {"message": "删除"},
"app.skin-site.admin.downloads.filename": {"message": "文件名"},
"app.skin-site.admin.downloads.description": {"message": "描述"},
"app.skin-site.admin.downloads.routes": {"message": "下载线路"},
"app.skin-site.admin.downloads.route-name": {"message": "名称"},
"app.skin-site.admin.downloads.route-url": {"message": "URL"},
"app.skin-site.admin.downloads.add-route": {"message": "添加线路"},
"app.skin-site.admin.downloads.remove-route": {"message": "移除"},
"app.skin-site.admin.downloads.statuses": {"message": "状态"},
"app.skin-site.admin.downloads.bind-category": {"message": "所属分类"},
"app.skin-site.admin.downloads.uploader": {"message": "由 {name} 上传"},
"app.skin-site.admin.downloads.created": {"message": "条目已创建。"},
"app.skin-site.admin.downloads.updated": {"message": "条目已更新。"},
"app.skin-site.admin.downloads.deleted": {"message": "条目已删除。"},
"app.skin-site.admin.downloads.category-added": {"message": "分类已添加。"},
"app.skin-site.admin.downloads.category-deleted": {"message": "分类已删除。"},
"app.skin-site.admin.downloads.confirm-delete-item": {"message": "确定删除「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.downloads.confirm-delete-category": {"message": "确定删除分类「{name}」吗?"}
}
+1
View File
@@ -33,6 +33,7 @@ const allowedUntranslatedMessages = new Set([
'UUID',
'QQ',
'IP',
'URL',
'LogShare AI',
'NeoForge',
'OptiFine',