🎉 开始项目

feat: 完成基础界面; 列表展示
todo: uplevel button function
todo: download/upload
This commit is contained in:
loveuer
2024-10-11 22:24:14 +08:00
commit 1c818daf16
76 changed files with 12517 additions and 0 deletions

View File

@@ -0,0 +1,174 @@
import {
Button,
Input,
makeStyles,
Menu,
MenuItem,
MenuList, MenuPopover, MenuProps,
mergeClasses, PositioningImperativeRef,
tokens,
Tooltip
} from "@fluentui/react-components"
import {DatabaseLinkRegular, DismissRegular} from "@fluentui/react-icons";
import React, {useState} from "react";
import {Bucket, 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",
},
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_list, conn_update} = useStoreConnection();
const [conn_filter, set_conn_filter] = useState<string>('');
const {bucket_get, bucket_set} = useStoreBucket()
async function handleSelect(item: Connection) {
conn_list.map((one: Connection) => {
if (item.id === one.id && one.active) {
conn_update(one)
bucket_get(one, false)
bucket_set(null)
}
})
}
async function handleConnect(item: Connection) {
let res = await Dial('/api/connection/connect', {id: item.id});
if (res.status !== 200) {
dispatchMessage(res.msg, "error")
return
}
conn_update({...item, active: true})
bucket_get(item, true)
bucket_set(null)
}
async function handleDisconnect(item: Connection) {
let res = await Dial('/api/connection/disconnect', {id: item.id})
if (res.status !== 200) {
dispatchMessage(res.msg, "error")
return
}
conn_update({...item, active: false})
}
async function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Connection) {
e.preventDefault()
console.log('[DEBUG] right click connection =', item, 'event =', e)
console.log(`[DEBUG] click position: [${e.pageX}, ${e.pageY}]`)
}
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 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}
<Tooltip
content="断开连接"
relationship="label">
<Button
appearance={'transparent'}
size="small"
icon={<DismissRegular/>}
className={styles.items_disconn}
onClick={async () => {
await handleDisconnect(item)
}}/>
</Tooltip>
</MenuItem>
})}
</MenuList>
</div>
</div>
<div className={styles.slider}>
<div></div>
</div>
</div>
)
}

View File

@@ -0,0 +1,147 @@
import {
DialogTrigger,
DialogSurface,
DialogTitle,
DialogBody,
DialogActions,
DialogContent,
Button, Spinner, Field, Input, FieldProps, 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");
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"
validationState="success"
validationMessage="This is a success message."
>
<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."
>
<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."
>
<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."
>
<Input placeholder='' 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>
</>
}

View File

@@ -0,0 +1,32 @@
import {Path} from "./path";
import {ListBucketComponent} from "./list_bucket";
import {makeStyles} from "@fluentui/react-components";
import {useStoreBucket} from "../../store/bucket";
import {useStoreFile} from "../../store/file";
import {ListFileComponent} from "./list_file";
const useStyles = makeStyles({
content: {
flex: '1',
display: "flex",
flexDirection: 'column',
height: "100%",
width: "100%",
},
})
export function Content() {
const styles = useStyles()
const {bucket_active, bucket_list} = useStoreBucket()
const {file_list} = useStoreFile()
return <div className={styles.content}>
<Path/>
{
bucket_active ?
<ListFileComponent/> :
<ListBucketComponent/>
}
</div>
}

View File

@@ -0,0 +1,76 @@
import {makeStyles, MenuItem, MenuList, Text, tokens} from "@fluentui/react-components";
import {ArchiveRegular, DocumentBulletListRegular} 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} 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 {conn_active} = useStoreConnection()
const {bucket_set, bucket_list} = useStoreBucket()
const {files_get} = useStoreFile()
async function handleClick(item: Bucket) {
bucket_set(item)
files_get(conn_active!, item, "")
}
function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: Bucket) {
e.preventDefault()
}
return <MenuList className={styles.container}>
<VirtualizerScrollView
numItems={bucket_list.length}
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>
}

View File

