Files
Starlight_Lancher/apps/app-frontend/src/composables/useGridGrouping.ts
T
AxTps f253d5529b
Sync source to CNB / Sync Git ref (push) Skipped
feat: 实例分组增强前端(拖拽分组 UI)
移植 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 条
2026-10-11 07:54:48 +08:00

231 lines
6.7 KiB
TypeScript

import { useStorage } from '@vueuse/core'
import dayjs from 'dayjs'
import type { Ref } from 'vue'
import { computed } from 'vue'
export const UNGROUPED_GROUP_KEY = '__ungrouped__'
export type GridGroupingOption = 'Group' | 'Loader' | 'Game version' | 'None'
export type GridSortOption =
'Name' | 'Last played' | 'Date created' | 'Date modified' | 'Game version'
export type GridSortDirection = 'asc' | 'desc'
export const defaultSortDirections: Record<GridSortOption, GridSortDirection> = {
Name: 'asc',
'Last played': 'desc',
'Date created': 'desc',
'Date modified': 'desc',
'Game version': 'asc',
}
export interface GridDisplayState {
group: GridGroupingOption
sortBy: GridSortOption
sortDirections: Partial<Record<GridSortOption, GridSortDirection>>
collapsedGroups: string[]
}
export function useGridGrouping<T extends Record<string, unknown>>(
label: string,
instances: Ref<T[]>,
options: {
getSearchText?: (instance: T) => string
getLoader?: (instance: T) => string
getGameVersion?: (instance: T) => string
getGroups?: (instance: T) => string[]
getLastPlayed?: (instance: T) => string | number | null
getDateCreated?: (instance: T) => string
getDateModified?: (instance: T) => string
formatLoader?: (loader: string) => string
} = {},
) {
const state = useStorage<GridDisplayState>(
`${label}-grid-display-state`,
{
group: 'Group',
sortBy: 'Name',
sortDirections: {},
collapsedGroups: [],
},
localStorage,
{ mergeDefaults: true },
)
const grouping = computed(() => state.value.group)
const collapsedSectionKeys = computed(() => new Set(state.value.collapsedGroups ?? []))
const getSectionKey = (sectionName: string) => `${state.value.group}:${sectionName}`
const isSectionCollapsed = (sectionName: string) => {
return collapsedSectionKeys.value.has(getSectionKey(sectionName))
}
const setSectionCollapsed = (sectionName: string, collapsed: boolean) => {
const sectionKey = getSectionKey(sectionName)
const collapsedSections = new Set(state.value.collapsedGroups ?? [])
if (collapsed) {
collapsedSections.add(sectionKey)
} else {
collapsedSections.delete(sectionKey)
}
state.value.collapsedGroups = [...collapsedSections]
}
function sortInstances(instances: T[], sortBy: GridSortOption, ascending: boolean): T[] {
const sorted = [...instances]
const getGameVersion = options.getGameVersion ?? ((i: T) => i.game_version ?? '')
const getLastPlayed = options.getLastPlayed ?? ((i: T) => i.last_played ?? 0)
const getDateCreated = options.getDateCreated ?? ((i: T) => i.date_created ?? '')
const getDateModified = options.getDateModified ?? ((i: T) => i.date_modified ?? '')
switch (sortBy) {
case 'Name':
sorted.sort((a, b) => (a.name ?? '').localeCompare(b.name ?? ''))
break
case 'Game version':
sorted.sort((a, b) =>
getGameVersion(a).localeCompare(getGameVersion(b), undefined, { numeric: true }),
)
break
case 'Last played':
sorted.sort((a, b) => dayjs(getLastPlayed(a)).diff(dayjs(getLastPlayed(b))))
break
case 'Date created':
sorted.sort((a, b) => dayjs(getDateCreated(a)).diff(dayjs(getDateCreated(b))))
break
case 'Date modified':
sorted.sort((a, b) => dayjs(getDateModified(a)).diff(dayjs(getDateModified(b))))
break
}
if (!ascending) {
sorted.reverse()
}
return sorted
}
function groupInstances(instances: T[], group: GridGroupingOption): Map<string, T[]> {
const instanceMap = new Map<string, T[]>()
const getLoader = options.getLoader ?? ((i: T) => i.loader ?? '')
const getGameVersion = options.getGameVersion ?? ((i: T) => i.game_version ?? '')
const getGroups = options.getGroups ?? ((i: T) => i.groups ?? [])
const formatLoader = options.formatLoader ?? ((l: string) => l)
switch (group) {
case 'Loader':
instances.forEach((instance) => {
const loader = formatLoader(getLoader(instance))
if (!instanceMap.has(loader)) {
instanceMap.set(loader, [])
}
instanceMap.get(loader)!.push(instance)
})
break
case 'Game version':
instances.forEach((instance) => {
const gameVersion = getGameVersion(instance)
if (!instanceMap.has(gameVersion)) {
instanceMap.set(gameVersion, [])
}
instanceMap.get(gameVersion)!.push(instance)
})
break
case 'Group':
instances.forEach((instance) => {
const groups = getGroups(instance)
const category = groups.length > 0 ? groups[0] : UNGROUPED_GROUP_KEY
if (!instanceMap.has(category)) {
instanceMap.set(category, [])
}
instanceMap.get(category)!.push(instance)
})
break
case 'None':
default:
instanceMap.set('None', instances)
break
}
return instanceMap
}
function sortSections(
instanceMap: Map<string, T[]>,
group: GridGroupingOption,
sortBy: GridSortOption,
): Map<string, T[]> {
if (group !== 'Group' && group !== 'Game version') {
return instanceMap
}
if (group === 'Group' && sortBy !== 'Name') {
if (instanceMap.has(UNGROUPED_GROUP_KEY) && instanceMap.size > 1) {
const ungroupedInstances = instanceMap.get(UNGROUPED_GROUP_KEY)!
instanceMap.delete(UNGROUPED_GROUP_KEY)
const newMap = new Map<string, T[]>([[UNGROUPED_GROUP_KEY, ungroupedInstances]])
for (const [key, instances] of instanceMap) {
newMap.set(key, instances)
}
return newMap
}
return instanceMap
}
const sortedEntries = [...instanceMap.entries()].sort((a, b) => {
if (group === 'Group') {
if (a[0] === UNGROUPED_GROUP_KEY && b[0] !== UNGROUPED_GROUP_KEY) return -1
if (a[0] !== UNGROUPED_GROUP_KEY && b[0] === UNGROUPED_GROUP_KEY) return 1
}
return a[0].localeCompare(b[0], undefined, { numeric: true })
})
instanceMap.clear()
sortedEntries.forEach((entry) => instanceMap.set(entry[0], entry[1]))
return instanceMap
}
const sortDirection = computed<GridSortDirection>(() => {
return (
state.value.sortDirections[state.value.sortBy] ?? defaultSortDirections[state.value.sortBy]
)
})
const isSortAscending = computed(() => sortDirection.value === 'asc')
const toggleSortDirection = () => {
const current = sortDirection.value
const next: GridSortDirection = current === 'asc' ? 'desc' : 'asc'
state.value.sortDirections = {
...state.value.sortDirections,
[state.value.sortBy]: next,
}
}
const filteredResults = computed(() => {
const { group = 'Group', sortBy = 'Name' } = state.value
const ascending = isSortAscending.value
const sorted = sortInstances(instances.value, sortBy, ascending)
const grouped = groupInstances(sorted, group)
return sortSections(grouped, group, sortBy)
})
return {
state,
grouping,
filteredResults,
isSectionCollapsed,
setSectionCollapsed,
sortDirection,
isSortAscending,
toggleSortDirection,
UNGROUPED_GROUP_KEY,
}
}