feat: 支持预览图片,发送图片到文生图,图生图等。支持下载其他文件,支持缩略图,改用虚拟列表

pull/3/head
zanllp 2023-03-26 04:07:07 +08:00
parent d758a2978a
commit 892e952b62
20 changed files with 799 additions and 423 deletions

View File

@ -6,8 +6,8 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Vite App</title>
<script type="module" crossorigin src="/baidu_netdisk/fe-static/assets/index-a8fb8db6.js"></script> <script type="module" crossorigin src="/baidu_netdisk/fe-static/assets/index-390bfde0.js"></script>
<link rel="stylesheet" href="/baidu_netdisk/fe-static/assets/index-56cef25f.css"> <link rel="stylesheet" href="/baidu_netdisk/fe-static/assets/index-f245e7cd.css">
</head> </head>
<body> <body>
<div id="zanllp_dev_gradio_fe"></div> <div id="zanllp_dev_gradio_fe"></div>

View File

@ -7,9 +7,14 @@ import re
import subprocess import subprocess
import asyncio import asyncio
import subprocess import subprocess
from typing import Any, List, Literal, Union from typing import Any, List, Literal, Optional, Union
from scripts.baiduyun_task import BaiduyunTask from scripts.baiduyun_task import BaiduyunTask
from pydantic import BaseModel from pydantic import BaseModel
from fastapi.responses import FileResponse
from PIL import Image
from io import BytesIO
import hashlib
from scripts.bin import ( from scripts.bin import (
check_bin_exists, check_bin_exists,
cwd, cwd,
@ -67,7 +72,7 @@ def list_file(cwd="/"):
"date": match.group(3), "date": match.group(3),
"name": name.strip("/"), "name": name.strip("/"),
"type": f_type, "type": f_type,
"bytes": convert_to_bytes(size) if size != "-" else size "bytes": convert_to_bytes(size) if size != "-" else size,
} }
files.append(file_info) files.append(file_info)
return files return files
@ -92,7 +97,6 @@ def logout():
return bool(match) return bool(match)
def singleton_async(fn): def singleton_async(fn):
@functools.wraps(fn) @functools.wraps(fn)
async def wrapper(*args, **kwargs): async def wrapper(*args, **kwargs):
@ -108,8 +112,9 @@ def singleton_async(fn):
wrapper.busy = [] wrapper.busy = []
return wrapper return wrapper
send_img_path = { "value": "" }
def baidu_netdisk_api(_: Any, app: FastAPI): def baidu_netdisk_api(_: Any = None, app: FastAPI):
pre = "/baidu_netdisk" pre = "/baidu_netdisk"
app.mount( app.mount(
f"{pre}/fe-static", f"{pre}/fe-static",
@ -144,8 +149,9 @@ def baidu_netdisk_api(_: Any, app: FastAPI):
conf = {} conf = {}
try: try:
from modules.shared import opts from modules.shared import opts
conf = opts.data conf = opts.data
except: except:
pass pass
return { return {
"global_setting": conf, "global_setting": conf,
@ -260,3 +266,59 @@ def baidu_netdisk_api(_: Any, app: FastAPI):
raise HTTPException(status_code=400, detail=str(e)) raise HTTPException(status_code=400, detail=str(e))
return {"files": files} return {"files": files}
@app.get(pre + "/image-thumbnail")
async def thumbnail(path: str, size: str = '256,256'):
# 生成缓存文件的路径
hash = hashlib.md5((path + size).encode('utf-8')).hexdigest()
cache_path = f'/tmp/{hash}.webp'
# 如果缓存文件存在,则直接返回该文件
if os.path.exists(cache_path):
return FileResponse(
cache_path,
media_type="image/webp",
headers={"Cache-Control": "max-age=31536000", "ETag": hash},
)
# 如果缓存文件不存在,则生成缩略图并保存
with open(path, "rb") as f:
img = Image.open(BytesIO(f.read()))
w,h = size.split(',')
img.thumbnail((int(w),int(h)))
buffer = BytesIO()
img.save(buffer, 'webp')
# 将二进制数据写入缓存文件中
with open(cache_path, 'wb') as f:
f.write(buffer.getvalue())
# 返回缓存文件
return FileResponse(
cache_path,
media_type="image/webp",
headers={"Cache-Control": "max-age=31536000", "ETag": hash},
)
@app.get(pre+"/file")
async def get_file(filename: str, disposition: Optional[str] = None):
import mimetypes
if not os.path.exists(filename):
raise HTTPException(status_code=404)
# 根据文件后缀名获取媒体类型
media_type, _ = mimetypes.guess_type(filename)
headers = {}
if disposition:
headers["Content-Disposition"] = f'attachment; filename="{disposition}"'
return FileResponse(
filename,
media_type=media_type,
headers=headers,
)
@app.post(pre+"/send_img_path")
async def api_set_send_img_path(path: str):
global send_img_path
send_img_path["value"] = path

View File

@ -1,5 +1,5 @@
from scripts.api import baidu_netdisk_api from scripts.api import baidu_netdisk_api, send_img_path
from modules import script_callbacks from modules import script_callbacks, generation_parameters_copypaste as send
from scripts.bin import ( from scripts.bin import (
bin_file_name, bin_file_name,
get_matched_summary, get_matched_summary,
@ -7,9 +7,10 @@ from scripts.bin import (
download_bin_file, download_bin_file,
) )
from scripts.tool import cwd from scripts.tool import cwd
'''
"""
api函数声明和启动分离方便另外一边被外部调用 api函数声明和启动分离方便另外一边被外部调用
''' """
not_exists_msg = ( not_exists_msg = (
f"找不到{bin_file_name},尝试手动从 {get_matched_summary()[1]} 下载,下载后放到 {cwd} 文件夹下,重启界面" f"找不到{bin_file_name},尝试手动从 {get_matched_summary()[1]} 下载,下载后放到 {cwd} 文件夹下,重启界面"
@ -31,6 +32,29 @@ def on_ui_tabs():
if not exists: if not exists:
print(f"\033[31m{not_exists_msg}\033[0m") print(f"\033[31m{not_exists_msg}\033[0m")
with gr.Blocks(analytics_enabled=False) as baidu_netdisk: with gr.Blocks(analytics_enabled=False) as baidu_netdisk:
img = gr.Image(
type="pil",
elem_id="bd_hidden_img",
)
img_update_trigger = gr.Button("button", elem_id="bd_hidden_img_update_trigger")
def img_update_func():
return send_img_path.get("value")
img_update_trigger.click(img_update_func, outputs=img)
img_file_info = gr.Textbox(elem_id="bd_hidden_img_file_info")
for tab in ["txt2img", "img2img", "inpaint", "extras"]:
btn = gr.Button(f"Send to {tab}", elem_id=f"bd_hidden_tab_{tab}")
send.register_paste_params_button(
send.ParamBinding(
paste_button=btn,
tabname=tab,
source_image_component=img,
source_text_component=img_file_info,
)
)
gr.Textbox(not_exists_msg, visible=not exists) gr.Textbox(not_exists_msg, visible=not exists)
with gr.Row(visible=bool(exists)): with gr.Row(visible=bool(exists)):
with gr.Column(): with gr.Column():

3
style.css Normal file
View File

@ -0,0 +1,3 @@
[id^="bd_hidden_"] {
display: none;
}

4
vue/components.d.ts vendored
View File

@ -9,21 +9,23 @@ export {}
declare module '@vue/runtime-core' { declare module '@vue/runtime-core' {
export interface GlobalComponents { export interface GlobalComponents {
AAlert: typeof import('ant-design-vue/es')['Alert']
ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb'] ABreadcrumb: typeof import('ant-design-vue/es')['Breadcrumb']
ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem'] ABreadcrumbItem: typeof import('ant-design-vue/es')['BreadcrumbItem']
AButton: typeof import('ant-design-vue/es')['Button'] AButton: typeof import('ant-design-vue/es')['Button']
ADropdown: typeof import('ant-design-vue/es')['Dropdown'] ADropdown: typeof import('ant-design-vue/es')['Dropdown']
AForm: typeof import('ant-design-vue/es')['Form'] AForm: typeof import('ant-design-vue/es')['Form']
AFormItem: typeof import('ant-design-vue/es')['FormItem'] AFormItem: typeof import('ant-design-vue/es')['FormItem']
AImage: typeof import('ant-design-vue/es')['Image']
AInput: typeof import('ant-design-vue/es')['Input'] AInput: typeof import('ant-design-vue/es')['Input']
AInputNumber: typeof import('ant-design-vue/es')['InputNumber'] AInputNumber: typeof import('ant-design-vue/es')['InputNumber']
AInputSearch: typeof import('ant-design-vue/es')['InputSearch']
AMenu: typeof import('ant-design-vue/es')['Menu'] AMenu: typeof import('ant-design-vue/es')['Menu']
AMenuItem: typeof import('ant-design-vue/es')['MenuItem'] AMenuItem: typeof import('ant-design-vue/es')['MenuItem']
AModal: typeof import('ant-design-vue/es')['Modal'] AModal: typeof import('ant-design-vue/es')['Modal']
AProgress: typeof import('ant-design-vue/es')['Progress'] AProgress: typeof import('ant-design-vue/es')['Progress']
ASelect: typeof import('ant-design-vue/es')['Select'] ASelect: typeof import('ant-design-vue/es')['Select']
ASkeleton: typeof import('ant-design-vue/es')['Skeleton'] ASkeleton: typeof import('ant-design-vue/es')['Skeleton']
ASwitch: typeof import('ant-design-vue/es')['Switch']
ATabPane: typeof import('ant-design-vue/es')['TabPane'] ATabPane: typeof import('ant-design-vue/es')['TabPane']
ATabs: typeof import('ant-design-vue/es')['Tabs'] ATabs: typeof import('ant-design-vue/es')['Tabs']
ATag: typeof import('ant-design-vue/es')['Tag'] ATag: typeof import('ant-design-vue/es')['Tag']

199
vue/dist/assets/index-390bfde0.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

1
vue/dist/assets/index-f245e7cd.css vendored Normal file

File diff suppressed because one or more lines are too long

4
vue/dist/index.html vendored
View File

@ -5,8 +5,8 @@
<link rel="icon" href="/favicon.ico"> <link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Vite App</title> <title>Vite App</title>
<script type="module" crossorigin src="/baidu_netdisk/fe-static/assets/index-a8fb8db6.js"></script> <script type="module" crossorigin src="/baidu_netdisk/fe-static/assets/index-390bfde0.js"></script>
<link rel="stylesheet" href="/baidu_netdisk/fe-static/assets/index-56cef25f.css"> <link rel="stylesheet" href="/baidu_netdisk/fe-static/assets/index-f245e7cd.css">
</head> </head>
<body> <body>
<div id="zanllp_dev_gradio_fe"></div> <div id="zanllp_dev_gradio_fe"></div>

View File

@ -24,6 +24,7 @@
"pinia": "^2.0.33", "pinia": "^2.0.33",
"pinia-plugin-persistedstate": "^3.1.0", "pinia-plugin-persistedstate": "^3.1.0",
"vue": "^3.2.47", "vue": "^3.2.47",
"vue-virtual-scroller": "^2.0.0-beta.8",
"vue3-ts-util": "^0.8.2", "vue3-ts-util": "^0.8.2",
"vuedraggable": "^4.1.0" "vuedraggable": "^4.1.0"
}, },

View File

@ -12,16 +12,16 @@ import { useGlobalStore } from './store/useGlobalStore'
import { getAutoCompletedTagList } from './taskRecord/autoComplete' import { getAutoCompletedTagList } from './taskRecord/autoComplete'
import { useTaskListStore } from './store/useTaskListStore' import { useTaskListStore } from './store/useTaskListStore'
import TaskOperation from './taskRecord/taskOperation.vue' import TaskOperation from './taskRecord/taskOperation.vue'
import { useIntervalFn } from '@vueuse/core'
const user = ref<UserInfo>() const user = ref<UserInfo>()
const bduss = ref('') const bduss = ref('')
const queue = reactive(new FetchQueue(-1, 0, 0, 'throw')) const queue = reactive(new FetchQueue(-1, 0, 0, 'throw'))
const globalStore = useGlobalStore() const globalStore = useGlobalStore()
const taskStore = useTaskListStore() const taskStore = useTaskListStore()
onMounted(async () => { onMounted(async () => {
getGlobalSetting().then((resp) => { getGlobalSetting().then((resp) => {
globalStore.conf = resp globalStore.conf = resp
globalStore.autoCompletedDirList = getAutoCompletedTagList(resp).filter(v => v?.dir?.trim?.()) globalStore.autoCompletedDirList = getAutoCompletedTagList(resp).filter(v => v?.dir?.trim?.())
}) })
user.value = await queue.pushAction(getUserInfo).res user.value = await queue.pushAction(getUserInfo).res
@ -40,6 +40,19 @@ const onLoginBtnClick = async () => {
message.error(isAxiosError(error) ? error.response?.data?.detail ?? '未知错误' : '未知错误') message.error(isAxiosError(error) ? error.response?.data?.detail ?? '未知错误' : '未知错误')
} }
} }
const tips = ref('')
const msgs = [
'使用“快速移动”, 可以直接到到达图生图,文生图等文件夹',
"你可以通过拖拽调整两边区域的大小",
"使用ctrlshift可以很方便的进行多选",
"从百度云向本地拖拽是下载,本地向百度云拖拽是上传",
"可以多尝试更多里面的功能",
"鼠标在文件上右键可以打开上下文菜单",
"提醒任务完成后,你需要手动刷新下才能看到新文件"]
useIntervalFn(() => {
tips.value = msgs[~~(Math.random() * msgs.length)]
}, 3000)
</script> </script>
<template> <template>
@ -50,12 +63,22 @@ const onLoginBtnClick = async () => {
<div> <div>
已登录用户{{ user.username }} 已登录用户{{ user.username }}
</div> </div>
<a-button @click="onLogoutBtnClick"> <div class="flex-placeholder" /><a-alert :message="tips" type="info" show-icon />
<template #icon>
<logout-outlined /> <a-form layout="inline">
</template> <a-form-item label="使用缩略图预览">
登出 <a-switch v-model:checked="globalStore.enableThumbnail" />
</a-button> </a-form-item>
<a-form-item>
<a-button @click="onLogoutBtnClick">
<template #icon>
<logout-outlined />
</template>
登出
</a-button>
</a-form-item>
</a-form>
</template> </template>
<a-form layout="inline" v-else> <a-form layout="inline" v-else>
@ -83,13 +106,12 @@ const onLoginBtnClick = async () => {
<loading3-quarters-outlined v-if="!taskStore.queue.isIdle" spin /> <loading3-quarters-outlined v-if="!taskStore.queue.isIdle" spin />
</span> </span>
</template> </template>
<task-operation/> <task-operation />
</a-tab-pane> </a-tab-pane>
</a-tabs> </a-tabs>
</a-skeleton> </a-skeleton>
</template> </template>
<style scoped lang="scss"> <style scoped lang="scss">
:deep() .ant-tabs-nav { :deep() .ant-tabs-nav {
margin: 0 16px; margin: 0 16px;
} }
@ -102,7 +124,7 @@ const onLoginBtnClick = async () => {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
.actions-bar>* { &> :not(:first-child) {
margin-left: 16px; margin-left: 16px;
} }
} }

View File

@ -155,4 +155,8 @@ export const cancelTask = async (id: string) => {
export const removeTask = async (id: string) => { export const removeTask = async (id: string) => {
return axiosInst.delete(`/task/${id}`) return axiosInst.delete(`/task/${id}`)
}
export const setImgPath = async (path: string) => {
return axiosInst.post(`/send_img_path?path=${encodeURIComponent(path)}`)
} }

View File

@ -1,10 +1,11 @@
<script setup lang="ts"> <script setup lang="ts">
import { SplitView } from 'vue3-ts-util' import { SplitView } from 'vue3-ts-util'
import StackView from './stackView.vue' import StackView from './stackView.vue'
import { useGlobalStore } from '@/store/useGlobalStore'
const store = useGlobalStore()
</script> </script>
<template> <template>
<split-view> <split-view v-model:percent="store.stackViewSplit">
<template #left> <template #left>
<stack-view target="local" /> <stack-view target="local" />
</template> </template>

View File

@ -13,7 +13,7 @@ const onOK = () => {
</script> </script>
<template> <template>
<a-modal v-model:visible="visible" title="输入地址回车" @ok="onOK"> <a-modal v-model:visible="visible" title="输入地址回车" @ok="onOK">
<a-input @press-enter="onOK" v-model:value="loc" style="width: 300px;" allow-clear></a-input> <a-input @press-enter="onOK" v-model:value="loc" style="width: 100%;" allow-clear></a-input>
</a-modal> </a-modal>
<a style="margin-left: 4px;" @click="visible = true">前往</a> <a style="margin-left: 8px;" @click="visible = true">前往</a>
</template> </template>

View File

@ -1,20 +1,26 @@
<script setup lang="ts"> <script setup lang="ts">
import { getTargetFolderFiles, type FileNodeInfo } from '@/api/files' import { getTargetFolderFiles, type FileNodeInfo } from '@/api/files'
import { setImgPath } from '@/api'
import { cloneDeep, last, range, uniq } from 'lodash' import { cloneDeep, last, range, uniq } from 'lodash'
import { ref, computed, onMounted, watch, h } from 'vue' import { ref, computed, onMounted, watch, h } from 'vue'
import { FileOutlined, FolderOpenOutlined, DownOutlined } from '@/icon' import { FileOutlined, FolderOpenOutlined, DownOutlined } from '@/icon'
import { sortMethodMap, sortFiles, SortMethod } from './fileSort' import { sortMethodMap, sortFiles, SortMethod } from './fileSort'
import path from 'path-browserify' import path from 'path-browserify'
import { useGlobalStore } from '@/store/useGlobalStore' import { useGlobalStore } from '@/store/useGlobalStore'
import { copy2clipboard, ok, type SearchSelectConv, SearchSelect, useWatchDocument } from 'vue3-ts-util' import { copy2clipboard, ok, type SearchSelectConv, SearchSelect, useWatchDocument, fallbackImage, delay } from 'vue3-ts-util'
// @ts-ignore // @ts-ignore
import NProgress from 'multi-nprogress' import NProgress from 'multi-nprogress'
import 'multi-nprogress/nprogress.css' import 'multi-nprogress/nprogress.css'
import type Progress from 'nprogress' import type Progress from 'nprogress'
import { message, Modal } from 'ant-design-vue' import { message, Modal } from 'ant-design-vue'
import FolderNavigator from './folderNavigator.vue' import FolderNavigator from './folderNavigator.vue'
import { gradioApp, isImageFile } from '@/util'
import { useElementSize } from '@vueuse/core'
// @ts-ignore
import { RecycleScroller } from 'vue-virtual-scroller'
import 'vue-virtual-scroller/dist/vue-virtual-scroller.css'
import type { MenuInfo } from 'ant-design-vue/lib/menu/src/interface'
const np = ref<Progress.NProgress>()
const el = ref<HTMLDivElement>() const el = ref<HTMLDivElement>()
const props = defineProps<{ const props = defineProps<{
target: 'local' | 'netdisk' target: 'local' | 'netdisk'
@ -23,186 +29,278 @@ interface Page {
files: FileNodeInfo[] files: FileNodeInfo[]
curr: string curr: string
} }
const stack = ref<Page[]>([]) interface FileNodeInfoR extends FileNodeInfo {
fullpath: string
}
type ViewMode = 'line' | 'grid' | 'large-size-grid'
const global = useGlobalStore() const global = useGlobalStore()
const currPage = computed(() => last(stack.value)) const { currLocation, currPage, refresh, copyLocation, back, openNext, stack, to } = useLocation()
const multiSelectedIdxs = ref([] as number[]) const { gridItems, sortMethodConv, moreActionsDropdownShow, sortedFiles, sortMethod, viewMode, gridSize, viewModeMap, largeGridSize } = useFilesDisplay()
const currLocation = computed(() => path.join(...getBasePath())) const { onDrop, onFileDragStart, multiSelectedIdxs } = useFileTransfer()
const { onFileItemClick, onContextMenuClick } = useFileItemActions()
useWatchDocument('click', () => multiSelectedIdxs.value = []) const toRawFileUrl = (file: FileNodeInfoR, download = false) => `/baidu_netdisk/file?filename=${encodeURIComponent(file.fullpath)}${download ? `&disposition=${encodeURIComponent(file.name)}` : ''}`
useWatchDocument('blur', () => multiSelectedIdxs.value = []) const toImageThumbnailUrl = (file: FileNodeInfoR, size = '256,256') => `/baidu_netdisk/image-thumbnail?path=${encodeURIComponent(file.fullpath)}&size=${size}`
watch(currPage, () => multiSelectedIdxs.value = [])
const sortMethodConv: SearchSelectConv<SortMethod> = {
value: (v) => v,
text: (v) => '按' + sortMethodMap[v]
}
const sortMethod = ref(SortMethod.DATE_DESC)
const sortedFiles = computed(() => sortFiles(currPage.value?.files ?? [], sortMethod.value))
onMounted(async () => { function useFilesDisplay () {
const resp = await getTargetFolderFiles(props.target, '/') const moreActionsDropdownShow = ref(false)
stack.value.push({ const viewMode = ref<ViewMode>('line')
files: resp.files, const viewModeMap: Record<ViewMode, string> = { line: '详情列表', 'grid': '预览网格', 'large-size-grid': '大尺寸预览网格' }
curr: '/' const sortMethodConv: SearchSelectConv<SortMethod> = {
}) value: (v) => v,
np.value = new NProgress() text: (v) => '按' + sortMethodMap[v]
np.value!.configure({ parent: el.value as any })
if (props.target == 'local') {
global.conf?.home && to(global.conf.home)
} }
}) const sortMethod = ref(SortMethod.DATE_DESC)
const sortedFiles = computed(() => sortFiles(currPage.value?.files ?? [], sortMethod.value).map(v => ({ ...v, fullpath: path.join(currLocation.value, v.name) })))
const gridSize = 288
const getBasePath = () => const largeGridSize = gridSize * 2
stack.value.map((v) => v.curr).slice(global.conf?.is_win && props.target === 'local' ? 1 : 0) const { width } = useElementSize(el)
const gridItems = computed(() => {
const w = width.value
if (viewMode.value === 'line' || !w) {
const copyLocation = () => copy2clipboard(currLocation.value)
const openNext = async (file: FileNodeInfo) => {
if (file.type !== 'dir') {
return
}
try {
np.value?.start()
const prev = getBasePath()
const { files } = await getTargetFolderFiles(
props.target,
path.normalize(path.join(...prev, file.name))
)
stack.value.push({
files,
curr: file.name
})
} finally {
np.value?.done()
}
}
const back = (idx: number) => {
while (idx < stack.value.length - 1) {
stack.value.pop()
}
}
const to = async (dir: string) => {
const backup = cloneDeep(stack.value)
try {
if (!/^((\w:)|\/)/.test(dir)) {
//
dir = path.join(global.conf?.sd_cwd ?? '/', dir)
}
const frags = dir.split(/\\|\//)
if (global.conf?.is_win && props.target === 'local') {
frags[0] = frags[0] + '/' // c:
} else {
frags.shift() // /
}
back(0) //
for (const frag of frags) {
const target = currPage.value?.files.find((v) => v.name === frag)
ok(target)
await openNext(target)
}
} catch (error) {
message.error('移动失败,检查你的路径输入')
stack.value = backup
throw error
}
}
const onDrop = async (e: DragEvent) => {
type Data = {
from: typeof props.target
path: string[],
includeDir: boolean
}
const data = JSON.parse(e.dataTransfer?.getData('text') || '{}') as Data
console.log(data)
if (data.from && data.path && typeof data.includeDir !== 'undefined') {
if (data.from === props.target) {
return return
} }
const type = data.from === 'local' ? 'upload' : 'download' return ~~(w / (viewMode.value === 'grid' ? gridSize : largeGridSize))
const typeZH = type === 'upload' ? '上传' : '下载' })
const toPath = path.join(...getBasePath()) return {
const content = h('div', [ gridItems,
h('div', `${props.target !== 'local' ? '本地' : '云盘'} `), sortedFiles,
h('ol', data.path.map(v => v.split(/[/\\]/).pop()).map(v => h('li', v))), sortMethodConv,
h('div', `${typeZH} ${props.target === 'local' ? '本地' : '云盘'} ${toPath}`) viewModeMap,
]) moreActionsDropdownShow,
Modal.confirm({ viewMode,
title: `确定创建${typeZH}任务${data.includeDir ? ', 这是文件夹或者包含文件夹!' : ''}`, gridSize,
content, sortMethod,
maskClosable: true, largeGridSize
async onOk () {
global.eventEmitter.emit('createNewTask', { send_dirs: data.path, recv_dir: toPath, type })
}
})
} }
} }
const refresh = async () => {
if (stack.value.length === 1) { function useLocation () {
const np = ref<Progress.NProgress>()
const currPage = computed(() => last(stack.value))
const stack = ref<Page[]>([])
const currLocation = computed(() => path.join(...getBasePath()))
onMounted(async () => {
const resp = await getTargetFolderFiles(props.target, '/') const resp = await getTargetFolderFiles(props.target, '/')
stack.value = [ stack.value.push({
{ files: resp.files,
files: resp.files, curr: '/'
curr: '/'
}
]
} else {
const last = currPage.value
stack.value.pop()
await openNext(currPage.value?.files.find((v) => v.name === last?.curr)!)
}
}
const onFileItemClick = async (e: MouseEvent, file: FileNodeInfo) => {
e.stopPropagation()
const files = sortedFiles.value
const idx = files.indexOf(file)
if (e.shiftKey) {
multiSelectedIdxs.value.push(idx)
multiSelectedIdxs.value.sort((a, b) => a - b)
const first = multiSelectedIdxs.value[0]
const last = multiSelectedIdxs.value[multiSelectedIdxs.value.length - 1]
multiSelectedIdxs.value = range(first, last + 1)
} else if (e.ctrlKey) {
multiSelectedIdxs.value.push(idx)
} else {
await openNext(file)
}
}
const onFileDragStart = (e: DragEvent, idx: number) => {
const file = cloneDeep(sortedFiles.value[idx])
const names = [file.name]
let includeDir = file.type === 'dir'
if (multiSelectedIdxs.value.includes(idx)) {
const selectedFiles = multiSelectedIdxs.value.map(idx => sortedFiles.value[idx])
names.push(...selectedFiles.map(v => v.name))
includeDir = selectedFiles.some(v => v.type === 'dir')
}
const basePath = getBasePath()
e.dataTransfer!.setData(
'text/plain',
JSON.stringify({
from: props.target,
includeDir,
path: uniq(names).map(name => path.join(...basePath, name))
}) })
) np.value = new NProgress()
np.value!.configure({ parent: el.value as any })
if (props.target == 'local') {
global.conf?.home && to(global.conf.home)
}
})
const getBasePath = () =>
stack.value.map((v) => v.curr).slice(global.conf?.is_win && props.target === 'local' ? 1 : 0)
const copyLocation = () => copy2clipboard(currLocation.value)
const openNext = async (file: FileNodeInfo) => {
if (file.type !== 'dir') {
return
}
try {
np.value?.start()
const prev = getBasePath()
const { files } = await getTargetFolderFiles(
props.target,
path.normalize(path.join(...prev, file.name))
)
stack.value.push({
files,
curr: file.name
})
} finally {
np.value?.done()
}
}
const back = (idx: number) => {
while (idx < stack.value.length - 1) {
stack.value.pop()
}
}
const to = async (dir: string) => {
const backup = cloneDeep(stack.value)
try {
if (!/^((\w:)|\/)/.test(dir)) {
//
dir = path.join(global.conf?.sd_cwd ?? '/', dir)
}
const frags = dir.split(/\\|\//)
if (global.conf?.is_win && props.target === 'local') {
frags[0] = frags[0] + '/' // c:
} else {
frags.shift() // /
}
back(0) //
for (const frag of frags) {
const target = currPage.value?.files.find((v) => v.name === frag)
ok(target)
await openNext(target)
}
} catch (error) {
message.error('移动失败,检查你的路径输入')
stack.value = backup
throw error
}
}
const refresh = async () => {
if (stack.value.length === 1) {
const resp = await getTargetFolderFiles(props.target, '/')
stack.value = [
{
files: resp.files,
curr: '/'
}
]
} else {
const last = currPage.value
stack.value.pop()
await openNext(currPage.value?.files.find((v) => v.name === last?.curr)!)
}
}
return {
refresh,
copyLocation,
back,
openNext,
currPage,
currLocation,
to,
stack
}
} }
function useFileTransfer () {
const multiSelectedIdxs = ref([] as number[])
const recover = () => {
multiSelectedIdxs.value = []
}
useWatchDocument('click', recover)
useWatchDocument('blur', recover)
watch(currPage, recover)
const onFileDragStart = (e: DragEvent, idx: number) => {
const file = cloneDeep(sortedFiles.value[idx])
const names = [file.name]
let includeDir = file.type === 'dir'
if (multiSelectedIdxs.value.includes(idx)) {
const selectedFiles = multiSelectedIdxs.value.map(idx => sortedFiles.value[idx])
names.push(...selectedFiles.map(v => v.name))
includeDir = selectedFiles.some(v => v.type === 'dir')
}
e.dataTransfer!.setData(
'text/plain',
JSON.stringify({
from: props.target,
includeDir,
path: uniq(names).map(name => path.join(currLocation.value, name))
})
)
}
const onDrop = async (e: DragEvent) => {
type Data = {
from: typeof props.target
path: string[],
includeDir: boolean
}
const data = JSON.parse(e.dataTransfer?.getData('text') || '{}') as Data
console.log(data)
if (data.from && data.path && typeof data.includeDir !== 'undefined') {
if (data.from === props.target) {
return
}
const type = data.from === 'local' ? 'upload' : 'download'
const typeZH = type === 'upload' ? '上传' : '下载'
const toPath = currLocation.value
const content = h('div', [
h('div', `${props.target !== 'local' ? '本地' : '云盘'} `),
h('ol', data.path.map(v => v.split(/[/\\]/).pop()).map(v => h('li', v))),
h('div', `${typeZH} ${props.target === 'local' ? '本地' : '云盘'} ${toPath}`)
])
Modal.confirm({
title: `确定创建${typeZH}任务${data.includeDir ? ', 这是文件夹或者包含文件夹!' : ''}`,
content,
maskClosable: true,
async onOk () {
global.eventEmitter.emit('createNewTask', { send_dirs: data.path, recv_dir: toPath, type })
}
})
}
}
return {
onFileDragStart,
onDrop,
multiSelectedIdxs
}
}
function useFileItemActions () {
const onFileItemClick = async (e: MouseEvent, file: FileNodeInfo) => {
const files = sortedFiles.value
const idx = files.findIndex(v => v.name === file.name)
if (e.shiftKey) {
multiSelectedIdxs.value.push(idx)
multiSelectedIdxs.value.sort((a, b) => a - b)
const first = multiSelectedIdxs.value[0]
const last = multiSelectedIdxs.value[multiSelectedIdxs.value.length - 1]
multiSelectedIdxs.value = range(first, last + 1)
e.stopPropagation()
} else if (e.ctrlKey) {
multiSelectedIdxs.value.push(idx)
e.stopPropagation()
} else {
await openNext(file)
}
}
const onContextMenuClick = async (e: MenuInfo, file: FileNodeInfoR) => {
const url = toRawFileUrl(file)
const copyImgTo = async (tab: ["txt2img", "img2img", "inpaint", "extras"][number]) => {
await setImgPath(file.fullpath) //
const btn = gradioApp().querySelector('#bd_hidden_img_update_trigger')! as HTMLButtonElement
btn.click() //
await delay(500) // workaround
const tabBtn = gradioApp().querySelector(`#bd_hidden_tab_${tab}`) as HTMLButtonElement
tabBtn.click() //
}
switch (e.key) {
case 'openInNewWindow': return window.open(url)
case 'download': return window.open(toRawFileUrl(file, true))
case 'copyPreviewUrl': return copy2clipboard(location.host + url)
case 'send2txt2img': return copyImgTo('txt2img')
case 'send2img2img': return copyImgTo('img2img')
case 'send2inpaint': return copyImgTo('inpaint')
case 'send2extras': return copyImgTo('extras')
}
}
return {
onFileItemClick,
onContextMenuClick
}
}
</script> </script>
<template> <template>
<div ref="el" @dragover.prevent @drop.prevent="onDrop($event)" class="container"> <div ref="el" @dragover.prevent @drop.prevent="onDrop($event)" class="container">
<div class="location-bar"> <div class="location-bar">
<div class="breadcrumb"> <div class="breadcrumb">
<a-breadcrumb style="flex: 1"> <a-breadcrumb style="flex: 1">
@ -213,12 +311,11 @@ const onFileDragStart = (e: DragEvent, idx: number) => {
</div> </div>
<div class="actions"> <div class="actions">
<SearchSelect v-model:value="sortMethod" :conv="sortMethodConv" :options="Object.keys(sortMethodMap)" />
<a class="opt" @click.prevent="refresh"> 刷新 </a> <a class="opt" @click.prevent="refresh"> 刷新 </a>
<a-dropdown v-if="props.target === 'local'"> <a-dropdown v-if="props.target === 'local'">
<a class="ant-dropdown-link opt" @click.prevent> <a class="opt" @click.prevent>
快速移动 快速移动
<DownOutlined /> <down-outlined />
</a> </a>
<template #overlay> <template #overlay>
<a-menu> <a-menu>
@ -228,30 +325,82 @@ const onFileDragStart = (e: DragEvent, idx: number) => {
</a-menu> </a-menu>
</template> </template>
</a-dropdown> </a-dropdown>
<a class="opt" @click.prevent="copyLocation">复制路径</a>
<folder-navigator :loc="currLocation" @to="to"/> <a-dropdown :trigger="['click']" v-model:visible="moreActionsDropdownShow" placement="bottomLeft">
<a class="opt" @click.prevent>
更多
</a>
<template #overlay>
<div @click.stop
style=" width: 384px; background: white; padding: 16px; border-radius: 4px; box-shadow: 0 0 4px #aaa; border: 1px solid #aaa;">
<a-form v-bind="{
labelCol: { span: 6 },
wrapperCol: { span: 18 }
}">
<a-form-item label="查看模式">
<search-select v-model:value="viewMode" :conv="{ value: v => v, text: v => viewModeMap[v as ViewMode] }"
:options="Object.keys(viewModeMap)" />
</a-form-item>
<a-form-item label="排序方法">
<search-select v-model:value="sortMethod" :conv="sortMethodConv"
:options="Object.keys(sortMethodMap)" />
</a-form-item>
<a-form-item>
<a @click.prevent="copyLocation">复制路径</a>
<folder-navigator :loc="currLocation" @to="to" />
</a-form-item>
</a-form>
</div>
</template>
</a-dropdown>
</div> </div>
</div> </div>
<div v-if="currPage" class="view"> <div v-if="currPage" class="view">
<ul class="file-list"> <RecycleScroller class="file-list" :items="sortedFiles" :prerender="10"
<li class="file" v-for="file, idx in sortedFiles" :item-size="viewMode === 'line' ? 80 : (viewMode === 'grid' ? gridSize : largeGridSize)" key-field="fullpath"
:class="{ clickable: file.type === 'dir', selected: multiSelectedIdxs.includes(idx) }" :key="file.name" :gridItems="gridItems">
draggable="true" @dragstart="onFileDragStart($event, idx)" @click.capture="onFileItemClick($event, file)"> <template v-slot="{ item: file, index: idx }">
<file-outlined v-if="file.type === 'file'" /> <a-dropdown :trigger="['contextmenu']">
<folder-open-outlined v-else /> <li class="file"
<div class="name"> :class="{ clickable: file.type === 'dir', selected: multiSelectedIdxs.includes(idx), grid: viewMode === 'grid', 'large-grid': viewMode === 'large-size-grid' }"
{{ file.name }} :key="file.name" draggable="true" @dragstart="onFileDragStart($event, idx)"
</div> @click.capture="onFileItemClick($event, file)">
<div class="basic-info"> <a-image :key="file.fullpath"
<div> v-if="props.target === 'local' && viewMode !== 'line' && isImageFile(file.name)"
{{ file.size }} :src="global.enableThumbnail ? toImageThumbnailUrl(file, viewMode === 'grid' ? void 0 : '512,512') : toRawFileUrl(file)"
</div> :fallback="fallbackImage" :preview="{ src: toRawFileUrl(file) }">
<div> </a-image>
{{ file.date }} <template v-else>
</div> <file-outlined class="icon" v-if="file.type === 'file'" />
</div> <folder-open-outlined class="icon" v-else />
</li> <div class="name">
</ul> {{ file.name }}
</div>
<div class="basic-info">
<div>
{{ file.size }}
</div>
<div>
{{ file.date }}
</div>
</div>
</template>
</li>
<template #overlay>
<a-menu v-if="props.target === 'local' && file.type === 'file'" @click="onContextMenuClick($event, file)">
<a-menu-item key="openInNewWindow">在新窗口预览如果浏览器处理不了会下载大文件的话谨慎</a-menu-item>
<a-menu-item key="download">直接下载大文件的话谨慎</a-menu-item>
<a-menu-item key="copyPreviewUrl">复制源文件预览链接</a-menu-item>
<a-menu-item key="send2txt2img">发送到文生图</a-menu-item>
<a-menu-item key="send2img2img">发送到图生图</a-menu-item>
<a-menu-item key="send2inpaint">发送到局部重绘</a-menu-item>
<a-menu-item key="send2extras">发送到附加功能</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</template>
</RecycleScroller>
</div> </div>
</div> </div>
</template> </template>
@ -271,6 +420,7 @@ const onFileDragStart = (e: DragEvent, idx: number) => {
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
} }
a.opt { a.opt {
@ -295,6 +445,74 @@ const onFileDragStart = (e: DragEvent, idx: number) => {
background: white; background: white;
border-radius: 8px; border-radius: 8px;
box-shadow: 0 0 4px #ccc; box-shadow: 0 0 4px #ccc;
position: relative;
&.grid {
padding: 8px;
height: 256px;
width: 256px;
margin: 16px;
display: inline-block;
box-sizing: content-box;
:deep() {
.icon {
font-size: 6em;
margin-top: 16px;
}
.name {
margin: 16px 0;
}
.basic-info {
position: absolute;
bottom: 16px;
right: 16px;
}
img {
height: 256px;
width: 256px;
object-fit: contain;
}
}
}
&.large-grid {
padding: 8px;
height: 512px;
width: 512px;
margin: 16px;
display: inline-block;
box-sizing: content-box;
:deep() {
.icon {
font-size: 6em;
margin-top: 16px;
}
.name {
margin: 16px 0;
}
.basic-info {
position: absolute;
bottom: 16px;
right: 16px;
}
img {
height: 512px;
width: 512px;
object-fit: contain;
}
}
}
&.clickable { &.clickable {
cursor: pointer; cursor: pointer;
@ -307,6 +525,7 @@ const onFileDragStart = (e: DragEvent, idx: number) => {
.name { .name {
flex: 1; flex: 1;
padding: 8px; padding: 8px;
word-break: break-all
} }
.basic-info { .basic-info {

View File

@ -7,10 +7,17 @@ import { typedEventEmitter } from 'vue3-ts-util'
export const useGlobalStore = defineStore('useGlobalStore', () => { export const useGlobalStore = defineStore('useGlobalStore', () => {
const conf = ref<GlobalConf>() const conf = ref<GlobalConf>()
const autoCompletedDirList = ref([] as ReturnType<typeof getAutoCompletedTagList>) const autoCompletedDirList = ref([] as ReturnType<typeof getAutoCompletedTagList>)
const enableThumbnail = ref(true)
const stackViewSplit = ref(50)
return { return {
conf, conf,
autoCompletedDirList, autoCompletedDirList,
enableThumbnail,
stackViewSplit,
...typedEventEmitter<{ createNewTask: Partial<UploadTaskSummary> }>() ...typedEventEmitter<{ createNewTask: Partial<UploadTaskSummary> }>()
} }
}, {
persist: {
paths: ['enableThumbnail', 'stackViewSplit']
}
}) })

View File

@ -24,19 +24,19 @@ export const getAutoCompletedTagList = ({ global_setting, sd_cwd, home }: Return
} }
type Keys = keyof (typeof allTag) type Keys = keyof (typeof allTag)
const cnMap: Record<Keys, string> = { const cnMap: Record<Keys, string> = {
outdir_txt2img_samples: '文生图的输出目录',
outdir_img2img_samples: '图生图的输出目录',
outdir_save: '使用“保存”按钮保存图像的目录',
outdir_extras_samples: '附加功能选项卡的输出目录',
additional_networks_extra_lora_path: '扫描 LoRA 模型的附加目录', additional_networks_extra_lora_path: '扫描 LoRA 模型的附加目录',
outdir_grids: '宫格图的输出目录', outdir_grids: '宫格图的输出目录',
outdir_extras_samples: '附加功能选项卡的输出目录',
outdir_img2img_grids: '图生图网格文件夹', outdir_img2img_grids: '图生图网格文件夹',
outdir_img2img_samples: '图生图的输出目录',
outdir_samples: '图像的输出目录', outdir_samples: '图像的输出目录',
outdir_txt2img_samples: '文生图的输出目录',
outdir_txt2img_grids: '文生图宫格的输出目录', outdir_txt2img_grids: '文生图宫格的输出目录',
hypernetworks: '超网络模型的路径', hypernetworks: '超网络模型的路径',
outdir_save: '使用“保存”按钮保存图像的目录',
embeddings: 'Embedding的文件夹', embeddings: 'Embedding的文件夹',
cwd: '工作文件夹', cwd: '工作文件夹',
home: 'home' home: 'home',
} }
return Object.keys(cnMap).map((k) => { return Object.keys(cnMap).map((k) => {
const key = k as Keys const key = k as Keys

View File

@ -1,7 +1,7 @@
import { idKey, type UniqueId } from 'vue3-ts-util' import { idKey, type UniqueId } from 'vue3-ts-util'
export function gradioApp() { export function gradioApp() {
const elems = document.getElementsByTagName('gradio-app') const elems = parent.document.getElementsByTagName('gradio-app')
const gradioShadowRoot = elems.length == 0 ? null : elems[0].shadowRoot const gradioShadowRoot = elems.length == 0 ? null : elems[0].shadowRoot
return gradioShadowRoot ? gradioShadowRoot : document; return gradioShadowRoot ? gradioShadowRoot : document;
} }
@ -40,4 +40,10 @@ export const pick = <T extends Dict, keys extends Array<keyof T>> (v: T, ...keys
* *
* ReturnTypeAsync\<typeof fn\> * ReturnTypeAsync\<typeof fn\>
*/ */
export type ReturnTypeAsync<T extends (...arg: any) => Promise<any>> = Awaited<ReturnType<T>> export type ReturnTypeAsync<T extends (...arg: any) => Promise<any>> = Awaited<ReturnType<T>>
export function isImageFile(filename: string): boolean {
const imageExtensions = ['.jpg', '.jpeg', '.png', '.gif', '.bmp', '.webp'];
const extension = filename.split('.').pop()?.toLowerCase();
return extension !== undefined && imageExtensions.includes(`.${extension}`);
}

View File

@ -2008,6 +2008,11 @@ minimatch@^7.4.2:
dependencies: dependencies:
brace-expansion "^2.0.1" brace-expansion "^2.0.1"
mitt@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/mitt/-/mitt-2.1.0.tgz#f740577c23176c6205b121b2973514eade1b2230"
integrity sha512-ILj2TpLiysu2wkBbWjAmww7TkZb65aiQO+DkVdUTBpBXq+MHYiETENkKFMtsJZX1Lf4pe4QOrTSjIfUwN5lRdg==
ms@2.1.2: ms@2.1.2:
version "2.1.2" version "2.1.2"
resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.2.tgz#d09d1f357b443f493382a8eb3ccd183872ae6009"
@ -2755,6 +2760,16 @@ vue-eslint-parser@^9.0.0, vue-eslint-parser@^9.0.1:
lodash "^4.17.21" lodash "^4.17.21"
semver "^7.3.6" semver "^7.3.6"
vue-observe-visibility@^2.0.0-alpha.1:
version "2.0.0-alpha.1"
resolved "https://registry.yarnpkg.com/vue-observe-visibility/-/vue-observe-visibility-2.0.0-alpha.1.tgz#1e4eda7b12562161d58984b7e0dea676d83bdb13"
integrity sha512-flFbp/gs9pZniXR6fans8smv1kDScJ8RS7rEpMjhVabiKeq7Qz3D9+eGsypncjfIyyU84saU88XZ0zjbD6Gq/g==
vue-resize@^2.0.0-alpha.1:
version "2.0.0-alpha.1"
resolved "https://registry.yarnpkg.com/vue-resize/-/vue-resize-2.0.0-alpha.1.tgz#43eeb79e74febe932b9b20c5c57e0ebc14e2df3a"
integrity sha512-7+iqOueLU7uc9NrMfrzbG8hwMqchfVfSzpVlCMeJQe4pyibqyoifDNbKTZvwxZKDvGkB+PdFeKvnGZMoEb8esg==
vue-router@^4.1.6: vue-router@^4.1.6:
version "4.1.6" version "4.1.6"
resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.1.6.tgz#b70303737e12b4814578d21d68d21618469375a1" resolved "https://registry.yarnpkg.com/vue-router/-/vue-router-4.1.6.tgz#b70303737e12b4814578d21d68d21618469375a1"
@ -2785,6 +2800,15 @@ vue-types@^3.0.0:
dependencies: dependencies:
is-plain-object "3.0.1" is-plain-object "3.0.1"
vue-virtual-scroller@^2.0.0-beta.8:
version "2.0.0-beta.8"
resolved "https://registry.yarnpkg.com/vue-virtual-scroller/-/vue-virtual-scroller-2.0.0-beta.8.tgz#eeceda57e4faa5ba1763994c873923e2a956898b"
integrity sha512-b8/f5NQ5nIEBRTNi6GcPItE4s7kxNHw2AIHLtDp+2QvqdTjVN0FgONwX9cr53jWRgnu+HRLPaWDOR2JPI5MTfQ==
dependencies:
mitt "^2.1.0"
vue-observe-visibility "^2.0.0-alpha.1"
vue-resize "^2.0.0-alpha.1"
vue3-ts-util@^0.8.2: vue3-ts-util@^0.8.2:
version "0.8.2" version "0.8.2"
resolved "https://registry.yarnpkg.com/vue3-ts-util/-/vue3-ts-util-0.8.2.tgz#d0d9dfa6077c65bd124ba5fcf0e4ed32906be3ab" resolved "https://registry.yarnpkg.com/vue3-ts-util/-/vue3-ts-util-0.8.2.tgz#d0d9dfa6077c65bd124ba5fcf0e4ed32906be3ab"