- 新增 ChatUserPicker 用户选择器:支持按用户名/邮箱/账号 ID 或玩家名称搜索,封禁与游客不可选,支持排除与白名单 - 房管任命与撤销 - 聚落房间可任命聚落主,展示当前聚落主 - 房间审计记录查看,操作类型本地化映射 - 补充对应 API 与类型定义 - i18n 白名单加入 ID 缩写 - 中英文各补 31 个文案
This commit is contained in:
1 parent
ef1435bd31
commit
ac38c98203
6 files changed
+772
-2
No files matched your search
@@ -0,0 +1,290 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { type SkinSiteChatUserInfo, skinSiteChatUserSearch } from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roomId?: number
|
||||
label?: string
|
||||
placeholder?: string
|
||||
excludeUuids?: string[]
|
||||
allowedUuids?: string[]
|
||||
allowedHint?: string
|
||||
}>(),
|
||||
{
|
||||
roomId: undefined,
|
||||
label: undefined,
|
||||
placeholder: undefined,
|
||||
excludeUuids: () => [],
|
||||
allowedUuids: undefined,
|
||||
allowedHint: undefined,
|
||||
},
|
||||
)
|
||||
|
||||
const model = defineModel<SkinSiteChatUserInfo | null>({ required: true })
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'app.skin-site.chat.user-picker.label', defaultMessage: 'Pick a user' },
|
||||
placeholder: {
|
||||
id: 'app.skin-site.chat.user-picker.placeholder',
|
||||
defaultMessage: 'Search username, email or account ID',
|
||||
},
|
||||
byUser: { id: 'app.skin-site.chat.user-picker.by-user', defaultMessage: 'By user' },
|
||||
byPlayer: { id: 'app.skin-site.chat.user-picker.by-player', defaultMessage: 'By player' },
|
||||
hint: {
|
||||
id: 'app.skin-site.chat.user-picker.hint',
|
||||
defaultMessage: 'Fuzzy search on username and email, or search by account ID.',
|
||||
},
|
||||
playerHint: {
|
||||
id: 'app.skin-site.chat.user-picker.player-hint',
|
||||
defaultMessage: 'Enter a player name to find the bound user.',
|
||||
},
|
||||
empty: {
|
||||
id: 'app.skin-site.chat.user-picker.empty',
|
||||
defaultMessage: 'No matching user.',
|
||||
},
|
||||
banned: {
|
||||
id: 'app.skin-site.chat.user-picker.banned',
|
||||
defaultMessage: 'This account is banned.',
|
||||
},
|
||||
guest: {
|
||||
id: 'app.skin-site.chat.user-picker.guest',
|
||||
defaultMessage: 'Guests cannot become a manager or settlement owner.',
|
||||
},
|
||||
alreadyIn: {
|
||||
id: 'app.skin-site.chat.user-picker.already-in',
|
||||
defaultMessage: 'This user is already in the room.',
|
||||
},
|
||||
notAllowed: {
|
||||
id: 'app.skin-site.chat.user-picker.not-allowed',
|
||||
defaultMessage: 'This user is outside the allowed range.',
|
||||
},
|
||||
select: { id: 'app.skin-site.chat.user-picker.select', defaultMessage: 'Select' },
|
||||
selected: {
|
||||
id: 'app.skin-site.chat.user-picker.selected',
|
||||
defaultMessage: 'Selected',
|
||||
},
|
||||
idLabel: { id: 'app.skin-site.chat.user-picker.id', defaultMessage: 'ID' },
|
||||
})
|
||||
|
||||
const keyword = ref('')
|
||||
const searchMode = ref<'user' | 'player'>('user')
|
||||
const results = ref<SkinSiteChatUserInfo[]>([])
|
||||
const preview = ref<SkinSiteChatUserInfo | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
let timer: ReturnType<typeof setTimeout> | undefined
|
||||
let requestId = 0
|
||||
|
||||
const currentLabel = computed(() => props.label ?? formatMessage(messages.label))
|
||||
const currentPlaceholder = computed(
|
||||
() => props.placeholder ?? formatMessage(messages.placeholder),
|
||||
)
|
||||
|
||||
watch([keyword, searchMode], () => {
|
||||
clearTimeout(timer)
|
||||
const query = keyword.value.trim()
|
||||
if (!query) {
|
||||
results.value = []
|
||||
preview.value = null
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
timer = setTimeout(() => void search(query), 300)
|
||||
})
|
||||
|
||||
watch(model, (value) => {
|
||||
if (value) return
|
||||
keyword.value = ''
|
||||
preview.value = null
|
||||
results.value = []
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.roomId,
|
||||
() => {
|
||||
model.value = null
|
||||
},
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => clearTimeout(timer))
|
||||
|
||||
async function search(query: string) {
|
||||
const current = ++requestId
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await skinSiteChatUserSearch(query, props.roomId, searchMode.value)
|
||||
if (current !== requestId) return
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
results.value = res.payload ?? []
|
||||
preview.value = results.value.length === 1 ? results.value[0] : null
|
||||
} catch (error) {
|
||||
if (current === requestId) {
|
||||
results.value = []
|
||||
handleError(error)
|
||||
}
|
||||
} finally {
|
||||
if (current === requestId) loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function unavailableReason(user: SkinSiteChatUserInfo): string | null {
|
||||
if (user.banned) return formatMessage(messages.banned)
|
||||
if (user.permissions.includes('GUEST')) return formatMessage(messages.guest)
|
||||
if (props.excludeUuids.includes(user.uuid)) return formatMessage(messages.alreadyIn)
|
||||
if (props.allowedUuids && !props.allowedUuids.includes(user.uuid)) {
|
||||
return props.allowedHint ?? formatMessage(messages.notAllowed)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function selectPreview() {
|
||||
if (preview.value && !unavailableReason(preview.value)) {
|
||||
model.value = preview.value
|
||||
}
|
||||
}
|
||||
|
||||
function formatTime(ts: number): string {
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-3">
|
||||
<div class="flex flex-col gap-1.5">
|
||||
<span class="text-xs font-semibold text-secondary">{{ currentLabel }}</span>
|
||||
<div class="flex gap-2">
|
||||
<select
|
||||
v-model="searchMode"
|
||||
class="shrink-0 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="user">{{ formatMessage(messages.byUser) }}</option>
|
||||
<option value="player">{{ formatMessage(messages.byPlayer) }}</option>
|
||||
</select>
|
||||
<input
|
||||
v-model.trim="keyword"
|
||||
type="text"
|
||||
maxlength="100"
|
||||
:placeholder="searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder"
|
||||
class="min-w-0 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"
|
||||
autocomplete="off"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[11px] text-secondary">
|
||||
{{ searchMode === 'player' ? formatMessage(messages.playerHint) : formatMessage(messages.hint) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="keyword && !loading"
|
||||
class="flex max-h-56 flex-col gap-1.5 overflow-y-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
|
||||
>
|
||||
<button
|
||||
v-for="user in results"
|
||||
:key="user.uuid"
|
||||
type="button"
|
||||
class="flex w-full items-center justify-between gap-3 rounded-lg border border-solid p-2 text-left transition-colors"
|
||||
:class="
|
||||
preview?.uuid === user.uuid
|
||||
? 'border-brand bg-brand/10'
|
||||
: 'border-transparent bg-bg hover:border-surface-5'
|
||||
"
|
||||
@click="preview = user"
|
||||
>
|
||||
<span class="flex min-w-0 items-center gap-2">
|
||||
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand">
|
||||
{{ user.username.slice(0, 1).toUpperCase() }}
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<strong class="block truncate text-xs text-contrast">{{ user.username }}</strong>
|
||||
<span class="block truncate text-[10px] text-secondary">
|
||||
{{ formatMessage(messages.idLabel) }} {{ user.id }} · {{ user.email }}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
<span
|
||||
v-if="unavailableReason(user)"
|
||||
class="shrink-0 rounded-full bg-red/20 px-2 py-0.5 text-[10px] font-semibold text-red"
|
||||
>
|
||||
{{ unavailableReason(user) }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="model?.uuid === user.uuid"
|
||||
class="shrink-0 rounded-full bg-brand px-2 py-0.5 text-[10px] font-semibold text-contrast"
|
||||
>
|
||||
{{ formatMessage(messages.selected) }}
|
||||
</span>
|
||||
</button>
|
||||
<p v-if="!results.length" class="m-0 py-4 text-center text-xs text-secondary">
|
||||
{{ formatMessage(messages.empty) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section
|
||||
v-if="preview"
|
||||
class="flex flex-col gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised p-3"
|
||||
>
|
||||
<div class="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h3 class="m-0 text-sm font-bold text-contrast">{{ preview.username }}</h3>
|
||||
<p class="m-0 text-[11px] text-secondary">
|
||||
{{ formatMessage(messages.idLabel) }} {{ preview.id }}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="preview.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
|
||||
>
|
||||
{{ preview.banned ? 'Banned' : 'OK' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
|
||||
<div class="min-w-0">
|
||||
<span class="block text-secondary">UUID</span>
|
||||
<span class="block break-all font-mono text-contrast">{{ preview.uuid }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-secondary">Email</span>
|
||||
<span class="block break-all text-contrast">{{ preview.email }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-secondary">QQ</span>
|
||||
<span class="block text-contrast">{{ preview.qq ?? '—' }}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="block text-secondary">Registered</span>
|
||||
<span class="block text-contrast">{{ formatTime(preview.registerTime) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1.5">
|
||||
<span
|
||||
v-for="perm in preview.permissions"
|
||||
:key="perm"
|
||||
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
|
||||
>
|
||||
{{ perm }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="unavailableReason(preview)" class="m-0 text-[11px] text-red">
|
||||
{{ unavailableReason(preview) }}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
class="w-full rounded-lg bg-brand px-3 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="Boolean(unavailableReason(preview))"
|
||||
@click="selectPreview"
|
||||
>
|
||||
{{ model?.uuid === preview.uuid ? formatMessage(messages.selected) : formatMessage(messages.select) }}
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -12,8 +12,15 @@ import {
|
||||
skinSiteChatRoomDelete,
|
||||
skinSiteChatRoomList,
|
||||
skinSiteChatRoomUpdate,
|
||||
skinSiteChatAuditList,
|
||||
skinSiteChatManagerSet,
|
||||
skinSiteChatSettlementOwnerAssign,
|
||||
type SkinSiteChatAuditInfo,
|
||||
type SkinSiteChatUserInfo,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
import ChatUserPicker from '../ChatUserPicker.vue'
|
||||
|
||||
import RoomAvatar from '../RoomAvatar.vue'
|
||||
import RoomAvatarEditor from '../RoomAvatarEditor.vue'
|
||||
|
||||
@@ -98,6 +105,59 @@ const messages = defineMessages({
|
||||
id: 'app.skin-site.chat.admin.voice-badge',
|
||||
defaultMessage: 'Voice',
|
||||
},
|
||||
managers: { id: 'app.skin-site.chat.admin.managers', defaultMessage: 'Managers' },
|
||||
managerTitle: {
|
||||
id: 'app.skin-site.chat.admin.manager-title',
|
||||
defaultMessage: 'Room manager',
|
||||
},
|
||||
managerHint: {
|
||||
id: 'app.skin-site.chat.admin.manager-hint',
|
||||
defaultMessage: 'Managers can moderate members, messages and files in this room.',
|
||||
},
|
||||
grant: { id: 'app.skin-site.chat.admin.grant', defaultMessage: 'Grant' },
|
||||
revoke: { id: 'app.skin-site.chat.admin.revoke', defaultMessage: 'Revoke' },
|
||||
managerGranted: {
|
||||
id: 'app.skin-site.chat.admin.manager-granted',
|
||||
defaultMessage: 'Manager granted.',
|
||||
},
|
||||
managerRevoked: {
|
||||
id: 'app.skin-site.chat.admin.manager-revoked',
|
||||
defaultMessage: 'Manager revoked.',
|
||||
},
|
||||
ownerTitle: {
|
||||
id: 'app.skin-site.chat.admin.owner-title',
|
||||
defaultMessage: 'Settlement owner',
|
||||
},
|
||||
ownerHint: {
|
||||
id: 'app.skin-site.chat.admin.owner-hint',
|
||||
defaultMessage: 'Assign the first settlement owner for this room.',
|
||||
},
|
||||
ownerAssigned: {
|
||||
id: 'app.skin-site.chat.admin.owner-assigned',
|
||||
defaultMessage: 'Settlement owner assigned.',
|
||||
},
|
||||
ownerCurrent: {
|
||||
id: 'app.skin-site.chat.admin.owner-current',
|
||||
defaultMessage: 'Current owner: {name}',
|
||||
},
|
||||
ownerNone: {
|
||||
id: 'app.skin-site.chat.admin.owner-none',
|
||||
defaultMessage: 'No settlement owner yet.',
|
||||
},
|
||||
audit: { id: 'app.skin-site.chat.admin.audit', defaultMessage: 'Audit' },
|
||||
auditTitle: {
|
||||
id: 'app.skin-site.chat.admin.audit-title',
|
||||
defaultMessage: 'Room audit',
|
||||
},
|
||||
auditEmpty: {
|
||||
id: 'app.skin-site.chat.admin.audit-empty',
|
||||
defaultMessage: 'No audit records yet.',
|
||||
},
|
||||
auditTarget: {
|
||||
id: 'app.skin-site.chat.admin.audit-target',
|
||||
defaultMessage: 'Target: {name}',
|
||||
},
|
||||
close: { id: 'app.skin-site.chat.admin.close', defaultMessage: 'Close' },
|
||||
})
|
||||
|
||||
type FormState = {
|
||||
@@ -268,6 +328,138 @@ function formatDateTime(ts?: number | null): string {
|
||||
}
|
||||
}
|
||||
|
||||
const managerRoom = ref<SkinSiteChatRoomInfo | null>(null)
|
||||
const managerUser = ref<SkinSiteChatUserInfo | null>(null)
|
||||
const managerGranting = ref(false)
|
||||
|
||||
const ownerRoom = ref<SkinSiteChatRoomInfo | null>(null)
|
||||
const ownerUser = ref<SkinSiteChatUserInfo | null>(null)
|
||||
const ownerSaving = ref(false)
|
||||
|
||||
const auditRoom = ref<SkinSiteChatRoomInfo | null>(null)
|
||||
const auditItems = ref<SkinSiteChatAuditInfo[]>([])
|
||||
const auditLoading = ref(false)
|
||||
|
||||
const AUDIT_LABELS: Record<string, string> = {
|
||||
ROOM_CREATED: '创建房间',
|
||||
ROOM_UPDATED: '修改房间资料',
|
||||
ROOM_AVATAR_UPDATED: '更新房间头像',
|
||||
ROOM_AVATAR_CLEARED: '移除房间头像',
|
||||
MEMBER_JOINED: '成员加入房间',
|
||||
MEMBER_LEFT: '成员退出房间',
|
||||
MEMBER_INVITED: '邀请成员',
|
||||
MEMBER_REMOVED: '踢出成员',
|
||||
MEMBER_BANNED: '踢出并拉黑',
|
||||
MEMBER_UNBANNED: '解除成员封禁',
|
||||
MEMBER_MUTED: '禁言成员',
|
||||
MEMBER_UNMUTED: '解除成员禁言',
|
||||
MANAGER_ADDED: '任命房间管理',
|
||||
MANAGER_REMOVED: '撤销房间管理',
|
||||
BOT_ADDED: '添加机器人',
|
||||
BOT_REMOVED: '移除机器人',
|
||||
MESSAGE_DELETED: '撤回消息',
|
||||
MESSAGE_RECALLED: '撤回消息',
|
||||
MESSAGE_PINNED: '设为精华',
|
||||
MESSAGE_UNPINNED: '取消精华',
|
||||
ANNOUNCEMENT_UPDATED: '更新房间公告',
|
||||
ANNOUNCEMENT_CLEARED: '清空房间公告',
|
||||
FILE_DELETED: '删除房间文件',
|
||||
VOICE_ROOM_UPDATED: '修改房间语音设置',
|
||||
VOICE_CAPACITY_UPDATED: '修改房间语音容量',
|
||||
VOICE_MEMBER_KICKED: '断开成员语音',
|
||||
VOICE_MEMBER_BANNED: '临时封禁成员语音',
|
||||
VOICE_MEMBER_UNBANNED: '解除成员语音封禁',
|
||||
VOICE_SPEAKER_REVOKED: '关闭成员麦克风',
|
||||
VOICE_OBSERVER_JOINED: 'Web管理开始旁听语音',
|
||||
VOICE_OBSERVER_LEFT: 'Web管理结束旁听语音',
|
||||
SETTLEMENT_OWNER_ASSIGNED: '任命聚落主',
|
||||
SETTLEMENT_OWNER_TRANSFERRED: '转让聚落',
|
||||
SETTLEMENT_MANAGER_ADDED: '任命聚落管理',
|
||||
SETTLEMENT_MANAGER_REMOVED: '撤销聚落管理',
|
||||
}
|
||||
|
||||
function auditLabel(action: string): string {
|
||||
return AUDIT_LABELS[action] ?? action
|
||||
}
|
||||
|
||||
function auditActor(name?: string): string {
|
||||
return name?.trim() || '已删除用户'
|
||||
}
|
||||
|
||||
function openManager(room: SkinSiteChatRoomInfo) {
|
||||
managerRoom.value = room
|
||||
managerUser.value = null
|
||||
}
|
||||
|
||||
function closeManager() {
|
||||
managerRoom.value = null
|
||||
managerUser.value = null
|
||||
}
|
||||
|
||||
async function grantManager(manager: boolean) {
|
||||
if (!managerRoom.value || !managerUser.value || managerGranting.value) return
|
||||
managerGranting.value = true
|
||||
try {
|
||||
const res = await skinSiteChatManagerSet(managerRoom.value.id, managerUser.value.uuid, manager)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(manager ? messages.managerGranted : messages.managerRevoked),
|
||||
})
|
||||
closeManager()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
managerGranting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openOwner(room: SkinSiteChatRoomInfo) {
|
||||
ownerRoom.value = room
|
||||
ownerUser.value = null
|
||||
}
|
||||
|
||||
function closeOwner() {
|
||||
ownerRoom.value = null
|
||||
ownerUser.value = null
|
||||
}
|
||||
|
||||
async function assignOwner() {
|
||||
if (!ownerRoom.value || !ownerUser.value || ownerSaving.value) return
|
||||
ownerSaving.value = true
|
||||
try {
|
||||
const res = await skinSiteChatSettlementOwnerAssign(ownerRoom.value.id, ownerUser.value.uuid)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
addNotification({ type: 'success', title: formatMessage(messages.ownerAssigned) })
|
||||
closeOwner()
|
||||
await loadRooms()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
ownerSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function openAudit(room: SkinSiteChatRoomInfo) {
|
||||
auditRoom.value = room
|
||||
auditItems.value = []
|
||||
auditLoading.value = true
|
||||
try {
|
||||
const res = await skinSiteChatAuditList(room.id)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
auditItems.value = res.payload ?? []
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
auditLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function closeAudit() {
|
||||
auditRoom.value = null
|
||||
auditItems.value = []
|
||||
}
|
||||
|
||||
onMounted(() => void loadRooms())
|
||||
</script>
|
||||
|
||||
@@ -371,6 +563,28 @@ onMounted(() => void loadRooms())
|
||||
>
|
||||
{{ perm }}
|
||||
</span>
|
||||
<button
|
||||
v-if="room.kind === 'SETTLEMENT'"
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="openOwner(room)"
|
||||
>
|
||||
{{ formatMessage(messages.ownerTitle) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="openManager(room)"
|
||||
>
|
||||
{{ formatMessage(messages.managers) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="openAudit(room)"
|
||||
>
|
||||
{{ formatMessage(messages.audit) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@@ -510,5 +724,149 @@ onMounted(() => void loadRooms())
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="managerRoom"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="closeManager"
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[88vh] 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.managerTitle) }} · {{ managerRoom.name }}
|
||||
</h3>
|
||||
<p class="m-0 text-xs text-secondary">{{ formatMessage(messages.managerHint) }}</p>
|
||||
<ChatUserPicker
|
||||
v-model="managerUser"
|
||||
:room-id="managerRoom.id"
|
||||
:label="formatMessage(messages.managerTitle)"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 border-t border-solid border-surface-5 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
|
||||
@click="closeManager"
|
||||
>
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-red/20 px-4 py-2 text-sm font-semibold text-red hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="!managerUser || managerGranting"
|
||||
@click="grantManager(false)"
|
||||
>
|
||||
{{ formatMessage(messages.revoke) }}
|
||||
</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="!managerUser || managerGranting"
|
||||
@click="grantManager(true)"
|
||||
>
|
||||
{{ formatMessage(messages.grant) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="ownerRoom"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="closeOwner"
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[88vh] 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.ownerTitle) }} · {{ ownerRoom.name }}
|
||||
</h3>
|
||||
<p class="m-0 text-xs text-secondary">{{ formatMessage(messages.ownerHint) }}</p>
|
||||
<p class="m-0 text-xs text-secondary">
|
||||
{{
|
||||
ownerRoom.settlementOwnerName
|
||||
? formatMessage(messages.ownerCurrent, { name: ownerRoom.settlementOwnerName })
|
||||
: formatMessage(messages.ownerNone)
|
||||
}}
|
||||
</p>
|
||||
<ChatUserPicker
|
||||
v-model="ownerUser"
|
||||
:room-id="ownerRoom.id"
|
||||
:label="formatMessage(messages.ownerTitle)"
|
||||
/>
|
||||
<div class="flex justify-end gap-2 border-t border-solid border-surface-5 pt-3">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
|
||||
@click="closeOwner"
|
||||
>
|
||||
{{ 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="!ownerUser || ownerSaving"
|
||||
@click="assignOwner"
|
||||
>
|
||||
{{ formatMessage(messages.save) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="auditRoom"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="closeAudit"
|
||||
>
|
||||
<div
|
||||
class="flex max-h-[88vh] 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) }} · {{ auditRoom.name }}
|
||||
</h3>
|
||||
<div
|
||||
v-if="auditLoading"
|
||||
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-1.5">
|
||||
<div
|
||||
v-for="entry in auditItems"
|
||||
:key="entry.id"
|
||||
class="flex flex-wrap items-center gap-2 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-2"
|
||||
>
|
||||
<span class="text-xs font-semibold text-contrast">{{ auditLabel(entry.action) }}</span>
|
||||
<span class="text-[11px] text-secondary">{{ auditActor(entry.actorName) }}</span>
|
||||
<span
|
||||
v-if="entry.target"
|
||||
class="text-[11px] text-secondary"
|
||||
>
|
||||
{{ formatMessage(messages.auditTarget, { name: entry.target }) }}
|
||||
</span>
|
||||
<span class="ml-auto text-[10px] text-secondary">
|
||||
{{ formatDateTime(entry.createdAt) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end border-t border-solid border-surface-5 pt-3">
|
||||
<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.close) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -2193,3 +2193,62 @@ export async function skinSiteChatRoomAvatarFetch(roomId: number): Promise<Blob
|
||||
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<SkinSiteResponse<SkinSiteChatUserInfo[]>> {
|
||||
const query = new URLSearchParams({ keyword })
|
||||
if (roomId !== undefined) query.set('room_id', String(roomId))
|
||||
if (searchBy === 'player') query.set('search_by', 'player')
|
||||
return skinSiteRequest<SkinSiteChatUserInfo[]>('chat/users/search?' + query.toString())
|
||||
}
|
||||
|
||||
export function skinSiteChatManagerSet(
|
||||
roomId: number,
|
||||
userUuid: string,
|
||||
manager: boolean,
|
||||
): Promise<SkinSiteResponse<string>> {
|
||||
return skinSiteRequest<string>('chat/admin/rooms/' + roomId + '/manager', {
|
||||
method: 'POST',
|
||||
body: { userUuid, manager },
|
||||
})
|
||||
}
|
||||
|
||||
export function skinSiteChatSettlementOwnerAssign(
|
||||
roomId: number,
|
||||
userUuid: string,
|
||||
): Promise<SkinSiteResponse<string>> {
|
||||
return skinSiteRequest<string>('chat/settlements/' + roomId + '/owner', {
|
||||
method: 'POST',
|
||||
body: { userUuid },
|
||||
})
|
||||
}
|
||||
|
||||
export function skinSiteChatAuditList(
|
||||
roomId: number,
|
||||
): Promise<SkinSiteResponse<SkinSiteChatAuditInfo[]>> {
|
||||
return skinSiteRequest<SkinSiteChatAuditInfo[]>('chat/rooms/' + roomId + '/audit')
|
||||
}
|
||||
@@ -12286,5 +12286,36 @@
|
||||
"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.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"}
|
||||
}
|
||||
@@ -12117,5 +12117,36 @@
|
||||
"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.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": "关闭"}
|
||||
}
|
||||
Reference in new issue
Block a user