Sync source to CNB / Sync Git ref (push) Skipped
移植 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 条
182 lines
4.7 KiB
TypeScript
182 lines
4.7 KiB
TypeScript
import { computed, type Ref, ref, watch } from 'vue'
|
|
|
|
import { instance_groups_listener } from '@/helpers/events'
|
|
import {
|
|
create_group,
|
|
delete_group,
|
|
type InstanceGroupDefinition,
|
|
type InstanceGroupMembershipUpdate,
|
|
list_groups,
|
|
MAX_INSTANCE_GROUP_NAME_LENGTH,
|
|
rename_group,
|
|
set_group_memberships,
|
|
set_group_order,
|
|
} from '@/helpers/instance-groups'
|
|
|
|
export const FAVORITES_GROUP_ID = 'group:favorites'
|
|
|
|
export const UNGROUPED_GROUP_ID = 'group:none'
|
|
|
|
export { MAX_INSTANCE_GROUP_NAME_LENGTH }
|
|
|
|
export { type InstanceGroupDefinition, type InstanceGroupMembershipUpdate }
|
|
|
|
export interface InstanceGroupWithMembership {
|
|
key: string
|
|
name: string
|
|
instances: string[]
|
|
}
|
|
|
|
export function useInstanceGroups(instances: Ref<{ id: string; groups: string[] }[]>) {
|
|
const libraryGroups = ref<InstanceGroupDefinition[]>([])
|
|
const libraryGroupsLoaded = ref(false)
|
|
|
|
async function fetchGroups() {
|
|
libraryGroups.value = await list_groups()
|
|
libraryGroupsLoaded.value = true
|
|
}
|
|
|
|
fetchGroups()
|
|
|
|
instance_groups_listener(() => {
|
|
fetchGroups()
|
|
})
|
|
|
|
const orderedLibraryGroupIds = ref<string[]>([])
|
|
|
|
watch(
|
|
libraryGroups,
|
|
(groups) => {
|
|
orderedLibraryGroupIds.value = groups.map((g) => g.id)
|
|
},
|
|
{ immediate: true },
|
|
)
|
|
|
|
const instanceGroups = computed(() => {
|
|
if (!libraryGroupsLoaded.value) {
|
|
return []
|
|
}
|
|
|
|
const instanceGroupMap = new Map<string, string[]>()
|
|
|
|
for (const instance of instances.value) {
|
|
const groups = instance.groups ?? []
|
|
for (const groupId of groups) {
|
|
if (!instanceGroupMap.has(groupId)) {
|
|
instanceGroupMap.set(groupId, [])
|
|
}
|
|
instanceGroupMap.get(groupId)!.push(instance.id)
|
|
}
|
|
}
|
|
|
|
const allGroupIds = new Set<string>()
|
|
const groupMap = new Map<string, string[]>()
|
|
|
|
for (const groupId of orderedLibraryGroupIds.value) {
|
|
allGroupIds.add(groupId)
|
|
groupMap.set(groupId, instanceGroupMap.get(groupId) ?? [])
|
|
}
|
|
|
|
const ungroupedIds: string[] = []
|
|
for (const instance of instances.value) {
|
|
const instanceGroups = instance.groups ?? []
|
|
const isInAnyGroup = instanceGroups.some((g) => allGroupIds.has(g))
|
|
if (!isInAnyGroup) {
|
|
ungroupedIds.push(instance.id)
|
|
}
|
|
}
|
|
|
|
if (ungroupedIds.length > 0) {
|
|
groupMap.set(UNGROUPED_GROUP_ID, ungroupedIds)
|
|
}
|
|
|
|
return [...groupMap.entries()].map(([key, instances]) => ({
|
|
key,
|
|
name:
|
|
key === UNGROUPED_GROUP_ID
|
|
? 'Ungrouped'
|
|
: (libraryGroups.value.find((g) => g.id === key)?.name ?? key),
|
|
instances,
|
|
}))
|
|
})
|
|
|
|
const creatingGroup = ref(false)
|
|
const reorderingGroups = ref(false)
|
|
|
|
async function createNewGroup(name: string): Promise<string | null> {
|
|
creatingGroup.value = true
|
|
try {
|
|
const group = await create_group(name)
|
|
libraryGroups.value = [...libraryGroups.value, group]
|
|
orderedLibraryGroupIds.value = [...orderedLibraryGroupIds.value, group.id]
|
|
return group.id
|
|
} finally {
|
|
creatingGroup.value = false
|
|
}
|
|
}
|
|
|
|
async function renameGroupById(id: string, newName: string): Promise<boolean> {
|
|
if (id === FAVORITES_GROUP_ID) return false
|
|
const result = await rename_group(id, newName)
|
|
const idx = libraryGroups.value.findIndex((g) => g.id === id)
|
|
if (idx !== -1) {
|
|
libraryGroups.value = [
|
|
...libraryGroups.value.slice(0, idx),
|
|
result,
|
|
...libraryGroups.value.slice(idx + 1),
|
|
]
|
|
}
|
|
return true
|
|
}
|
|
|
|
async function deleteGroupById(id: string): Promise<boolean> {
|
|
if (id === FAVORITES_GROUP_ID) return false
|
|
await delete_group(id)
|
|
libraryGroups.value = libraryGroups.value.filter((g) => g.id !== id)
|
|
orderedLibraryGroupIds.value = orderedLibraryGroupIds.value.filter((gid) => gid !== id)
|
|
return true
|
|
}
|
|
|
|
async function reorderGroups(groupIds: string[]): Promise<boolean> {
|
|
reorderingGroups.value = true
|
|
try {
|
|
await set_group_order(groupIds)
|
|
// Favorites is rendered separately from draggable sections. Keep it in
|
|
// the local order after a drag even though the backend accepts only the
|
|
// reorderable group ids; otherwise it appears again as an ordinary
|
|
// section until the next full group refresh.
|
|
orderedLibraryGroupIds.value = [
|
|
FAVORITES_GROUP_ID,
|
|
...groupIds.filter((id) => id !== FAVORITES_GROUP_ID),
|
|
]
|
|
return true
|
|
} finally {
|
|
reorderingGroups.value = false
|
|
}
|
|
}
|
|
|
|
async function setMemberships(updates: InstanceGroupMembershipUpdate[]): Promise<boolean> {
|
|
await set_group_memberships(updates)
|
|
return true
|
|
}
|
|
|
|
function groupNameById(id: string): string | undefined {
|
|
if (id === UNGROUPED_GROUP_ID) return 'Ungrouped'
|
|
return libraryGroups.value.find((g) => g.id === id)?.name
|
|
}
|
|
|
|
return {
|
|
libraryGroups,
|
|
libraryGroupsLoaded,
|
|
orderedLibraryGroupIds,
|
|
instanceGroups,
|
|
creatingGroup,
|
|
reorderingGroups,
|
|
createGroup: createNewGroup,
|
|
renameGroupById,
|
|
deleteGroupById,
|
|
reorderGroups,
|
|
setMemberships,
|
|
groupNameById,
|
|
}
|
|
} |