Files
Starlight_Lancher/apps/app-frontend/src/components/ui/skin-site/admin/AdminBroadcastPage.vue
T
AxTps 227af685d4 fix: 修正管理面板权限组、公告发布人与问卷选项显示
- 用户列表新增权限组列,展示权限标签
- 公告发布人由 UUID 解析为用户名
- 问卷选择题作答由选项 ID 解析为选项文字
- 维基默认打开首页,内部链接标题不再被替换下划线
2026-10-22 22:07:05 +08:00

387 lines
12 KiB
Vue

<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteAdminUser,
type SkinSiteBroadcast,
type SkinSiteBroadcastInput,
skinSiteAdminUserList,
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 uploaderNames = ref<Record<string, string>>({})
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,
)
function uploaderName(uuid: string): string {
return uploaderNames.value[uuid] ?? uuid
}
async function loadUploaderNames() {
try {
const res = await skinSiteAdminUserList({ limit: 200, offset: 0 })
if (!res.success) return
const users: SkinSiteAdminUser[] = res.payload ?? []
const map: Record<string, string> = {}
for (const u of users) map[u.uuid] = u.username
uploaderNames.value = map
} catch {
// 名称映射失败不影响公告列表展示
}
}
async function load() {
loading.value = true
failed.value = false
try {
const [result] = await Promise.all([skinSiteBroadcasts(), loadUploaderNames()])
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: uploaderName(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>