1 parent
77258eee9d
commit
799d542227
6 files changed
+650
-6
No files matched your search
@@ -4,6 +4,9 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import { skinSiteUser } from '@/composables/skin-site-session'
|
||||
|
||||
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
|
||||
import AdminSummaryPage from './admin/AdminSummaryPage.vue'
|
||||
|
||||
const emit = defineEmits<{ (e: 'back'): void }>()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
@@ -151,10 +154,9 @@ const current = ref<AdminKey>('summary')
|
||||
<section
|
||||
class="min-w-0 flex-1 overflow-y-auto rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
|
||||
>
|
||||
<h1 class="m-0 mb-3 text-xl font-bold text-contrast">
|
||||
{{ formatMessage(messages.title) }} · {{ current }}
|
||||
</h1>
|
||||
<!-- Page content is filled in one admin screen at a time. -->
|
||||
<AdminSummaryPage v-if="current === 'summary'" />
|
||||
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
|
||||
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSiteBroadcast,
|
||||
type SkinSiteBroadcastInput,
|
||||
skinSiteBroadcasts,
|
||||
skinSiteCreateBroadcast,
|
||||
skinSiteDeleteBroadcast,
|
||||
skinSiteUpdateBroadcast,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.admin.broadcast.title', defaultMessage: 'Announcements' },
|
||||
loading: { id: 'app.skin-site.admin.broadcast.loading', defaultMessage: 'Loading…' },
|
||||
failed: { id: 'app.skin-site.admin.broadcast.failed', defaultMessage: 'Could not load.' },
|
||||
empty: { id: 'app.skin-site.admin.broadcast.empty', defaultMessage: 'No announcements yet.' },
|
||||
create: { id: 'app.skin-site.admin.broadcast.create', defaultMessage: 'New announcement' },
|
||||
save: { id: 'app.skin-site.admin.broadcast.save', defaultMessage: 'Save' },
|
||||
cancel: { id: 'app.skin-site.admin.broadcast.cancel', defaultMessage: 'Cancel' },
|
||||
edit: { id: 'app.skin-site.admin.broadcast.edit', defaultMessage: 'Edit' },
|
||||
remove: { id: 'app.skin-site.admin.broadcast.remove', defaultMessage: 'Delete' },
|
||||
titleField: { id: 'app.skin-site.admin.broadcast.title-field', defaultMessage: 'Title' },
|
||||
subtitleField: { id: 'app.skin-site.admin.broadcast.subtitle-field', defaultMessage: 'Subtitle' },
|
||||
contentField: { id: 'app.skin-site.admin.broadcast.content-field', defaultMessage: 'Content' },
|
||||
expireAt: { id: 'app.skin-site.admin.broadcast.expire-at', defaultMessage: 'Expires at' },
|
||||
star: { id: 'app.skin-site.admin.broadcast.star', defaultMessage: 'Highlight' },
|
||||
level: { id: 'app.skin-site.admin.broadcast.level', defaultMessage: 'Importance' },
|
||||
image: { id: 'app.skin-site.admin.broadcast.image', defaultMessage: 'Image' },
|
||||
removeImage: { id: 'app.skin-site.admin.broadcast.remove-image', defaultMessage: 'Remove image' },
|
||||
created: { id: 'app.skin-site.admin.broadcast.created', defaultMessage: 'Announcement created.' },
|
||||
updated: { id: 'app.skin-site.admin.broadcast.updated', defaultMessage: 'Announcement updated.' },
|
||||
deleted: { id: 'app.skin-site.admin.broadcast.deleted', defaultMessage: 'Announcement deleted.' },
|
||||
confirmDelete: {
|
||||
id: 'app.skin-site.admin.broadcast.confirm-delete',
|
||||
defaultMessage: 'Delete “{title}”? This cannot be undone.',
|
||||
},
|
||||
uploader: { id: 'app.skin-site.admin.broadcast.uploader', defaultMessage: 'by {name}' },
|
||||
})
|
||||
|
||||
const items = ref<SkinSiteBroadcast[]>([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const saving = ref(false)
|
||||
const deletingId = ref<number | null>(null)
|
||||
|
||||
const editorOpen = ref(false)
|
||||
const editingId = ref<number | null>(null)
|
||||
const form = ref({
|
||||
title: '',
|
||||
subtitle: '',
|
||||
content: '',
|
||||
expireAt: '',
|
||||
star: false,
|
||||
importantLevel: 0,
|
||||
})
|
||||
const imageFile = ref<File | null>(null)
|
||||
const removeImage = ref(false)
|
||||
|
||||
const sorted = computed(() => [...items.value].sort((a, b) => b.id - a.id))
|
||||
|
||||
function formatDate(ts: number): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function toDateInput(ts: number): string {
|
||||
try {
|
||||
const d = new Date(ts)
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
return (
|
||||
d.getFullYear() +
|
||||
'-' +
|
||||
pad(d.getMonth() + 1) +
|
||||
'-' +
|
||||
pad(d.getDate()) +
|
||||
'T' +
|
||||
pad(d.getHours()) +
|
||||
':' +
|
||||
pad(d.getMinutes())
|
||||
)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
function resetForm() {
|
||||
form.value = {
|
||||
title: '',
|
||||
subtitle: '',
|
||||
content: '',
|
||||
expireAt: '',
|
||||
star: false,
|
||||
importantLevel: 0,
|
||||
}
|
||||
imageFile.value = null
|
||||
removeImage.value = false
|
||||
editingId.value = null
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
resetForm()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(item: SkinSiteBroadcast) {
|
||||
form.value = {
|
||||
title: item.title,
|
||||
subtitle: item.subtitle,
|
||||
content: item.content,
|
||||
expireAt: toDateInput(item.expireAt),
|
||||
star: item.star,
|
||||
importantLevel: item.importantLevel,
|
||||
}
|
||||
imageFile.value = null
|
||||
removeImage.value = false
|
||||
editingId.value = item.id
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorOpen.value = false
|
||||
resetForm()
|
||||
}
|
||||
|
||||
function onImageChange(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
imageFile.value = input.files?.[0] ?? null
|
||||
}
|
||||
|
||||
function buildInput(): SkinSiteBroadcastInput {
|
||||
const ts = form.value.expireAt ? new Date(form.value.expireAt).getTime() : 0
|
||||
return {
|
||||
title: form.value.title.trim(),
|
||||
subtitle: form.value.subtitle.trim(),
|
||||
content: form.value.content,
|
||||
expireAt: Number.isFinite(ts) ? ts : 0,
|
||||
star: form.value.star,
|
||||
importantLevel: Number(form.value.importantLevel) || 0,
|
||||
imageFile: imageFile.value,
|
||||
removeImage: removeImage.value,
|
||||
}
|
||||
}
|
||||
|
||||
const canSave = computed(
|
||||
() => form.value.title.trim().length > 0 && form.value.content.length > 0 && !saving.value,
|
||||
)
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await skinSiteBroadcasts()
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
items.value = result.payload ?? []
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (!canSave.value) return
|
||||
saving.value = true
|
||||
try {
|
||||
const input = buildInput()
|
||||
const result =
|
||||
editingId.value === null
|
||||
? await skinSiteCreateBroadcast(input)
|
||||
: await skinSiteUpdateBroadcast(editingId.value, input)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(editingId.value === null ? messages.created : messages.updated),
|
||||
})
|
||||
closeEditor()
|
||||
await load()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function remove(item: SkinSiteBroadcast) {
|
||||
if (deletingId.value !== null) return
|
||||
if (!window.confirm(formatMessage(messages.confirmDelete, { title: item.title }))) return
|
||||
deletingId.value = item.id
|
||||
try {
|
||||
const result = await skinSiteDeleteBroadcast(item.id)
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
|
||||
await load()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void load())
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
<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.create) }}
|
||||
</button>
|
||||
</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="!sorted.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 sorted"
|
||||
:key="item.id"
|
||||
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
|
||||
>
|
||||
<span class="shrink-0 text-xs text-secondary">#{{ item.id }}</span>
|
||||
<div class="flex min-w-0 flex-1 flex-col">
|
||||
<span class="truncate text-sm font-semibold text-contrast">{{ item.title }}</span>
|
||||
<span v-if="item.subtitle" class="truncate text-xs text-secondary">
|
||||
{{ item.subtitle }}
|
||||
</span>
|
||||
</div>
|
||||
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
|
||||
<span v-if="item.uploader" class="shrink-0 text-xs text-secondary">
|
||||
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="shrink-0 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="shrink-0 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
|
||||
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.titleField) }}
|
||||
</span>
|
||||
<input
|
||||
v-model="form.title"
|
||||
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.subtitleField) }}
|
||||
</span>
|
||||
<input
|
||||
v-model="form.subtitle"
|
||||
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.contentField) }}
|
||||
</span>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
rows="5"
|
||||
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-wrap items-end gap-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.expireAt) }}
|
||||
</span>
|
||||
<input
|
||||
v-model="form.expireAt"
|
||||
type="datetime-local"
|
||||
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.level) }}
|
||||
</span>
|
||||
<input
|
||||
v-model.number="form.importantLevel"
|
||||
type="number"
|
||||
min="0"
|
||||
max="9"
|
||||
class="w-20 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 items-center gap-2 pb-2">
|
||||
<input v-model="form.star" type="checkbox" />
|
||||
<span class="text-sm text-contrast">{{ formatMessage(messages.star) }}</span>
|
||||
</label>
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-sm font-semibold text-contrast">
|
||||
{{ formatMessage(messages.image) }}
|
||||
</span>
|
||||
<input type="file" accept="image/*" @change="onImageChange" />
|
||||
</label>
|
||||
<label v-if="editingId !== null" class="flex items-center gap-2 pb-2">
|
||||
<input v-model="removeImage" type="checkbox" />
|
||||
<span class="text-sm text-contrast">{{ formatMessage(messages.removeImage) }}</span>
|
||||
</label>
|
||||
</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>
|
||||
@@ -0,0 +1,124 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import { type SkinSiteAdminStatus, skinSiteAdminStatus } from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.admin.summary.title', defaultMessage: 'System overview' },
|
||||
loading: { id: 'app.skin-site.admin.summary.loading', defaultMessage: 'Loading…' },
|
||||
failed: { id: 'app.skin-site.admin.summary.failed', defaultMessage: 'Could not load status.' },
|
||||
uptime: { id: 'app.skin-site.admin.summary.uptime', defaultMessage: 'Uptime' },
|
||||
memory: { id: 'app.skin-site.admin.summary.memory', defaultMessage: 'Memory usage' },
|
||||
users: { id: 'app.skin-site.admin.summary.users', defaultMessage: 'Users' },
|
||||
players: { id: 'app.skin-site.admin.summary.players', defaultMessage: 'Players' },
|
||||
realtimeHint: {
|
||||
id: 'app.skin-site.admin.summary.realtime-hint',
|
||||
defaultMessage:
|
||||
'Live CPU and GC charts require the admin WebSocket, which is not connected yet.',
|
||||
},
|
||||
})
|
||||
|
||||
const status = ref<SkinSiteAdminStatus | null>(null)
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
const now = ref(Date.now())
|
||||
let ticker: number | undefined
|
||||
|
||||
const uptime = computed(() => {
|
||||
if (!status.value) return ''
|
||||
let ms = now.value - status.value.bootTime
|
||||
if (ms < 0) ms = 0
|
||||
const totalSeconds = Math.floor(ms / 1000)
|
||||
const days = Math.floor(totalSeconds / 86400)
|
||||
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||
const seconds = totalSeconds % 60
|
||||
const parts: string[] = []
|
||||
if (days) parts.push(days + 'd')
|
||||
if (hours) parts.push(hours + 'h')
|
||||
if (minutes) parts.push(minutes + 'm')
|
||||
parts.push(seconds + 's')
|
||||
return parts.join(' ')
|
||||
})
|
||||
|
||||
function formatSize(bytes: number): string {
|
||||
if (!Number.isFinite(bytes) || bytes < 0) return '—'
|
||||
const units = ['B', 'KB', 'MB', 'GB', 'TB']
|
||||
let value = bytes
|
||||
let idx = 0
|
||||
while (value >= 1024 && idx < units.length - 1) {
|
||||
value /= 1024
|
||||
idx++
|
||||
}
|
||||
return value.toFixed(idx === 0 ? 0 : 2) + ' ' + units[idx]
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const result = await skinSiteAdminStatus()
|
||||
if (!result.success) throw new Error(result.errorMessage)
|
||||
status.value = result.payload
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void load()
|
||||
ticker = window.setInterval(() => {
|
||||
now.value = Date.now()
|
||||
}, 1000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (ticker) clearInterval(ticker)
|
||||
})
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
|
||||
|
||||
<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>
|
||||
|
||||
<template v-else-if="status">
|
||||
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.uptime) }}</span>
|
||||
<span class="text-xl font-bold text-contrast">{{ uptime }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.memory) }}</span>
|
||||
<span class="text-xl font-bold text-contrast">{{ formatSize(status.memoryUsage) }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.users) }}</span>
|
||||
<span class="text-xl font-bold text-contrast">{{ status.userCount }}</span>
|
||||
</div>
|
||||
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
|
||||
<span class="text-xs text-secondary">{{ formatMessage(messages.players) }}</span>
|
||||
<span class="text-xl font-bold text-contrast">{{ status.playerCount }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="m-0 rounded-xl border border-dashed border-surface-5 p-3 text-xs text-secondary">
|
||||
{{ formatMessage(messages.realtimeHint) }}
|
||||
</p>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -812,3 +812,95 @@ export function skinSiteMarkBugNotificationRead(
|
||||
body: issueId === undefined ? {} : { issueId },
|
||||
})
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Admin console
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface SkinSiteAdminStatus {
|
||||
bootTime: number
|
||||
memoryUsage: number
|
||||
userCount: number
|
||||
playerCount: number
|
||||
}
|
||||
|
||||
export function skinSiteAdminStatus(): Promise<SkinSiteResponse<SkinSiteAdminStatus>> {
|
||||
return skinSiteRequest<SkinSiteAdminStatus>('admin/status')
|
||||
}
|
||||
|
||||
export interface SkinSiteBroadcast {
|
||||
id: number
|
||||
title: string
|
||||
subtitle: string
|
||||
imageName?: string
|
||||
content: string
|
||||
updateAt: number
|
||||
star: boolean
|
||||
importantLevel: number
|
||||
source: string
|
||||
expireAt: number
|
||||
uploader: string
|
||||
}
|
||||
|
||||
export interface SkinSiteBroadcastInput {
|
||||
title: string
|
||||
subtitle: string
|
||||
content: string
|
||||
expireAt: number
|
||||
star: boolean
|
||||
importantLevel: number
|
||||
imageFile?: File | null
|
||||
removeImage?: boolean
|
||||
}
|
||||
|
||||
export function skinSiteBroadcasts(): Promise<SkinSiteResponse<SkinSiteBroadcast[]>> {
|
||||
return skinSiteRequest<SkinSiteBroadcast[]>('broadcast/all/complex', { type: 'skin' })
|
||||
}
|
||||
|
||||
export function skinSiteCreateBroadcast(
|
||||
input: SkinSiteBroadcastInput,
|
||||
): Promise<SkinSiteResponse<string>> {
|
||||
const form = new FormData()
|
||||
form.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
title: input.title,
|
||||
subtitle: input.subtitle,
|
||||
content: input.content,
|
||||
expireAt: input.expireAt,
|
||||
star: input.star,
|
||||
importantLevel: input.importantLevel,
|
||||
}),
|
||||
)
|
||||
if (input.imageFile) form.append('image', input.imageFile)
|
||||
return skinSiteRequest<string>('broadcast', { method: 'PUT', type: 'skin', form })
|
||||
}
|
||||
|
||||
export function skinSiteUpdateBroadcast(
|
||||
id: number,
|
||||
input: SkinSiteBroadcastInput,
|
||||
): Promise<SkinSiteResponse<string>> {
|
||||
const form = new FormData()
|
||||
form.append(
|
||||
'payload',
|
||||
JSON.stringify({
|
||||
title: input.title,
|
||||
subtitle: input.subtitle,
|
||||
content: input.content,
|
||||
expireAt: input.expireAt,
|
||||
star: input.star,
|
||||
importantLevel: input.importantLevel,
|
||||
removeImage: input.removeImage ?? false,
|
||||
}),
|
||||
)
|
||||
if (input.imageFile) form.append('image', input.imageFile)
|
||||
return skinSiteRequest<string>('broadcast/update/' + id, {
|
||||
method: 'PUT',
|
||||
type: 'skin',
|
||||
form,
|
||||
})
|
||||
}
|
||||
|
||||
export function skinSiteDeleteBroadcast(id: number): Promise<SkinSiteResponse<string>> {
|
||||
return skinSiteRequest<string>('broadcast/' + id, { method: 'DELETE', type: 'skin' })
|
||||
}
|
||||
@@ -11800,5 +11800,35 @@
|
||||
"app.skin-site.home.luck": {"message": "Luck"},
|
||||
"app.skin-site.home.luck-unavailable": {"message": "Unavailable"},
|
||||
"app.skin-site.home.signed-out-title": {"message": "Not signed in"},
|
||||
"app.skin-site.home.signed-out-body": {"message": "Sign in to manage your account and textures."}
|
||||
"app.skin-site.home.signed-out-body": {"message": "Sign in to manage your account and textures."},
|
||||
"app.skin-site.admin.summary.title": {"message": "System overview"},
|
||||
"app.skin-site.admin.summary.loading": {"message": "Loading…"},
|
||||
"app.skin-site.admin.summary.failed": {"message": "Could not load status."},
|
||||
"app.skin-site.admin.summary.uptime": {"message": "Uptime"},
|
||||
"app.skin-site.admin.summary.memory": {"message": "Memory usage"},
|
||||
"app.skin-site.admin.summary.users": {"message": "Users"},
|
||||
"app.skin-site.admin.summary.players": {"message": "Players"},
|
||||
"app.skin-site.admin.summary.realtime-hint": {"message": "Live CPU and GC charts require the admin WebSocket, which is not connected yet."},
|
||||
"app.skin-site.admin.broadcast.title": {"message": "Announcements"},
|
||||
"app.skin-site.admin.broadcast.loading": {"message": "Loading…"},
|
||||
"app.skin-site.admin.broadcast.failed": {"message": "Could not load."},
|
||||
"app.skin-site.admin.broadcast.empty": {"message": "No announcements yet."},
|
||||
"app.skin-site.admin.broadcast.create": {"message": "New announcement"},
|
||||
"app.skin-site.admin.broadcast.save": {"message": "Save"},
|
||||
"app.skin-site.admin.broadcast.cancel": {"message": "Cancel"},
|
||||
"app.skin-site.admin.broadcast.edit": {"message": "Edit"},
|
||||
"app.skin-site.admin.broadcast.remove": {"message": "Delete"},
|
||||
"app.skin-site.admin.broadcast.title-field": {"message": "Title"},
|
||||
"app.skin-site.admin.broadcast.subtitle-field": {"message": "Subtitle"},
|
||||
"app.skin-site.admin.broadcast.content-field": {"message": "Content"},
|
||||
"app.skin-site.admin.broadcast.expire-at": {"message": "Expires at"},
|
||||
"app.skin-site.admin.broadcast.star": {"message": "Highlight"},
|
||||
"app.skin-site.admin.broadcast.level": {"message": "Importance"},
|
||||
"app.skin-site.admin.broadcast.image": {"message": "Image"},
|
||||
"app.skin-site.admin.broadcast.remove-image": {"message": "Remove image"},
|
||||
"app.skin-site.admin.broadcast.created": {"message": "Announcement created."},
|
||||
"app.skin-site.admin.broadcast.updated": {"message": "Announcement updated."},
|
||||
"app.skin-site.admin.broadcast.deleted": {"message": "Announcement deleted."},
|
||||
"app.skin-site.admin.broadcast.confirm-delete": {"message": "Delete “{title}”? This cannot be undone."},
|
||||
"app.skin-site.admin.broadcast.uploader": {"message": "by {name}"}
|
||||
}
|
||||
@@ -11631,5 +11631,35 @@
|
||||
"app.skin-site.home.luck": {"message": "幸运值"},
|
||||
"app.skin-site.home.luck-unavailable": {"message": "不可用"},
|
||||
"app.skin-site.home.signed-out-title": {"message": "未登录"},
|
||||
"app.skin-site.home.signed-out-body": {"message": "登录后可管理你的账号与皮肤。"}
|
||||
"app.skin-site.home.signed-out-body": {"message": "登录后可管理你的账号与皮肤。"},
|
||||
"app.skin-site.admin.summary.title": {"message": "系统概况"},
|
||||
"app.skin-site.admin.summary.loading": {"message": "加载中…"},
|
||||
"app.skin-site.admin.summary.failed": {"message": "无法加载状态。"},
|
||||
"app.skin-site.admin.summary.uptime": {"message": "运行时长"},
|
||||
"app.skin-site.admin.summary.memory": {"message": "内存占用"},
|
||||
"app.skin-site.admin.summary.users": {"message": "用户数量"},
|
||||
"app.skin-site.admin.summary.players": {"message": "玩家数量"},
|
||||
"app.skin-site.admin.summary.realtime-hint": {"message": "实时 CPU 与 GC 图表依赖管理端 WebSocket,目前尚未接入。"},
|
||||
"app.skin-site.admin.broadcast.title": {"message": "公告"},
|
||||
"app.skin-site.admin.broadcast.loading": {"message": "加载中…"},
|
||||
"app.skin-site.admin.broadcast.failed": {"message": "加载失败。"},
|
||||
"app.skin-site.admin.broadcast.empty": {"message": "尚未发布过任何公告。"},
|
||||
"app.skin-site.admin.broadcast.create": {"message": "发布新公告"},
|
||||
"app.skin-site.admin.broadcast.save": {"message": "保存"},
|
||||
"app.skin-site.admin.broadcast.cancel": {"message": "取消"},
|
||||
"app.skin-site.admin.broadcast.edit": {"message": "编辑"},
|
||||
"app.skin-site.admin.broadcast.remove": {"message": "删除"},
|
||||
"app.skin-site.admin.broadcast.title-field": {"message": "标题"},
|
||||
"app.skin-site.admin.broadcast.subtitle-field": {"message": "副标题"},
|
||||
"app.skin-site.admin.broadcast.content-field": {"message": "内容"},
|
||||
"app.skin-site.admin.broadcast.expire-at": {"message": "过期时间"},
|
||||
"app.skin-site.admin.broadcast.star": {"message": "标星"},
|
||||
"app.skin-site.admin.broadcast.level": {"message": "重要程度"},
|
||||
"app.skin-site.admin.broadcast.image": {"message": "图片"},
|
||||
"app.skin-site.admin.broadcast.remove-image": {"message": "移除图片"},
|
||||
"app.skin-site.admin.broadcast.created": {"message": "公告已发布。"},
|
||||
"app.skin-site.admin.broadcast.updated": {"message": "公告已更新。"},
|
||||
"app.skin-site.admin.broadcast.deleted": {"message": "公告已删除。"},
|
||||
"app.skin-site.admin.broadcast.confirm-delete": {"message": "确定删除「{title}」吗?此操作不可撤销。"},
|
||||
"app.skin-site.admin.broadcast.uploader": {"message": "由 {name} 发布"}
|
||||
}
|
||||
Reference in new issue
Block a user