来先说一个我这半年遇见好几次的场景同事想装机问我预算八千怎么配明天又问那块显卡现在多少钱。我与其一遍遍翻电商和报价站不如自己写一个小工具。正好手头一直想练练 FastAPI于是就有了这个“FastAPI 原生js 写的一个 前后端 电脑组装报价指南”。项目不大就两个核心页面、三四个接口但前后端分离的流程、异步渲染、报价计算和打印输出全走了一遍。这篇文章我会把需求怎么拆、接口怎么设计、原生 JS 怎么实现、部署的时候有哪些坑一次性讲清楚。适合刚入门的后端同学也适合前端想看看接口对接完整流程的朋友。1. 项目从0到1需求拆解与技术选型1.1 装机报价的核心需求是什么咱先把“做一个报价工具”这句话拆开看。很多人以为做这种工具就是把配件价格列个表用户点一点页面显示总价就完了。实际跑起来远不止这些。我当时的真实需求有三层。第一层是“数据可得”常见 CPU、主板、内存、显卡、SSD、电源、机箱这些配件得有结构化数据包含品名、规格、价格、兼容性备注不能写死在前端模板里不然更新一个价格要改一遍 HTML维护成本太高。第二层是“能算、能校验”用户勾选一堆配件后后端要能算总价并且能提示一些明显不合理的组合比如 AMD 的 CPU 配 Intel 的主板DDR5 内存插到只支持 DDR4 的主板上。第三层是“能交付”前端要把最终报价单渲染成可阅读、可打印、可复制的形式而不是停留在控制台里打印一堆 JSON。这三层需求对应到技术上正好就是后端接口、计算逻辑、前端交互和输出排版。项目虽小但它把前后端分离项目该有的环节都覆盖了后端提供 REST API前端用异步请求拉数据渲染页面两者通过约定好的 JSON 结构通信。这也是我选择把它写成博客记录的原因它是个“麻雀虽小五脏俱全”的练手项目。1.2 为什么是 FastAPI 原生JS先说后端为什么选 FastAPI 而不是 Flask 或者 Django。最直接的原因是 FastAPI 基于 Python 类型注解和 Pydantic定义请求和响应模型相当省事。比如用户提交的是“配件ID 列表”你只需要写一个class QuoteRequest(BaseModel): part_ids: List[int]FastAPI 会自动做类型校验不符合要求直接返回 422不用自己写一坨if not isinstance(...)的判断。另一个原因是我需要它的自动接口文档启动服务后打开/docs就能看到所有接口的入参、出参和示例前后端联调时特别方便。前端选原生 JS也是有意的。说实话这种页面用 Vue 或 React 能写但在交互复杂度不高的情况下引入框架反而增加构建成本。原生 JS 配合 fetch、DOM 操作、事件监听就能把配件列表渲染、勾选联动、报价单输出全搞定。而且没有打包步骤前端就是一个静态文件夹开发时交给浏览器插件起个本地服务器部署时甚至可以和后端放到一起跑。对想增强“原生前端功夫”的人来说这比一上来就套框架更能理解浏览器和 HTTP 交互的本质。技术选型这东西没有绝对的好关键是你知道自己图什么。我图的是FastAPI 省后端样板代码原生 JS 省前端构建流程两者配合把精力集中在“报价逻辑怎么做才合理”这件事上。2. FastAPI 后端数据模型、接口设计与报价逻辑2.1 配件数据模型与报价返回结构后端第一步是把配件数据结构定义清楚。我用 Pydantic 的BaseModel来定义Part每个配件包含 id、分类、名称、规格、价格和备注。这里的 spec 字段很关键报价页面上要展示“B650M MORTAR / AM5 / DDR5 / MATX”这类细节用户一眼能看出这块主板支持什么平台。from pydantic import BaseModel from typing import Optional class Part(BaseModel): id: int category: str name: str spec: str price: float notes: str 这个模型是整个报价系统的基础。之后接口返回的配件列表其实就是List[Part]。而报价单返回结构我另外定义一个QuoteResult里面包含选中配件列表、总价、配件数量和兼容性提示列表。为什么单独定义返回模型而不是直接把列表和总价拼成字典因为 FastAPI 的response_model可以自动过滤多余字段还能生成清晰的接口文档对前端对接非常友好。class QuoteItem(BaseModel): category: str name: str spec: str price: float notes: str class QuoteResult(BaseModel): items: list[QuoteItem] total: float count: int warnings: list[str] []实际项目里配件数据我先放在内存列表里方便演示。正式做的话这部分可以换成 SQLite 或 MySQL但接口不需要改加一层数据访问模块就行。这就是数据模型和业务逻辑分离的好处。2.2 报价接口与计算逻辑接口设计我定了两个。一个是GET /api/parts返回全部配件前端初始化页面时用它渲染列表。另一个是POST /api/quote接收用户勾选的配件 ID 数组后端查配件、算总价、做兼容性校验返回报价单 JSON。用 POST 而不是 GET是因为提交的是一组结构化 IDPOST 的 body 能装更多数据语义也更贴合“创建一张报价单”的动作。from fastapi import FastAPI, HTTPException app FastAPI(titlePC Quote API) app.get(/api/parts, response_modellist[Part]) def get_parts(): return PARTS_DB app.post(/api/quote, response_modelQuoteResult) def create_quote(request: QuoteRequest): ids request.part_ids selected [p for p in PARTS_DB if p.id in ids] if len(selected) ! len(set(ids)): raise HTTPException(status_code400, detail部分配件ID不存在) total round(sum(p.price for p in selected), 2) warnings check_compatibility(selected) return QuoteResult( itemsselected, totaltotal, countlen(selected), warningswarnings, )这段代码里有几个细节值得展开。第一我检查len(selected) ! len(set(ids))是为了防止前端传了不存在的 ID比如库里已经删掉的配件还被缓存页面上勾选着后端必须兜住这种异常。第二round(sum(...), 2)是为了避免浮点计算出现 0.1 0.2 那种精度问题。第三兼容性校验独立成一个check_compatibility函数方便后续加规则。兼容性校验是这个项目的亮点也是很容易被忽略的地方。最简单的规则是三组CPU 插槽和主板插槽要匹配内存代数和主板内存规格要匹配电源功率不能低于整机参考功耗。我实现时先给配件加了一个“平台标识”字段比如 CPU 的platformAM5、主板的platformAM5然后做交叉匹配发现不一致就把提示文本加入 warnings前端展示成黄色警告条。def check_compatibility(parts: list[Part]) - list[str]: warnings [] cpu next((p for p in parts if p.category CPU), None) board next((p for p in parts if p.category 主板), None) memory next((p for p in parts if p.category 内存), None) if cpu and board and cpu.platform ! board.platform: warnings.append(fCPU平台{cpu.platform}与主板平台{board.platform}不匹配) if memory and board and DDR4 in memory.spec and DDR5 in board.spec: warnings.append(内存DDR4与主板DDR5不兼容) return warnings这种规则看起来简单却是整个工具最核心的价值。它把“装机老手凭经验扫一眼”的判断自动化了报价指南才算真正能干点活儿。2.3 CORS 与静态文件托管前后端分离开发时最绕不开的问题是跨域。我的前端跑在 5500 端口后端跑在 8000 端口浏览器会拦截 fetch 请求这时就需要后端配置 CORS 中间件。from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[http://127.0.0.1:5500, http://localhost:5500], allow_credentialsTrue, allow_methods[*], allow_headers[*], )这里要注意allow_origins要写全协议、域名和端口不能只写127.0.0.1:5500。有些同学折腾半天发现还是跨域多半是漏了http://前缀或者开发时用了局域网 IP 访问页面却没把那个 IP 加进列表。另一个建议是开发阶段先不用“允许所有来源”这种粗暴配置把前端地址明确列出来部署时再根据真实域名调整既能减少踩坑也更安全。到了部署阶段我直接把前端静态文件挂到 FastAPI 上这样整个项目一个服务就起来了。FastAPI 自带StaticFiles把frontend目录映射到/static路径再把/根路径指向index.html。from fastapi.staticfiles import StaticFiles from fastapi.responses import FileResponse app.mount(/static, StaticFiles(directory../frontend), namestatic) app.get(/) def index(): return FileResponse(../frontend/index.html)这样一个 FastAPI 服务同时承担 API 和静态页面本地演示、部署到服务器都很省事。要是以后前端想拆出去独立部署后端代码一行不用改CORS 配置调整一下就行。3. 原生JS 前端从页面渲染到报价单打印3.1 页面布局与配件列表渲染前端的 HTML 结构我分成了三个区域最上面是标题栏说明这是“电脑组装报价指南”中间是配件列表区按 CPU、主板、内存、显卡、SSD、电源、机箱分组展示右侧或下方是报价单区域实时显示已选配件和总价。页面骨架用原生 HTML 写清楚然后 JS 在页面加载完成后调用后端接口动态生成配件卡片。这里用原生 JS 的好处体现得很明显你完全清楚每个节点是怎么创建、怎么挂载到 DOM 的。async function loadParts() { const res await fetch(http://127.0.0.1:8000/api/parts); const parts await res.json(); renderParts(parts); } function renderParts(parts) { const container document.getElementById(parts-list); container.innerHTML ; const grouped parts.reduce((acc, part) { (acc[part.category] acc[part.category] || []).push(part); return acc; }, {}); Object.keys(grouped).forEach(category { const section document.createElement(div); section.className category-section; const title document.createElement(h3); title.textContent category; section.appendChild(title); grouped[category].forEach(part { const card document.createElement(label); card.className part-card; card.innerHTML input typecheckbox>let selectedIds new Set(); document.addEventListener(change, (e) { if (e.target.matches(input[typecheckbox])) { const id Number(e.target.dataset.id); if (e.target.checked) { selectedIds.add(id); } else { selectedIds.delete(id); } updatePreviewTotal(); } }); async function submitQuote() { if (selectedIds.size 0) { alert(至少选择一个配件); return; } const res await fetch(http://127.0.0.1:8000/api/quote, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ part_ids: [...selectedIds] }), }); if (!res.ok) { const err await res.json(); alert(err.detail || 生成报价单失败); return; } const quote await res.json(); renderQuote(quote); }用事件委托在document上监听 change是原生 JS 里比较常用的做法这样以后动态添加配件卡片也不用重新绑定事件。选择Set来存 ID天然去重不用操心同一个配件被勾选两次。3.3 报价单渲染与打印导出后端返回的QuoteResult结构包含 items、total、count、warnings前端拿到后要渲染成正式报价单。我的报价单区域是一个独立的div里面生成表格每一行是一个配件分类、名称、规格、单价最后一行是总价。function renderQuote(quote) { const box document.getElementById(quote-result); box.innerHTML ; const title document.createElement(h2); title.textContent 配置报价单; box.appendChild(title); if (quote.warnings.length 0) { const warningBox document.createElement(div); warningBox.className warning-box; quote.warnings.forEach(w { const p document.createElement(p); p.textContent 警告 w; warningBox.appendChild(p); }); box.appendChild(warningBox); } const table document.createElement(table); const tbody document.createElement(tbody); quote.items.forEach(item { const tr document.createElement(tr); tr.innerHTML td${item.category}/td td${item.name}/td td${item.spec}/td td¥${item.price}/td ; tbody.appendChild(tr); }); const totalTr document.createElement(tr); totalTr.className total-row; totalTr.innerHTML td colspan3总价/td td¥${quote.total}/td ; tbody.appendChild(totalTr); table.appendChild(tbody); box.appendChild(table); }打印功能的实现也很简单浏览器自带的window.print()就可以。但得注意默认打印整个页面会把配件列表也打出来很丑。解决方法是加一个media print样式把无关区域隐藏只保留报价单区域。media print { body * { visibility: hidden; } #quote-result, #quote-result * { visibility: visible; } #quote-result { position: absolute; left: 0; top: 0; width: 100%; } }这样用户在页面上点“打印报价单”打印预览里就只有干净整洁的表格不会被左侧的配件卡片干扰。4. 完整实操从环境搭建到跑通全流程4.1 环境准备与项目目录想完整跑一遍这个项目先准备 Python 3.10 以上的环境和任意现代浏览器。目录我建议这样组织前后端分开但能一眼看明白。pc-quote/ ├── backend/ │ ├── main.py │ └── requirements.txt └── frontend/ ├── index.html ├── style.css └── app.js后端依赖很简单只需要 FastAPI 和 Uvicorn。写进requirements.txt然后创建虚拟环境并安装。cd pc-quote/backend python -m venv venv source venv/bin/activate # Windows 用 venv\Scripts\activate pip install fastapi uvicorn4.2 后端代码逐步实现按照前面章节的思路把配件数据、兼容性校验、两个接口、CORS、静态文件托管全部写进main.py。这里我补上完整的数据初始化让项目可以开箱即用。PARTS_DB [ Part(id1, categoryCPU, nameAMD Ryzen 5 7600, spec6核12线程 / AM5, platformAM5, price1299), Part(id2, categoryCPU, nameIntel i5-13490F, spec10核16线程 / LGA1700, platformLGA1700, price1399), Part(id3, category主板, name微星 B650M MORTAR, specAM5 / DDR5 / MATX, platformAM5, price1099), Part(id4, category主板, name技嘉 B760M 小雕, specLGA1700 / DDR5 / MATX, platformLGA1700, price949), Part(id5, category内存, name金士顿 骇客神条 16G, specDDR5 5600, price399), Part(id6, category显卡, nameRTX 4060 8G, specNVIDIA / 8GB GDDR6, price2199), Part(id7, categorySSD, name三星 980 Pro 1TB, specNVMe PCIe 4.0, price699), Part(id8, category电源, name长城 750W金牌全模组, spec750W / 80Plus金牌, price549), Part(id9, category机箱, name先马 朱雀, specATX / 侧透, price199), ]platform字段是后加的因为兼容性校验需要它。所以Part模型也要加platform: Optional[str] None这样其他没有平台属性需求的配件也能正常创建。启动后端很直接uvicorn main:app --reload --port 8000启动后访问http://127.0.0.1:8000/docs就能看到 FastAPI 自动生成的接口文档可以在页面上直接测试/api/quote接口传{part_ids: [1, 3, 5]}看返回结果。这一步能验证后端逻辑对不对前端还没介入时就能把接口调通。4.3 前端页面与脚本落地前端需要三个文件。index.html负责骨架style.css负责配色和布局app.js负责逻辑。我先给出index.html的关键部分!DOCTYPE html html langzh-CN head meta charsetUTF-8 meta nameviewport contentwidthdevice-width, initial-scale1.0 title电脑组装报价指南/title link relstylesheet hrefstyle.css /head body header h1电脑组装报价指南/h1 p勾选你想要的配件一键生成配置报价单/p /header main section idparts-list/section aside h2当前报价单/h2 div idquote-summary暂未生成/div div idquote-result/div button idsubmit-quote生成报价单/button button idprint-quote打印报价单/button /aside /main script srcapp.js/script /body /htmlapp.js里除了前面写到的loadParts、renderParts、submitQuote还需要在网页加载完成后初始化并给两个按钮绑定事件document.addEventListener(DOMContentLoaded, () { loadParts(); document.getElementById(submit-quote).addEventListener(click, submitQuote); document.getElementById(print-quote).addEventListener(click, () window.print()); });开发阶段打开index.html时注意不要直接用file://协议打开否则 fetch 会报跨域或安全错误。我是用 VS Code 的 Live Server 插件起了一个本地静态服务器地址是http://127.0.0.1:5500正好和 CORS 配置里的地址对应。到这里整个流程就通了浏览器打开前端页面JS 调/api/parts拿配件数据并渲染用户勾选配件后点“生成报价单”前端把 ID 数组 POST 给/api/quote后端返回报价结果前端再渲染出正式报价单。整套闭环下来前后端项目该有的数据流转、接口设计、异常处理全都体现到了。5. 避坑实录跨域、精度与浏览器兼容5.1 跨域配置的常见坑跨域是前后端分离开发里最容易卡壳的地方。我遇到过的坑主要有三类。第一类是allow_origins配了但不生效原因多半是地址没写全。比如前端用的地址是http://localhost:5500而后端只配了http://127.0.0.1:5500浏览器可能因为域名不一致仍然拦截两个都要配上才稳。第二类是前端请求带了自定义 Header 或者credentials但后端没开对应选项导致浏览器预检请求失败。第三类是用file://打开 HTML这时候 Origin 是null后端怎么配都可能出问题最简单的解法是别用 file 协议访问起个静态服务器。排查跨域问题时不要只会看浏览器控制台的报错打开开发者工具的 Network 面板找到那个红色的请求看 Request Headers 里的 Origin 是什么再看 Response Headers 里有没有Access-Control-Allow-Origin。对不上号就改配置基本一次能定位。5.2 浮点数精度与金额计算扶摇直上的教训是金额计算不要直接用浮点数相加。Python 里0.1 0.2的结果是0.30000000000000004如果把配件价格累加起来直接返回给前端总价可能会出现4999.999999999999这种尴尬数字。我在项目里用round(sum(...), 2)初步解决但这只够用如果以后加折扣、税率、各种优惠建议直接用Decimal或者以分为单位做整数运算最后再格式化输出。前端展示报价的时候也要注意格式化。item.price可能是整数形式的1299也可能带小数显示给用户应该统一走toFixed(2)或者自己写格式化函数不然会出现有的价格显示1299、有的显示399.00报价单看起来不专业。5.3 前端异步与浏览器兼容问题原生 JS 写异步有一个很容易踩的坑页面加载时同时发多个请求如果某个接口慢先返回的响应未必是第一个请求的结果。我项目里分两个请求场景一个是初始化时请求配件列表一个是生成报价单时请求报价属于串行关系不冲突。但如果你以后做成“同时加载配件和价格配置”就要用Promise.all保证所有请求都完成后统一渲染避免出现一次渲染一半数据的半成品页面。另一个兼容性问题是剪贴板复制。navigator.clipboard.writeText在非 HTTPS 环境下不一定可用而且需要页面获得焦点。我的方案是写一个兜底函数优先用 Clipboard API失败就退回document.execCommand(copy)这种老办法。说实话现在大多数浏览器对 Clipboard API 支持都很好但兼容代码写上去不吃亏尤其是报价单工具经常被用户在局域网环境里访问那些环境可能是老浏览器。async function copyQuoteText() { const quoteText document.getElementById(quote-result).innerText; try { await navigator.clipboard.writeText(quoteText); } catch (e) { const textarea document.createElement(textarea); textarea.value quoteText; document.body.appendChild(textarea); textarea.select(); document.execCommand(copy); document.body.removeChild(textarea); } }5.4 优化建议与后续扩展这个项目跑通后可以扩展的方向其实不少。最直接的是把配件数据换到数据库这样能支持管理员后台维护价格而不是改代码里的列表。其次是给报价单加一个“保存/分享”功能后端把报价单 JSON 存起来生成一个短链接用户下次打开就能看到同一份配置。还可以增加更多兼容性规则比如电源功率是否满足显卡供电需求、机箱是否支持主板版型、散热器高度是否超过机箱限高这些都是装机场景里很实际的判断。我个人在使用这个工具的过程中最大的体会是小项目的技术难点往往不在“用什么框架”而在“边界条件有没有处理干净”。比如 ID 不存在怎么办、总价精度怎么保、跨域怎么配、打印样式怎么控这些才是实际开发里真正耗时的地方。把这些问题一个一个处理掉比换一个更“高级”的框架更有价值。当然如果你本来就想练练现代前端框架和 FastAPI 的配合那也可以把原生 JS 那层换掉后端接口完全不用动这恰好就是前后端分离带来的自由度。