feat: 新增皮肤站管理台捐赠管理页
Sync source to CNB / Sync Git ref (push) Skipped

This commit is contained in:
AxTps committed 2026-10-18 08:01:24 +08:00
1 parent 57ff974d79
commit b0178c046f
5 files changed
+505 -3

No files matched your search

@@ -6,6 +6,7 @@ import { skinSiteUser } from '@/composables/skin-site-session'
import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminDonationsPage from './admin/AdminDonationsPage.vue'
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
@@ -98,7 +99,9 @@ const navGroups = computed<NavGroup[]>(() => [
{ key: 'broadcast', label: formatMessage(messages.itemBroadcast) },
{ key: 'chat', label: formatMessage(messages.itemChat) },
{ key: 'wiki', label: formatMessage(messages.itemWiki) },
{ key: 'donations', label: formatMessage(messages.itemDonations) },
...(hasWebAdmin.value
? [{ key: 'donations' as AdminKey, label: formatMessage(messages.itemDonations) }]
: []),
],
},
{
@@ -177,6 +180,7 @@ const current = ref<AdminKey>('summary')
<AdminReviewPage v-else-if="current === 'review'" />
<AdminLauncherPage v-else-if="current === 'launcher'" />
<AdminDownloadsPage v-else-if="current === 'downloads'" />
<AdminDonationsPage v-else-if="current === 'donations' && hasWebAdmin" />
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
</section>
</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 {
skinSiteAdminDonations,
skinSiteCreateDonation,
skinSiteDeleteDonation,
type SkinSiteDonationAdminEntry,
type SkinSiteDonationSummary,
skinSiteUpdateDonation,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.donations.title', defaultMessage: 'Donations' },
loading: { id: 'app.skin-site.admin.donations.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.donations.failed',
defaultMessage: 'Could not load donations.',
},
empty: { id: 'app.skin-site.admin.donations.empty', defaultMessage: 'No donations yet.' },
totalAmount: { id: 'app.skin-site.admin.donations.total-amount', defaultMessage: 'Total' },
donorCount: { id: 'app.skin-site.admin.donations.donor-count', defaultMessage: 'Donors' },
donor: { id: 'app.skin-site.admin.donations.donor', defaultMessage: 'Donor' },
amount: { id: 'app.skin-site.admin.donations.amount', defaultMessage: 'Amount' },
updatedAt: { id: 'app.skin-site.admin.donations.updated-at', defaultMessage: 'Updated' },
external: { id: 'app.skin-site.admin.donations.external', defaultMessage: 'External' },
linked: { id: 'app.skin-site.admin.donations.linked', defaultMessage: 'Linked' },
create: { id: 'app.skin-site.admin.donations.create', defaultMessage: 'Add donation' },
save: { id: 'app.skin-site.admin.donations.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.donations.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.donations.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.donations.remove', defaultMessage: 'Delete' },
amountField: { id: 'app.skin-site.admin.donations.amount-field', defaultMessage: 'Amount' },
displayName: {
id: 'app.skin-site.admin.donations.display-name',
defaultMessage: 'Display name',
},
userUuid: { id: 'app.skin-site.admin.donations.user-uuid', defaultMessage: 'User UUID' },
qq: { id: 'app.skin-site.admin.donations.qq', defaultMessage: 'QQ' },
appendMode: {
id: 'app.skin-site.admin.donations.append-mode',
defaultMessage: 'Append to existing amount',
},
created: { id: 'app.skin-site.admin.donations.created', defaultMessage: 'Donation created.' },
updated: { id: 'app.skin-site.admin.donations.updated', defaultMessage: 'Donation updated.' },
deleted: { id: 'app.skin-site.admin.donations.deleted', defaultMessage: 'Donation deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.donations.confirm-delete',
defaultMessage: 'Delete the donation from “{name}”? This cannot be undone.',
},
loadMore: { id: 'app.skin-site.admin.donations.load-more', defaultMessage: 'Load more' },
})
const items = ref<SkinSiteDonationAdminEntry[]>([])
const summary = ref<SkinSiteDonationSummary | null>(null)
const hasMore = ref(false)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const offset = ref(0)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const editingVersion = ref(0)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
amount: '',
displayName: '',
userUuid: '',
qq: '',
appendMode: false,
})
const canSave = computed(() => form.value.amount.trim().length > 0 && !saving.value)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function resetForm() {
form.value = { amount: '', displayName: '', userUuid: '', qq: '', appendMode: false }
editingId.value = null
editingVersion.value = 0
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(entry: SkinSiteDonationAdminEntry) {
form.value = {
amount: entry.donor.amount,
displayName: entry.donor.username,
userUuid: entry.userUuid ?? '',
qq: entry.qq ?? '',
appendMode: false,
}
editingId.value = entry.donor.id
editingVersion.value = entry.version
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
offset.value = 0
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAdminDonations(offset.value)
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
summary.value = page.summary
hasMore.value = page.hasMore
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadMore() {
if (!hasMore.value) return
offset.value += 20
await load(false)
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
if (editingId.value === null) {
const payload: Parameters<typeof skinSiteCreateDonation>[0] = {
amount: form.value.amount.trim(),
}
if (form.value.userUuid.trim()) payload.userUuid = form.value.userUuid.trim()
if (form.value.qq.trim()) payload.qq = form.value.qq.trim()
if (form.value.displayName.trim()) payload.displayName = form.value.displayName.trim()
const result = await skinSiteCreateDonation(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.created) })
} else {
const result = await skinSiteUpdateDonation(editingId.value, {
amount: form.value.amount.trim(),
version: editingVersion.value,
displayName: form.value.displayName.trim() || undefined,
mode: form.value.appendMode ? 'APPEND' : 'REPLACE',
})
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
}
closeEditor()
await load(true)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(entry: SkinSiteDonationAdminEntry) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: entry.donor.username }))) return
deletingId.value = entry.donor.id
try {
const result = await skinSiteDeleteDonation(entry.donor.id, entry.version)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load(true)
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
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>
<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="summary" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.totalAmount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.totalAmount }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.donorCount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.donorCount }}</span>
</div>
</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.donor.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<span class="shrink-0 text-xs text-secondary">#{{ entry.donor.rank }}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ entry.donor.username }}
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.updatedAt) }}:
{{ formatDateTime(entry.updatedAt) }}
</span>
</div>
<span class="shrink-0 text-sm font-bold text-contrast">{{ entry.donor.amount }}</span>
<span
v-if="entry.external"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.external) }}
</span>
<span
v-else
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.linked) }}
</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(entry)"
>
{{ 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(entry)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
<button
v-if="hasMore"
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="loadMore"
>
{{ formatMessage(messages.loadMore) }}
</button>
</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.amountField) }}
</span>
<input
v-model="form.amount"
type="text"
inputmode="decimal"
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.displayName) }}
</span>
<input
v-model="form.displayName"
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>
<template v-if="editingId === null">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.userUuid) }}
</span>
<input
v-model="form.userUuid"
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.qq) }}
</span>
<input
v-model="form.qq"
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>
</template>
<label v-else class="flex items-center gap-2">
<input v-model="form.appendMode" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.appendMode) }}</span>
</label>
<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>
@@ -1452,3 +1452,83 @@ export function skinSiteUpdateAccessAuditSettings(
body: { retentionDays },
})
}
export interface SkinSiteDonationEntry {
id: number
rank: number
username: string
avatar: string | null
amount: string
}
export interface SkinSiteDonationSummary {
totalAmount: string
donorCount: number
}
export interface SkinSiteDonationAdminEntry {
donor: SkinSiteDonationEntry
userUuid: string | null
qq: string | null
external: boolean
version: number
updatedAt: number
}
export interface SkinSiteDonationAdminPage {
items: SkinSiteDonationAdminEntry[]
summary: SkinSiteDonationSummary
hasMore: boolean
}
export function skinSiteAdminDonations(
offset = 0,
): Promise<SkinSiteResponse<SkinSiteDonationAdminPage>> {
return skinSiteRequest<SkinSiteDonationAdminPage>('donation/admin/list?limit=20&offset=' + offset)
}
export function skinSiteFindAdminDonations(identity: {
userUuid?: string
qq?: string
}): Promise<SkinSiteResponse<SkinSiteDonationAdminEntry[]>> {
const query = new URLSearchParams()
if (identity.userUuid) query.set('userUuid', identity.userUuid)
if (identity.qq) query.set('qq', identity.qq)
return skinSiteRequest<SkinSiteDonationAdminEntry[]>('donation/admin/find?' + query.toString())
}
export interface SkinSiteDonationCreate {
amount: string
userUuid?: string
qq?: string
displayName?: string
}
export function skinSiteCreateDonation(
data: SkinSiteDonationCreate,
): Promise<SkinSiteResponse<number>> {
return skinSiteRequest<number>('donation/admin', { method: 'POST', body: data })
}
export interface SkinSiteDonationUpdate {
amount: string
version: number
displayName?: string
mode?: 'REPLACE' | 'APPEND'
}
export function skinSiteUpdateDonation(
id: number,
data: SkinSiteDonationUpdate,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('donation/admin/' + id, { method: 'PUT', body: data })
}
export function skinSiteDeleteDonation(
id: number,
version: number,
): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('donation/admin/' + id + '?version=' + version, {
method: 'DELETE',
})
}
+27 -1
View File
@@ -12023,5 +12023,31 @@
"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."}
"app.skin-site.admin.audit.retention-saved": {"message": "Retention updated."},
"app.skin-site.admin.donations.title": {"message": "Donations"},
"app.skin-site.admin.donations.loading": {"message": "Loading…"},
"app.skin-site.admin.donations.failed": {"message": "Could not load donations."},
"app.skin-site.admin.donations.empty": {"message": "No donations yet."},
"app.skin-site.admin.donations.total-amount": {"message": "Total"},
"app.skin-site.admin.donations.donor-count": {"message": "Donors"},
"app.skin-site.admin.donations.donor": {"message": "Donor"},
"app.skin-site.admin.donations.amount": {"message": "Amount"},
"app.skin-site.admin.donations.updated-at": {"message": "Updated"},
"app.skin-site.admin.donations.external": {"message": "External"},
"app.skin-site.admin.donations.linked": {"message": "Linked"},
"app.skin-site.admin.donations.create": {"message": "Add donation"},
"app.skin-site.admin.donations.save": {"message": "Save"},
"app.skin-site.admin.donations.cancel": {"message": "Cancel"},
"app.skin-site.admin.donations.edit": {"message": "Edit"},
"app.skin-site.admin.donations.remove": {"message": "Delete"},
"app.skin-site.admin.donations.amount-field": {"message": "Amount"},
"app.skin-site.admin.donations.display-name": {"message": "Display name"},
"app.skin-site.admin.donations.user-uuid": {"message": "User UUID"},
"app.skin-site.admin.donations.qq": {"message": "QQ"},
"app.skin-site.admin.donations.append-mode": {"message": "Append to existing amount"},
"app.skin-site.admin.donations.created": {"message": "Donation created."},
"app.skin-site.admin.donations.updated": {"message": "Donation updated."},
"app.skin-site.admin.donations.deleted": {"message": "Donation deleted."},
"app.skin-site.admin.donations.confirm-delete": {"message": "Delete the donation from “{name}”? This cannot be undone."},
"app.skin-site.admin.donations.load-more": {"message": "Load more"}
}
+27 -1
View File
@@ -11854,5 +11854,31 @@
"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": "保留天数已更新。"}
"app.skin-site.admin.audit.retention-saved": {"message": "保留天数已更新。"},
"app.skin-site.admin.donations.title": {"message": "捐赠管理"},
"app.skin-site.admin.donations.loading": {"message": "加载中…"},
"app.skin-site.admin.donations.failed": {"message": "无法加载捐赠列表。"},
"app.skin-site.admin.donations.empty": {"message": "暂无捐赠记录。"},
"app.skin-site.admin.donations.total-amount": {"message": "总额"},
"app.skin-site.admin.donations.donor-count": {"message": "捐赠人数"},
"app.skin-site.admin.donations.donor": {"message": "捐赠者"},
"app.skin-site.admin.donations.amount": {"message": "金额"},
"app.skin-site.admin.donations.updated-at": {"message": "更新时间"},
"app.skin-site.admin.donations.external": {"message": "外部"},
"app.skin-site.admin.donations.linked": {"message": "已绑定"},
"app.skin-site.admin.donations.create": {"message": "新增捐赠"},
"app.skin-site.admin.donations.save": {"message": "保存"},
"app.skin-site.admin.donations.cancel": {"message": "取消"},
"app.skin-site.admin.donations.edit": {"message": "编辑"},
"app.skin-site.admin.donations.remove": {"message": "删除"},
"app.skin-site.admin.donations.amount-field": {"message": "金额"},
"app.skin-site.admin.donations.display-name": {"message": "显示名称"},
"app.skin-site.admin.donations.user-uuid": {"message": "用户 UUID"},
"app.skin-site.admin.donations.qq": {"message": "QQ"},
"app.skin-site.admin.donations.append-mode": {"message": "在原有金额上累加"},
"app.skin-site.admin.donations.created": {"message": "捐赠记录已创建。"},
"app.skin-site.admin.donations.updated": {"message": "捐赠记录已更新。"},
"app.skin-site.admin.donations.deleted": {"message": "捐赠记录已删除。"},
"app.skin-site.admin.donations.confirm-delete": {"message": "确定删除「{name}」的捐赠记录吗?此操作不可撤销。"},
"app.skin-site.admin.donations.load-more": {"message": "加载更多"}
}