Files
Starlight_Lancher/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue
T
AxTps 87de306245
Sync source to CNB / Sync Git ref (push) Skipped
Prepare pnpm cache / prepare (push) Canceled after 0s
feat: 补全皮肤站管理台 Mod 页上传与批量操作
- 新增 Mod 上传弹窗:拖拽或选择 .jar、多文件、大小限制、规则标签附加
- 上传流程:xxHash64 查重、同名检测、名称冲突确认(覆盖/跳过)、429 限流退避重试、HashDiff 重传
- Mod 列表支持多选模式:全选本页、取消选择、批量删除
- 新增服务器地址过滤,配合名称搜索与标签筛选
- 补充 Mod 相关 API:文件哈希、按哈希/名称查重、上传、批量更新
- 引入 xxhash-wasm 依赖
- 中英文各补 32 个文案
2026-10-18 12:02:39 +08:00

441 lines
12 KiB
Vue

<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
type SkinSiteModRuleTag,
skinSiteModExistsByHash,
skinSiteModExistsByName,
skinSiteModHashFromFile,
skinSiteUploadMod,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
defineProps<{ ruleTags: SkinSiteModRuleTag[] }>()
const emit = defineEmits<{ (e: 'finished'): void }>()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.upload-title', defaultMessage: 'Upload mods' },
dropHint: {
id: 'app.skin-site.admin.mods.upload-drop',
defaultMessage: 'Drag .jar files here, or click to choose',
},
dropSub: {
id: 'app.skin-site.admin.mods.upload-sub',
defaultMessage: 'Multiple files supported, up to {size} MB each',
},
tagsLabel: {
id: 'app.skin-site.admin.mods.upload-tags',
defaultMessage: 'Attach rule tags',
},
queue: { id: 'app.skin-site.admin.mods.upload-queue', defaultMessage: 'Files' },
statusPending: {
id: 'app.skin-site.admin.mods.upload-pending',
defaultMessage: 'Pending',
},
statusChecking: {
id: 'app.skin-site.admin.mods.upload-checking',
defaultMessage: 'Checking…',
},
statusUploading: {
id: 'app.skin-site.admin.mods.upload-uploading',
defaultMessage: 'Uploading…',
},
statusConflict: {
id: 'app.skin-site.admin.mods.upload-conflict',
defaultMessage: 'Name exists',
},
statusSuccess: {
id: 'app.skin-site.admin.mods.upload-success',
defaultMessage: 'Done',
},
statusError: {
id: 'app.skin-site.admin.mods.upload-error',
defaultMessage: 'Failed',
},
existsAlready: {
id: 'app.skin-site.admin.mods.upload-exists',
defaultMessage: 'This file already exists on the server.',
},
nameConflictTitle: {
id: 'app.skin-site.admin.mods.upload-name-conflict',
defaultMessage: 'A mod with the same file name already exists',
},
nameConflictHint: {
id: 'app.skin-site.admin.mods.upload-name-conflict-hint',
defaultMessage: 'Overwrite the existing mod, or skip this file.',
},
overwrite: { id: 'app.skin-site.admin.mods.upload-overwrite', defaultMessage: 'Overwrite' },
skip: { id: 'app.skin-site.admin.mods.upload-skip', defaultMessage: 'Skip' },
close: { id: 'app.skin-site.admin.mods.upload-close', defaultMessage: 'Close' },
allDone: {
id: 'app.skin-site.admin.mods.upload-all-done',
defaultMessage: 'All files processed.',
},
tooLarge: {
id: 'app.skin-site.admin.mods.upload-too-large',
defaultMessage: 'File too large: {name}',
},
})
const MAX_MOD_SIZE_MB = 200
const MAX_RETRIES = 3
type TaskStatus = 'pending' | 'checking' | 'uploading' | 'conflict' | 'success' | 'error'
interface Task {
file: File
status: TaskStatus
message?: string
attempts: number
}
const queue = ref<Task[]>([])
const selectedTagIds = ref<Set<number>>(new Set())
const fileInput = ref<HTMLInputElement | null>(null)
const busy = ref(false)
const conflictTask = ref<Task | null>(null)
const finishedCount = computed(
() => queue.value.filter((t) => t.status === 'success' || t.status === 'error').length,
)
const allDone = computed(() => queue.value.length > 0 && finishedCount.value === queue.value.length)
const hasWork = computed(() => queue.value.length > 0)
function toggleTag(id: number) {
const next = new Set(selectedTagIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedTagIds.value = next
}
function pickFiles() {
if (busy.value) return
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
enqueue(files)
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (busy.value) return
enqueue(Array.from(event.dataTransfer?.files ?? []))
}
function enqueue(files: File[]) {
const jars = files.filter((f) => f.name.toLowerCase().endsWith('.jar'))
if (!jars.length) return
const accepted: Task[] = []
for (const file of jars) {
if (file.size > MAX_MOD_SIZE_MB * 1024 * 1024) {
addNotification({
type: 'error',
title: formatMessage(messages.tooLarge, { name: file.name }),
})
continue
}
accepted.push({ file, status: 'pending', attempts: 0 })
}
if (!accepted.length) return
queue.value = [...queue.value, ...accepted]
void runQueue()
}
async function runQueue() {
if (busy.value) return
busy.value = true
try {
for (;;) {
const task = queue.value.find((t) => t.status === 'pending')
if (!task) break
await processTask(task)
if (task.status === 'conflict') break
}
} finally {
busy.value = false
}
if (allDone.value) {
addNotification({ type: 'success', title: formatMessage(messages.allDone) })
emit('finished')
}
}
async function processTask(task: Task) {
task.status = 'checking'
task.message = undefined
try {
const hash = await skinSiteModHashFromFile(task.file)
const exists = await skinSiteModExistsByHash(hash)
if (exists.success) {
task.status = 'error'
task.message = formatMessage(messages.existsAlready)
return
}
const nameExists = await skinSiteModExistsByName(task.file.name)
if (nameExists.success) {
task.status = 'conflict'
conflictTask.value = task
return
}
await upload(task, false)
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
handleError(error)
}
}
async function upload(task: Task, overwrite: boolean) {
task.status = 'uploading'
try {
const res = await skinSiteUploadMod(task.file, Array.from(selectedTagIds.value), overwrite)
if (res.success) {
task.status = 'success'
return
}
if (res.errorCode === 429) {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'rate limited, retrying'
setTimeout(() => void runQueue(), 1500 * task.attempts)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'HashDiff') {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'hash mismatch, retrying'
setTimeout(() => void runQueue(), 800)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'ModNameConflict') {
task.status = 'conflict'
conflictTask.value = task
return
}
task.status = 'error'
task.message = res.errorMessage
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
}
}
async function resolveConflict(overwrite: boolean) {
const task = conflictTask.value
conflictTask.value = null
if (!task) return
if (overwrite) {
busy.value = true
try {
await upload(task, true)
} finally {
busy.value = false
}
} else {
task.status = 'error'
task.message = formatMessage(messages.skip)
}
void runQueue()
}
function close() {
if (busy.value) return
queue.value = []
conflictTask.value = null
emit('finished')
}
function statusLabel(status: TaskStatus): string {
switch (status) {
case 'pending':
return formatMessage(messages.statusPending)
case 'checking':
return formatMessage(messages.statusChecking)
case 'uploading':
return formatMessage(messages.statusUploading)
case 'conflict':
return formatMessage(messages.statusConflict)
case 'success':
return formatMessage(messages.statusSuccess)
default:
return formatMessage(messages.statusError)
}
}
function statusClass(status: TaskStatus): string {
switch (status) {
case 'success':
return 'bg-green text-contrast'
case 'error':
return 'bg-red text-contrast'
case 'conflict':
return 'bg-yellow text-contrast'
default:
return 'bg-button-bg text-secondary'
}
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click.self="close">
<div class="flex max-h-[88vh] w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-xl bg-bg p-6">
<div class="flex items-start justify-between gap-3">
<h3 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h3>
<button
type="button"
class="shrink-0 rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
✕
</button>
</div>
<div
class="flex min-h-40 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-surface-5 p-6 text-center transition-colors hover:bg-bg-raised"
:class="busy ? 'cursor-not-allowed opacity-60' : ''"
@dragover.prevent
@dragenter.prevent
@drop.prevent="onDrop"
@click="pickFiles"
>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept=".jar"
@change="onFileChange"
/>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.dropHint) }}
</span>
<span class="text-xs text-secondary">
{{ formatMessage(messages.dropSub, { size: MAX_MOD_SIZE_MB }) }}
</span>
</div>
<div v-if="ruleTags.length" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.tagsLabel) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in ruleTags"
:key="tag.id"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedTagIds.has(tag.id)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
</button>
</div>
</div>
<div v-if="hasWork" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.queue) }} ({{ finishedCount }}/{{ queue.length }})
</span>
<div class="flex max-h-64 flex-col gap-1.5 overflow-y-auto">
<div
v-for="(task, index) in queue"
:key="task.file.name + index"
class="flex flex-wrap items-center gap-2 rounded-lg bg-bg-raised px-3 py-2"
>
<span class="min-w-0 flex-1 truncate text-xs text-contrast">
{{ task.file.name }}
</span>
<span class="shrink-0 text-[10px] text-secondary">
{{ (task.file.size / 1024 / 1024).toFixed(2) }} MB
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="statusClass(task.status)"
>
{{ statusLabel(task.status) }}
</span>
<span
v-if="task.message"
class="w-full truncate text-[10px] text-secondary"
:title="task.message"
>
{{ task.message }}
</span>
</div>
</div>
</div>
<div
v-if="busy"
class="flex items-center gap-2 text-xs text-secondary"
>
<SpinnerIcon class="size-3.5 animate-spin" />
{{ formatMessage(messages.statusUploading) }}
</div>
<div class="flex justify-end gap-2 border-t border-solid border-surface-5 pt-3">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
{{ formatMessage(messages.close) }}
</button>
</div>
</div>
<div
v-if="conflictTask"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
>
<div class="flex w-full max-w-md flex-col gap-3 rounded-xl bg-bg p-6">
<h4 class="m-0 text-base font-bold text-contrast">
{{ formatMessage(messages.nameConflictTitle) }}
</h4>
<p class="m-0 text-xs break-all text-secondary">{{ conflictTask.file.name }}</p>
<p class="m-0 text-xs text-secondary">{{ formatMessage(messages.nameConflictHint) }}</p>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="resolveConflict(false)"
>
{{ formatMessage(messages.skip) }}
</button>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="resolveConflict(true)"
>
{{ formatMessage(messages.overwrite) }}
</button>
</div>
</div>
</div>
</div>
</template>