文旅问答前端源码拆解:大模型应用与部署优化实战

文旅问答前端源码拆解:大模型应用与部署优化实战 简介面向文旅知识库大模型问答场景这份HTML前端设计源码为前端学习者和应用开发者提供了一套可直接借鉴的界面实现方案。项目融合超文本标记语言、JavaScript交互动效、层叠样式表视觉设计以及Python脚本覆盖知识检索、问答展示等模块便于掌握前端与后端服务对接的完整思路。压缩包共482个文件含207个PNG图片、111个JPG图片、35个WOFF2字体、32个JS脚本、24个Python脚本、21个CSS样式表等其中PNG多用于图标与透明背景JPG承载丰富视觉内容WOFF2保障网页字体清晰JS支撑动态交互Python辅助接口请求与数据处理整体约25.17MB。目前已有311人学习通过完整项目案例可系统了解文旅问答类前端设计、多类型资源组织方式以及前端工程化实践并借助其中的Git忽略规则、License与readme文档快速上手方便二次开发与功能扩展。1. 一份 477 文件的文旅问答前端源码值得你拆一遍第一次拿到这套基于文旅知识库大模型问答的 HTML 前端源码时我没有先看页面效果而是先做了文件清点。477 个文件里图片占了 318 个JavaScript 32 个CSS 21 个Python 19 个WOFF2 字体 35 个。这种资源结构很典型文旅项目需要大量景点照片问答界面本身需要图标字体、视频背景、自定义滚动条和代码高亮。真正值得拆的是它在同一个工程里把 HTML、CSS、JavaScript 和 Python 串成了一条完整的大模型问答链路。对前端开发而言这是一份能照着写的中等复杂度工程样本对正在准备前端面试题或做大模型应用的人它也能回答一个很现实的问题问答页面不只是放输入框和按钮还要处理流式响应、历史记录、Markdown 渲染和样式隔离。目录里的 .gitignore、LICENSE、readme.txt 说明它不是临时 demo而是按可分发、可二次开发的方式组织的。接下来的拆解按读源码的顺序推进先看静态资源和加载顺序再看 CSS 覆盖策略和问答请求链路然后跑通本地部署与排错最后收在几个能直接落地的改造技巧上。2. 从 477 个文件里找到页面入口HTML 骨架与静态资源加载顺序2.1 文件类型分布为什么图片和字体占了大头按下 F12 打开 Network 刷一遍首页会看到真正影响首屏的是前几十个请求大部分图片和字体被滚动加载推迟。477 个文件的分布不是平均的207 个 PNG 主要承载透明图标、头像、卡片切图和装饰纹理111 个 JPG 给文旅场景提供实景照片压缩率高且兼容性好35 个 WOFF2 里既有图标字体也有定制中文字体的子集因为 WOFF2 的压缩率比 WOFF 更高适合在移动端保持清晰度。下表是我按目录整理出的文件构成读源码时先按这张表分配注意力比从头到尾翻文件快得多。文件类型数量在问答前端里的职责PNG207透明背景图标、UI 切图、头像与装饰JPG111景点照片、背景大图WOFF235图标字体和正文中文字体JavaScript32交互逻辑、插件、API 调用CSS21栅格、主题、动画、响应式Python19本地服务、API 代理、预处理SVG/GIF9矢量 logo 和动态 banner如果一开始就点开每个文件几个小时就没了。我的习惯是先看 HTML 引用了哪些 CSS 和 JS因为引用顺序决定了页面最终渲染结果。入口通常叫 index.html也可能放在 views 或 pages 目录下直接搜索bootstrap.min.css就能定位。2.2 head 里的样式加载顺序决定了后面会不会踩坑打开的 index.html 会看到类似这样的 headlink relstylesheet hrefassets/css/bootstrap.min.css link relstylesheet hrefassets/css/style1.css link relstylesheet hrefassets/css/theme.css link relstylesheet hrefassets/css/animate.css link relstylesheet hrefassets/css/all.css link relstylesheet hrefassets/css/jquery.mCustomScrollbar.min.css link relstylesheet hrefassets/css/video-js.css link relstylesheet hrefassets/css/style.css link relstylesheet hrefassets/css/responsive.css这个顺序很关键。Bootstrap 在最前面负责栅格和基础组件紧接着是 style1.css 和 theme.css前者偏结构后者保存设计变量主色、圆角、阴影都从这里出。animate.css 给元素进场动画all.css 是图标字体集合mCustomScrollbar、video-js 分别管聊天面板滚动条和视频播放它们的样式文件必须在对应容器出现前先注册否则初始化时会因为样式缺失出现错位。最后两个文件不要调换style.css 是整体视觉的最终覆盖层responsive.css 必须留在最后否则媒体查询会被前面的同名类覆盖。如果你打开页面后发现按钮还是默认蓝色而不是主题色先查是不是 responsive.css 被提前加载了。另一个容易踩的是 prism.css 的位置。问答返回的代码块需要高亮prism.css 一般也在 head 里但如果放在 style.css 前面主题里的pre背景色会覆盖高亮背景。我的做法是把它放在 style.css 之后并为问答容器单独限定作用域比如.qa-answer pre { background: #f6f8fa; }。2.3 JavaScript 初始化顺序滚动条、视频和 Prism 的挂载时机源码里能找到一个公共 JS 文件初始化顺序通常是这样的$(function () { // 自定义滚动条只作用于问答列表和知识库侧栏 $(.chat-list, .knowledge-panel).mCustomScrollbar({ theme: minimal-dark, scrollInertia: 300, axis: y }); // 视频播放器preloadnone 能避免首屏同时拉取视频 if (window.videojs) { videojs(#qa-video, { controls: true, preload: none }); } });这里两个参数值得说scrollInertia 是滚动惯性毫秒数数值小跟手、数值大顺滑axis 限制只纵向滚动避免横向意外滚动。videojs 的 preload:none 表示不自动预加载视频适合文旅首页放景区视频背景的场景。如果发现聊天列表能滚动但看不到滚动条或者视频区域黑屏先检查这两个初始化是否在 DOM 渲染之后执行最简单的方式是把脚本移到/body前或者包在 DOMContentLoaded 里。问答返回的代码块是动态插入的需要在渲染完成后调用Prism.highlightAllUnder(container)否则代码块会以纯文本露出。这一句放在第 4 节渲染部分一起看更完整。3. 主题、图标与代码高亮21 个 CSS 文件到底在管什么3.1 插件样式和业务样式的边界21 个 CSS 文件不可能都是手写的大部分来自模板和插件。可以按作用分成三层框架层是 bootstrap.min.css提供栅格、按钮、表单等基础组件视觉层是 style1.css、theme.css、style.css控制文旅问答页的整体氛围和业务元素功能层是 animate.css、all.css、jquery.mCustomScrollbar.min.css、video-js.css、prism.css为动画、图标、滚动条、视频和代码高亮服务。分清楚这层关系后你就知道改样式时该动哪个文件而不是全局搜索.btn到处覆盖。文件层主要作用bootstrap.min.css框架栅格、按钮、表单、模态框style1.css视觉页面结构、旧版卡片布局theme.css视觉颜色变量、圆角、阴影等设计 tokenanimate.css功能元素进入/离开动画all.css功能图标字体jquery.mCustomScrollbar.min.css功能自定义滚动条video-js.css功能视频播放器样式prism.css功能Markdown 代码高亮style.css / responsive.css业务最终覆盖与响应式调整readme.txt 里如果写了设计说明一般也会按这个层级描述。遇到样式冲突先判断冲突发生在哪一层再去改对应文件。3.2 用 style.css 覆盖 Bootstrap 和 theme 的优先级文旅问答页面里最常见的修改是给提问按钮换主题色、调整输入框圆角。直接改 Bootstrap 源码不现实正确的姿势是在 style.css 里用更高优先级覆盖。我一般会给页面根节点加一个id然后利用 ID 选择器压过插件自带的类名#tourist-app .btn-ask { background: var(--primary-color, #2a9d8f); border-radius: 20px; font-size: 15px; transition: all .2s ease; } #tourist-app .btn-ask:hover { transform: translateY(-1px); box-shadow: 0 4px 12px rgba(0, 0, 0, .15); }这里var(--primary-color)读取 theme.css 里定义的 CSS 变量如果变量不存在就回退到#2a9d8f。border-radius: 20px是为了让按钮更像对话气泡入口而不是生硬的长方形。transition让 hover 状态有轻微上浮和阴影变化这种细节在文旅展示页里很能提升质感。使用 ID 前缀是因为#tourist-app比.btn-ask的优先级高不用在每个类后面加!important后续维护也干净。3.3 响应式断点与文旅问答卡片布局mobile 上文旅问答通常要把侧边栏收起来只保留主对话区不然 375px 的屏幕上根本放不下输入框和历史知识库列表。responsive.css 里已经预留了断点常见做法是这样media (max-width: 768px) { .qa-sidebar { display: none; } .qa-main { width: 100%; } .video-banner { height: 220px; } }768px 是平板和手机之间的常用分界。小于这个宽度时左侧景点推荐、知识库入口这些次要信息隐藏主问答区独占一行视频 banner 也从宽屏比例压到 220px 高减少首屏滚动成本。如果只改一处我建议先看这张断点表断点典型设备页面主要变化1200px大屏笔记本三栏布局完整展示992px小笔记本知识库侧栏收窄768px平板隐藏侧栏问答区占满576px手机输入框和按钮纵向排列改响应式时不要只调一个断点要顺着min-width和max-width一起看否则会出现 769px 到 810px 之间布局错乱的情况。4. Python 脚本与大模型问答接口前端请求是如何打到知识库的4.1 19 个 Python 脚本的真正职责不是后端是粘合层很多人看到 19 个 Python 文件会以为这是前后端一体项目。实际上在多数文旅问答前端源码里Python 脚本的角色是把大模型服务包装成一个同源接口避免浏览器直接访问内部的模型地址也顺便处理知识库检索的预处理。常见做法是用 FastAPI 或 Flask 起一个轻量服务。打开源码里server.py或proxy.py会看到类似这样的结构from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware import httpx app FastAPI() app.post(/api/chat) async def chat(payload: dict): question payload[question] history payload.get(history, []) # 转发到本地大模型推理服务的 OpenAI 兼容接口 async with httpx.AsyncClient(timeout30) as client: resp await client.post( http://127.0.0.1:11434/v1/chat/completions, json{ model: payload.get(model, qwen2.5), messages: [*history, {role: user, content: question}], temperature: payload.get(temperature, 0.7), max_tokens: payload.get(max_tokens, 1024), }, ) return resp.json()这里model字段需要根据你自己部署的模型名修改temperature控制回答随机性0.2 左右适合知识库问答0.7 以上适合文案润色。max_tokens决定单次最大输出长度。我一般会在这一层再做两件事把知识库检索结果拼进 prompt以及记录每次问答耗时方便后端观测。如果你的环境里没有大模型服务也可以让代理先返回一段固定 JSON前端就能脱离模型先调样式。4.2 前端怎么封装问答请求fetch、AbortController 和超时页面里的问答输入框绑定的不是普通表单提交而是一个带超时控制的异步函数async function sendQuestion(question) { const controller new AbortController(); const timer setTimeout(() controller.abort(), 20000); try { const resp await fetch(/api/chat, { method: POST, headers: { Content-Type: application/json }, body: JSON.stringify({ question: question, city: document.getElementById(city).value, // 当前选择的城市 history: window.__chatHistory || [], // 传给后端的多轮上下文 temperature: 0.7, max_tokens: 1024 }), signal: controller.signal }); const data await resp.json(); renderAnswer(data.choices[0].message.content); } catch (e) { if (e.name AbortError) { showToast(请求超时请缩小问题范围后重试); } else { showToast(问答服务暂时不可用); } } finally { clearTimeout(timer); } }超时设为 20 秒是因为大模型流式回答通常比普通接口慢特别是知识库命中多个文档时。history数组里保存的是{role, content}结构而不是纯文本拼接这样后端可以直接展开成 OpenAI 兼容的 messages。city参数是文旅问答常用的过滤条件相当于在知识库里限定城市范围。如果后端返回的不是这个结构比如直接返回{answer: ...}只需要把renderAnswer的入参改成data.answer。注意AbortController在部分旧内核浏览器里不可用上线前可以做一个降级如果controller未定义就跳过超时逻辑。字段示例说明question西湖门票多少钱用户本次问题city杭州知识库预筛选条件history[{role: user...}]多轮对话上下文temperature0.7回答随机性max_tokens1024最大输出 token 数4.3 流式回答的渲染Markdown 与代码高亮文旅问答里用户经常问“给我一份三天行程”“这个景点的门票规则”这类问题模型回答自带列表和标题。直接放进 div 会很难看常见做法是用 marked 加 Prism 做两步渲染const container document.getElementById(answer-content); container.innerHTML marked.parse(markdownText); container.querySelectorAll(pre code).forEach((block) { Prism.highlightElement(block); });marked.parse负责把 Markdown 转成 HTML。Prism 的highlightElement要在 DOM 插入后调用否则新节点没有被观察。如果跳过这一步回答里的 Python/JSON 片段会当成普通文本这在文旅知识库的票务接口说明、行程安排示例里非常明显。若你的源码里没有 marked也可以引入 marked.min.js 和 prism.js并把 prism.css 放进 head 或组件样式里。到这里前端问答链路基本就完整了。5. 把源码跑起来本地部署、CORS 与三个典型排错5.1 先起静态服务器再起问答代理这套源码不能直接用file://双击打开因为视频、字体和异步请求在本地文件协议下会被浏览器拦截。正确做法是先启动一个静态服务器再用前面说到的 Python 代理接收问答请求cd tourist-qa-frontend python -m http.server 8080然后在另一个终端启动 Python 服务python proxy.py --port 8000 --model-api http://127.0.0.1:11434/v1/chat/completions第一个命令用 Python 自带 HTTP 服务器托管静态文件监听 8080 端口第二个命令把/api/chat转发到本地大模型推理服务。如果安装过 Node也可以顺手用npx serve -l 8080替代第一步。打开http://localhost:8080时前端页面的 API 地址要指向http://localhost:8000所以源码里通常会有一个config.js或api.js保存 baseURL。我建议在 api.js 顶部统一维护const API_BASE http://localhost:8000;这样后续切换到远程接口只改这一行不需要在所有 fetch 里找地址。如果你把静态页部署到 Nginx代理地址就要改成 Nginx 里配置的proxy_pass指向的地址保持 API_BASE 与部署拓扑一致。5.2 参数配置与 CORS让浏览器相信代理启动之后最常见的错误是控制台报Access-Control-Allow-Origin。原因是 8080 和 8000 端口不同浏览器默认跨域拦截。在 Python 代理里加一层 CORS 中间件即可from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins[http://localhost:8080, http://127.0.0.1:8080], allow_methods[POST, GET, OPTIONS], allow_headers[*], )如果改用 Flask也可以用flask-cors库的CORS(app, origins[http://localhost:8080])。注意allow_origins不要直接写*因为后续你要携带Authorization头时通配符会被浏览器拒绝。下面是我在调试时最常改的参数整理成表方便对照参数示例值作用API_BASEhttp://localhost:8000前端所有请求的前缀modelqwen2.5大模型服务中的模型名temperature0.3知识库问答建议调低max_tokens1024回答最大长度REQUEST_TIMEOUT20fetch 层超时秒数5.3 踩坑记录字体 404、样式错乱、接口挂掉我把这套源码跑通时遇到三个比较典型的问题如果你也被困住可以直接对照处理。第一个是字体 404。35 个 WOFF2 文件路径是assets/fonts/...但因为页面是从二级目录访问的浏览器会按当前路径找结果全部 404。处理办法是打开开发者工具的 Network 面板看请求 URL 是assets/fonts还是tourist-qa-frontend/assets/fonts然后在base标签或font-face的url()里改成相对根目录的路径。更稳妥的是直接在index.html的 head 里写base href/让所有相对路径基于根目录解析。第二个是样式错乱。某个按钮的圆角一会儿有一会儿没有最后定位到是 animate.css 给元素加了animation而 style.css 的border-radius被前面优先级更高的选择器覆盖。解决思路是给页面根节点加一层id把自定义样式提升到#tourist-app .btn这样就能压过插件自带样式。源码里保留 21 个 CSS 文件不是为了炫技而是为了让多个插件各自独立真正的业务样式应该集中在 style.css 的最后几段。第三个是接口挂掉但页面不提示。原因是我在 fetch 里只处理了resp.ok为 true 的情况而代理返回的 HTTP 200 里带着一个错误 JSON。稳妥的做法是在sendQuestion里检查data.error只要有值就把错误信息显示在输入框附近。这一步在前端开发里很容易被忽略却是问答类产品最影响体验的地方。6. 进阶让这个问答前端更适合生产环境6.1 用 URL 参数恢复问答会话文旅问答页面经常需要从列表页跳转过来并携带城市、景区名等查询条件。我一般会在问答框的回车事件里同步更新 URL比如history.pushState(null, , ?city杭州q西湖门票)页面加载时再读取参数并自动执行一次搜索。这样用户刷新后不会丢失问题分享链接给同事也能直接复现当时的提问。实现时注意用decodeURIComponent处理中文参数避免服务端日志里出现乱码。6.2 给 35 个 WOFF2 做字体子集化前端性能优化里最容易被忽视的是字体。这套源码有 35 个 WOFF2完整引入后首屏可能多出 400 KB 网络传输。我建议用fonttools的pyftsubset把中文字体裁成只包含页面实际出现的字符pyftsubset theme.woff2 --text-filepage_chars.txt --output-filetheme.subset.woff2先用一段 Python 脚本扫描 HTML 和 JS 里所有中文字符生成page_chars.txt再对每个字体执行子集化。做完之后把 CSS 里的font-family指向 subset 文件字体加载时间会明显下降这一步对移动端尤其明显。注意保留font-display: swap这样字体没加载完时页面先用系统字体显示用户不会看到空白文字。6.3 把历史回答折叠起来减少 DOM 占用问答页面的历史记录如果全部渲染成长列表几百轮对话后页面会明显卡顿。常见做法是只保留最近 10 条回答的 DOM其余回答折叠成一行标题展开时再用marked.parse重新渲染。由于问答内容是从后端拿到的 Markdown 原文重新渲染不会有信息损耗配合content-visibility: auto让浏览器跳过屏幕外的布局滚动性能会比之前好很多。如果想让折叠后的历史列表也支持多轮追问把expandedId和history数组放到同一个状态对象里管理后面加新功能时改动会小很多。本文还有配套的精品资源点击获取