feat(ui): optimize table UI

This commit is contained in:
Fu Diwei 2024-12-08 21:10:22 +08:00
parent 5c6be439e8
commit 7db933199a
14 changed files with 199 additions and 144 deletions

View File

@ -66,16 +66,12 @@ const CertificateList = ({ withPagination }: CertificateListProps) => {
return (
<div className="">
{leftDays > 0 ? (
<div className="text-green-500">
{leftDays} / {allDays} {t("certificate.props.expiry.days")}
</div>
<div className="text-green-500">{t("certificate.props.expiry.left_days", { left: leftDays, total: allDays })}</div>
) : (
<div className="text-red-500">{t("certificate.props.expiry.expired")}</div>
)}
<div>
{new Date(expireAt).toLocaleString().split(" ")[0]} {t("certificate.props.expiry.text.expire")}
</div>
<div>{t("certificate.props.expiry.expiration", { date: new Date(expireAt).toLocaleString().split(" ")[0] })}</div>
</div>
);
},

View File

@ -8,9 +8,11 @@
"certificate.props.domain": "Name",
"certificate.props.expiry": "Expiry",
"certificate.props.expiry.days": "Days",
"certificate.props.expiry.left_days": "{{left}} / {{total}} days left",
"certificate.props.expiry.expired": "Expired",
"certificate.props.expiry.text.expire": "Expire",
"certificate.props.expiry.expiration": "Expire on {{date}}",
"certificate.props.expiry.filter.expire_soon": "Expire Soon",
"certificate.props.expiry.filter.expired": "Expired",
"certificate.props.workflow": "Workflow",
"certificate.props.source": "Source",
"certificate.props.certificate_chain": "Certificate Chain",

View File

@ -11,6 +11,7 @@
"common.delete.succeeded.message": "Delete Successful",
"common.delete.failed.message": "Delete Failed",
"common.next": "Next",
"common.reset": "Reset",
"common.confirm": "Confirm",
"common.cancel": "Cancel",
"common.submit": "Submit",

View File

@ -21,7 +21,9 @@
"workflow.props.description": "Description",
"workflow.props.description.placeholder": "Please enter description",
"workflow.props.executionMethod": "Execution Method",
"workflow.props.enabled": "Enabled",
"workflow.props.state": "State",
"workflow.props.state.filter.enabled": "Enabled",
"workflow.props.state.filter.disabled": "Disabled",
"workflow.props.createdAt": "Created",
"workflow.props.updatedAt": "Updated",

View File

