13 Commits

Author SHA1 Message Date
Hayden
37be9a0b0d prepare release 2.3.4 2025-02-18 16:01:49 +08:00
Hayden
fcea052dde fix: resolve path (#132) 2025-02-10 17:00:08 +08:00
Hayden
9e95e7bd74 style: optimize style (#131) 2025-02-10 16:42:53 +08:00
Hayden
7e58d0a82d fix(setting): no modified value saved (#130)
* fix: save setting value

* prepare release 2.3.3
2025-02-10 13:51:45 +08:00
Hayden
55a4eff01b prepare releas 2.3.2 2025-02-10 12:42:29 +08:00
Hayden
45cf18299f feat: optimize resize card size (#129) 2025-02-10 12:41:00 +08:00
Hayden
c7898c47f1 fix: unpack folder_names_and_paths error (#128) 2025-02-10 10:59:36 +08:00
Hayden
17ab373b9c fix: change model size type to float (#126) 2025-02-06 12:02:00 +08:00
boeto
f6368fe20b fix: model preview path (#120) 2025-02-04 20:27:00 +08:00
Hayden
92f2d5ab9e Fix unable to install (#119)
* fix: release without requirements.txt

* prepare release 2.3.1
2025-02-04 11:12:15 +08:00
boeto
130c75f5bf fix huggingface download with tokens (#116) 2025-02-03 20:30:07 +08:00
Hayden
921dabc057 pref: optimize scan cost (#117) 2025-02-03 20:19:02 +08:00
Hayden
ac21c8015d style: optimize toolbar layout (#115) 2025-02-03 16:52:37 +08:00
10 changed files with 295 additions and 133 deletions

View File

@@ -60,7 +60,7 @@ jobs:
run: | run: |
pnpm install pnpm install
pnpm run build pnpm run build
tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml tar -czf dist.tar.gz py/ web/ __init__.py LICENSE pyproject.toml requirements.txt
- name: Create release draft - name: Create release draft
uses: softprops/action-gh-release@v2 uses: softprops/action-gh-release@v2

View File

@@ -61,7 +61,7 @@ class TaskContent:
description: str description: str
downloadPlatform: str downloadPlatform: str
downloadUrl: str downloadUrl: str
sizeBytes: int sizeBytes: float
hashes: Optional[dict[str, str]] = None hashes: Optional[dict[str, str]] = None
def __init__(self, **kwargs): def __init__(self, **kwargs):
@@ -71,7 +71,7 @@ class TaskContent:
self.description = kwargs.get("description", None) self.description = kwargs.get("description", None)
self.downloadPlatform = kwargs.get("downloadPlatform", None) self.downloadPlatform = kwargs.get("downloadPlatform", None)
self.downloadUrl = kwargs.get("downloadUrl", None) self.downloadUrl = kwargs.get("downloadUrl", None)
self.sizeBytes = int(kwargs.get("sizeBytes", 0)) self.sizeBytes = float(kwargs.get("sizeBytes", 0))
self.hashes = kwargs.get("hashes", None) self.hashes = kwargs.get("hashes", None)
def to_dict(self): def to_dict(self):
@@ -103,6 +103,8 @@ def get_task_content(task_id: str):
if not os.path.isfile(task_file): if not os.path.isfile(task_file):
raise RuntimeError(f"Task {task_id} not found") raise RuntimeError(f"Task {task_id} not found")
task_content = utils.load_dict_pickle_file(task_file) task_content = utils.load_dict_pickle_file(task_file)
if isinstance(task_content, TaskContent):
return task_content
return TaskContent(**task_content) return TaskContent(**task_content)
@@ -178,17 +180,18 @@ async def create_model_download_task(task_data: dict, request):
task_path = utils.join_path(download_path, f"{task_id}.task") task_path = utils.join_path(download_path, f"{task_id}.task")
if os.path.exists(task_path): if os.path.exists(task_path):
raise RuntimeError(f"Task {task_id} already exists") raise RuntimeError(f"Task {task_id} already exists")
download_platform = task_data.get("downloadPlatform", None)
try: try:
previewFile = task_data.pop("previewFile", None) preview_file = task_data.pop("previewFile", None)
utils.save_model_preview_image(task_path, previewFile) utils.save_model_preview_image(task_path, preview_file, download_platform)
set_task_content(task_id, task_data) set_task_content(task_id, task_data)
task_status = TaskStatus( task_status = TaskStatus(
taskId=task_id, taskId=task_id,
type=model_type, type=model_type,
fullname=fullname, fullname=fullname,
preview=utils.get_model_preview_name(task_path), preview=utils.get_model_preview_name(task_path),
platform=task_data.get("downloadPlatform", None), platform=download_platform,
totalSize=float(task_data.get("sizeBytes", 0)), totalSize=float(task_data.get("sizeBytes", 0)),
) )
download_model_task_status[task_id] = task_status download_model_task_status[task_id] = task_status
@@ -379,7 +382,7 @@ async def download_model_file(
# When parsing model information from HuggingFace API, # When parsing model information from HuggingFace API,
# the file size was not found and needs to be obtained from the response header. # the file size was not found and needs to be obtained from the response header.
if total_size == 0: if total_size == 0:
total_size = int(response.headers.get("content-length", 0)) total_size = float(response.headers.get("content-length", 0))
task_content.sizeBytes = total_size task_content.sizeBytes = total_size
task_status.totalSize = total_size task_status.totalSize = total_size
set_task_content(task_id, task_content) set_task_content(task_id, task_content)

View File

@@ -225,7 +225,7 @@ class HuggingfaceModelSearcher(ModelSearcher):
"pathIndex": 0, "pathIndex": 0,
"description": "\n".join(description_parts), "description": "\n".join(description_parts),
"metadata": {}, "metadata": {},
"downloadPlatform": "", "downloadPlatform": "huggingface",
"downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true", "downloadUrl": f"https://huggingface.co/{model_id}/resolve/main/{filename}?download=true",
} }
models.append(model) models.append(model)
@@ -386,7 +386,7 @@ class Information:
model_base_paths = utils.resolve_model_base_paths() model_base_paths = utils.resolve_model_base_paths()
for model_type in model_base_paths: for model_type in model_base_paths:
folders, extensions = folder_paths.folder_names_and_paths[model_type] folders, *others = folder_paths.folder_names_and_paths[model_type]
for path_index, base_path in enumerate(folders): for path_index, base_path in enumerate(folders):
files = utils.recursive_search_files(base_path, request) files = utils.recursive_search_files(base_path, request)

View File

@@ -1,6 +1,7 @@
import os import os
import folder_paths import folder_paths
from aiohttp import web from aiohttp import web
from concurrent.futures import ThreadPoolExecutor, as_completed
from . import utils from . import utils
@@ -115,43 +116,61 @@ class ModelManager:
def scan_models(self, folder: str, request): def scan_models(self, folder: str, request):
result = [] result = []
folders, extensions = folder_paths.folder_names_and_paths[folder] include_hidden_files = utils.get_setting_value(request, "scan.include_hidden_files", False)
folders, *others = folder_paths.folder_names_and_paths[folder]
def get_file_info(entry: os.DirEntry[str], base_path: str, path_index: int):
prefix_path = utils.normalize_path(base_path)
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]
if extension not in folder_paths.supported_pt_extensions:
return None
model_preview = f"/model-manager/preview/{folder}/{path_index}/{basename}.webp"
stat = entry.stat()
return {
"fullname": fullname,
"basename": basename,
"extension": extension,
"type": folder,
"pathIndex": path_index,
"sizeBytes": stat.st_size,
"preview": model_preview,
"createdAt": round(stat.st_ctime_ns / 1000000),
"updatedAt": round(stat.st_mtime_ns / 1000000),
}
def get_all_files_entry(directory: str):
files = []
with os.scandir(directory) as it:
for entry in it:
# Skip hidden files
if not include_hidden_files:
if entry.name.startswith("."):
continue
if entry.is_dir():
files.extend(get_all_files_entry(entry.path))
elif entry.is_file():
files.append(entry)
return files
for path_index, base_path in enumerate(folders): for path_index, base_path in enumerate(folders):
files = utils.recursive_search_files(base_path, request) if not os.path.exists(base_path):
continue
models = folder_paths.filter_files_extensions(files, folder_paths.supported_pt_extensions) file_entries = get_all_files_entry(base_path)
with ThreadPoolExecutor() as executor:
for fullname in models: futures = {executor.submit(get_file_info, entry, base_path, path_index): entry for entry in file_entries}
fullname = utils.normalize_path(fullname) for future in as_completed(futures):
basename = os.path.splitext(fullname)[0] file_info = future.result()
extension = os.path.splitext(fullname)[1] if file_info is None:
continue
abs_path = utils.join_path(base_path, fullname) result.append(file_info)
file_stats = os.stat(abs_path)
# Resolve preview
image_name = utils.get_model_preview_name(abs_path)
image_name = utils.join_path(os.path.dirname(fullname), image_name)
abs_image_path = utils.join_path(base_path, image_name)
if os.path.isfile(abs_image_path):
image_state = os.stat(abs_image_path)
image_timestamp = round(image_state.st_mtime_ns / 1000000)
image_name = f"{image_name}?ts={image_timestamp}"
model_preview = f"/model-manager/preview/{folder}/{path_index}/{image_name}"
model_info = {
"fullname": fullname,
"basename": basename,
"extension": extension,
"type": folder,
"pathIndex": path_index,
"sizeBytes": file_stats.st_size,
"preview": model_preview,
"createdAt": round(file_stats.st_ctime_ns / 1000000),
"updatedAt": round(file_stats.st_mtime_ns / 1000000),
}
result.append(model_info)
return result return result

View File

@@ -277,10 +277,9 @@ def remove_model_preview_image(model_path: str):
os.remove(preview_path) os.remove(preview_path)
def save_model_preview_image(model_path: str, image_file_or_url: Any): def save_model_preview_image(model_path: str, image_file_or_url: Any, platform: str | None = None):
basename = os.path.splitext(model_path)[0] basename = os.path.splitext(model_path)[0]
preview_path = f"{basename}.webp" preview_path = f"{basename}.webp"
# Download image file if it is url # Download image file if it is url
if type(image_file_or_url) is str: if type(image_file_or_url) is str:
image_url = image_file_or_url image_url = image_file_or_url
@@ -304,8 +303,11 @@ def save_model_preview_image(model_path: str, image_file_or_url: Any):
content_type: str = image_file.content_type content_type: str = image_file.content_type
if not content_type.startswith("image/"): if not content_type.startswith("image/"):
raise RuntimeError(f"FileTypeError: expected image, got {content_type}") if platform == "huggingface":
# huggingface previewFile content_type='text/plain', not startswith("image/")
return
else:
raise RuntimeError(f"FileTypeError: expected image, got {content_type}")
image = Image.open(image_file.file) image = Image.open(image_file.file)
image.save(preview_path, "WEBP") image.save(preview_path, "WEBP")

View File

@@ -1,7 +1,7 @@
[project] [project]
name = "comfyui-model-manager" name = "comfyui-model-manager"
description = "Manage models: browsing, download and delete." description = "Manage models: browsing, download and delete."
version = "2.3.0" version = "2.3.4"
license = { file = "LICENSE" } license = { file = "LICENSE" }
dependencies = ["markdownify"] dependencies = ["markdownify"]

View File

@@ -9,25 +9,29 @@
> >
<div ref="toolbarContainer" class="col-span-full"> <div ref="toolbarContainer" class="col-span-full">
<div :class="['flex gap-4', $toolbar_2xl('flex-row', 'flex-col')]"> <div :class="['flex gap-4', $toolbar_2xl('flex-row', 'flex-col')]">
<ResponseInput <div class="flex-1">
v-model="searchContent" <ResponseInput
:placeholder="$t('searchModels')" v-model="searchContent"
:allow-clear="true" :placeholder="$t('searchModels')"
suffix-icon="pi pi-search" :allow-clear="true"
></ResponseInput> suffix-icon="pi pi-search"
></ResponseInput>
</div>
<div class="flex items-center justify-between gap-4 overflow-hidden"> <div class="flex items-center justify-between gap-4 overflow-hidden">
<ResponseSelect <ResponseSelect
class="flex-1"
v-model="currentType" v-model="currentType"
:items="typeOptions" :items="typeOptions"
:type="isMobile ? 'drop' : 'button'"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect <ResponseSelect
class="flex-1"
v-model="sortOrder" v-model="sortOrder"
:items="sortOrderOptions" :items="sortOrderOptions"
></ResponseSelect> ></ResponseSelect>
<ResponseSelect <ResponseSelect
v-model="currentCardSize" class="flex-1"
v-model="cardSizeFlag"
:items="cardSizeOptions" :items="cardSizeOptions"
></ResponseSelect> ></ResponseSelect>
</div> </div>
@@ -76,7 +80,14 @@ import { genModelKey } from 'utils/model'
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
const { isMobile, gutter, cardSize } = useConfig() const {
isMobile,
gutter,
cardSize,
cardSizeMap,
cardSizeFlag,
dialog: settings,
} = useConfig()
const { data, folders } = useModels() const { data, folders } = useModels()
const { t } = useI18n() const { t } = useI18n()
@@ -89,7 +100,8 @@ const { $lg: $content_lg } = useContainerQueries(contentContainer)
const searchContent = ref<string>() const searchContent = ref<string>()
const currentType = ref('all') const allType = 'All'
const currentType = ref(allType)
const typeOptions = computed(() => { const typeOptions = computed(() => {
const excludeScanTypes = app.ui?.settings.getSettingValue<string>( const excludeScanTypes = app.ui?.settings.getSettingValue<string>(
configSetting.excludeScanTypes, configSetting.excludeScanTypes,
@@ -100,7 +112,7 @@ const typeOptions = computed(() => {
.map((type) => type.trim()) .map((type) => type.trim())
.filter(Boolean) ?? [] .filter(Boolean) ?? []
return [ return [
'all', allType,
...Object.keys(folders.value).filter( ...Object.keys(folders.value).filter(
(folder) => !customBlackList.includes(folder), (folder) => !customBlackList.includes(folder),
), ),
@@ -155,7 +167,7 @@ const list = computed(() => {
const mergedList = Object.values(data.value).flat() const mergedList = Object.values(data.value).flat()
const filterList = mergedList.filter((model) => { const filterList = mergedList.filter((model) => {
const showAllModel = currentType.value === 'all' const showAllModel = currentType.value === allType
const matchType = showAllModel || model.type === currentType.value const matchType = showAllModel || model.type === currentType.value
const matchName = model.fullname const matchName = model.fullname
@@ -197,48 +209,24 @@ const contentStyle = computed(() => ({
paddingRight: `1rem`, paddingRight: `1rem`,
})) }))
const currentCardSize = computed({
get: () => {
const options = cardSizeOptions.value.map((item) => item.value)
const current = [cardSize.value.width, cardSize.value.height].join('x')
if (options.includes(current)) {
return current
}
return 'custom'
},
set: (val) => {
if (val === 'custom') {
app.ui?.settings.show(t('size.customTip'))
return
}
const [width, height] = val.split('x')
app.ui?.settings.setSettingValue(
'ModelManager.UI.CardWidth',
parseInt(width),
)
app.ui?.settings.setSettingValue(
'ModelManager.UI.CardHeight',
parseInt(height),
)
},
})
const cardSizeOptions = computed(() => { const cardSizeOptions = computed(() => {
const defineOptions = { const customSize = 'size.custom'
extraLarge: '240x320',
large: '180x240', const customOptionMap = {
medium: '120x160', ...cardSizeMap.value,
small: '80x120', [customSize]: 'custom',
custom: 'custom',
} }
return Object.entries(defineOptions).map(([key, value]) => { return Object.keys(customOptionMap).map((key) => {
return { return {
label: t(`size.${key}`), label: t(key),
value, value: key,
command() { command: () => {
currentCardSize.value = value if (key === customSize) {
settings.showCardSizeSetting()
} else {
cardSizeFlag.value = key
}
}, },
} }
}) })

View File

@@ -0,0 +1,110 @@
<template>
<div class="flex h-full flex-col">
<div class="flex-1 px-4">
<DataTable :value="sizeList">
<Column field="name" :header="$t('name')">
<template #body="{ data, field }">
{{ $t(data[field]) }}
</template>
</Column>
<Column field="width" :header="$t('width')" class="min-w-36">
<template #body="{ data, field }">
<span class="flex items-center gap-4">
<Slider
v-model="data[field]"
class="flex-1"
v-bind="sizeStint"
></Slider>
<span>{{ data[field] }}</span>
</span>
</template>
</Column>
<Column field="height" :header="$t('height')" class="min-w-36">
<template #body="{ data, field }">
<span class="flex items-center gap-4">
<Slider
v-model="data[field]"
class="flex-1"
v-bind="sizeStint"
></Slider>
<span>{{ data[field] }}</span>
</span>
</template>
</Column>
</DataTable>
</div>
<div class="flex justify-between px-4">
<div></div>
<div class="flex gap-2">
<Button
icon="pi pi-refresh"
:label="$t('reset')"
@click="handleReset"
></Button>
<Button :label="$t('cancel')" @click="handleCancelEditor"></Button>
<Button :label="$t('save')" @click="handleSaveSizeMap"></Button>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useConfig } from 'hooks/config'
import { useDialog } from 'hooks/dialog'
import Button from 'primevue/button'
import Column from 'primevue/column'
import DataTable from 'primevue/datatable'
import Slider from 'primevue/slider'
import { onMounted, ref } from 'vue'
const { cardSizeMap, defaultCardSizeMap } = useConfig()
const dialog = useDialog()
const sizeList = ref()
const sizeStint = {
step: 10,
min: 80,
max: 320,
}
const resolveSizeMap = (sizeMap: Record<string, string>) => {
return Object.entries(sizeMap).map(([key, value]) => {
const [width, height] = value.split('x')
return {
id: key,
name: key,
width: parseInt(width),
height: parseInt(height),
}
})
}
const resolveSizeList = (
sizeList: { name: string; width: number; height: number }[],
) => {
return Object.fromEntries(
sizeList.map(({ name, width, height }) => {
return [name, [width, height].join('x')]
}),
)
}
onMounted(() => {
sizeList.value = resolveSizeMap(cardSizeMap.value)
})
const handleReset = () => {
sizeList.value = resolveSizeMap(defaultCardSizeMap)
}
const handleCancelEditor = () => {
sizeList.value = resolveSizeMap(cardSizeMap.value)
dialog.close()
}
const handleSaveSizeMap = () => {
cardSizeMap.value = resolveSizeList(sizeList.value)
dialog.close()
}
</script>

View File

@@ -1,11 +1,14 @@
import SettingCardSize from 'components/SettingCardSize.vue'
import { request } from 'hooks/request' import { request } from 'hooks/request'
import { defineStore } from 'hooks/store' import { defineStore } from 'hooks/store'
import { $el, app, ComfyDialog } from 'scripts/comfyAPI' import { $el, app, ComfyDialog } from 'scripts/comfyAPI'
import { onMounted, onUnmounted, ref } from 'vue' import { computed, onMounted, onUnmounted, readonly, ref, watch } from 'vue'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
import { useToast } from './toast' import { useToast } from './toast'
export const useConfig = defineStore('config', (store) => { export const useConfig = defineStore('config', (store) => {
const { t } = useI18n()
const mobileDeviceBreakPoint = 759 const mobileDeviceBreakPoint = 759
const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint) const isMobile = ref(window.innerWidth < mobileDeviceBreakPoint)
@@ -21,23 +24,59 @@ export const useConfig = defineStore('config', (store) => {
window.removeEventListener('resize', checkDeviceType) window.removeEventListener('resize', checkDeviceType)
}) })
const cardSize = ref({ const defaultCardSizeMap = readonly({
width: 'size.extraLarge': '240x320',
app.ui?.settings.getSettingValue<number>('ModelManager.UI.CardWidth') ?? 'size.large': '180x240',
240, 'size.medium': '120x160',
height: 'size.small': '80x120',
app.ui?.settings.getSettingValue<number>('ModelManager.UI.CardHeight') ?? })
310,
const cardSizeMap = ref<Record<string, string>>({ ...defaultCardSizeMap })
const cardSizeFlag = ref('size.extraLarge')
const cardSize = computed(() => {
const size = cardSizeMap.value[cardSizeFlag.value]
const [width = '120', height = '240'] = size.split('x')
return {
width: parseInt(width),
height: parseInt(height),
}
}) })
const config = { const config = {
isMobile, isMobile,
gutter: 16, gutter: 16,
cardSize, defaultCardSizeMap: defaultCardSizeMap,
cardSizeMap: cardSizeMap,
cardSizeFlag: cardSizeFlag,
cardSize: cardSize,
cardWidth: 240, cardWidth: 240,
aspect: 7 / 9, aspect: 7 / 9,
dialog: {
showCardSizeSetting: () => {
store.dialog.open({
key: 'setting.cardSize',
title: t('setting.cardSize'),
content: SettingCardSize,
defaultSize: {
width: 500,
height: 390,
},
})
},
},
} }
watch(cardSizeFlag, (val) => {
app.ui?.settings.setSettingValue('ModelManager.UI.CardSize', val)
})
watch(cardSizeMap, (val) => {
app.ui?.settings.setSettingValue(
'ModelManager.UI.CardSizeMap',
JSON.stringify(val),
)
})
useAddConfigSettings(store) useAddConfigSettings(store)
return config return config
@@ -109,36 +148,27 @@ function useAddConfigSettings(store: import('hooks/store').StoreProvider) {
defaultValue: undefined, defaultValue: undefined,
}) })
// UI settings const defaultCardSize = store.config.defaultCardSizeMap
app.ui?.settings.addSetting({ app.ui?.settings.addSetting({
id: 'ModelManager.UI.CardWidth', id: 'ModelManager.UI.CardSize',
category: [t('modelManager'), t('setting.ui'), 'CardWidth'], category: [t('modelManager'), t('setting.ui'), 'CardSize'],
name: t('setting.cardWidth'), name: t('setting.cardSize'),
type: 'slider', defaultValue: 'size.extraLarge',
defaultValue: 240, type: 'hidden',
attrs: { onChange: (val) => {
min: 80, store.config.cardSizeFlag.value = val
max: 320,
step: 10,
},
onChange(value) {
store.config.cardSize.value.width = value
}, },
}) })
app.ui?.settings.addSetting({ app.ui?.settings.addSetting({
id: 'ModelManager.UI.CardHeight', id: 'ModelManager.UI.CardSizeMap',
category: [t('modelManager'), t('setting.ui'), 'CardHeight'], category: [t('modelManager'), t('setting.ui'), 'CardSizeMap'],
name: t('setting.cardHeight'), name: t('setting.cardSize'),
type: 'slider', defaultValue: JSON.stringify(defaultCardSize),
defaultValue: 320, type: 'hidden',
attrs: {
min: 80,
max: 320,
step: 10,
},
onChange(value) { onChange(value) {
store.config.cardSize.value.height = value store.config.cardSizeMap.value = JSON.parse(value)
}, },
}) })

View File

@@ -25,6 +25,10 @@ const messages = {
none: 'None', none: 'None',
uploadFile: 'Upload File', uploadFile: 'Upload File',
tapToChange: 'Tap description to change content', tapToChange: 'Tap description to change content',
name: 'Name',
width: 'Width',
height: 'Height',
reset: 'Reset',
sort: { sort: {
name: 'Name', name: 'Name',
size: 'Largest', size: 'Largest',
@@ -57,6 +61,7 @@ const messages = {
includeHiddenFiles: 'Include hidden files(start with .)', includeHiddenFiles: 'Include hidden files(start with .)',
excludeScanTypes: 'Exclude scan types (separate with commas)', excludeScanTypes: 'Exclude scan types (separate with commas)',
ui: 'UI', ui: 'UI',
cardSize: 'Card Size',
}, },
}, },
zh: { zh: {
@@ -82,6 +87,10 @@ const messages = {
none: '无', none: '无',
uploadFile: '上传文件', uploadFile: '上传文件',
tapToChange: '点击描述可更改内容', tapToChange: '点击描述可更改内容',
name: '名称',
width: '宽度',
height: '高度',
reset: '重置',
sort: { sort: {
name: '名称', name: '名称',
size: '最大', size: '最大',
@@ -114,6 +123,7 @@ const messages = {
includeHiddenFiles: '包含隐藏文件(以 . 开头的文件或文件夹)', includeHiddenFiles: '包含隐藏文件(以 . 开头的文件或文件夹)',
excludeScanTypes: '排除扫描类型(使用英文逗号隔开)', excludeScanTypes: '排除扫描类型(使用英文逗号隔开)',
ui: '外观', ui: '外观',
cardSize: '卡片尺寸',
}, },
}, },
} }