feat(ui): new AccessList page

This commit is contained in:
Fu Diwei
2025-07-20 21:17:58 +08:00
parent d8e7659088
commit 004d8dbd0a
16 changed files with 131 additions and 84 deletions
+1 -1
View File
@@ -27,7 +27,7 @@ const Empty = (props: EmptyProps) => {
<div className={className} style={style}>
<div className="relative w-full overflow-hidden">
<div className="relative top-0 left-0 z-1 flex h-full w-full py-4">
<div className="relative w-full max-w-lg">
<div className="relative mx-auto w-full max-w-lg">
<div className="flex flex-col gap-2 text-center">
<Show when={!!icon}>
<div className="mx-auto">
@@ -51,7 +51,7 @@ const AccessEditDrawer = ({ data, loading, trigger, scene, usage, afterSubmit, .
try {
let values: AccessModel = formRef.current!.getFieldsValue();
if (scene === "add") {
if (scene === "create") {
if (data?.id) {
throw "Invalid props: `data`";
}
@@ -109,7 +109,7 @@ const AccessEditDrawer = ({ data, loading, trigger, scene, usage, afterSubmit, .
width={720}
onClose={() => setOpen(false)}
>
<AccessForm ref={formRef} initialValues={data} scene={scene === "add" ? "add" : "edit"} usage={usage} />
<AccessForm ref={formRef} initialValues={data} scene={scene === "create" ? "create" : "edit"} usage={usage} />
</Drawer>
</>
);
+2 -2
View File
@@ -51,7 +51,7 @@ const AccessEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ..
try {
let values: AccessModel = formRef.current!.getFieldsValue();
if (scene === "add") {
if (scene === "create") {
if (data?.id) {
throw "Invalid props: `data`";
}
@@ -113,7 +113,7 @@ const AccessEditModal = ({ data, loading, trigger, scene, usage, afterSubmit, ..
onCancel={handleCancelClick}
>
<div className="pb-2 pt-4">
<AccessForm ref={formRef} initialValues={data} scene={scene === "add" ? "add" : "edit"} usage={usage} />
<AccessForm ref={formRef} initialValues={data} scene={scene === "create" ? "create" : "edit"} usage={usage} />
</div>
</Modal>
</>
+2 -2
View File
@@ -86,7 +86,7 @@ import AccessFormWestcnConfig from "./AccessFormWestcnConfig";
import AccessFormZeroSSLConfig from "./AccessFormZeroSSLConfig";
type AccessFormFieldValues = Partial<MaybeModelRecord<AccessModel>>;
type AccessFormScenes = "add" | "edit";
type AccessFormScenes = "create" | "edit";
type AccessFormUsages = "both-dns-hosting" | "ca-only" | "notification-only";
export type AccessFormProps = {
@@ -406,7 +406,7 @@ const AccessForm = forwardRef<AccessFormInstance, AccessFormProps>(({ className,
<Form.Item name="provider" label={providerLabel} rules={[formRule]} tooltip={providerTooltip}>
<AccessProviderSelect
filter={providerFilter}
disabled={scene !== "add"}
disabled={scene !== "create"}
placeholder={providerPlaceholder}
showOptionTags={usage == null || (usage === "both-dns-hosting" ? { [ACCESS_USAGES.DNS]: true, [ACCESS_USAGES.HOSTING]: true } : false)}
showSearch={!disabled}
@@ -326,7 +326,7 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
</div>
<div className="text-right">
<AccessEditModal
scene="add"
scene="create"
trigger={
<Button size="small" type="link">
{t("workflow_node.apply.form.provider_access.button")}
@@ -408,7 +408,7 @@ const ApplyNodeConfigForm = forwardRef<ApplyNodeConfigFormInstance, ApplyNodeCon
<div className="text-right">
<AccessEditModal
data={{ provider: caProvidersMap.get(fieldCAProvider!)?.provider }}
scene="add"
scene="create"
trigger={
<Button size="small" type="link">
{t("workflow_node.apply.form.ca_provider_access.button")}
@@ -489,7 +489,7 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
<div className="text-right">
<AccessEditModal
data={{ provider: deploymentProvidersMap.get(fieldProvider!)?.provider }}
scene="add"
scene="create"
trigger={
<Button size="small" type="link">
{t("workflow_node.deploy.form.provider_access.button")}
@@ -199,7 +199,7 @@ const NotifyNodeConfigForm = forwardRef<NotifyNodeConfigFormInstance, NotifyNode
</div>
<div className="text-right">
<AccessEditModal
scene="add"
scene="create"
trigger={
<Button size="small" type="link">
{t("workflow_node.notify.form.provider_access.button")}
+4 -2
View File
@@ -2,7 +2,10 @@
"access.page.title": "Credentials",
"access.page.subtitle": "Credentials store authentication information (username and password, API key, OAuth tokens, etc.) to connect with specific third-party apps and services.",
"access.nodata": "No credentials. Please create an credential first.",
"access.nodata": "No credentials",
"access.nodata.title": "No credentials",
"access.nodata.description": "Add a credential to store authentication information.",
"access.nodata.button": "Create credential",
"access.search.placeholder": "Search by credential name ...",
@@ -13,7 +16,6 @@
"access.action.delete.confirm": "Are you sure want to delete this \"{{name}}\" credential?<br>This action cannot be undone.",
"access.props.name": "Name",
"access.props.provider": "Provider",
"access.props.provider.usage.dns": "DNS provider",
"access.props.provider.usage.hosting": "Hosting provider",
"access.props.provider.usage.ca": "Certificate authority",
+5 -5
View File
@@ -3,8 +3,8 @@
"certificate.page.subtitle": "SSL certificates contain the website's public key and the website's identity, along with related information. They are generated from the execution output of workflows.",
"certificate.nodata.title": "No Certificates",
"certificate.nodata.description": "Create a workflow to generate certificates!",
"certificate.nodata.button": "Create workflow",
"certificate.nodata.description": "Add a workflow to generate certificates!",
"certificate.nodata.button": "Go to workflows",
"certificate.search.placeholder": "Search by certificate name or serial number ...",
@@ -19,9 +19,9 @@
"certificate.props.validity.less_than_a_day": "Less than 1 day",
"certificate.props.validity.expired": "Expired",
"certificate.props.validity.expiration": "Expire on {{date}}",
"certificate.props.validity.filters.all": "All",
"certificate.props.validity.filters.expire_soon": "Expire soon",
"certificate.props.validity.filters.expired": "Expired",
"certificate.props.validity.filter.all": "All",
"certificate.props.validity.filter.expire_soon": "Expire soon",
"certificate.props.validity.filter.expired": "Expired",
"certificate.props.brand": "Brand",
"certificate.props.source": "Source",
"certificate.props.source.workflow": "Workflow",
+2 -2
View File
@@ -23,8 +23,8 @@
"workflow.props.trigger.manual": "Manual",
"workflow.props.last_run_at": "Last run at",
"workflow.props.state": "State",
"workflow.props.state.filters.enabled": "Active",
"workflow.props.state.filters.disabled": "Inactive",
"workflow.props.state.filter.enabled": "Active",
"workflow.props.state.filter.disabled": "Inactive",
"workflow.props.created_at": "Created at",
"workflow.props.updated_at": "Updated at",
+3 -2
View File
@@ -2,7 +2,9 @@
"access.page.title": "授权管理",
"access.page.subtitle": "授权中存储有用于访问特定第三方应用程序和服务的身份验证信息(如账号密码、API 密钥、OAuth 令牌等)。",
"access.nodata": "暂无授权信息,请先新建授权",
"access.nodata.title": "暂无授权",
"access.nodata.description": "新建一个授权并存储身份验证信息吧~",
"access.nodata.button": "新建授权",
"access.search.placeholder": "按授权名称搜索……",
@@ -13,7 +15,6 @@
"access.action.delete.confirm": "确定要删除授权「{{name}}」吗?<br>注意此操作不可撤销,请谨慎操作。",
"access.props.name": "名称",
"access.props.provider": "提供商",
"access.props.provider.usage.dns": "DNS 提供商",
"access.props.provider.usage.hosting": "主机提供商",
"access.props.provider.usage.ca": "证书颁发机构",
+4 -4
View File
@@ -4,7 +4,7 @@
"certificate.nodata.title": "暂无证书",
"certificate.nodata.description": "新建一个工作流去生成证书吧~",
"certificate.nodata.button": "新建工作流",
"certificate.nodata.button": "前往工作流",
"certificate.search.placeholder": "按证书名称或序列号搜索……",
@@ -19,9 +19,9 @@
"certificate.props.validity.less_than_a_day": "不足 1 天",
"certificate.props.validity.expired": "已过期",
"certificate.props.validity.expiration": "{{date}} 过期",
"certificate.props.validity.filters.all": "全部",
"certificate.props.validity.filters.expire_soon": "即将过期",
"certificate.props.validity.filters.expired": "已过期",
"certificate.props.validity.filter.all": "全部",
"certificate.props.validity.filter.expire_soon": "即将过期",
"certificate.props.validity.filter.expired": "已过期",
"certificate.props.brand": "证书品牌",
"certificate.props.source": "来源",
"certificate.props.source.workflow": "工作流",
+2 -2
View File
@@ -23,8 +23,8 @@
"workflow.props.trigger.manual": "手动",
"workflow.props.last_run_at": "最近执行时间",
"workflow.props.state": "启用状态",
"workflow.props.state.filters.enabled": "启用",
"workflow.props.state.filters.disabled": "未启用",
"workflow.props.state.filter.enabled": "启用",
"workflow.props.state.filter.disabled": "未启用",
"workflow.props.created_at": "创建时间",
"workflow.props.updated_at": "更新时间",
+83 -46
View File
@@ -1,13 +1,14 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import { IconCopy, IconEdit, IconPlus, IconReload, IconTrash } from "@tabler/icons-react";
import { IconCirclePlus, IconCopy, IconEdit, IconFingerprint, IconPlus, IconReload, IconTrash } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { App, Avatar, Button, Empty, Input, Table, type TableProps, Tabs, Tooltip, Typography } from "antd";
import { App, Avatar, Button, Input, Skeleton, Table, type TableProps, Tabs, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
import AccessEditDrawer, { type AccessEditDrawerProps } from "@/components/access/AccessEditDrawer";
import Empty from "@/components/Empty";
import { type AccessModel } from "@/domain/access";
import { ACCESS_USAGES, accessProvidersMap } from "@/domain/provider";
import { useZustandShallowSelector } from "@/hooks";
@@ -48,17 +49,16 @@ const AccessList = () => {
key: "name",
title: t("access.props.name"),
ellipsis: true,
render: (_, record) => <>{record.name}</>,
},
{
key: "provider",
title: t("access.props.provider"),
ellipsis: true,
render: (_, record) => {
return (
<div className="flex max-w-full items-center gap-2 truncate overflow-hidden">
<Avatar shape="square" src={accessProvidersMap.get(record.provider)?.icon} size="small" />
<Typography.Text ellipsis>{t(accessProvidersMap.get(record.provider)?.name ?? "")}</Typography.Text>
<div className="flex max-w-full items-center gap-4 truncate overflow-hidden">
<Avatar shape="square" src={accessProvidersMap.get(record.provider)?.icon} size="large" />
<div className="flex max-w-full flex-col gap-1">
<Typography.Text ellipsis>{record.name}</Typography.Text>
<Typography.Text ellipsis type="secondary">
{t(accessProvidersMap.get(record.provider)?.name ?? "")}
</Typography.Text>
</div>
</div>
);
},
@@ -71,14 +71,6 @@ const AccessList = () => {
return dayjs(record.created!).format("YYYY-MM-DD HH:mm:ss");
},
},
{
key: "updatedAt",
title: t("access.props.updated_at"),
ellipsis: true,
render: (_, record) => {
return dayjs(record.updated!).format("YYYY-MM-DD HH:mm:ss");
},
},
{
key: "$action",
align: "end",
@@ -86,35 +78,36 @@ const AccessList = () => {
width: 120,
render: (_, record) => (
<div className="flex items-center justify-end">
<AccessEditDrawer
data={record}
usage={filters["usage"] as AccessUsageProp}
scene="edit"
trigger={
<Tooltip title={t("access.action.edit")}>
<Button color="primary" icon={<IconEdit size="1.25em" />} variant="text" />
</Tooltip>
}
/>
<AccessEditDrawer
data={{ ...record, id: undefined, name: `${record.name}-copy` }}
usage={filters["usage"] as AccessUsageProp}
scene="add"
trigger={
<Tooltip title={t("access.action.duplicate")}>
<Button color="primary" icon={<IconCopy size="1.25em" />} variant="text" />
</Tooltip>
}
/>
<Tooltip title={t("access.action.edit")}>
<Button
color="primary"
icon={<IconEdit size="1.25em" />}
variant="text"
onClick={(e) => {
e.stopPropagation();
handleRecordDetailClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("access.action.duplicate")}>
<Button
color="primary"
icon={<IconCopy size="1.25em" />}
variant="text"
onClick={(e) => {
e.stopPropagation();
handleRecordDuplicateClick(record);
}}
/>
</Tooltip>
<Tooltip title={t("access.action.delete")}>
<Button
color="danger"
icon={<IconTrash size="1.25em" />}
variant="text"
onClick={() => {
handleDeleteClick(record);
onClick={(e) => {
e.stopPropagation();
handleRecordDeleteClick(record);
}}
/>
</Tooltip>
@@ -190,7 +183,23 @@ const AccessList = () => {
fetchAccesses();
};
const handleDeleteClick = async (access: AccessModel) => {
const [detailRecord, setDetailRecord] = useState<MaybeModelRecord<AccessModel>>();
const [detailScene, setDetailScene] = useState<AccessEditDrawerProps["scene"]>("create");
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const handleRecordDetailClick = (access: AccessModel) => {
setDetailRecord(access);
setDetailScene("edit");
setDetailOpen(true);
};
const handleRecordDuplicateClick = (access: AccessModel) => {
setDetailRecord({ ...access, id: undefined, name: `${access.name}-copy` });
setDetailScene("create");
setDetailOpen(true);
};
const handleRecordDeleteClick = async (access: AccessModel) => {
modal.confirm({
title: <span className="text-error">{t("access.action.delete")}</span>,
content: <span dangerouslySetInnerHTML={{ __html: t("access.action.delete.confirm", { name: access.name }) }} />,
@@ -238,8 +247,8 @@ const AccessList = () => {
</div>
<div>
<AccessEditDrawer
scene="create"
usage={filters["usage"] as AccessUsageProp}
scene="add"
trigger={
<Button className="text-sm" icon={<IconPlus size="1.25em" />} size="large" type="primary">
{t("access.action.create")}
@@ -269,12 +278,32 @@ const AccessList = () => {
size="large"
onChange={(key) => handleTabChange(key)}
/>
<Table<AccessModel>
columns={tableColumns}
dataSource={tableData}
loading={!loadedAtOnce || loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("access.nodata")} />,
emptyText: loading ? (
<Skeleton />
) : (
<Empty
title={t("access.nodata.title")}
description={t("access.nodata.description")}
icon={<IconFingerprint size={24} />}
extra={
<AccessEditDrawer
scene="create"
usage={filters["usage"] as AccessUsageProp}
trigger={
<Button icon={<IconCirclePlus size="1.25em" />} type="primary">
{t("access.action.create")}
</Button>
}
/>
}
/>
),
}}
pagination={{
current: page,
@@ -290,9 +319,17 @@ const AccessList = () => {
setPageSize(pageSize);
},
}}
rowClassName="cursor-pointer"
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
onRow={(record) => ({
onClick: () => {
handleRecordDetailClick(record);
},
})}
/>
<AccessEditDrawer data={detailRecord} open={detailOpen} scene={detailScene} usage={filters["usage"] as AccessUsageProp} onOpenChange={setDetailOpen} />
</div>
</div>
);
+15 -8
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { IconBrowserShare, IconCirclePlus, IconReload, IconShieldCheckeredFilled, IconTrash } from "@tabler/icons-react";
import { IconBrowserShare, IconExternalLink, IconReload, IconShieldCheckeredFilled, IconTrash } from "@tabler/icons-react";
import { useRequest } from "ahooks";
import { App, Button, Input, Segmented, Skeleton, Table, type TableProps, Tooltip, Typography } from "antd";
import dayjs from "dayjs";
@@ -34,6 +34,13 @@ const CertificateList = () => {
const [pageSize, setPageSize] = useState<number>(() => parseInt(+searchParams.get("perPage")! + "") || 15);
const tableColumns: TableProps<CertificateModel>["columns"] = [
{
key: "$index",
align: "center",
fixed: "left",
width: 50,
render: (_, __, index) => (page - 1) * pageSize + index + 1,
},
{
key: "name",
title: t("certificate.props.subject_alt_names"),
@@ -209,8 +216,8 @@ const CertificateList = () => {
const [detailRecord, setDetailRecord] = useState<CertificateModel>();
const [detailOpen, setDetailOpen] = useState<boolean>(false);
const handleRecordDetailClick = (record: CertificateModel) => {
setDetailRecord(record);
const handleRecordDetailClick = (certificate: CertificateModel) => {
setDetailRecord(certificate);
setDetailOpen(true);
};
@@ -252,9 +259,9 @@ const CertificateList = () => {
<Segmented
className="shadow-xs"
options={[
{ label: <span className="text-sm">{t("certificate.props.validity.filters.all")}</span>, value: "" },
{ label: <span className="text-sm">{t("certificate.props.validity.filters.expire_soon")}</span>, value: "expireSoon" },
{ label: <span className="text-sm">{t("certificate.props.validity.filters.expired")}</span>, value: "expired" },
{ label: <span className="text-sm">{t("certificate.props.validity.filter.all")}</span>, value: "" },
{ label: <span className="text-sm">{t("certificate.props.validity.filter.expire_soon")}</span>, value: "expireSoon" },
{ label: <span className="text-sm">{t("certificate.props.validity.filter.expired")}</span>, value: "expired" },
]}
size="large"
value={(filters["state"] as string) || ""}
@@ -300,8 +307,8 @@ const CertificateList = () => {
{t("common.button.reload")}
</Button>
) : (
<Button icon={<IconCirclePlus size="1.25em" />} type="primary" onClick={() => navigate("/workflows/new")}>
{t("workflow.action.create")}
<Button icon={<IconExternalLink size="1.25em" />} type="primary" onClick={() => navigate("/workflows")}>
{t("certificate.nodata.button")}
</Button>
)
}
+2 -2
View File
@@ -77,8 +77,8 @@ const WorkflowList = () => {
defaultFilteredValue: searchParams.has("state") ? [searchParams.get("state") as string] : undefined,
filterDropdown: ({ setSelectedKeys, confirm, clearFilters }) => {
const items: Required<MenuProps>["items"] = [
["enabled", "workflow.props.state.filters.enabled"],
["disabled", "workflow.props.state.filters.disabled"],
["enabled", "workflow.props.state.filter.enabled"],
["disabled", "workflow.props.state.filter.disabled"],
].map(([key, label]) => {
return {
key,