feat: 新增皮肤站服务器地图视图
Sync source to CNB / Sync Git ref (push) Skipped

- API 层补充地图服务器列表接口。
- SkinSiteMapView:内外服切换、选择子服与地图,用内嵌 iframe 展示,
  并提供在浏览器中打开的入口。
- 主页导航卡的「地图」改为打开该视图。
This commit is contained in:
AxTps committed 2026-10-17 21:28:04 +08:00
1 parent 77b381b8d0
commit 8cc4782497
4 files changed
+165 -3

No files matched your search

@@ -8,6 +8,7 @@ import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
import SkinSiteMapView from './SkinSiteMapView.vue'
import SkinSiteNavCards from './SkinSiteNavCards.vue'
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
import SkinSiteProfileView from './SkinSiteProfileView.vue'
@@ -28,7 +29,7 @@ const messages = defineMessages({
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
const view = ref<'home' | 'players' | 'downloads' | 'review' | 'bugs'>('home')
const view = ref<'home' | 'players' | 'downloads' | 'map' | 'review' | 'bugs'>('home')
async function copyAddress() {
try {
@@ -45,6 +46,7 @@ async function copyAddress() {
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" />
<SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
<SkinSiteMapView v-else-if="view === 'map'" @back="view = 'home'" />
<template v-else>
<header class="flex flex-col items-center gap-1 text-center">
@@ -0,0 +1,142 @@
<script setup lang="ts">
import { ExternalIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { type SkinSiteMapServer,skinSiteMapServers } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.map.title', defaultMessage: 'Server map' },
back: { id: 'app.skin-site.map.back', defaultMessage: 'Back to home' },
internal: { id: 'app.skin-site.map.internal', defaultMessage: 'Internal' },
external: { id: 'app.skin-site.map.external', defaultMessage: 'External' },
server: { id: 'app.skin-site.map.server', defaultMessage: 'Server' },
map: { id: 'app.skin-site.map.map', defaultMessage: 'Map' },
openExternal: { id: 'app.skin-site.map.open', defaultMessage: 'Open in browser' },
loading: { id: 'app.skin-site.map.loading', defaultMessage: 'Loading maps…' },
empty: { id: 'app.skin-site.map.empty', defaultMessage: 'No maps available.' },
failed: { id: 'app.skin-site.map.failed', defaultMessage: 'Could not load maps.' },
})
const emit = defineEmits<{ (e: 'back'): void }>()
const servers = ref<SkinSiteMapServer[]>([])
const internal = ref(false)
const serverIndex = ref(0)
const mapIndex = ref(0)
const loading = ref(true)
const failed = ref(false)
const filteredServers = computed(() => servers.value.filter((s) => s.internal === internal.value))
const selectedServer = computed(() => filteredServers.value[serverIndex.value])
const selectedMap = computed(() => selectedServer.value?.maps[mapIndex.value])
watch(internal, () => {
serverIndex.value = 0
mapIndex.value = 0
})
watch(serverIndex, () => {
mapIndex.value = 0
})
async function openExternal() {
if (!selectedMap.value) return
const { openUrl } = await import('@tauri-apps/plugin-opener')
await openUrl(selectedMap.value.url)
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
if (!servers.value.some((s) => !s.internal)) internal.value = true
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<template>
<div class="flex h-full min-h-0 flex-col gap-3">
<div class="flex flex-wrap items-center gap-2">
<button
type="button"
class="flex items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="emit('back')"
>
← {{ formatMessage(messages.back) }}
</button>
<h1 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<select
v-model="internal"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option :value="false">{{ formatMessage(messages.external) }}</option>
<option :value="true">{{ formatMessage(messages.internal) }}</option>
</select>
<select
v-if="filteredServers.length"
v-model.number="serverIndex"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="(server, index) in filteredServers" :key="index" :value="index">
{{ server.name }}
</option>
</select>
<select
v-if="selectedServer?.maps.length"
v-model.number="mapIndex"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
>
<option v-for="(map, index) in selectedServer.maps" :key="index" :value="index">
{{ map.name }}
</option>
</select>
<button
v-if="selectedMap"
type="button"
class="ml-auto flex items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openExternal"
>
<ExternalIcon class="size-3.5" />
{{ formatMessage(messages.openExternal) }}
</button>
</div>
<div
v-if="loading"
class="flex flex-1 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 flex-1 text-center text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!selectedMap" class="m-0 flex-1 text-center text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<iframe
v-else
:key="selectedMap.url"
:src="selectedMap.url"
:title="`${selectedServer?.name} · ${selectedMap.name}`"
class="min-h-0 w-full flex-1 rounded-lg border border-solid border-surface-5"
allow="fullscreen"
/>
</div>
</template>
@@ -12,7 +12,7 @@ import type { Component } from 'vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{ (e: 'navigate', view: 'downloads' | 'review' | 'bugs'): void }>()
const emit = defineEmits<{ (e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map'): void }>()
const messages = defineMessages({
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
@@ -104,7 +104,7 @@ const items: NavItem[] = [
]
function open(item: NavItem) {
if (item.id === 'download' || item.id === 'review' || item.id === 'bugs') {
if (item.id === 'download' || item.id === 'review' || item.id === 'bugs' || item.id === 'map') {
emit('navigate', item.id === 'download' ? 'downloads' : item.id)
return
}
@@ -532,3 +532,21 @@ export function skinSiteDownloadCategories(): Promise<
> {
return skinSiteRequest<SkinSiteDownloadCategory[]>('download/category/list', { auth: false })
}
// ---------------------------------------------------------------------------
// Maps
// ---------------------------------------------------------------------------
export interface SkinSiteMapEntry {
name: string
url: string
}
export interface SkinSiteMapServer {
name: string
internal: boolean
maps: SkinSiteMapEntry[]
}
export function skinSiteMapServers(): Promise<SkinSiteResponse<SkinSiteMapServer[]>> {
return skinSiteRequest<SkinSiteMapServer[]>('maps', { auth: false })
}