fix: 复用皮肤站逻辑并稳定玩家头像
Some checks failed
Axolotl desktop CI / guardrails (push) Has been cancelled
Axolotl desktop CI / desktop (macos-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (ubuntu-latest) (push) Has been cancelled
Axolotl desktop CI / desktop (windows-latest) (push) Has been cancelled
Axolotl desktop CI / website (push) Has been cancelled
Repository checks / typos (push) Has been cancelled
Repository checks / tombi (push) Has been cancelled
Rust checks / shear (push) Has been cancelled
Sync source to CNB / Sync Git ref (push) Has been cancelled

This commit is contained in:
2026-09-14 23:06:03 +08:00
parent 2a1dfa3c42
commit 0b30fc96a0
17 changed files with 1063 additions and 138 deletions

View File

@ -35,24 +35,44 @@
/>
</div>
<div v-if="skinSitePlayers.length > 0" class="border-0 border-t border-solid border-surface-5">
<div
<button
v-for="player in skinSitePlayers"
:key="player.uuid"
class="flex items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 last:border-b-0"
type="button"
class="flex w-full cursor-pointer items-center gap-2 border-0 border-b border-solid border-surface-5 px-3 py-2 text-left transition-colors last:border-b-0 hover:bg-button-hover"
:class="selectedSkinSitePlayerId === player.uuid ? 'bg-button-hover' : 'bg-transparent'"
:aria-pressed="selectedSkinSitePlayerId === player.uuid"
@click="setSkinSitePlayer(player.uuid)"
>
<Avatar :src="defaultSteveHeadUrl" size="24px" pixelated :unframed-natural-width="72" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm text-primary">{{ player.name }}</span>
<span class="truncate text-xs text-secondary">{{ player.uuid }}</span>
</div>
<span class="shrink-0 text-xs text-secondary">
{{
launcherAccountIds.has(player.uuid)
? formatMessage(messages.skinSitePlayerReady)
: formatMessage(messages.skinSitePlayerSynced)
}}
</span>
</div>
<Avatar
v-if="player.skinState === 'ready' && player.headDataUrl"
:src="player.headDataUrl"
size="32px"
pixelated
:unframed-natural-width="36"
/>
<div
v-else
class="h-8 w-8 shrink-0 rounded-sm border-2 border-dashed border-secondary/45 bg-surface-5/50 text-secondary"
style="
background-image:
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor),
linear-gradient(currentColor, currentColor);
background-position:
6px 8px,
22px 8px,
8px 20px;
background-repeat: no-repeat;
background-size:
4px 4px,
4px 4px,
16px 2px;
"
aria-hidden="true"
/>
<span class="min-w-0 flex-1 truncate text-sm text-primary">{{ player.name }}</span>
</button>
</div>
<p v-else-if="skinSitePlayersStatus === 'ready'" class="m-0 px-3 pb-3 text-sm text-secondary">
{{ formatMessage(messages.noSkinSitePlayers) }}
@ -250,11 +270,12 @@ import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import axolotlLogo from '@/assets/netherstar.png'
import steveSkinTexture from '@/assets/skins/steve.png?inline'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
selectedSkinSitePlayerId,
selectSkinSitePlayer,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
@ -347,25 +368,14 @@ let refreshGeneration = 0
let headRefreshTimer: ReturnType<typeof setTimeout> | undefined
let defaultUserUpdateQueue = Promise.resolve()
const minecraftLoginModal = ref<InstanceType<typeof MinecraftLoginModal> | null>(null)
const launcherAccountIds = computed(() => new Set(accounts.value.map((account) => account.profile.id)))
async function refreshSkinSitePlayers() {
await requestSkinSitePlayers().catch(() => {})
}
function createSkinHeadDataUrl(textureUrl: string) {
const escapedTextureUrl = textureUrl
.replaceAll('&', '&amp;')
.replaceAll('"', '&quot;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 8 8" shape-rendering="crispEdges"><image href="${escapedTextureUrl}" x="-8" y="-8" width="64" height="64" style="image-rendering:pixelated"/><image href="${escapedTextureUrl}" x="-40" y="-8" width="64" height="64" style="image-rendering:pixelated"/></svg>`
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(svg)}`
function setSkinSitePlayer(playerId: string) {
selectSkinSitePlayer(playerId)
}
const defaultSteveHeadUrl = createSkinHeadDataUrl(steveSkinTexture)
const HEAD_REFRESH_RETRY_DELAYS = [1500, 5000, 15000, 30000] as const
const HEAD_REFRESH_CONTINUOUS_DELAY = 60_000
@ -609,6 +619,7 @@ function persistDefaultUser(userId: string) {
async function setAccount(account: MinecraftCredential) {
const userId = account.profile.id
refreshGeneration += 1
selectSkinSitePlayer(null)
defaultUser.value = userId
equippedSkin.value = null
@ -618,6 +629,16 @@ async function setAccount(account: MinecraftCredential) {
if (defaultUser.value === userId) notifyAccountChange()
}
watch(
[skinSitePlayers, defaultUser],
([availablePlayers, selectedLocalUser]) => {
if (!selectedLocalUser && !selectedSkinSitePlayerId.value && availablePlayers.length > 0) {
selectSkinSitePlayer(availablePlayers[0].uuid)
}
},
{ immediate: true },
)
async function login() {
if (offline.value) return
loginDisabled.value = true
@ -708,14 +729,6 @@ const messages = defineMessages({
id: 'minecraft-account.skin-site.players.error',
defaultMessage: 'Could not refresh the player list. Previously loaded players are kept.',
},
skinSitePlayerReady: {
id: 'minecraft-account.skin-site.player.ready',
defaultMessage: 'Ready to launch',
},
skinSitePlayerSynced: {
id: 'minecraft-account.skin-site.player.synced',
defaultMessage: 'Synced from skin site',
},
offlineMode: {
id: 'minecraft-account.offline-mode',
defaultMessage: 'Offline mode',

View File

@ -1,14 +1,7 @@
<script setup lang="ts">
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, onUnmounted, ref } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
skinSiteFrameUrl,
} from '@/composables/skin-site-session'
import { skinSiteFrameUrl } from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
@ -18,42 +11,12 @@ const messages = defineMessages({
},
})
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
function connect() {
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
</script>
<template>
<iframe
ref="frame"
:src="skinSiteFrameUrl"
:title="formatMessage(messages.frameTitle)"
class="block h-full min-h-0 w-full border-0"
@load="connect"
/>
</template>

View File

@ -0,0 +1,51 @@
<script setup lang="ts">
import { onMounted, onUnmounted, ref } from 'vue'
import {
receiveSkinSiteMessage,
resetSkinSiteSession,
setSkinSiteFrame,
SKIN_SITE_ORIGIN,
} from '@/composables/skin-site-session'
const frame = ref<HTMLIFrameElement>()
let lastMessage = 0
let expiryTimer: ReturnType<typeof setInterval> | undefined
function connect() {
const contentWindow = frame.value?.contentWindow ?? null
setSkinSiteFrame(contentWindow)
contentWindow?.postMessage({ type: 'starlight-skin-session-connect' }, SKIN_SITE_ORIGIN)
}
function receive(event: MessageEvent) {
if (receiveSkinSiteMessage(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
}
onMounted(() => {
window.addEventListener('message', receive)
connect()
expiryTimer = setInterval(() => {
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
}, 10_000)
})
onUnmounted(() => {
window.removeEventListener('message', receive)
clearInterval(expiryTimer)
setSkinSiteFrame(null)
resetSkinSiteSession()
})
</script>
<template>
<iframe
ref="frame"
:src="`${SKIN_SITE_ORIGIN}/`"
title="StarLight Skin Site session"
aria-hidden="true"
tabindex="-1"
class="pointer-events-none fixed -left-[10000px] top-0 h-px w-px opacity-0"
@load="connect"
/>
</template>

View File

@ -84,6 +84,7 @@ const props = defineProps<{
isSkinActive: (skin: Skin) => boolean
isAddSkinButtonDragActive: boolean
readOnly?: boolean
manageSavedSkins?: boolean
activeTab?: 'saved' | 'default'
}>()
@ -182,7 +183,9 @@ const sections = computed<SkinSection[]>(() => {
const draggableSavedSkins = ref<Skin[]>([])
const isDraggingSavedSkin = ref(false)
const canReorderSavedSkins = computed(() => draggableSavedSkins.value.length > 1)
const canReorderSavedSkins = computed(
() => props.manageSavedSkins !== false && draggableSavedSkins.value.length > 1,
)
const fixedSavedSkins = computed(() => props.savedSkins.filter((skin) => !canDragSavedSkin(skin)))
const sectionLayouts = computed(() => {
@ -486,7 +489,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@ -526,7 +529,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template v-if="!readOnly" #overlay-buttons>
<template v-if="!readOnly && manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"
@ -568,7 +571,7 @@ defineExpose({ getAddSkinButtonElement })
:is-dragging="isDraggingSavedSkin"
@select="emit('select', skin)"
>
<template #overlay-buttons>
<template v-if="manageSavedSkins !== false" #overlay-buttons>
<ButtonStyled color="brand">
<button
:aria-label="formatMessage(messages.editSkinButton)"