feat: 主页改为原生皮肤站界面
Sync source to CNB / Sync Git ref (push) Skipped

主页原先内嵌整个皮肤站网页,现在换成启动器自己的界面,数据全部走
接口:

- SkinSiteAccountCard:登录状态、玩家列表(头像由皮肤裁剪生成)、
  每日运势。
- SkinSiteNavCards:资源下载、玩家审核、Wiki、漏洞追踪、地图、个人
  资料的入口,点击后用系统浏览器打开。
- SkinSiteAnnouncements:拉取公告并按重要度与时间排序。
- SkinSiteDonationCard:捐款累计金额与人数。
- SkinSiteHome:把以上拼装成主页,并显示可复制的接入地址。
- API 层补充捐款与公告接口。

Index.vue 的 iframe 换成 SkinSiteHome。
This commit is contained in:
AxTps committed 2026-10-17 19:11:04 +08:00
1 parent bf175160b0
commit 8599a014ad
7 files changed
+546 -2

No files matched your search

@@ -0,0 +1,146 @@
<script setup lang="ts">
import { SparklesIcon, SpinnerIcon } from '@modrinth/assets'
import { Avatar, ButtonStyled, defineMessages, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
requestSkinSiteLuck,
skinSitePlayers,
skinSitePlayersStatus,
skinSiteStatus,
skinSiteUser,
} from '@/composables/skin-site-session'
const { formatMessage } = useVIntl()
const messages = defineMessages({
signedOutTitle: { id: 'app.skin-site.home.signed-out-title', defaultMessage: 'Not signed in' },
signedOutBody: {
id: 'app.skin-site.home.signed-out-body',
defaultMessage: 'Sign in to the StarLight skin site to manage players and download packs.',
},
players: { id: 'app.skin-site.home.players', defaultMessage: 'Your players' },
noPlayers: {
id: 'app.skin-site.home.no-players',
defaultMessage: 'No players yet. Add one on the skin site.',
},
luck: { id: 'app.skin-site.home.luck', defaultMessage: "Today's luck" },
luckUnavailable: { id: 'app.skin-site.home.luck-unavailable', defaultMessage: 'Unavailable' },
loading: { id: 'app.skin-site.home.loading', defaultMessage: 'Loading…' },
error: { id: 'app.skin-site.home.error', defaultMessage: 'Could not load players.' },
signIn: { id: 'app.skin-site.home.sign-in', defaultMessage: 'Sign in' },
})
const emit = defineEmits<{ (e: 'sign-in'): void }>()
const luck = ref<number | null>(null)
const luckLoading = ref(false)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const players = computed(() => skinSitePlayers.value)
const playersLoading = computed(
() => skinSitePlayersStatus.value === 'checking' || skinSitePlayersStatus.value === 'idle',
)
const playersError = computed(() => skinSitePlayersStatus.value === 'error')
async function loadLuck() {
if (!signedIn.value) {
luck.value = null
return
}
luckLoading.value = true
try {
luck.value = await requestSkinSiteLuck()
} catch {
luck.value = null
} finally {
luckLoading.value = false
}
}
onMounted(loadLuck)
watch(signedIn, loadLuck)
</script>
<template>
<div
class="flex flex-col gap-4 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div v-if="!signedIn" class="flex flex-col items-start gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.signedOutTitle) }}
</h3>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.signedOutBody) }}</p>
<ButtonStyled color="brand">
<button type="button" @click="emit('sign-in')">
{{ formatMessage(messages.signIn) }}
</button>
</ButtonStyled>
</div>
<template v-else>
<div class="flex items-center gap-3">
<div
class="flex size-10 shrink-0 items-center justify-center rounded-full bg-brand-highlight text-lg font-bold text-contrast"
>
{{ skinSiteUser!.username.slice(0, 1).toUpperCase() }}
</div>
<div class="flex min-w-0 flex-col">
<span class="truncate font-semibold text-contrast">{{ skinSiteUser!.username }}</span>
<span class="truncate text-xs text-secondary">{{ skinSiteUser!.uuid }}</span>
</div>
<div
class="ml-auto flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-sm"
:title="formatMessage(messages.luck)"
>
<SpinnerIcon v-if="luckLoading" class="size-4 animate-spin" />
<template v-else-if="luck !== null">
<SparklesIcon class="size-4 text-brand" />
<span class="font-semibold text-contrast">{{ luck }}</span>
</template>
<span v-else class="text-xs text-secondary">{{
formatMessage(messages.luckUnavailable)
}}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.players)
}}</span>
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="playersError" class="m-0 text-sm text-red">
{{ formatMessage(messages.error) }}
</p>
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noPlayers) }}
</p>
<div v-else class="flex flex-wrap gap-3">
<div
v-for="player in players"
:key="player.uuid"
class="flex items-center gap-2 rounded-xl bg-button-bg px-2 py-1.5"
>
<Avatar
:src="player.headDataUrl"
:alt="player.name"
size="2rem"
pixelated
class="!rounded-md"
/>
<div class="flex flex-col">
<span class="text-sm font-medium text-contrast">{{ player.name }}</span>
<span v-if="player.isMojang" class="text-[10px] uppercase text-secondary"
>Mojang</span
>
</div>
</div>
</div>
</div>
</template>
</div>
</template>
@@ -0,0 +1,90 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import { type SkinSiteAnnouncement, skinSiteAnnouncements } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.announcements.title', defaultMessage: 'Announcements' },
empty: {
id: 'app.skin-site.home.announcements.empty',
defaultMessage: 'No announcements right now.',
},
loading: { id: 'app.skin-site.home.announcements.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.home.announcements.failed',
defaultMessage: 'Could not load announcements.',
},
})
const announcements = ref<SkinSiteAnnouncement[]>([])
const loading = ref(true)
const failed = ref(false)
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return ''
}
}
onMounted(async () => {
try {
const result = await skinSiteAnnouncements()
if (result.success) {
announcements.value = [...(result.payload ?? [])]
.sort((a, b) => {
if (a.star !== b.star) return a.star ? -1 : 1
return b.updateAt - a.updateAt
})
.slice(0, 5)
} else {
failed.value = true
}
} catch {
failed.value = true
} finally {
loading.value = false
}
})
</script>
<template>
<div class="flex flex-col gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<div v-if="loading" class="flex items-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 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!announcements.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="item in announcements"
:key="item.id"
class="flex flex-col gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex items-start gap-2">
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.title }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
</div>
<span v-if="item.subtitle" class="truncate text-xs text-secondary">{{
item.subtitle
}}</span>
</div>
</div>
</div>
</template>
@@ -0,0 +1,58 @@
<script setup lang="ts">
import { HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import { type SkinSiteDonationSummary, skinSiteDonationSummary } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.donation.title', defaultMessage: 'Donation thanks' },
total: { id: 'app.skin-site.home.donation.total', defaultMessage: 'Total raised' },
donors: { id: 'app.skin-site.home.donation.donors', defaultMessage: '{count} donors' },
unavailable: {
id: 'app.skin-site.home.donation.unavailable',
defaultMessage: 'Donation summary unavailable.',
},
loading: { id: 'app.skin-site.home.donation.loading', defaultMessage: 'Loading…' },
})
const summary = ref<SkinSiteDonationSummary | null>(null)
const loading = ref(true)
const failed = ref(false)
onMounted(async () => {
try {
const result = await skinSiteDonationSummary()
if (result.success) summary.value = result.payload
else failed.value = true
} catch {
failed.value = true
} finally {
loading.value = false
}
})
</script>
<template>
<div
class="flex items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<HeartIcon class="size-5 shrink-0 text-brand" />
<div class="flex min-w-0 flex-1 flex-col">
<span class="text-sm font-semibold text-contrast">{{ formatMessage(messages.title) }}</span>
<span v-if="loading" class="flex items-center gap-1 text-xs text-secondary">
<SpinnerIcon class="size-3 animate-spin" />
{{ formatMessage(messages.loading) }}
</span>
<span v-else-if="failed" class="text-xs text-secondary">
{{ formatMessage(messages.unavailable) }}
</span>
<span v-else-if="summary" class="text-xs text-secondary">
{{ formatMessage(messages.total) }}: {{ summary.totalAmount }} ·
{{ formatMessage(messages.donors, { count: summary.donorCount }) }}
</span>
</div>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import { CopyIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { ref } from 'vue'
import SkinSiteAccountCard from './SkinSiteAccountCard.vue'
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
import SkinSiteNavCards from './SkinSiteNavCards.vue'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.home.title', defaultMessage: 'StarLight Skin Site' },
subtitle: {
id: 'app.skin-site.home.subtitle',
defaultMessage: 'The wardrobe for StarLight Server players.',
},
address: { id: 'app.skin-site.home.address', defaultMessage: 'Skin site address' },
copied: { id: 'app.skin-site.home.copied', defaultMessage: 'Address copied.' },
})
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
async function copyAddress() {
try {
await navigator.clipboard.writeText(YGGDRASIL_URL)
addNotification({ type: 'success', title: formatMessage(messages.copied) })
} catch {
// Clipboard may be unavailable; the address stays selectable.
}
}
</script>
<template>
<div class="flex h-full min-h-0 w-full flex-col overflow-y-auto">
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
<header class="flex flex-col items-center gap-1 text-center">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
</header>
<SkinSiteAccountCard @sign-in="loginModal?.show()" />
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
<SkinSiteNavCards />
<SkinSiteAnnouncements />
</div>
<SkinSiteDonationCard />
<div
class="flex flex-wrap items-center gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised px-4 py-3"
>
<span class="text-sm font-semibold text-contrast">{{
formatMessage(messages.address)
}}</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">{{ YGGDRASIL_URL }}</code>
<button
type="button"
class="flex shrink-0 items-center gap-1 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="copyAddress"
>
<CopyIcon class="size-3.5" />
{{ formatMessage({ id: 'app.skin-site.home.copy', defaultMessage: 'Copy' }) }}
</button>
</div>
</div>
<SkinSiteLoginModal ref="loginModal" />
</div>
</template>
@@ -0,0 +1,126 @@
<script setup lang="ts">
import {
BookIcon,
BugIcon,
DownloadIcon,
MapIcon,
ShieldCheckIcon,
UserIcon,
} from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import type { Component } from 'vue'
const { formatMessage } = useVIntl()
const messages = defineMessages({
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
download: { id: 'app.skin-site.home.nav.download', defaultMessage: 'Downloads' },
downloadDesc: {
id: 'app.skin-site.home.nav.download-desc',
defaultMessage: 'Browse files shared by the server.',
},
review: { id: 'app.skin-site.home.nav.review', defaultMessage: 'Player review' },
reviewDesc: {
id: 'app.skin-site.home.nav.review-desc',
defaultMessage: 'Submit and review player applications.',
},
wiki: { id: 'app.skin-site.home.nav.wiki', defaultMessage: 'Server wiki' },
wikiDesc: {
id: 'app.skin-site.home.nav.wiki-desc',
defaultMessage: 'Guides and documentation for the server.',
},
bugs: { id: 'app.skin-site.home.nav.bugs', defaultMessage: 'Bug tracker' },
bugsDesc: {
id: 'app.skin-site.home.nav.bugs-desc',
defaultMessage: 'Report issues and follow their progress.',
},
map: { id: 'app.skin-site.home.nav.map', defaultMessage: 'Server map' },
mapDesc: {
id: 'app.skin-site.home.nav.map-desc',
defaultMessage: 'Explore the live world map.',
},
profile: { id: 'app.skin-site.home.nav.profile', defaultMessage: 'Profile' },
profileDesc: {
id: 'app.skin-site.home.nav.profile-desc',
defaultMessage: 'Manage your account and textures.',
},
})
interface NavItem {
id: string
title: string
description: string
icon: Component
href: string
}
const SKIN_SITE = 'https://skin.starlight.cool'
const items: NavItem[] = [
{
id: 'download',
title: formatMessage(messages.download),
description: formatMessage(messages.downloadDesc),
icon: DownloadIcon,
href: `${SKIN_SITE}/download`,
},
{
id: 'review',
title: formatMessage(messages.review),
description: formatMessage(messages.reviewDesc),
icon: ShieldCheckIcon,
href: `${SKIN_SITE}/review`,
},
{
id: 'wiki',
title: formatMessage(messages.wiki),
description: formatMessage(messages.wikiDesc),
icon: BookIcon,
href: `${SKIN_SITE}/wiki`,
},
{
id: 'bugs',
title: formatMessage(messages.bugs),
description: formatMessage(messages.bugsDesc),
icon: BugIcon,
href: `${SKIN_SITE}/bugs`,
},
{
id: 'map',
title: formatMessage(messages.map),
description: formatMessage(messages.mapDesc),
icon: MapIcon,
href: `${SKIN_SITE}/map`,
},
{
id: 'profile',
title: formatMessage(messages.profile),
description: formatMessage(messages.profileDesc),
icon: UserIcon,
href: `${SKIN_SITE}/profile`,
},
]
function open(href: string) {
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(href))
}
</script>
<template>
<div class="flex flex-col gap-3">
<h3 class="m-0 text-base font-semibold text-contrast">{{ formatMessage(messages.title) }}</h3>
<div class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<button
v-for="item in items"
:key="item.id"
type="button"
class="flex flex-col items-start gap-1 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3 text-left transition-colors hover:bg-button-bg"
@click="open(item.href)"
>
<component :is="item.icon" class="size-5 text-brand" />
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
<span class="text-xs text-secondary">{{ item.description }}</span>
</button>
</div>
</div>
</template>
@@ -382,3 +382,52 @@ export function skinSitePasswordReset(payload: {
auth: false,
})
}
// ---------------------------------------------------------------------------
// Donations & announcements
// ---------------------------------------------------------------------------
export interface SkinSiteDonationSummary {
totalAmount: string
donorCount: number
}
export interface SkinSiteDonationEntry {
id: number
rank: number
username: string
avatar: string | null
amount: string
}
export interface SkinSiteDonationPage {
items: SkinSiteDonationEntry[]
summary: SkinSiteDonationSummary
hasMore: boolean
}
export function skinSiteDonationSummary(): Promise<SkinSiteResponse<SkinSiteDonationSummary>> {
return skinSiteRequest<SkinSiteDonationSummary>('donation/summary', { auth: false })
}
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSiteDonationPage>> {
return skinSiteRequest<SkinSiteDonationPage>(`donation/list?limit=20&offset=${offset}`, {
auth: false,
})
}
export interface SkinSiteAnnouncement {
id: number
title: string
subtitle: string
imageName?: string
content: string
updateAt: number
star: boolean
importantLevel: number
source: string
}
/** Public announcements shown on the skin site. Requires the JWT when signed in. */
export function skinSiteAnnouncements(): Promise<SkinSiteResponse<SkinSiteAnnouncement[]>> {
return skinSiteRequest<SkinSiteAnnouncement[]>('broadcast/all', { type: 'skin' })
}
+2 -2
View File
@@ -16,7 +16,7 @@ import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
import HomeMinimal from '@/components/home/HomeMinimal.vue'
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
import SkinSiteHome from '@/components/ui/skin-site/SkinSiteHome.vue'
import { useNetworkStatus } from '@/composables/useNetworkStatus'
import { get_default_user, users } from '@/helpers/auth'
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
@@ -162,7 +162,7 @@ onUnmounted(() => {
@select="selectMinimalInstance"
/>
<div class="home-content" :class="{ 'is-minimal': isMinimal }">
<SkinSiteFrame v-if="!isMinimal" />
<SkinSiteHome v-if="!isMinimal" />
<HomeMinimal
v-else