- 房间列表:头像、名称、可见性、类型、语音标记、前置权限、创建时间 - 创建/编辑房间:名称、简介、公开或私有、普通或聚落、前置条件判定与所需权限 - 房间头像:上传、更换、移除,支持 PNG/JPEG/WebP,5 MB 限制 - 删除房间带二次确认 - 新增 RoomAvatar 与 RoomAvatarEditor 组件,头像按附件 id 缓存 Blob URL - 补充聊天室相关 API:房间列表、创建、更新、删除、头像更新、附件上传、头像获取 - 管理台导航接入聊天室入口 - 中英文各补 37 个文案
This commit is contained in:
1 parent
87de306245
commit
ef1435bd31
8 files changed
+968
-2
No files matched your search
@@ -0,0 +1,63 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
|
||||
import { loadCachedRoomAvatar } from './room-avatar-cache'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roomId: number
|
||||
avatarAttachmentId?: number | null
|
||||
name: string
|
||||
size?: 'sm' | 'md' | 'lg' | 'xl'
|
||||
}>(),
|
||||
{ avatarAttachmentId: null, size: 'md' },
|
||||
)
|
||||
|
||||
const avatarUrl = ref<string | null>(null)
|
||||
const loading = ref(false)
|
||||
|
||||
const sizeClass = computed(
|
||||
() =>
|
||||
({
|
||||
sm: 'size-9 rounded-lg text-lg',
|
||||
md: 'size-10 rounded-lg text-xl',
|
||||
lg: 'size-12 rounded-lg text-2xl',
|
||||
xl: 'size-20 rounded-xl text-3xl',
|
||||
})[props.size],
|
||||
)
|
||||
|
||||
watch(
|
||||
() => [props.roomId, props.avatarAttachmentId] as const,
|
||||
async ([roomId, attachmentId]) => {
|
||||
avatarUrl.value = null
|
||||
if (!attachmentId) {
|
||||
loading.value = false
|
||||
return
|
||||
}
|
||||
loading.value = true
|
||||
const url = await loadCachedRoomAvatar(roomId, attachmentId)
|
||||
if (props.roomId === roomId && props.avatarAttachmentId === attachmentId) {
|
||||
avatarUrl.value = url
|
||||
}
|
||||
loading.value = false
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="grid shrink-0 place-items-center overflow-hidden border border-solid border-surface-5 bg-brand/10 font-bold text-brand"
|
||||
:class="sizeClass"
|
||||
>
|
||||
<img
|
||||
v-if="avatarUrl"
|
||||
:src="avatarUrl"
|
||||
:alt="name"
|
||||
class="h-full w-full object-cover"
|
||||
decoding="async"
|
||||
/>
|
||||
<span v-else-if="loading" class="text-[10px] text-secondary">…</span>
|
||||
<span v-else class="select-none">#</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,161 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { onBeforeUnmount, ref, watch } from 'vue'
|
||||
|
||||
import { skinSiteChatAttachmentUpload } from '@/helpers/skin-site-api'
|
||||
|
||||
import RoomAvatar from './RoomAvatar.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
roomId?: number
|
||||
avatarAttachmentId?: number | null
|
||||
name: string
|
||||
token?: string | null
|
||||
remove?: boolean
|
||||
}>(),
|
||||
{ roomId: 0, avatarAttachmentId: null, token: null, remove: false },
|
||||
)
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:token', value: string | null): void
|
||||
(e: 'update:remove', value: boolean): void
|
||||
(e: 'update:uploading', value: boolean): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
label: { id: 'app.skin-site.chat.avatar.label', defaultMessage: 'Room avatar' },
|
||||
upload: { id: 'app.skin-site.chat.avatar.upload', defaultMessage: 'Upload avatar' },
|
||||
replace: { id: 'app.skin-site.chat.avatar.replace', defaultMessage: 'Replace avatar' },
|
||||
remove: { id: 'app.skin-site.chat.avatar.remove', defaultMessage: 'Remove avatar' },
|
||||
hint: {
|
||||
id: 'app.skin-site.chat.avatar.hint',
|
||||
defaultMessage: 'PNG, JPEG or WebP, up to 8192 px per side and 5 MB.',
|
||||
},
|
||||
badFormat: {
|
||||
id: 'app.skin-site.chat.avatar.bad-format',
|
||||
defaultMessage: 'Only PNG, JPEG and WebP are supported.',
|
||||
},
|
||||
tooLarge: {
|
||||
id: 'app.skin-site.chat.avatar.too-large',
|
||||
defaultMessage: 'Avatar must not exceed 5 MB.',
|
||||
},
|
||||
notImage: {
|
||||
id: 'app.skin-site.chat.avatar.not-image',
|
||||
defaultMessage: 'Avatar must be an image.',
|
||||
},
|
||||
})
|
||||
|
||||
const MAX_BYTES = 5 * 1024 * 1024
|
||||
const ALLOWED = ['png', 'jpg', 'jpeg', 'webp']
|
||||
|
||||
const uploading = ref(false)
|
||||
const previewUrl = ref<string | null>(null)
|
||||
|
||||
watch(
|
||||
() => props.token,
|
||||
(token) => {
|
||||
if (!token) revokePreview()
|
||||
},
|
||||
)
|
||||
|
||||
function revokePreview() {
|
||||
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
|
||||
previewUrl.value = null
|
||||
}
|
||||
|
||||
async function selectAvatar(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (!file) return
|
||||
|
||||
const ext = file.name.split('.').pop()?.toLowerCase()
|
||||
if (!ext || !ALLOWED.includes(ext)) {
|
||||
addNotification({ type: 'error', title: formatMessage(messages.badFormat) })
|
||||
return
|
||||
}
|
||||
if (file.size > MAX_BYTES) {
|
||||
addNotification({ type: 'error', title: formatMessage(messages.tooLarge) })
|
||||
return
|
||||
}
|
||||
|
||||
uploading.value = true
|
||||
emit('update:uploading', true)
|
||||
try {
|
||||
const res = await skinSiteChatAttachmentUpload(file)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
if (res.payload.attachment.kind !== 'IMAGE') {
|
||||
addNotification({ type: 'error', title: formatMessage(messages.notImage) })
|
||||
return
|
||||
}
|
||||
revokePreview()
|
||||
previewUrl.value = URL.createObjectURL(file)
|
||||
emit('update:token', res.payload.token)
|
||||
emit('update:remove', false)
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
emit('update:uploading', false)
|
||||
}
|
||||
}
|
||||
|
||||
function removeAvatar() {
|
||||
revokePreview()
|
||||
emit('update:token', null)
|
||||
emit('update:remove', true)
|
||||
}
|
||||
|
||||
onBeforeUnmount(revokePreview)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.label) }}
|
||||
</span>
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<div
|
||||
v-if="previewUrl"
|
||||
class="size-20 overflow-hidden rounded-xl border border-solid border-surface-5 bg-bg-raised"
|
||||
>
|
||||
<img :src="previewUrl" alt="" class="h-full w-full object-cover" />
|
||||
</div>
|
||||
<RoomAvatar
|
||||
v-else
|
||||
:room-id="roomId"
|
||||
:avatar-attachment-id="remove ? null : avatarAttachmentId"
|
||||
:name="name || 'Room'"
|
||||
size="xl"
|
||||
/>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<label
|
||||
class="cursor-pointer rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
:class="uploading ? 'pointer-events-none opacity-50' : ''"
|
||||
>
|
||||
{{ uploading ? '…' : (avatarAttachmentId || previewUrl ? formatMessage(messages.replace) : formatMessage(messages.upload)) }}
|
||||
<input
|
||||
type="file"
|
||||
class="hidden"
|
||||
accept=".png,.jpg,.jpeg,.webp"
|
||||
@change="selectAvatar"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
v-if="previewUrl || (avatarAttachmentId && !remove)"
|
||||
type="button"
|
||||
class="rounded-lg bg-red/20 px-3 py-1.5 text-xs font-semibold text-red hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="uploading"
|
||||
@click="removeAvatar"
|
||||
>
|
||||
{{ formatMessage(messages.remove) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="m-0 text-[11px] text-secondary">{{ formatMessage(messages.hint) }}</p>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import { skinSiteUser } from '@/composables/skin-site-session'
|
||||
|
||||
import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue'
|
||||
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
|
||||
import AdminChatPage from './admin/AdminChatPage.vue'
|
||||
import AdminDonationsPage from './admin/AdminDonationsPage.vue'
|
||||
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
|
||||
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
|
||||
@@ -177,6 +178,7 @@ const current = ref<AdminKey>('summary')
|
||||
<AdminServersPage v-else-if="current === 'servers' && hasWebAdmin" />
|
||||
<AdminAccessAuditPage v-else-if="current === 'access-audit'" />
|
||||
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
|
||||
<AdminChatPage v-else-if="current === 'chat'" />
|
||||
<AdminUsersPage v-else-if="current === 'users'" />
|
||||
<AdminPlayersPage v-else-if="current === 'players'" />
|
||||
<AdminReviewPage v-else-if="current === 'review'" />
|
||||
|
||||
@@ -0,0 +1,514 @@
|
||||
<script setup lang="ts">
|
||||
import { SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
type SkinSiteChatPrerequisiteMode,
|
||||
type SkinSiteChatRoomInfo,
|
||||
type SkinSiteChatRoomVisibility,
|
||||
skinSiteChatRoomAvatarUpdate,
|
||||
skinSiteChatRoomCreate,
|
||||
skinSiteChatRoomDelete,
|
||||
skinSiteChatRoomList,
|
||||
skinSiteChatRoomUpdate,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
import RoomAvatar from '../RoomAvatar.vue'
|
||||
import RoomAvatarEditor from '../RoomAvatarEditor.vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError, addNotification } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.chat.admin.title', defaultMessage: 'Chat rooms' },
|
||||
subtitle: {
|
||||
id: 'app.skin-site.chat.admin.subtitle',
|
||||
defaultMessage: 'Create public or private rooms and hand daily moderation to trusted members.',
|
||||
},
|
||||
createRoom: {
|
||||
id: 'app.skin-site.chat.admin.create-room',
|
||||
defaultMessage: 'Create room',
|
||||
},
|
||||
loading: { id: 'app.skin-site.chat.admin.loading', defaultMessage: 'Loading…' },
|
||||
failed: { id: 'app.skin-site.chat.admin.failed', defaultMessage: 'Could not load rooms.' },
|
||||
empty: { id: 'app.skin-site.chat.admin.empty', defaultMessage: 'No chat rooms yet.' },
|
||||
edit: { id: 'app.skin-site.chat.admin.edit', defaultMessage: 'Edit' },
|
||||
remove: { id: 'app.skin-site.chat.admin.remove', defaultMessage: 'Delete' },
|
||||
save: { id: 'app.skin-site.chat.admin.save', defaultMessage: 'Save' },
|
||||
cancel: { id: 'app.skin-site.chat.admin.cancel', defaultMessage: 'Cancel' },
|
||||
name: { id: 'app.skin-site.chat.admin.name', defaultMessage: 'Name' },
|
||||
description: {
|
||||
id: 'app.skin-site.chat.admin.description',
|
||||
defaultMessage: 'Description',
|
||||
},
|
||||
visibility: {
|
||||
id: 'app.skin-site.chat.admin.visibility',
|
||||
defaultMessage: 'Visibility',
|
||||
},
|
||||
visibilityPublic: {
|
||||
id: 'app.skin-site.chat.admin.visibility-public',
|
||||
defaultMessage: 'Public',
|
||||
},
|
||||
visibilityPrivate: {
|
||||
id: 'app.skin-site.chat.admin.visibility-private',
|
||||
defaultMessage: 'Private',
|
||||
},
|
||||
kind: { id: 'app.skin-site.chat.admin.kind', defaultMessage: 'Kind' },
|
||||
kindStandard: {
|
||||
id: 'app.skin-site.chat.admin.kind-standard',
|
||||
defaultMessage: 'Standard',
|
||||
},
|
||||
kindSettlement: {
|
||||
id: 'app.skin-site.chat.admin.kind-settlement',
|
||||
defaultMessage: 'Settlement',
|
||||
},
|
||||
prerequisiteMode: {
|
||||
id: 'app.skin-site.chat.admin.prerequisite-mode',
|
||||
defaultMessage: 'Prerequisite mode',
|
||||
},
|
||||
prerequisiteAny: {
|
||||
id: 'app.skin-site.chat.admin.prerequisite-any',
|
||||
defaultMessage: 'Any',
|
||||
},
|
||||
prerequisiteAll: {
|
||||
id: 'app.skin-site.chat.admin.prerequisite-all',
|
||||
defaultMessage: 'All',
|
||||
},
|
||||
prerequisitePermissions: {
|
||||
id: 'app.skin-site.chat.admin.prerequisite-permissions',
|
||||
defaultMessage: 'Required permissions',
|
||||
},
|
||||
prerequisiteHint: {
|
||||
id: 'app.skin-site.chat.admin.prerequisite-hint',
|
||||
defaultMessage: 'Comma separated. Leave empty for no restriction.',
|
||||
},
|
||||
created: { id: 'app.skin-site.chat.admin.created', defaultMessage: 'Room created.' },
|
||||
updated: { id: 'app.skin-site.chat.admin.updated', defaultMessage: 'Room updated.' },
|
||||
deleted: { id: 'app.skin-site.chat.admin.deleted', defaultMessage: 'Room deleted.' },
|
||||
confirmDelete: {
|
||||
id: 'app.skin-site.chat.admin.confirm-delete',
|
||||
defaultMessage: 'Permanently delete room “{name}” and all its messages?',
|
||||
},
|
||||
managerBadge: {
|
||||
id: 'app.skin-site.chat.admin.manager-badge',
|
||||
defaultMessage: 'Manager',
|
||||
},
|
||||
voiceBadge: {
|
||||
id: 'app.skin-site.chat.admin.voice-badge',
|
||||
defaultMessage: 'Voice',
|
||||
},
|
||||
})
|
||||
|
||||
type FormState = {
|
||||
name: string
|
||||
description: string
|
||||
visibility: SkinSiteChatRoomVisibility
|
||||
kind: 'STANDARD' | 'SETTLEMENT'
|
||||
prerequisiteMode: SkinSiteChatPrerequisiteMode
|
||||
prerequisitePermissions: string
|
||||
}
|
||||
|
||||
function emptyForm(): FormState {
|
||||
return {
|
||||
name: '',
|
||||
description: '',
|
||||
visibility: 'PUBLIC',
|
||||
kind: 'STANDARD',
|
||||
prerequisiteMode: 'ANY',
|
||||
prerequisitePermissions: '',
|
||||
}
|
||||
}
|
||||
|
||||
const rooms = ref<SkinSiteChatRoomInfo[]>([])
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
|
||||
const editorOpen = ref(false)
|
||||
const editingRoom = ref<SkinSiteChatRoomInfo | null>(null)
|
||||
const form = ref<FormState>(emptyForm())
|
||||
const saving = ref(false)
|
||||
|
||||
const avatarToken = ref<string | null>(null)
|
||||
const avatarRemove = ref(false)
|
||||
const avatarUploading = ref(false)
|
||||
|
||||
const deletingId = ref<number | null>(null)
|
||||
|
||||
const editorTitle = computed(() =>
|
||||
editingRoom.value
|
||||
? formatMessage(messages.edit)
|
||||
: formatMessage(messages.createRoom),
|
||||
)
|
||||
|
||||
function permissionsToText(list: string[]): string {
|
||||
return list.join(', ')
|
||||
}
|
||||
|
||||
function textToPermissions(text: string): string[] {
|
||||
return text
|
||||
.split(',')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function resetAvatarDraft() {
|
||||
avatarToken.value = null
|
||||
avatarRemove.value = false
|
||||
avatarUploading.value = false
|
||||
}
|
||||
|
||||
async function loadRooms() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const res = await skinSiteChatRoomList()
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
rooms.value = res.payload ?? []
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingRoom.value = null
|
||||
form.value = emptyForm()
|
||||
resetAvatarDraft()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function openEdit(room: SkinSiteChatRoomInfo) {
|
||||
editingRoom.value = room
|
||||
form.value = {
|
||||
name: room.name,
|
||||
description: room.description ?? '',
|
||||
visibility: room.visibility,
|
||||
kind: room.kind,
|
||||
prerequisiteMode: room.prerequisiteMode,
|
||||
prerequisitePermissions: permissionsToText(room.prerequisitePermissions ?? []),
|
||||
}
|
||||
resetAvatarDraft()
|
||||
editorOpen.value = true
|
||||
}
|
||||
|
||||
function closeEditor() {
|
||||
editorOpen.value = false
|
||||
editingRoom.value = null
|
||||
resetAvatarDraft()
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (saving.value) return
|
||||
if (!form.value.name.trim()) return
|
||||
saving.value = true
|
||||
const creating = editingRoom.value === null
|
||||
try {
|
||||
const payload = {
|
||||
name: form.value.name.trim(),
|
||||
description: form.value.description.trim(),
|
||||
visibility: form.value.visibility,
|
||||
prerequisiteMode: form.value.prerequisiteMode,
|
||||
prerequisitePermissions: textToPermissions(form.value.prerequisitePermissions),
|
||||
}
|
||||
const res = creating
|
||||
? await skinSiteChatRoomCreate({ ...payload, kind: form.value.kind })
|
||||
: await skinSiteChatRoomUpdate(editingRoom.value!.id, payload)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
|
||||
if (avatarToken.value || avatarRemove.value) {
|
||||
const avatarRes = await skinSiteChatRoomAvatarUpdate(res.payload.id, {
|
||||
imageToken: avatarToken.value ?? undefined,
|
||||
removeImage: avatarRemove.value,
|
||||
})
|
||||
if (!avatarRes.success) {
|
||||
addNotification({ type: 'error', title: avatarRes.errorMessage })
|
||||
await loadRooms()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
addNotification({
|
||||
type: 'success',
|
||||
title: formatMessage(creating ? messages.created : messages.updated),
|
||||
})
|
||||
closeEditor()
|
||||
await loadRooms()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function removeRoom(room: SkinSiteChatRoomInfo) {
|
||||
if (deletingId.value !== null) return
|
||||
if (!window.confirm(formatMessage(messages.confirmDelete, { name: room.name }))) return
|
||||
deletingId.value = room.id
|
||||
try {
|
||||
const res = await skinSiteChatRoomDelete(room.id)
|
||||
if (!res.success) throw new Error(res.errorMessage)
|
||||
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
|
||||
await loadRooms()
|
||||
} catch (error) {
|
||||
handleError(error)
|
||||
} finally {
|
||||
deletingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
function formatDateTime(ts?: number | null): string {
|
||||
if (!ts) return '—'
|
||||
try {
|
||||
return new Date(ts).toLocaleString()
|
||||
} catch {
|
||||
return '—'
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => void loadRooms())
|
||||
</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 max-w-xl text-sm text-secondary">
|
||||
{{ formatMessage(messages.subtitle) }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
|
||||
@click="openCreate"
|
||||
>
|
||||
{{ formatMessage(messages.createRoom) }}
|
||||
</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="!rooms.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>
|
||||
<div v-else class="grid gap-3 lg:grid-cols-2">
|
||||
<article
|
||||
v-for="room in rooms"
|
||||
:key="room.id"
|
||||
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
|
||||
>
|
||||
<div class="flex items-start gap-3">
|
||||
<RoomAvatar
|
||||
:room-id="room.id"
|
||||
:avatar-attachment-id="room.avatarAttachmentId"
|
||||
:name="room.name"
|
||||
size="lg"
|
||||
/>
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<h3 class="m-0 truncate text-base font-bold text-contrast">{{ room.name }}</h3>
|
||||
<span
|
||||
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="
|
||||
room.visibility === 'PUBLIC'
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-button-bg text-secondary'
|
||||
"
|
||||
>
|
||||
{{
|
||||
room.visibility === 'PUBLIC'
|
||||
? formatMessage(messages.visibilityPublic)
|
||||
: formatMessage(messages.visibilityPrivate)
|
||||
}}
|
||||
</span>
|
||||
<span
|
||||
v-if="room.kind === 'SETTLEMENT'"
|
||||
class="rounded-full bg-yellow/20 px-2 py-0.5 text-[10px] font-semibold text-yellow"
|
||||
>
|
||||
{{ formatMessage(messages.kindSettlement) }}
|
||||
</span>
|
||||
<span
|
||||
v-if="room.voiceEnabled"
|
||||
class="rounded-full bg-green/20 px-2 py-0.5 text-[10px] font-semibold text-green"
|
||||
>
|
||||
{{ formatMessage(messages.voiceBadge) }}
|
||||
</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="room.description"
|
||||
class="m-0 mt-1 line-clamp-2 text-xs whitespace-pre-wrap text-secondary"
|
||||
>
|
||||
{{ room.description }}
|
||||
</p>
|
||||
<p class="m-0 mt-1 text-[11px] text-secondary">
|
||||
{{ formatDateTime(room.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
v-for="perm in room.prerequisitePermissions"
|
||||
:key="perm"
|
||||
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
|
||||
>
|
||||
{{ perm }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="ml-auto rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="openEdit(room)"
|
||||
>
|
||||
{{ formatMessage(messages.edit) }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-lg bg-red/20 px-3 py-1 text-xs font-semibold text-red hover:brightness-110 disabled:opacity-50"
|
||||
:disabled="deletingId === room.id"
|
||||
@click="removeRoom(room)"
|
||||
>
|
||||
{{ formatMessage(messages.remove) }}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="editorOpen"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4"
|
||||
@click.self="closeEditor"
|
||||
>
|
||||
<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">{{ editorTitle }}</h3>
|
||||
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.name) }}
|
||||
</span>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
maxlength="80"
|
||||
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.description) }}
|
||||
</span>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
rows="3"
|
||||
maxlength="500"
|
||||
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"
|
||||
></textarea>
|
||||
</label>
|
||||
|
||||
<div class="grid gap-3 sm:grid-cols-2">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.visibility) }}
|
||||
</span>
|
||||
<select
|
||||
v-model="form.visibility"
|
||||
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"
|
||||
>
|
||||
<option value="PUBLIC">{{ formatMessage(messages.visibilityPublic) }}</option>
|
||||
<option value="PRIVATE">{{ formatMessage(messages.visibilityPrivate) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="!editingRoom" class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.kind) }}
|
||||
</span>
|
||||
<select
|
||||
v-model="form.kind"
|
||||
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"
|
||||
>
|
||||
<option value="STANDARD">{{ formatMessage(messages.kindStandard) }}</option>
|
||||
<option value="SETTLEMENT">{{ formatMessage(messages.kindSettlement) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<RoomAvatarEditor
|
||||
v-model:token="avatarToken"
|
||||
v-model:remove="avatarRemove"
|
||||
v-model:uploading="avatarUploading"
|
||||
:room-id="editingRoom?.id ?? 0"
|
||||
:avatar-attachment-id="editingRoom?.avatarAttachmentId ?? null"
|
||||
:name="form.name"
|
||||
/>
|
||||
|
||||
<div class="flex flex-col gap-2 rounded-lg border border-solid border-surface-5 p-3">
|
||||
<div class="flex flex-wrap items-end gap-3">
|
||||
<label class="flex flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.prerequisiteMode) }}
|
||||
</span>
|
||||
<select
|
||||
v-model="form.prerequisiteMode"
|
||||
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
|
||||
>
|
||||
<option value="ANY">{{ formatMessage(messages.prerequisiteAny) }}</option>
|
||||
<option value="ALL">{{ formatMessage(messages.prerequisiteAll) }}</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="flex flex-1 flex-col gap-1">
|
||||
<span class="text-xs font-semibold text-secondary">
|
||||
{{ formatMessage(messages.prerequisitePermissions) }}
|
||||
</span>
|
||||
<input
|
||||
v-model="form.prerequisitePermissions"
|
||||
type="text"
|
||||
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<p class="m-0 text-[11px] text-secondary">
|
||||
{{ formatMessage(messages.prerequisiteHint) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<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 disabled:opacity-50"
|
||||
:disabled="saving"
|
||||
@click="closeEditor"
|
||||
>
|
||||
{{ 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 || !form.name.trim() || avatarUploading"
|
||||
@click="submit"
|
||||
>
|
||||
{{ formatMessage(messages.save) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,30 @@
|
||||
import { skinSiteChatRoomAvatarFetch } from '@/helpers/skin-site-api'
|
||||
|
||||
const avatarUrls = new Map<number, string>()
|
||||
const pending = new Map<number, Promise<string | null>>()
|
||||
|
||||
export function loadCachedRoomAvatar(
|
||||
roomId: number,
|
||||
attachmentId: number,
|
||||
): Promise<string | null> {
|
||||
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)
|
||||
}
|
||||
@@ -2071,3 +2071,125 @@ export function skinSiteUpdateMultiModInfo(
|
||||
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<SkinSiteResponse<SkinSiteChatRoomInfo[]>> {
|
||||
return skinSiteRequest<SkinSiteChatRoomInfo[]>('chat/admin/rooms')
|
||||
}
|
||||
|
||||
export function skinSiteChatRoomCreate(
|
||||
data: SkinSiteChatRoomCreateInput,
|
||||
): Promise<SkinSiteResponse<SkinSiteChatRoomInfo>> {
|
||||
return skinSiteRequest<SkinSiteChatRoomInfo>('chat/admin/rooms', {
|
||||
method: 'POST',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function skinSiteChatRoomUpdate(
|
||||
id: number,
|
||||
data: SkinSiteChatRoomUpdateInput,
|
||||
): Promise<SkinSiteResponse<SkinSiteChatRoomInfo>> {
|
||||
return skinSiteRequest<SkinSiteChatRoomInfo>('chat/rooms/' + id, {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function skinSiteChatRoomDelete(id: number): Promise<SkinSiteResponse<string>> {
|
||||
return skinSiteRequest<string>('chat/admin/rooms/' + id, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
export function skinSiteChatRoomAvatarUpdate(
|
||||
roomId: number,
|
||||
data: { imageToken?: string; removeImage?: boolean },
|
||||
): Promise<SkinSiteResponse<SkinSiteChatRoomInfo>> {
|
||||
return skinSiteRequest<SkinSiteChatRoomInfo>('chat/rooms/' + roomId + '/avatar', {
|
||||
method: 'PUT',
|
||||
body: data,
|
||||
})
|
||||
}
|
||||
|
||||
export function skinSiteChatAttachmentUpload(
|
||||
file: File,
|
||||
): Promise<SkinSiteResponse<SkinSiteChatUploadInfo>> {
|
||||
const form = new FormData()
|
||||
form.append('file', file, file.name)
|
||||
return skinSiteRequest<SkinSiteChatUploadInfo>('chat/attachments', {
|
||||
method: 'POST',
|
||||
form,
|
||||
})
|
||||
}
|
||||
|
||||
export async function skinSiteChatRoomAvatarFetch(roomId: number): Promise<Blob | null> {
|
||||
const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/chat/rooms/${roomId}/avatar`
|
||||
const headers: Record<string, string> = { 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
|
||||
}
|
||||
}
|
||||
@@ -12249,5 +12249,42 @@
|
||||
"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.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"}
|
||||
}
|
||||
@@ -12080,5 +12080,42 @@
|
||||
"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.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": "语音"}
|
||||
}
|
||||
Reference in new issue
Block a user