Files
Starlight_Lancher/apps/app-frontend/src/components/ui/skin-site/admin/AdminUsersPage.vue
T
AxTps fbcbb803ac
Sync source to CNB / Sync Git ref (push) Skipped
feat: 补全皮肤站管理台用户页
- 用户列表:搜索(用户名/邮箱/UUID)、分页、封禁状态、注册时间
- 编辑用户:用户名、邮箱、QQ、密码、权限组、封禁状态
- 绑定玩家:展示 Mojang / StarLight 来源与旧内服适配标记
- Bot 管理:查看状态、修改机器人用户名、使 Token 失效、封禁/解封 Bot 创建
- 频率限制:滑块与数字输入设置丢包频率,支持保存与取消
- 管理审计:按用户或全局查看,游标分页加载更早记录
- 邀请码:按邮箱生成邀请码并复制
- 补充对应 API 与类型定义,中英文各补 39 个文案
2026-10-18 11:18:55 +08:00

1292 lines
42 KiB
Vue

<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteAdminPlayer,
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'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.users.title', defaultMessage: 'Users' },
loading: { id: 'app.skin-site.admin.users.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.users.failed', defaultMessage: 'Could not load users.' },
empty: { id: 'app.skin-site.admin.users.empty', defaultMessage: 'No users found.' },
search: { id: 'app.skin-site.admin.users.search', defaultMessage: 'Search' },
searchName: { id: 'app.skin-site.admin.users.search-name', defaultMessage: 'Username' },
searchEmail: { id: 'app.skin-site.admin.users.search-email', defaultMessage: 'Email' },
searchUuid: { id: 'app.skin-site.admin.users.search-uuid', defaultMessage: 'UUID' },
total: { id: 'app.skin-site.admin.users.total', defaultMessage: '{count} users' },
prev: { id: 'app.skin-site.admin.users.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.users.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.users.page', defaultMessage: 'Page {page}' },
edit: { id: 'app.skin-site.admin.users.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.users.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.users.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.users.remove', defaultMessage: 'Delete' },
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.' },
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' },
password: { id: 'app.skin-site.admin.users.password', defaultMessage: 'New password' },
passwordHint: {
id: 'app.skin-site.admin.users.password-hint',
defaultMessage: 'Leave empty to keep the current password.',
},
banned: { id: 'app.skin-site.admin.users.banned', defaultMessage: 'Banned' },
permissions: { id: 'app.skin-site.admin.users.permissions', defaultMessage: 'Permissions' },
permissionsHint: {
id: 'app.skin-site.admin.users.permissions-hint',
defaultMessage: 'Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN.',
},
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
const items = ref<SkinSiteAdminUser[]>([])
const total = ref(0)
const offset = ref(0)
const loading = ref(true)
const failed = ref(false)
const keyword = ref('')
const searchField = ref<'username' | 'email' | 'uuid'>('username')
const editingUuid = ref<string | null>(null)
const editForm = ref({
username: '',
email: '',
qq: '',
password: '',
banned: false,
permissions: '',
})
const saving = ref(false)
const deletingUuid = ref<string | null>(null)
const playersFor = ref<SkinSiteAdminPlayer[] | null>(null)
const playersLoading = ref(false)
const playersTarget = ref<SkinSiteAdminUser | null>(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)
// ---------- bot ----------
const botTarget = ref<SkinSiteAdminUser | null>(null)
const botInfo = ref<SkinSiteBotInfo | null>(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 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 closeBot() {
botTarget.value = null
botInfo.value = null
}
async function saveBotUsername() {
if (!botTarget.value || !validBotUsername.value) return
botBusy.value = true
try {
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<SkinSiteAdminUser | null>(null)
const frequencyLoading = ref(false)
const frequencyBusy = ref(false)
const frequencyCurrent = ref<SkinSiteUserFrequencyLimit | null>(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<SkinSiteAdminUser | null>(null)
const auditItems = ref<SkinSiteUserAdminAuditInfo[]>([])
const auditLoading = ref(false)
const auditNextCursor = ref<number | null>(null)
const AUDIT_ACTION_LABELS: Record<string, string> = {
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 '—'
}
}
async function load(reset = false) {
if (reset) offset.value = 0
loading.value = true
failed.value = false
try {
const listRes = await skinSiteAdminUserList({
keyword: keyword.value.trim() || undefined,
searchField: searchField.value,
limit: PAGE_SIZE,
offset: offset.value,
})
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)
} finally {
loading.value = false
}
}
function search() {
void load(true)
}
async function prevPage() {
if (!hasPrev.value) return
offset.value = Math.max(0, offset.value - PAGE_SIZE)
await load()
}
async function nextPage() {
if (!hasNext.value) return
offset.value += PAGE_SIZE
await load()
}
function startEdit(user: SkinSiteAdminUser) {
editingUuid.value = user.uuid
editForm.value = {
username: user.username,
email: user.email,
qq: user.qq !== null && user.qq !== undefined ? String(user.qq) : '',
password: '',
banned: user.banned,
permissions: (user.permissions ?? []).join(', '),
}
}
function cancelEdit() {
editingUuid.value = null
}
async function saveEdit() {
if (!editingUuid.value || saving.value) return
saving.value = true
try {
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(),
password: editForm.value.password.trim() || undefined,
qq: editForm.value.qq.trim() ? Number(editForm.value.qq.trim()) : undefined,
banned: editForm.value.banned,
permissions: editForm.value.permissions
.split(',')
.map((p) => p.trim())
.filter(Boolean),
})
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
editingUuid.value = null
await load()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
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 res = await skinSiteAdminDeleteUser(user.uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load()
} catch (error) {
handleError(error)
} finally {
deletingUuid.value = null
}
}
async function openPlayers(user: SkinSiteAdminUser) {
playersTarget.value = user
playersFor.value = null
playersLoading.value = true
try {
const res = await skinSiteAdminUserPlayers(user.uuid)
if (!res.success) throw new Error(res.errorMessage)
playersFor.value = res.payload ?? []
} catch (error) {
handleError(error)
} finally {
playersLoading.value = false
}
}
function closePlayers() {
playersTarget.value = null
playersFor.value = null
}
onMounted(() => void load())
</script>
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap items-end justify-between gap-3">
<div>
<h2 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<p class="m-0 mt-1 text-sm text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</p>
</div>
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="openInvite"
>
{{ formatMessage(messages.invite) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="openAudit()"
>
{{ formatMessage(messages.audit) }}
</button>
</div>
</div>
<div class="flex flex-wrap items-center gap-2">
<select
v-model="searchField"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1.5 text-xs text-contrast outline-none focus:border-brand"
>
<option value="username">{{ formatMessage(messages.searchName) }}</option>
<option value="email">{{ formatMessage(messages.searchEmail) }}</option>
<option value="uuid">{{ formatMessage(messages.searchUuid) }}</option>
</select>
<input
v-model="keyword"
type="text"
:placeholder="formatMessage(messages.search)"
class="min-w-48 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="search"
/>
<button
type="button"
class="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="search"
>
{{ formatMessage(messages.search) }}
</button>
</div>
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="failed"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.failed) }}
</p>
<p
v-else-if="!items.length"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<template v-else>
<div class="overflow-hidden rounded-lg border border-solid border-surface-5">
<table class="w-full border-collapse text-left text-sm">
<thead class="bg-bg-raised">
<tr class="text-xs text-secondary">
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.username) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.email) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.qq) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.status) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.registeredAt) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.actions) }}</th>
</tr>
</thead>
<tbody>
<tr
v-for="user in items"
:key="user.uuid"
class="border-t border-solid border-surface-5 hover:bg-bg-raised"
>
<td class="px-3 py-2">
<div class="font-semibold text-contrast">{{ user.username }}</div>
<div class="text-[10px] break-all text-secondary">{{ user.uuid }}</div>
</td>
<td class="px-3 py-2 text-xs break-all text-secondary">{{ user.email }}</td>
<td class="px-3 py-2 text-xs text-secondary">{{ user.qq ?? '—' }}</td>
<td class="px-3 py-2">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="user.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
>
{{
user.banned
? formatMessage(messages.statusBanned)
: formatMessage(messages.statusNormal)
}}
</span>
</td>
<td class="px-3 py-2 text-xs text-secondary">
{{ formatDateTime(user.registerTime) }}
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
<button
type="button"
class="rounded bg-button-bg px-2 py-0.5 text-[11px] font-semibold text-contrast hover:brightness-110"
@click="openPlayers(user)"
>
{{ formatMessage(messages.players) }}
</button>
<button
type="button"
class="rounded bg-button-bg px-2 py-0.5 text-[11px] font-semibold text-contrast hover:brightness-110"
@click="openBot(user)"
>
{{ formatMessage(messages.bot) }}
</button>
<button
type="button"
class="rounded bg-button-bg px-2 py-0.5 text-[11px] font-semibold text-contrast hover:brightness-110"
@click="openFrequency(user)"
>
{{ formatMessage(messages.frequency) }}
</button>
<button
type="button"
class="rounded bg-button-bg px-2 py-0.5 text-[11px] font-semibold text-contrast hover:brightness-110"
@click="openAudit(user)"
>
{{ formatMessage(messages.audit) }}
</button>
<button
type="button"
class="rounded bg-brand px-2 py-0.5 text-[11px] font-semibold text-contrast hover:brightness-110"
@click="startEdit(user)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded bg-red/20 px-2 py-0.5 text-[11px] font-semibold text-red hover:brightness-110 disabled:opacity-50"
:disabled="deletingUuid === user.uuid"
@click="removeUser(user)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</td>
</tr>
</tbody>
</table>
</div>
<div class="flex items-center justify-center gap-3 pt-1">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page }) }} / {{ pageCount }}
</span>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</template>
<!-- 编辑 -->
<div
v-if="editingUuid"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="cancelEdit"
>
<div
class="flex max-h-[85vh] w-full max-w-lg flex-col gap-3 overflow-y-auto rounded-xl bg-bg p-6"
>
<h3 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.edit) }}</h3>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.username) }}
</span>
<input
v-model="editForm.username"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.email) }}
</span>
<input
v-model="editForm.email"
type="email"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">{{ formatMessage(messages.qq) }}</span>
<input
v-model="editForm.qq"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.password) }}
</span>
<input
v-model="editForm.password"
type="password"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
<span class="text-[11px] text-secondary">{{ formatMessage(messages.passwordHint) }}</span>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.permissions) }}
</span>
<input
v-model="editForm.permissions"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.permissionsHint) }}
</span>
</label>
<label class="flex cursor-pointer items-center gap-2 text-sm text-contrast">
<input v-model="editForm.banned" type="checkbox" class="size-4" />
{{ formatMessage(messages.banned) }}
</label>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="cancelEdit"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="saving"
@click="saveEdit"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
<!-- 绑定玩家 -->
<div
v-if="playersTarget"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closePlayers"
>
<div
class="flex max-h-[85vh] w-full max-w-md flex-col gap-3 overflow-y-auto rounded-xl bg-bg p-6"
>
<h3 class="m-0 text-lg font-bold text-contrast">
{{ formatMessage(messages.boundPlayersTitle) }} · {{ playersTarget.username }}
</h3>
<div
v-if="playersLoading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="!playersFor || !playersFor.length"
class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
{{ formatMessage(messages.noPlayers) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="player in playersFor"
:key="player.uuid"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2"
>
<div class="flex items-center justify-between gap-2">
<span class="font-semibold text-contrast">{{ player.name }}</span>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="player.isMojang ? 'bg-yellow text-contrast' : 'bg-brand text-contrast'"
>
{{ player.isMojang ? 'Mojang' : 'StarLight' }}
</span>
</div>
<p class="m-0 mt-1 text-[10px] break-all text-secondary">{{ player.uuid }}</p>
<p v-if="player.legacyInternalAdapted" class="m-0 mt-1 text-[10px] text-red">
{{ formatMessage(messages.legacyAdapted) }}
</p>
</div>
</div>
<div class="flex justify-end pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="closePlayers"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
</div>
<!-- Bot 管理 -->
<div
v-if="botTarget"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeBot"
>
<div
class="flex max-h-[85vh] w-full max-w-md flex-col gap-3 overflow-y-auto rounded-xl bg-bg p-6"
>
<h3 class="m-0 text-lg font-bold text-contrast">
{{ formatMessage(messages.botTitle) }} · {{ botTarget.username }}
</h3>
<div class="rounded-lg bg-bg-raised px-3 py-2 text-xs text-secondary">
<p class="m-0 font-semibold text-contrast">{{ botTarget.username }}</p>
<p class="m-0 mt-1 break-all">{{ botTarget.uuid }}</p>
</div>
<div
v-if="botLoading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<template v-else-if="botInfo">
<div class="flex flex-wrap gap-2">
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="botInfo.exists ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
>
{{
botInfo.exists
? formatMessage(messages.botExists)
: formatMessage(messages.botNotExists)
}}
</span>
<span
v-if="botInfo.exists"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="botInfo.active ? 'bg-green text-contrast' : 'bg-yellow text-contrast'"
>
{{
botInfo.active
? formatMessage(messages.botActive)
: formatMessage(messages.botInactive)
}}
</span>
<span
v-if="botInfo.creationBanned"
class="rounded-full bg-red px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.botCreationBanned) }}
</span>
</div>
<form
v-if="botInfo.exists"
class="flex flex-col gap-2"
@submit.prevent="saveBotUsername"
>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.botUsername) }}
</span>
<input
v-model="botUsername"
type="text"
minlength="2"
maxlength="32"
:disabled="botBusy"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand disabled:opacity-50"
/>
</label>
<p class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.botTokenHint) }}:
{{ botInfo.tokenHint ? '••••••••' + botInfo.tokenHint : '—' }}
</p>
<button
type="submit"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="botBusy || !validBotUsername || botUsername === botInfo.username"
>
{{ formatMessage(messages.botSaveUsername) }}
</button>
</form>
<div class="grid gap-2 sm:grid-cols-2">
<button
type="button"
class="rounded-lg bg-yellow/20 px-3 py-2 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
:disabled="botBusy || !botInfo.active"
@click="invalidateBot"
>
{{ formatMessage(messages.botInvalidate) }}
</button>
<button
type="button"
class="rounded-lg px-3 py-2 text-xs font-semibold hover:brightness-110 disabled:opacity-50"
:class="
botInfo.creationBanned
? 'bg-green/20 text-green'
: 'bg-red/20 text-red'
"
:disabled="botBusy"
@click="toggleBotCreationBan"
>
{{
botInfo.creationBanned
? formatMessage(messages.botUnban)
: formatMessage(messages.botBan)
}}
</button>
</div>
</template>
<div class="flex justify-end pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="closeBot"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
</div>
<!-- 频率限制 -->
<div
v-if="frequencyTarget"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeFrequency"
>
<div
class="flex max-h-[85vh] w-full max-w-md flex-col gap-3 overflow-y-auto rounded-xl bg-bg p-6"
>
<h3 class="m-0 text-lg font-bold text-contrast">
{{ formatMessage(messages.frequencyTitle) }} · {{ frequencyTarget.username }}
</h3>
<div class="rounded-lg bg-bg-raised px-3 py-2 text-xs text-secondary">
<p class="m-0 font-semibold text-contrast">{{ frequencyTarget.username }}</p>
<p class="m-0 mt-1 break-all">{{ frequencyTarget.uuid }}</p>
</div>
<p class="m-0 text-xs text-secondary">{{ formatMessage(messages.frequencyHint) }}</p>
<div
v-if="frequencyLoading"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<template v-else>
<div class="flex items-center justify-between">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.frequency) }}
</span>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="
currentFrequencyLimit === null
? 'bg-button-bg text-secondary'
: 'bg-yellow text-contrast'
"
>
{{
currentFrequencyLimit === null
? formatMessage(messages.frequencyNone)
: formatMessage(messages.frequencyCurrent, { percent: currentFrequencyLimit })
}}
</span>
</div>
<input
v-model.number="frequencyPercent"
type="range"
min="10"
max="100"
step="1"
:disabled="frequencyBusy"
class="w-full accent-yellow"
/>
<input
v-model.number="frequencyPercent"
type="number"
min="10"
max="100"
step="1"
:disabled="frequencyBusy"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand disabled:opacity-50"
/>
<p class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.frequencyRange) }}
</p>
<div class="grid gap-2 sm:grid-cols-2">
<button
type="button"
class="rounded-lg bg-yellow/20 px-3 py-2 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
:disabled="frequencyBusy || !validFrequency"
@click="saveFrequency"
>
{{ formatMessage(messages.frequencySave) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-2 text-xs font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="frequencyBusy || currentFrequencyLimit === null"
@click="clearFrequency"
>
{{ formatMessage(messages.frequencyClear) }}
</button>
</div>
</template>
<div class="flex justify-end pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="closeFrequency"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
</div>
<!-- 审计 -->
<div
v-if="auditTarget !== null || auditItems.length || auditLoading"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeAudit"
>
<div
class="flex max-h-[85vh] w-full max-w-2xl flex-col gap-3 overflow-y-auto rounded-xl bg-bg p-6"
>
<h3 class="m-0 text-lg font-bold text-contrast">
{{ formatMessage(messages.auditTitle) }}
<span v-if="auditTarget" class="text-sm font-normal text-secondary">
· {{ auditTarget.username }}
</span>
</h3>
<div
v-if="auditLoading && !auditItems.length"
class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary"
>
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="!auditItems.length"
class="m-0 rounded-lg bg-bg-raised p-3 text-xs text-secondary"
>
{{ formatMessage(messages.auditEmpty) }}
</p>
<div v-else class="flex flex-col gap-2">
<article
v-for="entry in auditItems"
:key="entry.id"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex flex-wrap items-start justify-between gap-2">
<div>
<p class="m-0 text-sm font-semibold text-contrast">
{{ auditActionLabel(entry.action) }}
</p>
<p class="m-0 mt-1 text-xs text-secondary">
{{ entry.actorName }} → {{ entry.targetName }}
</p>
</div>
<span class="text-[10px] text-secondary">
{{ formatDateTime(entry.createdAt) }}
</span>
</div>
<div v-if="entry.changes.length" class="mt-2 flex flex-col gap-1">
<div
v-for="(change, i) in entry.changes"
:key="entry.id + '-' + change.field + '-' + i"
class="grid gap-1 rounded bg-bg px-2 py-1 text-[11px] sm:grid-cols-[8rem_1fr]"
>
<span class="font-semibold text-contrast">{{ auditFieldLabel(change.field) }}</span>
<span class="break-all text-secondary">
{{ auditValue(change.before) }}
<span class="px-1">→</span>
{{ auditValue(change.after) }}
</span>
</div>
</div>
</article>
<button
v-if="auditNextCursor"
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="auditLoading"
@click="loadMoreAudit"
>
{{ formatMessage(messages.auditMore) }}
</button>
</div>
<div class="flex justify-end pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="closeAudit"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
</div>
<!-- 邀请码 -->
<div
v-if="inviteOpen"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
@click.self="closeInvite"
>
<div class="flex w-full max-w-md flex-col gap-3 rounded-xl bg-bg p-6">
<h3 class="m-0 text-lg font-bold text-contrast">
{{ formatMessage(messages.inviteTitle) }}
</h3>
<label class="flex flex-col gap-1">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.inviteEmail) }}
</span>
<input
v-model="inviteEmail"
type="email"
placeholder="example@domain.com"
class="rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
@keyup.enter="generateInvite"
/>
</label>
<div class="flex gap-2">
<input
:value="inviteCode"
type="text"
readonly
class="flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2 font-mono text-sm text-contrast outline-none"
/>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!inviteCode"
@click="copyInvite"
>
{{ formatMessage(messages.inviteCopy) }}
</button>
</div>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="closeInvite"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="inviteBusy || !inviteEmailValid"
@click="generateInvite"
>
{{ formatMessage(messages.inviteGenerate) }}
</button>
</div>
</div>
</div>
</div>
</template>