feat: search by keyword on AccessList, CertificateList, WorkflowList

This commit is contained in:
Fu Diwei 2025-02-13 20:31:11 +08:00
parent 970fba90e0
commit 664bb692b6
15 changed files with 262 additions and 144 deletions

View File

@ -212,7 +212,6 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
const handleProviderSelect = (value: string) => {
if (fieldProvider === value) return;
// TODO: 暂时不支持切换部署目标,需后端调整,否则之前若存在部署结果输出就不会再部署
// 切换部署目标时重置表单,避免其他部署目标的配置字段影响当前部署目标
if (initialValues?.provider === value) {
formInst.resetFields();
@ -276,13 +275,7 @@ const DeployNodeConfigForm = forwardRef<DeployNodeConfigFormInstance, DeployNode
fallback={<DeployProviderPicker autoFocus placeholder={t("workflow_node.deploy.search.provider.placeholder")} onSelect={handleProviderPick} />}
>
<Form.Item name="provider" label={t("workflow_node.deploy.form.provider.label")} rules={[formRule]}>
<DeployProviderSelect
allowClear
disabled={!!initialValues?.provider}
placeholder={t("workflow_node.deploy.form.provider.placeholder")}
showSearch
onSelect={handleProviderSelect}
/>
<DeployProviderSelect allowClear placeholder={t("workflow_node.deploy.form.provider.placeholder")} showSearch onSelect={handleProviderSelect} />
</Form.Item>
<Form.Item className="mb-0">

View File

@ -3,6 +3,8 @@
"access.nodata": "No accesses. Please create an authorization first.",
"access.search.placeholder": "Search by access name ...",
"access.action.add": "Create authorization",
"access.action.edit": "Edit authorization",
"access.action.duplicate": "Duplicate authorization",

View File

@ -3,6 +3,8 @@
"certificate.nodata": "No certificates. Please create a workflow to generate certificates! 😀",
"certificate.search.placeholder": "Search by certificate name or serial number ...",
"certificate.action.view": "View certificate",
"certificate.action.delete": "Delete certificate",
"certificate.action.delete.confirm": "Are you sure to delete this certificate?",

View File

@ -3,6 +3,8 @@
"workflow.nodata": "No workflows. Please create a workflow to generate certificates! 😀",
"workflow.search.placeholder": "Search by workflow name ...",
"workflow.action.create": "Create workflow",
"workflow.action.edit": "Edit workflow",
"workflow.action.delete": "Delete workflow",

View File

@ -3,6 +3,8 @@
"access.nodata": "暂无授权信息,请先新建授权",
"access.search.placeholder": "按授权名称搜索……",
"access.action.add": "新建授权",
"access.action.edit": "编辑授权",
"access.action.duplicate": "复制授权",

View File

@ -3,6 +3,8 @@
"certificate.nodata": "暂无证书,新建一个工作流去生成证书吧~ 😀",
"certificate.search.placeholder": "按证书名称或序列号搜索……",
"certificate.action.view": "查看证书",
"certificate.action.delete": "删除证书",
"certificate.action.delete.confirm": "确定要删除此证书吗?",

View File

@ -3,6 +3,8 @@
"workflow.nodata": "暂无工作流,请先新建工作流",
"workflow.search.placeholder": "按工作流名称搜索……",
"workflow.action.create": "新建工作流",
"workflow.action.edit": "编辑工作流",
"workflow.action.delete": "删除工作流",

View File

@ -1,14 +1,16 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useSearchParams } from "react-router-dom";
import {
DeleteOutlined as DeleteOutlinedIcon,
EditOutlined as EditOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
ReloadOutlined as ReloadOutlinedIcon,
SnippetsOutlined as SnippetsOutlinedIcon,
} from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
import { useRequest } from "ahooks";
import { Avatar, Button, Empty, Modal, Space, Table, type TableProps, Tooltip, Typography, notification } from "antd";
import { Avatar, Button, Card, Empty, Flex, Input, Modal, Space, Table, type TableProps, Tooltip, Typography, notification } from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
@ -20,6 +22,8 @@ import { useAccessesStore } from "@/stores/access";
import { getErrMsg } from "@/utils/error";
const AccessList = () => {
const [searchParams] = useSearchParams();
const { t } = useTranslation();
const [modalApi, ModelContextHolder] = Modal.useModal();
@ -116,6 +120,12 @@ const AccessList = () => {
const [tableData, setTableData] = useState<AccessModel[]>([]);
const [tableTotal, setTableTotal] = useState<number>(0);
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
keyword: searchParams.get("keyword"),
};
});
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(10);
@ -134,14 +144,21 @@ const AccessList = () => {
() => {
const startIndex = (page - 1) * pageSize;
const endIndex = startIndex + pageSize;
const items = accesses.slice(startIndex, endIndex);
const list = accesses.filter((e) => {
const keyword = (filters["keyword"] as string | undefined)?.trim();
if (keyword) {
return e.name.includes(keyword);
}
return true;
});
return Promise.resolve({
items,
totalItems: accesses.length,
items: list.slice(startIndex, endIndex),
totalItems: list.length,
});
},
{
refreshDeps: [accesses, page, pageSize],
refreshDeps: [accesses, filters, page, pageSize],
onSuccess: (res) => {
setTableData(res.items);
setTableTotal(res.totalItems);
@ -149,6 +166,16 @@ const AccessList = () => {
}
);
const handleSearch = (value: string) => {
setFilters((prev) => ({ ...prev, keyword: value }));
};
const handleReloadClick = () => {
if (loading) return;
fetchAccesses();
};
const handleDeleteClick = async (data: AccessModel) => {
modalApi.confirm({
title: t("access.action.delete"),
@ -186,30 +213,43 @@ const AccessList = () => {
]}
/>
<Table<AccessModel>
columns={tableColumns}
dataSource={tableData}
loading={!loadedAtOnce || loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("access.nodata")} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
<Card size="small">
<div className="mb-4">
<Flex gap="small">
<div className="flex-1">
<Input.Search allowClear defaultValue={filters["keyword"] as string} placeholder={t("access.search.placeholder")} onSearch={handleSearch} />
</div>
<div>
<Button icon={<ReloadOutlinedIcon spin={loading} />} onClick={handleReloadClick} />
</div>
</Flex>
</div>
<Table<AccessModel>
columns={tableColumns}
dataSource={tableData}
loading={!loadedAtOnce || loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={t("access.nodata")} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</Card>
</div>
);
};

View File

@ -1,10 +1,28 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate, useSearchParams } from "react-router-dom";
import { DeleteOutlined as DeleteOutlinedIcon, SelectOutlined as SelectOutlinedIcon } from "@ant-design/icons";
import { DeleteOutlined as DeleteOutlinedIcon, ReloadOutlined as ReloadOutlinedIcon, SelectOutlined as SelectOutlinedIcon } from "@ant-design/icons";
import { PageHeader } from "@ant-design/pro-components";
import { useRequest } from "ahooks";
import { Button, Divider, Empty, Menu, type MenuProps, Modal, Radio, Space, Table, type TableProps, Tooltip, Typography, notification, theme } from "antd";
import {
Button,
Card,
Divider,
Empty,
Flex,
Input,
Menu,
type MenuProps,
Modal,
Radio,
Space,
Table,
type TableProps,
Tooltip,
Typography,
notification,
theme,
} from "antd";
import dayjs from "dayjs";
import { ClientResponseError } from "pocketbase";
@ -191,6 +209,7 @@ const CertificateList = () => {
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
keyword: searchParams.get("keyword"),
state: searchParams.get("state"),
};
});
@ -205,9 +224,10 @@ const CertificateList = () => {
} = useRequest(
() => {
return listCertificate({
keyword: filters["keyword"] as string,
state: filters["state"] as ListCertificateRequest["state"],
page: page,
perPage: pageSize,
state: filters["state"] as ListCertificateRequest["state"],
});
},
{
@ -229,6 +249,16 @@ const CertificateList = () => {
}
);
const handleSearch = (value: string) => {
setFilters((prev) => ({ ...prev, keyword: value.trim() }));
};
const handleReloadClick = () => {
if (loading) return;
refreshData();
};
const handleDeleteClick = (certificate: CertificateModel) => {
modalApi.confirm({
title: t("certificate.action.delete"),
@ -255,30 +285,43 @@ const CertificateList = () => {
<PageHeader title={t("certificate.page.title")} />
<Table<CertificateModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("certificate.nodata"))} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
<Card size="small">
<div className="mb-4">
<Flex gap="small">
<div className="flex-1">
<Input.Search allowClear defaultValue={filters["keyword"] as string} placeholder={t("certificate.search.placeholder")} onSearch={handleSearch} />
</div>
<div>
<Button icon={<ReloadOutlinedIcon spin={loading} />} onClick={handleReloadClick} />
</div>
</Flex>
</div>
<Table<CertificateModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("certificate.nodata"))} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</Card>
</div>
);
};

