Files
Starlight_Lancher/apps/app-frontend/src/components/ui/skin-site/RoomAvatarEditor.vue
T
AxTps ef1435bd31
Sync source to CNB / Sync Git ref (push) Skipped
feat: 新增皮肤站管理台聊天室房间管理
- 房间列表:头像、名称、可见性、类型、语音标记、前置权限、创建时间
- 创建/编辑房间:名称、简介、公开或私有、普通或聚落、前置条件判定与所需权限
- 房间头像:上传、更换、移除,支持 PNG/JPEG/WebP,5 MB 限制
- 删除房间带二次确认
- 新增 RoomAvatar 与 RoomAvatarEditor 组件,头像按附件 id 缓存 Blob URL
- 补充聊天室相关 API:房间列表、创建、更新、删除、头像更新、附件上传、头像获取
- 管理台导航接入聊天室入口
- 中英文各补 37 个文案
2026-10-18 12:13:34 +08:00

162 lines
4.6 KiB
Vue

<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>