Compare commits

..
Author SHA1 Message Date
loveuer 3ff83f12b4 wip: file preview 2024-10-14 22:31:59 +08:00
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
zhaoyupeng 777253063b feat: 完成了 新建桶; 上传文件(基本功能)
todo: 上传 rename, 上传 public 权限选择
bug: 首次加载 conns list; 上传的时候前缀过滤失败
2024-10-12 17:46:20 +08:00
loveuer 1c818daf16 🎉 开始项目
feat: 完成基础界面; 列表展示
todo: uplevel button function
todo: download/upload
2024-10-11 22:24:14 +08:00
55 changed files with 2598 additions and 393 deletions
+1 -1
View File
@@ -6,7 +6,7 @@
<title>nf-disk</title> <title>nf-disk</title>
</head> </head>
<body> <body>
<div id="root"></div> <div id="root" style="height: 100%"></div>
<script src="./src/main.tsx" type="module"></script> <script src="./src/main.tsx" type="module"></script>
</body> </body>
</html> </html>
+110 -1
View File
@@ -10,11 +10,16 @@
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.54.16", "@fluentui/react-components": "^9.54.16",
"@fluentui/react-icons": "^2.0.258", "@fluentui/react-icons": "^2.0.258",
"jotai": "^2.10.0",
"lodash": "^4.17.21",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2",
"use-debounce": "^10.0.3",
"zustand": "^5.0.0-rc.2"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.17.10",
"@types/react": "^18.0.17", "@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1", "@vitejs/plugin-react": "^2.0.1",
@@ -2370,6 +2375,13 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"node_modules/@types/lodash": {
"version": "4.17.10",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.10.tgz",
"integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/prop-types": { "node_modules/@types/prop-types": {
"version": "15.7.13", "version": "15.7.13",
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.13.tgz", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.13.tgz",
@@ -3002,6 +3014,27 @@
"url": "https://github.com/sponsors/ljharb" "url": "https://github.com/sponsors/ljharb"
} }
}, },
"node_modules/jotai": {
"version": "2.10.0",
"resolved": "https://registry.npmmirror.com/jotai/-/jotai-2.10.0.tgz",
"integrity": "sha512-8W4u0aRlOIwGlLQ0sqfl/c6+eExl5D8lZgAUolirZLktyaj4WnxO/8a0HEPmtriQAB6X5LMhXzZVmw02X0P0qQ==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=17.0.0",
"react": ">=17.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"react": {
"optional": true
}
}
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -3037,6 +3070,12 @@
"integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==", "integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"license": "MIT"
},
"node_modules/loose-envify": { "node_modules/loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -3427,6 +3466,18 @@
"browserslist": ">= 4.21.0" "browserslist": ">= 4.21.0"
} }
}, },
"node_modules/use-debounce": {
"version": "10.0.3",
"resolved": "https://registry.npmmirror.com/use-debounce/-/use-debounce-10.0.3.tgz",
"integrity": "sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==",
"license": "MIT",
"engines": {
"node": ">= 16.0.0"
},
"peerDependencies": {
"react": "*"
}
},
"node_modules/use-disposable": { "node_modules/use-disposable": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/use-disposable/-/use-disposable-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/use-disposable/-/use-disposable-1.0.2.tgz",
@@ -3502,6 +3553,35 @@
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true "dev": true
},
"node_modules/zustand": {
"version": "5.0.0-rc.2",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.0-rc.2.tgz",
"integrity": "sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
} }
}, },
"dependencies": { "dependencies": {
@@ -5193,6 +5273,12 @@
"tslib": "^2.4.0" "tslib": "^2.4.0"
} }
}, },
"@types/lodash": {
"version": "4.17.10",
"resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.10.tgz",
"integrity": "sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==",
"dev": true
},
"@types/prop-types": { "@types/prop-types": {
"version": "15.7.13", "version": "15.7.13",
"resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.13.tgz", "resolved": "https://registry.npmmirror.com/@types/prop-types/-/prop-types-15.7.13.tgz",
@@ -5549,6 +5635,12 @@
"hasown": "^2.0.2" "hasown": "^2.0.2"
} }
}, },
"jotai": {
"version": "2.10.0",
"resolved": "https://registry.npmmirror.com/jotai/-/jotai-2.10.0.tgz",
"integrity": "sha512-8W4u0aRlOIwGlLQ0sqfl/c6+eExl5D8lZgAUolirZLktyaj4WnxO/8a0HEPmtriQAB6X5LMhXzZVmw02X0P0qQ==",
"requires": {}
},
"js-tokens": { "js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmmirror.com/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -5571,6 +5663,11 @@
"resolved": "https://registry.npmmirror.com/keyborg/-/keyborg-2.6.0.tgz", "resolved": "https://registry.npmmirror.com/keyborg/-/keyborg-2.6.0.tgz",
"integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA==" "integrity": "sha512-o5kvLbuTF+o326CMVYpjlaykxqYP9DphFQZ2ZpgrvBouyvOxyEB7oqe8nOLFpiV5VCtz0D3pt8gXQYWpLpBnmA=="
}, },
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"loose-envify": { "loose-envify": {
"version": "1.4.0", "version": "1.4.0",
"resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz", "resolved": "https://registry.npmmirror.com/loose-envify/-/loose-envify-1.4.0.tgz",
@@ -5831,6 +5928,12 @@
"picocolors": "^1.0.1" "picocolors": "^1.0.1"
} }
}, },
"use-debounce": {
"version": "10.0.3",
"resolved": "https://registry.npmmirror.com/use-debounce/-/use-debounce-10.0.3.tgz",
"integrity": "sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==",
"requires": {}
},
"use-disposable": { "use-disposable": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/use-disposable/-/use-disposable-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/use-disposable/-/use-disposable-1.0.2.tgz",
@@ -5861,6 +5964,12 @@
"resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmmirror.com/yallist/-/yallist-3.1.1.tgz",
"integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==",
"dev": true "dev": true
},
"zustand": {
"version": "5.0.0-rc.2",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.0-rc.2.tgz",
"integrity": "sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==",
"requires": {}
} }
} }
} }
+6 -1
View File
@@ -11,11 +11,16 @@
"dependencies": { "dependencies": {
"@fluentui/react-components": "^9.54.16", "@fluentui/react-components": "^9.54.16",
"@fluentui/react-icons": "^2.0.258", "@fluentui/react-icons": "^2.0.258",
"jotai": "^2.10.0",
"lodash": "^4.17.21",
"react": "^18.2.0", "react": "^18.2.0",
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-router-dom": "^6.26.2" "react-router-dom": "^6.26.2",
"use-debounce": "^10.0.3",
"zustand": "^5.0.0-rc.2"
}, },
"devDependencies": { "devDependencies": {
"@types/lodash": "^4.17.10",
"@types/react": "^18.0.17", "@types/react": "^18.0.17",
"@types/react-dom": "^18.0.6", "@types/react-dom": "^18.0.6",
"@vitejs/plugin-react": "^2.0.1", "@vitejs/plugin-react": "^2.0.1",
+1 -1
View File
@@ -1 +1 @@
b35fc08c84ef0c2b0c3e1bf37916ac94 674272f31b23a798d77a17321c6a8785
+1 -1
View File
@@ -19,7 +19,7 @@ function isResp<T>(obj: any): obj is Resp<T> {
); );
} }
export async function Dial<T>(path: string, req: any = null): Promise<Resp<T>> { export async function Dial<T=any>(path: string, req: any = null): Promise<Resp<T>> {
const bs = JSON.stringify(req) const bs = JSON.stringify(req)
console.log(`[DEBUG] invoke req: path = ${path}, req =`, req) console.log(`[DEBUG] invoke req: path = ${path}, req =`, req)
@@ -0,0 +1,76 @@
import {makeStyles, MenuItem, MenuList, Text, tokens} from "@fluentui/react-components";
import {ArchiveRegular} from "@fluentui/react-icons";
import {VirtualizerScrollView} from "@fluentui/react-components/unstable";
import React from "react";
import {useStoreBucket} from "../../store/bucket";
import {Bucket} from "../../interfaces/connection";
import {useStoreFile, useStoreFileFilter} from "../../store/file";
import {useStoreConnection} from "../../store/connection";
const useStyles = makeStyles({
container: {
marginTop: '0.5rem',
maxWidth: 'calc(100vw - 25rem - 1px)',
},
row: {
height: '32px',
display: 'flex',
marginLeft: '0.5rem',
marginRight: '0.5rem',
},
item: {
width: '100%',
maxWidth: '100%',
"&:hover": {
color: tokens.colorNeutralForeground2BrandPressed,
}
},
text: {
overflow: 'hidden',
width: 'calc(100vw - 32rem)',
display: "block",
}
})
export function ListBucketComponent() {
const styles = useStyles();
const {bucket_set, bucket_list} = useStoreBucket()
const {filter_set, prefix_set} = useStoreFileFilter()
async function handleClick(item: Bucket) {
await bucket_set(item)
await filter_set('')
await prefix_set('')
}
function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Bucket) {
e.preventDefault()
}
return <MenuList className={styles.container}>
<VirtualizerScrollView
numItems={bucket_list ? bucket_list.length : 0}
itemSize={32}
container={{role: 'list', style: {maxHeight: 'calc(100vh - 9rem)'}}}
>
{(idx) => {
return <div
className={styles.row} key={idx}
onClick={async () => {
await handleClick(bucket_list[idx])
}}
onContextMenu={async (e) => {
handleRightClick(e, bucket_list[idx])
}}>
<MenuItem className={styles.item}
icon={<ArchiveRegular/>}>
<Text truncate wrap={false} className={styles.text}>
{bucket_list[idx].name}
</Text>
</MenuItem>
</div>
}}
</VirtualizerScrollView>
</MenuList>
}
+111
View File
@@ -0,0 +1,111 @@
import {
DialogTrigger,
DialogSurface,
DialogTitle,
DialogBody,
DialogActions,
DialogContent,
Button, Field, Input, makeStyles, tokens, Checkbox,
} from "@fluentui/react-components";
import {useState} from "react";
import {useToast} from "../../message";
import {useStoreConnection} from "../../store/connection";
import {useStoreBucket} from "../../store/bucket";
const useStyle = makeStyles({
container: {
backgroundColor: tokens.colorNeutralBackground1,
display: "flex",
flexDirection: "row",
height: "100%",
width: "100%",
gridColumnStart: 0,
},
content: {},
input: {
margin: '1rem',
},
checks: {
margin: '1rem',
display: "flex",
},
});
export interface BucketCreateProps {
openFn: (open: boolean) => void;
}
export function BucketCreate(props: BucketCreateProps) {
const styles = useStyle();
const {dispatchMessage} = useToast();
const [name, set_name] = useState<string>();
const [public_read, set_public_read] = useState(false);
const [public_read_write, set_public_read_write] = useState(false);
const {conn_active} = useStoreConnection();
const {bucket_create, bucket_get} = useStoreBucket()
async function create() {
if (!name) {
dispatchMessage('桶名不能为空', "warning")
return
}
bucket_create(conn_active!, name, public_read, public_read_write);
bucket_get(conn_active!, true)
props.openFn(false)
}
return <>
<DialogSurface>
<DialogBody>
<DialogTitle>在 {conn_active?.name} 新建桶</DialogTitle>
<DialogContent>
<div className={styles.content}>
<Field className={styles.input} label="桶名">
<Input
value={name}
onChange={(e) => {
set_name(e.target.value)
}}></Input>
</Field>
<div className={styles.checks}>
<Field>
<Checkbox
checked={public_read}
onChange={(e) => {
if (public_read_write) {
return
}
set_public_read(e.target.checked);
}}
label={"公共读"}></Checkbox>
</Field>
<Field>
<Checkbox
checked={public_read_write}
onChange={(e) => {
set_public_read_write(e.target.checked)
if (e.target.checked) {
set_public_read(e.target.checked)
}
}}
label={"公共读/写"}></Checkbox>
</Field>
</div>
</div>
</DialogContent>
<DialogActions className={styles.container}>
<DialogTrigger disableButtonEnhancement>
<Button appearance="secondary">取消</Button>
</DialogTrigger>
<Button onClick={async () => {
await create()
}} appearance="primary">新建</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</>
}
+229
View File
@@ -0,0 +1,229 @@
import {
Button,
Input,
makeStyles,
MenuItem,
MenuList,
mergeClasses,
tokens,
Tooltip
} from "@fluentui/react-components"
import {
DatabaseLinkRegular,
DeleteRegular,
DismissRegular,
PlugConnectedRegular, PlugDisconnectedRegular,
SettingsRegular
} from "@fluentui/react-icons";
import React, {useEffect, useState} from "react";
import {Connection} from "../../interfaces/connection";
import {useToast} from "../../message";
import {Dial} from "../../api";
import {useStoreConnection} from "../../store/connection";
import {useStoreBucket} from "../../store/bucket";
const useStyles = makeStyles({
list: {
display: "flex",
flexDirection: "row",
height: "100%",
},
content: {
height: "100%",
width: "25rem",
display: "flex",
flexDirection: "column",
},
filter: {
height: "4rem",
width: "100%",
display: "flex",
alignItems: "center",
},
filter_input: {
width: "100%",
marginLeft: "0.5rem",
marginRight: "0.5rem",
},
ctx_menu: {
position: "absolute",
zIndex: "1000",
width: "15rem",
backgroundColor: tokens.colorNeutralBackground1,
boxShadow: `${tokens.shadow16}`,
paddingTop: "4px",
paddingBottom: "4px",
},
items: {
height: "100%",
width: "100%",
},
items_one: {
marginLeft: "0.5rem",
marginRight: "0.5rem",
"&:hover": {
color: tokens.colorNeutralForeground2BrandPressed,
},
"&.active": {
color: tokens.colorNeutralForeground2BrandPressed,
fontWeight: "bold",
},
"& > span": {
display: "flex",
},
},
items_disconn: {
marginLeft: "auto",
},
slider: {
height: '100%', width: '1px',
// todo: resize
// cursor: 'ew-resize',
'& > div': {
height: '100%', width: '1px',
backgroundColor: 'lightgray',
},
},
})
export function ConnectionList() {
const styles = useStyles()
const {dispatchMessage} = useToast();
const {conn_get, conn_list, conn_set} = useStoreConnection();
const [conn_filter, set_conn_filter] = useState<string>('');
const {bucket_get, bucket_set} = useStoreBucket()
const [ctx_menu, set_ctx_menu] = useState<{
x: number,
y: number,
display: 'none' | 'block'
}>({x: 0, y: 0, display: 'none'});
const [menu_conn, set_menu_conn] = useState<Connection | null>(null);
useEffect(() => {
document.addEventListener("click", (e) => {
set_ctx_menu({x: 0, y: 0, display: 'none'});
})
setTimeout(() => {
conn_get().then()
}, 1000)
return () => {
document.removeEventListener("click", (e) => {
})
}
}, [])
async function handleSelect(item: Connection) {
conn_list.map((one: Connection) => {
if (item.id === one.id && one.active) {
conn_set(one)
bucket_get(one, false)
bucket_set(null)
}
})
}
async function handleConnect(item: Connection | null) {
if (!item) return;
let res = await Dial('/api/connection/connect', {id: item.id});
if (res.status !== 200) {
dispatchMessage(res.msg, "error")
return
}
await conn_set({...item, active: true})
bucket_get(item, true)
await bucket_set(null)
}
async function handleDisconnect(item: Connection | null) {
if (!item) return;
let res = await Dial('/api/connection/disconnect', {id: item.id})
if (res.status !== 200) {
dispatchMessage(res.msg, "error")
return
}
await conn_set({...item, active: false})
}
async function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Connection) {
e.preventDefault()
set_menu_conn(item)
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({
x: positionX,
y: positionY,
display: 'block',
})
}
return (
<div className={styles.list}>
<div className={styles.content}>
<div className={styles.filter}>
<Input
value={conn_filter}
className={styles.filter_input}
contentAfter={
<Button appearance={'transparent'} onClick={async () => {
set_conn_filter('')
}} size="small" icon={<DismissRegular/>}/>
}
placeholder="搜索连接"
onChange={(e) => set_conn_filter(e.target.value)}
/>
</div>
<div
id={'list-connection-container'}
className={styles.ctx_menu}
style={{left: ctx_menu.x, top: ctx_menu.y, display: ctx_menu.display}}
>
<MenuList>
<MenuItem
onClick={async () => {
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>
</div>
<div className={styles.items}>
<MenuList>
{conn_list.filter(item => item.name.includes(conn_filter)).map(item => {
return <MenuItem
className={item.active ? mergeClasses(styles.items_one, "active") : styles.items_one}
onClick={async () => {
await handleSelect(item)
}}
onDoubleClick={async () => {
await handleConnect(item)
}}
onContextMenu={async (e) => {
await handleRightClick(e, item)
}}
icon={<DatabaseLinkRegular/>}
key={item.id}>
{item.name}
</MenuItem>
})}
</MenuList>
</div>
</div>
<div className={styles.slider}>
<div></div>
</div>
</div>
)
}
+147
View File
@@ -0,0 +1,147 @@
import {
DialogTrigger,
DialogSurface,
DialogTitle,
DialogBody,
DialogActions,
DialogContent,
Button, Spinner, Field, Input, makeStyles, tokens,
} from "@fluentui/react-components";
import {useState} from "react";
import {CheckmarkFilled, DismissRegular} from "@fluentui/react-icons";
import {useToast} from "../../message";
import {Dial} from "../../api";
import {useStoreConnection} from "../../store/connection";
const useActionStyle = makeStyles({
container: {
backgroundColor: tokens.colorNeutralBackground1,
display: "flex",
flexDirection: "row",
height: "100%",
width: "100%",
gridColumnStart: 0,
},
test: {}
});
export interface ConnectionCreateProps {
openFn: (open: boolean) => void;
}
export function ConnectionCreate(props: ConnectionCreateProps) {
const actionStyle = useActionStyle();
const {dispatchMessage} = useToast();
const [testLoading, setTestLoading] = useState<"initial" | "loading" | "success" | "error">("initial");
const {conn_get} = useStoreConnection();
const buttonIcon =
testLoading === "loading" ? (
<Spinner size="tiny"/>
) : testLoading === "success" ? (
<CheckmarkFilled/>
) : testLoading === "error" ? (
<DismissRegular/>
) : null;
const [value, setValue] = useState<{ name: string, endpoint: string, access: string, key: string }>({
name: '',
endpoint: '',
access: '',
key: ''
})
async function test() {
setTestLoading("loading")
let res = await Dial<string>("/api/connection/test", value)
const status = res.status === 200 ? "success" : "error"
setTestLoading(status);
dispatchMessage(res.msg, status)
}
async function create() {
// self
// qUvfW8xpOTc23O96
// eTcuc8BebHPVpZZwIaNmzfwxRxPYGfTj
// 48-dev
// OSIsqPrl0TkAUj3R
// FYF4BBzL2j2ObbVYH0FrvOZqJf1EACRy
let res = await Dial("/api/connection/create", value)
dispatchMessage(res.msg, res.status === 200 ? "success" : "error");
if (res.status === 200) {
dispatchMessage("新建连接成功", "success");
await conn_get()
props.openFn(false)
}
}
return <>
<DialogSurface>
<DialogBody>
<DialogTitle>新建S3连接</DialogTitle>
<DialogContent>
<div className='connection-container'>
<div className='connection-form'>
<div className='connection-form-field'>
<Field
label="name"
>
<Input placeholder='名称 (example: 测试S3-minio)' value={value.name}
onChange={(e) => {
setValue({...value, name: e.target.value});
}}/>
</Field>
</div>
<div className='connection-form-field'>
<Field
label="endpoint"
required
>
<Input placeholder='地址 (example: https://ip_or_server-name:port)'
value={value.endpoint}
required
onChange={(e) => {
setValue({...value, endpoint: e.target.value});
}}/>
</Field>
</div>
<div className='connection-form-field'>
<Field
label="secret access"
required
>
<Input placeholder=''
required
value={value.access} onChange={(e) => {
setValue({...value, access: e.target.value});
}}/>
</Field>
</div>
<div className='connection-form-field'>
<Field
label="secret key"
required
>
<Input placeholder=''
required
value={value.key} onChange={(e) => {
setValue({...value, key: e.target.value});
}}/>
</Field>
</div>
</div>
</div>
</DialogContent>
<DialogActions className={actionStyle.container}>
<Button className={actionStyle.test} appearance='transparent' icon={buttonIcon}
onClick={async () => await test()}>测试连接</Button>
<DialogTrigger disableButtonEnhancement>
<Button appearance="secondary">取消</Button>
</DialogTrigger>
<Button onClick={async () => {
await create()
}} appearance="primary">新建</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</>
}
+35
View File
@@ -0,0 +1,35 @@
import {Path} from "./path";
import {ListBucketComponent} from "../bucket/list_bucket";
import {makeStyles} from "@fluentui/react-components";
import {useStoreBucket} from "../../store/bucket";
import {ListFileComponent} from "./list_file";
import {useState} from "react";
import {PreviewFile} from "../preview/preview";
const useStyles = makeStyles({
content: {
flex: '1',
display: "flex",
flexDirection: 'column',
height: "100%",
width: "100%",
},
})
export function Content() {
const styles = useStyles()
const [preview, set_preview] = useState<{ url: string, content_type: string }>({url: '', content_type: ''})
const {bucket_active} = useStoreBucket()
return <div className={styles.content}>
<Path/>
{
preview.url ? <PreviewFile url={preview.url} content_type={preview.content_type}/> :
(
bucket_active ?
<ListFileComponent set_preview_fn={set_preview}/> : <ListBucketComponent/>
)
}
</div>
}
+292
View File
@@ -0,0 +1,292 @@
import {makeStyles, MenuItem, MenuList, Spinner, Text, tokens} from "@fluentui/react-components";
import {
ArrowDownloadFilled,
DeleteRegular,
DocumentBulletListRegular,
DocumentChevronDoubleRegular,
DocumentCssRegular,
DocumentDatabaseRegular,
DocumentDismissRegular,
DocumentImageRegular,
DocumentJavascriptRegular,
DocumentPdfRegular,
DocumentYmlRegular,
FolderRegular,
PreviewLinkRegular
} from "@fluentui/react-icons";
import {VirtualizerScrollView} from "@fluentui/react-components/unstable";
import React, {useEffect, useState} from "react";
import {useStoreBucket} from "../../store/bucket";
import {S3File} from "../../interfaces/connection";
import {useStoreFile, useStoreFileFilter} from "../../store/file";
import {useStoreConnection} from "../../store/connection";
import {TrimSuffix} from "../../hook/strings";
import {Dial} from "../../api";
import {useToast} from "../../message";
import {CanPreview} from "../../hook/preview";
import {useStorePreview} from "../../store/preview";
const useStyles = makeStyles({
container: {
marginTop: '0.5rem',
maxWidth: 'calc(100vw - 25.2rem)',
width: 'calc(100vw - 25.2rem)',
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: {
height: '32px',
display: 'flex',
marginLeft: '0.5rem',
marginRight: '0.5rem',
},
item: {
width: '100%',
maxWidth: '100%',
"&:hover": {
color: tokens.colorNeutralForeground2BrandPressed,
}
},
text: {
overflow: 'hidden',
width: 'calc(100vw - 32rem)',
display: "block",
},
ctx_menu: {
position: "absolute",
zIndex: "1000",
width: "15rem",
backgroundColor: tokens.colorNeutralBackground1,
boxShadow: `${tokens.shadow16}`,
paddingTop: "4px",
paddingBottom: "4px",
},
})
export interface ListFileComponentProps {
set_preview_fn: React.Dispatch<React.SetStateAction<{url:string, content_type: string}>>
}
export function ListFileComponent(props: ListFileComponentProps) {
const styles = useStyles();
const {dispatchMessage} = useToast();
const {conn_active} = useStoreConnection();
const {bucket_active} = useStoreBucket()
const {file_active, files_get, file_set, files_list} = useStoreFile()
const {prefix, filter, prefix_set} = useStoreFileFilter()
const [preview_content_type, set_preview_content_type] = useState('')
const {preview_get} = useStorePreview()
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) => {
let strs = TrimSuffix(key, "/").split("/")
return strs[strs.length - 1]
}
async function handleClick(item: S3File) {
if (item.type === 1) {
await prefix_set(item.key)
return
}
}
async function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: S3File) {
e.preventDefault()
await file_set(item.key)
set_preview_content_type(CanPreview(item.name))
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',
})
}
async function handleDownload(file: string | null) {
if (!file) return
const res1 = await Dial<{ result: string }>("/runtime/dialog/save", {
default_filename: file,
})
if (res1.status !== 200) {
return
}
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() {
const res = await Dial<{url:string, method: string}>('/api/file/get', {
conn_id: conn_active?.id,
bucket: bucket_active?.name,
key: file_active ?? "",
})
if (res.status !== 200) {
dispatchMessage('预览失败', 'warning')
return
}
props.set_preview_fn({url: res.data.url, content_type: preview_content_type})
}
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={!preview_content_type}
onClick={async () => {
await handlePreview()
}}
icon={<PreviewLinkRegular/>}>预览</MenuItem>
<MenuItem icon={<DeleteRegular/>}>删除</MenuItem>
</MenuList>
</div>
<div className={styles.loading} style={{display: loading ? 'flex' : 'none'}}>
<Spinner appearance="primary" label="加载中..."/>
</div>
<div className={styles.no_data} style={{display: (!loading && !files_list.length) ? 'flex' : 'none'}}>
<div>
<DocumentDismissRegular/>
</div>
<Text size={900}>
没有文件
</Text>
</div>
<div style={{display: (!loading && files_list.length) ? 'block' : 'none'}}>
<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>
</div>
</div>
}
type FileIconProps = {
name: string
}
function FileIcon(props: FileIconProps) {
const strings = props.name.split(".")
const suffix = strings[strings.length - 1]
switch (suffix) {
case "png":
return <DocumentImageRegular/>
case "jpg":
return <DocumentImageRegular/>
case "jpeg":
return <DocumentImageRegular/>
case "gif":
return <DocumentImageRegular/>
case "db":
return <DocumentDatabaseRegular/>
case "sqlite":
return <DocumentDatabaseRegular/>
case "sqlite3":
return <DocumentDatabaseRegular/>
case "pdf":
return <DocumentPdfRegular/>
case "css":
return <DocumentCssRegular/>
case "js":
return <DocumentJavascriptRegular/>
case "yaml":
return <DocumentYmlRegular/>
case "yml":
return <DocumentYmlRegular/>
case "html":
return <DocumentChevronDoubleRegular/>
case "json":
return <DocumentChevronDoubleRegular/>
case "go":
return <DocumentChevronDoubleRegular/>
default:
return <DocumentBulletListRegular/>
}
}
+125
View File
@@ -0,0 +1,125 @@
import {Button, Input, makeStyles, Text, tokens, Tooltip} from "@fluentui/react-components";
import {useStoreBucket} from "../../store/bucket";
import {ArchiveRegular, ArrowCurveUpLeftFilled} from "@fluentui/react-icons";
import {useStoreFile, useStoreFileFilter} from "../../store/file";
import React, {useState} from "react";
import {debounce} from 'lodash'
import {useStoreConnection} from "../../store/connection";
import {ListFileComponent} from "./list_file";
import {ListBucketComponent} from "../bucket/list_bucket";
const useStyles = makeStyles({
container: {
height: '4rem',
width: '100%',
borderBottom: '1px solid lightgray',
display: 'flex',
alignItems: 'center',
},
show: {
marginLeft: '0.5rem',
height: '100%',
display: 'flex',
alignItems: 'center',
},
show_line: {
display: 'flex',
alignItems: 'center',
},
show_text: {
backgroundColor: tokens.colorNeutralBackground1Hover,
padding: '0.5rem 0.5rem',
borderRadius: '0.5rem',
cursor: 'pointer',
display: 'block',
alignItems: 'center',
marginLeft: '0.5rem',
overflow: 'hidden',
maxWidth: '8rem',
verticalAlign: 'middle',
'&:hover': {
textDecoration: 'none',
backgroundColor: tokens.colorNeutralBackground1Pressed,
},
'& > div': {
height: '100%',
display: 'flex',
alignItems: 'center',
},
},
op_up: {},
filter_prefix: {
margin: '0.5rem',
},
})
export function Path() {
const styles = useStyles()
const {conn_active} = useStoreConnection()
const {bucket_active, bucket_get, bucket_set} = useStoreBucket()
const {prefix, filter, prefix_set, filter_set} = useStoreFileFilter()
async function handleClickUp() {
const dirs = prefix.split('/').filter((item => item))
if (dirs.length > 0) {
dirs.pop()
await prefix_set(dirs.join('/'))
return
}
bucket_get(conn_active!, false)
await bucket_set(null)
}
const handleFilterChange = debounce(async (e) => {
await filter_set(e.target.value)
}, 500)
return <div className={styles.container}>
{bucket_active && (
<>
<div className={styles.show}>
<Tooltip content="返回上一级" relationship="label">
<Button className={styles.op_up}
onClick={async () => {
await handleClickUp()
}}
size="small" icon={<ArrowCurveUpLeftFilled/>}/>
</Tooltip>
<Tooltip content={bucket_active.name} relationship={'description'}>
<Text className={styles.show_text}
truncate
wrap={false}
align={'justify'}
style={{maxWidth: '16rem'}}
>
<div>
<ArchiveRegular style={{margin: '0rem 0.5rem 0 0'}}/>
{bucket_active.name}
</div>
</Text>
</Tooltip>
{prefix && (
prefix.split("/").filter(item => item).map((item, idx) => {
return <div className={styles.show_line} key={idx}>
<Text style={{marginLeft: '0.5rem'}}>/</Text>
<Text className={styles.show_text} truncate wrap={false}>{item}</Text>
</div>
})
)}
</div>
<div className={styles.filter_prefix}>
<Input
onChange={(e) => {
handleFilterChange(e)
}}
placeholder={"输入前缀过滤"}
// contentBefore={<Text>/</Text>}
/>
</div>
</>
)}
</div>
}
@@ -0,0 +1,124 @@
import {
DialogTrigger,
DialogSurface,
DialogTitle,
DialogBody,
DialogActions,
DialogContent,
Button, Field, Input, makeStyles, tokens, Tooltip,
} from "@fluentui/react-components";
import {useState} from "react";
import {useToast} from "../../message";
import {Dial} from "../../api";
import {useStoreConnection} from "../../store/connection";
import {useStoreBucket} from "../../store/bucket";
import {useStoreFile, useStoreFileFilter} from "../../store/file";
import {MoreHorizontalRegular} from "@fluentui/react-icons";
const useStyle = makeStyles({
container: {
backgroundColor: tokens.colorNeutralBackground1,
display: "flex",
flexDirection: "row",
height: "100%",
width: "100%",
gridColumnStart: 0,
},
input: {
cursor: "pointer",
display: 'flex',
},
select: {
minWidth: 'unset',
},
});
export interface UploadFilesProps {
openFn: (open: boolean) => void;
}
export function UploadFiles(props: UploadFilesProps) {
const styles = useStyle();
const {dispatchMessage} = useToast();
const { conn_active} = useStoreConnection();
const {bucket_active} = useStoreBucket();
const {files_get} = useStoreFile()
const {prefix } = useStoreFileFilter()
const [selected, set_selected] = useState<string[]>([]);
async function handleSelect() {
const res = await Dial<{ result: string[] }>('/runtime/dialog/open', {
title: '选择文件',
type: 'multi'
})
if (res.status !== 200) {
return
}
set_selected(res.data.result)
}
async function create() {
let ok = true
for (const item of selected) {
const res = await Dial('/api/file/upload', {
conn_id: conn_active?.id,
bucket: bucket_active?.name,
location: item,
detect_content_type: true,
})
if (res.status !== 200) {
dispatchMessage(`上传文件: ${item} 失败`, "error")
ok = false
return
}
}
if(ok) {
files_get(conn_active!, bucket_active!, prefix)
dispatchMessage('上传成功!', 'success')
props.openFn(false)
return
}
}
return <>
<DialogSurface>
<DialogBody>
<DialogTitle>上传文件到 {`${bucket_active?.name} / ${prefix}`}</DialogTitle>
<DialogContent>
<Field label="选择文件" required>
<Input
className={styles.input}
value={selected?.join('; ')}
contentAfter={
<Tooltip content={'点击选择文件'} relationship={'description'}>
<Button
className={styles.select}
onClick={async () => {
await handleSelect()
}}
size={'small'}
appearance={'transparent'}>
<MoreHorizontalRegular/>
</Button>
</Tooltip>
}/>
</Field>
</DialogContent>
<DialogActions className={styles.container}>
<DialogTrigger disableButtonEnhancement>
<Button appearance="secondary">取消</Button>
</DialogTrigger>
<Button onClick={async () => {
await create()
}} appearance="primary">上传</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</>
}
+21
View File
@@ -0,0 +1,21 @@
import {makeStyles} from "@fluentui/react-components";
import {ConnectionList} from "../connection/list";
import {Content} from "../file/content";
const useStyles = makeStyles({
body: {
display: "flex",
flexDirection: 'row',
width: "100%",
flex: '1',
},
})
export function Body() {
const styles = useStyles();
return <div className={styles.body}>
<ConnectionList/>
<Content/>
</div>
}
+3
View File
@@ -0,0 +1,3 @@
export function Footer() {
return <div></div>
}
+77
View File
@@ -0,0 +1,77 @@
import {Button, Dialog, DialogTrigger, makeStyles} from "@fluentui/react-components";
import {ConnectionCreate} from "../connection/new";
import {AppsAddInRegular, DocumentArrowUpRegular, PlugConnectedAddRegular} from "@fluentui/react-icons";
import {useState} from "react";
import {useStoreConnection} from "../../store/connection";
import {BucketCreate} from "../bucket/new";
import {useStoreBucket} from "../../store/bucket";
import {UploadFiles} from "../file/upload_files";
const useStyles = makeStyles({
header: {
height: "5rem",
minHeight: '5rem',
width: "100%",
display: 'flex',
alignItems: "center",
borderBottom: "1px solid lightgray",
},
button_new: {
margin: '0.5rem',
},
})
export function Header() {
const styles = useStyles();
const {conn_active} = useStoreConnection()
const {bucket_active} = useStoreBucket()
const [open_create_conn, set_open_create_conn] = useState(false);
const [open_create_bucket, set_open_create_bucket] = useState(false);
const [open_upload, set_open_upload] = useState(false);
return <div className={styles.header}>
<div className={styles.button_new}>
<Dialog
open={open_create_conn}
onOpenChange={(event, data) => set_open_create_conn(data.open)}>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary" icon={<PlugConnectedAddRegular/>}>
新建连接
</Button>
</DialogTrigger>
<ConnectionCreate openFn={set_open_create_conn}/>
</Dialog>
</div>
{conn_active &&
<div className={styles.button_new}>
<Dialog
open={open_create_bucket}
onOpenChange={(event, data) => set_open_create_bucket(data.open)}>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary" icon={<AppsAddInRegular/>}>
新建桶
</Button>
</DialogTrigger>
<BucketCreate openFn={set_open_create_bucket}/>
</Dialog>
</div>
}
{
bucket_active &&
<div className={styles.button_new}>
<Dialog
open={open_upload}
onOpenChange={(event, data) => set_open_upload(data.open)}>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary" icon={<DocumentArrowUpRegular/>}>
上传
</Button>
</DialogTrigger>
<UploadFiles openFn={set_open_upload}/>
</Dialog>
</div>
}
</div>
}
+26
View File
@@ -0,0 +1,26 @@
import {Header} from "./header";
import {Body} from "./body";
import {makeStyles} from "@fluentui/react-components";
import {Footer} from "./footer";
const useStyles = makeStyles({
container: {
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
},
})
export function Home() {
const styles = useStyles()
return (
<div className={styles.container}>
<Header />
<Body />
<Footer/>
</div>
)
}
@@ -0,0 +1,21 @@
import {CardPreview, makeStyles} from "@fluentui/react-components";
const useStyle = makeStyles({
container: {
width: '100%',
height: '100%',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
}
})
export function PreviewFile(props: {url:string, content_type:string}) {
const styles = useStyle()
return <div className={styles.container}>
<CardPreview
>
<img src={props.url} />
</CardPreview>
</div>
}
+13
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 ""
}
}
+10
View File
@@ -0,0 +1,10 @@
export function TrimSuffix(str: string, suffix: string) {
if (str.lastIndexOf(suffix) === str.length - suffix.length) {
return str.substring(0, str.length - suffix.length);
}
return str;
}
export function GetBaseFileName(fullPath: string) {
return fullPath.replace(/.*[\/\\]/, '');
}
+13
View File
@@ -7,3 +7,16 @@ export interface Connection {
endpoint: string; endpoint: string;
active: boolean; active: boolean;
} }
export interface Bucket {
name: string;
created_at: number;
}
export interface S3File {
name: string;
key: string;
last_modified: number;
size: number;
type: 0 | 1;
}
+2 -4
View File
@@ -3,8 +3,7 @@ import {createRoot} from 'react-dom/client'
import './style.css' import './style.css'
import {FluentProvider, webLightTheme} from '@fluentui/react-components'; import {FluentProvider, webLightTheme} from '@fluentui/react-components';
import {createBrowserRouter, RouterProvider} from "react-router-dom"; import {createBrowserRouter, RouterProvider} from "react-router-dom";
import Home from "./page/home/home"; import {Home} from "./component/home/home";
import Connection from "./page/connection/connection";
import {ToastProvider} from "./message"; import {ToastProvider} from "./message";
const container = document.getElementById('root') const container = document.getElementById('root')
@@ -13,11 +12,10 @@ const root = createRoot(container!)
const router = createBrowserRouter([ const router = createBrowserRouter([
{path: '/', element: <Home/>}, {path: '/', element: <Home/>},
{path: '/connection', element: <Connection/>},
]) ])
root.render( root.render(
<FluentProvider theme={webLightTheme}> <FluentProvider theme={webLightTheme} style={{height: '100%'}}>
<ToastProvider> <ToastProvider>
<RouterProvider router={router}/> <RouterProvider router={router}/>
</ToastProvider> </ToastProvider>
@@ -1,28 +0,0 @@
div.connection-container {
display: flex;
height: 100vh;
width: 100%;
justify-content: center;
align-items: center;
}
div.connection-form {
max-width: 700px;
min-width: 500px;
height: 100%;
display: flex;
flex: 1;
flex-direction: column;
justify-content: center;
align-items: center;
}
div.connection-form-field {
width: 100%;
margin-top: 20px;
}
div.connection-form-field-actions {
display: flex;
margin-top: 50px;
}
-124
View File
@@ -1,124 +0,0 @@
import './connection.css'
import {
useId,
Button,
FieldProps,
Spinner
} from "@fluentui/react-components";
import {Field, Input} from "@fluentui/react-components";
import {useNavigate} from "react-router-dom";
import {useState} from "react";
import {Dial} from "../../api";
import {useToast} from "../../message";
import {CheckmarkFilled, DismissRegular} from "@fluentui/react-icons";
const Connection = (props: Partial<FieldProps>) => {
const { dispatchMessage } = useToast();
const navigate = useNavigate();
const [testLoading, setTestLoading] = useState<"initial" | "loading" | "success" | "error">("initial");
const buttonIcon =
testLoading === "loading" ? (
<Spinner size="tiny"/>
) : testLoading === "success" ? (
<CheckmarkFilled/>
) : testLoading === "error" ? (
<DismissRegular/>
) : null;
const [value, setValue] = useState<{ name: string, endpoint: string, access: string, key: string }>({
name: '',
endpoint: '',
access: '',
key: ''
})
function test() {
setTestLoading("loading")
Dial<string>("/api/connection/test", value).then(res => {
let status: "success" | "error" = "error"
if (res.status === 200) {
status = "success"
}
setTestLoading(status);
dispatchMessage(res.msg, status)
})
}
function create() {
Dial<unknown>("/api/connection/create", value).then(res => {
dispatchMessage(res.msg, res.status === 200?"success":"error");
if (res.status === 200) {
navigate("/");
}
})
}
return <div className='connection-container'>
<div className='connection-form'>
<div className='connection-form-field'>
<Field
label="name"
validationState="success"
validationMessage="This is a success message."
{...props}
>
<Input placeholder='名称 (example: 测试S3-minio)' value={value.name} onChange={(e) => {
setValue({...value, name: e.target.value});
}}/>
</Field>
</div>
<div className='connection-form-field'>
<Field
label="endpoint"
validationState="success"
validationMessage="This is a success message."
{...props}
>
<Input placeholder='地址 (example: https://ip_or_server-name:port)' value={value.endpoint}
onChange={(e) => {
setValue({...value, endpoint: e.target.value});
}}/>
</Field>
</div>
<div className='connection-form-field'>
<Field
label="secret access"
validationState="success"
validationMessage="This is a success message."
{...props}
>
<Input placeholder='' value={value.access} onChange={(e) => {
setValue({...value, access: e.target.value});
}}/>
</Field>
</div>
<div className='connection-form-field'>
<Field
label="secret key"
validationState="success"
validationMessage="This is a success message."
{...props}
>
<Input placeholder='' value={value.key} onChange={(e) => {
setValue({...value, key: e.target.value});
}}/>
</Field>
</div>
<div className='connection-form-field connection-form-field-actions'>
<Button appearance='transparent' icon={buttonIcon} onClick={() => test()}>测试连接</Button>
<div style={{marginLeft: 'auto'}}>
<Button style={{marginRight: '20px'}} className='connection-form-field-actions-cancel'
onClick={() => {
navigate("/")
}}>取消</Button>
<Button className='connection-form-field-actions-confirm' appearance='primary'
onClick={() => create()}>新建</Button>
</div>
</div>
</div>
</div>
}
export default Connection;
-83
View File
@@ -1,83 +0,0 @@
div.container {
height: 100vh;
width: 100vw;
display: flex;
flex-direction: column;
}
div.header {
height: 50px;
border-bottom: 1px solid lightgray;
display: flex;
align-items: center;
padding: 0 8px;
}
div.body {
display: flex;
flex: 1;
width: 100%;
}
div.body div.body-connections {
width: 200px;
border-right: 1px solid lightgray;
}
div.body-connections-search {
display: flex;
height: 30px;
}
input.body-connections-search-input {
border: none;
border-bottom: 1px solid lightgray;
width: calc(100% - 4px);
height: 30px;
outline: none;
text-indent: 5px;
}
div.body-connections-search-dismiss {
border: none;
background: none;
outline: none;
border-bottom: 1px solid lightgray;
height: 32px;
width: 32px;
display: flex;
justify-content: center;
align-items: center;
}
div.body-connections-list-item {
height: 36px;
}
div.body-connections-list-item.active {
background: aquamarine;
}
div.body-connections-list-item:first-child {
margin-top: 2px;
}
button.body-connections-list-item-button {
background: none;
border: none;
height: 100%;
width: 100%;
padding: 0 6px;
display: flex;
justify-content: left;
align-items: center;
cursor: pointer;
font-size: 14px;
}
button.body-connections-list-item-button:hover {
background-color: var(--colorBrandBackground);
color: rgba(245, 245, 245, 1);
}
-83
View File
@@ -1,83 +0,0 @@
import {useEffect, useState} from 'react';
import './home.css';
import {
Button,
} from "@fluentui/react-components";
import {
CloudAddFilled, DismissRegular
} from "@fluentui/react-icons";
import {useNavigate} from "react-router-dom";
import {Dial} from "../../api";
import {useToast} from "../../message";
import {Connection} from "../../interfaces/connection";
function Home() {
const {dispatchMessage} = useToast();
const [connectionFilterKeywords, setConnectionFilterKeywords] = useState<string>('');
const navigate = useNavigate();
const [connectionList, setConnectionList] = useState<Connection[]>([]);
useEffect(() => {
Dial<{ list: Connection[] }>("/api/connection/list").then(res => {
dispatchMessage(res.msg, res.status === 200 ? "success" : "error");
if (res.status === 200) {
setConnectionList(res.data.list)
}
})
}, []);
async function handleConnect(item: Connection) {
console.log('[DEBUG] db click item =', item)
for (const c of connectionList) {
if(c.active) {
return
}
}
let res = await Dial("/api/connection/connect", {id: item.id})
if (res.status === 200) {
dispatchMessage("连接成功", "success")
for (const c of connectionList) {
c.active = c.id === item.id;
}
}
}
return (
<div className="container">
<div className="header">
<Button appearance="primary" icon={<CloudAddFilled/>} onClick={() => {
navigate("/connection")
}}>
新建连接
</Button>
</div>
<div className="body">
<div className="body-connections">
<div className="body-connections-search">
<input className="body-connections-search-input" type={"text"} placeholder="搜索连接"
value={connectionFilterKeywords}
onChange={(e) => setConnectionFilterKeywords(e.target.value)}/>
<div className="body-connections-search-dismiss" onClick={() => {
setConnectionFilterKeywords('')
}}>
<DismissRegular/>
</div>
</div>
<div className="body-connections-list">
{connectionList.map(item => {
return <div key={item.id} className={item.active?'body-connections-list-item active':'body-connections-list-item'}>
<button onDoubleClick={() => handleConnect(item)}
className='body-connections-list-item-button'>{item.name}</button>
</div>
})}
</div>
</div>
<div className="body-content"></div>
</div>
<div className="footer"></div>
</div>
)
}
export default Home
+55
View File
@@ -0,0 +1,55 @@
import {create} from 'zustand'
import {Bucket, Connection} from "../interfaces/connection";
import {Dial, Resp} from "../api";
interface StoreBucket {
bucket_active: Bucket | null;
bucket_set: (Bucket: Bucket | null) => Promise<void>;
bucket_list: Bucket[];
bucket_get: (conn: Connection, refresh: boolean) => void;
bucket_create: (conn: Connection, name: string, public_read: boolean, public_read_write: boolean) => void;
}
let bucket_map: { [id: number]: Bucket[] };
export const useStoreBucket = create<StoreBucket>()((set) => ({
bucket_active: null,
bucket_set: async (bucket: Bucket | null) => {
set({bucket_active: bucket});
},
bucket_list: [],
bucket_get: async (conn: Connection, refresh: boolean) => {
let res: Resp<{ list: Bucket[]; }>;
if (refresh) {
res = await Dial<{ list: Bucket[] }>('/api/connection/buckets', {id: conn.id});
if (res.status !== 200) {
return
}
}
set((state) => {
if (refresh) {
bucket_map = {...bucket_map, [conn.id]: res.data.list}
return {bucket_list: res.data.list};
}
return {bucket_list: bucket_map[conn.id]};
})
},
bucket_create: async (conn: Connection, name: string, public_read: boolean) => {
const res = await Dial<{ bucket: string }>('/api/bucket/create', {
conn_id: conn.id,
name: name,
public_read: public_read,
public_read_write: public_read,
})
if (res.status !== 200) {
return
}
set((state) => {
return {bucket_list: [...state.bucket_list, {name: res.data.bucket, created_at: 0}]}
})
}
}))
+38
View File
@@ -0,0 +1,38 @@
import {create} from 'zustand'
import {Connection} from "../interfaces/connection";
import {Dial} from "../api";
interface StoreConnection {
conn_active: Connection | null;
conn_list: Connection[];
conn_get: () => Promise<void>;
conn_set: (connection: Connection) => Promise<void>;
}
export const useStoreConnection = create<StoreConnection>()((set) => ({
conn_active: null,
conn_list: [],
conn_get: async () => {
const res = await Dial<{ list: Connection[] }>('/api/connection/list');
if (res.status !== 200) {
return
}
set({conn_list: res.data.list})
},
conn_set: async (connection: Connection) => {
set((state) => {
return {
conn_active: connection.active? connection: null,
conn_list: state.conn_list.map(item => {
if (item.id === connection.id) {
return connection
}
return item
})
}
})
}
}))
+53
View File
@@ -0,0 +1,53 @@
import {create} from 'zustand'
import {Bucket, Connection, S3File} from "../interfaces/connection";
import {Dial} from "../api";
interface StoreFile {
file_active: string | null,
file_set: (key: string) => Promise<void>,
files_list: S3File[];
files_get: (conn: Connection, bucket: Bucket, prefix?: string, filter?: string) => Promise<void>;
}
export const useStoreFile = create<StoreFile>()((set) => ({
file_active: null,
file_set: async (key: string) => {
set((state) => {
return {file_active: key}
})
},
files_list: [],
files_get: async (conn: Connection, bucket: Bucket, prefix = '', filter = '') => {
const res = await Dial<{ list: S3File[] }>('/api/bucket/files', {
conn_id: conn.id,
bucket: bucket.name,
prefix: prefix + filter,
})
if (res.status !== 200) {
return
}
set((state) => {
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}})
},
}))
+33
View File
@@ -0,0 +1,33 @@
import {create} from 'zustand'
import {Dial} from "../api";
import {Bucket, Connection} from "../interfaces/connection";
interface StorePreview {
preview_url: string;
preview_content_type: string;
preview_get: (conn:Connection,bucket: Bucket,key: string) => Promise<void>;
}
export const useStorePreview = create<StorePreview>()((set) => ({
preview_url: '',
preview_content_type: '',
preview_get: async (conn: Connection, bucket: Bucket,key: string) => {
if (key === '') {
return set(()=>{return {preview_url: ''}})
}
let res = await Dial<{url:string,method:string}>('/api/file/get', {
conn_id: conn.id,
bucket: bucket.name,
key: key
})
if(res.status!=200) {
return set(()=>{return {preview_url: ''}})
}
set(()=>{
return {preview_url:res.data.url}
})
},
}))
+3
View File
@@ -1,3 +1,6 @@
:root {
font-size: 10px;
}
body { body {
margin: 0; margin: 0;
color: white; color: white;
-3
View File
@@ -1,7 +1,4 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
import {context} from '../models';
export function Init(arg1:context.Context):Promise<void>;
export function Invoke(arg1:string,arg2:string):Promise<string>; export function Invoke(arg1:string,arg2:string):Promise<string>;
-4
View File
@@ -2,10 +2,6 @@
// Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL // Cynhyrchwyd y ffeil hon yn awtomatig. PEIDIWCH Â MODIWL
// This file is automatically generated. DO NOT EDIT // This file is automatically generated. DO NOT EDIT
export function Init(arg1) {
return window['go']['controller']['App']['Init'](arg1);
}
export function Invoke(arg1, arg2) { export function Invoke(arg1, arg2) {
return window['go']['controller']['App']['Invoke'](arg1, arg2); return window['go']['controller']['App']['Invoke'](arg1, arg2);
} }
+2 -2
View File
@@ -1,6 +1,6 @@
module github.com/loveuer/nf-disk module github.com/loveuer/nf-disk
go 1.21 go 1.22
toolchain go1.23.0 toolchain go1.23.0
@@ -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
+10
View File
@@ -24,10 +24,20 @@ 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/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)
register("/api/connection/connect", handler.ConnectionConnect) register("/api/connection/connect", handler.ConnectionConnect)
register("/api/connection/disconnect", handler.ConnectionDisconnect)
register("/api/connection/buckets", handler.ConnectionBuckets)
register("/api/bucket/files", handler.BucketFiles)
register("/api/bucket/create", handler.BucketCreate)
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
} }
+16 -10
View File
@@ -9,6 +9,11 @@ import (
"github.com/loveuer/nf-disk/internal/tool" "github.com/loveuer/nf-disk/internal/tool"
"github.com/loveuer/nf-disk/ndh" "github.com/loveuer/nf-disk/ndh"
"github.com/loveuer/nf/nft/log" "github.com/loveuer/nf/nft/log"
"github.com/wailsapp/wails/v2/pkg/runtime"
)
var (
app *App
) )
type App struct { type App struct {
@@ -16,23 +21,24 @@ type App struct {
handlers map[string]ndh.Handler handlers map[string]ndh.Handler
} }
func NewApp() *App { func NewApp(gctx context.Context) *App {
return &App{ app = &App{
handlers: make(map[string]ndh.Handler), handlers: make(map[string]ndh.Handler),
} }
go func() {
<-gctx.Done()
runtime.Quit(app.ctx)
}()
return app
} }
func (a *App) Init(ctx context.Context) { func (a *App) Startup(ctx context.Context) {
log.Info("app init!!!") log.Info("app startup!!!")
a.ctx = ctx a.ctx = ctx
tool.Must(db.Init(ctx, "sqlite::memory", db.OptSqliteByMem(nil))) tool.Must(db.Init(ctx, "sqlite::memory", db.OptSqliteByMem(nil)))
tool.Must(model.Init(db.Default.Session())) tool.Must(model.Init(db.Default.Session()))
tool.Must(manager.Init(ctx)) tool.Must(manager.Init(ctx))
tool.Must(api.Init(ctx)) tool.Must(api.Init(ctx))
} }
func (a *App) Startup(ctx context.Context) {
log.Info("app startup!!!")
}
+73
View File
@@ -0,0 +1,73 @@
package handler
import (
"github.com/loveuer/nf-disk/internal/manager"
"github.com/loveuer/nf-disk/internal/s3"
"github.com/loveuer/nf-disk/ndh"
)
func BucketFiles(c *ndh.Ctx) error {
type Req struct {
ConnId uint64 `json:"conn_id"`
Bucket string `json:"bucket"`
Prefix string `json:"prefix"`
}
var (
err error
req = new(Req)
client *s3.Client
list []*s3.ListFileRes
)
if err = c.ReqParse(req); err != nil {
return c.Send400(err.Error())
}
if req.ConnId == 0 || req.Bucket == "" {
return c.Send400(req, "缺少参数")
}
if _, client, err = manager.Manager.Use(req.ConnId); err != nil {
return c.Send500(err.Error())
}
if list, err = client.ListFile(c.Context(), req.Bucket, req.Prefix); err != nil {
return c.Send500(err.Error())
}
return c.Send200(map[string]any{"list": list})
}
func BucketCreate(c *ndh.Ctx) error {
type Req struct {
ConnId uint64 `json:"conn_id"`
Name string `json:"name"`
PublicRead bool `json:"public_read"`
PublicReadWrite bool `json:"public_read_write"`
}
var (
err error
req = new(Req)
client *s3.Client
)
if err = c.ReqParse(req); err != nil {
return c.Send400(err.Error())
}
if req.Name == "" {
return c.Send400(req, "桶名不能为空")
}
if _, client, err = manager.Manager.Use(req.ConnId); err != nil {
return c.Send500(err.Error())
}
if err = client.CreateBucket(c.Context(), req.Name, req.PublicRead, req.PublicReadWrite); err != nil {
return c.Send500(err.Error())
}
return c.Send200(map[string]any{"bucket": req.Name})
}
+70 -8
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"
"gorm.io/gorm"
) )
func ConnectionTest(c *ndh.Ctx) error { func ConnectionTest(c *ndh.Ctx) error {
@@ -89,11 +88,20 @@ func ConnectionCreate(c *ndh.Ctx) error {
} }
func ConnectionList(c *ndh.Ctx) error { func ConnectionList(c *ndh.Ctx) error {
type Req struct {
Keyword string `json:"keyword"`
}
var ( var (
err error err error
list = make([]*model.Connection, 0) list = make([]*model.Connection, 0)
req = new(Req)
) )
if err = c.ReqParse(req); err != nil {
return c.Send400(nil, "参数错误")
}
if err = db.Default.Session().Model(&model.Connection{}). if err = db.Default.Session().Model(&model.Connection{}).
Find(&list). Find(&list).
Error; err != nil { Error; err != nil {
@@ -123,7 +131,6 @@ func ConnectionConnect(c *ndh.Ctx) error {
var ( var (
err error err error
req = new(Req) req = new(Req)
conn = new(model.Connection)
client *s3.Client client *s3.Client
) )
@@ -131,12 +138,9 @@ func ConnectionConnect(c *ndh.Ctx) error {
return c.Send400(req) return c.Send400(req)
} }
if err = db.Default.Session().Take(conn, req.Id).Error; err != nil { conn := &model.Connection{Id: req.Id}
if errors.Is(err, gorm.ErrRecordNotFound) { if err = conn.Get(db.Default.Session(), c); err != nil {
return c.Send400(err.Error(), "连接不存在") return err
}
return c.Send500(err.Error())
} }
if client, err = s3.New(c.Context(), conn.Endpoint, conn.Access, conn.Key); err != nil { if client, err = s3.New(c.Context(), conn.Endpoint, conn.Access, conn.Key); err != nil {
@@ -149,3 +153,61 @@ func ConnectionConnect(c *ndh.Ctx) error {
return c.Send200(conn, "连接成功") return c.Send200(conn, "连接成功")
} }
func ConnectionDisconnect(c *ndh.Ctx) error {
type Req struct {
Id uint64 `json:"id"`
}
var (
err error
req = new(Req)
)
if err = c.ReqParse(req); err != nil {
return c.Send400(req)
}
conn := &model.Connection{Id: req.Id}
if err = conn.Get(db.Default.Session(), c); err != nil {
return err
}
if err = manager.Manager.UnRegister(conn.Id); err != nil {
return c.Send500(err.Error())
}
return c.Send200(conn)
}
func ConnectionBuckets(c *ndh.Ctx) error {
type Req struct {
Id uint64 `json:"id"`
Keyword string `json:"keyword"`
}
var (
err error
req = new(Req)
client *s3.Client
buckets []*s3.ListBucketRes
)
if err = c.ReqParse(req); err != nil {
return c.Send400(nil, "参数错误")
}
if _, client, err = manager.Manager.Use(req.Id); err != nil {
if errors.Is(err, manager.ErrNotFound) {
return c.Send400(nil, "所选连接未激活")
}
return c.Send500(err.Error())
}
if buckets, err = client.ListBucket(c.Context()); err != nil {
return c.Send500(err.Error())
}
return c.Send200(map[string]any{"list": buckets})
}
+113
View File
@@ -0,0 +1,113 @@
package handler
import (
"context"
"github.com/loveuer/nf-disk/ndh"
"github.com/samber/lo"
"github.com/wailsapp/wails/v2/pkg/runtime"
"os"
"path/filepath"
)
func DialogOpen(ctx context.Context) ndh.Handler {
return func(c *ndh.Ctx) error {
type Req struct {
Title string `json:"title"`
Type string `json:"type"` // "dir", "multi", ""
Filters []string `json:"filters"`
}
var (
err error
req = new(Req)
opt = runtime.OpenDialogOptions{
Title: "请选择文件",
}
result any
)
if err = c.ReqParse(req); err != nil {
return c.Send400(err.Error())
}
if req.Title != "" {
opt.Title = req.Title
}
if len(req.Filters) > 0 {
opt.Filters = lo.Map(req.Filters, func(item string, index int) runtime.FileFilter {
return runtime.FileFilter{Pattern: item}
})
}
switch req.Type {
case "dir":
result, err = runtime.OpenDirectoryDialog(ctx, opt)
case "multi":
result, err = runtime.OpenMultipleFilesDialog(ctx, opt)
default:
result, err = runtime.OpenFileDialog(ctx, opt)
}
if err != nil {
return c.Send500(err.Error())
}
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})
}
}
+185
View File
@@ -0,0 +1,185 @@
package handler
import (
"errors"
"fmt"
"github.com/loveuer/nf-disk/internal/manager"
"github.com/loveuer/nf-disk/internal/s3"
"github.com/loveuer/nf-disk/ndh"
"github.com/loveuer/nf/nft/log"
"io"
"net/http"
"os"
"path/filepath"
)
func FileUpload(c *ndh.Ctx) error {
type Req struct {
ConnId uint64 `json:"conn_id"`
Bucket string `json:"bucket"`
Location string `json:"location"`
Name string `json:"name"`
PublicRead bool `json:"public_read"`
PublicReadWrite bool `json:"public_read_write"`
DetectContentType bool `json:"detect_content_type"`
}
var (
err error
req = new(Req)
client *s3.Client
reader *os.File
info os.FileInfo
)
if err = c.ReqParse(req); err != nil {
return c.Send400(c, err.Error())
}
if req.Location == "" {
return c.Send400(req, "缺少文件位置")
}
if req.Name == "" {
req.Name = filepath.Base(req.Location)
}
if _, client, err = manager.Manager.Use(req.ConnId); err != nil {
return c.Send500(err.Error())
}
if reader, err = os.Open(req.Location); err != nil {
return c.Send400(err.Error(), fmt.Sprintf("文件: %s 打开错误", req.Location))
}
if info, err = reader.Stat(); err != nil {
log.Error("FileUpload: stat file info err = %s", err.Error())
return c.Send500(err.Error())
}
obj := &s3.PutFilesObj{
Key: req.Name,
Reader: reader,
ContentLength: info.Size(),
ContentType: "",
ExpireAt: 0,
PublicRead: req.PublicRead,
PublicReadWrite: req.PublicReadWrite,
}
if req.DetectContentType {
bs := make([]byte, 128)
if _, err = reader.ReadAt(bs, 0); err != nil {
if !errors.Is(err, io.EOF) {
log.Error("FileUpload: read file to detect content_type err = %s", err.Error())
return c.Send500(err.Error())
}
}
obj.ContentType = http.DetectContentType(bs)
}
if err = client.PutFile(c.Context(), req.Bucket, obj); err != nil {
log.Error("FileUpload: client.PutFile err = %s", err.Error())
return c.Send500(err.Error())
}
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)
}
+9 -10
View File
@@ -3,17 +3,16 @@ package model
import ( import (
"github.com/loveuer/nf-disk/internal/opt" "github.com/loveuer/nf-disk/internal/opt"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
func Init(tx *gorm.DB) (err error) { func Init(tx *gorm.DB) (err error) {
if err = tx.AutoMigrate( err = tx.AutoMigrate(
&Connection{}, &Connection{},
); err != nil { )
return err
}
if opt.Debug { if opt.Debug {
if err = tx.Create([]*Connection{ err = tx.Create([]*Connection{
{ {
Name: "dev-minio", Name: "dev-minio",
Endpoint: "http://10.220.10.15:9000", Endpoint: "http://10.220.10.15:9000",
@@ -23,12 +22,12 @@ func Init(tx *gorm.DB) (err error) {
{ {
Name: "test", Name: "test",
Endpoint: "http://10.220.10.14:19000", Endpoint: "http://10.220.10.14:19000",
Access: "", Access: "5VCR05L4BSGNCTCD8DXP",
Key: "", Key: "FPTMYBEiHhWLJ05C3aGXW8bjFXXNmghc8Za3Fo2u",
}, },
}).Error; err != nil { }).Clauses(clause.OnConflict{
return err DoNothing: true,
} }).Error
} }
return return
+24
View File
@@ -0,0 +1,24 @@
package model
import "encoding/json"
type Res struct {
Status uint32 `json:"status"`
Err string `json:"err"`
Msg string `json:"msg"`
Data any `json:"data"`
}
func NewRes(status uint32, err string, msg string, data any) *Res {
return &Res{
Status: status,
Err: err,
Msg: msg,
Data: data,
}
}
func (r *Res) String() string {
bs, _ := json.Marshal(r)
return string(bs)
}
+17 -1
View File
@@ -1,6 +1,10 @@
package model package model
import "gorm.io/gorm" import (
"errors"
"github.com/loveuer/nf-disk/ndh"
"gorm.io/gorm"
)
type Connection struct { type Connection struct {
Id uint64 `json:"id" gorm:"primaryKey;column:id"` Id uint64 `json:"id" gorm:"primaryKey;column:id"`
@@ -18,3 +22,15 @@ type Connection struct {
func (c *Connection) Create(tx *gorm.DB) error { func (c *Connection) Create(tx *gorm.DB) error {
return tx.Create(c).Error return tx.Create(c).Error
} }
func (c *Connection) Get(tx *gorm.DB, ctx *ndh.Ctx) error {
if err := tx.Take(c, c.Id).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return ctx.Send400(err.Error())
}
return ctx.Send500(err.Error())
}
return nil
}
+1 -1
View File
@@ -7,5 +7,5 @@ const (
) )
var ( var (
Debug bool Debug bool = false
) )
+36
View File
@@ -0,0 +1,36 @@
package s3
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
)
func (c *Client) CreateBucket(ctx context.Context, bucket string, publicRead bool, publicReadWrite bool) error {
var (
err error
input = &s3.CreateBucketInput{
Bucket: aws.String(bucket),
ACL: types.BucketCannedACLAuthenticatedRead,
}
output = &s3.CreateBucketOutput{}
)
if publicRead {
input.ACL = types.BucketCannedACLPublicRead
}
if publicReadWrite {
input.ACL = types.BucketCannedACLPublicReadWrite
}
if output, err = c.client.CreateBucket(ctx, input); err != nil {
return err
}
_ = output
return nil
}
+121
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 `json:"bucket"`
Key string `json:"key"`
ContentType string `json:"content_type"`
Expire int64 `json:"expire"`
}
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 `json:"url"`
Method string `json:"method"`
Header http.Header `json:"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 `json:"body"`
}
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
}
+101
View File
@@ -0,0 +1,101 @@
package s3
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"github.com/samber/lo"
"strings"
"time"
)
type ListBucketRes struct {
CreatedAt int64 `json:"created_at"`
Name string `json:"name"`
}
type ListFileType int64
const (
ListFileTypeFile ListFileType = iota
ListFileTypeDir
)
type ListFileRes struct {
Name string `json:"name"`
Key string `json:"key"`
LastModified time.Time `json:"last_modified"`
Size int64 `json:"size"`
Type ListFileType `json:"type"`
}
func (c *Client) ListBucket(ctx context.Context) ([]*ListBucketRes, error) {
var (
err error
input = &s3.ListBucketsInput{
MaxBuckets: aws.Int32(100),
}
output *s3.ListBucketsOutput
)
if output, err = c.client.ListBuckets(ctx, input); err != nil {
return nil, err
}
res := lo.Map(
output.Buckets,
func(item types.Bucket, index int) *ListBucketRes {
return &ListBucketRes{CreatedAt: item.CreationDate.UnixMilli(), Name: *item.Name}
},
)
return res, nil
}
func (c *Client) ListFile(ctx context.Context, bucket string, prefix string) ([]*ListFileRes, error) {
var (
err error
input = &s3.ListObjectsV2Input{
Delimiter: aws.String("/"),
MaxKeys: aws.Int32(1000),
Bucket: aws.String(bucket),
}
output *s3.ListObjectsV2Output
)
if prefix != "" {
input.Prefix = aws.String(prefix)
}
if output, err = c.client.ListObjectsV2(ctx, input); err != nil {
return nil, err
}
folder := lo.FilterMap(
output.CommonPrefixes,
func(item types.CommonPrefix, index int) (*ListFileRes, bool) {
name := strings.TrimPrefix(*item.Prefix, prefix)
return &ListFileRes{
Name: name,
Key: *item.Prefix,
Type: ListFileTypeDir,
}, name != ""
},
)
list := lo.Map(
output.Contents,
func(item types.Object, index int) *ListFileRes {
return &ListFileRes{
Key: strings.Clone(*item.Key),
Name: strings.TrimPrefix(*item.Key, prefix),
LastModified: *item.LastModified,
Size: *item.Size,
Type: ListFileTypeFile,
}
},
)
return append(folder, list...), nil
}
-15
View File
@@ -1,15 +0,0 @@
package s3
import (
"context"
"github.com/loveuer/nf/nft/log"
"testing"
)
func TestNewClient(t *testing.T) {
log.SetLogLevel(log.LogLevelDebug)
_, err := New(context.TODO(), "http://10.220.10.15:9000/", "8ALV3DUZI31YG4BDRJ0Z", "CRqwS1MsiUj27TbRK+3T2n+LpKWd07VvaDKuzU0H")
if err != nil {
t.Fatalf("call s3.New err = %s", err.Error())
}
}
+62
View File
@@ -0,0 +1,62 @@
package s3
import (
"context"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/s3"
"github.com/aws/aws-sdk-go-v2/service/s3/types"
"io"
"time"
)
type PutFilesObj struct {
Key string
Reader io.ReadSeeker
ContentLength int64
ContentType string
ExpireAt int64
PublicRead bool
PublicReadWrite bool
}
func (c *Client) PutFile(ctx context.Context, bucket string, obj *PutFilesObj) error {
var (
err error
input = &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(obj.Key),
Body: obj.Reader,
ACL: types.ObjectCannedACLPrivate,
}
output *s3.PutObjectOutput
)
if obj.ExpireAt > 0 {
t := time.UnixMilli(obj.ExpireAt)
input.Expires = &t
}
if obj.ContentLength > 0 {
input.ContentLength = aws.Int64(obj.ContentLength)
}
if obj.ContentType == "" {
input.ContentType = aws.String(obj.ContentType)
}
if obj.PublicRead {
input.ACL = types.ObjectCannedACLPublicRead
}
if obj.PublicReadWrite {
input.ACL = types.ObjectCannedACLPublicReadWrite
}
if output, err = c.client.PutObject(ctx, input); err != nil {
return err
}
_ = output
return nil
}
+16 -6
View File
@@ -14,11 +14,10 @@ import (
type resolverV2 struct{} type resolverV2 struct{}
func (*resolverV2) ResolveEndpoint(ctx context.Context, params s3.EndpointParameters) ( func (*resolverV2) ResolveEndpoint(ctx context.Context, params s3.EndpointParameters) (smithyendpoints.Endpoint, error) {
smithyendpoints.Endpoint, error,
) {
u, err := url.Parse(*params.Endpoint) u, err := url.Parse(*params.Endpoint)
if err != nil { if err != nil {
log.Warn("resolver v2: parse url = %s, err = %s", params.Endpoint, err.Error())
return smithyendpoints.Endpoint{}, err return smithyendpoints.Endpoint{}, err
} }
return smithyendpoints.Endpoint{ return smithyendpoints.Endpoint{
@@ -38,14 +37,25 @@ func New(ctx context.Context, endpoint string, access string, key string) (*Clie
output *s3.ListBucketsOutput output *s3.ListBucketsOutput
) )
if sdkConfig, err = config.LoadDefaultConfig(ctx); err != nil { customResolver := aws.EndpointResolverWithOptionsFunc(func(service, region string, options ...interface{}) (aws.Endpoint, error) {
return aws.Endpoint{
URL: endpoint,
}, nil
})
if sdkConfig, err = config.LoadDefaultConfig(
ctx,
config.WithEndpointResolverWithOptions(customResolver),
); err != nil {
return nil, err return nil, err
} }
s3Client := s3.NewFromConfig(sdkConfig, func(o *s3.Options) { s3Client := s3.NewFromConfig(sdkConfig, func(o *s3.Options) {
o.BaseEndpoint = aws.String(endpoint) //o.BaseEndpoint = aws.String(endpoint)
o.EndpointResolverV2 = &resolverV2{} //o.EndpointResolverV2 = &resolverV2{}
o.Credentials = aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(access, key, "")) o.Credentials = aws.NewCredentialsCache(credentials.NewStaticCredentialsProvider(access, key, ""))
o.UsePathStyle = true
o.Region = "auto"
}) })
if output, err = s3Client.ListBuckets(tool.Timeout(5), &s3.ListBucketsInput{ if output, err = s3Client.ListBuckets(tool.Timeout(5), &s3.ListBucketsInput{
+37
View File
@@ -0,0 +1,37 @@
package s3
import (
"context"
"github.com/loveuer/nf-disk/internal/tool"
"github.com/loveuer/nf/nft/log"
"testing"
)
func TestNewClient(t *testing.T) {
log.SetLogLevel(log.LogLevelDebug)
_, err := New(context.TODO(), "http://10.220.10.15:9000/", "8ALV3DUZI31YG4BDRJ0Z", "CRqwS1MsiUj27TbRK+3T2n+LpKWd07VvaDKuzU0H")
if err != nil {
t.Fatalf("call s3.New err = %s", err.Error())
}
}
func TestListFile(t *testing.T) {
//log.SetLogLevel(log.LogLevelDebug)
//cli, err := New(context.TODO(), "http://10.220.10.14:19000", "5VCR05L4BSGNCTCD8DXP", "FPTMYBEiHhWLJ05C3aGXW8bjFXXNmghc8Za3Fo2u")
cli, err := New(context.TODO(), "http://10.220.10.15:9000/", "8ALV3DUZI31YG4BDRJ0Z", "CRqwS1MsiUj27TbRK+3T2n+LpKWd07VvaDKuzU0H")
if err != nil {
t.Fatalf("call s3.New err = %s", err.Error())
}
files, err := cli.ListFile(tool.Timeout(30), "topic-audit", "")
if err != nil {
t.Fatalf("call s3.ListFile err = %s", err.Error())
}
t.Logf("[x] file length = %d", len(files))
for _, item := range files {
t.Logf("[x: %d] file = %s, size = %d", item.Type, item.Name, item.Size)
}
}
+32
View File
@@ -0,0 +1,32 @@
package tool
import "iter"
func Bulk[T any](slice []T, size int) iter.Seq2[int, []T] {
if size <= 0 {
panic("bulk size must be positive")
}
s := make([]T, 0, size)
idx := 0
return func(yield func(int, []T) bool) {
for i := range slice {
s = append(s, (slice)[i])
if len(s) >= size {
// send to handle
ok := yield(idx, s)
if !ok {
return
}
idx++
s = make([]T, 0, size)
}
}
if len(s) > 0 {
yield(idx, s)
}
}
}
+1 -6
View File
@@ -18,9 +18,6 @@ import (
//go:embed all:frontend/dist //go:embed all:frontend/dist
var assets embed.FS var assets embed.FS
func init() {
}
func main() { func main() {
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT) ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
defer cancel() defer cancel()
@@ -32,9 +29,7 @@ func main() {
log.SetLogLevel(log.LogLevelDebug) log.SetLogLevel(log.LogLevelDebug)
} }
app := controller.NewApp() app := controller.NewApp(ctx)
app.Init(ctx)
if err := wails.Run(&options.App{ if err := wails.Run(&options.App{
Title: "nf-disk", Title: "nf-disk",
+41
View File
@@ -0,0 +1,41 @@
{
"name": "nf-disk",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"dependencies": {
"zustand": "^5.0.0-rc.2"
}
},
"node_modules/zustand": {
"version": "5.0.0-rc.2",
"resolved": "https://registry.npmmirror.com/zustand/-/zustand-5.0.0-rc.2.tgz",
"integrity": "sha512-o2Nwuvnk8vQBX7CcHL8WfFkZNJdxB/VKeWw0tNglw8p4cypsZ3tRT7rTRTDNeUPFS0qaMBRSKe+fVwL5xpcE3A==",
"license": "MIT",
"engines": {
"node": ">=12.20.0"
},
"peerDependencies": {
"@types/react": ">=18.0.0",
"immer": ">=9.0.6",
"react": ">=18.0.0",
"use-sync-external-store": ">=1.2.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"immer": {
"optional": true
},
"react": {
"optional": true
},
"use-sync-external-store": {
"optional": true
}
}
}
}
}
+5
View File
@@ -0,0 +1,5 @@
{
"dependencies": {
"zustand": "^5.0.0-rc.2"
}
}