- API 层补充下载列表、数量与分类接口。 - SkinSiteDownloadsView:按分类筛选下载项,展示文件名、描述、 上传者与状态标签,点击后用系统浏览器打开下载地址。 - 主页导航卡的「下载」改为打开该视图,其余入口仍走浏览器。
This commit is contained in:
1 parent
4124f26095
commit
77b381b8d0
4 files changed
+263
-5
No files matched your search
@@ -0,0 +1,198 @@
|
||||
<script setup lang="ts">
|
||||
import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
skinSiteDownloadCategories,
|
||||
type SkinSiteDownloadCategory,
|
||||
type SkinSiteDownloadItem,
|
||||
skinSiteDownloadList,
|
||||
type SkinSiteDownloadStatus,
|
||||
} from '@/helpers/skin-site-api'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.downloads.title', defaultMessage: 'Downloads' },
|
||||
subtitle: {
|
||||
id: 'app.skin-site.downloads.subtitle',
|
||||
defaultMessage: 'Files shared by the server. Pick a category to narrow the list.',
|
||||
},
|
||||
back: { id: 'app.skin-site.downloads.back', defaultMessage: 'Back to home' },
|
||||
all: { id: 'app.skin-site.downloads.all', defaultMessage: 'All' },
|
||||
empty: { id: 'app.skin-site.downloads.empty', defaultMessage: 'Nothing to download yet.' },
|
||||
loading: { id: 'app.skin-site.downloads.loading', defaultMessage: 'Loading…' },
|
||||
failed: { id: 'app.skin-site.downloads.failed', defaultMessage: 'Could not load downloads.' },
|
||||
goDownload: { id: 'app.skin-site.downloads.go', defaultMessage: 'Download' },
|
||||
uploader: { id: 'app.skin-site.downloads.uploader', defaultMessage: 'by {name}' },
|
||||
statusOk: { id: 'app.skin-site.downloads.status.ok', defaultMessage: 'OK' },
|
||||
statusNew: { id: 'app.skin-site.downloads.status.new', defaultMessage: 'New' },
|
||||
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
|
||||
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
|
||||
statusRemoval: { id: 'app.skin-site.downloads.status.removal', defaultMessage: 'Removal' },
|
||||
})
|
||||
|
||||
const emit = defineEmits<{ (e: 'back'): void }>()
|
||||
|
||||
const items = ref<SkinSiteDownloadItem[]>([])
|
||||
const categories = ref<SkinSiteDownloadCategory[]>([])
|
||||
const selectedCategory = ref<string | null>(null)
|
||||
const loading = ref(true)
|
||||
const failed = ref(false)
|
||||
|
||||
const filtered = computed(() =>
|
||||
selectedCategory.value
|
||||
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
|
||||
: items.value,
|
||||
)
|
||||
|
||||
function statusLabel(status: SkinSiteDownloadStatus): string {
|
||||
switch (status) {
|
||||
case 'NEW':
|
||||
return formatMessage(messages.statusNew)
|
||||
case 'LEGACY':
|
||||
return formatMessage(messages.statusLegacy)
|
||||
case 'OUTDATED':
|
||||
return formatMessage(messages.statusOutdated)
|
||||
case 'REMOVAL':
|
||||
return formatMessage(messages.statusRemoval)
|
||||
default:
|
||||
return formatMessage(messages.statusOk)
|
||||
}
|
||||
}
|
||||
|
||||
async function openUrl(url: string) {
|
||||
const { openUrl: open } = await import('@tauri-apps/plugin-opener')
|
||||
await open(url)
|
||||
}
|
||||
|
||||
function download(item: SkinSiteDownloadItem) {
|
||||
const route = item.routes?.[0]
|
||||
void openUrl(route?.url ?? item.url)
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
failed.value = false
|
||||
try {
|
||||
const [listResult, categoryResult] = await Promise.all([
|
||||
skinSiteDownloadList(),
|
||||
skinSiteDownloadCategories(),
|
||||
])
|
||||
if (!listResult.success) throw new Error(listResult.errorMessage)
|
||||
items.value = listResult.payload ?? []
|
||||
if (categoryResult.success) categories.value = categoryResult.payload ?? []
|
||||
} catch (error) {
|
||||
failed.value = true
|
||||
handleError(error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
<template>
|
||||
<div class="flex flex-col gap-4">
|
||||
<button
|
||||
type="button"
|
||||
class="flex w-fit 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>
|
||||
|
||||
<header class="flex flex-col gap-1">
|
||||
<h1 class="m-0 text-xl font-bold text-contrast">{{ formatMessage(messages.title) }}</h1>
|
||||
<p class="m-0 text-sm text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
||||
</header>
|
||||
|
||||
<div v-if="categories.length" class="flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
|
||||
:class="
|
||||
selectedCategory === null
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-button-bg text-secondary hover:brightness-110'
|
||||
"
|
||||
@click="selectedCategory = null"
|
||||
>
|
||||
{{ formatMessage(messages.all) }}
|
||||
</button>
|
||||
<button
|
||||
v-for="category in categories"
|
||||
:key="category.id"
|
||||
type="button"
|
||||
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
|
||||
:class="
|
||||
selectedCategory === category.name
|
||||
? 'bg-brand text-contrast'
|
||||
: 'bg-button-bg text-secondary hover:brightness-110'
|
||||
"
|
||||
@click="selectedCategory = category.name"
|
||||
>
|
||||
{{ category.name }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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="!filtered.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 filtered"
|
||||
:key="item.id"
|
||||
class="flex flex-wrap items-center gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 flex-col gap-1">
|
||||
<div class="flex flex-wrap items-center gap-2">
|
||||
<span class="min-w-0 truncate text-sm font-semibold text-contrast">
|
||||
{{ item.filename }}
|
||||
</span>
|
||||
<span
|
||||
v-for="status in item.status"
|
||||
:key="status"
|
||||
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
|
||||
:class="{
|
||||
'bg-green text-contrast': status === 'NEW',
|
||||
'bg-yellow text-contrast': status === 'LEGACY',
|
||||
'bg-red text-contrast': status === 'OUTDATED' || status === 'REMOVAL',
|
||||
'bg-button-bg text-secondary':
|
||||
status !== 'NEW' &&
|
||||
status !== 'LEGACY' &&
|
||||
status !== 'OUTDATED' &&
|
||||
status !== 'REMOVAL',
|
||||
}"
|
||||
>
|
||||
{{ statusLabel(status) }}
|
||||
</span>
|
||||
</div>
|
||||
<span v-if="item.description" class="truncate text-xs text-secondary">
|
||||
{{ item.description }}
|
||||
</span>
|
||||
<span v-if="item.uploader" class="text-xs text-secondary">
|
||||
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="flex shrink-0 items-center gap-1 rounded-full bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||
@click="download(item)"
|
||||
>
|
||||
<DownloadIcon class="size-3.5" />
|
||||
{{ formatMessage(messages.goDownload) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -6,6 +6,7 @@ import { ref } from 'vue'
|
||||
import SkinSiteAccountCard from './SkinSiteAccountCard.vue'
|
||||
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
|
||||
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
|
||||
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
|
||||
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
|
||||
import SkinSiteNavCards from './SkinSiteNavCards.vue'
|
||||
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
|
||||
@@ -27,7 +28,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'>('home')
|
||||
const view = ref<'home' | 'players' | 'downloads' | 'review' | 'bugs'>('home')
|
||||
|
||||
async function copyAddress() {
|
||||
try {
|
||||
@@ -43,6 +44,7 @@ async function copyAddress() {
|
||||
<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">
|
||||
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" />
|
||||
<SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
|
||||
|
||||
<template v-else>
|
||||
<header class="flex flex-col items-center gap-1 text-center">
|
||||
@@ -57,7 +59,7 @@ async function copyAddress() {
|
||||
/>
|
||||
|
||||
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||
<SkinSiteNavCards />
|
||||
<SkinSiteNavCards @navigate="(v) => (view = v)" />
|
||||
<SkinSiteAnnouncements />
|
||||
</div>
|
||||
|
||||
|
||||
@@ -12,6 +12,8 @@ import type { Component } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const emit = defineEmits<{ (e: 'navigate', view: 'downloads' | 'review' | 'bugs'): void }>()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
|
||||
download: { id: 'app.skin-site.home.nav.download', defaultMessage: 'Downloads' },
|
||||
@@ -101,8 +103,12 @@ const items: NavItem[] = [
|
||||
},
|
||||
]
|
||||
|
||||
function open(href: string) {
|
||||
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(href))
|
||||
function open(item: NavItem) {
|
||||
if (item.id === 'download' || item.id === 'review' || item.id === 'bugs') {
|
||||
emit('navigate', item.id === 'download' ? 'downloads' : item.id)
|
||||
return
|
||||
}
|
||||
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href))
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -115,7 +121,7 @@ function open(href: string) {
|
||||
: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)"
|
||||
@click="open(item)"
|
||||
>
|
||||
<component :is="item.icon" class="size-5 text-brand" />
|
||||
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
|
||||
|
||||
@@ -480,3 +480,55 @@ export async function skinSiteUploadAvatar(blob: Blob): Promise<SkinSiteResponse
|
||||
}
|
||||
return processResponse<string>(response)
|
||||
}
|
||||
// ---------------------------------------------------------------------------
|
||||
// Downloads
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export type SkinSiteDownloadStatus = 'OK' | 'NEW' | 'LEGACY' | 'OUTDATED' | 'REMOVAL'
|
||||
|
||||
export interface SkinSiteDownloadRoute {
|
||||
name?: string | null
|
||||
url: string
|
||||
}
|
||||
|
||||
export interface SkinSiteDownloadItem {
|
||||
id: number
|
||||
uploader: string
|
||||
url: string
|
||||
routes: SkinSiteDownloadRoute[]
|
||||
filename: string
|
||||
description: string
|
||||
status: SkinSiteDownloadStatus[]
|
||||
category: string[]
|
||||
}
|
||||
|
||||
export interface SkinSiteDownloadCategory {
|
||||
id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export function skinSiteDownloadList(options?: {
|
||||
limit?: number
|
||||
offset?: number
|
||||
category?: string
|
||||
}): Promise<SkinSiteResponse<SkinSiteDownloadItem[]>> {
|
||||
const query = new URLSearchParams({
|
||||
limit: String(options?.limit ?? 200),
|
||||
offset: String(options?.offset ?? 0),
|
||||
})
|
||||
if (options?.category) query.set('category', options.category)
|
||||
return skinSiteRequest<SkinSiteDownloadItem[]>(`download/list?${query.toString()}`, {
|
||||
auth: false,
|
||||
})
|
||||
}
|
||||
|
||||
export function skinSiteDownloadCount(category?: string): Promise<SkinSiteResponse<number>> {
|
||||
const suffix = category ? `?category=${encodeURIComponent(category)}` : ''
|
||||
return skinSiteRequest<number>(`download/count${suffix}`, { auth: false })
|
||||
}
|
||||
|
||||
export function skinSiteDownloadCategories(): Promise<
|
||||
SkinSiteResponse<SkinSiteDownloadCategory[]>
|
||||
> {
|
||||
return skinSiteRequest<SkinSiteDownloadCategory[]>('download/category/list', { auth: false })
|
||||
}
|
||||
Reference in new issue
Block a user