feat: 新增皮肤站管理台服务器与审计页

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

No files matched your search

@@ -4,11 +4,13 @@ import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session' import { skinSiteUser } from '@/composables/skin-site-session'
import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue' import AdminBroadcastPage from './admin/AdminBroadcastPage.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 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 AdminSummaryPage from './admin/AdminSummaryPage.vue' import AdminSummaryPage from './admin/AdminSummaryPage.vue'
import AdminUsersPage from './admin/AdminUsersPage.vue' import AdminUsersPage from './admin/AdminUsersPage.vue'
@@ -160,6 +162,8 @@ const current = ref<AdminKey>('summary')
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"
> >
<AdminSummaryPage v-if="current === 'summary'" /> <AdminSummaryPage v-if="current === 'summary'" />
<AdminServersPage v-else-if="current === 'servers'" />
<AdminAccessAuditPage v-else-if="current === 'access-audit'" />
<AdminBroadcastPage v-else-if="current === 'broadcast'" /> <AdminBroadcastPage v-else-if="current === 'broadcast'" />
<AdminUsersPage v-else-if="current === 'users'" /> <AdminUsersPage v-else-if="current === 'users'" />
<AdminPlayersPage v-else-if="current === 'players'" /> <AdminPlayersPage v-else-if="current === 'players'" />
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
skinSiteAccessAudit,
type SkinSiteAccessAuditEntry,
skinSiteUpdateAccessAuditSettings,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.audit.title', defaultMessage: 'Access audit' },
loading: { id: 'app.skin-site.admin.audit.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.audit.failed', defaultMessage: 'Could not load audit log.' },
empty: { id: 'app.skin-site.admin.audit.empty', defaultMessage: 'No audit entries.' },
subject: { id: 'app.skin-site.admin.audit.subject', defaultMessage: 'Subject' },
action: { id: 'app.skin-site.admin.audit.action', defaultMessage: 'Action' },
sourceIp: { id: 'app.skin-site.admin.audit.source-ip', defaultMessage: 'Source IP' },
reportedIp: {
id: 'app.skin-site.admin.audit.reported-ip',
defaultMessage: 'Reported player IP',
},
count: { id: 'app.skin-site.admin.audit.count', defaultMessage: 'Count' },
firstAt: { id: 'app.skin-site.admin.audit.first-at', defaultMessage: 'First seen' },
lastAt: { id: 'app.skin-site.admin.audit.last-at', defaultMessage: 'Last seen' },
proxy: { id: 'app.skin-site.admin.audit.proxy', defaultMessage: 'Proxy' },
internal: { id: 'app.skin-site.admin.audit.internal', defaultMessage: 'Internal' },
searchSubject: {
id: 'app.skin-site.admin.audit.search-subject',
defaultMessage: 'Subject',
},
searchIp: { id: 'app.skin-site.admin.audit.search-ip', defaultMessage: 'IP' },
searchAction: { id: 'app.skin-site.admin.audit.search-action', defaultMessage: 'Action' },
search: { id: 'app.skin-site.admin.audit.search', defaultMessage: 'Search' },
clear: { id: 'app.skin-site.admin.audit.clear', defaultMessage: 'Clear' },
loadMore: { id: 'app.skin-site.admin.audit.load-more', defaultMessage: 'Load more' },
retention: {
id: 'app.skin-site.admin.audit.retention',
defaultMessage: 'Retention (days)',
},
recordLimit: { id: 'app.skin-site.admin.audit.record-limit', defaultMessage: 'Record limit' },
saveRetention: {
id: 'app.skin-site.admin.audit.save-retention',
defaultMessage: 'Save',
},
retentionSaved: {
id: 'app.skin-site.admin.audit.retention-saved',
defaultMessage: 'Retention updated.',
},
})
const items = ref<SkinSiteAccessAuditEntry[]>([])
const nextCursor = ref<number | null>(null)
const retentionDays = ref(0)
const recordLimit = ref(0)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const subjectFilter = ref('')
const ipFilter = ref('')
const actionFilter = ref('')
const retentionDraft = ref(0)
const savingRetention = ref(false)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
nextCursor.value = null
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAccessAudit({
before: reset ? undefined : (nextCursor.value ?? undefined),
subject: subjectFilter.value.trim() || undefined,
ip: ipFilter.value.trim() || undefined,
action: actionFilter.value.trim() || undefined,
})
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
nextCursor.value = page.nextCursor
retentionDays.value = page.retentionDays
recordLimit.value = page.recordLimit
retentionDraft.value = page.retentionDays
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
function clearFilters() {
subjectFilter.value = ''
ipFilter.value = ''
actionFilter.value = ''
void load(true)
}
async function saveRetention() {
if (savingRetention.value) return
savingRetention.value = true
try {
const result = await skinSiteUpdateAccessAuditSettings(Number(retentionDraft.value) || 0)
if (!result.success) throw new Error(result.errorMessage)
retentionDays.value = result.payload.retentionDays
retentionDraft.value = result.payload.retentionDays
addNotification({ type: 'success', title: formatMessage(messages.retentionSaved) })
} catch (error) {
handleError(error)
} finally {
savingRetention.value = false
}
}
onMounted(() => void load(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>
<span class="text-xs text-secondary">
{{ formatMessage(messages.recordLimit) }}: {{ recordLimit }}
</span>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchSubject) }}</span>
<input
v-model="subjectFilter"
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"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchIp) }}</span>
<input
v-model="ipFilter"
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"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchAction) }}</span>
<input
v-model="actionFilter"
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"
@keyup.enter="load(true)"
/>
</label>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="load(true)"
>
{{ formatMessage(messages.search) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="clearFilters"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div class="flex flex-wrap items-end gap-2 rounded-xl bg-bg-raised p-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.retention) }}</span>
<input
v-model.number="retentionDraft"
type="number"
min="1"
class="w-24 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<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="savingRetention"
@click="saveRetention"
>
{{ formatMessage(messages.saveRetention) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.retention) }}: {{ retentionDays }}
</span>
</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="!items.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="entry in items"
:key="entry.id"
class="flex flex-col gap-1.5 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ entry.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ entry.subject }}
</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ entry.action }}
</span>
<span class="shrink-0 text-xs text-secondary">×{{ entry.count }}</span>
<span
v-if="entry.proxy"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.proxy) }}
</span>
<span
v-if="entry.internalServer"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.internal) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ formatMessage(messages.sourceIp) }}: {{ entry.sourceIp }}</span>
<span v-if="entry.reportedPlayerIp">
{{ formatMessage(messages.reportedIp) }}: {{ entry.reportedPlayerIp }}
</span>
<span>{{ formatMessage(messages.firstAt) }}: {{ formatDateTime(entry.firstAt) }}</span>
<span>{{ formatMessage(messages.lastAt) }}: {{ formatDateTime(entry.lastAt) }}</span>
</div>
</div>
<button
v-if="nextCursor !== null"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="load(false)"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</div>
</template>
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteMapServer,
skinSiteMapServers,
skinSiteSaveMapServers,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.servers.title', defaultMessage: 'Map servers' },
loading: { id: 'app.skin-site.admin.servers.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.servers.failed', defaultMessage: 'Could not load servers.' },
empty: { id: 'app.skin-site.admin.servers.empty', defaultMessage: 'No map servers configured.' },
serverName: { id: 'app.skin-site.admin.servers.server-name', defaultMessage: 'Server name' },
internal: { id: 'app.skin-site.admin.servers.internal', defaultMessage: 'Internal' },
maps: { id: 'app.skin-site.admin.servers.maps', defaultMessage: 'Maps' },
mapName: { id: 'app.skin-site.admin.servers.map-name', defaultMessage: 'Map name' },
mapUrl: { id: 'app.skin-site.admin.servers.map-url', defaultMessage: 'Map URL' },
addMap: { id: 'app.skin-site.admin.servers.add-map', defaultMessage: 'Add map' },
removeMap: { id: 'app.skin-site.admin.servers.remove-map', defaultMessage: 'Remove' },
addServer: { id: 'app.skin-site.admin.servers.add-server', defaultMessage: 'Add server' },
removeServer: {
id: 'app.skin-site.admin.servers.remove-server',
defaultMessage: 'Remove server',
},
save: { id: 'app.skin-site.admin.servers.save', defaultMessage: 'Save changes' },
saving: { id: 'app.skin-site.admin.servers.saving', defaultMessage: 'Saving…' },
saved: { id: 'app.skin-site.admin.servers.saved', defaultMessage: 'Map servers saved.' },
})
const servers = ref<SkinSiteMapServer[]>([])
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
function addServer() {
servers.value.push({ name: '', internal: false, maps: [{ name: '', url: '' }] })
}
function removeServer(index: number) {
servers.value.splice(index, 1)
}
function addMap(server: SkinSiteMapServer) {
server.maps.push({ name: '', url: '' })
}
function removeMap(server: SkinSiteMapServer, index: number) {
server.maps.splice(index, 1)
}
async function save() {
if (saving.value) return
saving.value = true
try {
const cleaned = servers.value.map((s) => ({
name: s.name.trim(),
internal: s.internal,
maps: s.maps
.filter((m) => m.name.trim() || m.url.trim())
.map((m) => ({ name: m.name.trim(), url: m.url.trim() })),
}))
const result = await skinSiteSaveMapServers(cleaned)
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? cleaned
addNotification({ type: 'success', title: formatMessage(messages.saved) })
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
onMounted(() => void load())
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="addServer"
>
{{ formatMessage(messages.addServer) }}
</button>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="saving"
@click="save"
>
{{ saving ? formatMessage(messages.saving) : formatMessage(messages.save) }}
</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="!servers.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-3">
<div
v-for="(server, si) in servers"
:key="si"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="server.name"
type="text"
:placeholder="formatMessage(messages.serverName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
/>
<label class="flex shrink-0 items-center gap-1.5 text-xs text-contrast">
<input v-model="server.internal" type="checkbox" />
{{ formatMessage(messages.internal) }}
</label>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="removeServer(si)"
>
{{ formatMessage(messages.removeServer) }}
</button>
</div>
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.maps) }}
</span>
<div v-for="(map, mi) in server.maps" :key="mi" class="flex flex-wrap items-center gap-2">
<input
v-model="map.name"
type="text"
:placeholder="formatMessage(messages.mapName)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="map.url"
type="text"
:placeholder="formatMessage(messages.mapUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red"
@click="removeMap(server, mi)"
>
{{ formatMessage(messages.removeMap) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addMap(server)"
>
{{ formatMessage(messages.addMap) }}
</button>
</div>
</div>
</div>
</template>
@@ -1391,3 +1391,64 @@ export function skinSiteUnbindDownloadCategory(
method: 'POST', method: 'POST',
}) })
} }
export interface SkinSiteMapServer {
name: string
internal: boolean
maps: Array<{ name: string; url: string }>
}
export function skinSiteMapServers(): Promise<SkinSiteResponse<SkinSiteMapServer[]>> {
return skinSiteRequest<SkinSiteMapServer[]>('maps')
}
export function skinSiteSaveMapServers(
servers: SkinSiteMapServer[],
): Promise<SkinSiteResponse<SkinSiteMapServer[]>> {
return skinSiteRequest<SkinSiteMapServer[]>('maps', { method: 'PUT', body: servers })
}
export interface SkinSiteAccessAuditEntry {
id: number
subject: string
action: string
sourceIp: string
proxy: boolean
reportedPlayerIp: string | null
internalServer: boolean
count: number
firstAt: number
lastAt: number
}
export interface SkinSiteAccessAuditPage {
items: SkinSiteAccessAuditEntry[]
nextCursor: number | null
retentionDays: number
recordLimit: number
}
export function skinSiteAccessAudit(params?: {
before?: number
subject?: string
ip?: string
action?: string
}): Promise<SkinSiteResponse<SkinSiteAccessAuditPage>> {
const query = new URLSearchParams()
if (params) {
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== '') query.set(key, String(value))
}
}
const suffix = query.toString() ? '?' + query.toString() : ''
return skinSiteRequest<SkinSiteAccessAuditPage>('admin/access-audit' + suffix)
}
export function skinSiteUpdateAccessAuditSettings(
retentionDays: number,
): Promise<SkinSiteResponse<{ retentionDays: number }>> {
return skinSiteRequest<{ retentionDays: number }>('admin/access-audit/settings', {
method: 'PUT',
body: { retentionDays },
})
}
+40 -1
View File
@@ -11984,5 +11984,44 @@
"app.skin-site.admin.downloads.category-added": {"message": "Category added."}, "app.skin-site.admin.downloads.category-added": {"message": "Category added."},
"app.skin-site.admin.downloads.category-deleted": {"message": "Category deleted."}, "app.skin-site.admin.downloads.category-deleted": {"message": "Category deleted."},
"app.skin-site.admin.downloads.confirm-delete-item": {"message": "Delete “{name}”? This cannot be undone."}, "app.skin-site.admin.downloads.confirm-delete-item": {"message": "Delete “{name}”? This cannot be undone."},
"app.skin-site.admin.downloads.confirm-delete-category": {"message": "Delete category “{name}”?"} "app.skin-site.admin.downloads.confirm-delete-category": {"message": "Delete category “{name}”?"},
"app.skin-site.admin.servers.title": {"message": "Map servers"},
"app.skin-site.admin.servers.loading": {"message": "Loading…"},
"app.skin-site.admin.servers.failed": {"message": "Could not load servers."},
"app.skin-site.admin.servers.empty": {"message": "No map servers configured."},
"app.skin-site.admin.servers.server-name": {"message": "Server name"},
"app.skin-site.admin.servers.internal": {"message": "Internal"},
"app.skin-site.admin.servers.maps": {"message": "Maps"},
"app.skin-site.admin.servers.map-name": {"message": "Map name"},
"app.skin-site.admin.servers.map-url": {"message": "Map URL"},
"app.skin-site.admin.servers.add-map": {"message": "Add map"},
"app.skin-site.admin.servers.remove-map": {"message": "Remove"},
"app.skin-site.admin.servers.add-server": {"message": "Add server"},
"app.skin-site.admin.servers.remove-server": {"message": "Remove server"},
"app.skin-site.admin.servers.save": {"message": "Save changes"},
"app.skin-site.admin.servers.saving": {"message": "Saving…"},
"app.skin-site.admin.servers.saved": {"message": "Map servers saved."},
"app.skin-site.admin.audit.title": {"message": "Access audit"},
"app.skin-site.admin.audit.loading": {"message": "Loading…"},
"app.skin-site.admin.audit.failed": {"message": "Could not load audit log."},
"app.skin-site.admin.audit.empty": {"message": "No audit entries."},
"app.skin-site.admin.audit.subject": {"message": "Subject"},
"app.skin-site.admin.audit.action": {"message": "Action"},
"app.skin-site.admin.audit.source-ip": {"message": "Source IP"},
"app.skin-site.admin.audit.reported-ip": {"message": "Reported player IP"},
"app.skin-site.admin.audit.count": {"message": "Count"},
"app.skin-site.admin.audit.first-at": {"message": "First seen"},
"app.skin-site.admin.audit.last-at": {"message": "Last seen"},
"app.skin-site.admin.audit.proxy": {"message": "Proxy"},
"app.skin-site.admin.audit.internal": {"message": "Internal"},
"app.skin-site.admin.audit.search-subject": {"message": "Subject"},
"app.skin-site.admin.audit.search-ip": {"message": "IP"},
"app.skin-site.admin.audit.search-action": {"message": "Action"},
"app.skin-site.admin.audit.search": {"message": "Search"},
"app.skin-site.admin.audit.clear": {"message": "Clear"},
"app.skin-site.admin.audit.load-more": {"message": "Load more"},
"app.skin-site.admin.audit.retention": {"message": "Retention (days)"},
"app.skin-site.admin.audit.record-limit": {"message": "Record limit"},
"app.skin-site.admin.audit.save-retention": {"message": "Save"},
"app.skin-site.admin.audit.retention-saved": {"message": "Retention updated."}
} }
+40 -1
View File
@@ -11815,5 +11815,44 @@
"app.skin-site.admin.downloads.category-added": {"message": "分类已添加。"}, "app.skin-site.admin.downloads.category-added": {"message": "分类已添加。"},
"app.skin-site.admin.downloads.category-deleted": {"message": "分类已删除。"}, "app.skin-site.admin.downloads.category-deleted": {"message": "分类已删除。"},
"app.skin-site.admin.downloads.confirm-delete-item": {"message": "确定删除「{name}」吗?此操作不可撤销。"}, "app.skin-site.admin.downloads.confirm-delete-item": {"message": "确定删除「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.downloads.confirm-delete-category": {"message": "确定删除分类「{name}」吗?"} "app.skin-site.admin.downloads.confirm-delete-category": {"message": "确定删除分类「{name}」吗?"},
"app.skin-site.admin.servers.title": {"message": "地图服务器"},
"app.skin-site.admin.servers.loading": {"message": "加载中…"},
"app.skin-site.admin.servers.failed": {"message": "无法加载服务器列表。"},
"app.skin-site.admin.servers.empty": {"message": "尚未配置地图服务器。"},
"app.skin-site.admin.servers.server-name": {"message": "服务器名称"},
"app.skin-site.admin.servers.internal": {"message": "内部"},
"app.skin-site.admin.servers.maps": {"message": "地图"},
"app.skin-site.admin.servers.map-name": {"message": "地图名称"},
"app.skin-site.admin.servers.map-url": {"message": "地图地址"},
"app.skin-site.admin.servers.add-map": {"message": "添加地图"},
"app.skin-site.admin.servers.remove-map": {"message": "移除"},
"app.skin-site.admin.servers.add-server": {"message": "添加服务器"},
"app.skin-site.admin.servers.remove-server": {"message": "删除服务器"},
"app.skin-site.admin.servers.save": {"message": "保存更改"},
"app.skin-site.admin.servers.saving": {"message": "保存中…"},
"app.skin-site.admin.servers.saved": {"message": "地图服务器已保存。"},
"app.skin-site.admin.audit.title": {"message": "登录与认证审计"},
"app.skin-site.admin.audit.loading": {"message": "加载中…"},
"app.skin-site.admin.audit.failed": {"message": "无法加载审计日志。"},
"app.skin-site.admin.audit.empty": {"message": "没有审计记录。"},
"app.skin-site.admin.audit.subject": {"message": "主体"},
"app.skin-site.admin.audit.action": {"message": "操作"},
"app.skin-site.admin.audit.source-ip": {"message": "来源 IP"},
"app.skin-site.admin.audit.reported-ip": {"message": "上报玩家 IP"},
"app.skin-site.admin.audit.count": {"message": "次数"},
"app.skin-site.admin.audit.first-at": {"message": "首次出现"},
"app.skin-site.admin.audit.last-at": {"message": "最近出现"},
"app.skin-site.admin.audit.proxy": {"message": "代理"},
"app.skin-site.admin.audit.internal": {"message": "内部"},
"app.skin-site.admin.audit.search-subject": {"message": "主体"},
"app.skin-site.admin.audit.search-ip": {"message": "IP"},
"app.skin-site.admin.audit.search-action": {"message": "操作"},
"app.skin-site.admin.audit.search": {"message": "搜索"},
"app.skin-site.admin.audit.clear": {"message": "清空"},
"app.skin-site.admin.audit.load-more": {"message": "加载更多"},
"app.skin-site.admin.audit.retention": {"message": "保留天数"},
"app.skin-site.admin.audit.record-limit": {"message": "记录上限"},
"app.skin-site.admin.audit.save-retention": {"message": "保存"},
"app.skin-site.admin.audit.retention-saved": {"message": "保留天数已更新。"}
} }