fix: 完善整合包同步与启动器交互
Some checks failed
Axolotl desktop CI / guardrails (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
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
Sync LobeHub models / sync (push) Has been cancelled

This commit is contained in:
2026-09-16 17:01:22 +08:00
parent bd09fed0ed
commit 51fb0c30f7
45 changed files with 1804 additions and 219 deletions

View File

@ -0,0 +1,240 @@
<script setup lang="ts">
import { ButtonStyled, NewModal, useVIntl } from '@modrinth/ui'
import { computed, onUnmounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
import {
openSkinSiteLogin,
requestSkinSitePlayers,
skinSitePlayers,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
import { users } from '@/helpers/auth'
import { getInstanceMode } from '@/helpers/hosted-packs'
import {
registerInstancePlayerPicker,
saveInstancePlayer,
waitForSkinSiteSession,
type InstancePlayer,
type PlayerChoice,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const modal = ref<InstanceType<typeof NewModal>>()
const microsoftLogin = ref<InstanceType<typeof MinecraftLoginModal>>()
const router = useRouter()
const accounts = ref<PlayerChoice[]>([])
const active = ref(false)
const loading = ref(false)
const saving = ref(false)
const error = ref('')
const instanceId = ref('')
const locked = ref<InstancePlayer | null>(null)
const awaitingSkinLogin = ref(false)
let hideForLogin = false
let generation = 0
let resolveSelection: ((player: InstancePlayer) => void) | undefined
let rejectSelection: ((reason: Error) => void) | undefined
const choices = computed(() => {
const skin: PlayerChoice[] =
skinSiteStatus.value === 'signed-in'
? skinSitePlayers.value
.filter((player) => !player.isMojang)
.map((player) => ({
id: player.uuid,
name: player.name,
account_type: 'yggdrasil',
skin_site_user: skinSiteUser.value?.uuid,
head: player.headDataUrl,
}))
: []
return [...skin, ...accounts.value].filter(
(player) =>
!locked.value ||
(player.id.replaceAll('-', '') === locked.value.id.replaceAll('-', '') &&
player.account_type === locked.value.account_type &&
(player.skin_site_user ?? null) === (locked.value.skin_site_user ?? null)),
)
})
async function refresh() {
const revision = ++generation
loading.value = true
error.value = ''
try {
const [available, mode] = await Promise.all([users(), getInstanceMode(instanceId.value)])
if (revision !== generation) return
accounts.value = available
.filter(
(account) =>
account.account_type === 'microsoft' ||
(mode === 'local' && account.account_type === 'offline'),
)
.map((account) => ({
id: account.profile.id,
name: account.profile.name,
account_type: account.account_type,
}))
await waitForSkinSiteSession()
if (revision !== generation) return
if (skinSiteStatus.value === 'signed-in') await requestSkinSitePlayers()
if (skinSiteStatus.value === 'error') throw new Error('无法确认皮肤站登录状态,请重试。')
} catch (cause) {
if (revision === generation)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (revision === generation) loading.value = false
}
}
function cancelled() {
if (hideForLogin) {
hideForLogin = false
return
}
if (!active.value) return
active.value = false
awaitingSkinLogin.value = false
generation++
rejectSelection?.(new Error('已取消选择实例玩家'))
resolveSelection = undefined
rejectSelection = undefined
}
async function select(player: PlayerChoice) {
if (loading.value || saving.value || !active.value) return
saving.value = true
error.value = ''
try {
const { head: _, ...binding } = player
await saveInstancePlayer(instanceId.value, binding)
active.value = false
resolveSelection?.(binding)
resolveSelection = undefined
rejectSelection = undefined
modal.value?.hide()
} catch (cause) {
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
saving.value = false
}
}
function signInSkinSite() {
awaitingSkinLogin.value = true
hideForLogin = true
openSkinSiteLogin()
modal.value?.hide()
void router.push('/starlight-skin')
}
const unregister = registerInstancePlayerPicker((id, saved) => {
if (active.value) return Promise.reject(new Error('请先完成当前实例的玩家选择。'))
instanceId.value = id
locked.value = saved
accounts.value = []
awaitingSkinLogin.value = false
active.value = true
modal.value?.show()
void refresh()
return new Promise((resolve, reject) => {
resolveSelection = resolve
rejectSelection = reject
})
})
watch([skinSiteStatus, () => skinSiteUser.value?.uuid], () => {
if (!active.value || saving.value) return
if (
awaitingSkinLogin.value &&
skinSiteStatus.value === 'signed-in' &&
(!locked.value?.skin_site_user || locked.value.skin_site_user === skinSiteUser.value?.uuid)
) {
awaitingSkinLogin.value = false
modal.value?.show()
}
void refresh()
})
onUnmounted(() => {
unregister()
cancelled()
})
</script>
<template>
<NewModal
ref="modal"
:header="formatMessage(messages.title)"
:closable="!saving"
:on-hide="cancelled"
>
<div class="flex flex-col gap-3">
<p class="m-0 text-secondary">
{{
locked
? formatMessage(messages.restore, { name: locked.name })
: formatMessage(messages.remember)
}}
</p>
<p v-if="loading" class="m-0" role="status">{{ formatMessage(messages.loading) }}</p>
<template v-else>
<ButtonStyled v-for="player in choices" :key="`${player.account_type}:${player.id}`">
<button
type="button"
:disabled="saving"
class="flex items-center gap-3"
@click="select(player)"
>
<img
v-if="player.head"
:src="player.head"
alt=""
class="size-8 rounded-md [image-rendering:pixelated]"
/>
<span>{{ player.name }}</span
><span class="text-secondary">{{
formatMessage(
player.account_type === 'microsoft'
? messages.licensed
: player.account_type === 'offline'
? messages.offline
: messages.skin,
)
}}</span>
</button>
</ButtonStyled>
<p v-if="!choices.length && !error && !locked" class="m-0 text-secondary">
{{ formatMessage(skinSiteStatus === 'signed-in' ? messages.empty : messages.signIn) }}
</p>
</template>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
<ButtonStyled v-if="error"
><button :disabled="loading || saving" @click="refresh">
{{ formatMessage(messages.retry) }}
</button></ButtonStyled
>
<ButtonStyled
v-if="
skinSiteStatus === 'signed-out' ||
(locked?.skin_site_user && locked.skin_site_user !== skinSiteUser?.uuid)
"
><button :disabled="saving" @click="signInSkinSite">
{{ formatMessage(messages.skinLogin) }}
</button></ButtonStyled
>
<div class="flex flex-col items-center gap-2">
<span class="text-secondary">{{ formatMessage(messages.or) }}</span>
<ButtonStyled
><button :disabled="loading || saving" @click="microsoftLogin?.showDeviceLogin()">
{{ formatMessage(messages.useMicrosoft) }}
</button></ButtonStyled
>
</div>
<p v-if="saving" class="m-0" role="status">{{ formatMessage(messages.saving) }}</p>
</div>
</NewModal>
<MinecraftLoginModal ref="microsoftLogin" @complete="refresh" />
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import { ButtonStyled, useVIntl } from '@modrinth/ui'
import { onUnmounted, ref, watch } from 'vue'
import {
chooseInstancePlayer,
getInstancePlayer,
onInstancePlayerChanged,
type InstancePlayer,
} from '@/helpers/instance-player'
import { playerMessages as messages } from './instance-player-messages'
const { formatMessage } = useVIntl()
const props = defineProps<{ instanceId: string }>()
const player = ref<InstancePlayer | null>(null)
const busy = ref(false)
const error = ref('')
let generation = 0
const stop = onInstancePlayerChanged((id, saved) => {
if (id === props.instanceId) {
generation++
player.value = saved
busy.value = false
error.value = ''
}
})
onUnmounted(() => {
generation++
stop()
})
watch(
() => props.instanceId,
async (id) => {
const revision = ++generation
player.value = null
error.value = ''
busy.value = true
try {
const saved = await getInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision) error.value = String(cause)
} finally {
if (generation === revision) busy.value = false
}
},
{ immediate: true },
)
async function change() {
if (busy.value) return
const revision = generation
const id = props.instanceId
busy.value = true
error.value = ''
try {
const saved = await chooseInstancePlayer(id)
if (generation === revision) player.value = saved
} catch (cause) {
if (generation === revision)
error.value = cause instanceof Error ? cause.message : String(cause)
} finally {
if (generation === revision) busy.value = false
}
}
</script>
<template>
<div class="mb-6 flex flex-col gap-2" data-onboarding-id="instance-player-settings">
<h3 class="m-0 text-contrast">{{ formatMessage(messages.setting) }}</h3>
<p class="m-0 text-secondary">
{{ player ? player.name : formatMessage(messages.firstLaunch) }}
</p>
<ButtonStyled
><button :disabled="busy" @click="change">
{{ formatMessage(busy ? messages.loading : messages.change) }}
</button></ButtonStyled
>
<p v-if="error" class="m-0 text-red" role="alert">{{ error }}</p>
</div>
</template>

View File

@ -133,14 +133,18 @@ function openDownloads() {
</section>
</div>
<template #actions>
<ButtonStyled
><button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button></ButtonStyled
>
<ButtonStyled
><button @click="modal?.hide()">{{ formatMessage(messages.close) }}</button></ButtonStyled
>
<div class="flex w-full items-center justify-between gap-2">
<ButtonStyled>
<button @click="openDownloads">
{{ formatMessage(messages.downloads) }}
</button>
</ButtonStyled>
<ButtonStyled>
<button @click="modal?.hide()">
{{ formatMessage(messages.close) }}
</button>
</ButtonStyled>
</div>
</template>
</NewModal>
</template>

View File

@ -0,0 +1,37 @@
import { defineMessages } from '@modrinth/ui'
export const playerMessages = defineMessages({
title: { id: 'app.instance-player.title', defaultMessage: 'Choose an instance player' },
remember: {
id: 'app.instance-player.remember',
defaultMessage:
'This instance will keep using your choice. Switch players in instance settings.',
},
restore: {
id: 'app.instance-player.restore',
defaultMessage: 'Sign in again as {name}. To choose someone else, open instance settings.',
},
loading: { id: 'app.instance-player.loading', defaultMessage: 'Loading signed-in players…' },
licensed: { id: 'app.instance-player.licensed', defaultMessage: 'Minecraft account' },
skin: { id: 'app.instance-player.skin', defaultMessage: 'Skin site player' },
offline: { id: 'app.instance-player.offline', defaultMessage: 'Offline player' },
empty: {
id: 'app.instance-player.empty',
defaultMessage: 'No available skin site players. You can create a player on the skin site.',
},
signIn: { id: 'app.instance-player.sign-in', defaultMessage: 'Sign in to choose a player.' },
retry: { id: 'app.instance-player.retry', defaultMessage: 'Retry' },
skinLogin: { id: 'app.instance-player.skin-login', defaultMessage: 'Sign in to skin site' },
or: { id: 'app.instance-player.or', defaultMessage: 'Or' },
useMicrosoft: {
id: 'app.instance-player.use-microsoft',
defaultMessage: 'Use a Minecraft account',
},
saving: { id: 'app.instance-player.saving', defaultMessage: 'Saving instance player…' },
setting: { id: 'app.instance-player.setting', defaultMessage: 'Instance player' },
firstLaunch: {
id: 'app.instance-player.first-launch',
defaultMessage: 'Choose a player on first launch',
},
change: { id: 'app.instance-player.change', defaultMessage: 'Switch instance player' },
})

View File

@ -22,7 +22,8 @@ const { formatMessage } = useVIntl()
const viewport = ref<HTMLElement>()
const grid = ref<HTMLElement>()
const view = ref({ left: 0, top: 0, width: 0, height: 0 })
const brushCursor = ref({ x: 0, y: 0, color: '', visible: false })
const brushCursor = ref<string>()
const brushActive = computed(() => props.playing && props.selected >= 0)
const large = computed(() => props.puzzle.difficulty !== 'easy')
const visibleCells = computed(() =>
props.puzzle.answer.flatMap((color, i) =>
@ -87,37 +88,21 @@ function onWheel(event: WheelEvent) {
void changeZoom(props.zoom + (event.deltaY < 0 ? 0.1 : -0.1))
}
function moveBrushCursor(event: PointerEvent) {
if (event.pointerType === 'touch') {
hideBrushCursor()
return
}
const cell = (event.target as Element | null)?.closest<HTMLButtonElement>('.mine-cell')
const visible = Boolean(
props.selected >= 0 && cell && !cell.disabled && !cell.classList.contains('mine-open'),
)
brushCursor.value = {
x: event.clientX,
y: event.clientY,
color: visible ? getComputedStyle(event.currentTarget as HTMLElement).color : '',
visible,
}
let cursorColor = ''
function updateBrushCursor() {
if (!brushActive.value || !grid.value) return
const color = getComputedStyle(grid.value).color
if (color === cursorColor) return
cursorColor = color
// Let the native cursor follow the pointer independently of Vue and board rendering.
// The hotspot is the same brush tip as the previous 32px floating SVG.
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 1024 1024"><path fill="${color}" d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"/></svg>`
brushCursor.value = `url("data:image/svg+xml,${encodeURIComponent(svg)}") 5 27, crosshair`
}
function hideBrushCursor() {
brushCursor.value.visible = false
}
watch(
() => [props.playing, props.selected] as const,
([playing, selected]) => {
if (!playing || selected < 0) hideBrushCursor()
else if (brushCursor.value.visible && grid.value) {
brushCursor.value.color = getComputedStyle(grid.value).color
}
},
{ flush: 'post' },
)
watch(() => [props.playing, props.selected], updateBrushCursor, {
flush: 'post',
})
function label(index: number) {
const values = {
@ -135,6 +120,7 @@ function label(index: number) {
let observer: ResizeObserver | undefined
onMounted(() => {
updateBrushCursor()
observer = new ResizeObserver(updateView)
if (viewport.value) observer.observe(viewport.value)
})
@ -142,7 +128,10 @@ onScopeDispose(() => observer?.disconnect())
defineExpose({
centerFirstClue,
setView,
getView: () => ({ left: viewport.value?.scrollLeft ?? 0, top: viewport.value?.scrollTop ?? 0 }),
getView: () => ({
left: viewport.value?.scrollLeft ?? 0,
top: viewport.value?.scrollTop ?? 0,
}),
})
</script>
@ -152,16 +141,18 @@ defineExpose({
<div
ref="grid"
class="mine-grid"
:class="{ 'mine-grid-small': !large, 'mine-grid-brush-active': selected >= 0 }"
:class="{
'mine-grid-small': !large,
'mine-grid-brush-active': brushActive,
}"
:style="{
'--mine-size': puzzle.size,
'--mine-cell-size': `${40 * zoom}px`,
'--mine-brush-cursor': brushCursor,
color: selected >= 0 ? `var(--mine-color-${selected})` : undefined,
}"
:aria-label="formatMessage(messages.board, { size: puzzle.size })"
@pointermove="moveBrushCursor"
@pointerleave="hideBrushCursor"
@pointercancel="hideBrushCursor"
@pointerenter="updateBrushCursor"
>
<button
v-for="(_, i) in puzzle.answer"
@ -190,24 +181,6 @@ defineExpose({
</button>
</div>
</div>
<Teleport to="body">
<svg
v-show="brushCursor.visible"
class="mine-brush-cursor"
:style="{
left: `${brushCursor.x}px`,
top: `${brushCursor.y}px`,
color: brushCursor.color,
}"
viewBox="0 0 1024 1024"
aria-hidden="true"
>
<path
fill="currentColor"
d="M358.681 586.386s-90.968 49.4-94.488 126.827c-3.519 77.428-77.427 133.74-102.063 140.778s360.157 22.971 332.002-142.444l-135.45-125.16zm169.099 52.56c14.016 13.601 17.565 32.675 7.929 42.606-9.635 9.93-28.81 6.954-42.823-6.647l-92.767-88.518c-14.015-13.6-17.565-32.675-7.929-42.605 9.636-9.93 28.81-6.955 42.824 6.646l92.766 88.518zm321.734-465.083c-25.144-17.055-47.741-1.763-57.477 3.805-29.097 19.485-237.243 221.77-327.69 315.194-11.105 14.8-18.59 26.294 34.663 79.546 44.95 44.95 65.896 42.012 88.66 22.603 37.906-37.906 199.299-262.926 258.92-348.713 9.792-14.092 29.851-54.17 2.924-72.435z"
/>
</svg>
</Teleport>
<div v-if="large" class="mine-overview">
<svg
class="mine-map"
@ -260,7 +233,10 @@ defineExpose({
<style scoped>
.mine-navigation {
box-sizing: border-box;
width: 100%;
min-width: 0;
max-width: 100%;
display: grid;
gap: var(--gap-md);
}
@ -268,6 +244,9 @@ defineExpose({
grid-template-columns: minmax(0, 1fr) 7rem;
}
.mine-viewport {
box-sizing: border-box;
width: 100%;
max-width: 100%;
overflow: auto;
max-height: min(52vh, 32rem);
min-width: 0;
@ -308,17 +287,9 @@ defineExpose({
user-select: none;
touch-action: manipulation;
}
.mine-grid-brush-active,
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: none;
}
.mine-brush-cursor {
position: fixed;
z-index: 2147483647;
width: 2rem;
height: 2rem;
filter: drop-shadow(0 1px 1px rgb(0 0 0 / 45%));
pointer-events: none;
transform: translate(-16%, -84%);
cursor: var(--mine-brush-cursor, crosshair);
}
.mine-cell:hover:not(:disabled) {
background: var(--surface-5);
@ -392,12 +363,4 @@ defineExpose({
width: 4.5rem;
}
}
@media (pointer: coarse) {
.mine-grid-brush-active .mine-cell:not(.mine-open):not(:disabled) {
cursor: pointer;
}
.mine-brush-cursor {
display: none;
}
}
</style>

View File

@ -301,6 +301,7 @@ defineExpose({ show })
:header="formatMessage(messages.title)"
width="56rem"
max-width="56rem"
scrollable
:closable="false"
:close-on-esc="false"
actions-divider
@ -455,15 +456,21 @@ defineExpose({ show })
justify-content: space-between;
}
.mine-frame {
box-sizing: border-box;
min-width: 0;
max-width: 100%;
border: 2px solid var(--mine-ink);
border-radius: var(--radius-md);
padding: var(--gap-md);
background: var(--surface-2);
}
.mine-status {
min-width: 0;
max-width: 100%;
margin: 0 0 var(--gap-md);
color: var(--color-contrast);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.mine-actions {
justify-content: flex-end;

View File

@ -18,6 +18,7 @@ import { useRouter } from 'vue-router'
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
import InstanceModeSettings from '@/components/instance/InstanceModeSettings.vue'
import InstancePlayerSettings from '@/components/instance/InstancePlayerSettings.vue'
import ConfirmDeleteInstanceModal from '@/components/ui/modal/ConfirmDeleteInstanceModal.vue'
import { install_duplicate_instance } from '@/helpers/install'
import { edit, edit_icon, get_full_path, remove } from '@/helpers/instance'
@ -381,6 +382,7 @@ const messages = defineMessages({
<template>
<InstanceModeSettings class="mb-6" :instance-id="instance.id" />
<InstancePlayerSettings :instance-id="instance.id" />
<ConfirmDeleteInstanceModal
ref="deleteConfirmModal"
:symlink-target="instance.symlink_target"

View File

@ -242,7 +242,7 @@ export const onboardingMessages = defineMessages({
instanceActionsDescription: {
id: 'app.onboarding.instance-actions.description',
defaultMessage:
'Launch, stop, repair, configure, export, or open the instance from its header.',
'Launch or configure this instance here. Choose a player on first launch; the instance remembers your choice until you switch it in settings.',
},
instanceTabsTitle: {
id: 'app.onboarding.instance-tabs.title',