@@ -0,0 +1,105 @@
import {makeStyles, MenuItem, MenuList, Text, tokens} from "@fluentui/react-components";
import {ArchiveRegular, DocumentBulletListRegular, DocumentDismissRegular, FolderRegular} from "@fluentui/react-icons";
import {VirtualizerScrollView} from "@fluentui/react-components/unstable";
import React, {useEffect} from "react";
import {useStoreBucket} from "../../store/bucket";
import {Bucket, S3File} from "../../interfaces/connection";
import {useStoreFile} from "../../store/file";
import {useStoreConnection} from "../../store/connection";
import {TrimSuffix} from "../../hook/strings";
const useStyles = makeStyles({
container: {
marginTop: '0.5rem',
maxWidth: 'calc(100vw - 25rem - 1px)',
width: 'calc(100vw - 25rem - 1px)',
height: 'calc(100vh - 9rem)',
},
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",
},
no_data: {
flex: "1",
height: '100%',
width: '100%',
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
fontSize: '8rem',
flexDirection: 'column',
},
})
export function ListFileComponent() {
const styles = useStyles();
const {conn_active} = useStoreConnection();
const {bucket_active} = useStoreBucket()
const {files_get, files_list} = useStoreFile()
const filename = (key: string) => {
let strs = TrimSuffix(key, "/").split("/")
return strs[strs.length - 1]
}
async function handleClick(item: S3File) {
if (item.type === 1) {
files_get(conn_active!, bucket_active!, item.key)
return
}
}
function handleRightClick(e: React.MouseEvent<HTMLDivElement>, item: S3File) {
e.preventDefault()
}
return <MenuList className={styles.container}>
{files_list.length ?
<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) => {
handleRightClick(e, files_list[idx])
}}>
<MenuItem className={styles.item}
icon={files_list[idx].type ? <FolderRegular/> : <DocumentBulletListRegular/>}>
<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>
}
</MenuList>
}

View File

@@ -0,0 +1,114 @@
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} from "../../store/file";
import React from "react";
import {debounce} from 'lodash'
import {useStoreConnection} from "../../store/connection";
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} = useStoreBucket()
const {prefix, files_get} = useStoreFile()
async function handleClickUp() {
}
const handleFilterChange = debounce((e) => {
files_get(conn_active!, bucket_active!, prefix, 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>
}

View File

@@ -0,0 +1,34 @@
import {Button, Input, makeStyles, MenuItem, MenuList, mergeClasses, tokens, Tooltip} 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 {Content} from "../file/content";
const useStyles = makeStyles({
body: {
display: "flex",
flexDirection: 'row',
width: "100%",
flex: '1',
},
})
export function Body() {
const styles = useStyles();
const {conn_get} = useStoreConnection();
useEffect(() => {
conn_get()
}, []);
return <div className={styles.body}>
<ConnectionList/>
<Content />
</div>
}

View File

@@ -0,0 +1,3 @@
export function Footer() {
return <div></div>
}

View File

@@ -0,0 +1,37 @@
import {Button, Dialog, DialogTrigger, makeStyles} from "@fluentui/react-components";
import {ConnectionCreate} from "../connection/new";
import {CloudAddFilled} from "@fluentui/react-icons";
import {useState} from "react";
const useStyles = makeStyles({
header: {
height: "5rem",
width: "100%",
display: 'flex',
alignItems: "center",
borderBottom: "1px solid lightgray",
},
button_new_connection: {
margin: '0.5rem',
},
})
export function Header() {
const styles = useStyles();
const [openCreate, setOpenCreate] = useState(false);
return <div className={styles.header}>
<div className={styles.button_new_connection}>
<Dialog
open={openCreate}
onOpenChange={(event, data) => setOpenCreate(data.open)}>
<DialogTrigger disableButtonEnhancement>
<Button appearance="primary" icon={<CloudAddFilled/>}>
</Button>
</DialogTrigger>
<ConnectionCreate openFn={setOpenCreate}/>
</Dialog>
</div>
</div>
}

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>
)
}