View File

@ -8,6 +8,7 @@ import {
DeleteOutlined as DeleteOutlinedIcon,
EditOutlined as EditOutlinedIcon,
PlusOutlined as PlusOutlinedIcon,
ReloadOutlined as ReloadOutlinedIcon,
StopOutlined as StopOutlinedIcon,
SyncOutlined as SyncOutlinedIcon,
} from "@ant-design/icons";
@ -16,8 +17,11 @@ import { PageHeader } from "@ant-design/pro-components";
import { useRequest } from "ahooks";
import {
Button,
Card,
Divider,
Empty,
Flex,
Input,
Menu,
type MenuProps,
Modal,
@ -235,6 +239,7 @@ const WorkflowList = () => {
const [filters, setFilters] = useState<Record<string, unknown>>(() => {
return {
keyword: searchParams.get("keyword"),
state: searchParams.get("state"),
};
});
@ -249,9 +254,10 @@ const WorkflowList = () => {
} = useRequest(
() => {
return listWorkflow({
keyword: filters["keyword"] as string,
enabled: (filters["state"] as string) === "enabled" ? true : (filters["state"] as string) === "disabled" ? false : undefined,
page: page,
perPage: pageSize,
enabled: (filters["state"] as string) === "enabled" ? true : (filters["state"] as string) === "disabled" ? false : undefined,
});
},
{
@ -273,10 +279,20 @@ const WorkflowList = () => {
}
);
const handleSearch = (value: string) => {
setFilters((prev) => ({ ...prev, keyword: value.trim() }));
};
const handleCreateClick = () => {
navigate("/workflows/new");
};
const handleReloadClick = () => {
if (loading) return;
refreshData();
};
const handleEnabledChange = async (workflow: WorkflowModel) => {
try {
if (!workflow.enabled && (!workflow.content || !isAllNodesValidated(workflow.content))) {
@ -345,30 +361,43 @@ const WorkflowList = () => {
]}
/>
<Table<WorkflowModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("workflow.nodata"))} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
<Card size="small">
<div className="mb-4">
<Flex gap="small">
<div className="flex-1">
<Input.Search allowClear defaultValue={filters["keyword"] as string} placeholder={t("workflow.search.placeholder")} onSearch={handleSearch} />
</div>
<div>
<Button icon={<ReloadOutlinedIcon spin={loading} />} onClick={handleReloadClick} />
</div>
</Flex>
</div>
<Table<WorkflowModel>
columns={tableColumns}
dataSource={tableData}
loading={loading}
locale={{
emptyText: <Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description={getErrMsg(loadedError ?? t("workflow.nodata"))} />,
}}
pagination={{
current: page,
pageSize: pageSize,
total: tableTotal,
showSizeChanger: true,
onChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
onShowSizeChange: (page: number, pageSize: number) => {
setPage(page);
setPageSize(pageSize);
},
}}
rowKey={(record) => record.id}
scroll={{ x: "max(100%, 960px)" }}
/>
</Card>
</div>
);
};

View File

@ -4,11 +4,16 @@ import { type AccessModel } from "@/domain/access";
import { COLLECTION_NAME_ACCESS, getPocketBase } from "./_pocketbase";
export const list = async () => {
return await getPocketBase().collection(COLLECTION_NAME_ACCESS).getFullList<AccessModel>({
const list = await getPocketBase().collection(COLLECTION_NAME_ACCESS).getFullList<AccessModel>({
batch: 65535,
filter: "deleted=null",
sort: "-created",
requestKey: null,
});
return {
totalItems: list.length,
items: list,
};
};
export const save = async (record: MaybeModelRecord<AccessModel>) => {

View File

@ -1,55 +1,51 @@
import dayjs from "dayjs";
import { type RecordListOptions } from "pocketbase";
import { type CertificateModel } from "@/domain/certificate";
import { COLLECTION_NAME_CERTIFICATE, getPocketBase } from "./_pocketbase";
export type ListCertificateRequest = {
keyword?: string;
state?: "expireSoon" | "expired";
page?: number;
perPage?: number;
state?: "expireSoon" | "expired";
};
export const list = async (request: ListCertificateRequest) => {
const pb = getPocketBase();
const page = request.page || 1;
const perPage = request.perPage || 10;
const options: RecordListOptions = {
expand: "workflowId",
filter: "deleted=null",
sort: "-created",
requestKey: null,
};
const filters: string[] = ["deleted=null"];
if (request.keyword) {
filters.push(pb.filter("(subjectAltNames~{:keyword} || serialNumber={:keyword})", { keyword: request.keyword }));
}
if (request.state === "expireSoon") {
options.filter = pb.filter("expireAt<{:expiredAt} && deleted=null", {
expiredAt: dayjs().add(20, "d").toDate(),
});
filters.push(pb.filter("expireAt<{:expiredAt}", { expiredAt: dayjs().add(20, "d").toDate() }));
} else if (request.state === "expired") {
options.filter = pb.filter("expireAt<={:expiredAt} && deleted=null", {
expiredAt: new Date(),
});
filters.push(pb.filter("expireAt<={:expiredAt}", { expiredAt: new Date() }));
}
return pb.collection(COLLECTION_NAME_CERTIFICATE).getList<CertificateModel>(page, perPage, options);
const page = request.page || 1;
const perPage = request.perPage || 10;
return pb.collection(COLLECTION_NAME_CERTIFICATE).getList<CertificateModel>(page, perPage, {
expand: "workflowId",
filter: filters.join(" && "),
sort: "-created",
requestKey: null,
});
};
export const listByWorkflowRunId = async (workflowRunId: string) => {
const pb = getPocketBase();
const options: RecordListOptions = {
filter: pb.filter("workflowRunId={:workflowRunId}", {
workflowRunId: workflowRunId,
}),
const list = await pb.collection(COLLECTION_NAME_CERTIFICATE).getFullList<CertificateModel>({
batch: 65535,
filter: pb.filter("workflowRunId={:workflowRunId}", { workflowRunId: workflowRunId }),
sort: "-created",
requestKey: null,
};
const items = await pb.collection(COLLECTION_NAME_CERTIFICATE).getFullList<CertificateModel>(options);
});
return {
totalItems: items.length,
items: items,
totalItems: list.length,
items: list,
};
};

View File

@ -1,30 +1,33 @@
import { type RecordListOptions, type RecordSubscription } from "pocketbase";
import { type RecordSubscription } from "pocketbase";
import { type WorkflowModel } from "@/domain/workflow";
import { COLLECTION_NAME_WORKFLOW, getPocketBase } from "./_pocketbase";
export type ListWorkflowRequest = {
keyword?: string;
enabled?: boolean;
page?: number;
perPage?: number;
enabled?: boolean;
};
export const list = async (request: ListWorkflowRequest) => {
const pb = getPocketBase();
const page = request.page || 1;
const perPage = request.perPage || 10;
const options: RecordListOptions = {
sort: "-created",
requestKey: null,
};
const filters: string[] = [];
if (request.keyword) {
filters.push(pb.filter("name~{:keyword}", { keyword: request.keyword }));
}
if (request.enabled != null) {
options.filter = pb.filter("enabled={:enabled}", { enabled: request.enabled });
filters.push(pb.filter("enabled={:enabled}", { enabled: request.enabled }));
}
return await pb.collection(COLLECTION_NAME_WORKFLOW).getList<WorkflowModel>(page, perPage, options);
const page = request.page || 1;
const perPage = request.perPage || 10;
return await pb.collection(COLLECTION_NAME_WORKFLOW).getList<WorkflowModel>(page, perPage, {
filter: filters.join(" && "),
sort: "-created",
requestKey: null,
});
};
export const get = async (id: string) => {

View File

@ -12,24 +12,21 @@ export type ListWorkflowRunsRequest = {
};
export const list = async (request: ListWorkflowRunsRequest) => {
const page = request.page || 1;
const perPage = request.perPage || 10;
const pb = getPocketBase();
let filter = "";
const params: Record<string, string> = {};
const filters: string[] = [];
if (request.workflowId) {
filter = `workflowId={:workflowId}`;
params.workflowId = request.workflowId;
filters.push(pb.filter("workflowId={:workflowId}", { workflowId: request.workflowId }));
}
return await getPocketBase()
.collection(COLLECTION_NAME_WORKFLOW_RUN)
.getList<WorkflowRunModel>(page, perPage, {
filter: getPocketBase().filter(filter, params),
sort: "-created",
requestKey: null,
expand: request.expand ? "workflowId" : undefined,
});
const page = request.page || 1;
const perPage = request.perPage || 10;
return await pb.collection(COLLECTION_NAME_WORKFLOW_RUN).getList<WorkflowRunModel>(page, perPage, {
filter: filters.join(" && "),
sort: "-created",
requestKey: null,
expand: request.expand ? "workflowId" : undefined,
});
};
export const remove = async (record: MaybeModelRecordWithId<WorkflowRunModel>) => {

View File

@ -24,7 +24,7 @@ export const useAccessesStore = create<AccessesState>((set) => {
loadedAtOnce: false,
fetchAccesses: async () => {
fetcher ??= listAccess();
fetcher ??= listAccess().then((res) => res.items);
try {
set({ loading: true });