feat: 新增皮肤站管理台概览与公告页
Sync source to CNB / Sync Git ref (push) Skipped

This commit is contained in:
AxTps committed 2026-10-17 22:27:41 +08:00
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' })
}
+31 -1
View File
@@ -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}"}
}
+31 -1
View File
@@ -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} 发布"}
}