feat: 新增皮肤站管理台玩家审核页
Sync source to CNB / Sync Git ref (push) Skipped

This commit is contained in:
AxTps committed 2026-10-17 22:50:21 +08:00
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' },
)
}
+44 -1
View File
@@ -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}"}
} }
+44 -1
View File
@@ -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} 页"}
} }
+1
View File
@@ -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',