Compare commits

...

2 Commits

Author SHA1 Message Date
zhaoyupeng
6f15f82122 wip: file list 2024-10-14 18:08:49 +08:00
loveuer
660c5a7efb feat: 完成了 file list 右键简单菜单
feat: 完成了 file 下载
todo: 桶右键菜单和删除桶
2024-10-14 11:39:39 +08:00
21 changed files with 608 additions and 150 deletions

View File

@ -4,7 +4,7 @@ import {VirtualizerScrollView} from "@fluentui/react-components/unstable";
import React from "react"; import React from "react";
import {useStoreBucket} from "../../store/bucket"; import {useStoreBucket} from "../../store/bucket";
import {Bucket} from "../../interfaces/connection"; import {Bucket} from "../../interfaces/connection";
import {useStoreFile} from "../../store/file"; import {useStoreFile, useStoreFileFilter} from "../../store/file";
import {useStoreConnection} from "../../store/connection"; import {useStoreConnection} from "../../store/connection";
const useStyles = makeStyles({ const useStyles = makeStyles({
@ -35,13 +35,13 @@ const useStyles = makeStyles({
export function ListBucketComponent() { export function ListBucketComponent() {
const styles = useStyles(); const styles = useStyles();
const {conn_active} = useStoreConnection()
const {bucket_set, bucket_list} = useStoreBucket() const {bucket_set, bucket_list} = useStoreBucket()
const {files_get} = useStoreFile() const {filter_set, prefix_set} = useStoreFileFilter()
async function handleClick(item: Bucket) { async function handleClick(item: Bucket) {
bucket_set(item) await bucket_set(item)
files_get(conn_active!, item, "") await filter_set('')
await prefix_set('')
} }
function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Bucket) { function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Bucket) {

View File

@ -9,7 +9,13 @@ import {
tokens, tokens,
Tooltip Tooltip
} from "@fluentui/react-components" } from "@fluentui/react-components"
import {DatabaseLinkRegular, DismissRegular} from "@fluentui/react-icons"; import {
DatabaseLinkRegular,
DeleteRegular,
DismissRegular,
PlugConnectedRegular, PlugDisconnectedRegular,
SettingsRegular
} from "@fluentui/react-icons";
import React, {useEffect, useState} from "react"; import React, {useEffect, useState} from "react";
import {Connection} from "../../interfaces/connection"; import {Connection} from "../../interfaces/connection";
import {useToast} from "../../message"; import {useToast} from "../../message";
@ -84,7 +90,7 @@ const useStyles = makeStyles({
export function ConnectionList() { export function ConnectionList() {
const styles = useStyles() const styles = useStyles()
const {dispatchMessage} = useToast(); const {dispatchMessage} = useToast();
const {conn_list, conn_update} = useStoreConnection(); const {conn_get, conn_list, conn_set} = useStoreConnection();
const [conn_filter, set_conn_filter] = useState<string>(''); const [conn_filter, set_conn_filter] = useState<string>('');
const {bucket_get, bucket_set} = useStoreBucket() const {bucket_get, bucket_set} = useStoreBucket()
const [ctx_menu, set_ctx_menu] = useState<{ const [ctx_menu, set_ctx_menu] = useState<{
@ -92,54 +98,67 @@ export function ConnectionList() {
y: number, y: number,
display: 'none' | 'block' display: 'none' | 'block'
}>({x: 0, y: 0, display: 'none'}); }>({x: 0, y: 0, display: 'none'});
const [menu_conn, set_menu_conn] = useState<Connection | null>(null);
useEffect(() => { useEffect(() => {
document.addEventListener("click", (e) => { document.addEventListener("click", (e) => {
set_ctx_menu({x: 0, y: 0, display: 'none'}); set_ctx_menu({x: 0, y: 0, display: 'none'});
}) })
setTimeout(() => {
conn_get().then()
}, 1000)
return () => { return () => {
document.removeEventListener("click", (e) => {}) document.removeEventListener("click", (e) => {
})
} }
}, []) }, [])
async function handleSelect(item: Connection) { async function handleSelect(item: Connection) {
conn_list.map((one: Connection) => { conn_list.map((one: Connection) => {
if (item.id === one.id && one.active) { if (item.id === one.id && one.active) {
conn_update(one) conn_set(one)
bucket_get(one, false) bucket_get(one, false)
bucket_set(null) bucket_set(null)
} }
}) })
} }
async function handleConnect(item: Connection) { async function handleConnect(item: Connection | null) {
if (!item) return;
let res = await Dial('/api/connection/connect', {id: item.id}); let res = await Dial('/api/connection/connect', {id: item.id});
if (res.status !== 200) { if (res.status !== 200) {
dispatchMessage(res.msg, "error") dispatchMessage(res.msg, "error")
return return
} }
conn_update({...item, active: true}) await conn_set({...item, active: true})
bucket_get(item, true) bucket_get(item, true)
bucket_set(null) await bucket_set(null)
} }
async function handleDisconnect(item: Connection) { async function handleDisconnect(item: Connection | null) {
if (!item) return;
let res = await Dial('/api/connection/disconnect', {id: item.id}) let res = await Dial('/api/connection/disconnect', {id: item.id})
if (res.status !== 200) { if (res.status !== 200) {
dispatchMessage(res.msg, "error") dispatchMessage(res.msg, "error")
return return
} }
conn_update({...item, active: false}) await conn_set({...item, active: false})
} }
async function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Connection) { async function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Connection) {
e.preventDefault() e.preventDefault()
console.log('[DEBUG] right click connection =', item, 'event =', e) set_menu_conn(item)
console.log(`[DEBUG] click position: [${e.pageX}, ${e.pageY}]`)
const ele = document.querySelector('#list-connection-container')
const eleX = ele ? ele.clientWidth : 0;
const eleY = ele ? ele.clientHeight : 0;
const positionX = (e.pageX + eleX > window.innerWidth) ? e.pageX - eleX : e.pageX
const positionY = (e.pageY + eleY > window.innerHeight) ? e.pageY - eleY : e.pageY
set_ctx_menu({ set_ctx_menu({
x: e.pageX, x: positionX,
y: e.pageY, y: positionY,
display: 'block', display: 'block',
}) })
} }
@ -160,13 +179,24 @@ export function ConnectionList() {
onChange={(e) => set_conn_filter(e.target.value)} onChange={(e) => set_conn_filter(e.target.value)}
/> />
</div> </div>
<div className={styles.ctx_menu} <div
id={'list-connection-container'}
className={styles.ctx_menu}
style={{left: ctx_menu.x, top: ctx_menu.y, display: ctx_menu.display}} style={{left: ctx_menu.x, top: ctx_menu.y, display: ctx_menu.display}}
> >
<MenuList> <MenuList>
<MenuItem></MenuItem> <MenuItem
<MenuItem></MenuItem> onClick={async () => {
<MenuItem></MenuItem> await handleConnect(menu_conn)
}}
icon={<PlugConnectedRegular/>}></MenuItem>
<MenuItem
onClick={async () => {
await handleDisconnect(menu_conn)
}}
icon={<PlugDisconnectedRegular/>}></MenuItem>
<MenuItem icon={<SettingsRegular/>}></MenuItem>
<MenuItem icon={<DeleteRegular/>}></MenuItem>
</MenuList> </MenuList>
</div> </div>
<div className={styles.items}> <div className={styles.items}>
@ -186,18 +216,6 @@ export function ConnectionList() {
icon={<DatabaseLinkRegular/>} icon={<DatabaseLinkRegular/>}
key={item.id}> key={item.id}>
{item.name} {item.name}
<Tooltip
content="断开连接"
relationship="label">
<Button
appearance={'transparent'}
size="small"
icon={<DismissRegular/>}
className={styles.items_disconn}
onClick={async () => {
await handleDisconnect(item)
}}/>
</Tooltip>
</MenuItem> </MenuItem>
})} })}
</MenuList> </MenuList>

View File

@ -69,7 +69,7 @@ export function ConnectionCreate(props: ConnectionCreateProps) {
dispatchMessage(res.msg, res.status === 200 ? "success" : "error"); dispatchMessage(res.msg, res.status === 200 ? "success" : "error");
if (res.status === 200) { if (res.status === 200) {
dispatchMessage("新建连接成功", "success"); dispatchMessage("新建连接成功", "success");
conn_get() await conn_get()
props.openFn(false) props.openFn(false)
} }
} }

View File

@ -3,6 +3,7 @@ import {ListBucketComponent} from "../bucket/list_bucket";
import {makeStyles} from "@fluentui/react-components"; import {makeStyles} from "@fluentui/react-components";
import {useStoreBucket} from "../../store/bucket"; import {useStoreBucket} from "../../store/bucket";
import {ListFileComponent} from "./list_file"; import {ListFileComponent} from "./list_file";
import {useState} from "react";
const useStyles = makeStyles({ const useStyles = makeStyles({
content: { content: {
@ -18,6 +19,7 @@ export function Content() {
const styles = useStyles() const styles = useStyles()
const {bucket_active } = useStoreBucket() const {bucket_active } = useStoreBucket()
return <div className={styles.content}> return <div className={styles.content}>
<Path /> <Path />
{ {

View File

@ -1,25 +1,61 @@
import {makeStyles, MenuItem, MenuList, Text, tokens} from "@fluentui/react-components"; import {makeStyles, MenuItem, MenuList, Spinner, Text, tokens} from "@fluentui/react-components";
import { import {
ArrowDownloadFilled,
DocumentBulletListRegular, DocumentChevronDoubleRegular, DocumentCssRegular, DocumentDatabaseRegular, DeleteRegular,
DocumentBulletListRegular,
DocumentChevronDoubleRegular,
DocumentCssRegular,
DocumentDatabaseRegular,
DocumentDismissRegular, DocumentDismissRegular,
DocumentImageRegular, DocumentJavascriptRegular, DocumentPdfRegular, DocumentYmlRegular, DocumentImageRegular,
FolderRegular DocumentJavascriptRegular,
DocumentPdfRegular,
DocumentYmlRegular,
FolderRegular,
PreviewLinkRegular
} from "@fluentui/react-icons"; } from "@fluentui/react-icons";
import {VirtualizerScrollView} from "@fluentui/react-components/unstable"; import {VirtualizerScrollView} from "@fluentui/react-components/unstable";
import React from "react"; import React, {useEffect, useState} from "react";
import {useStoreBucket} from "../../store/bucket"; import {useStoreBucket} from "../../store/bucket";
import {S3File} from "../../interfaces/connection"; import {S3File} from "../../interfaces/connection";
import {useStoreFile} from "../../store/file"; import {useStoreFile, useStoreFileFilter} from "../../store/file";
import {useStoreConnection} from "../../store/connection"; import {useStoreConnection} from "../../store/connection";
import {TrimSuffix} from "../../hook/strings"; import {TrimSuffix} from "../../hook/strings";
import {Dial} from "../../api";
import {useToast} from "../../message";
import {CanPreview} from "../../hook/preview";
const useStyles = makeStyles({ const useStyles = makeStyles({
container: { container: {
marginTop: '0.5rem', marginTop: '0.5rem',
maxWidth: 'calc(100vw - 25rem - 1px)', maxWidth: 'calc(100vw - 25.2rem)',
width: 'calc(100vw - 25rem - 1px)', width: 'calc(100vw - 25.2rem)',
height: 'calc(100vh - 9rem)', maxHeight: 'calc(100vh - 10rem)',
height: 'calc(100vh - 10rem)',
},
loading: {
flex: "1",
width: '100%',
height: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
flexDirection: 'column',
},
no_data: {
flex: "1",
height: '100%',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '8rem',
flexDirection: 'column',
},
list: {
flex: "1",
height: '100%',
width: '100%',
}, },
row: { row: {
height: '32px', height: '32px',
@ -39,24 +75,49 @@ const useStyles = makeStyles({
width: 'calc(100vw - 32rem)', width: 'calc(100vw - 32rem)',
display: "block", display: "block",
}, },
no_data: { ctx_menu: {
flex: "1", position: "absolute",
height: '100%', zIndex: "1000",
width: '100%', width: "15rem",
display: 'flex', backgroundColor: tokens.colorNeutralBackground1,
justifyContent: 'center', boxShadow: `${tokens.shadow16}`,
alignItems: 'center', paddingTop: "4px",
fontSize: '8rem', paddingBottom: "4px",
flexDirection: 'column',
}, },
}) })
export function ListFileComponent() { export function ListFileComponent() {
const styles = useStyles(); const styles = useStyles();
const {dispatchMessage} = useToast();
const {conn_active} = useStoreConnection(); const {conn_active} = useStoreConnection();
const {bucket_active} = useStoreBucket() const {bucket_active} = useStoreBucket()
const {files_get, files_list} = useStoreFile() const {file_active, files_get, file_set, files_list} = useStoreFile()
const {prefix, filter, prefix_set} = useStoreFileFilter()
const [ctx_menu, set_ctx_menu] = useState<{
x: number,
y: number,
display: 'none' | 'block'
}>({x: 0, y: 0, display: 'none'});
const [loading, set_loading] = useState(true)
useEffect(() => {
document.addEventListener("click", (e) => {
set_ctx_menu({x: 0, y: 0, display: 'none'});
})
return () => {
document.removeEventListener("click", (e) => {
})
}
}, [])
useEffect(() => {
set_loading(true)
files_get(conn_active!, bucket_active!, prefix, filter).then(() => {
// set_loading(false)
})
}, [conn_active, bucket_active, prefix, filter]);
const filename = (key: string) => { const filename = (key: string) => {
let strs = TrimSuffix(key, "/").split("/") let strs = TrimSuffix(key, "/").split("/")
@ -65,50 +126,116 @@ export function ListFileComponent() {
async function handleClick(item: S3File) { async function handleClick(item: S3File) {
if (item.type === 1) { if (item.type === 1) {
files_get(conn_active!, bucket_active!, item.key) await prefix_set(item.key)
return return
} }
} }
function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: S3File) { async function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: S3File) {
e.preventDefault() e.preventDefault()
await file_set(item.key)
const ele = document.querySelector('#list-file-container')
let eleX = ele ? ele.clientWidth : 0
let eleY = ele ? ele.clientHeight : 0
const positionX = (e.pageX + eleX > window.innerWidth) ? e.pageX - eleX : e.pageX
const positionY = (e.pageY + eleY > window.innerHeight) ? e.pageY - eleY : e.pageY
set_ctx_menu({
x: positionX,
y: positionY,
display: 'block',
})
} }
return <MenuList className={styles.container}> async function handleDownload(file: string | null) {
{files_list.length ? if (!file) return
<VirtualizerScrollView const res1 = await Dial<{ result: string }>("/runtime/dialog/save", {
numItems={files_list.length} default_filename: file,
itemSize={32} })
container={{role: 'list', style: {maxHeight: 'calc(100vh - 9rem)'}}} if (res1.status !== 200) {
> return
{(idx) => {
return <div
className={styles.row} key={idx}
onClick={async () => {
await handleClick(files_list[idx])
}}
onContextMenu={async (e) => {
handleRightClick(e, files_list[idx])
}}>
<MenuItem className={styles.item}
icon={files_list[idx].type ? <FolderRegular/> :
<FileIcon name={files_list[idx].name}/>}>
<Text truncate wrap={false} className={styles.text}>
{filename(files_list[idx].key)}
</Text>
</MenuItem>
</div>
}}
</VirtualizerScrollView> : <div className={styles.no_data}>
<div>
<DocumentDismissRegular/>
</div>
<Text size={900}>
</Text>
</div>
} }
if (!res1.data) return
const res2 = await Dial('/api/file/download', {
conn_id: conn_active?.id,
bucket: bucket_active?.name,
key: file,
location: res1.data.result,
})
if (res2.status === 200) {
dispatchMessage('保存文件成功', 'success')
}
}
async function handlePreview() {
dispatchMessage('todo', 'warning')
}
return <div className={styles.container}>
<div
id={'list-file-container'}
className={styles.ctx_menu}
style={{left: ctx_menu.x, top: ctx_menu.y, display: ctx_menu.display}}
>
<MenuList>
<MenuItem
onClick={async () => {
await handleDownload(file_active)
}}
icon={<ArrowDownloadFilled/>}></MenuItem>
<MenuItem
disabled={!CanPreview(file_active ?? '')}
onClick={async () => {
await handlePreview()
}}
icon={<PreviewLinkRegular/>}></MenuItem>
<MenuItem icon={<DeleteRegular/>}></MenuItem>
</MenuList> </MenuList>
</div>
<div className={styles.loading} style={{display: loading ? 'flex' : 'none'}}>
<Spinner appearance="primary" label="加载中..."/>
</div>
{
// (!loading) && (files_list.length === 0) ?
// <div className={styles.no_data}>
// <div>
// <DocumentDismissRegular/>
// </div>
// <Text size={900}>
// 没有文件
// </Text>
// </div>
// : <></>
}
{
// (!loading && files_list) ?
// <VirtualizerScrollView
// numItems={files_list.length}
// itemSize={32}
// container={{role: 'list', style: {maxHeight: 'calc(100vh - 9rem)'}}}
// >
// {(idx) => {
// return <div
// className={styles.row} key={idx}
// onClick={async () => {
// await handleClick(files_list[idx])
// }}
// onContextMenu={async (e) => {
// await handleRightClick(e, files_list[idx])
// }}>
// <MenuItem className={styles.item}
// icon={files_list[idx].type ? <FolderRegular/> :
// <FileIcon name={files_list[idx].name}/>}>
// <Text truncate wrap={false} className={styles.text}>
// {filename(files_list[idx].key)}
// </Text>
// </MenuItem>
// </div>
// }}
// </VirtualizerScrollView> : <></>
}
<MenuList className={styles.list}>
</MenuList>
</div>
} }
type FileIconProps = { type FileIconProps = {

View File

@ -1,10 +1,12 @@
import {Button, Input, makeStyles, Text, tokens, Tooltip} from "@fluentui/react-components"; import {Button, Input, makeStyles, Text, tokens, Tooltip} from "@fluentui/react-components";
import {useStoreBucket} from "../../store/bucket"; import {useStoreBucket} from "../../store/bucket";
import {ArchiveRegular, ArrowCurveUpLeftFilled} from "@fluentui/react-icons"; import {ArchiveRegular, ArrowCurveUpLeftFilled} from "@fluentui/react-icons";
import {useStoreFile} from "../../store/file"; import {useStoreFile, useStoreFileFilter} from "../../store/file";
import React from "react"; import React, {useState} from "react";
import {debounce} from 'lodash' import {debounce} from 'lodash'
import {useStoreConnection} from "../../store/connection"; import {useStoreConnection} from "../../store/connection";
import {ListFileComponent} from "./list_file";
import {ListBucketComponent} from "../bucket/list_bucket";
const useStyles = makeStyles({ const useStyles = makeStyles({
container: { container: {
@ -51,27 +53,28 @@ const useStyles = makeStyles({
}, },
}) })
export function Path() { export function Path() {
const styles = useStyles() const styles = useStyles()
const {conn_active} = useStoreConnection() const {conn_active} = useStoreConnection()
const {bucket_active, bucket_get, bucket_set} = useStoreBucket() const {bucket_active, bucket_get, bucket_set} = useStoreBucket()
const {prefix, files_get} = useStoreFile() const {prefix, filter, prefix_set, filter_set} = useStoreFileFilter()
async function handleClickUp() { async function handleClickUp() {
const dirs = prefix.split('/').filter((item => item)) const dirs = prefix.split('/').filter((item => item))
if (dirs.length > 0) { if (dirs.length > 0) {
dirs.pop() dirs.pop()
files_get(conn_active!, bucket_active!, dirs.join("/")) await prefix_set(dirs.join('/'))
return return
} }
bucket_get(conn_active!, false) bucket_get(conn_active!, false)
bucket_set(null) await bucket_set(null)
} }
const handleFilterChange = debounce((e) => { const handleFilterChange = debounce(async (e) => {
files_get(conn_active!, bucket_active!, prefix, e.target.value) await filter_set(e.target.value)
}, 500) }, 500)
return <div className={styles.container}> return <div className={styles.container}>

View File

@ -1,11 +1,4 @@
import {Button, Input, makeStyles, MenuItem, MenuList, mergeClasses, tokens, Tooltip} from "@fluentui/react-components"; import {makeStyles} from "@fluentui/react-components";
import {DismissRegular} from "@fluentui/react-icons";
import {useEffect, useState} from "react";
import {Connection} from "../../interfaces/connection";
import {useStoreBucket} from "../../store/bucket";
import {useStoreConnection} from "../../store/connection";
import {Dial} from "../../api";
import {useToast} from "../../message";
import {ConnectionList} from "../connection/list"; import {ConnectionList} from "../connection/list";
import {Content} from "../file/content"; import {Content} from "../file/content";
@ -20,12 +13,6 @@ const useStyles = makeStyles({
export function Body() { export function Body() {
const styles = useStyles(); const styles = useStyles();
const {conn_get} = useStoreConnection();
useEffect(() => {
conn_get()
}, []);
return <div className={styles.body}> return <div className={styles.body}>
<ConnectionList/> <ConnectionList/>

View File

@ -10,6 +10,7 @@ import {UploadFiles} from "../file/upload_files";
const useStyles = makeStyles({ const useStyles = makeStyles({
header: { header: {
height: "5rem", height: "5rem",
minHeight: '5rem',
width: "100%", width: "100%",
display: 'flex', display: 'flex',
alignItems: "center", alignItems: "center",

View File

@ -0,0 +1,3 @@
export function PreviewFile() {
}

View File

@ -0,0 +1,13 @@
export function CanPreview(filename: string) {
const fs = filename.split(".")
switch (fs[fs.length - 1]) {
case "jpg":
return "image/jpg"
case "jpeg":
return "image/jpg"
case "png":
return "image/png"
default:
return ""
}
}

View File

@ -4,7 +4,7 @@ import {Dial, Resp} from "../api";
interface StoreBucket { interface StoreBucket {
bucket_active: Bucket | null; bucket_active: Bucket | null;
bucket_set: (Bucket: Bucket | null) => void; bucket_set: (Bucket: Bucket | null) => Promise<void>;
bucket_list: Bucket[]; bucket_list: Bucket[];
bucket_get: (conn: Connection, refresh: boolean) => void; bucket_get: (conn: Connection, refresh: boolean) => void;
bucket_create: (conn: Connection, name: string, public_read: boolean, public_read_write: boolean) => void; bucket_create: (conn: Connection, name: string, public_read: boolean, public_read_write: boolean) => void;

View File

@ -5,8 +5,8 @@ import {Dial} from "../api";
interface StoreConnection { interface StoreConnection {
conn_active: Connection | null; conn_active: Connection | null;
conn_list: Connection[]; conn_list: Connection[];
conn_get: () => void; conn_get: () => Promise<void>;
conn_update: (connection: Connection) => void; conn_set: (connection: Connection) => Promise<void>;
} }
export const useStoreConnection = create<StoreConnection>()((set) => ({ export const useStoreConnection = create<StoreConnection>()((set) => ({
@ -21,7 +21,7 @@ export const useStoreConnection = create<StoreConnection>()((set) => ({
set({conn_list: res.data.list}) set({conn_list: res.data.list})
}, },
conn_update: async (connection: Connection) => { conn_set: async (connection: Connection) => {
set((state) => { set((state) => {
return { return {
conn_active: connection.active? connection: null, conn_active: connection.active? connection: null,

View File

@ -3,13 +3,19 @@ import {Bucket, Connection, S3File} from "../interfaces/connection";
import {Dial} from "../api"; import {Dial} from "../api";
interface StoreFile { interface StoreFile {
prefix: string; file_active: string | null,
file_set: (key: string) => Promise<void>,
files_list: S3File[]; files_list: S3File[];
files_get: (conn: Connection, bucket: Bucket, prefix?: string, filter?: string) => void; files_get: (conn: Connection, bucket: Bucket, prefix?: string, filter?: string) => Promise<void>;
} }
export const useStoreFile = create<StoreFile>()((set) => ({ export const useStoreFile = create<StoreFile>()((set) => ({
prefix: "", file_active: null,
file_set: async (key: string) => {
set((state) => {
return {file_active: key}
})
},
files_list: [], files_list: [],
files_get: async (conn: Connection, bucket: Bucket, prefix = '', filter = '') => { files_get: async (conn: Connection, bucket: Bucket, prefix = '', filter = '') => {
const res = await Dial<{ list: S3File[] }>('/api/bucket/files', { const res = await Dial<{ list: S3File[] }>('/api/bucket/files', {
@ -23,7 +29,25 @@ export const useStoreFile = create<StoreFile>()((set) => ({
} }
set((state) => { set((state) => {
return {files_list: res.data.list, prefix: prefix} return {files_list: res.data.list}
}) })
} },
}))
interface StoreFileFilter {
prefix: string;
filter: string;
prefix_set: (prefix: string) => Promise<void>;
filter_set: (filter: string) => Promise<void>;
}
export const useStoreFileFilter = create<StoreFileFilter>()((set) => ({
prefix: '',
filter: '',
prefix_set: async (keyword: string) => {
set(state => {return {prefix: keyword}})
},
filter_set: async (keyword: string) => {
set(state => {return {filter: keyword}})
},
})) }))

View File

@ -0,0 +1,18 @@
import {create} from 'zustand'
import {Bucket, Connection} from "../interfaces/connection";
interface StorePreview {
preview_key: string;
preview_url: string;
preview_content_type: string;
preview_set: (key: string) => void;
}
export const useStorePreview = create<StorePreview>()((set) => ({
preview_key: '',
preview_url: '',
preview_content_type: '',
preview_set: async (key: string) => set(state => {
return {preview_key: key}
}),
}))

2
go.mod
View File

@ -10,6 +10,7 @@ require (
github.com/aws/aws-sdk-go-v2/credentials v1.17.36 github.com/aws/aws-sdk-go-v2/credentials v1.17.36
github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2 github.com/aws/aws-sdk-go-v2/service/s3 v1.63.2
github.com/aws/smithy-go v1.21.0 github.com/aws/smithy-go v1.21.0
github.com/labstack/gommon v0.4.0
github.com/loveuer/go-sqlite3 v1.0.2 github.com/loveuer/go-sqlite3 v1.0.2
github.com/loveuer/nf v0.2.11 github.com/loveuer/nf v0.2.11
github.com/ncruces/go-sqlite3/gormlite v0.18.4 github.com/ncruces/go-sqlite3/gormlite v0.18.4
@ -49,7 +50,6 @@ require (
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
github.com/labstack/echo/v4 v4.10.2 // indirect github.com/labstack/echo/v4 v4.10.2 // indirect
github.com/labstack/gommon v0.4.0 // indirect
github.com/leaanthony/go-ansi-parser v1.6.0 // indirect github.com/leaanthony/go-ansi-parser v1.6.0 // indirect
github.com/leaanthony/gosod v1.0.3 // indirect github.com/leaanthony/gosod v1.0.3 // indirect
github.com/leaanthony/slicer v1.6.0 // indirect github.com/leaanthony/slicer v1.6.0 // indirect

View File

@ -25,6 +25,7 @@ func Resolve(path string) (ndh.Handler, bool) {
func Init(ctx context.Context) error { func Init(ctx context.Context) error {
register("/runtime/dialog/open", handler.DialogOpen(ctx)) register("/runtime/dialog/open", handler.DialogOpen(ctx))
register("/runtime/dialog/save", handler.DialogSave(ctx))
register("/api/connection/test", handler.ConnectionTest) register("/api/connection/test", handler.ConnectionTest)
register("/api/connection/create", handler.ConnectionCreate) register("/api/connection/create", handler.ConnectionCreate)
register("/api/connection/list", handler.ConnectionList) register("/api/connection/list", handler.ConnectionList)
@ -34,6 +35,9 @@ func Init(ctx context.Context) error {
register("/api/bucket/files", handler.BucketFiles) register("/api/bucket/files", handler.BucketFiles)
register("/api/bucket/create", handler.BucketCreate) register("/api/bucket/create", handler.BucketCreate)
register("/api/file/upload", handler.FileUpload) register("/api/file/upload", handler.FileUpload)
register("/api/file/info", handler.FileInfo)
register("/api/file/get", handler.FileGet)
register("/api/file/download", handler.FileDownload)
return nil return nil
} }

View File

@ -8,7 +8,6 @@ import (
"github.com/loveuer/nf-disk/internal/s3" "github.com/loveuer/nf-disk/internal/s3"
"github.com/loveuer/nf-disk/ndh" "github.com/loveuer/nf-disk/ndh"
"github.com/samber/lo" "github.com/samber/lo"
"time"
) )
func ConnectionTest(c *ndh.Ctx) error { func ConnectionTest(c *ndh.Ctx) error {
@ -210,11 +209,5 @@ func ConnectionBuckets(c *ndh.Ctx) error {
return c.Send500(err.Error()) return c.Send500(err.Error())
} }
// todo: for frontend test
buckets = append(buckets, &s3.ListBucketRes{
Name: "这是一个非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长非常长的名字",
CreatedAt: time.Now().UnixMilli(),
})
return c.Send200(map[string]any{"list": buckets}) return c.Send200(map[string]any{"list": buckets})
} }

View File

@ -5,6 +5,8 @@ import (
"github.com/loveuer/nf-disk/ndh" "github.com/loveuer/nf-disk/ndh"
"github.com/samber/lo" "github.com/samber/lo"
"github.com/wailsapp/wails/v2/pkg/runtime" "github.com/wailsapp/wails/v2/pkg/runtime"
"os"
"path/filepath"
) )
func DialogOpen(ctx context.Context) ndh.Handler { func DialogOpen(ctx context.Context) ndh.Handler {
@ -54,3 +56,58 @@ func DialogOpen(ctx context.Context) ndh.Handler {
return c.Send200(map[string]interface{}{"result": result}) return c.Send200(map[string]interface{}{"result": result})
} }
} }
func DialogSave(ctx context.Context) ndh.Handler {
return func(c *ndh.Ctx) error {
type Req struct {
Title string `json:"title"`
Filters []string `json:"filters"`
DefaultDirectory string `json:"default_directory"`
DefaultFilename string `json:"default_filename"`
}
var (
err error
req = new(Req)
opt = runtime.SaveDialogOptions{
Title: "将文件保存到",
}
result any
)
if err = c.ReqParse(req); err != nil {
return c.Send400(err.Error())
}
if req.Title != "" {
opt.Title = req.Title
}
if req.DefaultFilename != "" {
opt.DefaultFilename = req.DefaultFilename
}
if req.DefaultDirectory != "" {
opt.DefaultDirectory = req.DefaultDirectory
}
if opt.DefaultDirectory == "" {
var home string
if home, err = os.UserHomeDir(); err != nil {
opt.DefaultDirectory = filepath.Join(home, "Downloads")
}
}
if len(req.Filters) > 0 {
opt.Filters = lo.Map(req.Filters, func(item string, index int) runtime.FileFilter {
return runtime.FileFilter{Pattern: item}
})
}
if result, err = runtime.SaveFileDialog(ctx, opt); err != nil {
return c.Send500(err.Error())
}
return c.Send200(map[string]interface{}{"result": result})
}
}

View File

@ -86,3 +86,100 @@ func FileUpload(c *ndh.Ctx) error {
return c.Send200(req) return c.Send200(req)
} }
func FileInfo(c *ndh.Ctx) error {
type Req struct {
ConnId uint64 `json:"conn_id"`
Bucket string `json:"bucket"`
Key string `json:"key"`
}
var (
err error
req = new(Req)
client *s3.Client
info *s3.ObjectInfo
)
if err = c.ReqParse(req); err != nil {
return c.Send400(err.Error())
}
if _, client, err = manager.Manager.Use(req.ConnId); err != nil {
return c.Send500(err.Error())
}
if info, err = client.GetObjectInfo(c.Context(), req.Bucket, req.Key); err != nil {
return c.Send500(err.Error())
}
return c.Send200(info)
}
func FileGet(c *ndh.Ctx) error {
type Req struct {
ConnId uint64 `json:"conn_id"`
Bucket string `json:"bucket"`
Key string `json:"key"`
}
var (
err error
req = new(Req)
client *s3.Client
link *s3.ObjectEntry
)
if err = c.ReqParse(req); err != nil {
return c.Send400(err.Error())
}
if _, client, err = manager.Manager.Use(req.ConnId); err != nil {
return c.Send500(err.Error())
}
if link, err = client.GetObjectEntry(c.Context(), req.Bucket, req.Key); err != nil {
return c.Send500(err.Error())
}
return c.Send200(link)
}
func FileDownload(c *ndh.Ctx) error {
type Req struct {
ConnId uint64 `json:"conn_id"`
Bucket string `json:"bucket"`
Key string `json:"key"`
Location string `json:"location"`
}
var (
err error
req = new(Req)
client *s3.Client
obj *s3.ObjectEntity
target *os.File
)
if err = c.ReqParse(req); err != nil {
return c.Send400(err.Error())
}
if _, client, err = manager.Manager.Use(req.ConnId); err != nil {
return c.Send500(err.Error())
}
if obj, err = client.GetObject(c.Context(), req.Bucket, req.Key); err != nil {
return c.Send500(err.Error())
}
if target, err = os.OpenFile(filepath.Clean(req.Location), os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644); err != nil {
return c.Send500(err.Error())
}
if _, err = io.Copy(target, obj.Body); err != nil {
return c.Send500(err.Error())
}
return c.Send200(req)
}

121
internal/s3/get.go Normal file
View File

@ -0,0 +1,121 @@
package s3
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/labstack/gommon/log"
"io"
"net/http"
"time"
)
type ObjectInfo struct {
Bucket string
Key string
ContentType string
Expire int64
}
func (c *Client) GetObjectInfo(ctx context.Context, bucket string, key string) (*ObjectInfo, error) {
var (
err error
input = &s3.HeadObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}
output *s3.HeadObjectOutput
)
if output, err = c.client.HeadObject(ctx, input); err != nil {
return nil, err
}
return &ObjectInfo{
Bucket: bucket,
Key: key,
ContentType: aws.ToString(output.ContentType),
Expire: aws.ToTime(output.Expires).UnixMilli(),
}, nil
}
// GetObject
// https://docs.aws.amazon.com/AmazonS3/latest/userguide/example_s3_Scenario_PresignedUrl_section.html
type Presigner struct {
PresignClient *s3.PresignClient
}
func (presigner *Presigner) GetObject(ctx context.Context, bucketName string, objectKey string, lifetimeSecs int64) (*v4.PresignedHTTPRequest, error) {
request, err := presigner.PresignClient.PresignGetObject(ctx, &s3.GetObjectInput{
Bucket: aws.String(bucketName),
Key: aws.String(objectKey),
}, func(opts *s3.PresignOptions) {
opts.Expires = time.Duration(lifetimeSecs * int64(time.Second))
})
if err != nil {
log.Error("Presigner: couldn't get a presigned request to get %v:%v. Here's why: %v\n",
bucketName, objectKey, err)
}
return request, err
}
type ObjectEntry struct {
URL string
Method string
Header http.Header
}
func (c *Client) GetObjectEntry(ctx context.Context, bucket string, key string, lifetimes ...int64) (*ObjectEntry, error) {
var (
err error
lifetime int64 = 5 * 60
pc = &Presigner{PresignClient: s3.NewPresignClient(c.client)}
output *v4.PresignedHTTPRequest
)
if len(lifetimes) > 0 && lifetimes[0] > 0 {
lifetime = lifetimes[0]
}
if output, err = pc.GetObject(ctx, bucket, key, lifetime); err != nil {
return nil, err
}
return &ObjectEntry{
URL: output.URL,
Method: output.Method,
Header: output.SignedHeader,
}, nil
}
type ObjectEntity struct {
ObjectInfo
Body io.ReadCloser
}
func (c *Client) GetObject(ctx context.Context, bucket string, key string) (*ObjectEntity, error) {
var (
err error
input = &s3.GetObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
}
output *s3.GetObjectOutput
)
if output, err = c.client.GetObject(ctx, input); err != nil {
return nil, err
}
return &ObjectEntity{
ObjectInfo: ObjectInfo{
Bucket: bucket,
Key: key,
ContentType: aws.ToString(output.ContentType),
Expire: aws.ToTime(output.Expires).UnixMilli(),
},
Body: output.Body,
}, nil
}

View File

@ -1,10 +0,0 @@
function getBaseFileName(fullPath) {
return fullPath.replace(/.*[\/\\]/, '');
}
// 测试
const filePath = 'C:\\Users\\username\\Documents\\example.txt';
console.log(getBaseFileName(filePath)); // 输出: example.txt
const filePath2 = '/home/user/documents/example.txt';
console.log(getBaseFileName(filePath2)); // 输出: example.txt