Files
admin-frontend-domain/src/components/fm.tsx
T
Chillln bb288c554f Fix and update (#139)
* refactor(ui): 统一组件引用类型为ComponentRef

更新所有UI组件中的forwardRef类型,从ElementRef改为ComponentRef以保持一致性
迁移postcss配置至mjs格式并更新依赖版本

* refactor: 优化表单类型定义和验证逻辑

移除自定义的 asOptionalField 工具函数,直接使用 Zod 的 optional() 方法,并明确定义表单数据类型。

* style: 更新UI主题配置和样式变量

将主题风格从default切换为new-york,并重构CSS变量使用OKLCH色彩空间。同时添加tailwindcss-animate插件支持。

* style: 统一页面头部按钮组样式

优化多个页面头部按钮组的布局样式,增加响应式设计和flex-wrap支持

* fix(server): 修复对话框交互问题并优化SWR配置

修复对话框关闭逻辑并阻止外部交互,同时禁用SWR的自动重新验证功能以提升性能。

* feat: 添加日历组件及账单相关国际化

实现基于 react-day-picker 的日历组件,并添加账单管理相关的多语言支持

* style(components): 统一按钮样式并格式化代码

为删除和禁用按钮添加text-white类名,同时调整ServerCard组件中的代码缩进格式。

* perf(build): 优化Vite打包配置与代码分割策略

调整Vite构建配置,改进第三方依赖的分组逻辑并添加UUID支持到安装命令组件

* fix: 修正页面标题翻译不一致问题

将CronPage和ServicePage的标题从"Server"分别改为"Task"和"Service",并优化NotificationGroupPage的按钮组布局。

* fix(auth): 改进登录错误处理和国际化支持

优化登录错误提示,添加多语言支持并移除控制台错误日志。同时修复头部组件透明度样式问题。

* feat: 添加服务器操作下拉菜单

为服务器卡片添加统一的下拉菜单操作入口,整合终端、配置和安装命令功能。

* feat[alert-rule]: 优化告警规则组件性能

重构告警规则组件代码结构,提升渲染效率并减少内存占用。

* docs(i18n): 新增翻译字段

为界面添加"Add"、"Delete"、"AdvancedJSON"和"Save"等关键操作的翻译字段,支持中英文双语显示。

* perf(vite): 优化分包策略以提升构建性能

重构 manualChunks 逻辑,按功能类别分组依赖项,并增加大型库的独立分包规则。

* style: 统一危险操作按钮的文字颜色

在所有确认操作的弹窗按钮中添加白色文字样式,保持视觉一致性。

* fix(components): 调整下拉菜单对齐方式

根据菜单项状态动态设置下拉菜单的对齐方向和起始位置。

* fix(types): 修复在线用户API分页类型

添加ModelOnlineUserApi接口类型,包含分页信息,并移除index.ts中重复的类型定义。

* chore: auto-fix linting and formatting issues

* feat(locales): 添加无过期相关翻译项

为英文和中文翻译文件添加"NoExpiry"、"SetNoExpiry"等无过期相关字段的翻译。

fix(components): 移除重复的图标按钮选项

从IconButton组件中删除重复的"more"图标选项。

* feat(ServerCard): 优化日期选择器并添加下拉提示

为日期选择器添加下拉布局和年份范围限制,并在公共笔记区域增加下拉项生效提示文本。

* chore: auto-fix linting and formatting issues

* style: 优化多个组件的UI交互细节

统一按钮悬停样式并简化国际化文本调用,移除冗余的单位显示和空值判断逻辑。

* refactor(ServerCard): 移除网络路由相关代码

删除 ServerCard 组件中与 plan.networkRoute 相关的字段验证和错误显示逻辑。

* chore: auto-fix linting and formatting issues

* feat(ui): 添加Switch组件并改进服务器表单交互

- 新增Radix UI Switch组件依赖及实现
- 将IPv4/IPv6输入改为开关控件,优化用户体验
- 添加"按量付费"选项和新的翻译字段
- 改进网络路由和备注输入的占位提示
- 修复暗黑模式下的按钮背景色

* style(components): 为禁止按钮添加白色文本样式

* chore: auto-fix linting and formatting issues

* fix(ServerCard): 修复日期选择器样式和滚动问题

调整日期选择器的宽度和高度限制,添加滚动容器以解决内容溢出问题

* refactor(server-config): 简化复选框checked属性的布尔转换

使用!!操作符简化controllerField.value的布尔值转换,使代码更简洁

* feat(国际化): 添加告警规则和搜索框的国际化支持

为告警规则组件添加多语言支持,包括服务器监控选项、忽略提示和示例文本。同时将搜索框的占位文本替换为国际化字段。

* chore: auto-fix linting and formatting issues

* fix(switch): 修正 Switch 组件 ref 类型定义错误

---------

Co-authored-by: Guccen <171530509+Chillln@users.noreply.github.com>
2025-10-02 14:23:11 +08:00

496 lines
17 KiB
TypeScript

import { createFM } from "@/api/fm"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog"
import {
Drawer,
DrawerContent,
DrawerHeader,
DrawerTitle,
DrawerTrigger,
} from "@/components/ui/drawer"
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu"
import { Input } from "@/components/ui/input"
import { useMediaQuery } from "@/hooks/useMediaQuery"
import { copyToClipboard, fm, formatPath, fmWorker as worker } from "@/lib/utils"
import {
FMEntry,
FMIdentifier,
FMOpcode,
FMWorkerData,
FMWorkerOpcode,
ModelCreateFMResponse,
} from "@/types"
import { ColumnDef } from "@tanstack/react-table"
import { Row, flexRender } from "@tanstack/react-table"
import { File, Folder } from "lucide-react"
import { HTMLAttributes, JSX, useEffect, useRef, useState } from "react"
import { useTranslation } from "react-i18next"
import { toast } from "sonner"
import { Button } from "./ui/button"
import { TableCell, TableRow } from "./ui/table"
import { Filepath } from "./xui/filepath"
import { IconButton } from "./xui/icon-button"
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
SheetTrigger,
} from "./xui/overlayless-sheet"
import { DataTable } from "./xui/virtulized-data-table"
interface FMProps {
wsUrl: string
}
const arraysEqual = (a: Uint8Array, b: Uint8Array) => {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) {
if (a[i] !== b[i]) return false
}
return true
}
const FMComponent: React.FC<FMProps & JSX.IntrinsicElements["div"]> = ({ wsUrl, ...props }) => {
const { t } = useTranslation()
const fmRef = useRef<HTMLDivElement>(null)
const wsRef = useRef<WebSocket | null>(null)
useEffect(() => {
return () => {
wsRef.current?.close()
}
}, [])
const [dOpen, setdOpen] = useState(false)
const [uOpen, setuOpen] = useState(false)
const columns: ColumnDef<FMEntry>[] = [
{
id: "type",
header: () => <span>{t("Type")}</span>,
accessorFn: (row) => row.type,
cell: ({ row }) => (row.original.type == 0 ? <File size={24} /> : <Folder size={24} />),
},
{
header: () => <span>{t("Name")}</span>,
id: "name",
accessorFn: (row) => row.name,
cell: ({ row }) => (
<div className="max-w-48 text-sm whitespace-normal break-words">
{row.original.name}
</div>
),
size: 5000,
},
{
header: () => <span>{t("Actions")}</span>,
id: "download",
cell: ({ row }) => {
return row.original.type == 0 ? (
<IconButton
variant="ghost"
icon="download"
onClick={() => {
if (!dOpen) setdOpen(true)
downloadFile(row.original.name)
}}
/>
) : (
<Button size="icon" variant="ghost" className="pointer-events-none" />
)
},
},
]
const tableRowComponent = (rows: Row<FMEntry>[]) =>
function getTableRow(props: HTMLAttributes<HTMLTableRowElement>) {
// @ts-expect-error data-index is a valid attribute
const index = props["data-index"]
const row = rows[index]
if (!row) return null
return (
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
onClick={() => {
if (row.original.type === 1) {
setPath(`${currentPath}/${row.original.name}`)
}
}}
className={row.original.type === 1 ? "cursor-pointer" : "cursor-default"}
{...props}
>
{row.getVisibleCells().map((cell) => (
<TableCell key={cell.id}>
{flexRender(cell.column.columnDef.cell, cell.getContext())}
</TableCell>
))}
</TableRow>
)
}
const [fmEntires, setFMEntries] = useState<FMEntry[]>([])
const firstChunk = useRef(true)
const handleReady = useRef(false)
const currentBasename = useRef("temp")
const waitForHandleReady = async () => {
while (!handleReady.current) {
await new Promise((resolve) => setTimeout(resolve, 10))
}
}
useEffect(() => {
const url = new URL(wsUrl, window.location.origin)
url.protocol = url.protocol.replace("http", "ws")
const ws = new WebSocket(url)
wsRef.current = ws
ws.binaryType = "arraybuffer"
ws.onopen = () => {
listFile()
}
ws.onclose = (e) => {
console.log("WebSocket connection closed:", e)
}
ws.onerror = (e) => {
console.error(e)
toast("Websocket" + " " + t("Error"), {
description: t("Results.UnExpectedError"),
})
}
ws.onmessage = async (e: MessageEvent<ArrayBufferLike>) => {
try {
const identifier = new Uint8Array(e.data, 0, 4)
if (arraysEqual(identifier, FMIdentifier.error)) {
const errBytes = e.data.slice(4)
const errMsg = new TextDecoder("utf-8").decode(errBytes)
throw new Error(errMsg)
}
if (firstChunk.current) {
if (arraysEqual(identifier, FMIdentifier.file)) {
worker.postMessage({
operation: 1,
arrayBuffer: e.data,
fileName: currentBasename.current,
})
firstChunk.current = false
} else if (arraysEqual(identifier, FMIdentifier.fileName)) {
const { path, fmList } = await fm.parseFMList(e.data)
setPath(path)
setFMEntries(fmList)
} else if (arraysEqual(identifier, FMIdentifier.complete)) {
// Upload completed
setuOpen(false)
listFile()
} else {
throw new Error(t("Results.UnknownIdentifier"))
}
} else {
await waitForHandleReady()
worker.postMessage({
operation: 2,
arrayBuffer: e.data,
fileName: currentBasename.current,
})
}
} catch (error) {
console.error("Error processing received data:", error)
toast("FM" + " " + t("Error"), {
description: t("Results.UnExpectedError"),
})
setdOpen(false)
setuOpen(false)
}
}
}, [wsUrl])
useEffect(() => {
worker.onmessage = async (event: MessageEvent<FMWorkerData>) => {
switch (event.data.type) {
case FMWorkerOpcode.Error: {
console.error("Error from worker", event.data.error)
break
}
case FMWorkerOpcode.Progress: {
handleReady.current = true
break
}
case FMWorkerOpcode.Result: {
handleReady.current = false
if (event.data.blob && event.data.fileName) {
const url = URL.createObjectURL(event.data.blob)
const anchor = document.createElement("a")
anchor.href = url
anchor.download = event.data.fileName
anchor.click()
URL.revokeObjectURL(url)
}
firstChunk.current = true
if (dOpen) setdOpen(false)
break
}
}
}
const handleBeforeUnload = () => {
worker.postMessage({
operation: 3,
})
}
window.addEventListener("beforeunload", handleBeforeUnload)
return () => {
window.removeEventListener("beforeunload", handleBeforeUnload)
}
}, [worker, dOpen])
const [currentPath, setPath] = useState("")
useEffect(() => {
if (wsRef.current && wsRef.current.readyState === WebSocket.OPEN) {
listFile()
}
}, [wsRef.current, currentPath])
const listFile = () => {
const prefix = new Int8Array([FMOpcode.List])
const pathMsg = new TextEncoder().encode(currentPath)
const msg = new Int8Array(prefix.length + pathMsg.length)
msg.set(prefix)
msg.set(pathMsg, prefix.length)
wsRef.current?.send(msg)
}
const downloadFile = (basename: string) => {
currentBasename.current = basename
const prefix = new Int8Array([FMOpcode.Download])
const filePathMessage = new TextEncoder().encode(`${currentPath}/${basename}`)
const msg = new Int8Array(prefix.length + filePathMessage.length)
msg.set(prefix)
msg.set(filePathMessage, prefix.length)
wsRef.current?.send(msg)
}
const uploadFile = async (file: File) => {
const chunkSize = 1048576 // 1MB chunk
let offset = 0
// Send header
const header = fm.buildUploadHeader({ path: currentPath, file: file })
wsRef.current?.send(header)
// Send data chunks
while (offset < file.size) {
const chunk = file.slice(offset, offset + chunkSize)
const arrayBuffer = await fm.readFileAsArrayBuffer(chunk)
if (arrayBuffer) wsRef.current?.send(arrayBuffer)
offset += chunkSize
}
}
const fileInputRef = useRef<HTMLInputElement>(null)
const [gotoPath, setGotoPath] = useState("")
return (
<div ref={fmRef} {...props}>
<div className="flex justify-center items-center gap-4">
<AlertDialog>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<IconButton variant="ghost" icon="menu" />
</DropdownMenuTrigger>
<DropdownMenuContent>
<DropdownMenuItem onClick={listFile}>{t("Refresh")}</DropdownMenuItem>
<DropdownMenuItem
onClick={async () => {
try {
await copyToClipboard(formatPath(currentPath))
} catch (error: any) {
toast("FM" + " " + t("Error"), {
description: error.message,
})
console.error("copy error: ", error)
}
}}
>
{t("CopyPath")}
</DropdownMenuItem>
<AlertDialogTrigger asChild>
<DropdownMenuItem>{t("Goto")}</DropdownMenuItem>
</AlertDialogTrigger>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("Goto")}</AlertDialogTitle>
<AlertDialogDescription />
</AlertDialogHeader>
<Input
className="mb-1"
placeholder="Path"
value={gotoPath}
onChange={(e) => {
setGotoPath(e.target.value)
}}
/>
<AlertDialogFooter>
<AlertDialogCancel>{t("Close")}</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
setPath(gotoPath)
}}
>
{t("Confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<h1 className="text-base">{t("FileManager")}</h1>
<div className="ml-auto">
<input
ref={fileInputRef}
type="file"
className="hidden"
onChange={async (e) => {
const files = e.target.files
if (files && files.length > 0) {
if (!uOpen) setuOpen(true)
await uploadFile(files[0])
}
}}
/>
<IconButton
icon="upload"
variant="ghost"
onClick={() => {
if (fileInputRef.current) fileInputRef.current.click()
}}
/>
</div>
</div>
<Filepath path={currentPath} setPath={setPath} />
<AlertDialog open={dOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("Downloading")}...</AlertDialogTitle>
<AlertDialogDescription />
</AlertDialogHeader>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={uOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("Uploading")}...</AlertDialogTitle>
<AlertDialogDescription />
</AlertDialogHeader>
</AlertDialogContent>
</AlertDialog>
<DataTable columns={columns} data={fmEntires} rowComponent={tableRowComponent} />
</div>
)
}
export const FMCard = ({ id }: { id?: string }) => {
const { t } = useTranslation()
const [open, setOpen] = useState(false)
const [fm, setFM] = useState<ModelCreateFMResponse | null>(null)
const [init, setInit] = useState(false)
const isDesktop = useMediaQuery("(min-width: 640px)")
const fetchFM = async () => {
if (id) {
try {
setInit(false)
const createdFM = await createFM(id)
setFM(createdFM)
} catch (e) {
toast(t("Error"), {
description: t("Results.UnExpectedError"),
})
console.error("fetch error", e)
return
}
setInit(true)
}
}
return isDesktop ? (
<Sheet
modal={false}
open={open}
onOpenChange={(isOpen) => {
if (isOpen) setOpen(true)
}}
>
<SheetTrigger asChild>
<IconButton icon="folder-closed" onClick={fetchFM} />
</SheetTrigger>
<SheetContent setOpen={setOpen} className="min-w-[35%]">
<div className="overflow-auto">
<SheetTitle />
<SheetHeader className="pb-2">
<SheetDescription />
</SheetHeader>
{fm?.session_id && init ? (
<FMComponent
className="p-1 space-y-5"
wsUrl={`/api/v1/ws/file/${fm.session_id}`}
/>
) : (
<p>{t("Results.TheServerDoesNotOnline")}</p>
)}
</div>
</SheetContent>
</Sheet>
) : (
<Drawer>
<DrawerTrigger asChild>
<IconButton icon="folder-closed" onClick={fetchFM} />
</DrawerTrigger>
<DrawerContent className="min-h-[60%] p-4">
<div className="overflow-auto">
<DrawerTitle />
<DrawerHeader className="pb-2">
<SheetDescription />
</DrawerHeader>
{fm?.session_id && init ? (
<FMComponent
className="p-1 space-y-5"
wsUrl={`/api/v1/ws/file/${fm.session_id}`}
/>
) : (
<p>{t("Results.TheServerDoesNotOnline")}</p>
)}
</div>
</DrawerContent>
</Drawer>
)
}