diff --git a/.trae/skills/fast-crud-page-dev/SKILL.md b/.trae/skills/fast-crud-page-dev/SKILL.md index 43b7bbed4..81c96e0f9 100644 --- a/.trae/skills/fast-crud-page-dev/SKILL.md +++ b/.trae/skills/fast-crud-page-dev/SKILL.md @@ -77,6 +77,84 @@ container:{}, //容器配置 ,对应fs-container - 有固定操作栏、统计区、说明区时,这些区域应 `flex: none`,把剩余空间交给表格区域。 - 修改嵌入式 Fast Crud 页面后,要检查空数据、少量数据和多页数据时表格高度、分页器和空状态是否仍在预期区域内。 +## 列表导出 + +- 列表需要导出时,优先使用 Fast Crud 工具栏导出能力,不要另写一套导出按钮或后端接口,除非数据必须跨权限、跨分页或异步生成文件。 +- 导出当前搜索条件下的数据时,在 `toolbar.export` 中设置 `dataFrom: "search"`,并显式打开导出按钮。 +- 导出列必须输出 Excel 可读的纯文本或数字;不要直接导出对象、数组、VNode、进度条组件、开关组件、时间戳毫秒值等。 +- 有隐藏但业务上需要导出的字段时,把字段定义为普通列并设置 `column.show: false`,再在 `columnFilter` 中对该字段返回 `true`。例如证书域名这类只用于导出的辅助列。 +- 嵌套字段可以使用 `lastVars.certDomains` 这类 key;导出格式化时用安全取值函数读取嵌套值。 +- `dataFormatter` 中统一格式化特殊字段:时间字段转 `YYYY-MM-DD HH:mm:ss`,日期类有效期转业务文案或 `YYYY-MM-DD`,枚举/开关转字典 label,数组转逗号分隔字符串,对象转明确的业务摘要。 + +```typescript +import { ColumnProps, DataFormatterContext } from "@fast-crud/fast-crud"; +import dayjs from "dayjs"; + +function getRecordValue(row: any, key: string) { + return key.split(".").reduce((target, item) => target?.[item], row); +} + +function formatListValue(value: any) { + if (Array.isArray(value)) { + return value.join(","); + } + return value ?? ""; +} + +function exportColumnFilter(col: ColumnProps) { + if (!col.key || ["_index", "_selection", "rowHandle"].includes(col.key)) { + return false; + } + if (col.key === "lastVars.certDomains") { + return true; + } + return col.show !== false; +} + +function exportDataFormatter(opts: DataFormatterContext) { + const { row, originalRow, col, exportCol } = opts; + const key = col.key; + const value = getRecordValue(originalRow, key); + + if (key === "lastVars.certDomains") { + row[key] = formatListValue(value); + } else if (key.includes("Time") && value) { + row[key] = dayjs(value).format("YYYY-MM-DD HH:mm:ss"); + } + + if (col.width) { + exportCol.width = col.width / 10; + } +} + +return { + crudOptions: { + toolbar: { + buttons: { + export: { show: true }, + }, + export: { + dataFrom: "search", + columnFilter: exportColumnFilter, + dataFormatter: exportDataFormatter, + }, + }, + columns: { + "lastVars.certDomains": { + title: "证书域名", + type: "text", + column: { + show: false, + width: 260, + ellipsis: true, + }, + form: { show: false }, + }, + }, + }, +}; +``` + ## 内置 CRUD 按钮 只要在 `request` 中配置了 `addRequest`、`editRequest`、`delRequest`,Fast Crud 会自动在 `rowHandle` 渲染新增、编辑、删除按钮并完成对应操作,**不需要手写 `openDeleteConfirm`、`openEditDialog` 等方法**。 diff --git a/packages/ui/certd-client/src/locales/langs/en-US/certd/common.ts b/packages/ui/certd-client/src/locales/langs/en-US/certd/common.ts index 39c78dac4..8b5aa3fdd 100644 --- a/packages/ui/certd-client/src/locales/langs/en-US/certd/common.ts +++ b/packages/ui/certd-client/src/locales/langs/en-US/certd/common.ts @@ -82,6 +82,7 @@ export default { pipelineContent: "Pipeline Content", scheduledTaskCount: "Scheduled Task Count", deployTaskCount: "Deployment Task Count", + certDomains: "Certificate Domains", remainingValidity: "Remaining Validity", effectiveTime: "Effective time", expiryTime: "Expiry Time", diff --git a/packages/ui/certd-client/src/locales/langs/en-US/certd/pipeline.ts b/packages/ui/certd-client/src/locales/langs/en-US/certd/pipeline.ts index 90d30c820..df7b9aa88 100644 --- a/packages/ui/certd-client/src/locales/langs/en-US/certd/pipeline.ts +++ b/packages/ui/certd-client/src/locales/langs/en-US/certd/pipeline.ts @@ -41,6 +41,7 @@ export default { pi: { validTime: "Piepline Valid Time", validTimeHelper: "Not filled in means permanent validity", + permanentValid: "Permanent", }, types: { certApply: "Cert Apply", diff --git a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/common.ts b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/common.ts index c68b5d3fb..0569139c1 100644 --- a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/common.ts +++ b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/common.ts @@ -86,6 +86,7 @@ export default { pipelineContent: "流水线内容", scheduledTaskCount: "定时任务数", deployTaskCount: "部署任务数", + certDomains: "证书域名", remainingValidity: "到期剩余", effectiveTime: "生效时间", expiryTime: "过期时间", diff --git a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/pipeline.ts b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/pipeline.ts index cb27a9976..d70654a86 100644 --- a/packages/ui/certd-client/src/locales/langs/zh-CN/certd/pipeline.ts +++ b/packages/ui/certd-client/src/locales/langs/zh-CN/certd/pipeline.ts @@ -41,6 +41,7 @@ export default { pi: { validTime: "流水线有效期", validTimeHelper: "不填则为永久有效", + permanentValid: "永久有效", }, types: { certApply: "证书申请", diff --git a/packages/ui/certd-client/src/views/certd/pipeline/crud.tsx b/packages/ui/certd-client/src/views/certd/pipeline/crud.tsx index 70fb8dc5a..6377a13d0 100644 --- a/packages/ui/certd-client/src/views/certd/pipeline/crud.tsx +++ b/packages/ui/certd-client/src/views/certd/pipeline/crud.tsx @@ -1,4 +1,4 @@ -import { AddReq, CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, EditReq, UserPageQuery, UserPageRes, useUi } from "@fast-crud/fast-crud"; +import { AddReq, ColumnProps, CreateCrudOptionsProps, CreateCrudOptionsRet, DataFormatterContext, DelReq, dict, EditReq, UserPageQuery, UserPageRes, useUi } from "@fast-crud/fast-crud"; import { Modal, notification } from "ant-design-vue"; import dayjs from "dayjs"; import { computed, ref } from "vue"; @@ -75,6 +75,17 @@ export default function ({ crudExpose, context: { selectedRowKeys, openCertApply const projectStore = useProjectStore(); const { myProjectDict } = useDicts(); const DEFAULT_WILL_EXPIRE_DAYS = settingStore.sysPublic.defaultWillExpireDays || settingStore.sysPublic.defaultCertRenewDays || 15; + const pipelineTypeDictData = [ + { value: "cert", label: t("certd.types.certApply") }, + { value: "cert_upload", label: t("certd.types.certUpload") }, + { value: "custom", label: t("certd.types.custom") }, + { value: "template", label: t("certd.types.template") }, + { value: "cert_auto", label: t("certd.types.certApply") }, + ]; + const disabledDictData = [ + { value: false, label: t("certd.fields.enabledLabel") }, + { value: true, label: t("certd.fields.disabledLabel") }, + ]; function onDialogOpen(opt: any) { const searchForm = crudExpose.getSearchValidatedFormData(); @@ -84,6 +95,79 @@ export default function ({ crudExpose, context: { selectedRowKeys, openCertApply }; } + function getRecordValue(row: any, key: string) { + return key.split(".").reduce((target, item) => target?.[item], row); + } + + function findDictLabel(data: any[], value: any) { + return data.find(item => item.value === value)?.label ?? value; + } + + function formatValidTime(value: any) { + if (!value || value <= 0) { + return t("certd.pi.permanentValid"); + } + if (value < Date.now()) { + return t("certd.hasExpired"); + } + return dayjs(value).format("YYYY-MM-DD"); + } + + function formatRemainingValidity(lastVars: any) { + const expiresTime = lastVars?.certExpiresTime; + if (!expiresTime) { + return "-"; + } + const leftDays = dayjs(expiresTime).diff(dayjs(), "day"); + if (leftDays < 0) { + return t("certd.hasExpired"); + } + return `${leftDays}${t("certd.days")}`; + } + + function formatListValue(value: any) { + if (Array.isArray(value)) { + return value.join(","); + } + return value ?? ""; + } + + function exportColumnFilter(col: ColumnProps) { + if (!col.key || ["_index", "_selection", "rowHandle"].includes(col.key)) { + return false; + } + if (col.key === "lastVars.certDomains") { + return true; + } + return col.show !== false; + } + + function exportDataFormatter(opts: DataFormatterContext) { + const { row, originalRow, col, exportCol } = opts; + const key = col.key; + const value = getRecordValue(originalRow, key); + + if (key === "validTime") { + row[key] = formatValidTime(value); + } else if (key === "lastVars") { + row[key] = formatRemainingValidity(value); + } else if (key === "lastVars.certDomains") { + row[key] = formatListValue(value); + } else if (key === "status") { + row[key] = statusUtil.get(value)?.label ?? value; + } else if (key === "disabled") { + row[key] = findDictLabel(disabledDictData, value); + } else if (key === "type") { + row[key] = findDictLabel(pipelineTypeDictData, value); + } else if (key.includes("Time") && value) { + row[key] = dayjs(value).format("YYYY-MM-DD HH:mm:ss"); + } + + if (col.width) { + exportCol.width = col.width / 10; + } + } + return { crudOptions: { request: { @@ -178,6 +262,18 @@ export default function ({ crudExpose, context: { selectedRowKeys, openCertApply confirmMessage: t("certd.table.confirmDeleteMessage"), }, }, + toolbar: { + buttons: { + export: { + show: true, + }, + }, + export: { + dataFrom: "search", + columnFilter: exportColumnFilter, + dataFormatter: exportDataFormatter, + }, + }, tabs: { name: "groupId", show: true, @@ -419,6 +515,19 @@ export default function ({ crudExpose, context: { selectedRowKeys, openCertApply width: 150, }, }, + "lastVars.certDomains": { + title: t("certd.fields.certDomains"), + type: "text", + form: { + show: false, + }, + column: { + width: 260, + show: false, + ellipsis: true, + showTitle: true, + }, + }, "lastVars.certEffectiveTime": { title: t("certd.fields.effectiveTime"), search: { @@ -503,10 +612,7 @@ export default function ({ crudExpose, context: { selectedRowKeys, openCertApply }, }, dict: dict({ - data: [ - { value: false, label: t("certd.fields.enabledLabel") }, - { value: true, label: t("certd.fields.disabledLabel") }, - ], + data: disabledDictData, }), form: { value: false, @@ -563,13 +669,7 @@ export default function ({ crudExpose, context: { selectedRowKeys, openCertApply col: { span: 2 }, }, dict: dict({ - data: [ - { value: "cert", label: t("certd.types.certApply") }, - { value: "cert_upload", label: t("certd.types.certUpload") }, - { value: "custom", label: t("certd.types.custom") }, - { value: "template", label: t("certd.types.template") }, - { value: "cert_auto", label: t("certd.types.certApply") }, - ], + data: pipelineTypeDictData, }), form: { show: false, @@ -650,7 +750,7 @@ export default function ({ crudExpose, context: { selectedRowKeys, openCertApply align: "center", cellRender({ value }) { if (!value || value <= 0) { - return "-"; + return t("certd.pi.permanentValid"); } if (value < Date.now()) { return t("certd.hasExpired"); diff --git a/packages/ui/certd-client/src/views/sys/pipeline/crud.tsx b/packages/ui/certd-client/src/views/sys/pipeline/crud.tsx index 956f2bdc5..dba04a87e 100644 --- a/packages/ui/certd-client/src/views/sys/pipeline/crud.tsx +++ b/packages/ui/certd-client/src/views/sys/pipeline/crud.tsx @@ -1,5 +1,5 @@ import createCrudOptionsUser from "/@/views/sys/authority/user/crud"; -import { CreateCrudOptionsProps, CreateCrudOptionsRet, DelReq, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; +import { ColumnProps, CreateCrudOptionsProps, CreateCrudOptionsRet, DataFormatterContext, DelReq, dict, UserPageQuery, UserPageRes } from "@fast-crud/fast-crud"; import { message, Modal } from "ant-design-vue"; import dayjs from "dayjs"; import { ref } from "vue"; @@ -18,6 +18,77 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat }; const selectedRowKeys = ref([]); + const pipelineTypeDictData = [ + { value: "cert", label: "证书申请" }, + { value: "cert_upload", label: "证书上传" }, + { value: "custom", label: "自定义" }, + { value: "template", label: "模板" }, + { value: "cert_auto", label: "证书申请" }, + ]; + const disabledDictData = [ + { label: "启用", value: false, color: "green" }, + { label: "禁用", value: true, color: "red" }, + ]; + + function findDictLabel(data: any[], value: any) { + return data.find(item => item.value === value)?.label ?? value; + } + + function formatValidTime(value: any) { + if (!value || value <= 0) { + return "永久有效"; + } + if (value < Date.now()) { + return "已过期"; + } + return dayjs(value).format("YYYY-MM-DD"); + } + + function getRecordValue(row: any, key: string) { + return key.split(".").reduce((target, item) => target?.[item], row); + } + + function formatListValue(value: any) { + if (Array.isArray(value)) { + return value.join(","); + } + return value ?? ""; + } + + function exportColumnFilter(col: ColumnProps) { + if (!col.key || ["_index", "_selection", "rowHandle"].includes(col.key)) { + return false; + } + if (col.key === "lastVars.certDomains") { + return true; + } + return col.show !== false; + } + + function exportDataFormatter(opts: DataFormatterContext) { + const { row, originalRow, col, exportCol } = opts; + const key = col.key; + const value = getRecordValue(originalRow, key); + + if (key === "validTime") { + row[key] = formatValidTime(value); + } else if (key === "lastVars.certDomains") { + row[key] = formatListValue(value); + } else if (key === "status") { + row[key] = statusUtil.get(value)?.label ?? value; + } else if (key === "disabled") { + row[key] = findDictLabel(disabledDictData, value); + } else if (key === "type") { + row[key] = findDictLabel(pipelineTypeDictData, value); + } else if (key.includes("Time") && value) { + row[key] = dayjs(value).format("YYYY-MM-DD HH:mm:ss"); + } + + if (col.width) { + exportCol.width = col.width / 10; + } + } + const handleBatchDelete = () => { if (!selectedRowKeys.value?.length) { message.error("请先选择要删除的记录"); @@ -54,6 +125,8 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat }, export: { dataFrom: "search", + columnFilter: exportColumnFilter, + dataFormatter: exportDataFormatter, }, }, pagination: { @@ -185,13 +258,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat }, }, dict: dict({ - data: [ - { value: "cert", label: "证书申请" }, - { value: "cert_upload", label: "证书上传" }, - { value: "custom", label: "自定义" }, - { value: "template", label: "模板" }, - { value: "cert_auto", label: "证书申请" }, - ], + data: pipelineTypeDictData, }), column: { width: 110, @@ -236,10 +303,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat }, }, dict: dict({ - data: [ - { label: "启用", value: false, color: "green" }, - { label: "禁用", value: true, color: "red" }, - ], + data: disabledDictData, }), column: { width: 90, @@ -273,6 +337,18 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat show: false, }, }, + "lastVars.certDomains": { + title: "证书域名", + type: "text", + column: { + width: 260, + show: false, + ellipsis: true, + }, + form: { + show: false, + }, + }, lastHistoryTime: { title: "最后执行时间", type: "datetime", @@ -306,7 +382,7 @@ export default function ({ crudExpose, context }: CreateCrudOptionsProps): Creat align: "center", cellRender({ value }) { if (!value || value <= 0) { - return "-"; + return "永久有效"; } if (value < Date.now()) { return 已过期; diff --git a/packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/base-convert.ts b/packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/base-convert.ts index d2335c44c..a1990434a 100644 --- a/packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/base-convert.ts +++ b/packages/ui/certd-server/src/plugins/plugin-cert/plugin/cert-plugin/base-convert.ts @@ -102,6 +102,7 @@ export abstract class CertApplyBaseConvertPlugin extends AbstractTaskPlugin { this._result.pipelineVars.certEffectiveTime = dayjs(certReader.detail.notBefore).valueOf(); this._result.pipelineVars.certExpiresTime = dayjs(certReader.detail.notAfter).valueOf(); + this._result.pipelineVars.certDomains = certReader.getAllDomains(); if (!this._result.pipelinePrivateVars) { this._result.pipelinePrivateVars = {}; }