From 87de3062452fb2a111364889a11349c617c56853 Mon Sep 17 00:00:00 2001 From: Ap_Tx <3045929398@qq.com> Date: Sun, 18 Oct 2026 12:02:39 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=A1=A5=E5=85=A8=E7=9A=AE=E8=82=A4?= =?UTF-8?q?=E7=AB=99=E7=AE=A1=E7=90=86=E5=8F=B0=20Mod=20=E9=A1=B5=E4=B8=8A?= =?UTF-8?q?=E4=BC=A0=E4=B8=8E=E6=89=B9=E9=87=8F=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 Mod 上传弹窗:拖拽或选择 .jar、多文件、大小限制、规则标签附加 - 上传流程:xxHash64 查重、同名检测、名称冲突确认(覆盖/跳过)、429 限流退避重试、HashDiff 重传 - Mod 列表支持多选模式:全选本页、取消选择、批量删除 - 新增服务器地址过滤,配合名称搜索与标签筛选 - 补充 Mod 相关 API:文件哈希、按哈希/名称查重、上传、批量更新 - 引入 xxhash-wasm 依赖 - 中英文各补 32 个文案 --- apps/app-frontend/package.json | 3 +- .../ui/skin-site/admin/AdminModsPage.vue | 196 +++++++- .../ui/skin-site/admin/ModUploadModal.vue | 440 ++++++++++++++++++ .../app-frontend/src/helpers/skin-site-api.ts | 56 +++ .../app-frontend/src/locales/en-US/index.json | 34 +- .../app-frontend/src/locales/zh-CN/index.json | 34 +- pnpm-lock.yaml | 8 + 7 files changed, 767 insertions(+), 4 deletions(-) create mode 100644 apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index f10352a..b24ed39 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -50,7 +50,8 @@ "vue": "^3.5.13", "vue-i18n": "^10.0.0", "vue-router": "^4.6.0", - "vuedraggable": "^4.1.0" + "vuedraggable": "^4.1.0", + "xxhash-wasm": "1.1.0" }, "devDependencies": { "@eslint/compat": "^1.1.1", diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue b/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue index 5aab3a6..f902f81 100644 --- a/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue +++ b/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue @@ -9,6 +9,7 @@ import { skinSiteDeleteModFile, skinSiteDeleteModRuleTag, skinSiteDeleteModRuntime, + skinSiteDeleteMultiModFile, skinSiteModCount, type SkinSiteModInfo, skinSiteModList, @@ -20,9 +21,12 @@ import { skinSiteModSearch, skinSiteUpdateModInfo, skinSiteUpdateModRuleTag, + skinSiteUpdateMultiModInfo, skinSiteUploadModRuntime, } from '@/helpers/skin-site-api' +import ModUploadModal from './ModUploadModal.vue' + const { formatMessage } = useVIntl() const { handleError, addNotification } = injectNotificationManager() @@ -92,6 +96,46 @@ const messages = defineMessages({ unavailable: { id: 'app.skin-site.admin.mods.unavailable', defaultMessage: 'Unavailable' }, active: { id: 'app.skin-site.admin.mods.active', defaultMessage: 'Active' }, uploader: { id: 'app.skin-site.admin.mods.uploader', defaultMessage: 'by {name}' }, + upload: { id: 'app.skin-site.admin.mods.upload', defaultMessage: 'Upload' }, + selectMode: { + id: 'app.skin-site.admin.mods.select-mode', + defaultMessage: 'Select multiple', + }, + exitSelect: { + id: 'app.skin-site.admin.mods.exit-select', + defaultMessage: 'Done selecting', + }, + selectAll: { id: 'app.skin-site.admin.mods.select-all', defaultMessage: 'Select all' }, + clearSelection: { + id: 'app.skin-site.admin.mods.clear-selection', + defaultMessage: 'Clear selection', + }, + selectedCount: { + id: 'app.skin-site.admin.mods.selected-count', + defaultMessage: '{count} selected', + }, + deleteSelected: { + id: 'app.skin-site.admin.mods.delete-selected', + defaultMessage: 'Delete selected', + }, + confirmDeleteMulti: { + id: 'app.skin-site.admin.mods.confirm-delete-multi', + defaultMessage: 'Delete {count} selected mods? This cannot be undone.', + }, + deletedMulti: { + id: 'app.skin-site.admin.mods.deleted-multi', + defaultMessage: 'Selected mods deleted.', + }, + serverAddress: { + id: 'app.skin-site.admin.mods.server-address', + defaultMessage: 'Server address', + }, + serverAddressHint: { + id: 'app.skin-site.admin.mods.server-address-hint', + defaultMessage: 'Filter by server address', + }, + apply: { id: 'app.skin-site.admin.mods.apply', defaultMessage: 'Apply' }, + clear: { id: 'app.skin-site.admin.mods.clear', defaultMessage: 'Clear' }, }) type Tab = 'mods' | 'tags' | 'runtime' @@ -112,6 +156,13 @@ const editForm = ref({ name: '', modId: '', version: '', domainRules: '' }) const savingMod = ref(false) const deletingHash = ref(null) +const selectedHashes = ref>(new Set()) +const selectionMode = ref(false) +const deletingMulti = ref(false) +const serverAddressInput = ref('') +const serverAddressFilter = ref('') +const uploadOpen = ref(false) + const tags = ref([]) const tagsLoading = ref(false) const tagEditorOpen = ref(false) @@ -173,8 +224,12 @@ async function loadMods(reset = true) { limit: PAGE_SIZE, offset: modOffset.value, ruleTagId: tagFilter.value, + serverAddr: serverAddressFilter.value || undefined, + }), + skinSiteModCount({ + ruleTagId: tagFilter.value, + serverAddr: serverAddressFilter.value || undefined, }), - skinSiteModCount({ ruleTagId: tagFilter.value }), ]) if (!listResult.success) throw new Error(listResult.errorMessage) mods.value = listResult.payload ?? [] @@ -293,6 +348,56 @@ async function removeMod(mod: SkinSiteModInfo) { } } +function toggleSelect(hash: string) { + const next = new Set(selectedHashes.value) + if (next.has(hash)) next.delete(hash) + else next.add(hash) + selectedHashes.value = next +} + +function selectAllVisible() { + selectedHashes.value = new Set(mods.value.map((m) => m.hash)) +} + +function clearSelection() { + selectedHashes.value = new Set() +} + +function exitSelectionMode() { + selectionMode.value = false + clearSelection() +} + +async function deleteSelected() { + if (deletingMulti.value || !selectedHashes.value.size) return + const count = selectedHashes.value.size + if (!window.confirm(formatMessage(messages.confirmDeleteMulti, { count }))) return + deletingMulti.value = true + try { + const result = await skinSiteDeleteMultiModFile(Array.from(selectedHashes.value)) + if (!result.success) throw new Error(result.errorMessage) + addNotification({ type: 'success', title: formatMessage(messages.deletedMulti) }) + clearSelection() + selectionMode.value = false + await loadMods(false) + } catch (error) { + handleError(error) + } finally { + deletingMulti.value = false + } +} + +function applyServerAddress() { + serverAddressFilter.value = serverAddressInput.value.trim() + void loadMods(true) +} + +function clearServerAddress() { + serverAddressInput.value = '' + serverAddressFilter.value = '' + void loadMods(true) +} + function openNewTag() { editingTagId.value = null tagForm.value = { name: '', rules: '' } @@ -456,9 +561,84 @@ onMounted(async () => { + + + {{ formatMessage(messages.total, { count: modTotal }) }} + + {{ formatMessage(messages.selectedCount, { count: selectedHashes.size }) }} + + + +
+ + +
@@ -476,8 +656,16 @@ onMounted(async () => { v-for="mod in mods" :key="mod.hash" class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3" + :class="selectionMode && selectedHashes.has(mod.hash) ? 'ring-2 ring-brand' : ''" >
+ {{ mod.metadata.name || mod.name }} @@ -784,5 +972,11 @@ onMounted(async () => {
+ + diff --git a/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue b/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue new file mode 100644 index 0000000..a8485e2 --- /dev/null +++ b/apps/app-frontend/src/components/ui/skin-site/admin/ModUploadModal.vue @@ -0,0 +1,440 @@ + + + diff --git a/apps/app-frontend/src/helpers/skin-site-api.ts b/apps/app-frontend/src/helpers/skin-site-api.ts index eb2e9f5..4f3e1ae 100644 --- a/apps/app-frontend/src/helpers/skin-site-api.ts +++ b/apps/app-frontend/src/helpers/skin-site-api.ts @@ -2015,3 +2015,59 @@ export function skinSiteAdminInviteUser(email: string): Promise { + return import('xxhash-wasm').then(async ({ default: xxhash }) => { + const { create64 } = await xxhash() + const hasher = create64() + const reader = file.stream().getReader() + for (;;) { + const { done, value } = await reader.read() + if (done) break + hasher.update(value) + } + const raw: bigint = hasher.digest() + const mask = (1n << 64n) - 1n + const v = raw & mask + const signed = v >= 1n << 63n ? v - (1n << 64n) : v + return signed.toString() + }) +} + +export function skinSiteModExistsByHash(hash: string): Promise> { + return skinSiteRequest('query/hash/' + encodeURIComponent(hash), { type: 'mod' }) +} + +export function skinSiteModExistsByName(modName: string): Promise> { + const q = new URLSearchParams({ mod_name: modName }) + return skinSiteRequest('query/name?' + q.toString(), { type: 'mod' }) +} + +export async function skinSiteUploadMod( + file: File, + ruleTagIds: number[] = [], + overwrite = false, +): Promise> { + const hash = await skinSiteModHashFromFile(file) + const form = new FormData() + form.append('mods', file, file.name) + const query = new URLSearchParams() + if (ruleTagIds.length) query.set('rule_tag_ids', ruleTagIds.join(',')) + if (overwrite) query.set('overwrite', 'true') + const suffix = query.size ? '?' + query.toString() : '' + return skinSiteRequest('upload/' + hash + suffix, { + method: 'POST', + type: 'mod', + form, + }) +} + +export function skinSiteUpdateMultiModInfo( + mods: SkinSiteModInfo[], +): Promise> { + return skinSiteRequest('update/info/multi', { + method: 'POST', + type: 'mod', + body: mods, + }) +} diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json index 4d34b60..cc17884 100644 --- a/apps/app-frontend/src/locales/en-US/index.json +++ b/apps/app-frontend/src/locales/en-US/index.json @@ -12217,5 +12217,37 @@ "app.skin-site.admin.users.registered-at": {"message": "Registered at"}, "app.skin-site.admin.users.status": {"message": "Status"}, "app.skin-site.admin.users.status-banned": {"message": "Banned"}, - "app.skin-site.admin.users.status-normal": {"message": "Normal"} + "app.skin-site.admin.users.status-normal": {"message": "Normal"}, + "app.skin-site.admin.mods.upload": {"message": "Upload"}, + "app.skin-site.admin.mods.select-mode": {"message": "Select multiple"}, + "app.skin-site.admin.mods.exit-select": {"message": "Done selecting"}, + "app.skin-site.admin.mods.select-all": {"message": "Select all"}, + "app.skin-site.admin.mods.clear-selection": {"message": "Clear selection"}, + "app.skin-site.admin.mods.selected-count": {"message": "{count} selected"}, + "app.skin-site.admin.mods.delete-selected": {"message": "Delete selected"}, + "app.skin-site.admin.mods.confirm-delete-multi": {"message": "Delete {count} selected mods? This cannot be undone."}, + "app.skin-site.admin.mods.deleted-multi": {"message": "Selected mods deleted."}, + "app.skin-site.admin.mods.server-address": {"message": "Server address"}, + "app.skin-site.admin.mods.server-address-hint": {"message": "Filter by server address"}, + "app.skin-site.admin.mods.apply": {"message": "Apply"}, + "app.skin-site.admin.mods.clear": {"message": "Clear"}, + "app.skin-site.admin.mods.upload-title": {"message": "Upload mods"}, + "app.skin-site.admin.mods.upload-drop": {"message": "Drag .jar files here, or click to choose"}, + "app.skin-site.admin.mods.upload-sub": {"message": "Multiple files supported, up to {size} MB each"}, + "app.skin-site.admin.mods.upload-tags": {"message": "Attach rule tags"}, + "app.skin-site.admin.mods.upload-queue": {"message": "Files"}, + "app.skin-site.admin.mods.upload-pending": {"message": "Pending"}, + "app.skin-site.admin.mods.upload-checking": {"message": "Checking…"}, + "app.skin-site.admin.mods.upload-uploading": {"message": "Uploading…"}, + "app.skin-site.admin.mods.upload-conflict": {"message": "Name exists"}, + "app.skin-site.admin.mods.upload-success": {"message": "Done"}, + "app.skin-site.admin.mods.upload-error": {"message": "Failed"}, + "app.skin-site.admin.mods.upload-exists": {"message": "This file already exists on the server."}, + "app.skin-site.admin.mods.upload-name-conflict": {"message": "A mod with the same file name already exists"}, + "app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "Overwrite the existing mod, or skip this file."}, + "app.skin-site.admin.mods.upload-overwrite": {"message": "Overwrite"}, + "app.skin-site.admin.mods.upload-skip": {"message": "Skip"}, + "app.skin-site.admin.mods.upload-close": {"message": "Close"}, + "app.skin-site.admin.mods.upload-all-done": {"message": "All files processed."}, + "app.skin-site.admin.mods.upload-too-large": {"message": "File too large: {name}"} } diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json index 56b811a..13b7baa 100644 --- a/apps/app-frontend/src/locales/zh-CN/index.json +++ b/apps/app-frontend/src/locales/zh-CN/index.json @@ -12048,5 +12048,37 @@ "app.skin-site.admin.users.registered-at": {"message": "注册时间"}, "app.skin-site.admin.users.status": {"message": "状态"}, "app.skin-site.admin.users.status-banned": {"message": "已封禁"}, - "app.skin-site.admin.users.status-normal": {"message": "正常"} + "app.skin-site.admin.users.status-normal": {"message": "正常"}, + "app.skin-site.admin.mods.upload": {"message": "上传"}, + "app.skin-site.admin.mods.select-mode": {"message": "选择多个"}, + "app.skin-site.admin.mods.exit-select": {"message": "完成选择"}, + "app.skin-site.admin.mods.select-all": {"message": "全选本页"}, + "app.skin-site.admin.mods.clear-selection": {"message": "取消全选"}, + "app.skin-site.admin.mods.selected-count": {"message": "已选择 {count} 项"}, + "app.skin-site.admin.mods.delete-selected": {"message": "删除所选"}, + "app.skin-site.admin.mods.confirm-delete-multi": {"message": "确定删除选中的 {count} 个 Mod?此操作不可撤销。"}, + "app.skin-site.admin.mods.deleted-multi": {"message": "已删除所选 Mod。"}, + "app.skin-site.admin.mods.server-address": {"message": "服务器地址"}, + "app.skin-site.admin.mods.server-address-hint": {"message": "按服务器地址过滤"}, + "app.skin-site.admin.mods.apply": {"message": "应用"}, + "app.skin-site.admin.mods.clear": {"message": "清除"}, + "app.skin-site.admin.mods.upload-title": {"message": "上传 Mod"}, + "app.skin-site.admin.mods.upload-drop": {"message": "拖拽 .jar 文件到此处,或点击选择"}, + "app.skin-site.admin.mods.upload-sub": {"message": "支持多文件,单个不超过 {size} MB"}, + "app.skin-site.admin.mods.upload-tags": {"message": "附加规则标签"}, + "app.skin-site.admin.mods.upload-queue": {"message": "文件列表"}, + "app.skin-site.admin.mods.upload-pending": {"message": "等待中"}, + "app.skin-site.admin.mods.upload-checking": {"message": "检查中…"}, + "app.skin-site.admin.mods.upload-uploading": {"message": "上传中…"}, + "app.skin-site.admin.mods.upload-conflict": {"message": "名称已存在"}, + "app.skin-site.admin.mods.upload-success": {"message": "完成"}, + "app.skin-site.admin.mods.upload-error": {"message": "失败"}, + "app.skin-site.admin.mods.upload-exists": {"message": "该文件已存在于服务器。"}, + "app.skin-site.admin.mods.upload-name-conflict": {"message": "已存在同名 Mod"}, + "app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "覆盖已有 Mod,或跳过此文件。"}, + "app.skin-site.admin.mods.upload-overwrite": {"message": "覆盖"}, + "app.skin-site.admin.mods.upload-skip": {"message": "跳过"}, + "app.skin-site.admin.mods.upload-close": {"message": "关闭"}, + "app.skin-site.admin.mods.upload-all-done": {"message": "全部文件处理完成。"}, + "app.skin-site.admin.mods.upload-too-large": {"message": "文件过大:{name}"} } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f6c8ba0..ec019e5 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -188,6 +188,9 @@ importers: vuedraggable: specifier: ^4.1.0 version: 4.1.0(vue@3.5.27(typescript@5.9.3)) + xxhash-wasm: + specifier: 1.1.0 + version: 1.1.0 devDependencies: '@eslint/compat': specifier: ^1.1.1 @@ -7103,6 +7106,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + xxhash-wasm@1.1.0: + resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -14160,6 +14166,8 @@ snapshots: xtend@4.0.2: {} + xxhash-wasm@1.1.0: {} + y18n@5.0.8: {} yallist@3.1.1: {}