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

This commit is contained in:
AxTps committed 2026-10-18 08:24:55 +08:00
1 parent b0178c046f
commit ede28e0062
6 files changed
+1051 -2

No files matched your search

@@ -9,6 +9,7 @@ import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminDonationsPage from './admin/AdminDonationsPage.vue' import AdminDonationsPage from './admin/AdminDonationsPage.vue'
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue' import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue' import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminModsPage from './admin/AdminModsPage.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 AdminServersPage from './admin/AdminServersPage.vue' import AdminServersPage from './admin/AdminServersPage.vue'
@@ -179,6 +180,7 @@ const current = ref<AdminKey>('summary')
<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'" /> <AdminLauncherPage v-else-if="current === 'launcher'" />
<AdminModsPage v-else-if="current === 'mods'" />
<AdminDownloadsPage v-else-if="current === 'downloads'" /> <AdminDownloadsPage v-else-if="current === 'downloads'" />
<AdminDonationsPage v-else-if="current === 'donations' && hasWebAdmin" /> <AdminDonationsPage v-else-if="current === 'donations' && hasWebAdmin" />
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p> <p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
@@ -0,0 +1,788 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateModRuntime,
skinSiteCreateModRuleTag,
skinSiteDeleteModFile,
skinSiteDeleteModRuleTag,
skinSiteDeleteModRuntime,
skinSiteModCount,
type SkinSiteModInfo,
skinSiteModList,
type SkinSiteModRuleTag,
skinSiteModRuleTags,
type SkinSiteModRuntimeAdminState,
type SkinSiteModRuntimeRelease,
skinSiteModRuntimeReleases,
skinSiteModSearch,
skinSiteUpdateModInfo,
skinSiteUpdateModRuleTag,
skinSiteUploadModRuntime,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.title', defaultMessage: 'Mods' },
tabMods: { id: 'app.skin-site.admin.mods.tab-mods', defaultMessage: 'Mod files' },
tabTags: { id: 'app.skin-site.admin.mods.tab-tags', defaultMessage: 'Rule tags' },
tabRuntime: { id: 'app.skin-site.admin.mods.tab-runtime', defaultMessage: 'Runtime' },
loading: { id: 'app.skin-site.admin.mods.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.mods.failed', defaultMessage: 'Could not load mods.' },
empty: { id: 'app.skin-site.admin.mods.empty', defaultMessage: 'No mods found.' },
total: { id: 'app.skin-site.admin.mods.total', defaultMessage: '{count} mods' },
search: { id: 'app.skin-site.admin.mods.search', defaultMessage: 'Search by name' },
filterAll: { id: 'app.skin-site.admin.mods.filter-all', defaultMessage: 'All tags' },
prev: { id: 'app.skin-site.admin.mods.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.mods.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.mods.page', defaultMessage: 'Page {page}' },
name: { id: 'app.skin-site.admin.mods.name', defaultMessage: 'Name' },
version: { id: 'app.skin-site.admin.mods.version', defaultMessage: 'Version' },
modId: { id: 'app.skin-site.admin.mods.mod-id', defaultMessage: 'Mod ID' },
size: { id: 'app.skin-site.admin.mods.size', defaultMessage: 'Size' },
loaders: { id: 'app.skin-site.admin.mods.loaders', defaultMessage: 'Loaders' },
domainRules: { id: 'app.skin-site.admin.mods.domain-rules', defaultMessage: 'Domain rules' },
edit: { id: 'app.skin-site.admin.mods.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.mods.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.mods.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.mods.remove', defaultMessage: 'Delete' },
updated: { id: 'app.skin-site.admin.mods.updated', defaultMessage: 'Mod updated.' },
deleted: { id: 'app.skin-site.admin.mods.deleted', defaultMessage: 'Mod deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.mods.confirm-delete',
defaultMessage: 'Delete mod “{name}”? This cannot be undone.',
},
ruleTags: { id: 'app.skin-site.admin.mods.rule-tags', defaultMessage: 'Rule tags' },
tagName: { id: 'app.skin-site.admin.mods.tag-name', defaultMessage: 'Tag name' },
tagRules: { id: 'app.skin-site.admin.mods.tag-rules', defaultMessage: 'Rules' },
tagRulesHint: {
id: 'app.skin-site.admin.mods.tag-rules-hint',
defaultMessage: 'One rule per line.',
},
newTag: { id: 'app.skin-site.admin.mods.new-tag', defaultMessage: 'New tag' },
tagCreated: { id: 'app.skin-site.admin.mods.tag-created', defaultMessage: 'Tag created.' },
tagUpdated: { id: 'app.skin-site.admin.mods.tag-updated', defaultMessage: 'Tag updated.' },
tagDeleted: { id: 'app.skin-site.admin.mods.tag-deleted', defaultMessage: 'Tag deleted.' },
confirmDeleteTag: {
id: 'app.skin-site.admin.mods.confirm-delete-tag',
defaultMessage: 'Delete tag “{name}”?',
},
runtime: { id: 'app.skin-site.admin.mods.runtime', defaultMessage: 'Runtime releases' },
manifestUrl: { id: 'app.skin-site.admin.mods.manifest-url', defaultMessage: 'Manifest URL' },
uploadRuntime: {
id: 'app.skin-site.admin.mods.upload-runtime',
defaultMessage: 'Upload runtime',
},
runtimeVersion: {
id: 'app.skin-site.admin.mods.runtime-version',
defaultMessage: 'Version',
},
uploading: { id: 'app.skin-site.admin.mods.uploading', defaultMessage: 'Uploading…' },
runtimeUploaded: {
id: 'app.skin-site.admin.mods.runtime-uploaded',
defaultMessage: 'Runtime uploaded.',
},
activate: { id: 'app.skin-site.admin.mods.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.mods.activated', defaultMessage: 'Runtime activated.' },
available: { id: 'app.skin-site.admin.mods.available', defaultMessage: 'Available' },
unavailable: { id: 'app.skin-site.admin.mods.unavailable', defaultMessage: 'Unavailable' },
active: { id: 'app.skin-site.admin.mods.active', defaultMessage: 'Active' },
uploader: { id: 'app.skin-site.admin.mods.uploader', defaultMessage: 'by {name}' },
})
type Tab = 'mods' | 'tags' | 'runtime'
const tab = ref<Tab>('mods')
const PAGE_SIZE = 20
const mods = ref<SkinSiteModInfo[]>([])
const modTotal = ref(0)
const modOffset = ref(0)
const modLoading = ref(true)
const modFailed = ref(false)
const searchKeyword = ref('')
const tagFilter = ref<number | null>(null)
const editingHash = ref<string | null>(null)
const editForm = ref({ name: '', modId: '', version: '', domainRules: '' })
const savingMod = ref(false)
const deletingHash = ref<string | null>(null)
const tags = ref<SkinSiteModRuleTag[]>([])
const tagsLoading = ref(false)
const tagEditorOpen = ref(false)
const editingTagId = ref<number | null>(null)
const tagForm = ref({ name: '', rules: '' })
const savingTag = ref(false)
const runtimeState = ref<SkinSiteModRuntimeAdminState | null>(null)
const runtimeLoading = ref(false)
const runtimeVersion = ref('')
const runtimeFile = ref<HTMLInputElement | null>(null)
const runtimeUploading = ref(false)
const activatingRuntimeId = ref<number | null>(null)
const deletingRuntimeId = ref<number | null>(null)
const modPage = computed(() => Math.floor(modOffset.value / PAGE_SIZE) + 1)
const modPageCount = computed(() => Math.max(1, Math.ceil(modTotal.value / PAGE_SIZE)))
const hasPrev = computed(() => modOffset.value > 0)
const hasNext = computed(() => modOffset.value + PAGE_SIZE < modTotal.value)
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return v.toFixed(i === 0 ? 0 : 2) + ' ' + units[i]
}
function formatDateTime(ts?: number | null): string {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
async function loadMods(reset = true) {
if (reset) {
modLoading.value = true
mods.value = []
modOffset.value = 0
}
modFailed.value = false
try {
const kw = searchKeyword.value.trim()
if (kw) {
const result = await skinSiteModSearch(kw, tagFilter.value)
if (!result.success) throw new Error(result.errorMessage)
mods.value = result.payload ?? []
modTotal.value = mods.value.length
} else {
const [listResult, countResult] = await Promise.all([
skinSiteModList({
limit: PAGE_SIZE,
offset: modOffset.value,
ruleTagId: tagFilter.value,
}),
skinSiteModCount({ ruleTagId: tagFilter.value }),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
mods.value = listResult.payload ?? []
if (countResult.success && typeof countResult.payload === 'number') {
modTotal.value = countResult.payload
}
}
} catch (error) {
modFailed.value = true
handleError(error)
} finally {
modLoading.value = false
}
}
function prevPage() {
if (hasPrev.value) {
modOffset.value = Math.max(0, modOffset.value - PAGE_SIZE)
void loadMods(false)
}
}
function nextPage() {
if (hasNext.value) {
modOffset.value += PAGE_SIZE
void loadMods(false)
}
}
async function loadTags() {
tagsLoading.value = true
try {
const result = await skinSiteModRuleTags()
if (result.success) tags.value = result.payload ?? []
} catch (error) {
handleError(error)
} finally {
tagsLoading.value = false
}
}
async function loadRuntime() {
runtimeLoading.value = true
try {
const result = await skinSiteModRuntimeReleases()
if (result.success) runtimeState.value = result.payload
} catch (error) {
handleError(error)
} finally {
runtimeLoading.value = false
}
}
async function switchTab(next: Tab) {
tab.value = next
if (next === 'tags' && !tags.value.length) await loadTags()
if (next === 'runtime' && !runtimeState.value) await loadRuntime()
}
function startEdit(mod: SkinSiteModInfo) {
editingHash.value = mod.hash
editForm.value = {
name: mod.metadata.name ?? mod.name,
modId: mod.metadata.modId ?? '',
version: mod.metadata.version ?? '',
domainRules: mod.domainRules.join(', '),
}
}
function cancelEdit() {
editingHash.value = null
}
async function saveMod(mod: SkinSiteModInfo) {
if (savingMod.value) return
savingMod.value = true
try {
const payload: SkinSiteModInfo = {
...mod,
name: editForm.value.name.trim() || mod.name,
domainRules: editForm.value.domainRules
.split(',')
.map((r) => r.trim())
.filter(Boolean),
metadata: {
...mod.metadata,
name: editForm.value.name.trim() || undefined,
modId: editForm.value.modId.trim() || undefined,
version: editForm.value.version.trim() || undefined,
},
}
const result = await skinSiteUpdateModInfo(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
cancelEdit()
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
savingMod.value = false
}
}
async function removeMod(mod: SkinSiteModInfo) {
if (deletingHash.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: mod.name }))) return
deletingHash.value = mod.hash
try {
const result = await skinSiteDeleteModFile(mod.hash)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
deletingHash.value = null
}
}
function openNewTag() {
editingTagId.value = null
tagForm.value = { name: '', rules: '' }
tagEditorOpen.value = true
}
function openEditTag(tag: SkinSiteModRuleTag) {
editingTagId.value = tag.id
tagForm.value = { name: tag.name, rules: tag.rules.join('\n') }
tagEditorOpen.value = true
}
function closeTagEditor() {
tagEditorOpen.value = false
tagForm.value = { name: '', rules: '' }
editingTagId.value = null
}
async function saveTag() {
const name = tagForm.value.name.trim()
if (!name || savingTag.value) return
savingTag.value = true
try {
const rules = tagForm.value.rules
.split('\n')
.map((r) => r.trim())
.filter(Boolean)
const result =
editingTagId.value === null
? await skinSiteCreateModRuleTag(name, rules)
: await skinSiteUpdateModRuleTag(editingTagId.value, name, rules)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingTagId.value === null ? messages.tagCreated : messages.tagUpdated),
})
closeTagEditor()
await loadTags()
} catch (error) {
handleError(error)
} finally {
savingTag.value = false
}
}
async function removeTag(tag: SkinSiteModRuleTag) {
if (!window.confirm(formatMessage(messages.confirmDeleteTag, { name: tag.name }))) return
try {
const result = await skinSiteDeleteModRuleTag(tag.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.tagDeleted) })
if (tagFilter.value === tag.id) tagFilter.value = null
await loadTags()
} catch (error) {
handleError(error)
}
}
function pickRuntimeFile() {
runtimeFile.value?.click()
}
async function onRuntimeFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const version = runtimeVersion.value.trim()
if (!file || !version || runtimeUploading.value) return
runtimeUploading.value = true
try {
const result = await skinSiteUploadModRuntime(version, file)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.runtimeUploaded) })
runtimeVersion.value = ''
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
runtimeUploading.value = false
}
}
async function activateRuntime(release: SkinSiteModRuntimeRelease) {
if (activatingRuntimeId.value !== null) return
activatingRuntimeId.value = release.id
try {
const result = await skinSiteActivateModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.activated) })
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
activatingRuntimeId.value = null
}
}
async function removeRuntime(release: SkinSiteModRuntimeRelease) {
if (deletingRuntimeId.value !== null) return
deletingRuntimeId.value = release.id
try {
const result = await skinSiteDeleteModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
deletingRuntimeId.value = null
}
}
onMounted(async () => {
await loadTags()
await loadMods(true)
})
</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>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'mods' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('mods')"
>
{{ formatMessage(messages.tabMods) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'tags' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('tags')"
>
{{ formatMessage(messages.tabTags) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'runtime' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('runtime')"
>
{{ formatMessage(messages.tabRuntime) }}
</button>
</div>
<template v-if="tab === 'mods'">
<div class="flex flex-wrap items-center gap-2">
<input
v-model="searchKeyword"
type="text"
:placeholder="formatMessage(messages.search)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="loadMods(true)"
/>
<select
v-model="tagFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="loadMods(true)"
>
<option :value="null">{{ formatMessage(messages.filterAll) }}</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">{{ tag.name }}</option>
</select>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: modTotal }) }}
</span>
</div>
<div v-if="modLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="modFailed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!mods.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="mod in mods"
:key="mod.hash"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ mod.metadata.name || mod.name }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatSize(mod.size) }}</span>
<span
v-for="loader in mod.metadata.loaderType"
:key="loader"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ loader }}
</span>
</div>
<span class="truncate font-mono text-[10px] text-secondary">{{ mod.hash }}</span>
<div v-if="editingHash === mod.hash" class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.name) }}</span>
<input
v-model="editForm.name"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.modId) }}</span>
<input
v-model="editForm.modId"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.version) }}</span>
<input
v-model="editForm.version"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1 sm:col-span-3">
<span class="text-xs text-secondary">{{ formatMessage(messages.domainRules) }}</span>
<input
v-model="editForm.domainRules"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
</div>
<div class="flex flex-wrap gap-2">
<template v-if="editingHash === mod.hash">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="cancelEdit"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="savingMod"
@click="saveMod(mod)"
>
{{ formatMessage(messages.save) }}
</button>
</template>
<template v-else>
<span
v-for="rule in mod.domainRules"
:key="rule"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ rule }}
</span>
<button
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="startEdit(mod)"
>
{{ 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="deletingHash !== null"
@click="removeMod(mod)"
>
{{ formatMessage(messages.remove) }}
</button>
</template>
</div>
</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: modPage }) }} / {{ modPageCount }}
</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>
</template>
<template v-else-if="tab === 'tags'">
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openNewTag"
>
{{ formatMessage(messages.newTag) }}
</button>
</div>
<div v-if="tagsLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="!tags.length" class="rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="tag in tags"
:key="tag.id"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ tag.name }}
</span>
<span class="text-xs text-secondary">×{{ tag.rules.length }}</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEditTag(tag)"
>
{{ 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"
@click="removeTag(tag)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<div
v-if="tagEditorOpen"
class="flex flex-col gap-2 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.tagName) }}
</span>
<input
v-model="tagForm.name"
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.tagRules) }}
</span>
<textarea
v-model="tagForm.rules"
rows="5"
:placeholder="formatMessage(messages.tagRulesHint)"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 font-mono text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<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="closeTagEditor"
>
{{ 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="savingTag || !tagForm.name.trim()"
@click="saveTag"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-2">
<input ref="runtimeFile" type="file" class="hidden" @change="onRuntimeFile" />
<input
v-model="runtimeVersion"
type="text"
:placeholder="formatMessage(messages.runtimeVersion)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
/>
<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="runtimeUploading || !runtimeVersion.trim()"
@click="pickRuntimeFile"
>
{{
runtimeUploading
? formatMessage(messages.uploading)
: formatMessage(messages.uploadRuntime)
}}
</button>
</div>
<div
v-if="runtimeState && runtimeState.manifestUrl"
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.manifestUrl) }}
</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">
{{ runtimeState.manifestUrl }}
</code>
</div>
<div v-if="runtimeLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="!runtimeState || !runtimeState.releases.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="release in runtimeState.releases"
: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, { name: release.uploadedBy }) }} ·
{{ formatDateTime(release.uploadedAt) }}
</span>
</div>
<span class="shrink-0 text-xs text-secondary">
{{ formatSize(release.fileSize) }}
</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>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="release.available ? 'bg-button-bg text-secondary' : 'bg-orange text-contrast'"
>
{{
release.available
? formatMessage(messages.available)
: formatMessage(messages.unavailable)
}}
</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="activatingRuntimeId !== null"
@click="activateRuntime(release)"
>
{{ formatMessage(messages.activate) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingRuntimeId !== null || release.active"
@click="removeRuntime(release)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</template>
</div>
</template>
@@ -1532,3 +1532,167 @@ export function skinSiteDeleteDonation(
method: 'DELETE', method: 'DELETE',
}) })
} }
export type SkinSiteModLoaderType = 'FABRIC' | 'FORGE'
export interface SkinSiteModMetadata {
modId?: string
name?: string
version?: string
iconBytes?: string
loaderType: SkinSiteModLoaderType[]
}
export interface SkinSiteModInfo {
domainRules: string[]
hash: string
name: string
size: number
metadata: SkinSiteModMetadata
}
export interface SkinSiteModRuleTag {
id: number
name: string
rules: string[]
}
export function skinSiteModCount(params?: {
serverAddr?: string
ruleTagId?: number | null
}): Promise<SkinSiteResponse<number>> {
const query = new URLSearchParams()
const addr = params?.serverAddr?.trim()
if (addr) query.set('server_addr', addr)
if (params?.ruleTagId != null) query.set('rule_tag_id', String(params.ruleTagId))
const suffix = query.toString() ? '?' + query.toString() : ''
return skinSiteRequest<number>('query/mod/number' + suffix, { type: 'mod' })
}
export function skinSiteModList(params: {
limit: number
offset: number
serverAddr?: string
ruleTagId?: number | null
}): Promise<SkinSiteResponse<SkinSiteModInfo[]>> {
return skinSiteRequest<SkinSiteModInfo[]>('query/mod/list', {
method: 'POST',
type: 'mod',
body: {
offset: params.offset || 0,
limit: params.limit || 0,
serverAddr: params.serverAddr?.trim() || undefined,
ruleTagId: params.ruleTagId ?? undefined,
},
})
}
export function skinSiteModSearch(
name: string,
ruleTagId?: number | null,
): Promise<SkinSiteResponse<SkinSiteModInfo[]>> {
const query = new URLSearchParams()
if (ruleTagId != null) query.set('rule_tag_id', String(ruleTagId))
const suffix = query.toString() ? '?' + query.toString() : ''
return skinSiteRequest<SkinSiteModInfo[]>(
'query/mod/search/' + encodeURIComponent(name) + suffix,
{ type: 'mod' },
)
}
export function skinSiteUpdateModInfo(info: SkinSiteModInfo): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('update/info', { method: 'POST', type: 'mod', body: info })
}
export function skinSiteDeleteModFile(hash: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(hash, { method: 'DELETE', type: 'mod' })
}
export function skinSiteDeleteMultiModFile(hashes: string[]): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('delete/multimod', {
method: 'POST',
type: 'mod',
body: { modHashes: hashes },
})
}
export function skinSiteModRuleTags(): Promise<SkinSiteResponse<SkinSiteModRuleTag[]>> {
return skinSiteRequest<SkinSiteModRuleTag[]>('rule-tags', { type: 'mod' })
}
export function skinSiteCreateModRuleTag(
name: string,
rules: string[],
): Promise<SkinSiteResponse<SkinSiteModRuleTag>> {
return skinSiteRequest<SkinSiteModRuleTag>('rule-tags', {
method: 'POST',
type: 'mod',
body: { name, rules },
})
}
export function skinSiteUpdateModRuleTag(
id: number,
name: string,
rules: string[],
): Promise<SkinSiteResponse<SkinSiteModRuleTag>> {
return skinSiteRequest<SkinSiteModRuleTag>('rule-tags/' + id, {
method: 'POST',
type: 'mod',
body: { name, rules },
})
}
export function skinSiteDeleteModRuleTag(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('rule-tags/' + id, { method: 'DELETE', type: 'mod' })
}
export interface SkinSiteModRuntimeRelease {
id: number
version: string
fileName: string
sha256: string
fileSize: number
uploadedBy: string
uploadedAt: number
publishedAt: number | null
active: boolean
available: boolean
downloadUrl: string
}
export interface SkinSiteModRuntimeAdminState {
manifestUrl: string
releases: SkinSiteModRuntimeRelease[]
}
export function skinSiteModRuntimeReleases(): Promise<
SkinSiteResponse<SkinSiteModRuntimeAdminState>
> {
return skinSiteRequest<SkinSiteModRuntimeAdminState>('runtime/admin', { type: 'mod' })
}
export function skinSiteUploadModRuntime(
version: string,
file: File,
): Promise<SkinSiteResponse<SkinSiteModRuntimeRelease>> {
const form = new FormData()
form.append('runtime', file, file.name)
return skinSiteRequest<SkinSiteModRuntimeRelease>(
'runtime/admin/upload?version=' + encodeURIComponent(version),
{ method: 'POST', type: 'mod', form },
)
}
export function skinSiteActivateModRuntime(
id: number,
): Promise<SkinSiteResponse<SkinSiteModRuntimeRelease>> {
return skinSiteRequest<SkinSiteModRuntimeRelease>('runtime/admin/' + id + '/activate', {
method: 'POST',
type: 'mod',
})
}
export function skinSiteDeleteModRuntime(id: number): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('runtime/admin/' + id, { method: 'DELETE', type: 'mod' })
}
+48 -1
View File
@@ -12049,5 +12049,52 @@
"app.skin-site.admin.donations.updated": {"message": "Donation updated."}, "app.skin-site.admin.donations.updated": {"message": "Donation updated."},
"app.skin-site.admin.donations.deleted": {"message": "Donation deleted."}, "app.skin-site.admin.donations.deleted": {"message": "Donation deleted."},
"app.skin-site.admin.donations.confirm-delete": {"message": "Delete the donation from “{name}”? This cannot be undone."}, "app.skin-site.admin.donations.confirm-delete": {"message": "Delete the donation from “{name}”? This cannot be undone."},
"app.skin-site.admin.donations.load-more": {"message": "Load more"} "app.skin-site.admin.donations.load-more": {"message": "Load more"},
"app.skin-site.admin.mods.title": {"message": "Mods"},
"app.skin-site.admin.mods.tab-mods": {"message": "Mod files"},
"app.skin-site.admin.mods.tab-tags": {"message": "Rule tags"},
"app.skin-site.admin.mods.tab-runtime": {"message": "Runtime"},
"app.skin-site.admin.mods.loading": {"message": "Loading…"},
"app.skin-site.admin.mods.failed": {"message": "Could not load mods."},
"app.skin-site.admin.mods.empty": {"message": "No mods found."},
"app.skin-site.admin.mods.total": {"message": "{count} mods"},
"app.skin-site.admin.mods.search": {"message": "Search by name"},
"app.skin-site.admin.mods.filter-all": {"message": "All tags"},
"app.skin-site.admin.mods.prev": {"message": "Previous"},
"app.skin-site.admin.mods.next": {"message": "Next"},
"app.skin-site.admin.mods.page": {"message": "Page {page}"},
"app.skin-site.admin.mods.name": {"message": "Name"},
"app.skin-site.admin.mods.version": {"message": "Version"},
"app.skin-site.admin.mods.mod-id": {"message": "Mod ID"},
"app.skin-site.admin.mods.size": {"message": "Size"},
"app.skin-site.admin.mods.loaders": {"message": "Loaders"},
"app.skin-site.admin.mods.domain-rules": {"message": "Domain rules"},
"app.skin-site.admin.mods.edit": {"message": "Edit"},
"app.skin-site.admin.mods.save": {"message": "Save"},
"app.skin-site.admin.mods.cancel": {"message": "Cancel"},
"app.skin-site.admin.mods.remove": {"message": "Delete"},
"app.skin-site.admin.mods.updated": {"message": "Mod updated."},
"app.skin-site.admin.mods.deleted": {"message": "Mod deleted."},
"app.skin-site.admin.mods.confirm-delete": {"message": "Delete mod “{name}”? This cannot be undone."},
"app.skin-site.admin.mods.rule-tags": {"message": "Rule tags"},
"app.skin-site.admin.mods.tag-name": {"message": "Tag name"},
"app.skin-site.admin.mods.tag-rules": {"message": "Rules"},
"app.skin-site.admin.mods.tag-rules-hint": {"message": "One rule per line."},
"app.skin-site.admin.mods.new-tag": {"message": "New tag"},
"app.skin-site.admin.mods.tag-created": {"message": "Tag created."},
"app.skin-site.admin.mods.tag-updated": {"message": "Tag updated."},
"app.skin-site.admin.mods.tag-deleted": {"message": "Tag deleted."},
"app.skin-site.admin.mods.confirm-delete-tag": {"message": "Delete tag “{name}”?"},
"app.skin-site.admin.mods.runtime": {"message": "Runtime releases"},
"app.skin-site.admin.mods.manifest-url": {"message": "Manifest URL"},
"app.skin-site.admin.mods.upload-runtime": {"message": "Upload runtime"},
"app.skin-site.admin.mods.runtime-version": {"message": "Version"},
"app.skin-site.admin.mods.uploading": {"message": "Uploading…"},
"app.skin-site.admin.mods.runtime-uploaded": {"message": "Runtime uploaded."},
"app.skin-site.admin.mods.activate": {"message": "Activate"},
"app.skin-site.admin.mods.activated": {"message": "Runtime activated."},
"app.skin-site.admin.mods.available": {"message": "Available"},
"app.skin-site.admin.mods.unavailable": {"message": "Unavailable"},
"app.skin-site.admin.mods.active": {"message": "Active"},
"app.skin-site.admin.mods.uploader": {"message": "by {name}"}
} }
+48 -1
View File
@@ -11880,5 +11880,52 @@
"app.skin-site.admin.donations.updated": {"message": "捐赠记录已更新。"}, "app.skin-site.admin.donations.updated": {"message": "捐赠记录已更新。"},
"app.skin-site.admin.donations.deleted": {"message": "捐赠记录已删除。"}, "app.skin-site.admin.donations.deleted": {"message": "捐赠记录已删除。"},
"app.skin-site.admin.donations.confirm-delete": {"message": "确定删除「{name}」的捐赠记录吗?此操作不可撤销。"}, "app.skin-site.admin.donations.confirm-delete": {"message": "确定删除「{name}」的捐赠记录吗?此操作不可撤销。"},
"app.skin-site.admin.donations.load-more": {"message": "加载更多"} "app.skin-site.admin.donations.load-more": {"message": "加载更多"},
"app.skin-site.admin.mods.title": {"message": "Mod 管理"},
"app.skin-site.admin.mods.tab-mods": {"message": "Mod 文件"},
"app.skin-site.admin.mods.tab-tags": {"message": "规则标签"},
"app.skin-site.admin.mods.tab-runtime": {"message": "运行时"},
"app.skin-site.admin.mods.loading": {"message": "加载中…"},
"app.skin-site.admin.mods.failed": {"message": "无法加载 Mod 列表。"},
"app.skin-site.admin.mods.empty": {"message": "没有找到 Mod。"},
"app.skin-site.admin.mods.total": {"message": "共 {count} 个 Mod"},
"app.skin-site.admin.mods.search": {"message": "按名称搜索"},
"app.skin-site.admin.mods.filter-all": {"message": "全部标签"},
"app.skin-site.admin.mods.prev": {"message": "上一页"},
"app.skin-site.admin.mods.next": {"message": "下一页"},
"app.skin-site.admin.mods.page": {"message": "第 {page} 页"},
"app.skin-site.admin.mods.name": {"message": "名称"},
"app.skin-site.admin.mods.version": {"message": "版本"},
"app.skin-site.admin.mods.mod-id": {"message": "Mod ID"},
"app.skin-site.admin.mods.size": {"message": "大小"},
"app.skin-site.admin.mods.loaders": {"message": "加载器"},
"app.skin-site.admin.mods.domain-rules": {"message": "域名规则"},
"app.skin-site.admin.mods.edit": {"message": "编辑"},
"app.skin-site.admin.mods.save": {"message": "保存"},
"app.skin-site.admin.mods.cancel": {"message": "取消"},
"app.skin-site.admin.mods.remove": {"message": "删除"},
"app.skin-site.admin.mods.updated": {"message": "Mod 已更新。"},
"app.skin-site.admin.mods.deleted": {"message": "Mod 已删除。"},
"app.skin-site.admin.mods.confirm-delete": {"message": "确定删除 Mod「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.mods.rule-tags": {"message": "规则标签"},
"app.skin-site.admin.mods.tag-name": {"message": "标签名称"},
"app.skin-site.admin.mods.tag-rules": {"message": "规则"},
"app.skin-site.admin.mods.tag-rules-hint": {"message": "每行一条规则。"},
"app.skin-site.admin.mods.new-tag": {"message": "新建标签"},
"app.skin-site.admin.mods.tag-created": {"message": "标签已创建。"},
"app.skin-site.admin.mods.tag-updated": {"message": "标签已更新。"},
"app.skin-site.admin.mods.tag-deleted": {"message": "标签已删除。"},
"app.skin-site.admin.mods.confirm-delete-tag": {"message": "确定删除标签「{name}」吗?"},
"app.skin-site.admin.mods.runtime": {"message": "运行时版本"},
"app.skin-site.admin.mods.manifest-url": {"message": "清单地址"},
"app.skin-site.admin.mods.upload-runtime": {"message": "上传运行时"},
"app.skin-site.admin.mods.runtime-version": {"message": "版本"},
"app.skin-site.admin.mods.uploading": {"message": "上传中…"},
"app.skin-site.admin.mods.runtime-uploaded": {"message": "运行时已上传。"},
"app.skin-site.admin.mods.activate": {"message": "设为当前"},
"app.skin-site.admin.mods.activated": {"message": "运行时已启用。"},
"app.skin-site.admin.mods.available": {"message": "可用"},
"app.skin-site.admin.mods.unavailable": {"message": "不可用"},
"app.skin-site.admin.mods.active": {"message": "当前版本"},
"app.skin-site.admin.mods.uploader": {"message": "由 {name} 上传"}
} }
+1
View File
@@ -34,6 +34,7 @@ const allowedUntranslatedMessages = new Set([
'QQ', 'QQ',
'IP', 'IP',
'URL', 'URL',
'Mod ID',
'LogShare AI', 'LogShare AI',
'NeoForge', 'NeoForge',
'OptiFine', 'OptiFine',