-
- {{ formatMessage(messages.answers) }}
-
-
+
+
+
+
+ | # |
+ {{ formatMessage(messages.applicant) }} |
+ {{ formatMessage(messages.resultPending) }} |
+ {{ formatMessage(messages.score) }} |
+ {{ formatMessage(messages.submittedAt) }} |
+ {{ formatMessage(messages.details) }} |
+
+
+
+
- {{ formatMessage(messages.noAnswers) }}
-
-
-
-
- {{ answer.text ?? (answer.values ? answer.values.join(', ') : '—') }}
+ {{ submission.id }} |
+
+ {{ submission.applicantName }}
+
+ {{ modeLabel(submission.mode) }}
-
- · {{ formatMessage(messages.score) }}: {{ answer.score }}
+ |
+
+
+ {{ resultLabel(submission.result) }}
-
-
-
-
-
-
- {{ formatMessage(messages.autoChecks) }}
-
-
-
- {{ key }}: {{ passed ? '✓' : '✗' }}
-
-
-
-
-
-
- {{ formatMessage(messages.history) }}
-
-
- {{ h.reviewer }} · {{ resultLabel(h.result) }} ·
- {{ formatDateTime(h.at) }}
- · {{ h.reason }}
-
-
-
-
-
-
-
-
-
-
-
-
+ |
+
+ {{
+ submission.score !== null && submission.score !== undefined
+ ? submission.score
+ : '—'
+ }}
+ |
+
+ {{ formatDateTime(submission.createdAt) }}
+ |
+
+
+ |
+
+
+
-
+
+
+
+
+
+
+
+ {{ formatMessage(messages.tabPapers) }}
+
+
+ {{ formatMessage(messages.newPaper) }}
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.papersEmpty) }}
+
+
+
+
+
+
+
+ {{ paper.title }}
+
+
+ {{ paper.enabled ? '✓' : '—' }}
+
+
+
+ {{ paper.annotation }}
+
+
+
+
+ {{ formatMessage(messages.copyLink) }}
+
+
+ {{ formatMessage(messages.editPaper) }}
+
+
+
+
+
+ {{ paper.mode === 'AUTO' ? formatMessage(messages.modeAuto) : formatMessage(messages.modeManual) }}
+
+ {{ paper.questionCount }} 题
+ 总分 {{ paper.totalScore }}
+ 通过分 {{ paper.passScore }}
+
+ {{ permissionLabel(perm) }}
+
+
+
+ {{ formatDateTime(paper.updatedAt) }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.details) }} · #{{ detailSubmission.id }}
+
+
+ {{ detailSubmission.applicantName }}
+
+
+
+ ✕
+
+
+
+
+
+ {{ resultLabel(detailSubmission.result) }}
+
+
+ {{ modeLabel(detailSubmission.mode) }}
+
+
+ {{ formatMessage(messages.score) }}: {{ detailSubmission.score }}
+
+
+
+
+
+ {{ formatMessage(messages.context) }}
+
+ {{ formatMessage(messages.ip) }}: {{ detailSubmission.context.ip ?? '—' }}
+
+ {{ formatMessage(messages.userAgent) }}:
+ {{ detailSubmission.context.userAgent }}
+
+
+ {{ formatMessage(messages.duration) }}:
+ {{ formatDuration(detailSubmission.context.activity.durationMs) }} ·
+ {{ formatMessage(messages.hiddenTime) }}:
+ {{ formatDuration(detailSubmission.context.activity.hiddenMs) }} ·
+ {{ formatMessage(messages.screenSwitches) }}:
+ {{ detailSubmission.context.activity.screenSwitches }}
+
+
+
+
+
+ {{ formatMessage(messages.answers) }}
+
+
+ {{ formatMessage(messages.noAnswers) }}
+
+
+
+
+
+ {{ idx + 1 }}.
+ {{
+ questionFor(detailSubmission, answer.questionId)?.title ||
+ formatMessage(messages.questionNo, { n: idx + 1 })
+ }}
+
+
+ {{ questionFor(detailSubmission, answer.questionId)?.informationAlias || 'info' }}
+
+
+ ({{ formatMessage(messages.score) }}:
+ {{ questionFor(detailSubmission, answer.questionId)?.score }})
+
+
+
+ {{ questionFor(detailSubmission, answer.questionId)?.annotation }}
+
+
+ {{ answerText(answer) }}
+
+
+ {{ formatMessage(messages.score) }}: {{ answer.awardedScore }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.autoChecks) }}
+
+
+
+ {{ key }}: {{ passed ? '✓' : '✗' }}
+
+
+
+
+
+
+ {{ formatMessage(messages.history) }}
+
+
+ {{ h.reviewer }} · {{ resultLabel(h.result) }} ·
+ {{ formatDateTime(h.at) }}
+ · {{ h.reason }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.accept) }}
+
+
+ {{ formatMessage(messages.reject) }}
+
+
+ {{ formatMessage(messages.regenInvite) }}
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminServersPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminServersPage.vue
new file mode 100644
index 0000000..d48eb38
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminServersPage.vue
@@ -0,0 +1,188 @@
+
+
+
+
+
{{ formatMessage(messages.title) }}
+
+ {{ formatMessage(messages.addServer) }}
+
+
+ {{ saving ? formatMessage(messages.saving) : formatMessage(messages.save) }}
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.failed) }}
+
+
+ {{ formatMessage(messages.empty) }}
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue
index 3cf833a..bf28e4e 100644
--- a/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue
+++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue
@@ -4,11 +4,22 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/u
import { computed, onMounted, ref } from 'vue'
import {
- skinSiteAdminDeleteUser,
type SkinSiteAdminPlayer,
- skinSiteAdminUpdateUser,
type SkinSiteAdminUser,
+ type SkinSiteBotInfo,
+ type SkinSiteUserAdminAuditInfo,
+ type SkinSiteUserFrequencyLimit,
+ skinSiteAdminBotInfo,
+ skinSiteAdminDeleteUser,
+ skinSiteAdminInvalidateBotToken,
+ skinSiteAdminInviteUser,
+ skinSiteAdminRenameBot,
+ skinSiteAdminSetBotCreationBanned,
+ skinSiteAdminUpdateUser,
+ skinSiteAdminUpdateUserFrequencyLimit,
+ skinSiteAdminUserAudit,
skinSiteAdminUserCount,
+ skinSiteAdminUserFrequencyLimit,
skinSiteAdminUserList,
skinSiteAdminUserPlayers,
} from '@/helpers/skin-site-api'
@@ -36,10 +47,6 @@ const messages = defineMessages({
players: { id: 'app.skin-site.admin.users.players', defaultMessage: 'Players' },
updated: { id: 'app.skin-site.admin.users.updated', defaultMessage: 'User updated.' },
deleted: { id: 'app.skin-site.admin.users.deleted', defaultMessage: 'User deleted.' },
- confirmDelete: {
- id: 'app.skin-site.admin.users.confirm-delete',
- defaultMessage: 'Delete user “{name}”? This cannot be undone.',
- },
username: { id: 'app.skin-site.admin.users.username', defaultMessage: 'Username' },
email: { id: 'app.skin-site.admin.users.email', defaultMessage: 'Email' },
qq: { id: 'app.skin-site.admin.users.qq', defaultMessage: 'QQ' },
@@ -56,6 +63,108 @@ const messages = defineMessages({
},
noPlayers: { id: 'app.skin-site.admin.users.no-players', defaultMessage: 'No linked players.' },
registered: { id: 'app.skin-site.admin.users.registered', defaultMessage: 'Registered' },
+ bot: { id: 'app.skin-site.admin.users.bot', defaultMessage: 'Bot' },
+ botTitle: { id: 'app.skin-site.admin.users.bot-title', defaultMessage: 'Bot management' },
+ botExists: { id: 'app.skin-site.admin.users.bot-exists', defaultMessage: 'Bot created' },
+ botNotExists: { id: 'app.skin-site.admin.users.bot-not-exists', defaultMessage: 'No bot yet' },
+ botActive: { id: 'app.skin-site.admin.users.bot-active', defaultMessage: 'Token valid' },
+ botInactive: {
+ id: 'app.skin-site.admin.users.bot-inactive',
+ defaultMessage: 'Token invalidated',
+ },
+ botCreationBanned: {
+ id: 'app.skin-site.admin.users.bot-creation-banned',
+ defaultMessage: 'Token creation banned',
+ },
+ botUsername: {
+ id: 'app.skin-site.admin.users.bot-username',
+ defaultMessage: 'Bot username',
+ },
+ botSaveUsername: {
+ id: 'app.skin-site.admin.users.bot-save-username',
+ defaultMessage: 'Save bot username',
+ },
+ botTokenHint: {
+ id: 'app.skin-site.admin.users.bot-token-hint',
+ defaultMessage: 'Token hint',
+ },
+ botInvalidate: {
+ id: 'app.skin-site.admin.users.bot-invalidate',
+ defaultMessage: 'Invalidate token',
+ },
+ botBan: { id: 'app.skin-site.admin.users.bot-ban', defaultMessage: 'Ban bot creation' },
+ botUnban: { id: 'app.skin-site.admin.users.bot-unban', defaultMessage: 'Unban bot creation' },
+ frequency: { id: 'app.skin-site.admin.users.frequency', defaultMessage: 'Rate limit' },
+ frequencyTitle: {
+ id: 'app.skin-site.admin.users.frequency-title',
+ defaultMessage: 'Rate limit',
+ },
+ frequencyHint: {
+ id: 'app.skin-site.admin.users.frequency-hint',
+ defaultMessage: 'Packets from this user will be dropped at the given rate.',
+ },
+ frequencyNone: {
+ id: 'app.skin-site.admin.users.frequency-none',
+ defaultMessage: 'Not enabled',
+ },
+ frequencyCurrent: {
+ id: 'app.skin-site.admin.users.frequency-current',
+ defaultMessage: 'Currently {percent}%',
+ },
+ frequencyRange: {
+ id: 'app.skin-site.admin.users.frequency-range',
+ defaultMessage: 'Only integer values from 10% to 100% are allowed.',
+ },
+ frequencySave: {
+ id: 'app.skin-site.admin.users.frequency-save',
+ defaultMessage: 'Save limit',
+ },
+ frequencyClear: {
+ id: 'app.skin-site.admin.users.frequency-clear',
+ defaultMessage: 'Clear limit',
+ },
+ audit: { id: 'app.skin-site.admin.users.audit', defaultMessage: 'Audit' },
+ auditTitle: {
+ id: 'app.skin-site.admin.users.audit-title',
+ defaultMessage: 'User management audit',
+ },
+ auditEmpty: {
+ id: 'app.skin-site.admin.users.audit-empty',
+ defaultMessage: 'No audit records yet.',
+ },
+ auditMore: { id: 'app.skin-site.admin.users.audit-more', defaultMessage: 'Load earlier' },
+ invite: { id: 'app.skin-site.admin.users.invite', defaultMessage: 'Invite user' },
+ inviteTitle: { id: 'app.skin-site.admin.users.invite-title', defaultMessage: 'Invite user' },
+ inviteEmail: { id: 'app.skin-site.admin.users.invite-email', defaultMessage: 'Email' },
+ inviteGenerate: {
+ id: 'app.skin-site.admin.users.invite-generate',
+ defaultMessage: 'Generate invite code',
+ },
+ inviteCopy: { id: 'app.skin-site.admin.users.invite-copy', defaultMessage: 'Copy' },
+ inviteCopied: {
+ id: 'app.skin-site.admin.users.invite-copied',
+ defaultMessage: 'Invite code copied.',
+ },
+ inviteEmailInvalid: {
+ id: 'app.skin-site.admin.users.invite-email-invalid',
+ defaultMessage: 'Enter a valid email address.',
+ },
+ boundPlayersTitle: {
+ id: 'app.skin-site.admin.users.bound-players-title',
+ defaultMessage: 'Linked players',
+ },
+ legacyAdapted: {
+ id: 'app.skin-site.admin.users.legacy-adapted',
+ defaultMessage: 'Legacy internal adapted',
+ },
+ registeredAt: {
+ id: 'app.skin-site.admin.users.registered-at',
+ defaultMessage: 'Registered at',
+ },
+ status: { id: 'app.skin-site.admin.users.status', defaultMessage: 'Status' },
+ statusNormal: { id: 'app.skin-site.admin.users.status-normal', defaultMessage: 'Normal' },
+ statusBanned: { id: 'app.skin-site.admin.users.status-banned', defaultMessage: 'Banned' },
+ actions: { id: 'app.skin-site.admin.users.actions', defaultMessage: 'Actions' },
})
const PAGE_SIZE = 20
@@ -83,26 +192,283 @@ const deletingUuid = ref
(null)
const playersFor = ref(null)
const playersLoading = ref(false)
-const playersUuid = ref(null)
+const playersTarget = ref(null)
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 SEARCH_FIELDS: Array<'username' | 'email' | 'uuid'> = ['username', 'email', 'uuid']
+// ---------- bot ----------
+const botTarget = ref(null)
+const botInfo = ref(null)
+const botLoading = ref(false)
+const botBusy = ref(false)
+const botUsername = ref('')
+const validBotUsername = computed(() => {
+ const name = botUsername.value.trim()
+ return name.length >= 2 && name.length <= 32
+})
-function searchFieldLabel(field: string): string {
- if (field === 'email') return formatMessage(messages.searchEmail)
- if (field === 'uuid') return formatMessage(messages.searchUuid)
- return formatMessage(messages.searchName)
+function openBot(user: SkinSiteAdminUser) {
+ botTarget.value = user
+ botInfo.value = null
+ botUsername.value = ''
+ botLoading.value = true
+ skinSiteAdminBotInfo(user.uuid)
+ .then((res) => {
+ if (!res.success) throw new Error(res.errorMessage)
+ botInfo.value = res.payload
+ botUsername.value = res.payload.username ?? ''
+ })
+ .catch(handleError)
+ .finally(() => {
+ botLoading.value = false
+ })
}
-function formatDate(ts: number): string {
+function closeBot() {
+ botTarget.value = null
+ botInfo.value = null
+}
+
+async function saveBotUsername() {
+ if (!botTarget.value || !validBotUsername.value) return
+ botBusy.value = true
try {
- return new Date(ts).toLocaleDateString()
+ const res = await skinSiteAdminRenameBot(botTarget.value.uuid, botUsername.value.trim())
+ if (!res.success) throw new Error(res.errorMessage)
+ botInfo.value = res.payload
+ botUsername.value = res.payload.username ?? ''
+ } catch (error) {
+ handleError(error)
+ } finally {
+ botBusy.value = false
+ }
+}
+
+async function invalidateBot() {
+ if (!botTarget.value) return
+ botBusy.value = true
+ try {
+ const res = await skinSiteAdminInvalidateBotToken(botTarget.value.uuid)
+ if (!res.success) throw new Error(res.errorMessage)
+ botInfo.value = res.payload
+ } catch (error) {
+ handleError(error)
+ } finally {
+ botBusy.value = false
+ }
+}
+
+async function toggleBotCreationBan() {
+ if (!botTarget.value || !botInfo.value) return
+ botBusy.value = true
+ try {
+ const res = await skinSiteAdminSetBotCreationBanned(
+ botTarget.value.uuid,
+ !botInfo.value.creationBanned,
+ )
+ if (!res.success) throw new Error(res.errorMessage)
+ botInfo.value = res.payload
+ } catch (error) {
+ handleError(error)
+ } finally {
+ botBusy.value = false
+ }
+}
+
+// ---------- frequency ----------
+const frequencyTarget = ref(null)
+const frequencyLoading = ref(false)
+const frequencyBusy = ref(false)
+const frequencyCurrent = ref(null)
+const frequencyPercent = ref(50)
+
+const validFrequency = computed(
+ () =>
+ Number.isInteger(frequencyPercent.value) &&
+ frequencyPercent.value >= 10 &&
+ frequencyPercent.value <= 100,
+)
+const currentFrequencyLimit = computed(() => frequencyCurrent.value?.dropPercent ?? null)
+
+function openFrequency(user: SkinSiteAdminUser) {
+ frequencyTarget.value = user
+ frequencyCurrent.value = null
+ frequencyPercent.value = 50
+ frequencyLoading.value = true
+ skinSiteAdminUserFrequencyLimit(user.uuid)
+ .then((res) => {
+ if (!res.success) throw new Error(res.errorMessage)
+ frequencyCurrent.value = res.payload
+ frequencyPercent.value = res.payload.dropPercent ?? 50
+ })
+ .catch(handleError)
+ .finally(() => {
+ frequencyLoading.value = false
+ })
+}
+
+function closeFrequency() {
+ frequencyTarget.value = null
+ frequencyCurrent.value = null
+}
+
+async function saveFrequency() {
+ if (!frequencyTarget.value || !validFrequency.value) return
+ frequencyBusy.value = true
+ try {
+ const res = await skinSiteAdminUpdateUserFrequencyLimit(
+ frequencyTarget.value.uuid,
+ frequencyPercent.value,
+ )
+ if (!res.success) throw new Error(res.errorMessage)
+ frequencyCurrent.value = res.payload
+ } catch (error) {
+ handleError(error)
+ } finally {
+ frequencyBusy.value = false
+ }
+}
+
+async function clearFrequency() {
+ if (!frequencyTarget.value) return
+ frequencyBusy.value = true
+ try {
+ const res = await skinSiteAdminUpdateUserFrequencyLimit(frequencyTarget.value.uuid, null)
+ if (!res.success) throw new Error(res.errorMessage)
+ frequencyCurrent.value = res.payload
+ } catch (error) {
+ handleError(error)
+ } finally {
+ frequencyBusy.value = false
+ }
+}
+
+// ---------- audit ----------
+const auditTarget = ref(null)
+const auditItems = ref([])
+const auditLoading = ref(false)
+const auditNextCursor = ref(null)
+
+const AUDIT_ACTION_LABELS: Record = {
+ USER_UPDATED: 'user-updated',
+ USER_DELETED: 'user-deleted',
+ RATE_LIMIT_UPDATED: 'rate-limit-updated',
+ BOT_USERNAME_UPDATED: 'bot-username-updated',
+ BOT_TOKEN_INVALIDATED: 'bot-token-invalidated',
+ BOT_CREATION_BANNED: 'bot-creation-banned',
+ BOT_CREATION_UNBANNED: 'bot-creation-unbanned',
+}
+
+function auditActionLabel(action: string) {
+ return formatMessage({
+ id: 'app.skin-site.admin.users.audit-action.' + (AUDIT_ACTION_LABELS[action] ?? 'unknown'),
+ defaultMessage: action,
+ })
+}
+
+function auditFieldLabel(field: string) {
+ return formatMessage({
+ id: 'app.skin-site.admin.users.audit-field.' + field,
+ defaultMessage: field,
+ })
+}
+
+function auditValue(value?: string | null) {
+ if (value === null || value === undefined || value === '') return '—'
+ return value
+}
+
+async function loadAudit(reset = false) {
+ auditLoading.value = true
+ if (reset) {
+ auditItems.value = []
+ auditNextCursor.value = null
+ }
+ try {
+ const res = await skinSiteAdminUserAudit({
+ targetUuid: auditTarget.value?.uuid,
+ before: auditNextCursor.value ?? undefined,
+ })
+ if (!res.success) throw new Error(res.errorMessage)
+ auditItems.value = reset ? res.payload.items : [...auditItems.value, ...res.payload.items]
+ auditNextCursor.value = res.payload.nextCursor ?? null
+ } catch (error) {
+ handleError(error)
+ } finally {
+ auditLoading.value = false
+ }
+}
+
+function openAudit(user?: SkinSiteAdminUser) {
+ auditTarget.value = user ?? null
+ void loadAudit(true)
+}
+
+function closeAudit() {
+ auditTarget.value = null
+ auditItems.value = []
+ auditNextCursor.value = null
+}
+
+function loadMoreAudit() {
+ if (auditLoading.value) return
+ void loadAudit(false)
+}
+
+// ---------- invite ----------
+const inviteOpen = ref(false)
+const inviteEmail = ref('')
+const inviteCode = ref('')
+const inviteBusy = ref(false)
+const inviteEmailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(inviteEmail.value))
+
+function openInvite() {
+ inviteOpen.value = true
+ inviteEmail.value = ''
+ inviteCode.value = ''
+}
+
+function closeInvite() {
+ inviteOpen.value = false
+}
+
+async function generateInvite() {
+ if (!inviteEmailValid.value) {
+ addNotification({ type: 'error', title: formatMessage(messages.inviteEmailInvalid) })
+ return
+ }
+ inviteBusy.value = true
+ try {
+ const res = await skinSiteAdminInviteUser(inviteEmail.value.trim())
+ if (!res.success) throw new Error(res.errorMessage)
+ inviteCode.value = res.payload.code
+ } catch (error) {
+ handleError(error)
+ } finally {
+ inviteBusy.value = false
+ }
+}
+
+async function copyInvite() {
+ if (!inviteCode.value) return
+ try {
+ await navigator.clipboard.writeText(inviteCode.value)
+ addNotification({ type: 'success', title: formatMessage(messages.inviteCopied) })
+ } catch (error) {
+ handleError(error)
+ }
+}
+
+// ---------- list ----------
+function formatDateTime(ts?: number | null): string {
+ if (!ts) return '—'
+ try {
+ return new Date(ts).toLocaleString()
} catch {
- return ''
+ return '—'
}
}
@@ -111,21 +477,19 @@ async function load(reset = false) {
loading.value = true
failed.value = false
try {
- const kw = keyword.value.trim()
- const listResult = await skinSiteAdminUserList({
+ const listRes = await skinSiteAdminUserList({
+ keyword: keyword.value.trim() || undefined,
+ searchField: searchField.value,
limit: PAGE_SIZE,
offset: offset.value,
- searchField: kw ? searchField.value : undefined,
- keyword: kw || undefined,
})
- if (!listResult.success) throw new Error(listResult.errorMessage)
- items.value = listResult.payload ?? []
- const countResult = await skinSiteAdminUserCount(
- kw ? { searchField: searchField.value, keyword: kw } : undefined,
- )
- if (countResult.success && typeof countResult.payload === 'number') {
- total.value = countResult.payload
- }
+ if (!listRes.success) throw new Error(listRes.errorMessage)
+ items.value = listRes.payload ?? []
+ const countRes = await skinSiteAdminUserCount({
+ keyword: keyword.value.trim() || undefined,
+ searchField: searchField.value,
+ })
+ if (countRes.success && typeof countRes.payload === 'number') total.value = countRes.payload
} catch (error) {
failed.value = true
handleError(error)
@@ -134,6 +498,10 @@ async function load(reset = false) {
}
}
+function search() {
+ void load(true)
+}
+
async function prevPage() {
if (!hasPrev.value) return
offset.value = Math.max(0, offset.value - PAGE_SIZE)
@@ -151,46 +519,38 @@ function startEdit(user: SkinSiteAdminUser) {
editForm.value = {
username: user.username,
email: user.email,
- qq: user.qq === undefined || user.qq === null ? '' : String(user.qq),
+ qq: user.qq !== null && user.qq !== undefined ? String(user.qq) : '',
password: '',
banned: user.banned,
- permissions: user.permissions.join(', '),
+ permissions: (user.permissions ?? []).join(', '),
}
}
function cancelEdit() {
editingUuid.value = null
- editForm.value = {
- username: '',
- email: '',
- qq: '',
- password: '',
- banned: false,
- permissions: '',
- }
}
async function saveEdit() {
if (!editingUuid.value || saving.value) return
saving.value = true
try {
- const qqText = editForm.value.qq.trim()
- const permissions = editForm.value.permissions
- .split(',')
- .map((p) => p.trim())
- .filter(Boolean)
- const result = await skinSiteAdminUpdateUser({
- uuid: editingUuid.value,
+ const target = items.value.find((u) => u.uuid === editingUuid.value)
+ if (!target) throw new Error('user not found')
+ const res = await skinSiteAdminUpdateUser({
+ uuid: target.uuid,
username: editForm.value.username.trim(),
email: editForm.value.email.trim(),
- qq: qqText ? Number(qqText) : undefined,
- password: editForm.value.password ? editForm.value.password : undefined,
+ password: editForm.value.password.trim() || undefined,
+ qq: editForm.value.qq.trim() ? Number(editForm.value.qq.trim()) : undefined,
banned: editForm.value.banned,
- permissions,
+ permissions: editForm.value.permissions
+ .split(',')
+ .map((p) => p.trim())
+ .filter(Boolean),
})
- if (!result.success) throw new Error(result.errorMessage)
+ if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
- cancelEdit()
+ editingUuid.value = null
await load()
} catch (error) {
handleError(error)
@@ -199,13 +559,13 @@ async function saveEdit() {
}
}
-async function remove(user: SkinSiteAdminUser) {
- if (deletingUuid.value !== null) return
+async function removeUser(user: SkinSiteAdminUser) {
+ if (deletingUuid.value) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: user.username }))) return
deletingUuid.value = user.uuid
try {
- const result = await skinSiteAdminDeleteUser(user.uuid)
- if (!result.success) throw new Error(result.errorMessage)
+ const res = await skinSiteAdminDeleteUser(user.uuid)
+ if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load()
} catch (error) {
@@ -215,237 +575,194 @@ async function remove(user: SkinSiteAdminUser) {
}
}
-async function togglePlayers(user: SkinSiteAdminUser) {
- if (playersUuid.value === user.uuid) {
- playersUuid.value = null
- playersFor.value = null
- return
- }
- playersUuid.value = user.uuid
- playersLoading.value = true
+async function openPlayers(user: SkinSiteAdminUser) {
+ playersTarget.value = user
playersFor.value = null
+ playersLoading.value = true
try {
- const result = await skinSiteAdminUserPlayers(user.uuid)
- if (result.success) {
- playersFor.value = result.payload ?? []
- } else {
- playersFor.value = []
- }
+ const res = await skinSiteAdminUserPlayers(user.uuid)
+ if (!res.success) throw new Error(res.errorMessage)
+ playersFor.value = res.payload ?? []
} catch (error) {
- playersFor.value = []
handleError(error)
} finally {
playersLoading.value = false
}
}
+function closePlayers() {
+ playersTarget.value = null
+ playersFor.value = null
+}
+
onMounted(() => void load())
+
-
-
-
{{ formatMessage(messages.title) }}
-
- {{ formatMessage(messages.total, { count: total }) }}
-
+
+
+
+
{{ formatMessage(messages.title) }}
+
+ {{ formatMessage(messages.total, { count: total }) }}
+
+
+
+
+ {{ formatMessage(messages.invite) }}
+
+
+ {{ formatMessage(messages.audit) }}
+
+
{{ formatMessage(messages.search) }}
-
+
{{ formatMessage(messages.loading) }}
-
+
{{ formatMessage(messages.failed) }}
-
+
{{ formatMessage(messages.empty) }}
-
-
-
-
-
- {{ user.username }}
- #{{ user.id }}
-
- {{ user.email }}
- {{ user.uuid }}
-
-
- {{ formatMessage(messages.banned) }}
-
-
- {{ perm }}
-
-
- {{ formatDate(user.registerTime) }}
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+ | {{ formatMessage(messages.username) }} |
+ {{ formatMessage(messages.email) }} |
+ {{ formatMessage(messages.qq) }} |
+ {{ formatMessage(messages.status) }} |
+ {{ formatMessage(messages.registeredAt) }} |
+ {{ formatMessage(messages.actions) }} |
+
+
+
+
- {{ formatMessage(messages.cancel) }}
-
-
- {{ formatMessage(messages.save) }}
-
-
-
-
-
-
-
- {{ formatMessage(messages.edit) }}
-
-
- {{ formatMessage(messages.players) }}
-
-
- {{ formatMessage(messages.remove) }}
-
-
-
-
-
-
- {{ formatMessage(messages.loading) }}
-
-
- {{ formatMessage(messages.noPlayers) }}
-
-
- {{ p.name }}
-
-
-
+
+ {{ user.username }}
+ {{ user.uuid }}
+ |
+ {{ user.email }} |
+ {{ user.qq ?? '—' }} |
+
+
+ {{
+ user.banned
+ ? formatMessage(messages.statusBanned)
+ : formatMessage(messages.statusNormal)
+ }}
+
+ |
+
+ {{ formatDateTime(user.registerTime) }}
+ |
+
+
+
+ {{ formatMessage(messages.players) }}
+
+
+ {{ formatMessage(messages.bot) }}
+
+
+ {{ formatMessage(messages.frequency) }}
+
+
+ {{ formatMessage(messages.audit) }}
+
+
+ {{ formatMessage(messages.edit) }}
+
+
+ {{ formatMessage(messages.remove) }}
+
+
+ |
+
+
+
-
+
@@ -456,13 +773,519 @@ onMounted(() => void load())
{{ formatMessage(messages.next) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.boundPlayersTitle) }} · {{ playersTarget.username }}
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.noPlayers) }}
+
+
+
+
+ {{ player.name }}
+
+ {{ player.isMojang ? 'Mojang' : 'StarLight' }}
+
+
+
{{ player.uuid }}
+
+ {{ formatMessage(messages.legacyAdapted) }}
+
+
+
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.botTitle) }} · {{ botTarget.username }}
+
+
+
{{ botTarget.username }}
+
{{ botTarget.uuid }}
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+
+
+ {{
+ botInfo.exists
+ ? formatMessage(messages.botExists)
+ : formatMessage(messages.botNotExists)
+ }}
+
+
+ {{
+ botInfo.active
+ ? formatMessage(messages.botActive)
+ : formatMessage(messages.botInactive)
+ }}
+
+
+ {{ formatMessage(messages.botCreationBanned) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.botInvalidate) }}
+
+
+ {{
+ botInfo.creationBanned
+ ? formatMessage(messages.botUnban)
+ : formatMessage(messages.botBan)
+ }}
+
+
+
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.frequencyTitle) }} · {{ frequencyTarget.username }}
+
+
+
{{ frequencyTarget.username }}
+
{{ frequencyTarget.uuid }}
+
+
{{ formatMessage(messages.frequencyHint) }}
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+
+
+ {{ formatMessage(messages.frequency) }}
+
+
+ {{
+ currentFrequencyLimit === null
+ ? formatMessage(messages.frequencyNone)
+ : formatMessage(messages.frequencyCurrent, { percent: currentFrequencyLimit })
+ }}
+
+
+
+
+
+ {{ formatMessage(messages.frequencyRange) }}
+
+
+
+ {{ formatMessage(messages.frequencySave) }}
+
+
+ {{ formatMessage(messages.frequencyClear) }}
+
+
+
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.auditTitle) }}
+
+ · {{ auditTarget.username }}
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.auditEmpty) }}
+
+
+
+
+
+
+ {{ auditActionLabel(entry.action) }}
+
+
+ {{ entry.actorName }} → {{ entry.targetName }}
+
+
+
+ {{ formatDateTime(entry.createdAt) }}
+
+
+
+
+ {{ auditFieldLabel(change.field) }}
+
+ {{ auditValue(change.before) }}
+ →
+ {{ auditValue(change.after) }}
+
+
+
+
+
+ {{ formatMessage(messages.auditMore) }}
+
+
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.inviteTitle) }}
+
+
+
+
+
+ {{ formatMessage(messages.inviteCopy) }}
+
+
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+ {{ formatMessage(messages.inviteGenerate) }}
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminWikiPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminWikiPage.vue
new file mode 100644
index 0000000..f0bd173
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminWikiPage.vue
@@ -0,0 +1,1258 @@
+
+
+
+
+
{{ formatMessage(messages.title) }}
+
+ {{ formatMessage(messages.tabPage) }}
+
+
+ {{ formatMessage(messages.tabAudit) }}
+
+
+ {{ formatMessage(messages.tabSettings) }}
+
+
+ {{ formatMessage(messages.tabMigration) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.loadPage) }}
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.pageNotFound) }}
+
+
+
+
+
+ {{ page.title }}
+
+
+ {{ formatMessage(messages.revision) }} #{{ page.revisionId }}
+
+
+
+ {{ formatMessage(messages.author) }}: {{ page.authorName }}
+
+ {{ formatMessage(messages.updatedAt) }}:
+ {{ formatDateTime(page.updatedAt) }}
+
+
+
+ {{ formatMessage(messages.categories) }}:
+
+ {{ c }}
+
+
+
+ {{ page.summary }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.saveProtect) }}
+
+
+ {{ formatMessage(messages.setHome) }}
+
+
+ {{
+ page.canEdit
+ ? formatMessage(messages.setDeleted)
+ : formatMessage(messages.setRestored)
+ }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.auditEmpty) }}
+
+
+
+
+ #{{ entry.id }}
+
+ {{ entry.action }}
+
+ {{ entry.actorName }}
+
+ {{ formatDateTime(entry.createdAt) }}
+
+
+
+ {{ formatMessage(messages.auditTarget) }}: {{ entry.target }}
+
+
+ {{ entry.detail }}
+
+
+
+
+ {{ formatMessage(messages.loadMore) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.saveSettings) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.migrationTitle) }}
+
+
+ {{ formatMessage(messages.migrationHint) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.xmlTitle) }}
+
+
+
+ {{ formatMessage(messages.startXml) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.jobs) }}
+
+
+ {{ formatMessage(messages.refreshJobs) }}
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.jobsEmpty) }}
+
+
+
+
+ #{{ job.id }} · {{ job.source }}
+
+ {{ jobStateLabel(job.state) }}
+
+
+
+
+ {{ formatMessage(messages.reviewedCount, { reviewed: job.reviewed, total: job.total }) }}
+ {{ formatMessage(messages.importedExcluded, { imported: job.imported, excluded: job.excluded }) }}
+
+ {{ job.error }}
+
+
+ {{ formatMessage(messages.viewJob) }}
+
+
+ {{ formatMessage(messages.deleteJob) }}
+
+
+
+
+ {{ formatMessage(messages.confirmDeleteJob, { id: job.id }) }}
+
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+ {{ formatMessage(messages.deleteJob) }}
+
+
+
+
+
+
+
+
+
+
+ {{ formatMessage(messages.candidates) }}
+
+
+
+ {{ formatMessage(messages.publish) }}
+
+
+ {{ formatMessage(messages.rollback) }}
+
+
+
+
+
{{ formatMessage(messages.confirmRollback) }}
+
+
+ {{ formatMessage(messages.cancel) }}
+
+
+ {{ formatMessage(messages.rollback) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.loading) }}
+
+
+ {{ formatMessage(messages.candidatesEmpty) }}
+
+
+
+
+ {{ candidate.title }}
+
+ {{ decisionLabel(candidate.decision) }}
+
+
+ {{ formatMessage(messages.conflict) }}
+
+
+
+ {{ formatMessage(messages.lastUpdated, { date: new Date(candidate.lastUpdatedAt).toLocaleDateString() }) }}
+ {{ formatMessage(messages.maintainerCount, { count: candidate.maintainerCount }) }}
+
+
+
+ {{ reason }}
+
+
+
+
+
+
+ {{ formatMessage(messages.importAction) }}
+
+
+ {{ formatMessage(messages.excludeAction) }}
+
+
+
+
+ {{ formatMessage(messages.loadMore) }}
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue b/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue
new file mode 100644
index 0000000..a8485e2
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue
@@ -0,0 +1,440 @@
+
+
+
+
+
+
+
{{ formatMessage(messages.title) }}
+
+ ✕
+
+
+
+
+
+
+ {{ formatMessage(messages.dropHint) }}
+
+
+ {{ formatMessage(messages.dropSub, { size: MAX_MOD_SIZE_MB }) }}
+
+
+
+
+
+ {{ formatMessage(messages.tagsLabel) }}
+
+
+
+ {{ tag.name }}
+
+
+
+
+
+
+ {{ formatMessage(messages.queue) }} ({{ finishedCount }}/{{ queue.length }})
+
+
+
+
+ {{ task.file.name }}
+
+
+ {{ (task.file.size / 1024 / 1024).toFixed(2) }} MB
+
+
+ {{ statusLabel(task.status) }}
+
+
+ {{ task.message }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.statusUploading) }}
+
+
+
+
+ {{ formatMessage(messages.close) }}
+
+
+
+
+
+
+
+ {{ formatMessage(messages.nameConflictTitle) }}
+
+
{{ conflictTask.file.name }}
+
{{ formatMessage(messages.nameConflictHint) }}
+
+
+ {{ formatMessage(messages.skip) }}
+
+
+ {{ formatMessage(messages.overwrite) }}
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/room-avatar-cache.ts b/apps/app-frontend/src/components/ui/skin-site/room-avatar-cache.ts
new file mode 100644
index 0000000..4e0a88c
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/room-avatar-cache.ts
@@ -0,0 +1,30 @@
+import { skinSiteChatRoomAvatarFetch } from '@/helpers/skin-site-api'
+
+const avatarUrls = new Map()
+const pending = new Map>()
+
+export function loadCachedRoomAvatar(
+ roomId: number,
+ attachmentId: number,
+): Promise {
+ const cached = avatarUrls.get(attachmentId)
+ if (cached) return Promise.resolve(cached)
+ const running = pending.get(attachmentId)
+ if (running) return running
+
+ const request = skinSiteChatRoomAvatarFetch(roomId).then((blob) => {
+ pending.delete(attachmentId)
+ if (!blob) return null
+ const url = URL.createObjectURL(blob)
+ avatarUrls.set(attachmentId, url)
+ return url
+ })
+ pending.set(attachmentId, request)
+ return request
+}
+
+export function forgetRoomAvatar(attachmentId: number) {
+ const url = avatarUrls.get(attachmentId)
+ if (url) URL.revokeObjectURL(url)
+ avatarUrls.delete(attachmentId)
+}
diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/WikiAttachmentNode.vue b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiAttachmentNode.vue
new file mode 100644
index 0000000..4f73b5a
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiAttachmentNode.vue
@@ -0,0 +1,74 @@
+
+
+
+
+ …
+
+
+
+
+
+
+ {{ imageCaption }}
+
+
+
+ {{ caption || attachment.name }}
+
+ 附件“{{ name }}”不存在
+
+
+
![]()
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNodeRenderer.vue b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNodeRenderer.vue
new file mode 100644
index 0000000..0c5288f
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNodeRenderer.vue
@@ -0,0 +1,185 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ node.text }}
+
+
+
+ {{ node.text }}
+
+
+
+
+
+
+
+
+
+
+
+ {{ node.text }}
+
+
+
+
+
+
+
+
+
+
+ {{ node.text }}
+
+ {{ node.text }}
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNotice.vue b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNotice.vue
new file mode 100644
index 0000000..95ae146
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/wiki/WikiNotice.vue
@@ -0,0 +1,32 @@
+
+
+
+
+
{{ title }}
+
+ {{ description }}
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/skin-site/wiki/wiki-color.ts b/apps/app-frontend/src/components/ui/skin-site/wiki/wiki-color.ts
new file mode 100644
index 0000000..e64cb4e
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/skin-site/wiki/wiki-color.ts
@@ -0,0 +1,18 @@
+export function safeWikiColor(value: unknown): string | null {
+ if (typeof value !== 'string') return null
+ const color = value.trim()
+ return color.length <= 80 &&
+ /^(?:[a-z]{1,20}|#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|rgba?\([\d\s.,%]+\))$/i.test(color)
+ ? color
+ : null
+}
+
+export function safeExternalUrl(url?: string | null): string {
+ if (!url) return '#'
+ try {
+ const parsed = new URL(url)
+ return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '#'
+ } catch {
+ return '#'
+ }
+}
diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts
index 5f322ce..c3bba36 100644
--- a/apps/app-frontend/src/helpers/skin-site-api.ts
+++ b/apps/app-frontend/src/helpers/skin-site-api.ts
@@ -389,12 +389,12 @@ export function skinSitePasswordReset(payload: {
// Donations & announcements
// ---------------------------------------------------------------------------
-export interface SkinSiteDonationSummary {
+export interface SkinSitePublicDonationSummary {
totalAmount: string
donorCount: number
}
-export interface SkinSiteDonationEntry {
+export interface SkinSitePublicDonationEntry {
id: number
rank: number
username: string
@@ -402,14 +402,16 @@ export interface SkinSiteDonationEntry {
amount: string
}
-export interface SkinSiteDonationPage {
- items: SkinSiteDonationEntry[]
- summary: SkinSiteDonationSummary
+export interface SkinSitePublicDonationPage {
+ items: SkinSitePublicDonationEntry[]
+ summary: SkinSitePublicDonationSummary
hasMore: boolean
}
-export function skinSiteDonationSummary(): Promise> {
- return skinSiteRequest('donation/summary', { auth: false })
+export function skinSitePublicDonationSummary(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('donation/summary', { auth: false })
}
export function skinSiteDonationList(offset = 0): Promise> {
@@ -487,71 +489,73 @@ export async function skinSiteUploadAvatar(blob: Blob): Promise> {
+}): Promise> {
const query = new URLSearchParams({
limit: String(options?.limit ?? 200),
offset: String(options?.offset ?? 0),
})
if (options?.category) query.set('category', options.category)
- return skinSiteRequest(`download/list?${query.toString()}`, {
+ return skinSiteRequest(`download/list?${query.toString()}`, {
auth: false,
})
}
-export function skinSiteDownloadCount(category?: string): Promise> {
+export function skinSitePublicDownloadCount(category?: string): Promise> {
const suffix = category ? `?category=${encodeURIComponent(category)}` : ''
return skinSiteRequest(`download/count${suffix}`, { auth: false })
}
-export function skinSiteDownloadCategories(): Promise<
- SkinSiteResponse
+export function skinSitePublicDownloadCategories(): Promise<
+ SkinSiteResponse
> {
- return skinSiteRequest('download/category/list', { auth: false })
+ return skinSiteRequest('download/category/list', {
+ auth: false,
+ })
}
// ---------------------------------------------------------------------------
// Maps
// ---------------------------------------------------------------------------
-export interface SkinSiteMapEntry {
+export interface SkinSitePublicMapEntry {
name: string
url: string
}
-export interface SkinSiteMapServer {
+export interface SkinSitePublicMapServer {
name: string
internal: boolean
- maps: SkinSiteMapEntry[]
+ maps: SkinSitePublicMapEntry[]
}
-export function skinSiteMapServers(): Promise> {
- return skinSiteRequest('maps', { auth: false })
+export function skinSitePublicMapServers(): Promise> {
+ return skinSiteRequest('maps', { auth: false })
}
// ---------------------------------------------------------------------------
// Player review (public side)
@@ -560,32 +564,32 @@ export function skinSiteMapServers(): Promise> {
+ const query = new URLSearchParams({
+ limit: String(params?.limit ?? 200),
+ offset: String(params?.offset ?? 0),
+ })
+ if (params?.category?.trim()) query.set('category', params.category.trim())
+ return skinSiteRequest('download/list?' + query.toString())
+}
+
+export function skinSiteDownloadItemCount(category?: string): Promise> {
+ const query = new URLSearchParams()
+ if (category?.trim()) query.set('category', category.trim())
+ const suffix = query.toString() ? '?' + query.toString() : ''
+ return skinSiteRequest('download/count' + suffix)
+}
+
+export function skinSiteAddDownloadItem(
+ data: SkinSiteDownloadItemInput,
+): Promise> {
+ return skinSiteRequest('download/add', { method: 'POST', body: data })
+}
+
+export function skinSiteUpdateDownloadItem(
+ id: number,
+ data: SkinSiteDownloadItemInput,
+): Promise> {
+ return skinSiteRequest('download/update/' + id, { method: 'POST', body: data })
+}
+
+export function skinSiteDeleteDownloadItem(id: number): Promise> {
+ return skinSiteRequest('download/' + id, { method: 'DELETE' })
+}
+
+export function skinSiteDownloadCategories(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('download/category/list')
+}
+
+export function skinSiteAddDownloadCategory(name: string): Promise> {
+ return skinSiteRequest('download/category/add/' + encodeURIComponent(name), {
+ method: 'POST',
+ })
+}
+
+export function skinSiteDeleteDownloadCategory(id: number): Promise> {
+ return skinSiteRequest('download/category/' + id, { method: 'DELETE' })
+}
+
+export function skinSiteBindDownloadCategory(
+ infoId: number,
+ categoryId: number,
+): Promise> {
+ return skinSiteRequest('download/category/bind/' + infoId + '/' + categoryId, {
+ method: 'POST',
+ })
+}
+
+export function skinSiteUnbindDownloadCategory(
+ infoId: number,
+ categoryId: number,
+): Promise> {
+ return skinSiteRequest('download/category/unbind/' + infoId + '/' + categoryId, {
+ method: 'POST',
+ })
+}
+
+export interface SkinSiteMapServer {
+ name: string
+ internal: boolean
+ maps: Array<{ name: string; url: string }>
+}
+
+export function skinSiteMapServers(): Promise> {
+ return skinSiteRequest('maps')
+}
+
+export function skinSiteSaveMapServers(
+ servers: SkinSiteMapServer[],
+): Promise> {
+ return skinSiteRequest('maps', { method: 'PUT', body: servers })
+}
+
+export interface SkinSiteAccessAuditEntry {
+ id: number
+ subject: string
+ action: string
+ sourceIp: string
+ proxy: boolean
+ reportedPlayerIp: string | null
+ internalServer: boolean
+ count: number
+ firstAt: number
+ lastAt: number
+}
+
+export interface SkinSiteAccessAuditPage {
+ items: SkinSiteAccessAuditEntry[]
+ nextCursor: number | null
+ retentionDays: number
+ recordLimit: number
+}
+
+export function skinSiteAccessAudit(params?: {
+ before?: number
+ subject?: string
+ ip?: string
+ action?: string
+}): Promise> {
+ const query = new URLSearchParams()
+ if (params) {
+ for (const [key, value] of Object.entries(params)) {
+ if (value !== undefined && value !== '') query.set(key, String(value))
+ }
+ }
+ const suffix = query.toString() ? '?' + query.toString() : ''
+ return skinSiteRequest('admin/access-audit' + suffix)
+}
+
+export function skinSiteUpdateAccessAuditSettings(
+ retentionDays: number,
+): Promise> {
+ return skinSiteRequest<{ retentionDays: number }>('admin/access-audit/settings', {
+ method: 'PUT',
+ 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> {
+ return skinSiteRequest('donation/admin/list?limit=20&offset=' + offset)
+}
+
+export function skinSiteFindAdminDonations(identity: {
+ userUuid?: string
+ qq?: string
+}): Promise> {
+ const query = new URLSearchParams()
+ if (identity.userUuid) query.set('userUuid', identity.userUuid)
+ if (identity.qq) query.set('qq', identity.qq)
+ return skinSiteRequest('donation/admin/find?' + query.toString())
+}
+
+export interface SkinSiteDonationCreate {
+ amount: string
+ userUuid?: string
+ qq?: string
+ displayName?: string
+}
+
+export function skinSiteCreateDonation(
+ data: SkinSiteDonationCreate,
+): Promise> {
+ return skinSiteRequest('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> {
+ return skinSiteRequest('donation/admin/' + id, { method: 'PUT', body: data })
+}
+
+export function skinSiteDeleteDonation(
+ id: number,
+ version: number,
+): Promise> {
+ return skinSiteRequest('donation/admin/' + id + '?version=' + version, {
+ method: 'DELETE',
+ })
+}
+
+export type SkinSiteModLoaderType = 'FABRIC' | 'FORGE'
+
+export interface SkinSiteModMetadata {
+ modId?: string
+ name?: string
+ version?: string
+ iconBytes?: string
+ loaderType: SkinSiteModLoaderType[]
+}
+
+export interface SkinSiteModInfo {
+ domainRules: string[]
+ hash: string
+ name: string
+ size: number
+ metadata: SkinSiteModMetadata
+}
+
+export interface SkinSiteModRuleTag {
+ id: number
+ name: string
+ rules: string[]
+}
+
+export function skinSiteModCount(params?: {
+ serverAddr?: string
+ ruleTagId?: number | null
+}): Promise> {
+ const query = new URLSearchParams()
+ const addr = params?.serverAddr?.trim()
+ if (addr) query.set('server_addr', addr)
+ if (params?.ruleTagId != null) query.set('rule_tag_id', String(params.ruleTagId))
+ const suffix = query.toString() ? '?' + query.toString() : ''
+ return skinSiteRequest('query/mod/number' + suffix, { type: 'mod' })
+}
+
+export function skinSiteModList(params: {
+ limit: number
+ offset: number
+ serverAddr?: string
+ ruleTagId?: number | null
+}): Promise> {
+ return skinSiteRequest('query/mod/list', {
+ method: 'POST',
+ type: 'mod',
+ body: {
+ offset: params.offset || 0,
+ limit: params.limit || 0,
+ serverAddr: params.serverAddr?.trim() || undefined,
+ ruleTagId: params.ruleTagId ?? undefined,
+ },
+ })
+}
+
+export function skinSiteModSearch(
+ name: string,
+ ruleTagId?: number | null,
+): Promise> {
+ const query = new URLSearchParams()
+ if (ruleTagId != null) query.set('rule_tag_id', String(ruleTagId))
+ const suffix = query.toString() ? '?' + query.toString() : ''
+ return skinSiteRequest(
+ 'query/mod/search/' + encodeURIComponent(name) + suffix,
+ { type: 'mod' },
+ )
+}
+
+export function skinSiteUpdateModInfo(info: SkinSiteModInfo): Promise> {
+ return skinSiteRequest('update/info', { method: 'POST', type: 'mod', body: info })
+}
+
+export function skinSiteDeleteModFile(hash: string): Promise> {
+ return skinSiteRequest(hash, { method: 'DELETE', type: 'mod' })
+}
+
+export function skinSiteDeleteMultiModFile(hashes: string[]): Promise> {
+ return skinSiteRequest('delete/multimod', {
+ method: 'POST',
+ type: 'mod',
+ body: { modHashes: hashes },
+ })
+}
+
+export function skinSiteModRuleTags(): Promise> {
+ return skinSiteRequest('rule-tags', { type: 'mod' })
+}
+
+export function skinSiteCreateModRuleTag(
+ name: string,
+ rules: string[],
+): Promise> {
+ return skinSiteRequest('rule-tags', {
+ method: 'POST',
+ type: 'mod',
+ body: { name, rules },
+ })
+}
+
+export function skinSiteUpdateModRuleTag(
+ id: number,
+ name: string,
+ rules: string[],
+): Promise> {
+ return skinSiteRequest('rule-tags/' + id, {
+ method: 'POST',
+ type: 'mod',
+ body: { name, rules },
+ })
+}
+
+export function skinSiteDeleteModRuleTag(id: number): Promise> {
+ return skinSiteRequest('rule-tags/' + id, { method: 'DELETE', type: 'mod' })
+}
+
+export interface SkinSiteModRuntimeRelease {
+ id: number
+ version: string
+ fileName: string
+ sha256: string
+ fileSize: number
+ uploadedBy: string
+ uploadedAt: number
+ publishedAt: number | null
+ active: boolean
+ available: boolean
+ downloadUrl: string
+}
+
+export interface SkinSiteModRuntimeAdminState {
+ manifestUrl: string
+ releases: SkinSiteModRuntimeRelease[]
+}
+
+export function skinSiteModRuntimeReleases(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('runtime/admin', { type: 'mod' })
+}
+
+export function skinSiteUploadModRuntime(
+ version: string,
+ file: File,
+): Promise> {
+ const form = new FormData()
+ form.append('runtime', file, file.name)
+ return skinSiteRequest(
+ 'runtime/admin/upload?version=' + encodeURIComponent(version),
+ { method: 'POST', type: 'mod', form },
+ )
+}
+
+export function skinSiteActivateModRuntime(
+ id: number,
+): Promise> {
+ return skinSiteRequest('runtime/admin/' + id + '/activate', {
+ method: 'POST',
+ type: 'mod',
+ })
+}
+
+export function skinSiteDeleteModRuntime(id: number): Promise> {
+ return skinSiteRequest('runtime/admin/' + id, { method: 'DELETE', type: 'mod' })
+}
+
+export type SkinSiteWikiReadPolicy = 'PUBLIC' | 'MEMBER'
+export type SkinSiteWikiEditPolicy = 'MEMBER' | 'WEB_ADMIN'
+
+export interface SkinSiteWikiNode {
+ type: string
+ color?: string | null
+ align?: 'left' | 'center' | 'right' | 'justify' | null
+ start?: number | null
+ colspan?: number | null
+ rowspan?: number | null
+ variant?: string | null
+ iconFamily?: string | null
+ iconName?: string | null
+ iconVersion?: number | null
+ linkTitle?: string | null
+ linkLabel?: string | null
+ tooltip?: string | null
+ exists?: boolean | null
+ text?: string | null
+ level?: number | null
+ href?: string | null
+ title?: string | null
+ children: SkinSiteWikiNode[]
+ rows: SkinSiteWikiNode[][][]
+}
+
+export interface SkinSiteWikiAttachmentInfo {
+ id: number
+ versionId: number
+ name: string
+ mime: string
+ size: number
+ image: boolean
+ uploaderName: string
+ createdAt: number
+}
+
+export interface SkinSiteWikiPageDetail {
+ title: string
+ revisionId: number
+ source: string
+ nodes: SkinSiteWikiNode[]
+ categories: string[]
+ updatedAt: number
+ authorName: string
+ summary: string
+ readPolicy: SkinSiteWikiReadPolicy
+ editPolicy: SkinSiteWikiEditPolicy
+ redirectTarget?: string | null
+ canEdit: boolean
+ watched: boolean
+}
+
+export interface SkinSiteWikiSettings {
+ domains: string[]
+ homePage: string
+ defaultSkin: string
+ maxPageUtf8Bytes: number
+ maxAttachmentBytes: number
+}
+
+export interface SkinSiteWikiAuditInfo {
+ id: number
+ actorName: string
+ action: string
+ target?: string | null
+ detail?: string | null
+ createdAt: number
+}
+
+export interface SkinSiteWikiAuditPage {
+ items: SkinSiteWikiAuditInfo[]
+ nextCursor?: number | null
+}
+
+export function skinSiteWikiPage(path: string): Promise> {
+ return skinSiteRequest('wiki/page?path=' + encodeURIComponent(path))
+}
+
+export function skinSiteWikiProtectPage(
+ path: string,
+ readPolicy: SkinSiteWikiReadPolicy,
+ editPolicy: SkinSiteWikiEditPolicy,
+): Promise> {
+ return skinSiteRequest('wiki/admin/protect', {
+ method: 'POST',
+ body: { path, readPolicy, editPolicy },
+ })
+}
+
+export function skinSiteWikiSetHomePage(path: string): Promise> {
+ return skinSiteRequest('wiki/admin/homepage', { method: 'POST', body: { path } })
+}
+
+export function skinSiteWikiSetPageDeleted(
+ path: string,
+ deleted: boolean,
+): Promise> {
+ return skinSiteRequest('wiki/admin/delete', {
+ method: 'POST',
+ body: { path, deleted },
+ })
+}
+
+export function skinSiteWikiAudit(
+ before?: number,
+): Promise> {
+ const suffix = before ? '?before=' + before : ''
+ return skinSiteRequest('wiki/admin/audit' + suffix)
+}
+
+export function skinSiteWikiSettings(): Promise> {
+ return skinSiteRequest('wiki/settings')
+}
+
+export function skinSiteWikiUpdateSettings(
+ settings: Partial,
+): Promise> {
+ return skinSiteRequest('wiki/admin/settings', {
+ method: 'POST',
+ body: settings,
+ })
+}
+
+
+export interface SkinSiteReviewPaperSummary {
+ id: string
+ enabled: boolean
+ title: string
+ annotation?: string | null
+ mode: SkinSiteReviewMode
+ passScore: number
+ targetPermissions: string[]
+ version: number
+ questionCount: number
+ totalScore: number
+ updatedAt: number
+ prerequisiteMode: 'ANY' | 'ALL'
+ prerequisitePermissions: string[]
+ eligible: boolean
+}
+
+export interface SkinSiteReviewMailConfig {
+ enabled: boolean
+ subject: string
+ message: string
+ includeInviteCode: boolean
+ extraText: string
+}
+
+export interface SkinSiteReviewConfigData {
+ preventDuplicate?: boolean
+ mail?: SkinSiteReviewMailConfig
+ id: string
+ enabled: boolean
+ title: string
+ annotation?: string | null
+ mode: SkinSiteReviewMode
+ passScore: number
+ targetPermissions: string[]
+ version: number
+ questions: SkinSiteReviewQuestion[]
+ updatedAt: number
+ prerequisiteMode: 'ANY' | 'ALL'
+ prerequisitePermissions: string[]
+}
+
+export function skinSiteReviewPaperList(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('review/admin/papers')
+}
+
+export function skinSiteReviewPaperAdmin(
+ paperId: string,
+): Promise> {
+ return skinSiteRequest(
+ 'review/admin/papers/' + encodeURIComponent(paperId),
+ )
+}
+
+export function skinSiteCreateReviewPaperAdmin(
+ data: SkinSiteReviewConfigData,
+): Promise> {
+ return skinSiteRequest('review/admin/papers', {
+ method: 'POST',
+ body: data,
+ })
+}
+
+export function skinSiteUpdateReviewPaperAdmin(
+ paperId: string,
+ data: SkinSiteReviewConfigData,
+): Promise> {
+ return skinSiteRequest(
+ 'review/admin/papers/' + encodeURIComponent(paperId),
+ { method: 'PUT', body: data },
+ )
+}
+
+export interface SkinSiteBotInfo {
+ exists: boolean
+ username?: string | null
+ active: boolean
+ creationBanned: boolean
+ tokenHint?: string | null
+ createdAt?: number | null
+ updatedAt?: number | null
+}
+
+export interface SkinSiteUserFrequencyLimit {
+ dropPercent?: number | null
+}
+
+export type SkinSiteUserAdminAuditAction =
+ | 'USER_UPDATED'
+ | 'USER_DELETED'
+ | 'RATE_LIMIT_UPDATED'
+ | 'BOT_USERNAME_UPDATED'
+ | 'BOT_TOKEN_INVALIDATED'
+ | 'BOT_CREATION_BANNED'
+ | 'BOT_CREATION_UNBANNED'
+
+export interface SkinSiteUserAdminAuditChange {
+ field: string
+ before?: string | null
+ after?: string | null
+}
+
+export interface SkinSiteUserAdminAuditInfo {
+ id: number
+ actorUuid: string
+ actorName: string
+ targetUuid: string
+ targetName: string
+ action: SkinSiteUserAdminAuditAction
+ changes: SkinSiteUserAdminAuditChange[]
+ createdAt: number
+}
+
+export interface SkinSiteUserAdminAuditPage {
+ items: SkinSiteUserAdminAuditInfo[]
+ nextCursor?: number | null
+}
+
+export interface SkinSiteInviteCode {
+ email: string
+ code: string
+}
+
+export function skinSiteAdminBotInfo(
+ userUuid: string,
+): Promise> {
+ return skinSiteRequest('admin/users/' + encodeURIComponent(userUuid) + '/bot')
+}
+
+export function skinSiteAdminRenameBot(
+ userUuid: string,
+ username: string,
+): Promise> {
+ return skinSiteRequest(
+ 'admin/users/' + encodeURIComponent(userUuid) + '/bot/username',
+ { method: 'POST', body: { username } },
+ )
+}
+
+export function skinSiteAdminInvalidateBotToken(
+ userUuid: string,
+): Promise> {
+ return skinSiteRequest(
+ 'admin/users/' + encodeURIComponent(userUuid) + '/bot/invalidate',
+ { method: 'POST' },
+ )
+}
+
+export function skinSiteAdminSetBotCreationBanned(
+ userUuid: string,
+ banned: boolean,
+): Promise> {
+ return skinSiteRequest(
+ 'admin/users/' + encodeURIComponent(userUuid) + '/bot/ban',
+ { method: 'POST', body: { banned } },
+ )
+}
+
+export function skinSiteAdminUpdateUserFrequencyLimit(
+ userUuid: string,
+ dropPercent: number | null,
+): Promise> {
+ return skinSiteRequest(
+ 'admin/users/' + encodeURIComponent(userUuid) + '/frequency-limit',
+ { method: 'POST', body: { dropPercent } },
+ )
+}
+
+export function skinSiteAdminUserAudit(params?: {
+ targetUuid?: string
+ before?: number
+}): Promise> {
+ const query = new URLSearchParams()
+ if (params?.targetUuid) query.set('targetUuid', params.targetUuid)
+ if (params?.before) query.set('before', String(params.before))
+ const suffix = query.size > 0 ? '?' + query.toString() : ''
+ return skinSiteRequest('admin/users/audit' + suffix)
+}
+
+export function skinSiteAdminInviteUser(email: string): Promise> {
+ return skinSiteRequest('admin/inviteCode', {
+ method: 'POST',
+ body: { email },
+ })
+}
+
+export function skinSiteModHashFromFile(file: File): Promise {
+ return import('xxhash-wasm').then(async ({ default: xxhash }) => {
+ const { create64 } = await xxhash()
+ const hasher = create64()
+ const reader = file.stream().getReader()
+ for (;;) {
+ const { done, value } = await reader.read()
+ if (done) break
+ hasher.update(value)
+ }
+ const raw: bigint = hasher.digest()
+ const mask = (1n << 64n) - 1n
+ const v = raw & mask
+ const signed = v >= 1n << 63n ? v - (1n << 64n) : v
+ return signed.toString()
+ })
+}
+
+export function skinSiteModExistsByHash(hash: string): Promise> {
+ return skinSiteRequest('query/hash/' + encodeURIComponent(hash), { type: 'mod' })
+}
+
+export function skinSiteModExistsByName(modName: string): Promise> {
+ const q = new URLSearchParams({ mod_name: modName })
+ return skinSiteRequest('query/name?' + q.toString(), { type: 'mod' })
+}
+
+export async function skinSiteUploadMod(
+ file: File,
+ ruleTagIds: number[] = [],
+ overwrite = false,
+): Promise> {
+ const hash = await skinSiteModHashFromFile(file)
+ const form = new FormData()
+ form.append('mods', file, file.name)
+ const query = new URLSearchParams()
+ if (ruleTagIds.length) query.set('rule_tag_ids', ruleTagIds.join(','))
+ if (overwrite) query.set('overwrite', 'true')
+ const suffix = query.size ? '?' + query.toString() : ''
+ return skinSiteRequest('upload/' + hash + suffix, {
+ method: 'POST',
+ type: 'mod',
+ form,
+ })
+}
+
+export function skinSiteUpdateMultiModInfo(
+ mods: SkinSiteModInfo[],
+): Promise> {
+ return skinSiteRequest('update/info/multi', {
+ method: 'POST',
+ type: 'mod',
+ body: mods,
+ })
+}
+
+// ---------------------------------------------------------------------------
+// Chat
+// ---------------------------------------------------------------------------
+
+export type SkinSiteChatRoomVisibility = 'PUBLIC' | 'PRIVATE'
+export type SkinSiteChatRoomKind = 'STANDARD' | 'SETTLEMENT'
+export type SkinSiteChatPrerequisiteMode = 'ANY' | 'ALL'
+
+export interface SkinSiteChatRoomInfo {
+ id: number
+ name: string
+ description: string
+ visibility: SkinSiteChatRoomVisibility
+ joined: boolean
+ invited: boolean
+ manager: boolean
+ createdAt: number
+ kind: SkinSiteChatRoomKind
+ avatarAttachmentId?: number | null
+ voiceEnabled: boolean
+ voiceAvailable: boolean
+ voiceParticipantCount: number
+ voicePublisherCount: number
+ voicePublisherLimit: number
+ voiceParticipantLimit: number
+ voiceListenerLimit: number
+ prerequisiteMode: SkinSiteChatPrerequisiteMode
+ prerequisitePermissions: string[]
+ prerequisiteEligible: boolean
+}
+
+export interface SkinSiteChatUploadInfo {
+ token: string
+ attachment: {
+ id: number
+ kind: 'IMAGE' | 'FILE'
+ name: string
+ size: number
+ }
+}
+
+export interface SkinSiteChatRoomCreateInput {
+ name: string
+ description: string
+ visibility: SkinSiteChatRoomVisibility
+ kind?: SkinSiteChatRoomKind
+ prerequisiteMode: SkinSiteChatPrerequisiteMode
+ prerequisitePermissions: string[]
+}
+
+export interface SkinSiteChatRoomUpdateInput {
+ name: string
+ description: string
+ visibility: SkinSiteChatRoomVisibility
+ prerequisiteMode: SkinSiteChatPrerequisiteMode
+ prerequisitePermissions: string[]
+}
+
+export function skinSiteChatRoomList(): Promise> {
+ return skinSiteRequest('chat/admin/rooms')
+}
+
+export function skinSiteChatRoomCreate(
+ data: SkinSiteChatRoomCreateInput,
+): Promise> {
+ return skinSiteRequest('chat/admin/rooms', {
+ method: 'POST',
+ body: data,
+ })
+}
+
+export function skinSiteChatRoomUpdate(
+ id: number,
+ data: SkinSiteChatRoomUpdateInput,
+): Promise> {
+ return skinSiteRequest('chat/rooms/' + id, {
+ method: 'PUT',
+ body: data,
+ })
+}
+
+export function skinSiteChatRoomDelete(id: number): Promise> {
+ return skinSiteRequest('chat/admin/rooms/' + id, { method: 'DELETE' })
+}
+
+export function skinSiteChatRoomAvatarUpdate(
+ roomId: number,
+ data: { imageToken?: string; removeImage?: boolean },
+): Promise> {
+ return skinSiteRequest('chat/rooms/' + roomId + '/avatar', {
+ method: 'PUT',
+ body: data,
+ })
+}
+
+export function skinSiteChatAttachmentUpload(
+ file: File,
+): Promise> {
+ const form = new FormData()
+ form.append('file', file, file.name)
+ return skinSiteRequest('chat/attachments', {
+ method: 'POST',
+ form,
+ })
+}
+
+export async function skinSiteChatRoomAvatarFetch(roomId: number): Promise {
+ const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/chat/rooms/${roomId}/avatar`
+ const headers: Record = { Accept: 'image/*' }
+ const token = getSkinSiteToken()
+ if (token) headers['Authorization'] = `Bearer ${token}`
+ try {
+ const response = await tauriFetch(url, { method: 'GET', headers })
+ if (!response.ok) return null
+ const type = response.headers.get('content-type') ?? ''
+ if (type.includes('application/json') || type.includes('text/')) return null
+ return await response.blob()
+ } catch {
+ return null
+ }
+}
+
+export interface SkinSiteChatUserInfo {
+ uuid: string
+ id: number
+ username: string
+ avatar?: string | null
+ email: string
+ qq?: number | null
+ permissions: string[]
+ banned: boolean
+ registerTime: number
+}
+
+export interface SkinSiteChatAuditInfo {
+ id: number
+ actorUuid: string
+ actorName?: string
+ action: string
+ target?: string | null
+ createdAt: number
+}
+
+export function skinSiteChatUserSearch(
+ keyword: string,
+ roomId?: number,
+ searchBy: 'user' | 'player' = 'user',
+): Promise> {
+ const query = new URLSearchParams({ keyword })
+ if (roomId !== undefined) query.set('room_id', String(roomId))
+ if (searchBy === 'player') query.set('search_by', 'player')
+ return skinSiteRequest('chat/users/search?' + query.toString())
+}
+
+export function skinSiteChatManagerSet(
+ roomId: number,
+ userUuid: string,
+ manager: boolean,
+): Promise> {
+ return skinSiteRequest('chat/admin/rooms/' + roomId + '/manager', {
+ method: 'POST',
+ body: { userUuid, manager },
+ })
+}
+
+export function skinSiteChatSettlementOwnerAssign(
+ roomId: number,
+ userUuid: string,
+): Promise> {
+ return skinSiteRequest('chat/settlements/' + roomId + '/owner', {
+ method: 'POST',
+ body: { userUuid },
+ })
+}
+
+export function skinSiteChatAuditList(
+ roomId: number,
+): Promise> {
+ return skinSiteRequest('chat/rooms/' + roomId + '/audit')
+}
+
+export type SkinSiteChatVoiceRuntimeState =
+ | 'DISABLED'
+ | 'NOT_INSTALLED'
+ | 'DOWNLOADING'
+ | 'VERIFYING'
+ | 'EXTRACTING'
+ | 'STARTING'
+ | 'READY'
+ | 'FAILED'
+ | 'UNSUPPORTED_PLATFORM'
+
+export interface SkinSiteChatVoiceRuntimeInfo {
+ state: SkinSiteChatVoiceRuntimeState
+ version: string
+ progress?: number | null
+ message?: string | null
+ retryCount: number
+ publicUrl?: string | null
+}
+
+export interface SkinSiteChatVoiceGlobalConfig {
+ enabled: boolean
+ runtimePath: string
+ mirrorBaseUrl: string
+ publicUrl: string
+ bindAddress: string
+ signalPort: number
+ rtcTcpPort: number
+ rtcUdpPort: number
+ startupTimeoutSeconds: number
+ useExternalIp: boolean
+ nodeIp: string
+}
+
+export interface SkinSiteChatVoiceGlobalConfigUpdateResult {
+ config: SkinSiteChatVoiceGlobalConfig
+ runtime: SkinSiteChatVoiceRuntimeInfo
+ restarted: boolean
+}
+
+export interface SkinSiteChatVoiceLogEntry {
+ id: number
+ timestamp: number
+ message: string
+}
+
+export interface SkinSiteChatVoiceLogPage {
+ entries: SkinSiteChatVoiceLogEntry[]
+ latestId: number
+}
+
+export interface SkinSiteChatVoiceRoomInfo {
+ roomId: number
+ enabled: boolean
+ available: boolean
+ publisherLimit: number
+ participantLimit: number
+ listenerLimit: number
+ participantCount: number
+ publisherCount: number
+ listenerCount: number
+ joined: boolean
+ observer: boolean
+ muted: boolean
+ bannedUntil?: number | null
+}
+
+export function skinSiteChatVoiceRuntime(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('chat/voice/admin/runtime')
+}
+
+export function skinSiteChatVoiceRuntimeRetry(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('chat/voice/admin/runtime/retry', {
+ method: 'POST',
+ })
+}
+
+export function skinSiteChatVoiceConfig(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('chat/voice/admin/config')
+}
+
+export function skinSiteChatVoiceConfigUpdate(
+ data: SkinSiteChatVoiceGlobalConfig,
+): Promise> {
+ return skinSiteRequest(
+ 'chat/voice/admin/config',
+ { method: 'PUT', body: data },
+ )
+}
+
+export function skinSiteChatVoiceLogs(
+ after?: number,
+ limit = 300,
+): Promise> {
+ const query = new URLSearchParams({ limit: String(limit) })
+ if (after !== undefined) query.set('after', String(after))
+ return skinSiteRequest('chat/voice/admin/logs?' + query.toString())
+}
+
+export function skinSiteChatVoiceCapacityUpdate(
+ roomId: number,
+ data: { participantLimit: number; listenerLimit: number },
+): Promise> {
+ return skinSiteRequest(
+ 'chat/admin/rooms/' + roomId + '/voice/capacity',
+ { method: 'PUT', body: data },
+ )
+}
+
+// ---------------------------------------------------------------------------
+// Wiki migration
+// ---------------------------------------------------------------------------
+
+export type SkinSiteWikiMigrationState =
+ | 'IMPORTING'
+ | 'REVIEW'
+ | 'PUBLISHING'
+ | 'PUBLISHED'
+ | 'FAILED'
+ | 'ROLLED_BACK'
+
+export type SkinSiteWikiMigrationDecision = 'PENDING' | 'IMPORT' | 'EXCLUDE'
+
+export interface SkinSiteWikiMigrationJobInfo {
+ id: number
+ source: string
+ state: SkinSiteWikiMigrationState
+ total: number
+ reviewed: number
+ imported: number
+ excluded: number
+ error?: string | null
+ missingAttachmentCount?: number
+}
+
+export interface SkinSiteWikiMigrationCandidate {
+ id: number
+ title: string
+ lastUpdatedAt: number
+ maintainerCount: number
+ suspiciousReasons: string[]
+ decision: SkinSiteWikiMigrationDecision
+ conflict: boolean
+}
+
+export interface SkinSiteWikiMigrationPage {
+ items: SkinSiteWikiMigrationCandidate[]
+ nextCursor?: number | null
+}
+
+export interface SkinSiteWikiMigrationPublishResult {
+ imported: number
+ overwritten: number
+}
+
+export interface SkinSiteWikiAutomaticMigrationInput {
+ apiUrl: string
+ username?: string | null
+ password?: string | null
+ allowPrivateAddress: boolean
+ rules: {
+ staleDays: number
+ maintainerWindowDays: number
+ maximumMaintainers: number
+ staleMarkers: string[]
+ }
+}
+
+export function skinSiteWikiMigrationJobs(): Promise<
+ SkinSiteResponse
+> {
+ return skinSiteRequest('wiki/admin/migration')
+}
+
+export function skinSiteWikiMigrationStartAutomatic(
+ data: SkinSiteWikiAutomaticMigrationInput,
+): Promise> {
+ return skinSiteRequest('wiki/admin/migration/automatic', {
+ method: 'POST',
+ body: data,
+ })
+}
+
+export function skinSiteWikiMigrationStartXml(
+ file: File,
+): Promise> {
+ const form = new FormData()
+ form.append('xml', file, file.name)
+ return skinSiteRequest('wiki/admin/migration/xml', {
+ method: 'POST',
+ form,
+ })
+}
+
+export function skinSiteWikiMigrationCandidates(
+ jobId: number,
+ after?: number,
+): Promise> {
+ const suffix = after ? '?after=' + after : ''
+ return skinSiteRequest(
+ 'wiki/admin/migration/' + jobId + '/candidates' + suffix,
+ )
+}
+
+export function skinSiteWikiMigrationDecide(
+ candidateId: number,
+ data: { decision: SkinSiteWikiMigrationDecision; targetTitle?: string | null; overwrite?: boolean },
+): Promise> {
+ return skinSiteRequest('wiki/admin/migration/candidates/' + candidateId, {
+ method: 'POST',
+ body: data,
+ })
+}
+
+export function skinSiteWikiMigrationPublish(
+ jobId: number,
+): Promise> {
+ return skinSiteRequest(
+ 'wiki/admin/migration/' + jobId + '/publish',
+ { method: 'POST' },
+ )
+}
+
+export function skinSiteWikiMigrationRollback(
+ jobId: number,
+): Promise> {
+ return skinSiteRequest('wiki/admin/migration/' + jobId + '/rollback', {
+ method: 'POST',
+ })
+}
+
+export function skinSiteWikiMigrationDelete(jobId: number): Promise> {
+ return skinSiteRequest('wiki/admin/migration/' + jobId, { method: 'DELETE' })
+}
+
+export function skinSiteWikiAttachment(
+ name: string,
+): Promise> {
+ return skinSiteRequest(
+ 'wiki/attachment?name=' + encodeURIComponent(name),
+ )
+}
+
+export function skinSiteWikiAttachmentUrl(id: number): string {
+ return `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/wiki/attachment/${id}/content`
+}
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 065b42c..9a3ae7f 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -11952,5 +11952,487 @@
"app.skin-site.admin.launcher.prev": {"message": "Previous"},
"app.skin-site.admin.launcher.next": {"message": "Next"},
"app.skin-site.admin.launcher.page": {"message": "Page {page}"},
- "app.skin-site.admin.launcher.total": {"message": "{count} releases"}
+ "app.skin-site.admin.launcher.total": {"message": "{count} releases"},
+ "app.skin-site.admin.downloads.title": {"message": "Downloads"},
+ "app.skin-site.admin.downloads.loading": {"message": "Loading…"},
+ "app.skin-site.admin.downloads.failed": {"message": "Could not load downloads."},
+ "app.skin-site.admin.downloads.empty": {"message": "No download items."},
+ "app.skin-site.admin.downloads.categories": {"message": "Categories"},
+ "app.skin-site.admin.downloads.all": {"message": "All items"},
+ "app.skin-site.admin.downloads.add-category": {"message": "Add"},
+ "app.skin-site.admin.downloads.category-name": {"message": "Category name"},
+ "app.skin-site.admin.downloads.remove-category": {"message": "Remove"},
+ "app.skin-site.admin.downloads.total": {"message": "{count} items"},
+ "app.skin-site.admin.downloads.add-item": {"message": "New item"},
+ "app.skin-site.admin.downloads.save": {"message": "Save"},
+ "app.skin-site.admin.downloads.cancel": {"message": "Cancel"},
+ "app.skin-site.admin.downloads.edit": {"message": "Edit"},
+ "app.skin-site.admin.downloads.remove": {"message": "Delete"},
+ "app.skin-site.admin.downloads.filename": {"message": "File name"},
+ "app.skin-site.admin.downloads.description": {"message": "Description"},
+ "app.skin-site.admin.downloads.routes": {"message": "Download routes"},
+ "app.skin-site.admin.downloads.route-name": {"message": "Name"},
+ "app.skin-site.admin.downloads.route-url": {"message": "URL"},
+ "app.skin-site.admin.downloads.add-route": {"message": "Add route"},
+ "app.skin-site.admin.downloads.remove-route": {"message": "Remove"},
+ "app.skin-site.admin.downloads.statuses": {"message": "Statuses"},
+ "app.skin-site.admin.downloads.bind-category": {"message": "Categories"},
+ "app.skin-site.admin.downloads.uploader": {"message": "by {name}"},
+ "app.skin-site.admin.downloads.created": {"message": "Item created."},
+ "app.skin-site.admin.downloads.updated": {"message": "Item updated."},
+ "app.skin-site.admin.downloads.deleted": {"message": "Item deleted."},
+ "app.skin-site.admin.downloads.category-added": {"message": "Category added."},
+ "app.skin-site.admin.downloads.category-deleted": {"message": "Category deleted."},
+ "app.skin-site.admin.downloads.confirm-delete-item": {"message": "Delete “{name}”? This cannot be undone."},
+ "app.skin-site.admin.downloads.confirm-delete-category": {"message": "Delete category “{name}”?"},
+ "app.skin-site.admin.servers.title": {"message": "Map servers"},
+ "app.skin-site.admin.servers.loading": {"message": "Loading…"},
+ "app.skin-site.admin.servers.failed": {"message": "Could not load servers."},
+ "app.skin-site.admin.servers.empty": {"message": "No map servers configured."},
+ "app.skin-site.admin.servers.server-name": {"message": "Server name"},
+ "app.skin-site.admin.servers.internal": {"message": "Internal"},
+ "app.skin-site.admin.servers.maps": {"message": "Maps"},
+ "app.skin-site.admin.servers.map-name": {"message": "Map name"},
+ "app.skin-site.admin.servers.map-url": {"message": "Map URL"},
+ "app.skin-site.admin.servers.add-map": {"message": "Add map"},
+ "app.skin-site.admin.servers.remove-map": {"message": "Remove"},
+ "app.skin-site.admin.servers.add-server": {"message": "Add server"},
+ "app.skin-site.admin.servers.remove-server": {"message": "Remove server"},
+ "app.skin-site.admin.servers.save": {"message": "Save changes"},
+ "app.skin-site.admin.servers.saving": {"message": "Saving…"},
+ "app.skin-site.admin.servers.saved": {"message": "Map servers saved."},
+ "app.skin-site.admin.audit.title": {"message": "Access audit"},
+ "app.skin-site.admin.audit.loading": {"message": "Loading…"},
+ "app.skin-site.admin.audit.failed": {"message": "Could not load audit log."},
+ "app.skin-site.admin.audit.empty": {"message": "No audit entries."},
+ "app.skin-site.admin.audit.subject": {"message": "Subject"},
+ "app.skin-site.admin.audit.action": {"message": "Action"},
+ "app.skin-site.admin.audit.source-ip": {"message": "Source IP"},
+ "app.skin-site.admin.audit.reported-ip": {"message": "Reported player IP"},
+ "app.skin-site.admin.audit.count": {"message": "Count"},
+ "app.skin-site.admin.audit.first-at": {"message": "First seen"},
+ "app.skin-site.admin.audit.last-at": {"message": "Last seen"},
+ "app.skin-site.admin.audit.proxy": {"message": "Proxy"},
+ "app.skin-site.admin.audit.internal": {"message": "Internal"},
+ "app.skin-site.admin.audit.search-subject": {"message": "Subject"},
+ "app.skin-site.admin.audit.search-ip": {"message": "IP"},
+ "app.skin-site.admin.audit.search-action": {"message": "Action"},
+ "app.skin-site.admin.audit.search": {"message": "Search"},
+ "app.skin-site.admin.audit.clear": {"message": "Clear"},
+ "app.skin-site.admin.audit.load-more": {"message": "Load more"},
+ "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.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"},
+ "app.skin-site.admin.mods.title": {"message": "Mods"},
+ "app.skin-site.admin.mods.tab-mods": {"message": "Mod files"},
+ "app.skin-site.admin.mods.tab-tags": {"message": "Rule tags"},
+ "app.skin-site.admin.mods.tab-runtime": {"message": "Runtime"},
+ "app.skin-site.admin.mods.loading": {"message": "Loading…"},
+ "app.skin-site.admin.mods.failed": {"message": "Could not load mods."},
+ "app.skin-site.admin.mods.empty": {"message": "No mods found."},
+ "app.skin-site.admin.mods.total": {"message": "{count} mods"},
+ "app.skin-site.admin.mods.search": {"message": "Search by name"},
+ "app.skin-site.admin.mods.filter-all": {"message": "All tags"},
+ "app.skin-site.admin.mods.prev": {"message": "Previous"},
+ "app.skin-site.admin.mods.next": {"message": "Next"},
+ "app.skin-site.admin.mods.page": {"message": "Page {page}"},
+ "app.skin-site.admin.mods.name": {"message": "Name"},
+ "app.skin-site.admin.mods.version": {"message": "Version"},
+ "app.skin-site.admin.mods.mod-id": {"message": "Mod ID"},
+ "app.skin-site.admin.mods.size": {"message": "Size"},
+ "app.skin-site.admin.mods.loaders": {"message": "Loaders"},
+ "app.skin-site.admin.mods.domain-rules": {"message": "Domain rules"},
+ "app.skin-site.admin.mods.edit": {"message": "Edit"},
+ "app.skin-site.admin.mods.save": {"message": "Save"},
+ "app.skin-site.admin.mods.cancel": {"message": "Cancel"},
+ "app.skin-site.admin.mods.remove": {"message": "Delete"},
+ "app.skin-site.admin.mods.updated": {"message": "Mod updated."},
+ "app.skin-site.admin.mods.deleted": {"message": "Mod deleted."},
+ "app.skin-site.admin.mods.confirm-delete": {"message": "Delete mod “{name}”? This cannot be undone."},
+ "app.skin-site.admin.mods.rule-tags": {"message": "Rule tags"},
+ "app.skin-site.admin.mods.tag-name": {"message": "Tag name"},
+ "app.skin-site.admin.mods.tag-rules": {"message": "Rules"},
+ "app.skin-site.admin.mods.tag-rules-hint": {"message": "One rule per line."},
+ "app.skin-site.admin.mods.new-tag": {"message": "New tag"},
+ "app.skin-site.admin.mods.tag-created": {"message": "Tag created."},
+ "app.skin-site.admin.mods.tag-updated": {"message": "Tag updated."},
+ "app.skin-site.admin.mods.tag-deleted": {"message": "Tag deleted."},
+ "app.skin-site.admin.mods.confirm-delete-tag": {"message": "Delete tag “{name}”?"},
+ "app.skin-site.admin.mods.runtime": {"message": "Runtime releases"},
+ "app.skin-site.admin.mods.manifest-url": {"message": "Manifest URL"},
+ "app.skin-site.admin.mods.upload-runtime": {"message": "Upload runtime"},
+ "app.skin-site.admin.mods.runtime-version": {"message": "Version"},
+ "app.skin-site.admin.mods.uploading": {"message": "Uploading…"},
+ "app.skin-site.admin.mods.runtime-uploaded": {"message": "Runtime uploaded."},
+ "app.skin-site.admin.mods.activate": {"message": "Activate"},
+ "app.skin-site.admin.mods.activated": {"message": "Runtime activated."},
+ "app.skin-site.admin.mods.available": {"message": "Available"},
+ "app.skin-site.admin.mods.unavailable": {"message": "Unavailable"},
+ "app.skin-site.admin.mods.active": {"message": "Active"},
+ "app.skin-site.admin.mods.uploader": {"message": "by {name}"},
+ "app.skin-site.home.nav.title": {"message": "Skin site"},
+ "app.skin-site.home.nav.download": {"message": "Downloads"},
+ "app.skin-site.home.nav.download-desc": {"message": "Browse files shared by the server."},
+ "app.skin-site.home.nav.review": {"message": "Player review"},
+ "app.skin-site.home.nav.review-desc": {"message": "Submit and review player applications."},
+ "app.skin-site.home.nav.wiki": {"message": "Server wiki"},
+ "app.skin-site.home.nav.wiki-desc": {"message": "Guides and documentation for the server."},
+ "app.skin-site.home.nav.bugs": {"message": "Bug tracker"},
+ "app.skin-site.home.nav.bugs-desc": {"message": "Report issues and follow their progress."},
+ "app.skin-site.home.nav.map": {"message": "Server map"},
+ "app.skin-site.home.nav.map-desc": {"message": "Explore the live world map."},
+ "app.skin-site.home.nav.profile": {"message": "Profile"},
+ "app.skin-site.home.nav.profile-desc": {"message": "Manage your account and textures."},
+ "app.skin-site.admin.wiki.title": {"message": "Wiki"},
+ "app.skin-site.admin.wiki.tab-page": {"message": "Page"},
+ "app.skin-site.admin.wiki.tab-audit": {"message": "Audit log"},
+ "app.skin-site.admin.wiki.tab-settings": {"message": "Settings"},
+ "app.skin-site.admin.wiki.loading": {"message": "Loading…"},
+ "app.skin-site.admin.wiki.page-path": {"message": "Page path"},
+ "app.skin-site.admin.wiki.load-page": {"message": "Load page"},
+ "app.skin-site.admin.wiki.page-not-found": {"message": "Page not found."},
+ "app.skin-site.admin.wiki.revision": {"message": "Revision"},
+ "app.skin-site.admin.wiki.author": {"message": "Author"},
+ "app.skin-site.admin.wiki.updated-at": {"message": "Updated"},
+ "app.skin-site.admin.wiki.categories": {"message": "Categories"},
+ "app.skin-site.admin.wiki.read-policy": {"message": "Read policy"},
+ "app.skin-site.admin.wiki.edit-policy": {"message": "Edit policy"},
+ "app.skin-site.admin.wiki.save-protect": {"message": "Save policies"},
+ "app.skin-site.admin.wiki.protect-saved": {"message": "Page policies updated."},
+ "app.skin-site.admin.wiki.set-home": {"message": "Set as home page"},
+ "app.skin-site.admin.wiki.home-set": {"message": "Home page updated."},
+ "app.skin-site.admin.wiki.set-deleted": {"message": "Mark as deleted"},
+ "app.skin-site.admin.wiki.set-restored": {"message": "Restore page"},
+ "app.skin-site.admin.wiki.deleted-updated": {"message": "Page deletion state updated."},
+ "app.skin-site.admin.wiki.audit-empty": {"message": "No audit entries."},
+ "app.skin-site.admin.wiki.audit-target": {"message": "Target"},
+ "app.skin-site.admin.wiki.load-more": {"message": "Load more"},
+ "app.skin-site.admin.wiki.settings-hint": {"message": "Comma separated domains."},
+ "app.skin-site.admin.wiki.domains": {"message": "Domains"},
+ "app.skin-site.admin.wiki.home-page": {"message": "Home page"},
+ "app.skin-site.admin.wiki.default-skin": {"message": "Default skin"},
+ "app.skin-site.admin.wiki.max-page-bytes": {"message": "Max page bytes"},
+ "app.skin-site.admin.wiki.max-attachment-bytes": {"message": "Max attachment bytes"},
+ "app.skin-site.admin.wiki.save-settings": {"message": "Save settings"},
+ "app.skin-site.admin.wiki.settings-saved": {"message": "Settings saved."},
+ "app.skin-site.admin.review.tab-list": {"message": "Submissions"},
+ "app.skin-site.admin.review.tab-papers": {"message": "Papers"},
+ "app.skin-site.admin.review.papers-empty": {"message": "No review papers yet."},
+ "app.skin-site.admin.review.new-paper": {"message": "New paper"},
+ "app.skin-site.admin.review.edit-paper": {"message": "Edit"},
+ "app.skin-site.admin.review.paper-title": {"message": "Title"},
+ "app.skin-site.admin.review.paper-annotation": {"message": "Description"},
+ "app.skin-site.admin.review.paper-enabled": {"message": "Enabled"},
+ "app.skin-site.admin.review.paper-mode": {"message": "Mode"},
+ "app.skin-site.admin.review.paper-pass-score": {"message": "Pass score"},
+ "app.skin-site.admin.review.paper-target-permissions": {"message": "Target permissions"},
+ "app.skin-site.admin.review.paper-questions": {"message": "Questions"},
+ "app.skin-site.admin.review.add-question": {"message": "Add question"},
+ "app.skin-site.admin.review.question-title": {"message": "Question title"},
+ "app.skin-site.admin.review.question-type": {"message": "Type"},
+ "app.skin-site.admin.review.question-score": {"message": "Score"},
+ "app.skin-site.admin.review.question-annotation": {"message": "Annotation"},
+ "app.skin-site.admin.review.add-option": {"message": "Add option"},
+ "app.skin-site.admin.review.option-description": {"message": "Option text"},
+ "app.skin-site.admin.review.option-correct": {"message": "Correct"},
+ "app.skin-site.admin.review.remove-question": {"message": "Remove"},
+ "app.skin-site.admin.review.question-no": {"message": "Question {n}"},
+ "app.skin-site.admin.review.paper-created": {"message": "Paper created."},
+ "app.skin-site.admin.review.paper-saved": {"message": "Paper saved."},
+ "app.skin-site.admin.review.mail-title": {"message": "Pass mail"},
+ "app.skin-site.admin.review.mail-enabled": {"message": "Send mail on pass"},
+ "app.skin-site.admin.review.mail-subject": {"message": "Subject"},
+ "app.skin-site.admin.review.mail-message": {"message": "Body"},
+ "app.skin-site.admin.review.mail-extra": {"message": "Extra text"},
+ "app.skin-site.admin.review.mail-include-invite": {"message": "Include invite code"},
+ "app.skin-site.admin.review.prevent-duplicate": {"message": "Reject duplicate submissions"},
+ "app.skin-site.admin.review.copy-link": {"message": "Copy link"},
+ "app.skin-site.admin.review.link-copied": {"message": "Link copied."},
+ "app.skin-site.admin.review.move-up": {"message": "Move up"},
+ "app.skin-site.admin.review.move-down": {"message": "Move down"},
+ "app.skin-site.admin.review.mail-subject-required": {"message": "Subject is required when mail is enabled."},
+ "app.skin-site.admin.review.mail-message-required": {"message": "Body is required when mail is enabled."},
+ "app.skin-site.admin.users.actions": {"message": "Actions"},
+ "app.skin-site.admin.users.audit": {"message": "Audit"},
+ "app.skin-site.admin.users.audit-empty": {"message": "No audit records yet."},
+ "app.skin-site.admin.users.audit-more": {"message": "Load earlier"},
+ "app.skin-site.admin.users.audit-title": {"message": "User management audit"},
+ "app.skin-site.admin.users.bot": {"message": "Bot"},
+ "app.skin-site.admin.users.bot-active": {"message": "Token valid"},
+ "app.skin-site.admin.users.bot-ban": {"message": "Ban bot creation"},
+ "app.skin-site.admin.users.bot-creation-banned": {"message": "Token creation banned"},
+ "app.skin-site.admin.users.bot-exists": {"message": "Bot created"},
+ "app.skin-site.admin.users.bot-inactive": {"message": "Token invalidated"},
+ "app.skin-site.admin.users.bot-invalidate": {"message": "Invalidate token"},
+ "app.skin-site.admin.users.bot-not-exists": {"message": "No bot yet"},
+ "app.skin-site.admin.users.bot-save-username": {"message": "Save bot username"},
+ "app.skin-site.admin.users.bot-title": {"message": "Bot management"},
+ "app.skin-site.admin.users.bot-token-hint": {"message": "Token hint"},
+ "app.skin-site.admin.users.bot-unban": {"message": "Unban bot creation"},
+ "app.skin-site.admin.users.bot-username": {"message": "Bot username"},
+ "app.skin-site.admin.users.bound-players-title": {"message": "Linked players"},
+ "app.skin-site.admin.users.frequency": {"message": "Rate limit"},
+ "app.skin-site.admin.users.frequency-clear": {"message": "Clear limit"},
+ "app.skin-site.admin.users.frequency-current": {"message": "Currently {percent}%"},
+ "app.skin-site.admin.users.frequency-hint": {"message": "Packets from this user will be dropped at the given rate."},
+ "app.skin-site.admin.users.frequency-none": {"message": "Not enabled"},
+ "app.skin-site.admin.users.frequency-range": {"message": "Only integer values from 10% to 100% are allowed."},
+ "app.skin-site.admin.users.frequency-save": {"message": "Save limit"},
+ "app.skin-site.admin.users.frequency-title": {"message": "Rate limit"},
+ "app.skin-site.admin.users.invite": {"message": "Invite user"},
+ "app.skin-site.admin.users.invite-copied": {"message": "Invite code copied."},
+ "app.skin-site.admin.users.invite-copy": {"message": "Copy"},
+ "app.skin-site.admin.users.invite-email": {"message": "Email"},
+ "app.skin-site.admin.users.invite-email-invalid": {"message": "Enter a valid email address."},
+ "app.skin-site.admin.users.invite-generate": {"message": "Generate invite code"},
+ "app.skin-site.admin.users.invite-title": {"message": "Invite user"},
+ "app.skin-site.admin.users.legacy-adapted": {"message": "Legacy internal adapted"},
+ "app.skin-site.admin.users.registered-at": {"message": "Registered at"},
+ "app.skin-site.admin.users.status": {"message": "Status"},
+ "app.skin-site.admin.users.status-banned": {"message": "Banned"},
+ "app.skin-site.admin.users.status-normal": {"message": "Normal"},
+ "app.skin-site.admin.mods.upload": {"message": "Upload"},
+ "app.skin-site.admin.mods.select-mode": {"message": "Select multiple"},
+ "app.skin-site.admin.mods.exit-select": {"message": "Done selecting"},
+ "app.skin-site.admin.mods.select-all": {"message": "Select all"},
+ "app.skin-site.admin.mods.clear-selection": {"message": "Clear selection"},
+ "app.skin-site.admin.mods.selected-count": {"message": "{count} selected"},
+ "app.skin-site.admin.mods.delete-selected": {"message": "Delete selected"},
+ "app.skin-site.admin.mods.confirm-delete-multi": {"message": "Delete {count} selected mods? This cannot be undone."},
+ "app.skin-site.admin.mods.deleted-multi": {"message": "Selected mods deleted."},
+ "app.skin-site.admin.mods.server-address": {"message": "Server address"},
+ "app.skin-site.admin.mods.server-address-hint": {"message": "Filter by server address"},
+ "app.skin-site.admin.mods.apply": {"message": "Apply"},
+ "app.skin-site.admin.mods.clear": {"message": "Clear"},
+ "app.skin-site.admin.mods.upload-title": {"message": "Upload mods"},
+ "app.skin-site.admin.mods.upload-drop": {"message": "Drag .jar files here, or click to choose"},
+ "app.skin-site.admin.mods.upload-sub": {"message": "Multiple files supported, up to {size} MB each"},
+ "app.skin-site.admin.mods.upload-tags": {"message": "Attach rule tags"},
+ "app.skin-site.admin.mods.upload-queue": {"message": "Files"},
+ "app.skin-site.admin.mods.upload-pending": {"message": "Pending"},
+ "app.skin-site.admin.mods.upload-checking": {"message": "Checking…"},
+ "app.skin-site.admin.mods.upload-uploading": {"message": "Uploading…"},
+ "app.skin-site.admin.mods.upload-conflict": {"message": "Name exists"},
+ "app.skin-site.admin.mods.upload-success": {"message": "Done"},
+ "app.skin-site.admin.mods.upload-error": {"message": "Failed"},
+ "app.skin-site.admin.mods.upload-exists": {"message": "This file already exists on the server."},
+ "app.skin-site.admin.mods.upload-name-conflict": {"message": "A mod with the same file name already exists"},
+ "app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "Overwrite the existing mod, or skip this file."},
+ "app.skin-site.admin.mods.upload-overwrite": {"message": "Overwrite"},
+ "app.skin-site.admin.mods.upload-skip": {"message": "Skip"},
+ "app.skin-site.admin.mods.upload-close": {"message": "Close"},
+ "app.skin-site.admin.mods.upload-all-done": {"message": "All files processed."},
+ "app.skin-site.admin.mods.upload-too-large": {"message": "File too large: {name}"},
+ "app.skin-site.chat.avatar.label": {"message": "Room avatar"},
+ "app.skin-site.chat.avatar.upload": {"message": "Upload avatar"},
+ "app.skin-site.chat.avatar.replace": {"message": "Replace avatar"},
+ "app.skin-site.chat.avatar.remove": {"message": "Remove avatar"},
+ "app.skin-site.chat.avatar.hint": {"message": "PNG, JPEG or WebP, up to 8192 px per side and 5 MB."},
+ "app.skin-site.chat.avatar.bad-format": {"message": "Only PNG, JPEG and WebP are supported."},
+ "app.skin-site.chat.avatar.too-large": {"message": "Avatar must not exceed 5 MB."},
+ "app.skin-site.chat.avatar.not-image": {"message": "Avatar must be an image."},
+ "app.skin-site.chat.admin.title": {"message": "Chat rooms"},
+ "app.skin-site.chat.admin.subtitle": {"message": "Create public or private rooms and hand daily moderation to trusted members."},
+ "app.skin-site.chat.admin.create-room": {"message": "Create room"},
+ "app.skin-site.chat.admin.loading": {"message": "Loading…"},
+ "app.skin-site.chat.admin.failed": {"message": "Could not load rooms."},
+ "app.skin-site.chat.admin.empty": {"message": "No chat rooms yet."},
+ "app.skin-site.chat.admin.edit": {"message": "Edit"},
+ "app.skin-site.chat.admin.remove": {"message": "Delete"},
+ "app.skin-site.chat.admin.save": {"message": "Save"},
+ "app.skin-site.chat.admin.cancel": {"message": "Cancel"},
+ "app.skin-site.chat.admin.name": {"message": "Name"},
+ "app.skin-site.chat.admin.description": {"message": "Description"},
+ "app.skin-site.chat.admin.visibility": {"message": "Visibility"},
+ "app.skin-site.chat.admin.visibility-public": {"message": "Public"},
+ "app.skin-site.chat.admin.visibility-private": {"message": "Private"},
+ "app.skin-site.chat.admin.kind": {"message": "Kind"},
+ "app.skin-site.chat.admin.kind-standard": {"message": "Standard"},
+ "app.skin-site.chat.admin.kind-settlement": {"message": "Settlement"},
+ "app.skin-site.chat.admin.prerequisite-mode": {"message": "Prerequisite mode"},
+ "app.skin-site.chat.admin.prerequisite-any": {"message": "Any"},
+ "app.skin-site.chat.admin.prerequisite-all": {"message": "All"},
+ "app.skin-site.chat.admin.prerequisite-permissions": {"message": "Required permissions"},
+ "app.skin-site.chat.admin.prerequisite-hint": {"message": "Comma separated. Leave empty for no restriction."},
+ "app.skin-site.chat.admin.created": {"message": "Room created."},
+ "app.skin-site.chat.admin.updated": {"message": "Room updated."},
+ "app.skin-site.chat.admin.deleted": {"message": "Room deleted."},
+ "app.skin-site.chat.admin.confirm-delete": {"message": "Permanently delete room “{name}” and all its messages?"},
+ "app.skin-site.chat.admin.manager-badge": {"message": "Manager"},
+ "app.skin-site.chat.admin.voice-badge": {"message": "Voice"},
+ "app.skin-site.chat.user-picker.label": {"message": "Pick a user"},
+ "app.skin-site.chat.user-picker.placeholder": {"message": "Search username, email or account ID"},
+ "app.skin-site.chat.user-picker.by-user": {"message": "By user"},
+ "app.skin-site.chat.user-picker.by-player": {"message": "By player"},
+ "app.skin-site.chat.user-picker.hint": {"message": "Fuzzy search on username and email, or search by account ID."},
+ "app.skin-site.chat.user-picker.player-hint": {"message": "Enter a player name to find the bound user."},
+ "app.skin-site.chat.user-picker.empty": {"message": "No matching user."},
+ "app.skin-site.chat.user-picker.banned": {"message": "This account is banned."},
+ "app.skin-site.chat.user-picker.guest": {"message": "Guests cannot become a manager or settlement owner."},
+ "app.skin-site.chat.user-picker.already-in": {"message": "This user is already in the room."},
+ "app.skin-site.chat.user-picker.not-allowed": {"message": "This user is outside the allowed range."},
+ "app.skin-site.chat.user-picker.select": {"message": "Select"},
+ "app.skin-site.chat.user-picker.selected": {"message": "Selected"},
+ "app.skin-site.chat.user-picker.id": {"message": "ID"},
+ "app.skin-site.chat.admin.managers": {"message": "Managers"},
+ "app.skin-site.chat.admin.manager-title": {"message": "Room manager"},
+ "app.skin-site.chat.admin.manager-hint": {"message": "Managers can moderate members, messages and files in this room."},
+ "app.skin-site.chat.admin.grant": {"message": "Grant"},
+ "app.skin-site.chat.admin.revoke": {"message": "Revoke"},
+ "app.skin-site.chat.admin.manager-granted": {"message": "Manager granted."},
+ "app.skin-site.chat.admin.manager-revoked": {"message": "Manager revoked."},
+ "app.skin-site.chat.admin.owner-title": {"message": "Settlement owner"},
+ "app.skin-site.chat.admin.owner-hint": {"message": "Assign the first settlement owner for this room."},
+ "app.skin-site.chat.admin.owner-assigned": {"message": "Settlement owner assigned."},
+ "app.skin-site.chat.admin.owner-current": {"message": "Current owner: {name}"},
+ "app.skin-site.chat.admin.owner-none": {"message": "No settlement owner yet."},
+ "app.skin-site.chat.admin.audit": {"message": "Audit"},
+ "app.skin-site.chat.admin.audit-title": {"message": "Room audit"},
+ "app.skin-site.chat.admin.audit-empty": {"message": "No audit records yet."},
+ "app.skin-site.chat.admin.audit-target": {"message": "Target: {name}"},
+ "app.skin-site.chat.admin.close": {"message": "Close"},
+ "app.skin-site.chat.voice-runtime-title": {"message": "Realtime voice runtime"},
+ "app.skin-site.chat.voice-logs": {"message": "View logs"},
+ "app.skin-site.chat.voice-config": {"message": "Configure runtime"},
+ "app.skin-site.chat.voice-retry": {"message": "Retry deployment"},
+ "app.skin-site.chat.voice-config-title": {"message": "Voice runtime configuration"},
+ "app.skin-site.chat.voice-logs-title": {"message": "LiveKit logs"},
+ "app.skin-site.chat.voice-capacity-title": {"message": "Voice capacity"},
+ "app.skin-site.chat.voice-capacity": {"message": "Voice capacity"},
+ "app.skin-site.chat.participant-limit": {"message": "Participant limit"},
+ "app.skin-site.chat.listener-limit": {"message": "Listener limit"},
+ "app.skin-site.chat.voice-capacity-updated": {"message": "Voice capacity updated."},
+ "app.skin-site.chat.voice-enabled": {"message": "Enable realtime voice"},
+ "app.skin-site.chat.voice-runtime-path": {"message": "Runtime directory"},
+ "app.skin-site.chat.voice-mirror": {"message": "Mirror base URL"},
+ "app.skin-site.chat.voice-public-url": {"message": "Public URL"},
+ "app.skin-site.chat.voice-bind": {"message": "Bind address"},
+ "app.skin-site.chat.voice-signal-port": {"message": "Signal port"},
+ "app.skin-site.chat.voice-rtc-tcp-port": {"message": "Media TCP port"},
+ "app.skin-site.chat.voice-rtc-udp-port": {"message": "Media UDP port"},
+ "app.skin-site.chat.voice-startup-timeout": {"message": "Startup timeout (s)"},
+ "app.skin-site.chat.voice-use-external-ip": {"message": "Use external IP"},
+ "app.skin-site.chat.voice-node-ip": {"message": "Node IP"},
+ "app.skin-site.chat.voice-saved": {"message": "Voice configuration saved."},
+ "app.skin-site.chat.voice-logs-auto-refresh": {"message": "Auto refresh"},
+ "app.skin-site.chat.voice-logs-refresh": {"message": "Refresh"},
+ "app.skin-site.chat.voice-logs-empty": {"message": "No logs yet."},
+ "app.skin-site.chat.admin.voice-runtime-title": {"message": "Realtime voice runtime"},
+ "app.skin-site.chat.admin.voice-logs": {"message": "View logs"},
+ "app.skin-site.chat.admin.voice-config": {"message": "Configure runtime"},
+ "app.skin-site.chat.admin.voice-retry": {"message": "Retry deployment"},
+ "app.skin-site.chat.admin.voice-config-title": {"message": "Voice runtime configuration"},
+ "app.skin-site.chat.admin.voice-logs-title": {"message": "LiveKit logs"},
+ "app.skin-site.chat.admin.voice-capacity-title": {"message": "Voice capacity"},
+ "app.skin-site.chat.admin.voice-capacity": {"message": "Voice capacity"},
+ "app.skin-site.chat.admin.participant-limit": {"message": "Participant limit"},
+ "app.skin-site.chat.admin.listener-limit": {"message": "Listener limit"},
+ "app.skin-site.chat.admin.voice-capacity-updated": {"message": "Voice capacity updated."},
+ "app.skin-site.chat.admin.voice-enabled": {"message": "Enable realtime voice"},
+ "app.skin-site.chat.admin.voice-runtime-path": {"message": "Runtime directory"},
+ "app.skin-site.chat.admin.voice-mirror": {"message": "Mirror base URL"},
+ "app.skin-site.chat.admin.voice-public-url": {"message": "Public URL"},
+ "app.skin-site.chat.admin.voice-bind": {"message": "Bind address"},
+ "app.skin-site.chat.admin.voice-signal-port": {"message": "Signal port"},
+ "app.skin-site.chat.admin.voice-rtc-tcp-port": {"message": "Media TCP port"},
+ "app.skin-site.chat.admin.voice-rtc-udp-port": {"message": "Media UDP port"},
+ "app.skin-site.chat.admin.voice-startup-timeout": {"message": "Startup timeout (s)"},
+ "app.skin-site.chat.admin.voice-use-external-ip": {"message": "Use external IP"},
+ "app.skin-site.chat.admin.voice-node-ip": {"message": "Node IP"},
+ "app.skin-site.chat.admin.voice-saved": {"message": "Voice configuration saved."},
+ "app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "Auto refresh"},
+ "app.skin-site.chat.admin.voice-logs-refresh": {"message": "Refresh"},
+ "app.skin-site.chat.admin.voice-logs-empty": {"message": "No logs yet."},
+ "app.skin-site.admin.wiki.tab-migration": {"message": "Migration"},
+ "app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki migration"},
+ "app.skin-site.admin.wiki.migration-hint": {"message": "Migrated content lands in a hidden staging area and is published in one batch after every page is reviewed."},
+ "app.skin-site.admin.wiki.auto-title": {"message": "Read from MediaWiki API"},
+ "app.skin-site.admin.wiki.api-url": {"message": "api.php URL"},
+ "app.skin-site.admin.wiki.bot-username": {"message": "Bot username (optional)"},
+ "app.skin-site.admin.wiki.bot-password": {"message": "Bot password (optional)"},
+ "app.skin-site.admin.wiki.credentials-hint": {"message": "Credentials stay in memory for this job only. HTTPS is required when supplied."},
+ "app.skin-site.admin.wiki.allow-private": {"message": "Allow private network source"},
+ "app.skin-site.admin.wiki.allow-private-hint": {"message": "Private sources may reach services inside the server network."},
+ "app.skin-site.admin.wiki.start-auto": {"message": "Create automatic migration job"},
+ "app.skin-site.admin.wiki.xml-title": {"message": "Import MediaWiki XML"},
+ "app.skin-site.admin.wiki.xml-file": {"message": "Export file"},
+ "app.skin-site.admin.wiki.start-xml": {"message": "Upload and stage"},
+ "app.skin-site.admin.wiki.stale-rules": {"message": "Stale page detection rules"},
+ "app.skin-site.admin.wiki.stale-days": {"message": "Days without update"},
+ "app.skin-site.admin.wiki.maintainer-window": {"message": "Maintainer window (days)"},
+ "app.skin-site.admin.wiki.max-maintainers": {"message": "Max maintainers"},
+ "app.skin-site.admin.wiki.stale-markers": {"message": "Stale categories, templates or text"},
+ "app.skin-site.admin.wiki.stale-markers-hint": {"message": "Comma separated"},
+ "app.skin-site.admin.wiki.jobs": {"message": "Migration jobs"},
+ "app.skin-site.admin.wiki.refresh-jobs": {"message": "Refresh"},
+ "app.skin-site.admin.wiki.jobs-empty": {"message": "No migration jobs yet."},
+ "app.skin-site.admin.wiki.reviewed-count": {"message": "Reviewed {reviewed} / {total}"},
+ "app.skin-site.admin.wiki.imported-excluded": {"message": "Imported {imported} · Excluded {excluded}"},
+ "app.skin-site.admin.wiki.view-job": {"message": "View job"},
+ "app.skin-site.admin.wiki.delete-job": {"message": "Delete job record"},
+ "app.skin-site.admin.wiki.confirm-delete-job": {"message": "Delete migration job #{id}? This cannot be undone."},
+ "app.skin-site.admin.wiki.candidates": {"message": "Candidate pages"},
+ "app.skin-site.admin.wiki.candidates-empty": {"message": "Select a job to review its pages."},
+ "app.skin-site.admin.wiki.last-updated": {"message": "Updated {date}"},
+ "app.skin-site.admin.wiki.maintainer-count": {"message": "{count} maintainers"},
+ "app.skin-site.admin.wiki.conflict": {"message": "Title conflict"},
+ "app.skin-site.admin.wiki.target-title": {"message": "Target title"},
+ "app.skin-site.admin.wiki.overwrite": {"message": "Overwrite"},
+ "app.skin-site.admin.wiki.import": {"message": "Import"},
+ "app.skin-site.admin.wiki.exclude": {"message": "Exclude"},
+ "app.skin-site.admin.wiki.publish": {"message": "Publish batch"},
+ "app.skin-site.admin.wiki.rollback": {"message": "Roll back"},
+ "app.skin-site.admin.wiki.confirm-rollback": {"message": "Roll back this migration batch? Published pages will be reverted."},
+ "app.skin-site.admin.wiki.state-importing": {"message": "Importing"},
+ "app.skin-site.admin.wiki.state-review": {"message": "Waiting review"},
+ "app.skin-site.admin.wiki.state-publishing": {"message": "Publishing"},
+ "app.skin-site.admin.wiki.state-published": {"message": "Published"},
+ "app.skin-site.admin.wiki.state-failed": {"message": "Failed"},
+ "app.skin-site.admin.wiki.state-rolled-back": {"message": "Rolled back"},
+ "app.skin-site.admin.wiki.decision-pending": {"message": "Pending"},
+ "app.skin-site.admin.wiki.decision-import": {"message": "Import"},
+ "app.skin-site.admin.wiki.decision-exclude": {"message": "Exclude"},
+ "app.skin-site.admin.wiki.migration-started": {"message": "Migration job created."},
+ "app.skin-site.admin.wiki.xml-staged": {"message": "XML parsed and staged for review."},
+ "app.skin-site.admin.wiki.published": {"message": "Published {imported} pages, overwrote {overwritten}."},
+ "app.skin-site.admin.wiki.rolled-back": {"message": "Migration batch rolled back."},
+ "app.skin-site.admin.wiki.job-deleted": {"message": "Migration record deleted."},
+ "app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."},
+ "app.skin-site.wiki.title": {"message": "StarLight Wiki"},
+ "app.skin-site.wiki.search-placeholder": {"message": "Enter a page title"},
+ "app.skin-site.wiki.open": {"message": "Open"},
+ "app.skin-site.wiki.loading": {"message": "Loading…"},
+ "app.skin-site.wiki.not-found": {"message": "Page not found."},
+ "app.skin-site.wiki.empty": {"message": "Enter a page title above to start reading."},
+ "app.skin-site.wiki.updated-at": {"message": "Last edited {date} by {author}"},
+ "app.skin-site.wiki.revision": {"message": "Revision #{id}"},
+ "app.skin-site.wiki.categories": {"message": "Categories"},
+ "app.skin-site.wiki.back": {"message": "Back"}
}
diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json
index 7026430..0e33964 100644
--- a/apps/app-frontend/src/locales/zh-CN/index.json
+++ b/apps/app-frontend/src/locales/zh-CN/index.json
@@ -11783,5 +11783,487 @@
"app.skin-site.admin.launcher.prev": {"message": "上一页"},
"app.skin-site.admin.launcher.next": {"message": "下一页"},
"app.skin-site.admin.launcher.page": {"message": "第 {page} 页"},
- "app.skin-site.admin.launcher.total": {"message": "共 {count} 个版本"}
+ "app.skin-site.admin.launcher.total": {"message": "共 {count} 个版本"},
+ "app.skin-site.admin.downloads.title": {"message": "资源下载"},
+ "app.skin-site.admin.downloads.loading": {"message": "加载中…"},
+ "app.skin-site.admin.downloads.failed": {"message": "无法加载资源列表。"},
+ "app.skin-site.admin.downloads.empty": {"message": "没有下载条目。"},
+ "app.skin-site.admin.downloads.categories": {"message": "分类"},
+ "app.skin-site.admin.downloads.all": {"message": "全部条目"},
+ "app.skin-site.admin.downloads.add-category": {"message": "添加"},
+ "app.skin-site.admin.downloads.category-name": {"message": "分类名称"},
+ "app.skin-site.admin.downloads.remove-category": {"message": "移除"},
+ "app.skin-site.admin.downloads.total": {"message": "共 {count} 个条目"},
+ "app.skin-site.admin.downloads.add-item": {"message": "新建条目"},
+ "app.skin-site.admin.downloads.save": {"message": "保存"},
+ "app.skin-site.admin.downloads.cancel": {"message": "取消"},
+ "app.skin-site.admin.downloads.edit": {"message": "编辑"},
+ "app.skin-site.admin.downloads.remove": {"message": "删除"},
+ "app.skin-site.admin.downloads.filename": {"message": "文件名"},
+ "app.skin-site.admin.downloads.description": {"message": "描述"},
+ "app.skin-site.admin.downloads.routes": {"message": "下载线路"},
+ "app.skin-site.admin.downloads.route-name": {"message": "名称"},
+ "app.skin-site.admin.downloads.route-url": {"message": "URL"},
+ "app.skin-site.admin.downloads.add-route": {"message": "添加线路"},
+ "app.skin-site.admin.downloads.remove-route": {"message": "移除"},
+ "app.skin-site.admin.downloads.statuses": {"message": "状态"},
+ "app.skin-site.admin.downloads.bind-category": {"message": "所属分类"},
+ "app.skin-site.admin.downloads.uploader": {"message": "由 {name} 上传"},
+ "app.skin-site.admin.downloads.created": {"message": "条目已创建。"},
+ "app.skin-site.admin.downloads.updated": {"message": "条目已更新。"},
+ "app.skin-site.admin.downloads.deleted": {"message": "条目已删除。"},
+ "app.skin-site.admin.downloads.category-added": {"message": "分类已添加。"},
+ "app.skin-site.admin.downloads.category-deleted": {"message": "分类已删除。"},
+ "app.skin-site.admin.downloads.confirm-delete-item": {"message": "确定删除「{name}」吗?此操作不可撤销。"},
+ "app.skin-site.admin.downloads.confirm-delete-category": {"message": "确定删除分类「{name}」吗?"},
+ "app.skin-site.admin.servers.title": {"message": "地图服务器"},
+ "app.skin-site.admin.servers.loading": {"message": "加载中…"},
+ "app.skin-site.admin.servers.failed": {"message": "无法加载服务器列表。"},
+ "app.skin-site.admin.servers.empty": {"message": "尚未配置地图服务器。"},
+ "app.skin-site.admin.servers.server-name": {"message": "服务器名称"},
+ "app.skin-site.admin.servers.internal": {"message": "内部"},
+ "app.skin-site.admin.servers.maps": {"message": "地图"},
+ "app.skin-site.admin.servers.map-name": {"message": "地图名称"},
+ "app.skin-site.admin.servers.map-url": {"message": "地图地址"},
+ "app.skin-site.admin.servers.add-map": {"message": "添加地图"},
+ "app.skin-site.admin.servers.remove-map": {"message": "移除"},
+ "app.skin-site.admin.servers.add-server": {"message": "添加服务器"},
+ "app.skin-site.admin.servers.remove-server": {"message": "删除服务器"},
+ "app.skin-site.admin.servers.save": {"message": "保存更改"},
+ "app.skin-site.admin.servers.saving": {"message": "保存中…"},
+ "app.skin-site.admin.servers.saved": {"message": "地图服务器已保存。"},
+ "app.skin-site.admin.audit.title": {"message": "登录与认证审计"},
+ "app.skin-site.admin.audit.loading": {"message": "加载中…"},
+ "app.skin-site.admin.audit.failed": {"message": "无法加载审计日志。"},
+ "app.skin-site.admin.audit.empty": {"message": "没有审计记录。"},
+ "app.skin-site.admin.audit.subject": {"message": "主体"},
+ "app.skin-site.admin.audit.action": {"message": "操作"},
+ "app.skin-site.admin.audit.source-ip": {"message": "来源 IP"},
+ "app.skin-site.admin.audit.reported-ip": {"message": "上报玩家 IP"},
+ "app.skin-site.admin.audit.count": {"message": "次数"},
+ "app.skin-site.admin.audit.first-at": {"message": "首次出现"},
+ "app.skin-site.admin.audit.last-at": {"message": "最近出现"},
+ "app.skin-site.admin.audit.proxy": {"message": "代理"},
+ "app.skin-site.admin.audit.internal": {"message": "内部"},
+ "app.skin-site.admin.audit.search-subject": {"message": "主体"},
+ "app.skin-site.admin.audit.search-ip": {"message": "IP"},
+ "app.skin-site.admin.audit.search-action": {"message": "操作"},
+ "app.skin-site.admin.audit.search": {"message": "搜索"},
+ "app.skin-site.admin.audit.clear": {"message": "清空"},
+ "app.skin-site.admin.audit.load-more": {"message": "加载更多"},
+ "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.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": "加载更多"},
+ "app.skin-site.admin.mods.title": {"message": "Mod 管理"},
+ "app.skin-site.admin.mods.tab-mods": {"message": "Mod 文件"},
+ "app.skin-site.admin.mods.tab-tags": {"message": "规则标签"},
+ "app.skin-site.admin.mods.tab-runtime": {"message": "运行时"},
+ "app.skin-site.admin.mods.loading": {"message": "加载中…"},
+ "app.skin-site.admin.mods.failed": {"message": "无法加载 Mod 列表。"},
+ "app.skin-site.admin.mods.empty": {"message": "没有找到 Mod。"},
+ "app.skin-site.admin.mods.total": {"message": "共 {count} 个 Mod"},
+ "app.skin-site.admin.mods.search": {"message": "按名称搜索"},
+ "app.skin-site.admin.mods.filter-all": {"message": "全部标签"},
+ "app.skin-site.admin.mods.prev": {"message": "上一页"},
+ "app.skin-site.admin.mods.next": {"message": "下一页"},
+ "app.skin-site.admin.mods.page": {"message": "第 {page} 页"},
+ "app.skin-site.admin.mods.name": {"message": "名称"},
+ "app.skin-site.admin.mods.version": {"message": "版本"},
+ "app.skin-site.admin.mods.mod-id": {"message": "Mod ID"},
+ "app.skin-site.admin.mods.size": {"message": "大小"},
+ "app.skin-site.admin.mods.loaders": {"message": "加载器"},
+ "app.skin-site.admin.mods.domain-rules": {"message": "域名规则"},
+ "app.skin-site.admin.mods.edit": {"message": "编辑"},
+ "app.skin-site.admin.mods.save": {"message": "保存"},
+ "app.skin-site.admin.mods.cancel": {"message": "取消"},
+ "app.skin-site.admin.mods.remove": {"message": "删除"},
+ "app.skin-site.admin.mods.updated": {"message": "Mod 已更新。"},
+ "app.skin-site.admin.mods.deleted": {"message": "Mod 已删除。"},
+ "app.skin-site.admin.mods.confirm-delete": {"message": "确定删除 Mod「{name}」吗?此操作不可撤销。"},
+ "app.skin-site.admin.mods.rule-tags": {"message": "规则标签"},
+ "app.skin-site.admin.mods.tag-name": {"message": "标签名称"},
+ "app.skin-site.admin.mods.tag-rules": {"message": "规则"},
+ "app.skin-site.admin.mods.tag-rules-hint": {"message": "每行一条规则。"},
+ "app.skin-site.admin.mods.new-tag": {"message": "新建标签"},
+ "app.skin-site.admin.mods.tag-created": {"message": "标签已创建。"},
+ "app.skin-site.admin.mods.tag-updated": {"message": "标签已更新。"},
+ "app.skin-site.admin.mods.tag-deleted": {"message": "标签已删除。"},
+ "app.skin-site.admin.mods.confirm-delete-tag": {"message": "确定删除标签「{name}」吗?"},
+ "app.skin-site.admin.mods.runtime": {"message": "运行时版本"},
+ "app.skin-site.admin.mods.manifest-url": {"message": "清单地址"},
+ "app.skin-site.admin.mods.upload-runtime": {"message": "上传运行时"},
+ "app.skin-site.admin.mods.runtime-version": {"message": "版本"},
+ "app.skin-site.admin.mods.uploading": {"message": "上传中…"},
+ "app.skin-site.admin.mods.runtime-uploaded": {"message": "运行时已上传。"},
+ "app.skin-site.admin.mods.activate": {"message": "设为当前"},
+ "app.skin-site.admin.mods.activated": {"message": "运行时已启用。"},
+ "app.skin-site.admin.mods.available": {"message": "可用"},
+ "app.skin-site.admin.mods.unavailable": {"message": "不可用"},
+ "app.skin-site.admin.mods.active": {"message": "当前版本"},
+ "app.skin-site.admin.mods.uploader": {"message": "由 {name} 上传"},
+ "app.skin-site.home.nav.title": {"message": "皮肤站"},
+ "app.skin-site.home.nav.download": {"message": "资源下载"},
+ "app.skin-site.home.nav.download-desc": {"message": "浏览服务器分享的文件。"},
+ "app.skin-site.home.nav.review": {"message": "玩家审核"},
+ "app.skin-site.home.nav.review-desc": {"message": "提交并审核玩家申请。"},
+ "app.skin-site.home.nav.wiki": {"message": "服务器维基"},
+ "app.skin-site.home.nav.wiki-desc": {"message": "服务器的指南与文档。"},
+ "app.skin-site.home.nav.bugs": {"message": "问题追踪"},
+ "app.skin-site.home.nav.bugs-desc": {"message": "反馈问题并跟踪处理进度。"},
+ "app.skin-site.home.nav.map": {"message": "服务器地图"},
+ "app.skin-site.home.nav.map-desc": {"message": "探索实时世界地图。"},
+ "app.skin-site.home.nav.profile": {"message": "个人资料"},
+ "app.skin-site.home.nav.profile-desc": {"message": "管理你的账号与皮肤。"},
+ "app.skin-site.admin.wiki.title": {"message": "维基管理"},
+ "app.skin-site.admin.wiki.tab-page": {"message": "页面"},
+ "app.skin-site.admin.wiki.tab-audit": {"message": "审计日志"},
+ "app.skin-site.admin.wiki.tab-settings": {"message": "设置"},
+ "app.skin-site.admin.wiki.loading": {"message": "加载中…"},
+ "app.skin-site.admin.wiki.page-path": {"message": "页面路径"},
+ "app.skin-site.admin.wiki.load-page": {"message": "加载页面"},
+ "app.skin-site.admin.wiki.page-not-found": {"message": "未找到该页面。"},
+ "app.skin-site.admin.wiki.revision": {"message": "版本"},
+ "app.skin-site.admin.wiki.author": {"message": "作者"},
+ "app.skin-site.admin.wiki.updated-at": {"message": "更新时间"},
+ "app.skin-site.admin.wiki.categories": {"message": "分类"},
+ "app.skin-site.admin.wiki.read-policy": {"message": "阅读权限"},
+ "app.skin-site.admin.wiki.edit-policy": {"message": "编辑权限"},
+ "app.skin-site.admin.wiki.save-protect": {"message": "保存权限"},
+ "app.skin-site.admin.wiki.protect-saved": {"message": "页面权限已更新。"},
+ "app.skin-site.admin.wiki.set-home": {"message": "设为首页"},
+ "app.skin-site.admin.wiki.home-set": {"message": "首页已更新。"},
+ "app.skin-site.admin.wiki.set-deleted": {"message": "标记为已删除"},
+ "app.skin-site.admin.wiki.set-restored": {"message": "恢复页面"},
+ "app.skin-site.admin.wiki.deleted-updated": {"message": "页面删除状态已更新。"},
+ "app.skin-site.admin.wiki.audit-empty": {"message": "没有审计记录。"},
+ "app.skin-site.admin.wiki.audit-target": {"message": "目标"},
+ "app.skin-site.admin.wiki.load-more": {"message": "加载更多"},
+ "app.skin-site.admin.wiki.settings-hint": {"message": "以逗号分隔域名。"},
+ "app.skin-site.admin.wiki.domains": {"message": "域名"},
+ "app.skin-site.admin.wiki.home-page": {"message": "首页"},
+ "app.skin-site.admin.wiki.default-skin": {"message": "默认皮肤"},
+ "app.skin-site.admin.wiki.max-page-bytes": {"message": "页面最大字节数"},
+ "app.skin-site.admin.wiki.max-attachment-bytes": {"message": "附件最大字节数"},
+ "app.skin-site.admin.wiki.save-settings": {"message": "保存设置"},
+ "app.skin-site.admin.wiki.settings-saved": {"message": "设置已保存。"},
+ "app.skin-site.admin.review.tab-list": {"message": "申请列表"},
+ "app.skin-site.admin.review.tab-papers": {"message": "试卷管理"},
+ "app.skin-site.admin.review.papers-empty": {"message": "还没有审核试卷。"},
+ "app.skin-site.admin.review.new-paper": {"message": "新建试卷"},
+ "app.skin-site.admin.review.edit-paper": {"message": "编辑"},
+ "app.skin-site.admin.review.paper-title": {"message": "试卷标题"},
+ "app.skin-site.admin.review.paper-annotation": {"message": "描述"},
+ "app.skin-site.admin.review.paper-enabled": {"message": "启用"},
+ "app.skin-site.admin.review.paper-mode": {"message": "阅卷模式"},
+ "app.skin-site.admin.review.paper-pass-score": {"message": "通过分数"},
+ "app.skin-site.admin.review.paper-target-permissions": {"message": "通过后获得的身份"},
+ "app.skin-site.admin.review.paper-questions": {"message": "题目"},
+ "app.skin-site.admin.review.add-question": {"message": "添加题目"},
+ "app.skin-site.admin.review.question-title": {"message": "题目"},
+ "app.skin-site.admin.review.question-type": {"message": "题型"},
+ "app.skin-site.admin.review.question-score": {"message": "分数"},
+ "app.skin-site.admin.review.question-annotation": {"message": "题目注解"},
+ "app.skin-site.admin.review.add-option": {"message": "添加选项"},
+ "app.skin-site.admin.review.option-description": {"message": "选项文本"},
+ "app.skin-site.admin.review.option-correct": {"message": "正确"},
+ "app.skin-site.admin.review.remove-question": {"message": "删除"},
+ "app.skin-site.admin.review.question-no": {"message": "第 {n} 题"},
+ "app.skin-site.admin.review.paper-created": {"message": "试卷已创建。"},
+ "app.skin-site.admin.review.paper-saved": {"message": "试卷已保存。"},
+ "app.skin-site.admin.review.mail-title": {"message": "通过邮件"},
+ "app.skin-site.admin.review.mail-enabled": {"message": "审核通过后发送邮件"},
+ "app.skin-site.admin.review.mail-subject": {"message": "邮件标题"},
+ "app.skin-site.admin.review.mail-message": {"message": "邮件正文"},
+ "app.skin-site.admin.review.mail-extra": {"message": "邮件补充说明"},
+ "app.skin-site.admin.review.mail-include-invite": {"message": "附带邀请码"},
+ "app.skin-site.admin.review.prevent-duplicate": {"message": "禁止重复作答"},
+ "app.skin-site.admin.review.copy-link": {"message": "复制直链"},
+ "app.skin-site.admin.review.link-copied": {"message": "直链已复制。"},
+ "app.skin-site.admin.review.move-up": {"message": "上移"},
+ "app.skin-site.admin.review.move-down": {"message": "下移"},
+ "app.skin-site.admin.review.mail-subject-required": {"message": "开启邮件后必须填写邮件标题。"},
+ "app.skin-site.admin.review.mail-message-required": {"message": "开启邮件后必须填写邮件正文。"},
+ "app.skin-site.admin.users.actions": {"message": "操作"},
+ "app.skin-site.admin.users.audit": {"message": "审计"},
+ "app.skin-site.admin.users.audit-empty": {"message": "暂无管理审计记录。"},
+ "app.skin-site.admin.users.audit-more": {"message": "加载更早记录"},
+ "app.skin-site.admin.users.audit-title": {"message": "用户管理审计"},
+ "app.skin-site.admin.users.bot": {"message": "机器人"},
+ "app.skin-site.admin.users.bot-active": {"message": "Token 有效"},
+ "app.skin-site.admin.users.bot-ban": {"message": "封禁 Bot 创建"},
+ "app.skin-site.admin.users.bot-creation-banned": {"message": "禁止创建 Token"},
+ "app.skin-site.admin.users.bot-exists": {"message": "已创建 Bot"},
+ "app.skin-site.admin.users.bot-inactive": {"message": "Token 已失效"},
+ "app.skin-site.admin.users.bot-invalidate": {"message": "使 Token 失效"},
+ "app.skin-site.admin.users.bot-not-exists": {"message": "尚未创建 Bot"},
+ "app.skin-site.admin.users.bot-save-username": {"message": "保存机器人用户名"},
+ "app.skin-site.admin.users.bot-title": {"message": "Bot 管理"},
+ "app.skin-site.admin.users.bot-token-hint": {"message": "Token 标识"},
+ "app.skin-site.admin.users.bot-unban": {"message": "解除 Bot 封禁"},
+ "app.skin-site.admin.users.bot-username": {"message": "机器人用户名"},
+ "app.skin-site.admin.users.bound-players-title": {"message": "绑定玩家"},
+ "app.skin-site.admin.users.frequency": {"message": "频率限制"},
+ "app.skin-site.admin.users.frequency-clear": {"message": "取消限制"},
+ "app.skin-site.admin.users.frequency-current": {"message": "当前 {percent}%"},
+ "app.skin-site.admin.users.frequency-hint": {"message": "以下列频率对指定用户进行丢包处理,限制其进入服务器的频率。"},
+ "app.skin-site.admin.users.frequency-none": {"message": "当前未启用"},
+ "app.skin-site.admin.users.frequency-range": {"message": "仅允许设置 10% 至 100% 的整数。"},
+ "app.skin-site.admin.users.frequency-save": {"message": "保存限制"},
+ "app.skin-site.admin.users.frequency-title": {"message": "频率限制"},
+ "app.skin-site.admin.users.invite": {"message": "邀请用户"},
+ "app.skin-site.admin.users.invite-copied": {"message": "邀请码已复制。"},
+ "app.skin-site.admin.users.invite-copy": {"message": "复制"},
+ "app.skin-site.admin.users.invite-email": {"message": "邮箱"},
+ "app.skin-site.admin.users.invite-email-invalid": {"message": "请输入正确格式的邮箱。"},
+ "app.skin-site.admin.users.invite-generate": {"message": "生成邀请码"},
+ "app.skin-site.admin.users.invite-title": {"message": "邀请用户"},
+ "app.skin-site.admin.users.legacy-adapted": {"message": "已适配旧内服"},
+ "app.skin-site.admin.users.registered-at": {"message": "注册时间"},
+ "app.skin-site.admin.users.status": {"message": "状态"},
+ "app.skin-site.admin.users.status-banned": {"message": "已封禁"},
+ "app.skin-site.admin.users.status-normal": {"message": "正常"},
+ "app.skin-site.admin.mods.upload": {"message": "上传"},
+ "app.skin-site.admin.mods.select-mode": {"message": "选择多个"},
+ "app.skin-site.admin.mods.exit-select": {"message": "完成选择"},
+ "app.skin-site.admin.mods.select-all": {"message": "全选本页"},
+ "app.skin-site.admin.mods.clear-selection": {"message": "取消全选"},
+ "app.skin-site.admin.mods.selected-count": {"message": "已选择 {count} 项"},
+ "app.skin-site.admin.mods.delete-selected": {"message": "删除所选"},
+ "app.skin-site.admin.mods.confirm-delete-multi": {"message": "确定删除选中的 {count} 个 Mod?此操作不可撤销。"},
+ "app.skin-site.admin.mods.deleted-multi": {"message": "已删除所选 Mod。"},
+ "app.skin-site.admin.mods.server-address": {"message": "服务器地址"},
+ "app.skin-site.admin.mods.server-address-hint": {"message": "按服务器地址过滤"},
+ "app.skin-site.admin.mods.apply": {"message": "应用"},
+ "app.skin-site.admin.mods.clear": {"message": "清除"},
+ "app.skin-site.admin.mods.upload-title": {"message": "上传 Mod"},
+ "app.skin-site.admin.mods.upload-drop": {"message": "拖拽 .jar 文件到此处,或点击选择"},
+ "app.skin-site.admin.mods.upload-sub": {"message": "支持多文件,单个不超过 {size} MB"},
+ "app.skin-site.admin.mods.upload-tags": {"message": "附加规则标签"},
+ "app.skin-site.admin.mods.upload-queue": {"message": "文件列表"},
+ "app.skin-site.admin.mods.upload-pending": {"message": "等待中"},
+ "app.skin-site.admin.mods.upload-checking": {"message": "检查中…"},
+ "app.skin-site.admin.mods.upload-uploading": {"message": "上传中…"},
+ "app.skin-site.admin.mods.upload-conflict": {"message": "名称已存在"},
+ "app.skin-site.admin.mods.upload-success": {"message": "完成"},
+ "app.skin-site.admin.mods.upload-error": {"message": "失败"},
+ "app.skin-site.admin.mods.upload-exists": {"message": "该文件已存在于服务器。"},
+ "app.skin-site.admin.mods.upload-name-conflict": {"message": "已存在同名 Mod"},
+ "app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "覆盖已有 Mod,或跳过此文件。"},
+ "app.skin-site.admin.mods.upload-overwrite": {"message": "覆盖"},
+ "app.skin-site.admin.mods.upload-skip": {"message": "跳过"},
+ "app.skin-site.admin.mods.upload-close": {"message": "关闭"},
+ "app.skin-site.admin.mods.upload-all-done": {"message": "全部文件处理完成。"},
+ "app.skin-site.admin.mods.upload-too-large": {"message": "文件过大:{name}"},
+ "app.skin-site.chat.avatar.label": {"message": "房间头像"},
+ "app.skin-site.chat.avatar.upload": {"message": "上传头像"},
+ "app.skin-site.chat.avatar.replace": {"message": "更换头像"},
+ "app.skin-site.chat.avatar.remove": {"message": "移除头像"},
+ "app.skin-site.chat.avatar.hint": {"message": "支持 PNG、JPEG、WebP,单边不超过 8192 像素,文件不超过 5 MB。"},
+ "app.skin-site.chat.avatar.bad-format": {"message": "房间头像仅支持 PNG、JPEG 和 WebP 格式。"},
+ "app.skin-site.chat.avatar.too-large": {"message": "房间头像大小不能超过 5 MB。"},
+ "app.skin-site.chat.avatar.not-image": {"message": "房间头像必须是图片。"},
+ "app.skin-site.chat.admin.title": {"message": "聊天室管理"},
+ "app.skin-site.chat.admin.subtitle": {"message": "创建公开或私有房间,并将日常管理权限交给合适的成员。"},
+ "app.skin-site.chat.admin.create-room": {"message": "创建房间"},
+ "app.skin-site.chat.admin.loading": {"message": "加载中…"},
+ "app.skin-site.chat.admin.failed": {"message": "无法加载房间列表。"},
+ "app.skin-site.chat.admin.empty": {"message": "还没有聊天室。"},
+ "app.skin-site.chat.admin.edit": {"message": "编辑"},
+ "app.skin-site.chat.admin.remove": {"message": "删除"},
+ "app.skin-site.chat.admin.save": {"message": "保存"},
+ "app.skin-site.chat.admin.cancel": {"message": "取消"},
+ "app.skin-site.chat.admin.name": {"message": "房间名称"},
+ "app.skin-site.chat.admin.description": {"message": "房间简介"},
+ "app.skin-site.chat.admin.visibility": {"message": "可见性"},
+ "app.skin-site.chat.admin.visibility-public": {"message": "公开"},
+ "app.skin-site.chat.admin.visibility-private": {"message": "私有"},
+ "app.skin-site.chat.admin.kind": {"message": "房间类型"},
+ "app.skin-site.chat.admin.kind-standard": {"message": "普通房间"},
+ "app.skin-site.chat.admin.kind-settlement": {"message": "聚落房间"},
+ "app.skin-site.chat.admin.prerequisite-mode": {"message": "前置条件判定"},
+ "app.skin-site.chat.admin.prerequisite-any": {"message": "满足任一"},
+ "app.skin-site.chat.admin.prerequisite-all": {"message": "满足全部"},
+ "app.skin-site.chat.admin.prerequisite-permissions": {"message": "所需权限"},
+ "app.skin-site.chat.admin.prerequisite-hint": {"message": "以逗号分隔;留空表示无限制。"},
+ "app.skin-site.chat.admin.created": {"message": "房间已创建。"},
+ "app.skin-site.chat.admin.updated": {"message": "房间资料已更新。"},
+ "app.skin-site.chat.admin.deleted": {"message": "房间已删除。"},
+ "app.skin-site.chat.admin.confirm-delete": {"message": "确认永久删除房间“{name}”及其中全部消息吗?"},
+ "app.skin-site.chat.admin.manager-badge": {"message": "管理"},
+ "app.skin-site.chat.admin.voice-badge": {"message": "语音"},
+ "app.skin-site.chat.user-picker.label": {"message": "选择用户"},
+ "app.skin-site.chat.user-picker.placeholder": {"message": "搜索用户名、邮箱或账号 ID"},
+ "app.skin-site.chat.user-picker.by-user": {"message": "按用户信息"},
+ "app.skin-site.chat.user-picker.by-player": {"message": "按玩家 ID"},
+ "app.skin-site.chat.user-picker.hint": {"message": "支持按用户名、邮箱模糊搜索,也可输入账号 ID 搜索。"},
+ "app.skin-site.chat.user-picker.player-hint": {"message": "输入玩家名称,查找绑定该玩家 ID 的用户。"},
+ "app.skin-site.chat.user-picker.empty": {"message": "没有找到匹配的用户"},
+ "app.skin-site.chat.user-picker.banned": {"message": "该账号已被封禁,不能执行此操作。"},
+ "app.skin-site.chat.user-picker.guest": {"message": "游客不能成为房管、聚落主或聚落成员。"},
+ "app.skin-site.chat.user-picker.already-in": {"message": "该用户已经在当前房间中。"},
+ "app.skin-site.chat.user-picker.not-allowed": {"message": "该用户不在当前允许选择的范围内"},
+ "app.skin-site.chat.user-picker.select": {"message": "选择此用户"},
+ "app.skin-site.chat.user-picker.selected": {"message": "已选择此用户"},
+ "app.skin-site.chat.user-picker.id": {"message": "ID"},
+ "app.skin-site.chat.admin.managers": {"message": "房管"},
+ "app.skin-site.chat.admin.manager-title": {"message": "任命房管"},
+ "app.skin-site.chat.admin.manager-hint": {"message": "房管可以管理该房间的成员、消息与文件。"},
+ "app.skin-site.chat.admin.grant": {"message": "任命"},
+ "app.skin-site.chat.admin.revoke": {"message": "撤销"},
+ "app.skin-site.chat.admin.manager-granted": {"message": "已任命房管。"},
+ "app.skin-site.chat.admin.manager-revoked": {"message": "已撤销房管。"},
+ "app.skin-site.chat.admin.owner-title": {"message": "聚落主"},
+ "app.skin-site.chat.admin.owner-hint": {"message": "为该房间任命首任聚落主。"},
+ "app.skin-site.chat.admin.owner-assigned": {"message": "已任命聚落主。"},
+ "app.skin-site.chat.admin.owner-current": {"message": "当前聚落主:{name}"},
+ "app.skin-site.chat.admin.owner-none": {"message": "尚未设置聚落主。"},
+ "app.skin-site.chat.admin.audit": {"message": "审计"},
+ "app.skin-site.chat.admin.audit-title": {"message": "房间审计"},
+ "app.skin-site.chat.admin.audit-empty": {"message": "暂无审计记录。"},
+ "app.skin-site.chat.admin.audit-target": {"message": "对象:{name}"},
+ "app.skin-site.chat.admin.close": {"message": "关闭"},
+ "app.skin-site.chat.voice-runtime-title": {"message": "实时语音库"},
+ "app.skin-site.chat.voice-logs": {"message": "查看日志"},
+ "app.skin-site.chat.voice-config": {"message": "配置语音库"},
+ "app.skin-site.chat.voice-retry": {"message": "重新部署"},
+ "app.skin-site.chat.voice-config-title": {"message": "语音库配置"},
+ "app.skin-site.chat.voice-logs-title": {"message": "LiveKit 日志"},
+ "app.skin-site.chat.voice-capacity-title": {"message": "语音容量"},
+ "app.skin-site.chat.voice-capacity": {"message": "语音容量"},
+ "app.skin-site.chat.participant-limit": {"message": "参与人数上限"},
+ "app.skin-site.chat.listener-limit": {"message": "旁听人数上限"},
+ "app.skin-site.chat.voice-capacity-updated": {"message": "语音容量已更新。"},
+ "app.skin-site.chat.voice-enabled": {"message": "启用实时语音"},
+ "app.skin-site.chat.voice-runtime-path": {"message": "语音库目录"},
+ "app.skin-site.chat.voice-mirror": {"message": "镜像基础地址"},
+ "app.skin-site.chat.voice-public-url": {"message": "公网访问地址"},
+ "app.skin-site.chat.voice-bind": {"message": "监听地址"},
+ "app.skin-site.chat.voice-signal-port": {"message": "信令端口"},
+ "app.skin-site.chat.voice-rtc-tcp-port": {"message": "媒体 TCP 端口"},
+ "app.skin-site.chat.voice-rtc-udp-port": {"message": "媒体 UDP 端口"},
+ "app.skin-site.chat.voice-startup-timeout": {"message": "启动等待时间(秒)"},
+ "app.skin-site.chat.voice-use-external-ip": {"message": "使用外部 IP"},
+ "app.skin-site.chat.voice-node-ip": {"message": "节点 IP"},
+ "app.skin-site.chat.voice-saved": {"message": "语音库配置已保存。"},
+ "app.skin-site.chat.voice-logs-auto-refresh": {"message": "自动刷新"},
+ "app.skin-site.chat.voice-logs-refresh": {"message": "刷新"},
+ "app.skin-site.chat.voice-logs-empty": {"message": "暂无日志。"},
+ "app.skin-site.chat.admin.voice-runtime-title": {"message": "实时语音库"},
+ "app.skin-site.chat.admin.voice-logs": {"message": "查看日志"},
+ "app.skin-site.chat.admin.voice-config": {"message": "配置语音库"},
+ "app.skin-site.chat.admin.voice-retry": {"message": "重新部署"},
+ "app.skin-site.chat.admin.voice-config-title": {"message": "语音库配置"},
+ "app.skin-site.chat.admin.voice-logs-title": {"message": "LiveKit 日志"},
+ "app.skin-site.chat.admin.voice-capacity-title": {"message": "语音容量"},
+ "app.skin-site.chat.admin.voice-capacity": {"message": "语音容量"},
+ "app.skin-site.chat.admin.participant-limit": {"message": "参与人数上限"},
+ "app.skin-site.chat.admin.listener-limit": {"message": "旁听人数上限"},
+ "app.skin-site.chat.admin.voice-capacity-updated": {"message": "语音容量已更新。"},
+ "app.skin-site.chat.admin.voice-enabled": {"message": "启用实时语音"},
+ "app.skin-site.chat.admin.voice-runtime-path": {"message": "语音库目录"},
+ "app.skin-site.chat.admin.voice-mirror": {"message": "镜像基础地址"},
+ "app.skin-site.chat.admin.voice-public-url": {"message": "公网访问地址"},
+ "app.skin-site.chat.admin.voice-bind": {"message": "监听地址"},
+ "app.skin-site.chat.admin.voice-signal-port": {"message": "信令端口"},
+ "app.skin-site.chat.admin.voice-rtc-tcp-port": {"message": "媒体 TCP 端口"},
+ "app.skin-site.chat.admin.voice-rtc-udp-port": {"message": "媒体 UDP 端口"},
+ "app.skin-site.chat.admin.voice-startup-timeout": {"message": "启动等待时间(秒)"},
+ "app.skin-site.chat.admin.voice-use-external-ip": {"message": "使用外部 IP"},
+ "app.skin-site.chat.admin.voice-node-ip": {"message": "节点 IP"},
+ "app.skin-site.chat.admin.voice-saved": {"message": "语音库配置已保存。"},
+ "app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "自动刷新"},
+ "app.skin-site.chat.admin.voice-logs-refresh": {"message": "刷新"},
+ "app.skin-site.chat.admin.voice-logs-empty": {"message": "暂无日志。"},
+ "app.skin-site.admin.wiki.tab-migration": {"message": "迁移中心"},
+ "app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki 迁移"},
+ "app.skin-site.admin.wiki.migration-hint": {"message": "迁移内容会先进入不可见暂存区,所有页面完成导入或排除审核后才能整批发布。"},
+ "app.skin-site.admin.wiki.auto-title": {"message": "从 MediaWiki API 自动读取"},
+ "app.skin-site.admin.wiki.api-url": {"message": "api.php 地址"},
+ "app.skin-site.admin.wiki.bot-username": {"message": "Bot 用户名(选填)"},
+ "app.skin-site.admin.wiki.bot-password": {"message": "Bot 密码(选填)"},
+ "app.skin-site.admin.wiki.credentials-hint": {"message": "凭据只存在于本次后台任务的内存中,携带凭据时必须使用 HTTPS。"},
+ "app.skin-site.admin.wiki.allow-private": {"message": "明确允许访问内网来源"},
+ "app.skin-site.admin.wiki.allow-private-hint": {"message": "内网来源可能访问皮肤站所在网络中的服务,请只对可信地址启用。"},
+ "app.skin-site.admin.wiki.start-auto": {"message": "创建自动迁移任务"},
+ "app.skin-site.admin.wiki.xml-title": {"message": "导入 MediaWiki XML"},
+ "app.skin-site.admin.wiki.xml-file": {"message": "导出文件"},
+ "app.skin-site.admin.wiki.start-xml": {"message": "上传并建立暂存任务"},
+ "app.skin-site.admin.wiki.stale-rules": {"message": "疑似过时页面筛选规则"},
+ "app.skin-site.admin.wiki.stale-days": {"message": "未更新天数"},
+ "app.skin-site.admin.wiki.maintainer-window": {"message": "维护者统计天数"},
+ "app.skin-site.admin.wiki.max-maintainers": {"message": "维护者人数不超过"},
+ "app.skin-site.admin.wiki.stale-markers": {"message": "过时分类、模板或文字标记"},
+ "app.skin-site.admin.wiki.stale-markers-hint": {"message": "使用逗号分隔"},
+ "app.skin-site.admin.wiki.jobs": {"message": "迁移任务"},
+ "app.skin-site.admin.wiki.refresh-jobs": {"message": "刷新"},
+ "app.skin-site.admin.wiki.jobs-empty": {"message": "还没有迁移任务。"},
+ "app.skin-site.admin.wiki.reviewed-count": {"message": "已审核 {reviewed} / {total}"},
+ "app.skin-site.admin.wiki.imported-excluded": {"message": "导入 {imported} · 排除 {excluded}"},
+ "app.skin-site.admin.wiki.view-job": {"message": "查看任务"},
+ "app.skin-site.admin.wiki.delete-job": {"message": "删除迁移记录"},
+ "app.skin-site.admin.wiki.confirm-delete-job": {"message": "确认删除迁移任务 #{id}?此操作不可恢复。"},
+ "app.skin-site.admin.wiki.candidates": {"message": "候选页面"},
+ "app.skin-site.admin.wiki.candidates-empty": {"message": "选择任务后查看其页面。"},
+ "app.skin-site.admin.wiki.last-updated": {"message": "更新于 {date}"},
+ "app.skin-site.admin.wiki.maintainer-count": {"message": "{count} 位维护者"},
+ "app.skin-site.admin.wiki.conflict": {"message": "标题冲突"},
+ "app.skin-site.admin.wiki.target-title": {"message": "目标标题"},
+ "app.skin-site.admin.wiki.overwrite": {"message": "覆盖"},
+ "app.skin-site.admin.wiki.import": {"message": "导入"},
+ "app.skin-site.admin.wiki.exclude": {"message": "排除"},
+ "app.skin-site.admin.wiki.publish": {"message": "整批发布"},
+ "app.skin-site.admin.wiki.rollback": {"message": "回滚"},
+ "app.skin-site.admin.wiki.confirm-rollback": {"message": "确认回滚本批迁移?已发布的页面将被还原。"},
+ "app.skin-site.admin.wiki.state-importing": {"message": "正在读取"},
+ "app.skin-site.admin.wiki.state-review": {"message": "等待审核"},
+ "app.skin-site.admin.wiki.state-publishing": {"message": "正在发布"},
+ "app.skin-site.admin.wiki.state-published": {"message": "已发布"},
+ "app.skin-site.admin.wiki.state-failed": {"message": "失败"},
+ "app.skin-site.admin.wiki.state-rolled-back": {"message": "已回滚"},
+ "app.skin-site.admin.wiki.decision-pending": {"message": "待审核"},
+ "app.skin-site.admin.wiki.decision-import": {"message": "导入"},
+ "app.skin-site.admin.wiki.decision-exclude": {"message": "排除"},
+ "app.skin-site.admin.wiki.migration-started": {"message": "迁移任务已创建。"},
+ "app.skin-site.admin.wiki.xml-staged": {"message": "XML 已解析并进入逐页审核阶段。"},
+ "app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"},
+ "app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"},
+ "app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"},
+ "app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"},
+ "app.skin-site.wiki.title": {"message": "StarLight 维基"},
+ "app.skin-site.wiki.search-placeholder": {"message": "输入页面标题"},
+ "app.skin-site.wiki.open": {"message": "打开"},
+ "app.skin-site.wiki.loading": {"message": "加载中…"},
+ "app.skin-site.wiki.not-found": {"message": "页面不存在。"},
+ "app.skin-site.wiki.empty": {"message": "在上方输入页面标题开始阅读。"},
+ "app.skin-site.wiki.updated-at": {"message": "最后编辑于 {date},编辑者 {author}"},
+ "app.skin-site.wiki.revision": {"message": "修订 #{id}"},
+ "app.skin-site.wiki.categories": {"message": "分类"},
+ "app.skin-site.wiki.back": {"message": "返回"}
}
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f6c8ba0..ec019e5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -188,6 +188,9 @@ importers:
vuedraggable:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.27(typescript@5.9.3))
+ xxhash-wasm:
+ specifier: 1.1.0
+ version: 1.1.0
devDependencies:
'@eslint/compat':
specifier: ^1.1.1
@@ -7103,6 +7106,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
+ xxhash-wasm@1.1.0:
+ resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
+
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -14160,6 +14166,8 @@ snapshots:
xtend@4.0.2: {}
+ xxhash-wasm@1.1.0: {}
+
y18n@5.0.8: {}
yallist@3.1.1: {}
diff --git a/scripts/axolotl/i18n-check.mjs b/scripts/axolotl/i18n-check.mjs
index d3255ff..1f52425 100644
--- a/scripts/axolotl/i18n-check.mjs
+++ b/scripts/axolotl/i18n-check.mjs
@@ -31,8 +31,11 @@ const allowedUntranslatedMessages = new Set([
'LiteLoader',
'LogShare.CN',
'UUID',
+ 'ID',
'QQ',
'IP',
+ 'URL',
+ 'Mod ID',
'LogShare AI',
'NeoForge',
'OptiFine',