[New Feature] sub directories support (#135)

* feat: add close all dialog

* feat: add new ui toggle setting

* feat: add tree display ui

* feat: add search and sort

* feat: change model data structure

* pref: Optimize model data structure

* feat: set sub folder by choose
This commit is contained in:
Hayden
2025-02-18 16:03:07 +08:00
committed by GitHub
parent 37be9a0b0d
commit 86c11e5343
20 changed files with 1123 additions and 216 deletions

View File

@@ -124,41 +124,43 @@ class ModelManager:
if not prefix_path.endswith("/"):
prefix_path = f"{prefix_path}/"
fullname = utils.normalize_path(entry.path).replace(prefix_path, "")
basename = os.path.splitext(fullname)[0]
extension = os.path.splitext(fullname)[1]
relative_path = utils.normalize_path(entry.path).replace(prefix_path, "")
sub_folder = os.path.dirname(relative_path)
filename = os.path.basename(relative_path)
basename = os.path.splitext(filename)[0]
extension = os.path.splitext(filename)[1]
if extension not in folder_paths.supported_pt_extensions:
is_file = entry.is_file()
if is_file and extension not in folder_paths.supported_pt_extensions:
return None
model_preview = f"/model-manager/preview/{folder}/{path_index}/{basename}.webp"
model_preview = f"/model-manager/preview/{folder}/{path_index}/{relative_path.replace(extension, '.webp')}"
stat = entry.stat()
return {
"fullname": fullname,
"type": folder if is_file else "folder",
"subFolder": sub_folder,
"basename": basename,
"extension": extension,
"type": folder,
"pathIndex": path_index,
"sizeBytes": stat.st_size,
"preview": model_preview,
"sizeBytes": stat.st_size if is_file else 0,
"preview": model_preview if is_file else None,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}
def get_all_files_entry(directory: str):
files = []
entries: list[os.DirEntry[str]] = []
with os.scandir(directory) as it:
for entry in it:
# Skip hidden files
if not include_hidden_files:
if entry.name.startswith("."):
continue
entries.append(entry)
if entry.is_dir():
files.extend(get_all_files_entry(entry.path))
elif entry.is_file():
files.append(entry)
return files
entries.extend(get_all_files_entry(entry.path))
return entries
for path_index, base_path in enumerate(folders):
if not os.path.exists(base_path):

View File

@@ -7,6 +7,7 @@
<script setup lang="ts">
import DialogDownload from 'components/DialogDownload.vue'
import DialogExplorer from 'components/DialogExplorer.vue'
import DialogManager from 'components/DialogManager.vue'
import GlobalDialogStack from 'components/GlobalDialogStack.vue'
import GlobalLoading from 'components/GlobalLoading.vue'
@@ -50,7 +51,7 @@ onMounted(() => {
}
const openManagerDialog = () => {
const { cardWidth, gutter, aspect } = config
const { cardWidth, gutter, aspect, flat } = config
if (firstOpenManager.value) {
models.refresh(true)
@@ -60,7 +61,7 @@ onMounted(() => {
dialog.open({
key: 'model-manager',
title: t('modelManager'),
content: DialogManager,
content: flat.value ? DialogManager : DialogExplorer,
keepAlive: true,
headerButtons: [
{

View File

@@ -0,0 +1,258 @@
<template>
<div class="flex h-full w-full select-none flex-col overflow-hidden">
<div class="flex w-full gap-4 overflow-hidden px-4 pb-4">
<div :class="['flex gap-4 overflow-hidden', showToolbar || 'flex-1']">
<div class="flex overflow-hidden">
<Button
icon="pi pi-arrow-up"
text
rounded
severity="secondary"
:disabled="folderPaths.length < 2"
@click="handleGoBackParentFolder"
></Button>
</div>
<ResponseBreadcrumb
v-show="!showToolbar"
class="h-10 flex-1"
:items="folderPaths"
@item-click="(item, index) => openFolder(index, item.name, item.icon)"
></ResponseBreadcrumb>
</div>
<div :class="['flex gap-4', showToolbar && 'flex-1']">
<ResponseInput
v-model="searchContent"
:placeholder="$t('searchModels')"
></ResponseInput>
<div
v-show="showToolbar"
class="flex flex-1 items-center justify-end gap-2"
>
<ResponseSelect
v-model="sortOrder"
:items="sortOrderOptions"
></ResponseSelect>
<ResponseSelect
v-model="cardSizeFlag"
:items="cardSizeOptions"
></ResponseSelect>
</div>
<Button
:icon="`mdi mdi-menu-${showToolbar ? 'close' : 'open'}`"
text
severity="secondary"
@click="toggleToolbar"
></Button>
</div>
</div>
<div ref="contentContainer" class="relative flex-1 overflow-hidden px-2">
<ResponseScroll :items="renderedList" :item-size="itemSize">
<template #item="{ item }">
<div
class="grid h-full justify-center"
:style="{
gridTemplateColumns: `repeat(auto-fit, ${cardSize.width}px)`,
columnGap: `${gutter.x}px`,
rowGap: `${gutter.y}px`,
}"
>
<ModelCard
:model="rowItem"
v-for="rowItem in item.row"
:key="genModelKey(rowItem)"
:style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
@dblclick="openItem(rowItem)"
></ModelCard>
<div class="col-span-full"></div>
</div>
</template>
</ResponseScroll>
</div>
<!-- bottom status bar -->
<div class="flex justify-between px-4 py-2 text-sm">
<div></div>
<div></div>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ModelCard from 'components/ModelCard.vue'
import ResponseBreadcrumb from 'components/ResponseBreadcrumb.vue'
import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useConfig } from 'hooks/config'
import { type ModelTreeNode, useModelExplorer } from 'hooks/explorer'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import { genModelKey } from 'utils/model'
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
const gutter = {
x: 4,
y: 32,
}
const { dataTreeList, folderPaths, findFolder, openFolder, openModelDetail } =
useModelExplorer()
const { cardSize, cardSizeMap, cardSizeFlag, dialog: settings } = useConfig()
const showToolbar = ref(false)
const toggleToolbar = () => {
showToolbar.value = !showToolbar.value
}
const contentContainer = ref<HTMLElement | null>(null)
const contentSize = useElementSize(contentContainer)
const itemSize = computed(() => {
return cardSize.value.height + gutter.y
})
const cols = computed(() => {
const containerWidth = contentSize.width.value + gutter.x
const itemWidth = cardSize.value.width + gutter.x
return Math.floor(containerWidth / itemWidth)
})
const searchContent = ref<string>()
const sortOrder = ref('name')
const sortOrderOptions = ref(
['name', 'size', 'created', 'modified'].map((key) => {
return {
label: t(`sort.${key}`),
value: key,
icon: key === 'name' ? 'pi pi-sort-alpha-down' : 'pi pi-sort-amount-down',
command: () => {
sortOrder.value = key
},
}
}),
)
const currentDataList = computed(() => {
let renderedList = dataTreeList.value
for (const folderItem of folderPaths.value) {
const found = findFolder(renderedList, folderItem.name)
renderedList = found?.children || []
}
if (searchContent.value) {
const filterItems: ModelTreeNode[] = []
const searchList = [...renderedList]
while (searchList.length) {
const item = searchList.pop()!
const children = (item as any).children ?? []
searchList.push(...children)
if (
item.basename
.toLocaleLowerCase()
.includes(searchContent.value.toLocaleLowerCase())
) {
filterItems.push(item)
}
}
renderedList = filterItems
}
if (folderPaths.value.length > 1) {
const folderItems: ModelTreeNode[] = []
const modelItems: ModelTreeNode[] = []
for (const item of renderedList) {
if (item.type === 'folder') {
folderItems.push(item)
} else {
modelItems.push(item)
}
}
folderItems.sort((a, b) => {
return a.basename.localeCompare(b.basename)
})
modelItems.sort((a, b) => {
const sortFieldMap = {
name: 'basename',
size: 'sizeBytes',
created: 'createdAt',
modified: 'updatedAt',
}
const sortField = sortFieldMap[sortOrder.value]
const aValue = a[sortField]
const bValue = b[sortField]
const result =
typeof aValue === 'string'
? aValue.localeCompare(bValue)
: aValue - bValue
return result
})
renderedList = [...folderItems, ...modelItems]
}
return renderedList
})
const renderedList = computed(() => {
return chunk(currentDataList.value, cols.value).map((row) => {
return { key: row.map((o) => o.basename).join('#'), row }
})
})
const cardSizeOptions = computed(() => {
const customSize = 'size.custom'
const customOptionMap = {
...cardSizeMap.value,
[customSize]: 'custom',
}
return Object.keys(customOptionMap).map((key) => {
return {
label: t(key),
value: key,
command: () => {
if (key === customSize) {
settings.showCardSizeSetting()
} else {
cardSizeFlag.value = key
}
},
}
})
})
const openItem = (item: ModelTreeNode) => {
if (item.type === 'folder') {
openFolder(folderPaths.value.length, item.basename)
} else {
openModelDetail(item)
}
}
const handleGoBackParentFolder = () => {
folderPaths.value.pop()
}
</script>

View File

@@ -49,7 +49,56 @@
v-for="model in item.row"
:key="genModelKey(model)"
:model="model"
></ModelCard>
:style="{
width: `${cardSize.width}px`,
height: `${cardSize.height}px`,
}"
class="group/card cursor-pointer !p-0"
@click="openModelDetail(model)"
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
>
<template #name>
<div
v-show="showModelName"
class="absolute top-0 h-full w-full p-2"
>
<div class="flex h-full flex-col justify-end text-lg">
<div class="line-clamp-3 break-all font-bold text-shadow">
{{ model.basename }}
</div>
</div>
</div>
</template>
<template #extra>
<div
v-show="showModeAction"
class="pointer-events-none absolute right-2 top-2 opacity-0 duration-300 group-hover/card:opacity-100"
>
<div class="flex flex-col gap-2">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode(model)"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow(model)"
></Button>
</div>
</div>
</template>
</ModelCard>
<div class="col-span-full"></div>
</div>
</template>
@@ -72,8 +121,9 @@ import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { configSetting, useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useModels } from 'hooks/model'
import { useModelNodeAction, useModels } from 'hooks/model'
import { chunk } from 'lodash'
import Button from 'primevue/button'
import { app } from 'scripts/comfyAPI'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model'
@@ -89,7 +139,7 @@ const {
dialog: settings,
} = useConfig()
const { data, folders } = useModels()
const { data, folders, openModelDetail } = useModels()
const { t } = useI18n()
const toolbarContainer = ref<HTMLElement | null>(null)
@@ -165,12 +215,15 @@ const cols = computed(() => {
const list = computed(() => {
const mergedList = Object.values(data.value).flat()
const pureModels = mergedList.filter((item) => {
return item.type !== 'folder'
})
const filterList = mergedList.filter((model) => {
const filterList = pureModels.filter((model) => {
const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname
const matchName = model.basename
.toLowerCase()
.includes(searchContent.value?.toLowerCase() || '')
@@ -180,7 +233,7 @@ const list = computed(() => {
let sortStrategy: (a: Model, b: Model) => number = () => 0
switch (sortOrder.value) {
case 'name':
sortStrategy = (a, b) => a.fullname.localeCompare(b.fullname)
sortStrategy = (a, b) => a.basename.localeCompare(b.basename)
break
case 'size':
sortStrategy = (a, b) => b.sizeBytes - a.sizeBytes
@@ -231,4 +284,15 @@ const cardSizeOptions = computed(() => {
}
})
})
const showModelName = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const showModeAction = computed(() => {
return cardSize.value.width > 120 && cardSize.value.height > 160
})
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction()
</script>

View File

@@ -18,12 +18,18 @@
icon="pi pi-eye"
@click="openModelPage(metadata.modelPage)"
></Button>
<Button icon="pi pi-plus" @click.stop="addModelNode"></Button>
<Button icon="pi pi-copy" @click.stop="copyModelNode"></Button>
<Button
icon="pi pi-plus"
@click.stop="addModelNode(model)"
></Button>
<Button
icon="pi pi-copy"
@click.stop="copyModelNode(model)"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
@click.stop="loadPreviewWorkflow"
@click.stop="loadPreviewWorkflow(model)"
></Button>
<Button
icon="pi pi-pen-to-square"
@@ -44,7 +50,7 @@
<script setup lang="ts">
import ModelContent from 'components/ModelContent.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import { useModelNodeAction, useModels } from 'hooks/model'
import { genModelUrl, useModelNodeAction, useModels } from 'hooks/model'
import { useRequest } from 'hooks/request'
import Button from 'primevue/button'
import { BaseModel, Model, WithResolved } from 'types/typings'
@@ -59,7 +65,7 @@ const { remove, update } = useModels()
const editable = ref(false)
const modelDetailUrl = `/model/${props.model.type}/${props.model.pathIndex}/${props.model.fullname}`
const modelDetailUrl = genModelUrl(props.model)
const { data: extraInfo } = useRequest(modelDetailUrl, {
method: 'GET',
})
@@ -85,7 +91,6 @@ const openModelPage = (url: string) => {
window.open(url, '_blank')
}
const { addModelNode, copyModelNode, loadPreviewWorkflow } = useModelNodeAction(
props.model,
)
const { addModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction()
</script>

View File

@@ -7,13 +7,56 @@
</template>
</ResponseSelect>
<ResponseSelect class="w-full" v-model="pathIndex" :items="pathOptions">
</ResponseSelect>
<div class="flex gap-2 overflow-hidden">
<div class="flex-1 overflow-hidden rounded bg-gray-500/30">
<div class="flex h-full items-center justify-end">
<span class="overflow-hidden text-ellipsis whitespace-nowrap px-2">
{{ renderedModelFolder }}
</span>
</div>
</div>
<Button icon="pi pi-folder" @click="handleSelectFolder"></Button>
<Dialog
v-model:visible="folderSelectVisible"
:header="$t('folder')"
:auto-z-index="false"
:pt:mask:style="{ zIndex }"
:pt:root:style="{ height: '50vh', maxWidth: '50vw' }"
pt:content:class="flex-1"
>
<div class="flex h-full flex-col overflow-hidden">
<div class="flex-1 overflow-hidden">
<ResponseScroll>
<Tree
class="h-full"
v-model:selection-keys="modelFolder"
:value="pathOptions"
selectionMode="single"
:pt:nodeLabel:class="'text-ellipsis overflow-hidden'"
></Tree>
</ResponseScroll>
</div>
<div class="flex justify-end gap-2">
<Button
:label="$t('cancel')"
severity="secondary"
@click="handleCancelSelectFolder"
></Button>
<Button
:label="$t('select')"
@click="handleConfirmSelectFolder"
></Button>
</div>
</div>
</Dialog>
</div>
<ResponseInput
v-model.trim="basename"
v-model.trim.valid="basename"
class="-mr-2 text-right"
update-trigger="blur"
:validate="validateBasename"
>
<template #suffix>
<span class="text-base opacity-60">
@@ -48,14 +91,30 @@
<script setup lang="ts">
import ResponseInput from 'components/ResponseInput.vue'
import ResponseScroll from 'components/ResponseScroll.vue'
import ResponseSelect from 'components/ResponseSelect.vue'
import { useModelBaseInfo } from 'hooks/model'
import { computed } from 'vue'
import { useDialog } from 'hooks/dialog'
import { useModelBaseInfo, useModelFolder } from 'hooks/model'
import { useToast } from 'hooks/toast'
import Button from 'primevue/button'
import { usePrimeVue } from 'primevue/config'
import Dialog from 'primevue/dialog'
import Tree from 'primevue/tree'
import { computed, ref } from 'vue'
const editable = defineModel<boolean>('editable')
const { baseInfo, pathIndex, basename, extension, type, modelFolders } =
useModelBaseInfo()
const { toast } = useToast()
const {
baseInfo,
pathIndex,
subFolder,
basename,
extension,
type,
modelFolders,
} = useModelBaseInfo()
const typeOptions = computed(() => {
return Object.keys(modelFolders.value).map((curr) => {
@@ -70,25 +129,104 @@ const typeOptions = computed(() => {
})
})
const pathOptions = computed(() => {
return (modelFolders.value[type.value] ?? []).map((folder, index) => {
return {
value: index,
label: folder,
command: () => {
pathIndex.value = index
},
}
})
})
const information = computed(() => {
return Object.values(baseInfo.value).filter((row) => {
if (editable.value) {
const hiddenKeys = ['fullname', 'pathIndex']
const hiddenKeys = ['basename', 'pathIndex']
return !hiddenKeys.includes(row.key)
}
return true
})
})
const validateBasename = (val: string | undefined) => {
if (!val) {
toast.add({
severity: 'error',
detail: 'basename is required',
life: 3000,
})
return false
}
const invalidChart = /[\\/:*?"<>|]/
if (invalidChart.test(val)) {
toast.add({
severity: 'error',
detail: 'basename is invalid, \\/:*?"<>|',
life: 3000,
})
return false
}
return true
}
const folderSelectVisible = ref(false)
const { stack } = useDialog()
const { config } = usePrimeVue()
const zIndex = computed(() => {
const baseZIndex = config.zIndex?.modal ?? 1100
return baseZIndex + stack.value.length + 1
})
const handleSelectFolder = () => {
if (!type.value) {
toast.add({
severity: 'error',
summary: 'Error',
detail: 'Please select model type first',
life: 5000,
})
return
}
folderSelectVisible.value = true
}
const { pathOptions } = useModelFolder({ type })
const selectedModelFolder = ref<string>()
const modelFolder = computed({
get: () => {
const folderPath = baseInfo.value.pathIndex.display
const selectedKey = selectedModelFolder.value ?? folderPath
return { [selectedKey]: true }
},
set: (val) => {
const folderPath = Object.keys(val)[0]
selectedModelFolder.value = folderPath
},
})
const renderedModelFolder = computed(() => {
return baseInfo.value.pathIndex.display
})
const handleCancelSelectFolder = () => {
selectedModelFolder.value = undefined
folderSelectVisible.value = false
}
const handleConfirmSelectFolder = () => {
const folderPath = Object.keys(modelFolder.value)[0]
const folders = modelFolders.value[type.value]
pathIndex.value = folders.findIndex((item) => folderPath.includes(item))
if (pathIndex.value < 0) {
toast.add({
severity: 'error',
detail: 'Folder not found',
life: 3000,
})
return
}
const prefixPath = folders[pathIndex.value]
subFolder.value = folderPath.replace(prefixPath, '')
if (subFolder.value.startsWith('/')) {
subFolder.value = subFolder.value.replace('/', '')
}
selectedModelFolder.value = undefined
folderSelectVisible.value = false
}
</script>

View File

@@ -1,135 +1,94 @@
<template>
<div
class="group/card relative cursor-pointer select-none"
:style="{ width: `${cardSize.width}px`, height: `${cardSize.height}px` }"
v-tooltip.top="{ value: model.basename, disabled: showModelName }"
@click.stop="openDetailDialog"
ref="container"
class="relative h-full select-none rounded-lg hover:bg-gray-500/40"
>
<div class="h-full overflow-hidden rounded-lg">
<div class="h-full bg-gray-500 duration-300 group-hover/card:scale-110">
<img class="h-full w-full object-cover" :src="preview" />
<div data-card-main class="flex h-full w-full flex-col">
<div data-card-preview class="flex-1 overflow-hidden">
<div v-if="model.type === 'folder'" class="h-full w-full">
<svg
class="icon"
viewBox="0 0 1024 1024"
version="1.1"
xmlns="http://www.w3.org/2000/svg"
width="100%"
height="100%"
>
<path
d="M853.333333 256H469.333333l-85.333333-85.333333H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v170.666667h853.333334v-85.333334c0-46.933333-38.4-85.333333-85.333334-85.333333z"
fill="#FFA000"
></path>
<path
d="M853.333333 256H170.666667c-46.933333 0-85.333333 38.4-85.333334 85.333333v426.666667c0 46.933333 38.4 85.333333 85.333334 85.333333h682.666666c46.933333 0 85.333333-38.4 85.333334-85.333333V341.333333c0-46.933333-38.4-85.333333-85.333334-85.333333z"
fill="#FFCA28"
></path>
</svg>
</div>
<div v-else class="h-full w-full p-1 hover:p-0">
<img class="h-full w-full rounded-lg object-cover" :src="preview" />
</div>
</div>
<slot name="name">
<div class="flex justify-center overflow-hidden px-1">
<span class="overflow-hidden text-ellipsis whitespace-nowrap">
{{ model.basename }}
</span>
</div>
</slot>
</div>
<div
v-if="model.type !== 'folder'"
data-draggable-overlay
class="absolute left-0 top-0 h-full w-full"
draggable="true"
@dragend.stop="dragToAddModelNode"
@dragend.stop="dragToAddModelNode(model, $event)"
></div>
<div class="pointer-events-none absolute left-0 top-0 h-full w-full p-4">
<div class="relative h-full w-full text-white">
<div v-show="showModelName" class="absolute bottom-0 left-0">
<div class="drop-shadow-[0px_2px_2px_rgba(0,0,0,0.75)]">
<div
:class="[
'line-clamp-3 break-all font-bold',
$lg('text-lg', 'text-2xl'),
]"
>
{{ model.basename }}
</div>
</div>
</div>
<div class="absolute left-0 top-0 w-full">
<div class="flex flex-row items-start justify-between">
<div
v-show="showModelType"
class="flex items-center rounded-full bg-black/30 px-3 py-2"
>
<div :class="['font-bold', $lg('text-xs')]">
{{ model.type }}
</div>
</div>
<div
v-show="showToolButton"
class="opacity-0 duration-300 group-hover/card:opacity-100"
>
<div class="flex flex-col gap-4 *:pointer-events-auto">
<Button
icon="pi pi-plus"
severity="secondary"
rounded
@click.stop="addModelNode"
></Button>
<Button
icon="pi pi-copy"
severity="secondary"
rounded
@click.stop="copyModelNode"
></Button>
<Button
v-show="model.preview"
icon="pi pi-file-import"
severity="secondary"
rounded
@click.stop="loadPreviewWorkflow"
></Button>
</div>
</div>
</div>
</div>
<div
v-if="model.type !== 'folder'"
data-mode-type
class="pointer-events-none absolute left-2 top-2"
:style="{
transform: `scale(${typeLabelScale})`,
transformOrigin: 'left top',
}"
>
<div class="rounded-full bg-black/50 px-3 py-1">
<span>{{ model.type }}</span>
</div>
</div>
<slot name="extra"></slot>
</div>
</template>
<script setup lang="ts">
import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useConfig } from 'hooks/config'
import { useContainerQueries } from 'hooks/container'
import { useDialog } from 'hooks/dialog'
import { useElementSize } from '@vueuse/core'
import { useModelNodeAction } from 'hooks/model'
import Button from 'primevue/button'
import { Model } from 'types/typings'
import { genModelKey } from 'utils/model'
import { computed } from 'vue'
import { BaseModel } from 'types/typings'
import { computed, ref } from 'vue'
interface Props {
model: Model
model: BaseModel
}
const props = defineProps<Props>()
const { cardSize } = useConfig()
const dialog = useDialog()
const openDetailDialog = () => {
const basename = props.model.fullname.split('/').pop()!
const filename = basename.replace(props.model.extension, '')
dialog.open({
key: genModelKey(props.model),
title: filename,
content: DialogModelDetail,
contentProps: { model: props.model },
})
}
const preview = computed(() =>
Array.isArray(props.model.preview)
? props.model.preview[0]
: props.model.preview,
)
const showToolButton = computed(() => {
return cardSize.value.width >= 180 && cardSize.value.height >= 240
const container = ref<HTMLElement | null>(null)
const { width } = useElementSize(container)
const typeLabelScale = computed(() => {
return width.value / 200
})
const showModelName = computed(() => {
return cardSize.value.width >= 160 && cardSize.value.height >= 120
})
const showModelType = computed(() => {
return cardSize.value.width >= 120
})
const { addModelNode, dragToAddModelNode, copyModelNode, loadPreviewWorkflow } =
useModelNodeAction(props.model)
const { $lg } = useContainerQueries()
const { dragToAddModelNode } = useModelNodeAction()
</script>

View File

@@ -0,0 +1,163 @@
<template>
<div ref="container" class="breadcrumb-container">
<div v-if="firstItem" class="breadcrumb-item">
<span class="breadcrumb-label" @click="firstItem.onClick">
<i v-if="firstItem.icon" :class="firstItem.icon"></i>
<i v-else class="breadcrumb-name">{{ firstItem.name }}</i>
</span>
<ResponseSelect
v-if="!!firstItem.children?.length"
:items="firstItem.children"
>
<template #target="{ toggle, overlayVisible }">
<span class="breadcrumb-split" @click="toggle">
<i
class="pi pi-angle-right transition-all"
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
></i>
</span>
</template>
</ResponseSelect>
</div>
<div v-if="!!renderedItems.collapsed.length" class="breadcrumb-item">
<ResponseSelect :items="renderedItems.collapsed">
<template #target="{ toggle }">
<span class="breadcrumb-split" @click="toggle">
<i class="pi pi-ellipsis-h"></i>
</span>
</template>
</ResponseSelect>
</div>
<div
v-for="(item, index) in renderedItems.tail"
:key="`${index}-${item.name}`"
class="breadcrumb-item"
>
<span class="breadcrumb-label" @click="item.onClick">
<i v-if="item.icon" :class="item.icon"></i>
<i v-else class="breadcrumb-name">{{ item.name }}</i>
</span>
<ResponseSelect v-if="!!item.children?.length" :items="item.children">
<template #target="{ toggle, overlayVisible }">
<span class="breadcrumb-split" @click="toggle">
<i
class="pi pi-angle-right transition-all"
:style="{ transform: overlayVisible ? 'rotate(90deg)' : '' }"
></i>
</span>
</template>
</ResponseSelect>
</div>
</div>
</template>
<script setup lang="ts">
import { useElementSize } from '@vueuse/core'
import ResponseSelect from 'components/ResponseSelect.vue'
import { SelectOptions } from 'types/typings'
import { computed, ref } from 'vue'
interface BreadcrumbItem {
name: string
icon?: string
onClick?: () => void
children?: SelectOptions[]
}
interface Props {
items: BreadcrumbItem[]
}
const props = defineProps<Props>()
const container = ref<HTMLElement | null>(null)
const { width } = useElementSize(container)
const firstItem = computed<BreadcrumbItem | null>(() => {
return props.items[0]
})
const renderedItems = computed(() => {
const [, ...items] = props.items
const lastItem = items.pop()
items.reverse()
const separatorWidth = 32
const calculateItemWidth = (item: BreadcrumbItem | undefined) => {
if (!item) {
return 0
}
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')!
context.font = '16px Arial'
const text = item.name
return context.measureText(text).width + 16 + separatorWidth
}
const firstItemEL = container.value?.querySelector('div')
const firstItemWidth = firstItemEL?.getBoundingClientRect().width ?? 0
const lastItemWidth = calculateItemWidth(lastItem)
const collapseWidth = separatorWidth
let totalWidth = firstItemWidth + collapseWidth + lastItemWidth
const containerWidth = width.value - 18
const collapsed: SelectOptions[] = []
const tail: BreadcrumbItem[] = []
for (const item of items) {
const itemWidth = calculateItemWidth(item)
totalWidth += itemWidth
if (totalWidth < containerWidth) {
tail.unshift(item)
} else {
collapsed.unshift({
value: item.name,
label: item.name,
command: () => {
item.onClick?.()
},
})
}
}
if (lastItem) {
tail.push(lastItem)
}
return { collapsed, tail }
})
</script>
<style scoped>
.breadcrumb-container {
@apply flex overflow-hidden rounded-lg bg-gray-500/30 px-2 py-1;
}
.breadcrumb-item {
@apply flex h-full overflow-hidden rounded border border-transparent hover:border-gray-500/30;
}
.breadcrumb-item:nth-of-type(-n + 2) {
@apply flex-shrink-0;
}
.breadcrumb-label {
@apply flex h-full min-w-8 items-center overflow-hidden px-2 hover:bg-gray-500/30;
}
.breadcrumb-name {
@apply overflow-hidden text-ellipsis whitespace-nowrap not-italic;
}
.breadcrumb-split {
@apply flex aspect-square h-full min-w-8 items-center justify-center hover:bg-gray-500/30;
}
</style>

View File

@@ -9,7 +9,7 @@
minimizeIcon="pi pi-arrow-down-left-and-arrow-up-right-to-center"
:pt:mask:class="['group', { open: visible }]"
:pt:root:class="['max-h-full group-[:not(.open)]:!hidden', $style.dialog]"
pt:content:class="px-0 flex-1"
pt:content:class="p-0 flex-1"
:base-z-index="1000"
:auto-z-index="isNil(zIndex)"
:pt:mask:style="isNil(zIndex) ? {} : { zIndex: 1000 + zIndex }"

View File

@@ -14,7 +14,7 @@
<input
ref="inputRef"
v-model="innerValue"
v-model="inputValue"
class="flex-1 border-none bg-transparent text-base outline-none"
type="text"
:placeholder="placeholder"
@@ -47,14 +47,23 @@ interface Props {
placeholder?: string
allowClear?: boolean
updateTrigger?: string
validate?: (value: string | undefined) => boolean
}
const props = defineProps<Props>()
const [content, modifiers] = defineModel<string, 'trim'>()
const [content, modifiers] = defineModel<string, 'trim' | 'valid'>()
const inputRef = ref()
const innerValue = ref(content)
const innerValue = ref<string>()
const inputValue = computed({
get: () => {
return innerValue.value ?? content.value
},
set: (val) => {
innerValue.value = val
},
})
const trigger = computed(() => props.updateTrigger ?? 'change')
const updateContent = () => {
let value = innerValue.value
@@ -63,6 +72,16 @@ const updateContent = () => {
value = innerValue.value?.trim()
}
if (modifiers.valid) {
const isValid = props.validate?.(value) ?? true
console.log({ isValid, value })
if (!isValid) {
innerValue.value = content.value
return
}
}
innerValue.value = undefined
content.value = value
inputRef.value.value = value
}

View File

@@ -1,5 +1,5 @@
<template>
<div class="group/scroll relative overflow-hidden">
<div class="group/scroll relative h-full overflow-hidden">
<div ref="viewport" class="h-full w-full overflow-auto scrollbar-none">
<div ref="content">
<slot name="default">
@@ -40,13 +40,13 @@
</div>
</template>
<script setup lang="ts" generic="T">
<script setup lang="ts" generic="T extends { key: string }">
import { useDraggable, useElementSize, useScroll } from '@vueuse/core'
import { clamp } from 'lodash'
import { computed, ref } from 'vue'
interface ScrollAreaProps {
items?: (T & { key: string })[]
items?: T[]
itemSize?: number
}

View File

@@ -2,7 +2,14 @@
<slot
v-if="type === 'drop'"
name="target"
v-bind="{ toggle, prefixIcon, suffixIcon, currentLabel, current }"
v-bind="{
toggle,
prefixIcon,
suffixIcon,
currentLabel,
current,
overlayVisible,
}"
>
<div :class="['-my-1 py-1', $attrs.class]" @click="toggle">
<Button
@@ -197,6 +204,10 @@ const toggle = (event: MouseEvent) => {
}
}
const overlayVisible = computed(() => {
return isMobile.value ? visible.value : (menu.value?.overlayVisible ?? false)
})
// Select Button Type
const scrollArea = ref<HTMLElement | null>(null)
const contentArea = ref()

View File

@@ -24,6 +24,8 @@ export const useConfig = defineStore('config', (store) => {
window.removeEventListener('resize', checkDeviceType)
})
const flatLayout = ref(false)
const defaultCardSizeMap = readonly({
'size.extraLarge': '240x320',
'size.large': '180x240',
@@ -64,6 +66,7 @@ export const useConfig = defineStore('config', (store) => {
})
},
},
flat: flatLayout,
}
watch(cardSizeFlag, (val) => {
@@ -172,6 +175,18 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
},
})
app.ui?.settings.addSetting({
id: 'ModelManager.UI.Flat',
category: [t('modelManager'), t('setting.ui'), 'Flat'],
name: t('setting.useFlatUI'),
type: 'boolean',
defaultValue: false,
onChange(value) {
store.dialog.closeAll()
store.config.flat.value = value
},
})
// Scan information
app.ui?.settings.addSetting({
id: 'ModelManager.ScanFiles.Full',

View File

@@ -63,7 +63,11 @@ export const useDialog = defineStore('dialog', () => {
}
}
return { stack, open, close, rise }
const closeAll = () => {
stack.value = []
}
return { stack, open, close, closeAll, rise }
})
declare module 'hooks/store' {

147
src/hooks/explorer.ts Normal file
View File

@@ -0,0 +1,147 @@
import { genModelFullName, useModels } from 'hooks/model'
import { cloneDeep, filter, find } from 'lodash'
import { BaseModel, Model, SelectOptions } from 'types/typings'
import { computed, ref, watchEffect } from 'vue'
export interface FolderPathItem {
name: string
icon?: string
onClick: () => void
children: SelectOptions[]
}
export type ModelFolder = BaseModel & {
type: 'folder'
children: ModelTreeNode[]
}
export type ModelItem = Model
export type ModelTreeNode = BaseModel & {
children?: ModelTreeNode[]
}
export type TreeItemNode = ModelTreeNode & {
onDbClick: () => void
onContextMenu: () => void
}
export const useModelExplorer = () => {
const { data, folders, ...modelRest } = useModels()
const folderPaths = ref<FolderPathItem[]>([])
const genFolderItem = (basename: string, subFolder: string): ModelFolder => {
return {
id: basename,
basename: basename,
subFolder: subFolder,
pathIndex: 0,
sizeBytes: 0,
extension: '',
description: '',
metadata: {},
preview: '',
type: 'folder',
children: [],
}
}
const dataTreeList = computed<ModelTreeNode[]>(() => {
const rootChildren: ModelTreeNode[] = []
for (const folder in folders.value) {
if (Object.prototype.hasOwnProperty.call(folders.value, folder)) {
const folderItem = genFolderItem(folder, '')
const folderModels = cloneDeep(data.value[folder]) ?? []
const pathMap: Record<string, ModelTreeNode> = Object.fromEntries(
folderModels.map((item) => [
`${item.pathIndex}-${genModelFullName(item)}`,
item,
]),
)
for (const item of folderModels) {
const key = genModelFullName(item)
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === '') {
folderItem.children.push(item)
continue
}
const parentItem = pathMap[`${item.pathIndex}-${parentKey}`]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
rootChildren.push(folderItem)
}
}
const root: ModelTreeNode = genFolderItem('root', '')
root.children = rootChildren
return [root]
})
function findFolder(list: ModelTreeNode[], name: string) {
return find(list, { type: 'folder', basename: name }) as
| ModelFolder
| undefined
}
function findFolders(list: ModelTreeNode[]) {
return filter(list, { type: 'folder' }) as ModelFolder[]
}
async function openFolder(level: number, name: string, icon?: string) {
if (folderPaths.value.length >= level) {
folderPaths.value.splice(level)
}
let currentLevel = dataTreeList.value
for (const folderItem of folderPaths.value) {
const found = findFolder(currentLevel, folderItem.name)
currentLevel = found?.children || []
}
const folderItem = findFolder(currentLevel, name)
const folderItemChildren = folderItem?.children ?? []
const subFolders = findFolders(folderItemChildren)
folderPaths.value.push({
name,
icon,
onClick: () => {
openFolder(level, name, icon)
},
children: subFolders.map((item) => {
const name = item.basename
return {
value: name,
label: name,
command: () => openFolder(level + 1, name),
}
}),
})
}
watchEffect(() => {
if (Object.keys(folders.value).length > 0 && folderPaths.value.length < 2) {
openFolder(0, 'root', 'pi pi-desktop')
}
}, {})
return {
folders,
folderPaths,
dataTreeList,
...modelRest,
findFolder: findFolder,
findFolders: findFolders,
openFolder: openFolder,
}
}

View File

@@ -1,9 +1,11 @@
import DialogModelDetail from 'components/DialogModelDetail.vue'
import { useLoading } from 'hooks/loading'
import { useMarkdown } from 'hooks/markdown'
import { request } from 'hooks/request'
import { defineStore } from 'hooks/store'
import { useToast } from 'hooks/toast'
import { castArray, cloneDeep } from 'lodash'
import { TreeNode } from 'primevue/treenode'
import { app } from 'scripts/comfyAPI'
import { BaseModel, Model, SelectEvent, WithResolved } from 'types/typings'
import { bytesToSize, formatDate, previewUrlToFile } from 'utils/common'
@@ -12,11 +14,14 @@ import { genModelKey, resolveModelTypeLoader } from 'utils/model'
import {
computed,
inject,
InjectionKey,
type InjectionKey,
MaybeRefOrGetter,
onMounted,
provide,
type Ref,
ref,
toRaw,
toValue,
unref,
} from 'vue'
import { useI18n } from 'vue-i18n'
@@ -24,7 +29,20 @@ import { configSetting } from './config'
type ModelFolder = Record<string, string[]>
const modelFolderProvideKey = Symbol('modelFolder')
const modelFolderProvideKey = Symbol('modelFolder') as InjectionKey<
Ref<ModelFolder>
>
export const genModelFullName = (model: BaseModel) => {
return [model.subFolder, `${model.basename}${model.extension}`]
.filter(Boolean)
.join('/')
}
export const genModelUrl = (model: BaseModel) => {
const fullname = genModelFullName(model)
return `/model/${model.type}/${model.pathIndex}/${fullname}`
}
export const useModels = defineStore('models', (store) => {
const { toast, confirm } = useToast()
@@ -32,6 +50,7 @@ export const useModels = defineStore('models', (store) => {
const loading = useLoading()
const folders = ref<ModelFolder>({})
const refreshFolders = async () => {
return request('/models').then((resData) => {
folders.value = resData
@@ -65,7 +84,7 @@ export const useModels = defineStore('models', (store) => {
?.split(',')
.map((type) => type.trim())
.filter(Boolean) ?? []
return forceRefresh.then(() =>
await forceRefresh.then(() =>
Promise.allSettled(
Object.keys(folders.value)
.filter((folder) => !customBlackList.includes(folder))
@@ -102,13 +121,13 @@ export const useModels = defineStore('models', (store) => {
// Check current name and pathIndex
if (
model.fullname !== data.fullname ||
model.subFolder !== data.subFolder ||
model.pathIndex !== data.pathIndex
) {
oldKey = genModelKey(model)
updateData.set('type', data.type)
updateData.set('pathIndex', data.pathIndex.toString())
updateData.set('fullname', data.fullname)
updateData.set('fullname', genModelFullName(data as BaseModel))
needUpdate = true
}
@@ -117,7 +136,8 @@ export const useModels = defineStore('models', (store) => {
}
loading.show()
await request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
await request(genModelUrl(model), {
method: 'PUT',
body: updateData,
})
@@ -160,14 +180,14 @@ export const useModels = defineStore('models', (store) => {
accept: () => {
const dialogKey = genModelKey(model)
loading.show()
request(`/model/${model.type}/${model.pathIndex}/${model.fullname}`, {
request(genModelUrl(model), {
method: 'DELETE',
})
.then(() => {
toast.add({
severity: 'success',
summary: 'Success',
detail: `${model.fullname} Deleted`,
detail: `${model.basename} Deleted`,
life: 2000,
})
store.dialog.close({ key: dialogKey })
@@ -195,12 +215,24 @@ export const useModels = defineStore('models', (store) => {
})
}
function openModelDetail(model: BaseModel) {
const filename = model.basename.replace(model.extension, '')
store.dialog.open({
key: genModelKey(model),
title: filename,
content: DialogModelDetail,
contentProps: { model: model },
})
}
return {
folders: folders,
data: models,
refresh: refreshAllModels,
remove: deleteModel,
update: updateModel,
openModelDetail: openModelDetail,
}
})
@@ -267,9 +299,9 @@ const baseInfoKey = Symbol('baseInfo') as InjectionKey<
>
export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
const { formData: model, modelData } = formInstance
const { formData: model } = formInstance
const provideModelFolders = inject<any>(modelFolderProvideKey)
const provideModelFolders = inject(modelFolderProvideKey)
const modelFolders = computed<ModelFolder>(() => {
return provideModelFolders?.value ?? {}
})
@@ -292,16 +324,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
},
})
const subFolder = computed({
get: () => {
return model.value.subFolder
},
set: (val) => {
model.value.subFolder = val
},
})
const extension = computed(() => {
return model.value.extension
})
const basename = computed({
get: () => {
return model.value.fullname.replace(model.value.extension, '')
return model.value.basename
},
set: (val) => {
model.value.fullname = `${val ?? ''}${model.value.extension}`
model.value.basename = val
},
})
@@ -321,22 +362,25 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
{
key: 'type',
formatter: () =>
modelData.value.type in modelFolders.value
? modelData.value.type
: undefined,
model.value.type in modelFolders.value ? model.value.type : undefined,
},
{
key: 'pathIndex',
formatter: () => {
const modelType = modelData.value.type
const pathIndex = modelData.value.pathIndex
const modelType = model.value.type
const pathIndex = model.value.pathIndex
if (!modelType) {
return undefined
}
const folders = modelFolders.value[modelType] ?? []
return `${folders[pathIndex]}`
return [`${folders[pathIndex]}`, model.value.subFolder]
.filter(Boolean)
.join('/')
},
},
{
key: 'fullname',
formatter: (val) => val,
key: 'basename',
formatter: (val) => `${val}${model.value.extension}`,
},
{
key: 'sizeBytes',
@@ -371,6 +415,7 @@ export const useModelBaseInfoEditor = (formInstance: ModelFormInstance) => {
baseInfo,
basename,
extension,
subFolder,
pathIndex,
modelFolders,
}
@@ -384,6 +429,74 @@ export const useModelBaseInfo = () => {
return inject(baseInfoKey)!
}
export const useModelFolder = (
option: {
type?: MaybeRefOrGetter<string>
} = {},
) => {
const { data: models, folders: modelFolders } = useModels()
const pathOptions = computed(() => {
const type = toValue(option.type)
if (!type) {
return []
}
const folderItems = cloneDeep(models.value[type]) ?? []
const pureFolders = folderItems.filter((item) => item.type === 'folder')
pureFolders.sort((a, b) => a.basename.localeCompare(b.basename))
const folders = modelFolders.value[type] ?? []
const root: TreeNode[] = []
for (const [index, folder] of folders.entries()) {
const pathIndexItem: TreeNode = {
key: folder,
label: folder,
children: [],
}
const items = pureFolders
.filter((item) => item.pathIndex === index)
.map((item) => {
const node: TreeNode = {
key: `${folder}/${genModelFullName(item)}`,
label: item.basename,
data: item,
}
return node
})
const itemMap = Object.fromEntries(items.map((item) => [item.key, item]))
for (const item of items) {
const key = item.key
const parentKey = key.split('/').slice(0, -1).join('/')
if (parentKey === folder) {
pathIndexItem.children!.push(item)
continue
}
const parentItem = itemMap[parentKey]
if (parentItem) {
parentItem.children ??= []
parentItem.children.push(item)
}
}
root.push(pathIndexItem)
}
return root
})
return {
pathOptions,
}
}
/**
* Editable preview image.
*
@@ -552,11 +665,11 @@ export const useModelMetadata = () => {
return inject(metadataKey)!
}
export const useModelNodeAction = (model: BaseModel) => {
export const useModelNodeAction = () => {
const { t } = useI18n()
const { toast, wrapperToastError } = useToast()
const createNode = (options: Record<string, any> = {}) => {
const createNode = (model: BaseModel, options: Record<string, any> = {}) => {
const nodeType = resolveModelTypeLoader(model.type)
if (!nodeType) {
throw new Error(t('unSupportedModelType', [model.type]))
@@ -565,50 +678,52 @@ export const useModelNodeAction = (model: BaseModel) => {
const node = window.LiteGraph.createNode(nodeType, null, options)
const widgetIndex = node.widgets.findIndex((w) => w.type === 'combo')
if (widgetIndex > -1) {
node.widgets[widgetIndex].value = model.fullname
node.widgets[widgetIndex].value = genModelFullName(model)
}
return node
}
const dragToAddModelNode = wrapperToastError((event: DragEvent) => {
// const target = document.elementFromPoint(event.clientX, event.clientY)
// if (
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
// target.id === 'graph-canvas'
// ) {
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// const node = createNode({ pos })
// app.graph.add(node)
// app.canvas.selectNode(node)
// }
//
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
const dragToAddModelNode = wrapperToastError(
(model: BaseModel, event: DragEvent) => {
// const target = document.elementFromPoint(event.clientX, event.clientY)
// if (
// target?.tagName.toLocaleLowerCase() === 'canvas' &&
// target.id === 'graph-canvas'
// ) {
// const pos = app.clientPosToCanvasPos([event.clientX - 20, event.clientY])
// const node = createNode({ pos })
// app.graph.add(node)
// app.canvas.selectNode(node)
// }
//
// Use the legacy method instead
const removeEmbeddingExtension = true
const strictDragToAdd = false
ModelGrid.dragAddModel(
event,
model.type,
model.fullname,
removeEmbeddingExtension,
strictDragToAdd,
)
})
ModelGrid.dragAddModel(
event,
model.type,
genModelFullName(model),
removeEmbeddingExtension,
strictDragToAdd,
)
},
)
const addModelNode = wrapperToastError(() => {
const addModelNode = wrapperToastError((model: BaseModel) => {
const selectedNodes = app.canvas.selected_nodes
const firstSelectedNode = Object.values(selectedNodes)[0]
const offset = 25
const pos = firstSelectedNode
? [firstSelectedNode.pos[0] + offset, firstSelectedNode.pos[1] + offset]
: app.canvas.canvas_mouse
const node = createNode({ pos })
const node = createNode(model, { pos })
app.graph.add(node)
app.canvas.selectNode(node)
})
const copyModelNode = wrapperToastError(() => {
const node = createNode()
const copyModelNode = wrapperToastError((model: BaseModel) => {
const node = createNode(model)
app.canvas.copyToClipboard([node])
toast.add({
severity: 'success',
@@ -618,13 +733,13 @@ export const useModelNodeAction = (model: BaseModel) => {
})
})
const loadPreviewWorkflow = wrapperToastError(async () => {
const loadPreviewWorkflow = wrapperToastError(async (model: BaseModel) => {
const previewUrl = model.preview as string
const response = await fetch(previewUrl)
const data = await response.blob()
const type = data.type
const extension = type.split('/').pop()
const file = new File([data], `${model.fullname}.${extension}`, { type })
const file = new File([data], `${model.basename}.${extension}`, { type })
app.handleFile(file)
})

View File

@@ -46,7 +46,7 @@ const messages = {
info: {
type: 'Model Type',
pathIndex: 'Directory',
fullname: 'File Name',
basename: 'File Name',
sizeBytes: 'File Size',
createdAt: 'Created At',
updatedAt: 'Updated At',
@@ -62,6 +62,7 @@ const messages = {
excludeScanTypes: 'Exclude scan types (separate with commas)',
ui: 'UI',
cardSize: 'Card Size',
useFlatUI: 'Flat Layout',
},
},
zh: {
@@ -108,7 +109,7 @@ const messages = {
info: {
type: '类型',
pathIndex: '目录',
fullname: '文件名',
basename: '文件名',
sizeBytes: '文件大小',
createdAt: '创建时间',
updatedAt: '更新时间',
@@ -124,6 +125,7 @@ const messages = {
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
ui: '外观',
cardSize: '卡片尺寸',
useFlatUI: '展平布局',
},
},
}

View File

@@ -3,11 +3,11 @@ export type ContainerPosition = { left: number; top: number }
export interface BaseModel {
id: number | string
fullname: string
basename: string
extension: string
sizeBytes: number
type: string
subFolder: string
pathIndex: number
preview: string | string[]
description: string
@@ -17,6 +17,7 @@ export interface BaseModel {
export interface Model extends BaseModel {
createdAt: number
updatedAt: number
children?: Model[]
}
export interface VersionModel extends BaseModel {

View File

@@ -25,5 +25,5 @@ export const resolveModelTypeLoader = (type: string) => {
}
export const genModelKey = (model: BaseModel) => {
return `${model.type}:${model.pathIndex}:${model.fullname}`
return `${model.type}:${model.pathIndex}:${model.subFolder}:${model.basename}${model.extension}`
}

View File

@@ -9,6 +9,9 @@ export default {
plugins: [
plugin(({ addUtilities }) => {
addUtilities({
'.text-shadow': {
'text-shadow': '2px 2px 4px rgba(0, 0, 0, 0.5)',
},
'.scrollbar-none': {
'scrollbar-width': 'none',
},