@ -1,7 +1,7 @@
{
"access.page.title": "授权管理",
"access.nodata": "暂无授权信息,请先创建。",
"access.nodata": "暂无授权信息,请先新建",
"access.action.add": "新建授权",
"access.action.edit": "编辑授权",

View File

@ -1,16 +1,18 @@
{
"certificate.page.title": "证书管理",
"certificate.nodata": "暂无证书,建一个工作流去生成证书吧~ 😀",
"certificate.nodata": "暂无证书,建一个工作流去生成证书吧~ 😀",
"certificate.action.view": "查看证书",
"certificate.action.download": "下载证书",
"certificate.props.domain": "名称",
"certificate.props.expiry": "有效期限",
"certificate.props.expiry.days": "天",
"certificate.props.expiry.left_days": "{{left}} / {{total}} 天",
"certificate.props.expiry.expired": "已到期",
"certificate.props.expiry.text.expire": "到期",
"certificate.props.expiry.expiration": "{{date}} 到期",
"certificate.props.expiry.filter.expire_soon": "即将到期",
"certificate.props.expiry.filter.expired": "已到期",
"certificate.props.workflow": "所属工作流",
"certificate.props.source": "来源",
"certificate.props.certificate_chain": "证书内容",

View File

@ -11,6 +11,7 @@
"common.delete.succeeded.message": "删除成功",
"common.delete.failed.message": "删除失败",
"common.next": "下一步",
"common.reset": "重置",
"common.confirm": "确认",
"common.cancel": "取消",
"common.submit": "提交",

View File

@ -1,9 +1,7 @@
{
"workflow.page.title": "工作流",
"certificate.nodata": "暂无证书,创建一个工作流去生成证书吧~ 😀",
"workflow.nodata": "No workflows yet. Try to create a workflow to generate certificates! 😀",
"workflow.nodata": "暂无工作流,请先新建",
"workflow.detail.title": "流程",
"workflow.detail.history": "历史",
@ -23,7 +21,9 @@
"workflow.props.description": "描述",
"workflow.props.description.placeholder": "请输入描述",
"workflow.props.executionMethod": "执行方式",
"workflow.props.enabled": "是否启用",
"workflow.props.state": "启用状态",
"workflow.props.state.filter.enabled": "启用",
"workflow.props.state.filter.disabled": "未启用",
"workflow.props.createdAt": "创建时间",
"workflow.props.updatedAt": "更新时间",

View File

@ -16,11 +16,6 @@ export const convertZulu2Beijing = (zuluTime: string) => {
return formattedBeijingTime;
};
export const getDate = (zuluTime: string) => {
const time = convertZulu2Beijing(zuluTime);
return time.split(" ")[0];
};
export const getLeftDays = (zuluTime: string) => {
const time = convertZulu2Beijing(zuluTime);
const date = time.split(" ")[0];
@ -38,49 +33,3 @@ export const diffDays = (date1: string, date2: string) => {
const days = Math.ceil(diff / (1000 * 60 * 60 * 24));
return days;
};
export function getTimeBefore(days: number): string {
// 获取当前时间
const currentDate = new Date();
// 减去指定的天数
currentDate.setUTCDate(currentDate.getUTCDate() - days);
// 格式化日期为 yyyy-mm-dd
const year = currentDate.getUTCFullYear();
const month = String(currentDate.getUTCMonth() + 1).padStart(2, "0"); // 月份从 0 开始
const day = String(currentDate.getUTCDate()).padStart(2, "0");
// 格式化时间为 hh:ii:ss
const hours = String(currentDate.getUTCHours()).padStart(2, "0");
const minutes = String(currentDate.getUTCMinutes()).padStart(2, "0");
const seconds = String(currentDate.getUTCSeconds()).padStart(2, "0");
// 组合成 yyyy-mm-dd hh:ii:ss 格式
const formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
return formattedDate;
}
export function getTimeAfter(days: number): string {
// 获取当前时间
const currentDate = new Date();
// 加上指定的天数
currentDate.setUTCDate(currentDate.getUTCDate() + days);
// 格式化日期为 yyyy-mm-dd
const year = currentDate.getUTCFullYear();
const month = String(currentDate.getUTCMonth() + 1).padStart(2, "0"); // 月份从 0 开始
const day = String(currentDate.getUTCDate()).padStart(2, "0");
// 格式化时间为 hh:ii:ss
const hours = String(currentDate.getUTCHours()).padStart(2, "0");
const minutes = String(currentDate.getUTCMinutes()).padStart(2, "0");
const seconds = String(currentDate.getUTCSeconds()).padStart(2, "0");
// 组合成 yyyy-mm-dd hh:ii:ss 格式
const formattedDate = `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
return formattedDate;
}

View File

@ -24,9 +24,7 @@ const ConsoleLayout = () => {
const { t } = useTranslation();
const {
token: { colorBgContainer },
} = theme.useToken();
const { token: themeToken } = theme.useToken();
const menuItems: Required<MenuProps>["items"] = [
{
@ -56,10 +54,15 @@ const ConsoleLayout = () => {
];
const [menuSelectedKey, setMenuSelectedKey] = useState<string>();
useEffect(() => {
const getActiveMenuItem = () => {
const item =
menuItems.find((item) => item!.key === location.pathname) ??
menuItems.find((item) => item!.key !== "/" && location.pathname.startsWith(item!.key as string));
return item;
};
useEffect(() => {
const item = getActiveMenuItem();
if (item) {
setMenuSelectedKey(item.key as string);
} else {
@ -68,7 +71,7 @@ const ConsoleLayout = () => {
}, [location.pathname]);
useEffect(() => {
if (menuSelectedKey) {
if (menuSelectedKey && menuSelectedKey !== getActiveMenuItem()?.key) {
navigate(menuSelectedKey);
}
}, [menuSelectedKey]);
@ -116,7 +119,7 @@ const ConsoleLayout = () => {
</Layout.Sider>
<Layout>
<Layout.Header style={{ padding: 0, background: colorBgContainer }}>
<Layout.Header style={{ padding: 0, background: themeToken.colorBgContainer }}>
<div className="flex items-center justify-between size-full px-4 overflow-hidden">
<div className="flex items-center gap-4 size-full">{/* <Button icon={<MenuIcon />} size="large" /> */}</div>
<div className="flex-grow flex items-center justify-end gap-4 size-full overflow-hidden">

View File

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { Avatar, Button, Empty, Modal, notification, Space, Table, Tooltip, Typography, type TableProps } from "antd";
import { PageHeader } from "@ant-design/pro-components";
@ -13,9 +13,6 @@ import { useConfigContext } from "@/providers/config";
const AccessList = () => {
const { t } = useTranslation();
// a flag to fix the twice-rendering issue in strict mode
const mountRef = useRef(true);
const [modalApi, ModelContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
@ -134,11 +131,6 @@ const AccessList = () => {
}, [page, pageSize, configContext.config.accesses]);
useEffect(() => {
if (mountRef.current) {
mountRef.current = false;
return;
}
fetchTableData();
}, [fetchTableData]);

View File

@ -1,15 +1,14 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, Empty, notification, Space, Table, Tooltip, Typography, type TableProps } from "antd";
import { Button, Divider, Empty, Menu, notification, Radio, Space, Table, theme, Tooltip, Typography, type MenuProps, type TableProps } from "antd";
import { PageHeader } from "@ant-design/pro-components";
import { Eye as EyeIcon } from "lucide-react";
import { Eye as EyeIcon, Filter as FilterIcon } from "lucide-react";
import moment from "moment";
import CertificateDetailDrawer from "@/components/certificate/CertificateDetailDrawer";
import { Certificate as CertificateType } from "@/domain/certificate";
import { list as listCertificate, type CertificateListReq } from "@/repository/certificate";
import { diffDays, getLeftDays } from "@/lib/time";
const CertificateList = () => {
const navigate = useNavigate();
@ -17,8 +16,7 @@ const CertificateList = () => {
const { t } = useTranslation();
// a flag to fix the twice-rendering issue in strict mode
const mountRef = useRef(true);
const { token: themeToken } = theme.useToken();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
@ -40,21 +38,65 @@ const CertificateList = () => {
{
key: "expiry",
title: t("certificate.props.expiry"),
filterDropdown: ({ setSelectedKeys, confirm, clearFilters }) => {
const items: Required<MenuProps>["items"] = [
["expireSoon", "certificate.props.expiry.filter.expire_soon"],
["expired", "certificate.props.expiry.filter.expired"],
].map(([key, label]) => {
return {
key,
label: <Radio checked={filters["state"] === key}>{t(label)}</Radio>,
onClick: () => {
if (filters["state"] !== key) {
setFilters((prev) => ({ ...prev, state: key }));
setSelectedKeys([key]);
}
confirm({ closeDropdown: true });
},
};
});
const handleResetClick = () => {
setFilters((prev) => ({ ...prev, state: undefined }));
setSelectedKeys([]);
clearFilters?.();
confirm();
};
const handleConfirmClick = () => {
confirm();
};
return (
<div style={{ padding: 0 }}>
<Menu items={items} selectable={false} />
<Divider style={{ margin: 0 }} />
<Space className="justify-end w-full" style={{ padding: themeToken.paddingSM }}>
<Button size="small" disabled={!filters.state} onClick={handleResetClick}>
{t("common.reset")}
</Button>
<Button type="primary" size="small" onClick={handleConfirmClick}>
{t("common.confirm")}
</Button>
</Space>
</div>
);
},
filterIcon: () => <FilterIcon size={14} />,
render: (_, record) => {
const leftDays = getLeftDays(record.expireAt);
const allDays = diffDays(record.expireAt, record.created);
const total = moment(record.expireAt).diff(moment(record.created), "d") + 1;
const left = moment(record.expireAt).diff(moment(), "d");
return (
<Space className="max-w-full" direction="vertical" size={4}>
{leftDays > 0 ? (
<Typography.Text type="success">
{leftDays} / {allDays} {t("certificate.props.expiry.days")}
</Typography.Text>
{left > 0 ? (
<Typography.Text type="success">{t("certificate.props.expiry.left_days", { left, total })}</Typography.Text>
) : (
<Typography.Text type="danger">{t("certificate.props.expiry.expired")}</Typography.Text>
)}
<Typography.Text type="secondary">
{moment(record.expireAt).format("YYYY-MM-DD")} {t("certificate.props.expiry.text.expire")}
{t("certificate.props.expiry.expiration", { date: moment(record.expireAt).format("YYYY-MM-DD") })}
</Typography.Text>
</Space>
);
@ -122,21 +164,31 @@ const CertificateList = () => {
const [tableData, setTableData] = useState<CertificateType[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [filters, setFilters] = useState<Record<string, unknown>>({});
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
const [currentRecord, setCurrentRecord] = useState<CertificateType>();
const [drawerOpen, setDrawerOpen] = useState(false);
useEffect(() => {
setFilters({ ...filters, state: searchParams.get("state") });
setPage(parseInt(+searchParams.get("page")! + "") || 1);
setPageSize(parseInt(+searchParams.get("perPage")! + "") || 10);
}, []);
const fetchTableData = useCallback(async () => {
if (loading) return;
setLoading(true);
const state = searchParams.get("state");
const req: CertificateListReq = { page: page, perPage: pageSize };
if (state) {
req.state = state as CertificateListReq["state"];
}
try {
const resp = await listCertificate(req);
const resp = await listCertificate({
page: page,
perPage: pageSize,
state: filters["state"] as CertificateListReq["state"],
});
setTableData(resp.items);
setTableTotal(resp.totalItems);
@ -146,20 +198,12 @@ const CertificateList = () => {
} finally {
setLoading(false);
}
}, [page, pageSize]);
}, [filters, page, pageSize]);
useEffect(() => {
if (mountRef.current) {
mountRef.current = false;
return;
}
fetchTableData();
}, [fetchTableData]);
const [drawerOpen, setDrawerOpen] = useState(false);
const [currentRecord, setCurrentRecord] = useState<CertificateType>();
const handleViewClick = (certificate: CertificateType) => {
setDrawerOpen(true);
setCurrentRecord(certificate);
@ -194,6 +238,10 @@ const CertificateList = () => {
},
}}
rowKey={(record) => record.id}
onChange={(_, filters, __, extra) => {
console.log(filters);
extra.action === "filter" && fetchTableData();
}}
/>
<CertificateDetailDrawer

View File

@ -1,13 +1,29 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useCallback, useEffect, useState } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Button, Empty, Modal, notification, Space, Switch, Table, Tooltip, Typography, type TableProps } from "antd";
import {
Button,
Divider,
Empty,
Menu,
Modal,
notification,
Radio,
Space,
Switch,
Table,
theme,
Tooltip,
Typography,
type MenuProps,
type TableProps,
} from "antd";
import { PageHeader } from "@ant-design/pro-components";
import { Pencil as PencilIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react";
import { Filter as FilterIcon, Pencil as PencilIcon, Plus as PlusIcon, Trash2 as Trash2Icon } from "lucide-react";
import moment from "moment";
import { Workflow as WorkflowType } from "@/domain/workflow";
import { list as listWorkflow, remove as removeWorkflow, save as saveWorkflow, type WorkflowListReq } from "@/repository/workflow";
import { list as listWorkflow, remove as removeWorkflow, save as saveWorkflow } from "@/repository/workflow";
const WorkflowList = () => {
const navigate = useNavigate();
@ -15,8 +31,7 @@ const WorkflowList = () => {
const { t } = useTranslation();
// a flag to fix the twice-rendering issue in strict mode
const mountRef = useRef(true);
const { token: themeToken } = theme.useToken();
const [modalApi, ModelContextHolder] = Modal.useModal();
const [notificationApi, NotificationContextHolder] = notification.useNotification();
@ -63,19 +78,63 @@ const WorkflowList = () => {
},
},
{
key: "enabled",
title: t("workflow.props.enabled"),
key: "state",
title: t("workflow.props.state"),
filterDropdown: ({ setSelectedKeys, confirm, clearFilters }) => {
const items: Required<MenuProps>["items"] = [
["enabled", "workflow.props.state.filter.enabled"],
["disabled", "workflow.props.state.filter.disabled"],
].map(([key, label]) => {
return {
key,
label: <Radio checked={filters["state"] === key}>{t(label)}</Radio>,
onClick: () => {
if (filters["state"] !== key) {
setFilters((prev) => ({ ...prev, state: key }));
setSelectedKeys([key]);
}
confirm({ closeDropdown: true });
},
};
});
const handleResetClick = () => {
setFilters((prev) => ({ ...prev, state: undefined }));
setSelectedKeys([]);
clearFilters?.();
confirm();
};
const handleConfirmClick = () => {
confirm();
};
return (
<div style={{ padding: 0 }}>
<Menu items={items} selectable={false} />
<Divider style={{ margin: 0 }} />
<Space className="justify-end w-full" style={{ padding: themeToken.paddingSM }}>
<Button size="small" disabled={!filters.state} onClick={handleResetClick}>
{t("common.reset")}
</Button>
<Button type="primary" size="small" onClick={handleConfirmClick}>
{t("common.confirm")}
</Button>
</Space>
</div>
);
},
filterIcon: () => <FilterIcon size={14} />,
render: (_, record) => {
const enabled = record.enabled;
return (
<>
<Switch
checked={enabled}
onChange={() => {
handleEnabledChange(record);
}}
/>
</>
<Switch
checked={enabled}
onChange={() => {
handleEnabledChange(record);
}}
/>
);
},
},
@ -136,22 +195,27 @@ const WorkflowList = () => {
const [tableData, setTableData] = useState<WorkflowType[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [filters, setFilters] = useState<Record<string, unknown>>({});
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
// TODO: 表头筛选
useEffect(() => {
setFilters({ ...filters, state: searchParams.get("state") });
setPage(parseInt(+searchParams.get("page")! + "") || 1);
setPageSize(parseInt(+searchParams.get("perPage")! + "") || 10);
}, []);
const fetchTableData = useCallback(async () => {
if (loading) return;
setLoading(true);
const state = searchParams.get("state");
const req: WorkflowListReq = { page: page, perPage: pageSize };
if (state == "enabled") {
req.enabled = true;
}
try {
const resp = await listWorkflow(req);
const resp = await listWorkflow({
page: page,
perPage: pageSize,
enabled: (filters["state"] as string) === "enabled" ? true : (filters["state"] as string) === "disabled" ? false : undefined,
});
setTableData(resp.items);
setTableTotal(resp.totalItems);
@ -161,14 +225,9 @@ const WorkflowList = () => {
} finally {
setLoading(false);
}
}, [searchParams, page, pageSize]);
}, [filters, page, pageSize]);
useEffect(() => {
if (mountRef.current) {
mountRef.current = false;
return;
}
fetchTableData();
}, [fetchTableData]);

View File

@ -1,7 +1,7 @@
import { type RecordListOptions } from "pocketbase";
import moment from "moment";
import { type Certificate } from "@/domain/certificate";
import { getTimeAfter } from "@/lib/time";
import { getPocketBase } from "./pocketbase";
export type CertificateListReq = {
@ -23,7 +23,7 @@ export const list = async (req: CertificateListReq) => {
if (req.state === "expireSoon") {
options.filter = pb.filter("expireAt<{:expiredAt}", {
expiredAt: getTimeAfter(15),
expiredAt: moment().add(15, "d").toDate(),
});
} else if (req.state === "expired") {
options.filter = pb.filter("expireAt<={:expiredAt}", {