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 { 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 emit = defineEmits<{ (e: 'back'): void }>()
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
@@ -151,10 +154,9 @@ const current = ref<AdminKey>('summary')
|
|||||||
<section
|
<section
|
||||||
class="min-w-0 flex-1 overflow-y-auto rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
|
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">
|
<AdminSummaryPage v-if="current === 'summary'" />
|
||||||
{{ formatMessage(messages.title) }} · {{ current }}
|
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
|
||||||
</h1>
|
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
|
||||||
<!-- Page content is filled in one admin screen at a time. -->
|
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
</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 },
|
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": {"message": "Luck"},
|
||||||
"app.skin-site.home.luck-unavailable": {"message": "Unavailable"},
|
"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-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": {"message": "幸运值"},
|
||||||
"app.skin-site.home.luck-unavailable": {"message": "不可用"},
|
"app.skin-site.home.luck-unavailable": {"message": "不可用"},
|
||||||
"app.skin-site.home.signed-out-title": {"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