- 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 SkinSiteAccountCard from './SkinSiteAccountCard.vue'
|
||||||
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
|
import SkinSiteAnnouncements from './SkinSiteAnnouncements.vue'
|
||||||
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
|
import SkinSiteDonationCard from './SkinSiteDonationCard.vue'
|
||||||
|
import SkinSiteDownloadsView from './SkinSiteDownloadsView.vue'
|
||||||
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
|
import SkinSiteLoginModal from './SkinSiteLoginModal.vue'
|
||||||
import SkinSiteNavCards from './SkinSiteNavCards.vue'
|
import SkinSiteNavCards from './SkinSiteNavCards.vue'
|
||||||
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
|
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
|
||||||
@@ -27,7 +28,7 @@ const messages = defineMessages({
|
|||||||
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
|
const YGGDRASIL_URL = 'https://skin.starlight.cool/yggdrasil'
|
||||||
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
|
const loginModal = ref<InstanceType<typeof SkinSiteLoginModal>>()
|
||||||
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
|
const profileModal = ref<InstanceType<typeof SkinSiteProfileModal>>()
|
||||||
const view = ref<'home' | 'players'>('home')
|
const view = ref<'home' | 'players' | 'downloads' | 'review' | 'bugs'>('home')
|
||||||
|
|
||||||
async function copyAddress() {
|
async function copyAddress() {
|
||||||
try {
|
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="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">
|
<div class="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
|
||||||
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" />
|
<SkinSiteProfileView v-if="view === 'players'" @back="view = 'home'" />
|
||||||
|
<SkinSiteDownloadsView v-else-if="view === 'downloads'" @back="view = 'home'" />
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<header class="flex flex-col items-center gap-1 text-center">
|
<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">
|
<div class="grid grid-cols-1 gap-6 lg:grid-cols-2">
|
||||||
<SkinSiteNavCards />
|
<SkinSiteNavCards @navigate="(v) => (view = v)" />
|
||||||
<SkinSiteAnnouncements />
|
<SkinSiteAnnouncements />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ import type { Component } from 'vue'
|
|||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
|
|
||||||
|
const emit = defineEmits<{ (e: 'navigate', view: 'downloads' | 'review' | 'bugs'): void }>()
|
||||||
|
|
||||||
const messages = defineMessages({
|
const messages = defineMessages({
|
||||||
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
|
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
|
||||||
download: { id: 'app.skin-site.home.nav.download', defaultMessage: 'Downloads' },
|
download: { id: 'app.skin-site.home.nav.download', defaultMessage: 'Downloads' },
|
||||||
@@ -101,8 +103,12 @@ const items: NavItem[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
function open(href: string) {
|
function open(item: NavItem) {
|
||||||
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(href))
|
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>
|
</script>
|
||||||
|
|
||||||
@@ -115,7 +121,7 @@ function open(href: string) {
|
|||||||
:key="item.id"
|
:key="item.id"
|
||||||
type="button"
|
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"
|
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" />
|
<component :is="item.icon" class="size-5 text-brand" />
|
||||||
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
|
<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)
|
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