资讯动态

基于 Refine、Strapi 与 Ant Design 构建可定制发票生成器(Part II):Mission 管理、关联数据填充与 PDF 发票

发布时间:2026/9/9 15:13:21 来源:尧图企业网站定制
基于 Refine、Strapi 与 Ant Design 构建可定制发票生成器Part IIMission 管理、关联数据填充与 PDF 发票【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine本篇是 Refine Invoice Generator 实战系列的第二部分在已有 Company / Contact / Client 资源的基础上新增Missions与Invoices两个 Strapi 集合并在 Refine 前端中以「列表 弹窗表单 创建页 PDF 预览」的完整链路把它们落地成可用的发票功能。读完本文你将掌握useTable、useModalForm、useSelect、useForm等 Refine 核心 Hook 的组合用法理解 strapi-v4 数据提供者中meta.populate对关系数据的填充机制并能用react-pdf/renderer把业务数据渲染成可视化 PDF 发票。文中所有代码均可在仓库 examples/blog-invoice-generator 中找到可运行版本。说明原博文撰写于 Refine 3.x 时期仓库中的配套示例代码已升级至 4.x示例依赖见 package.jsonrefinedev/core为 ^4.56.0两者 API 向后兼容。本文的代码以仓库内实际源码为准。整体目标从能管数据到能开票Part I 已经搭建好客户Clients、联系人Contacts与公司Companies三个基础资源。本部分要在此基础上补齐发票业务的两个关键能力Mission任务 / 服务项管理把公司提供的产品与服务的「单价」和「工时」抽象为可复用数据它是发票行项目line item的来源Invoice发票管理与 PDF 输出把某家开票公司、某个客户联系人、若干 Mission 组合成一张发票并允许在浏览器中预览、下载为 PDF。最终效果是选定一个 Mission输入它持续的工作日天数Day与每日费率Daily Rate发票的总金额会自动累加并反映到 PDF 上全程无需手算金额。创建 Strapi CollectionsMission 与 Invoice在 Strapi Admin 中新建两个 Collection Type字段设计如下。Mission集合用于描述一项可售卖的服务/任务字段名类型说明Mission即mission字段标题Text任务标题Mission_descriptionText任务描述DayNumber服务持续的工作日天数Daily_rateNumber每日费率Invoice集合发票主档通过关系字段串联既有资源字段名类型说明NameText发票名称DateDate开票日期CompanyRelation关联 Company开票方公司DiscountNumber折扣百分比TaxNumber税率百分比Custom_idText自定义发票编号ContactRelation关联 Contact收票方联系人MissionsRelation关联 Mission发票包含的服务项多对多这里的建模思路是Mission 是「计价单元」天数 × 日费率Invoice 聚合若干个 Mission同时通过 Company / Contact 关系确定买卖双方。折扣与税额以百分比形式存放供前端计算最终应付金额。Refine Missions 页面用 useTable 快速落表Missions 列表页用 antdTable组件承载数据数据获取交给 refine-antd 包的useTableIMission()。useTable内部会按当前资源名自动发请求这里是missions并把分页、排序、加载状态等以tableProps形式展开到Table上。字段定义、Total实时计算列以及Actions编辑列的完整实现如下源码见 list.tsximport { List, useTable, TagField, useModalForm, EditButton, } from refinedev/antd; import { Table } from antd; import type { IMission } from interfaces; import { CreateMission, EditMission } from /components/mission; export const MissionList: React.FC () { const { tableProps } useTableIMission(); const { formProps, modalProps, show } useModalForm({ resource: missions, action: create, }); const { formProps: editFormProps, modalProps: editModalProps, show: editShow, } useModalForm({ resource: missions, action: edit, }); return ( List createButtonProps{{ onClick: () { show(); }, }} Table {...tableProps} Table.Column dataIndexid titleID / Table.Column dataIndexmission titleMission / Table.Column dataIndexmission_description titleMission Description / Table.Column dataIndexday titleDay(s) / Table.Column dataIndexdaily_rate titleDaily Rate render{(value) TagField value{value} colorred /} / Table.ColumnIMission titleTotal render{(_, record) { return ( TagField value{${record.daily_rate * record.day} $} colorgreen / ); }} / Table.ColumnIMission titleActions dataIndexactions keyactions render{(_value, record) ( EditButton hideText sizesmall recordItemId{record.id} onClick{() editShow(record.id)} / )} / /Table /List CreateMission modalProps{modalProps} formProps{formProps} / EditMission modalProps{editModalProps} formProps{editFormProps} / / ); };值得注意的细节Total 列不需要后端字段通过render{(_, record) ...}拿到整行record用record.daily_rate * record.day即时算出小计并以绿色TagField展示再次呼应了「天数 × 日费率」的自动计价模型。编辑复用useModalForm不同于文档中只演示了action: create仓库源码同时声明了action: edit的第二个实例配合EditMission弹窗实现编辑EditButton通过recordItemId与editShow(record.id)触发回填。从源码结构看useModalForm会自动完成点击编辑/新建按钮 → 打开发送窗口 → 按需拉取记录详情或准备空表单 → 提交时调用对应 resource 的 create/update 数据方法。Mission 的创建 / 编辑弹窗纯 UI 组件的解耦写法弹窗被拆成独立组件只接收modalProps与formProps不关心数据逻辑来自哪里——这是把 UI 与数据 Hook 解耦的常见模式。源码见 create.tsximport { Form, Input, type ModalProps, type FormProps, Modal, InputNumber, } from antd; type CreateMissionProps { modalProps: ModalProps; formProps: FormProps; }; export const CreateMission: React.FCCreateMissionProps ({ modalProps, formProps, }) { return ( Modal {...modalProps} titleCreate Contact Form {...formProps} layoutvertical Form.Item labelTitle namemission rules{[ { required: true, }, ]} Input / /Form.Item Form.Item labelDescription namemission_description Input / /Form.Item Form.Item labelDay(s) nameday InputNumber defaultValue{1} / /Form.Item Form.Item labelDaily Rate namedaily_rate InputNumber defaultValue{1} / /Form.Item /Form /Modal ); };同名组件目录下还有 edit.tsx通过components/mission/index.ts统一对外导出组件入口。页面容器中的formProps/modalProps全部来自页面顶部的useModalForm表单字段通过 antdForm.Item的name与 Strapi 集合字段一一对应提交后数据会自动写入missions集合。Invoices 列表页关系数据与 meta.populate 填充Invoice 集合与 Company、Contact、Missions 存在关系。得益于 refinedev/strapi-v4 数据提供者对关系数据的支持列表页无需手工拼接多次请求只需要在useTable的meta里声明要填充的关系即可源码见 invoice/list.tsxconst { tableProps } useTableIInvoice({ meta: { populate: { contact: { populate: [client] }, company: { populate: [logo] }, missions: *, }, }, });为什么必须写populate打开 strapi-v4 数据提供者的实现packages/strapi-v4/src/dataProvider.ts可以看到在getList、getOne、create等请求路径上都会执行类似const populate meta?.populate的取值并把结果拼进 Strapi 查询串。也就是说meta.populate是透传给 Strapi REST 接口的填充指令contact: { populate: [client] }填充发票的 Contact并继续穿透到该联系人所属的 Clientcompany: { populate: [logo] }填充 Company 及其 Logo 媒体字段PDF 中展示公司 Logo 需要它missions: *填充全部关联 Mission 记录。若省略 populate这些关系字段在响应中将只有id占位列表无法渲染出公司名、联系人邮箱与 Mission 标签PDF 也无法取到 Logo 与计价明细。列表页随后用多张 Table 列把这些关系渲染出来完整代码与真实示例一致Table {...tableProps} Table.Column dataIndexid titleID / Table.ColumnIInvoice dataIndexname titleInvoice Name render{(_, record) { return Invoice_#${record.id}${record.name}; }} / Table.ColumnIInvoice dataIndexdate titleInvoice Date render{(value) DateField formatLL value{value} /} / Table.Column dataIndex{[company, name]} titleCompany / Table.Column dataIndex{missions} titleMissions render{(value) { return value.map((item: IMission) { return ( TagField key{item.id} colorblue value{item.mission} / ); }); }} / Table.Column dataIndexdiscount titleDiscount(%) render{(value) TagField colorblue value{value} /} / Table.Column dataIndextax titleTax(%) render{(value) TagField colorcyan value{value} /} / Table.Column dataIndexcustom_id titleCustom Invoice ID / Table.Column dataIndex{[contact, email]} titleContact render{(value) EmailField value{value} /} / {/* Actions 列Edit / Delete / PDF 预览按钮 */} /Table各列的取数技巧值得记录嵌套取值用路径数组dataIndex{[company, name]}与dataIndex{[contact, email]}直接命中嵌套对象避免手写 render格式化展示用语义字段DateField负责日期本地化格式formatLLEmailField让邮箱可点击一对多关系用 render 遍历missions字段是数组遍历后为每个任务渲染蓝色 Tag发票名用 render 组合把 id 与 name 拼成Invoice_#idname风格的可读标题。创建发票useSelect 填充下拉 Create 页面表单新建发票时用户要从存量数据中选择「开票公司、收票联系人、多个 Mission」。Refine 用useSelect一行声明即可生成适配 antdSelect的selectProps源码见 invoice/create.tsxconst { formProps, saveButtonProps } useFormIInvoice(); const { selectProps: companySelectProps } useSelectICompany({ resource: companies, optionLabel: name, pagination: { mode: server }, }); const { selectProps: contactSelectProps } useSelectIContact({ resource: contacts, optionLabel: first_name, pagination: { mode: server }, }); const { selectProps: missionSelectProps } useSelectIMission({ resource: missions, optionLabel: mission, pagination: { mode: server }, });optionLabel指定了「用什么字段作为下拉文案」Company 用name、Contact 用first_name、Mission 用mission。仓库版本中还显式启用了pagination: { mode: server }让下拉数据走服务端分页避免一次性拉全量。表单主体用Create布局组件包裹自动生成顶部保存按钮与saveButtonProps绑定Create saveButtonProps{saveButtonProps} Form {...formProps} layoutvertical Form.Item labelInvoice Name namename Input / /Form.Item Form.Item labelCompany namecompany rules{[{ required: true }]} Select {...companySelectProps} / /Form.Item Form.Item labelMissions namemissions rules{[{ required: true }]} Select {...missionSelectProps} modemultiple / /Form.Item Form.Item labelDiscount(%) namediscount Input / /Form.Item Form.Item labelTax(%) nametax Input / /Form.Item Form.Item labelCustom ID namecustom_id Input / /Form.Item Form.Item labelContact namecontact rules{[{ required: true }]} Select {...contactSelectProps} / /Form.Item Form.Item labelInvoice Date namedate DatePicker style{{ width: 50% }} / /Form.Item /Form /Create要点下拉选中值直接写入关系字段company/contact/missions多选时是数组Strapi 序列化后即为关联 IDCompany、Missions、Contact 被标记为必填required保证一张合法发票必须有开票方、内容与收票方关联数据与类型定义可对照 interfaces/index.d.tsIInvoice由ICompany、IContact、IMission[]组成且带discount/tax/custom_id/date字段这与 Strapi 集合字段一一对应。至此发票可在「Missions 页维护服务项 → Invoices 页组合开票」的闭环中产生剩下的最后一步是把发票变成一份专业样式的 PDF。用 react-pdf/renderer 预览并下载发票 PDF安装PDF 渲染选用react-pdf/renderer它用 React 组件描述 PDF 文档树。按 package.json 中锁定的大版本安装npm i react-pdf/renderer设计 PDF 布局组件先建一个接收record: IInvoice | undefined的布局组件源码见 pdf/pdfLayout.tsx。组件内部先用reduce把若干 Mission 的小计加总为subtotalexport const PdfLayout: React.FCPdfProps ({ record }) { const subtotal record?.missions.reduce((prev, cur) { return prev cur.day * cur.daily_rate; }, 0) ?? 0; // ... };随后返回PDFViewer页面内嵌预览包着DocumentPage sizeA4的文档树。整张 A4 发票自上而下分为四块内容1) 抬头区Logo 发票标题公司 Logo 取自已填充的company.logo.url与API_URL示例中为https://api.strapi-invoice.refine.dev见 constants.ts拼接得到完整媒体地址右侧并列展示Invoice_#idname与INVOICE_#idView Image src{API_URL record?.company?.logo?.url} style{{ width: 120px, height: auto }} / View style{styles.inoviceTextNumberContainer} Text style{styles.inoviceText} {Invoice: Invoice_#${record?.id}${record?.name}} /Text Text style{styles.inoviceId} {Invoice ID: INVOICE_#${record?.id}} /Text /View /View View style{styles.dividerLG} /2) 双方信息区Invoice For 与 From左侧invoice For展示收票方Client 名、联系人姓名、邮箱右侧From展示开票公司名称、城市、地址、国家下方附公司地址联系方式与custom_id、开票日期View style{styles.invoiceFor} Text style{styles.invoiceForFromTitle}Inovice For:/Text {/* record.contact.client.name / first_name / last_name / email */} /View View style{styles.invoiceFrom} Text style{styles.invoiceForFromTitle}From:/Text {/* record.company.name / city / address / country */} View style{styles.dividerSM} / {/* Invoice ID / Invoice Custom ID / Invoice Date */} /View3) 行项目明细表表头四列Mission 40%、Day 20%、Day Rate 20%、Total 20%然后遍历record.missions输出行day * daily_rate得出每行金额{record?.missions.map((item) { return ( View key{item.id} style{styles.tableRow} Text style{[styles.tableCol, { width: 40% }]}{item.mission}/Text Text style{[styles.tableCol, { width: 20% }]}{item.day}/Text Text style{[styles.tableCol, { width: 20% }]}{item.daily_rate}/Text Text style{[styles.tableCol, { width: 20% }]} {item.daily_rate * item.day} /Text /View ); })}4) 签名与金额汇总区左侧为签名字段与日期右侧依次输出 SUBTOTAL、Discount(%)、Tax(%)并按subtotal subtotal * tax / 100 - subtotal * discount / 100得出含税折后的最终应付金额Text style{styles.totalText}SUBTOTAL: {subtotal}/Text Text style{styles.totalText}Discount(%): {record?.discount}/Text Text style{styles.totalText}Tax(%): {record?.tax}/Text Text style{styles.totalText} Total($): {subtotal (subtotal * (record?.tax as number)) / 100 - (subtotal * (record?.discount as number)) / 100} /Text整份文档的排版统一由react-pdf/renderer的StyleSheet.create声明A4 页面留 0.4in 内边距、行项目使用带边框的表格、细分隔线与页脚采用#e5e5e5浅灰线、正文灰色#787878/ 主标题#3aabf0蓝色强调。由于react-pdf/renderer支持 flex 布局双栏的双方信息区与金额汇总区都可用flexDirection: rowjustifyContent: space-between实现与 React Native 的样式心智一致。在列表页挂载 PDF 预览回到 Invoices 列表页需要三件事记录当前行record、控制弹窗显隐、渲染Modal包裹的PdfLayout。弹窗显隐用refinedev/core提供的useModal返回show/visible/close不需要自己管理 stateconst [record, setRecord] useStateIInvoice(); const { show, visible, close } useModal(); // Actions 列的渲染 Space EditButton hideText sizesmall recordItemId{record.id} / DeleteButton hideText sizesmall recordItemId{record.id} / {record.company ( Button sizesmall icon{FilePdfOutlined /} onClick{() { setRecord(record); show(); }} / )} /Space页面底部挂载弹窗Modal visible{visible} onCancel{close} width80% footer{null} PdfLayout record{record} / /Modal点击行内 PDF 图标时先setRecord(record)暂存该行数据再show()打开弹窗PdfLayout即收到完整的、已 populate 的发票记录渲染出与上文一致的 A4 版式。仓库源码在 PDF 按钮外包了一层record.company 条件判断——只有开票公司信息完整时才允许预览避免渲染半成品数据见 invoice/list.tsx。关于下载PDFViewer在浏览器内嵌一个 PDF 预览面板用户可借助浏览器 PDF 查看器的打印/另存功能将发票保存为 PDF若需独立下载按钮react-pdf/renderer还提供PDFDownloadLink可在组件树中为Document声明一个可点击下载的包装本项目按需选用即可。资源注册与运行整个示例资源如何串联在 App.tsx 中五个资源被注册进Refine的resources数组并为每个资源指定路由与侧边栏图标companies→ 列表/companiesclients→ 列表/clientscontacts→ 列表/contacts、编辑/contacts/edit/:idmissions→ 列表/missionsinvoices→ 列表/invoices、创建/invoices/create、编辑/invoices/edit/:id数据提供者在此处初始化指向 Strapi 服务端import { DataProvider } from refinedev/strapi-v4; const dataProvider DataProvider(${API_URL}/api, axiosInstance);其中axiosInstance来自 authProvider.ts登录成功后会附带 JWT 鉴权头本地存储键strapi-jwt-token保证后续请求能访问受保护集合。本地运行与演示账号该示例是一个独立 Vite React 19 应用脚本见 package.jsondev/build/start均由refineCLI 驱动最简单的拉取方式是使用脚手架npm create refine-applatest -- --example blog-invoice-generator随后在项目目录执行npm run dev启动并使用 README 与博文中给出的演示凭据登录见 README.md用户名邮箱demorefine.dev密码demodemo登录后先进入默认首页NavigateToResource会引导到companies通过侧边栏可依次维护 Company / Client / Contact再进入 Missions 添加服务项最后在 Invoices 新建并预览带完整明细的 PDF 发票。结语至此一个可投入使用的发票生成器已经成型Mission 负责把「工时 × 日费率」固化为计价单元Invoice 通过关系字段与meta.populate聚合买卖双方与行项目列表页与弹窗表单借助useTable/useModalForm/useSelect/useForm几乎不需要手写数据请求逻辑而react-pdf/renderer用一套声明式组件把同源数据渲染成版式专业、金额自动核算的 PDF。本部分所依赖的核心模式在 Refine 中具有普遍复用性任何「主档 子表 关系字段 文档导出」类的内部工具报价单、工单、合同、对账单都可以照此套路快速落地。若想继续扩展还可以基于 Part I 与本文积累的资源加入发票状态管理、自动发送邮件、或引入 Next.js 进行 SSR 化改造——Refine 的资源抽象与数据提供者机制并不绑定特定路由或服务端方案。【免费下载链接】refineA React Framework for building internal tools, admin panels, dashboards B2B apps with unmatched flexibility.项目地址: https://gitcode.com/GitHub_Trending/re/refine创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价