1 parent
eef7d02f45
commit
1c671943b7
6 files changed
+667
-2
No files matched your search
@@ -6,6 +6,7 @@ import { skinSiteUser } from '@/composables/skin-site-session'
|
|||||||
|
|
||||||
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
|
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
|
||||||
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
|
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
|
||||||
|
import AdminReviewPage from './admin/AdminReviewPage.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 +161,7 @@ const current = ref<AdminKey>('summary')
|
|||||||
<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'" />
|
||||||
|
<AdminReviewPage v-else-if="current === 'review'" />
|
||||||
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
|
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,466 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { SpinnerIcon } from '@modrinth/assets'
|
||||||
|
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
skinSiteDecideReviewSubmission,
|
||||||
|
skinSiteRegenerateReviewInvite,
|
||||||
|
type SkinSiteReviewResult,
|
||||||
|
type SkinSiteReviewSubmission,
|
||||||
|
skinSiteReviewSubmissionCount,
|
||||||
|
skinSiteReviewSubmissions,
|
||||||
|
} from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
|
const { formatMessage } = useVIntl()
|
||||||
|
const { handleError, addNotification } = injectNotificationManager()
|
||||||
|
|
||||||
|
const messages = defineMessages({
|
||||||
|
title: { id: 'app.skin-site.admin.review.title', defaultMessage: 'Player review' },
|
||||||
|
loading: { id: 'app.skin-site.admin.review.loading', defaultMessage: 'Loading…' },
|
||||||
|
failed: {
|
||||||
|
id: 'app.skin-site.admin.review.failed',
|
||||||
|
defaultMessage: 'Could not load submissions.',
|
||||||
|
},
|
||||||
|
empty: { id: 'app.skin-site.admin.review.empty', defaultMessage: 'No submissions found.' },
|
||||||
|
total: { id: 'app.skin-site.admin.review.total', defaultMessage: '{count} submissions' },
|
||||||
|
filterAll: { id: 'app.skin-site.admin.review.filter-all', defaultMessage: 'All' },
|
||||||
|
filterPending: { id: 'app.skin-site.admin.review.filter-pending', defaultMessage: 'Pending' },
|
||||||
|
filterPassed: { id: 'app.skin-site.admin.review.filter-passed', defaultMessage: 'Passed' },
|
||||||
|
filterRejected: { id: 'app.skin-site.admin.review.filter-rejected', defaultMessage: 'Rejected' },
|
||||||
|
resultPending: { id: 'app.skin-site.admin.review.result-pending', defaultMessage: 'Pending' },
|
||||||
|
resultPassed: { id: 'app.skin-site.admin.review.result-passed', defaultMessage: 'Passed' },
|
||||||
|
resultRejected: { id: 'app.skin-site.admin.review.result-rejected', defaultMessage: 'Rejected' },
|
||||||
|
modeAuto: { id: 'app.skin-site.admin.review.mode-auto', defaultMessage: 'Auto' },
|
||||||
|
modeManual: { id: 'app.skin-site.admin.review.mode-manual', defaultMessage: 'Manual' },
|
||||||
|
applicant: { id: 'app.skin-site.admin.review.applicant', defaultMessage: 'Applicant' },
|
||||||
|
submittedAt: { id: 'app.skin-site.admin.review.submitted-at', defaultMessage: 'Submitted' },
|
||||||
|
score: { id: 'app.skin-site.admin.review.score', defaultMessage: 'Score' },
|
||||||
|
reviewer: { id: 'app.skin-site.admin.review.reviewer', defaultMessage: 'Reviewer' },
|
||||||
|
details: { id: 'app.skin-site.admin.review.details', defaultMessage: 'Details' },
|
||||||
|
hide: { id: 'app.skin-site.admin.review.hide', defaultMessage: 'Hide' },
|
||||||
|
answers: { id: 'app.skin-site.admin.review.answers', defaultMessage: 'Answers' },
|
||||||
|
noAnswers: { id: 'app.skin-site.admin.review.no-answers', defaultMessage: 'No answers.' },
|
||||||
|
autoChecks: { id: 'app.skin-site.admin.review.auto-checks', defaultMessage: 'Automatic checks' },
|
||||||
|
context: { id: 'app.skin-site.admin.review.context', defaultMessage: 'Submission context' },
|
||||||
|
ip: { id: 'app.skin-site.admin.review.ip', defaultMessage: 'IP' },
|
||||||
|
userAgent: { id: 'app.skin-site.admin.review.user-agent', defaultMessage: 'User agent' },
|
||||||
|
activity: { id: 'app.skin-site.admin.review.activity', defaultMessage: 'Activity' },
|
||||||
|
duration: { id: 'app.skin-site.admin.review.duration', defaultMessage: 'Duration' },
|
||||||
|
hiddenTime: { id: 'app.skin-site.admin.review.hidden-time', defaultMessage: 'Hidden time' },
|
||||||
|
screenSwitches: {
|
||||||
|
id: 'app.skin-site.admin.review.screen-switches',
|
||||||
|
defaultMessage: 'Screen switches',
|
||||||
|
},
|
||||||
|
history: { id: 'app.skin-site.admin.review.history', defaultMessage: 'Decision history' },
|
||||||
|
accept: { id: 'app.skin-site.admin.review.accept', defaultMessage: 'Accept' },
|
||||||
|
reject: { id: 'app.skin-site.admin.review.reject', defaultMessage: 'Reject' },
|
||||||
|
reason: { id: 'app.skin-site.admin.review.reason', defaultMessage: 'Reason' },
|
||||||
|
acceptDone: {
|
||||||
|
id: 'app.skin-site.admin.review.accept-done',
|
||||||
|
defaultMessage: 'Submission accepted.',
|
||||||
|
},
|
||||||
|
rejectDone: {
|
||||||
|
id: 'app.skin-site.admin.review.reject-done',
|
||||||
|
defaultMessage: 'Submission rejected.',
|
||||||
|
},
|
||||||
|
regenInvite: {
|
||||||
|
id: 'app.skin-site.admin.review.regen-invite',
|
||||||
|
defaultMessage: 'Regenerate invite',
|
||||||
|
},
|
||||||
|
inviteRegenerated: {
|
||||||
|
id: 'app.skin-site.admin.review.invite-regenerated',
|
||||||
|
defaultMessage: 'Invite code regenerated.',
|
||||||
|
},
|
||||||
|
inviteSent: {
|
||||||
|
id: 'app.skin-site.admin.review.invite-sent',
|
||||||
|
defaultMessage: 'Email sent.',
|
||||||
|
},
|
||||||
|
inviteNotSent: {
|
||||||
|
id: 'app.skin-site.admin.review.invite-not-sent',
|
||||||
|
defaultMessage: 'Email not sent.',
|
||||||
|
},
|
||||||
|
prev: { id: 'app.skin-site.admin.review.prev', defaultMessage: 'Previous' },
|
||||||
|
next: { id: 'app.skin-site.admin.review.next', defaultMessage: 'Next' },
|
||||||
|
page: { id: 'app.skin-site.admin.review.page', defaultMessage: 'Page {page}' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const PAGE_SIZE = 20
|
||||||
|
|
||||||
|
const items = ref<SkinSiteReviewSubmission[]>([])
|
||||||
|
const total = ref(0)
|
||||||
|
const offset = ref(0)
|
||||||
|
const loading = ref(true)
|
||||||
|
const failed = ref(false)
|
||||||
|
|
||||||
|
const filter = ref<'' | SkinSiteReviewResult | 'COMPLETED'>('PENDING')
|
||||||
|
|
||||||
|
const expandedId = ref<number | null>(null)
|
||||||
|
const decisions = ref<Record<number, { reason: string; working: boolean }>>({})
|
||||||
|
|
||||||
|
const page = computed(() => Math.floor(offset.value / PAGE_SIZE) + 1)
|
||||||
|
const pageCount = computed(() => Math.max(1, Math.ceil(total.value / PAGE_SIZE)))
|
||||||
|
const hasPrev = computed(() => offset.value > 0)
|
||||||
|
const hasNext = computed(() => offset.value + PAGE_SIZE < total.value)
|
||||||
|
|
||||||
|
const FILTERS: Array<{ key: '' | SkinSiteReviewResult | 'COMPLETED'; label: () => string }> = [
|
||||||
|
{ key: '', label: () => formatMessage(messages.filterAll) },
|
||||||
|
{ key: 'PENDING', label: () => formatMessage(messages.filterPending) },
|
||||||
|
{ key: 'PASSED', label: () => formatMessage(messages.filterPassed) },
|
||||||
|
{ key: 'REJECTED', label: () => formatMessage(messages.filterRejected) },
|
||||||
|
]
|
||||||
|
|
||||||
|
function resultLabel(result: SkinSiteReviewResult): string {
|
||||||
|
if (result === 'PASSED') return formatMessage(messages.resultPassed)
|
||||||
|
if (result === 'REJECTED') return formatMessage(messages.resultRejected)
|
||||||
|
return formatMessage(messages.resultPending)
|
||||||
|
}
|
||||||
|
|
||||||
|
function resultClass(result: SkinSiteReviewResult): string {
|
||||||
|
if (result === 'PASSED') return 'bg-green text-contrast'
|
||||||
|
if (result === 'REJECTED') return 'bg-red text-contrast'
|
||||||
|
return 'bg-button-bg text-secondary'
|
||||||
|
}
|
||||||
|
|
||||||
|
function modeLabel(mode: string): string {
|
||||||
|
return mode === 'AUTO' ? formatMessage(messages.modeAuto) : formatMessage(messages.modeManual)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDateTime(ts?: number | null): string {
|
||||||
|
if (!ts) return ''
|
||||||
|
try {
|
||||||
|
return new Date(ts).toLocaleString()
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(ms?: number | null): string {
|
||||||
|
if (!ms || ms < 0) return '—'
|
||||||
|
const totalSeconds = Math.floor(ms / 1000)
|
||||||
|
const m = Math.floor(totalSeconds / 60)
|
||||||
|
const s = totalSeconds % 60
|
||||||
|
return m > 0 ? m + 'm ' + s + 's' : s + 's'
|
||||||
|
}
|
||||||
|
|
||||||
|
function decisionFor(id: number) {
|
||||||
|
if (!decisions.value[id]) decisions.value[id] = { reason: '', working: false }
|
||||||
|
return decisions.value[id]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load(reset = false) {
|
||||||
|
if (reset) offset.value = 0
|
||||||
|
loading.value = true
|
||||||
|
failed.value = false
|
||||||
|
try {
|
||||||
|
const resultFilter = filter.value || undefined
|
||||||
|
const listResult = await skinSiteReviewSubmissions({
|
||||||
|
result: resultFilter,
|
||||||
|
limit: PAGE_SIZE,
|
||||||
|
offset: offset.value,
|
||||||
|
})
|
||||||
|
if (!listResult.success) throw new Error(listResult.errorMessage)
|
||||||
|
items.value = listResult.payload ?? []
|
||||||
|
const countResult = await skinSiteReviewSubmissionCount({ result: resultFilter })
|
||||||
|
if (countResult.success && typeof countResult.payload === 'number') {
|
||||||
|
total.value = countResult.payload
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
failed.value = true
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setFilter(key: '' | SkinSiteReviewResult | 'COMPLETED') {
|
||||||
|
filter.value = key
|
||||||
|
await load(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function prevPage() {
|
||||||
|
if (!hasPrev.value) return
|
||||||
|
offset.value = Math.max(0, offset.value - PAGE_SIZE)
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function nextPage() {
|
||||||
|
if (!hasNext.value) return
|
||||||
|
offset.value += PAGE_SIZE
|
||||||
|
await load()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleDetails(id: number) {
|
||||||
|
expandedId.value = expandedId.value === id ? null : id
|
||||||
|
}
|
||||||
|
|
||||||
|
async function decide(submission: SkinSiteReviewSubmission, accepted: boolean) {
|
||||||
|
const state = decisionFor(submission.id)
|
||||||
|
if (state.working) return
|
||||||
|
state.working = true
|
||||||
|
try {
|
||||||
|
const result = await skinSiteDecideReviewSubmission(
|
||||||
|
submission.id,
|
||||||
|
accepted,
|
||||||
|
{},
|
||||||
|
submission.revision,
|
||||||
|
state.reason,
|
||||||
|
)
|
||||||
|
if (!result.success) throw new Error(result.errorMessage)
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: formatMessage(accepted ? messages.acceptDone : messages.rejectDone),
|
||||||
|
})
|
||||||
|
expandedId.value = null
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
} finally {
|
||||||
|
state.working = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function regenerateInvite(submission: SkinSiteReviewSubmission) {
|
||||||
|
try {
|
||||||
|
const result = await skinSiteRegenerateReviewInvite(submission.id)
|
||||||
|
if (!result.success) throw new Error(result.errorMessage)
|
||||||
|
addNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: formatMessage(result.payload.emailSent ? messages.inviteSent : messages.inviteNotSent),
|
||||||
|
})
|
||||||
|
await load()
|
||||||
|
} catch (error) {
|
||||||
|
handleError(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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>
|
||||||
|
<span class="text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.total, { count: total }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<button
|
||||||
|
v-for="f in FILTERS"
|
||||||
|
:key="f.key || 'all'"
|
||||||
|
type="button"
|
||||||
|
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
|
||||||
|
:class="
|
||||||
|
filter === f.key
|
||||||
|
? 'bg-brand text-contrast'
|
||||||
|
: 'bg-button-bg text-secondary hover:text-contrast'
|
||||||
|
"
|
||||||
|
@click="setFilter(f.key)"
|
||||||
|
>
|
||||||
|
{{ f.label() }}
|
||||||
|
</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="!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="submission in items"
|
||||||
|
:key="submission.id"
|
||||||
|
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
|
||||||
|
>
|
||||||
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
|
<span class="shrink-0 text-xs text-secondary">#{{ submission.id }}</span>
|
||||||
|
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
|
||||||
|
{{ submission.applicantName }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||||
|
:class="resultClass(submission.result)"
|
||||||
|
>
|
||||||
|
{{ resultLabel(submission.result) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
|
||||||
|
>
|
||||||
|
{{ modeLabel(submission.mode) }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="submission.score !== null && submission.score !== undefined"
|
||||||
|
class="shrink-0 text-xs font-semibold text-contrast"
|
||||||
|
>
|
||||||
|
{{ submission.score }}
|
||||||
|
</span>
|
||||||
|
<span class="shrink-0 text-xs text-secondary">
|
||||||
|
{{ formatDateTime(submission.createdAt) }}
|
||||||
|
</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="toggleDetails(submission.id)"
|
||||||
|
>
|
||||||
|
{{
|
||||||
|
expandedId === submission.id
|
||||||
|
? formatMessage(messages.hide)
|
||||||
|
: formatMessage(messages.details)
|
||||||
|
}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="expandedId === submission.id"
|
||||||
|
class="flex flex-col gap-3 border-t border-solid border-surface-5 pt-3"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
v-if="submission.context"
|
||||||
|
class="flex flex-col gap-1 rounded-lg bg-bg p-3 text-xs text-secondary"
|
||||||
|
>
|
||||||
|
<span class="font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.context) }}
|
||||||
|
</span>
|
||||||
|
<span>{{ formatMessage(messages.ip) }}: {{ submission.context.ip ?? '—' }}</span>
|
||||||
|
<span class="break-all">
|
||||||
|
{{ formatMessage(messages.userAgent) }}: {{ submission.context.userAgent }}
|
||||||
|
</span>
|
||||||
|
<span v-if="submission.context.activity">
|
||||||
|
{{ formatMessage(messages.duration) }}:
|
||||||
|
{{ formatDuration(submission.context.activity.durationMs) }} ·
|
||||||
|
{{ formatMessage(messages.hiddenTime) }}:
|
||||||
|
{{ formatDuration(submission.context.activity.hiddenMs) }} ·
|
||||||
|
{{ formatMessage(messages.screenSwitches) }}:
|
||||||
|
{{ submission.context.activity.screenSwitches }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-1">
|
||||||
|
<span class="text-xs font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.answers) }}
|
||||||
|
</span>
|
||||||
|
<p
|
||||||
|
v-if="!submission.answers || !submission.answers.length"
|
||||||
|
class="m-0 text-xs text-secondary"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.noAnswers) }}
|
||||||
|
</p>
|
||||||
|
<div v-else class="flex flex-col gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(answer, idx) in submission.answers"
|
||||||
|
:key="answer.id || idx"
|
||||||
|
class="rounded-lg bg-bg p-2 text-xs text-secondary"
|
||||||
|
>
|
||||||
|
<span class="text-contrast">
|
||||||
|
{{ answer.text ?? (answer.values ? answer.values.join(', ') : '—') }}
|
||||||
|
</span>
|
||||||
|
<span v-if="answer.score !== null && answer.score !== undefined">
|
||||||
|
· {{ formatMessage(messages.score) }}: {{ answer.score }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="Object.keys(submission.automaticResults || {}).length"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.autoChecks) }}
|
||||||
|
</span>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="(passed, key) in submission.automaticResults"
|
||||||
|
:key="key"
|
||||||
|
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||||
|
:class="passed ? 'bg-green text-contrast' : 'bg-red text-contrast'"
|
||||||
|
>
|
||||||
|
{{ key }}: {{ passed ? '✓' : '✗' }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
v-if="submission.decisionHistory && submission.decisionHistory.length"
|
||||||
|
class="flex flex-col gap-1"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-semibold text-contrast">
|
||||||
|
{{ formatMessage(messages.history) }}
|
||||||
|
</span>
|
||||||
|
<div
|
||||||
|
v-for="(h, idx) in submission.decisionHistory"
|
||||||
|
:key="idx"
|
||||||
|
class="rounded-lg bg-bg p-2 text-xs text-secondary"
|
||||||
|
>
|
||||||
|
{{ h.reviewer }} · {{ resultLabel(h.result) }} ·
|
||||||
|
{{ formatDateTime(h.at) }}
|
||||||
|
<span v-if="h.reason"> · {{ h.reason }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<input
|
||||||
|
v-model="decisionFor(submission.id).reason"
|
||||||
|
type="text"
|
||||||
|
:placeholder="formatMessage(messages.reason)"
|
||||||
|
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
|
||||||
|
/>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-green px-4 py-1.5 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||||
|
:disabled="decisionFor(submission.id).working || submission.result === 'PASSED'"
|
||||||
|
@click="decide(submission, true)"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.accept) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-red px-4 py-1.5 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||||
|
:disabled="decisionFor(submission.id).working || submission.result === 'REJECTED'"
|
||||||
|
@click="decide(submission, false)"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.reject) }}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="rounded-full bg-button-bg px-4 py-1.5 text-xs font-semibold text-secondary hover:brightness-110"
|
||||||
|
@click="regenerateInvite(submission)"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.regenInvite) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center gap-3 pt-2">
|
||||||
|
<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="!hasPrev"
|
||||||
|
@click="prevPage"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.prev) }}
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-secondary">
|
||||||
|
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
|
||||||
|
</span>
|
||||||
|
<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="!hasNext"
|
||||||
|
@click="nextPage"
|
||||||
|
>
|
||||||
|
{{ formatMessage(messages.next) }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1032,3 +1032,113 @@ export function skinSiteAdminUserFrequencyLimit(
|
|||||||
'admin/users/' + encodeURIComponent(uuid) + '/frequency-limit',
|
'admin/users/' + encodeURIComponent(uuid) + '/frequency-limit',
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SkinSiteReviewResult = 'PENDING' | 'PASSED' | 'REJECTED'
|
||||||
|
export type SkinSiteReviewMode = 'AUTO' | 'MANUAL'
|
||||||
|
|
||||||
|
export interface SkinSiteReviewAnswer {
|
||||||
|
id: string
|
||||||
|
questionId?: string
|
||||||
|
values?: string[]
|
||||||
|
text?: string
|
||||||
|
score?: number | null
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteReviewSubmission {
|
||||||
|
id: number
|
||||||
|
reviewVersion: number
|
||||||
|
revision: number
|
||||||
|
applicantName: string
|
||||||
|
username?: string | null
|
||||||
|
qq?: string | null
|
||||||
|
userUuid?: string | null
|
||||||
|
registeredAt?: number | null
|
||||||
|
mode: SkinSiteReviewMode
|
||||||
|
result: SkinSiteReviewResult
|
||||||
|
score?: number | null
|
||||||
|
targetPermissions: string[]
|
||||||
|
automaticResults: Record<string, boolean>
|
||||||
|
inviteCode?: string | null
|
||||||
|
inviteUsed: boolean
|
||||||
|
reviewerUuid?: string | null
|
||||||
|
reviewerName?: string | null
|
||||||
|
createdAt: number
|
||||||
|
reviewedAt?: number | null
|
||||||
|
answers: SkinSiteReviewAnswer[]
|
||||||
|
decisionHistory?: Array<{
|
||||||
|
previousResult: SkinSiteReviewResult
|
||||||
|
result: SkinSiteReviewResult
|
||||||
|
reviewer: string
|
||||||
|
reason: string
|
||||||
|
at: number
|
||||||
|
permissionNote: string
|
||||||
|
}>
|
||||||
|
context?: {
|
||||||
|
ip: string | null
|
||||||
|
ipSource: string
|
||||||
|
userAgent: string
|
||||||
|
activity?: {
|
||||||
|
durationMs: number
|
||||||
|
hiddenMs: number
|
||||||
|
screenSwitches: number
|
||||||
|
} | null
|
||||||
|
} | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteReviewSubmissions(params?: {
|
||||||
|
result?: SkinSiteReviewResult | 'COMPLETED'
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
paperId?: string
|
||||||
|
}): Promise<SkinSiteResponse<SkinSiteReviewSubmission[]>> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
limit: String(params?.limit ?? 20),
|
||||||
|
offset: String(params?.offset ?? 0),
|
||||||
|
})
|
||||||
|
if (params?.result) query.set('result', params.result)
|
||||||
|
if (params?.paperId) query.set('paperId', params.paperId)
|
||||||
|
return skinSiteRequest<SkinSiteReviewSubmission[]>('review/admin/submissions?' + query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteReviewSubmissionCount(params?: {
|
||||||
|
result?: SkinSiteReviewResult | 'COMPLETED'
|
||||||
|
paperId?: string
|
||||||
|
}): Promise<SkinSiteResponse<number>> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (params?.result) query.set('result', params.result)
|
||||||
|
if (params?.paperId) query.set('paperId', params.paperId)
|
||||||
|
const suffix = query.toString() ? '?' + query.toString() : ''
|
||||||
|
return skinSiteRequest<number>('review/admin/submissions/count' + suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteDecideReviewSubmission(
|
||||||
|
id: number,
|
||||||
|
accepted: boolean,
|
||||||
|
manualScores: Record<string, number> = {},
|
||||||
|
expectedRevision?: number,
|
||||||
|
reason = '',
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteReviewSubmission>> {
|
||||||
|
const body: Record<string, unknown> = { accepted, manualScores, reason }
|
||||||
|
if (expectedRevision !== undefined) body.expectedRevision = expectedRevision
|
||||||
|
return skinSiteRequest<SkinSiteReviewSubmission>('review/admin/submissions/' + id + '/decision', {
|
||||||
|
method: 'POST',
|
||||||
|
body,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteReviewInviteDelivery {
|
||||||
|
email: string
|
||||||
|
inviteCode: string
|
||||||
|
emailSent: boolean
|
||||||
|
emailAttempted: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteRegenerateReviewInvite(
|
||||||
|
id: number,
|
||||||
|
): Promise<SkinSiteResponse<SkinSiteReviewInviteDelivery>> {
|
||||||
|
return skinSiteRequest<SkinSiteReviewInviteDelivery>(
|
||||||
|
'review/admin/submissions/' + id + '/invite/regenerate',
|
||||||
|
{ method: 'POST' },
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -11886,5 +11886,48 @@
|
|||||||
"app.skin-site.admin.users.permissions": {"message": "Permissions"},
|
"app.skin-site.admin.users.permissions": {"message": "Permissions"},
|
||||||
"app.skin-site.admin.users.permissions-hint": {"message": "Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN."},
|
"app.skin-site.admin.users.permissions-hint": {"message": "Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN."},
|
||||||
"app.skin-site.admin.users.no-players": {"message": "No linked players."},
|
"app.skin-site.admin.users.no-players": {"message": "No linked players."},
|
||||||
"app.skin-site.admin.users.registered": {"message": "Registered"}
|
"app.skin-site.admin.users.registered": {"message": "Registered"},
|
||||||
|
"app.skin-site.admin.review.title": {"message": "Player review"},
|
||||||
|
"app.skin-site.admin.review.loading": {"message": "Loading…"},
|
||||||
|
"app.skin-site.admin.review.failed": {"message": "Could not load submissions."},
|
||||||
|
"app.skin-site.admin.review.empty": {"message": "No submissions found."},
|
||||||
|
"app.skin-site.admin.review.total": {"message": "{count} submissions"},
|
||||||
|
"app.skin-site.admin.review.filter-all": {"message": "All"},
|
||||||
|
"app.skin-site.admin.review.filter-pending": {"message": "Pending"},
|
||||||
|
"app.skin-site.admin.review.filter-passed": {"message": "Passed"},
|
||||||
|
"app.skin-site.admin.review.filter-rejected": {"message": "Rejected"},
|
||||||
|
"app.skin-site.admin.review.result-pending": {"message": "Pending"},
|
||||||
|
"app.skin-site.admin.review.result-passed": {"message": "Passed"},
|
||||||
|
"app.skin-site.admin.review.result-rejected": {"message": "Rejected"},
|
||||||
|
"app.skin-site.admin.review.mode-auto": {"message": "Auto"},
|
||||||
|
"app.skin-site.admin.review.mode-manual": {"message": "Manual"},
|
||||||
|
"app.skin-site.admin.review.applicant": {"message": "Applicant"},
|
||||||
|
"app.skin-site.admin.review.submitted-at": {"message": "Submitted"},
|
||||||
|
"app.skin-site.admin.review.score": {"message": "Score"},
|
||||||
|
"app.skin-site.admin.review.reviewer": {"message": "Reviewer"},
|
||||||
|
"app.skin-site.admin.review.details": {"message": "Details"},
|
||||||
|
"app.skin-site.admin.review.hide": {"message": "Hide"},
|
||||||
|
"app.skin-site.admin.review.answers": {"message": "Answers"},
|
||||||
|
"app.skin-site.admin.review.no-answers": {"message": "No answers."},
|
||||||
|
"app.skin-site.admin.review.auto-checks": {"message": "Automatic checks"},
|
||||||
|
"app.skin-site.admin.review.context": {"message": "Submission context"},
|
||||||
|
"app.skin-site.admin.review.ip": {"message": "IP"},
|
||||||
|
"app.skin-site.admin.review.user-agent": {"message": "User agent"},
|
||||||
|
"app.skin-site.admin.review.activity": {"message": "Activity"},
|
||||||
|
"app.skin-site.admin.review.duration": {"message": "Duration"},
|
||||||
|
"app.skin-site.admin.review.hidden-time": {"message": "Hidden time"},
|
||||||
|
"app.skin-site.admin.review.screen-switches": {"message": "Screen switches"},
|
||||||
|
"app.skin-site.admin.review.history": {"message": "Decision history"},
|
||||||
|
"app.skin-site.admin.review.accept": {"message": "Accept"},
|
||||||
|
"app.skin-site.admin.review.reject": {"message": "Reject"},
|
||||||
|
"app.skin-site.admin.review.reason": {"message": "Reason"},
|
||||||
|
"app.skin-site.admin.review.accept-done": {"message": "Submission accepted."},
|
||||||
|
"app.skin-site.admin.review.reject-done": {"message": "Submission rejected."},
|
||||||
|
"app.skin-site.admin.review.regen-invite": {"message": "Regenerate invite"},
|
||||||
|
"app.skin-site.admin.review.invite-regenerated": {"message": "Invite code regenerated."},
|
||||||
|
"app.skin-site.admin.review.invite-sent": {"message": "Email sent."},
|
||||||
|
"app.skin-site.admin.review.invite-not-sent": {"message": "Email not sent."},
|
||||||
|
"app.skin-site.admin.review.prev": {"message": "Previous"},
|
||||||
|
"app.skin-site.admin.review.next": {"message": "Next"},
|
||||||
|
"app.skin-site.admin.review.page": {"message": "Page {page}"}
|
||||||
}
|
}
|
||||||
@@ -11717,5 +11717,48 @@
|
|||||||
"app.skin-site.admin.users.permissions": {"message": "权限"},
|
"app.skin-site.admin.users.permissions": {"message": "权限"},
|
||||||
"app.skin-site.admin.users.permissions-hint": {"message": "以逗号分隔,例如 WEB_ADMIN, SERVER_ADMIN。"},
|
"app.skin-site.admin.users.permissions-hint": {"message": "以逗号分隔,例如 WEB_ADMIN, SERVER_ADMIN。"},
|
||||||
"app.skin-site.admin.users.no-players": {"message": "没有绑定的角色。"},
|
"app.skin-site.admin.users.no-players": {"message": "没有绑定的角色。"},
|
||||||
"app.skin-site.admin.users.registered": {"message": "注册时间"}
|
"app.skin-site.admin.users.registered": {"message": "注册时间"},
|
||||||
|
"app.skin-site.admin.review.title": {"message": "玩家审核"},
|
||||||
|
"app.skin-site.admin.review.loading": {"message": "加载中…"},
|
||||||
|
"app.skin-site.admin.review.failed": {"message": "无法加载申请列表。"},
|
||||||
|
"app.skin-site.admin.review.empty": {"message": "没有找到申请。"},
|
||||||
|
"app.skin-site.admin.review.total": {"message": "共 {count} 份申请"},
|
||||||
|
"app.skin-site.admin.review.filter-all": {"message": "全部"},
|
||||||
|
"app.skin-site.admin.review.filter-pending": {"message": "待处理"},
|
||||||
|
"app.skin-site.admin.review.filter-passed": {"message": "已通过"},
|
||||||
|
"app.skin-site.admin.review.filter-rejected": {"message": "已拒绝"},
|
||||||
|
"app.skin-site.admin.review.result-pending": {"message": "待处理"},
|
||||||
|
"app.skin-site.admin.review.result-passed": {"message": "已通过"},
|
||||||
|
"app.skin-site.admin.review.result-rejected": {"message": "已拒绝"},
|
||||||
|
"app.skin-site.admin.review.mode-auto": {"message": "自动"},
|
||||||
|
"app.skin-site.admin.review.mode-manual": {"message": "人工"},
|
||||||
|
"app.skin-site.admin.review.applicant": {"message": "申请人"},
|
||||||
|
"app.skin-site.admin.review.submitted-at": {"message": "提交时间"},
|
||||||
|
"app.skin-site.admin.review.score": {"message": "得分"},
|
||||||
|
"app.skin-site.admin.review.reviewer": {"message": "审核人"},
|
||||||
|
"app.skin-site.admin.review.details": {"message": "详情"},
|
||||||
|
"app.skin-site.admin.review.hide": {"message": "收起"},
|
||||||
|
"app.skin-site.admin.review.answers": {"message": "作答内容"},
|
||||||
|
"app.skin-site.admin.review.no-answers": {"message": "没有作答记录。"},
|
||||||
|
"app.skin-site.admin.review.auto-checks": {"message": "自动检查"},
|
||||||
|
"app.skin-site.admin.review.context": {"message": "提交环境"},
|
||||||
|
"app.skin-site.admin.review.ip": {"message": "IP"},
|
||||||
|
"app.skin-site.admin.review.user-agent": {"message": "浏览器标识"},
|
||||||
|
"app.skin-site.admin.review.activity": {"message": "行为记录"},
|
||||||
|
"app.skin-site.admin.review.duration": {"message": "时长"},
|
||||||
|
"app.skin-site.admin.review.hidden-time": {"message": "切出时长"},
|
||||||
|
"app.skin-site.admin.review.screen-switches": {"message": "切屏次数"},
|
||||||
|
"app.skin-site.admin.review.history": {"message": "审核记录"},
|
||||||
|
"app.skin-site.admin.review.accept": {"message": "通过"},
|
||||||
|
"app.skin-site.admin.review.reject": {"message": "拒绝"},
|
||||||
|
"app.skin-site.admin.review.reason": {"message": "理由"},
|
||||||
|
"app.skin-site.admin.review.accept-done": {"message": "申请已通过。"},
|
||||||
|
"app.skin-site.admin.review.reject-done": {"message": "申请已拒绝。"},
|
||||||
|
"app.skin-site.admin.review.regen-invite": {"message": "重发邀请码"},
|
||||||
|
"app.skin-site.admin.review.invite-regenerated": {"message": "邀请码已重新生成。"},
|
||||||
|
"app.skin-site.admin.review.invite-sent": {"message": "邮件已发送。"},
|
||||||
|
"app.skin-site.admin.review.invite-not-sent": {"message": "邮件未发送。"},
|
||||||
|
"app.skin-site.admin.review.prev": {"message": "上一页"},
|
||||||
|
"app.skin-site.admin.review.next": {"message": "下一页"},
|
||||||
|
"app.skin-site.admin.review.page": {"message": "第 {page} 页"}
|
||||||
}
|
}
|
||||||
@@ -32,6 +32,7 @@ const allowedUntranslatedMessages = new Set([
|
|||||||
'LogShare.CN',
|
'LogShare.CN',
|
||||||
'UUID',
|
'UUID',
|
||||||
'QQ',
|
'QQ',
|
||||||
|
'IP',
|
||||||
'LogShare AI',
|
'LogShare AI',
|
||||||
'NeoForge',
|
'NeoForge',
|
||||||
'OptiFine',
|
'OptiFine',
|
||||||
|
|||||||
Reference in new issue
Block a user