fix: 修复内容筛选即时切换并将警告改为仅客户端

This commit is contained in:
Disy committed 2026-09-24 23:44:40 +08:00
1 parent c2bc7c2441
commit 91f483b960
7 files changed
+179 -20

No files matched your search

@@ -38,4 +38,5 @@ export function canToggleContentItem(item: ContentItem): boolean {
export interface ContentFilterOption {
id: string
label: string
tooltip?: string
}
@@ -0,0 +1,136 @@
import assert from 'node:assert/strict'
import { registerHooks } from 'node:module'
import test, { type TestContext } from 'node:test'
import { effectScope, nextTick, ref } from 'vue'
import type { ContentItem } from '../types.ts'
// Keep the real Vue state, search, and filter pipeline; replace only the UI's
// translation provider, which normally requires an application context.
const hooks = registerHooks({
resolve(specifier, context, nextResolve) {
if (specifier === '#ui/composables/i18n' || specifier === '../composables/i18n') {
return {
url: `data:text/javascript,${encodeURIComponent('export const defineMessage=x=>x, defineMessages=x=>x, useVIntl=()=>({formatMessage:x=>x.defaultMessage})')}`,
shortCircuit: true,
}
}
if (specifier.startsWith('#ui/')) {
return nextResolve(
new URL(`../../../../${specifier.slice(4)}.ts`, import.meta.url).href,
context,
)
}
if (specifier.startsWith('./') && !specifier.endsWith('.ts')) {
return nextResolve(`${specifier}.ts`, context)
}
return nextResolve(specifier, context)
},
})
const previousWindow = Object.getOwnPropertyDescriptor(globalThis, 'window')
Object.defineProperty(globalThis, 'window', { configurable: true, value: {} })
const { useContentPipeline } = await import('./content-pipeline.ts')
hooks.deregister()
if (previousWindow) Object.defineProperty(globalThis, 'window', previousWindow)
else Reflect.deleteProperty(globalThis, 'window')
function item(id: string, overrides: Partial<ContentItem> = {}): ContentItem {
return {
id,
project: { id, title: id, slug: id, icon_url: null },
file_name: `${id}.jar`,
project_type: 'mod',
update: null,
origin_provider: null,
provider_refs: [],
enabled: true,
...overrides,
}
}
function fixture(t: TestContext) {
t.mock.timers.enable({ apis: ['setTimeout'] })
const update = {
provider: 'modrinth' as const,
project_id: 'project',
current_version_id: 'v1',
target_version_id: 'v2',
}
const items = ref([
item('ordinary'),
item('update', { update }),
item('warning', { environment: 'client_only' }),
item('both', { update, pack_client_depends: true }),
item('disabled', { enabled: false }),
])
const scope = effectScope()
t.after(() => scope.stop())
const pipeline = scope.run(() =>
useContentPipeline({
items,
duplicateItems: ref([]),
sortItems: (items) => items,
getItemId: (item) => item.id,
showUpdateFilter: true,
showWarningsFilter: true,
}),
)!
return {
...pipeline,
ids: () => pipeline.filteredItems.value.map((item) => item.id),
async flush() {
await nextTick()
t.mock.timers.tick(100)
await nextTick()
},
}
}
test('status filters update the visible list on every click without reloading items', async (t) => {
const p = fixture(t)
await p.flush()
assert.equal(p.ids().length, 5)
p.toggleStatusFilter('updates')
await p.flush()
assert.deepEqual(p.ids(), ['update', 'both'])
p.toggleStatusFilter('warnings')
await p.flush()
assert.deepEqual(p.ids(), ['both'])
p.toggleStatusFilter('updates')
await p.flush()
assert.deepEqual(p.ids(), ['warning', 'both'])
p.toggleStatusFilter('warnings')
await p.flush()
assert.equal(p.ids().length, 5)
})
test('All clears status filters and restores the visible list without a refresh', async (t) => {
const p = fixture(t)
await p.flush()
p.toggleStatusFilter('warnings')
await p.flush()
assert.deepEqual(p.ids(), ['warning', 'both'])
p.selectedStatusFilters.value = []
await p.flush()
assert.equal(p.ids().length, 5)
})
test('enabled and disabled remain exclusive and can each be deselected', async (t) => {
const p = fixture(t)
await p.flush()
p.toggleStatusFilter('enabled')
await p.flush()
assert.deepEqual(p.ids(), ['ordinary', 'update', 'warning', 'both'])
p.toggleStatusFilter('enabled')
await p.flush()
assert.equal(p.ids().length, 5)
p.toggleStatusFilter('enabled')
p.toggleStatusFilter('disabled')
await p.flush()
assert.deepEqual(p.selectedStatusFilters.value, ['disabled'])
assert.deepEqual(p.ids(), ['disabled'])
p.toggleStatusFilter('disabled')
await p.flush()
assert.equal(p.ids().length, 5)
})
@@ -62,9 +62,14 @@ const filterMessages = defineMessages({
id: 'content.filter.updates',
defaultMessage: 'Update available',
},
warnings: {
id: 'content.filter.warnings',
defaultMessage: 'Warnings',
clientOnly: {
id: 'content.filter.client-only',
defaultMessage: 'Client only',
},
clientOnlyDescription: {
id: 'content.filter.client-only-description',
defaultMessage:
'Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers.',
},
duplicates: {
id: 'content.filter.duplicates',
@@ -329,7 +334,11 @@ export function useContentPipeline(config: ContentPipelineConfig) {
row2.push({ id: 'updates', label: formatMessage(filterMessages.updates) })
}
if (showWarningsFilter && typeFiltered.some((m) => getClientWarningType(m) !== null)) {
row2.push({ id: 'warnings', label: formatMessage(filterMessages.warnings) })
row2.push({
id: 'warnings',
label: formatMessage(filterMessages.clientOnly),
tooltip: formatMessage(filterMessages.clientOnlyDescription),
})
}
if (hasEnabled && hasDisabled) {
@@ -472,26 +481,20 @@ export function useContentPipeline(config: ContentPipelineConfig) {
}
function toggleStatusFilter(filterId: string) {
if (filterId === 'enabled' || filterId === 'disabled') {
const index = selectedStatusFilters.value.indexOf(filterId)
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
if (index === -1) {
selectedStatusFilters.value = [
...selectedStatusFilters.value.filter((f) => f !== otherStatusFilter),
filterId,
]
} else {
selectedStatusFilters.value.splice(index, 1)
}
const filters = selectedStatusFilters.value
// Replace the array so both the pipeline and saved-filter watchers are notified.
if (filters.includes(filterId)) {
selectedStatusFilters.value = filters.filter((id) => id !== filterId)
return
}
const index = selectedStatusFilters.value.indexOf(filterId)
if (index === -1) {
selectedStatusFilters.value.push(filterId)
} else {
selectedStatusFilters.value.splice(index, 1)
if (filterId === 'enabled' || filterId === 'disabled') {
const otherStatusFilter = filterId === 'enabled' ? 'disabled' : 'enabled'
selectedStatusFilters.value = [...filters.filter((id) => id !== otherStatusFilter), filterId]
return
}
selectedStatusFilters.value = [...filters, filterId]
}
return {
@@ -1110,6 +1110,7 @@ const confirmUnlinkModal = ref<InstanceType<typeof ConfirmUnlinkModal>>()
<button
v-for="option in row2FilterOptions"
:key="option.id"
v-tooltip="option.tooltip"
class="relative pb-1 text-base font-semibold transition-colors"
:class="
selectedStatusFilters.includes(option.id)
+6
View File
@@ -872,6 +872,12 @@
"content.filter.updates": {
"defaultMessage": "Update available"
},
"content.filter.client-only": {
"defaultMessage": "Client only"
},
"content.filter.client-only-description": {
"defaultMessage": "Includes client-only or singleplayer-only mods and mods that depend on them. These may be incompatible with dedicated servers."
},
"content.filter.warnings": {
"defaultMessage": "Warnings"
},
+6
View File
@@ -848,6 +848,12 @@
"content.filter.updates": {
"defaultMessage": "可更新"
},
"content.filter.client-only": {
"defaultMessage": "仅客户端"
},
"content.filter.client-only-description": {
"defaultMessage": "包含仅供客户端或单人游戏使用的模组,以及依赖它们的模组。这些模组可能不兼容独立服务器。"
},
"content.filter.warnings": {
"defaultMessage": "警告"
},
+6
View File
@@ -698,6 +698,12 @@
"content.filter.updates": {
"defaultMessage": "更新"
},
"content.filter.client-only": {
"defaultMessage": "僅用戶端"
},
"content.filter.client-only-description": {
"defaultMessage": "包含僅供用戶端或單人遊戲使用的模組,以及依賴它們的模組。這些模組可能不相容於獨立伺服器。"
},
"content.filter.warnings": {
"defaultMessage": "警告"
},