移植 Axolotl 的拖拽式实例分组界面到 Starlight,接入阶段1的规范化分组后端: - 新增 helpers/analytics.ts(trackEvent 遥测桩) - 补全 helpers/instance-groups.ts(6 个 invoke 包装) - 新增 composables/useInstanceGroups.ts(分组状态 + 事件监听) - 升级 composables/useGridGrouping.ts(排序方向 isSortAscending/toggleSortDirection) - helpers/events.js 补 instance_groups_listener(对接 instance_groups_changed 事件) - 新增 components/ui/library/DraggableInstanceCard.vue - 新增 components/ui/library/InstanceGroup.vue(并补 group-drag-handle 修复组拖拽重排) - 新增 components/ui/modal/InstanceGroupModal.vue - GridDisplay.vue 替换为 Axolotl 拖拽版(DragDropProvider + vuedraggable) - 补 en-US/zh-CN i18n 键各 39 条
This commit is contained in:
1 parent
253141b6ff
commit
f253d5529b
11 files changed
+2214
-297
No files matched your search
@@ -0,0 +1,313 @@
|
||||
<template>
|
||||
<NewModal ref="modal" no-padding scrollable actions-divider max-width="560px" width="560px">
|
||||
<template #title>
|
||||
<span class="text-2xl font-semibold text-[var(--color-text-primary)]">
|
||||
{{
|
||||
existingGroupName
|
||||
? formatMessage(messages.addToGroupTitle, { groupName: existingGroupName })
|
||||
: formatMessage(messages.header)
|
||||
}}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<template v-if="!existingGroupName">
|
||||
<div class="flex flex-col gap-2.5 p-6">
|
||||
<label for="new-group-name" class="font-semibold text-[var(--color-text-primary)]">
|
||||
{{ formatMessage(messages.groupName) }}
|
||||
</label>
|
||||
<StyledInput
|
||||
id="new-group-name"
|
||||
ref="groupNameInput"
|
||||
v-model="newGroupName"
|
||||
:placeholder="formatMessage(messages.groupNamePlaceholder)"
|
||||
:maxlength="32"
|
||||
@click="groupNameInput?.select()"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="h-px bg-divider" />
|
||||
</template>
|
||||
|
||||
<div class="flex h-[400px] flex-col gap-3 overflow-y-auto bg-surface-2 py-4">
|
||||
<div class="px-6">
|
||||
<StyledInput
|
||||
v-model="newGroupSearch"
|
||||
:icon="SearchIcon"
|
||||
:placeholder="formatMessage(messages.searchInstance)"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="newGroupInstances.length === 0"
|
||||
class="flex items-center justify-center py-12 text-[var(--color-text-tertiary)]"
|
||||
>
|
||||
{{ formatMessage(messages.noInstancesFound) }}
|
||||
</div>
|
||||
<div v-else class="flex flex-col gap-1">
|
||||
<div
|
||||
v-for="instance in newGroupInstances"
|
||||
:key="instance.id"
|
||||
class="flex items-center justify-between gap-4 px-6 py-1.5 hover:bg-surface-3"
|
||||
:class="{ 'opacity-60': selectedNewGroupInstanceIds.has(instance.id) }"
|
||||
>
|
||||
<div class="flex min-w-0 items-center gap-2.5">
|
||||
<InstanceIcon
|
||||
:icon-path="instance.icon_path"
|
||||
:instance-id="instance.id"
|
||||
:loader="instance.loader"
|
||||
class="!size-[2rem] !rounded-md"
|
||||
/>
|
||||
<div class="flex min-w-0 items-center gap-2">
|
||||
<span class="truncate font-semibold text-[var(--color-text-primary)]">{{
|
||||
instance.name
|
||||
}}</span>
|
||||
<TagItem v-if="instance.groups && instance.groups.length > 0" class="shrink-0">
|
||||
{{ getGroupName(instance.groups[0]) }}
|
||||
</TagItem>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
:type="selectedNewGroupInstanceIds.has(instance.id) ? 'outlined' : 'base'"
|
||||
@click="toggleNewGroupInstance(instance.id)"
|
||||
>
|
||||
<CheckIcon v-if="selectedNewGroupInstanceIds.has(instance.id)" />
|
||||
{{
|
||||
formatMessage(
|
||||
selectedNewGroupInstanceIds.has(instance.id) ? messages.added : messages.add,
|
||||
)
|
||||
}}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template #actions>
|
||||
<div class="flex items-center justify-end gap-2">
|
||||
<Button type="outlined" @click="modal?.hide()">
|
||||
<XIcon />
|
||||
{{ formatMessage(messages.cancel) }}
|
||||
</Button>
|
||||
<Button
|
||||
type="colored"
|
||||
color="brand"
|
||||
:disabled="existingGroupName ? !canSaveGroups : !canCreateGroup"
|
||||
@click="existingGroupName ? handleSaveGroups() : handleCreateGroup()"
|
||||
>
|
||||
<SpinnerIcon v-if="creatingGroup" class="animate-spin" />
|
||||
<CheckIcon v-else-if="existingGroupName" />
|
||||
<PlusIcon v-else />
|
||||
{{ formatMessage(existingGroupName ? messages.saveChanges : messages.createGroup) }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { CheckIcon, PlusIcon, SearchIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import { Button, defineMessages, NewModal, StyledInput, TagItem, useVIntl } from '@modrinth/ui'
|
||||
import { computed, ref } from 'vue'
|
||||
|
||||
import InstanceIcon from '@/components/ui/InstanceIcon.vue'
|
||||
import { FAVORITES_GROUP_ID } from '@/composables/useInstanceGroups'
|
||||
import { list } from '@/helpers/instance'
|
||||
import { create_group, list_groups, set_group_memberships } from '@/helpers/instance-groups'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const props = defineProps<{
|
||||
instanceIds: string[]
|
||||
existingGroupName?: string
|
||||
existingGroupId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'applied'): void
|
||||
}>()
|
||||
|
||||
const messages = defineMessages({
|
||||
header: {
|
||||
id: 'app.instances.batch-edit-groups.header',
|
||||
defaultMessage: 'Create group',
|
||||
},
|
||||
addToGroupTitle: {
|
||||
id: 'app.instances.batch-edit-groups.add-to-group-title',
|
||||
defaultMessage: 'Add instances to "{groupName}"',
|
||||
},
|
||||
groupName: {
|
||||
id: 'app.instances.batch-edit-groups.group-name',
|
||||
defaultMessage: 'Group name',
|
||||
},
|
||||
groupNamePlaceholder: {
|
||||
id: 'app.instances.batch-edit-groups.group-name-placeholder',
|
||||
defaultMessage: 'Enter group name',
|
||||
},
|
||||
searchInstance: {
|
||||
id: 'app.instances.batch-edit-groups.search-instance',
|
||||
defaultMessage: 'Search instances',
|
||||
},
|
||||
noInstancesFound: {
|
||||
id: 'app.instances.batch-edit-groups.no-instances-found',
|
||||
defaultMessage: 'No instances found',
|
||||
},
|
||||
add: {
|
||||
id: 'app.instances.batch-edit-groups.add',
|
||||
defaultMessage: 'Add',
|
||||
},
|
||||
added: {
|
||||
id: 'app.instances.batch-edit-groups.added',
|
||||
defaultMessage: 'Added',
|
||||
},
|
||||
cancel: {
|
||||
id: 'app.instances.batch-edit-groups.cancel',
|
||||
defaultMessage: 'Cancel',
|
||||
},
|
||||
createGroup: {
|
||||
id: 'app.instances.batch-edit-groups.create',
|
||||
defaultMessage: 'Create group',
|
||||
},
|
||||
pinnedGroupName: {
|
||||
id: 'app.instances.group.pinned',
|
||||
defaultMessage: 'Pinned',
|
||||
},
|
||||
saveChanges: {
|
||||
id: 'app.instances.batch-edit-groups.save-changes',
|
||||
defaultMessage: 'Save',
|
||||
},
|
||||
})
|
||||
|
||||
const modal = ref<InstanceType<typeof NewModal>>()
|
||||
const groupNameInput = ref<InstanceType<typeof StyledInput>>()
|
||||
const newGroupName = ref('')
|
||||
const newGroupSearch = ref('')
|
||||
const creatingGroup = ref(false)
|
||||
const allInstances = ref<GameInstance[]>([])
|
||||
const groupNameMap = ref(new Map<string, string>())
|
||||
const selectedNewGroupInstanceIds = ref(new Set<string>())
|
||||
|
||||
const canCreateGroup = computed(() => newGroupName.value.trim().length > 0)
|
||||
|
||||
const canSaveGroups = computed(() => {
|
||||
if (!props.existingGroupName) return false
|
||||
const groupId = props.existingGroupId
|
||||
if (!groupId) return false
|
||||
return allInstances.value.some((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
const hasGroup = (instance.groups || []).includes(groupId)
|
||||
return isSelected !== hasGroup
|
||||
})
|
||||
})
|
||||
|
||||
const newGroupInstances = computed(() => {
|
||||
const search = newGroupSearch.value.toLowerCase()
|
||||
return allInstances.value.filter((instance) => {
|
||||
if (!search) return true
|
||||
return instance.name.toLowerCase().includes(search)
|
||||
})
|
||||
})
|
||||
|
||||
function getGroupName(groupId: string) {
|
||||
if (groupId === FAVORITES_GROUP_ID) {
|
||||
return formatMessage(messages.pinnedGroupName)
|
||||
}
|
||||
return groupNameMap.value.get(groupId) || groupId
|
||||
}
|
||||
|
||||
function show(ids?: string[]) {
|
||||
newGroupSearch.value = ''
|
||||
creatingGroup.value = false
|
||||
Promise.all([list(), list_groups()]).then(([instances, groups]) => {
|
||||
allInstances.value = instances as GameInstance[]
|
||||
const map = new Map<string, string>()
|
||||
for (const g of groups) {
|
||||
map.set(g.id, g.name)
|
||||
}
|
||||
groupNameMap.value = map
|
||||
|
||||
if (!props.existingGroupName) {
|
||||
const customGroups = groups.filter((g: { id: string }) => g.id !== 'group:favorites')
|
||||
let groupNumber = customGroups.length + 1
|
||||
const existingNames = new Set(groups.map((g: { name: string }) => g.name.toLowerCase()))
|
||||
while (existingNames.has(`group ${groupNumber}`)) {
|
||||
groupNumber++
|
||||
}
|
||||
newGroupName.value = `Group ${groupNumber}`
|
||||
}
|
||||
|
||||
if (props.existingGroupName) {
|
||||
const groupId = props.existingGroupId
|
||||
selectedNewGroupInstanceIds.value = new Set(
|
||||
allInstances.value.filter((i) => (i.groups || []).includes(groupId || '')).map((i) => i.id),
|
||||
)
|
||||
} else {
|
||||
selectedNewGroupInstanceIds.value = new Set(ids ?? props.instanceIds)
|
||||
}
|
||||
})
|
||||
modal.value?.show()
|
||||
}
|
||||
|
||||
function toggleNewGroupInstance(instanceId: string) {
|
||||
const next = new Set(selectedNewGroupInstanceIds.value)
|
||||
if (next.has(instanceId)) {
|
||||
next.delete(instanceId)
|
||||
} else {
|
||||
next.add(instanceId)
|
||||
}
|
||||
selectedNewGroupInstanceIds.value = next
|
||||
}
|
||||
|
||||
async function handleCreateGroup() {
|
||||
const name = newGroupName.value.trim()
|
||||
if (!name || selectedNewGroupInstanceIds.value.size === 0) return
|
||||
|
||||
creatingGroup.value = true
|
||||
|
||||
const group = await create_group(name).catch(() => null)
|
||||
if (!group) {
|
||||
creatingGroup.value = false
|
||||
return
|
||||
}
|
||||
|
||||
if (selectedNewGroupInstanceIds.value.size > 0) {
|
||||
const updates = [...selectedNewGroupInstanceIds.value].map((instanceId) => ({
|
||||
instance_id: instanceId,
|
||||
group_ids: [group.id],
|
||||
}))
|
||||
await set_group_memberships(updates).catch(() => {})
|
||||
}
|
||||
|
||||
creatingGroup.value = false
|
||||
modal.value?.hide()
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
async function handleSaveGroups() {
|
||||
if (!props.existingGroupName) return
|
||||
|
||||
creatingGroup.value = true
|
||||
const groupId = props.existingGroupId
|
||||
|
||||
const changedInstances = allInstances.value.filter((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
const hasGroup = (instance.groups || []).includes(groupId || '')
|
||||
return isSelected !== hasGroup
|
||||
})
|
||||
|
||||
const updates = changedInstances.map((instance) => {
|
||||
const isSelected = selectedNewGroupInstanceIds.value.has(instance.id)
|
||||
return {
|
||||
instance_id: instance.id,
|
||||
group_ids: isSelected ? [groupId!] : [],
|
||||
}
|
||||
})
|
||||
await set_group_memberships(updates).catch(() => {})
|
||||
|
||||
creatingGroup.value = false
|
||||
modal.value?.hide()
|
||||
emit('applied')
|
||||
}
|
||||
|
||||
defineExpose({ show })
|
||||
</script>
|
||||
Reference in new issue
Block a user