多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

纯页面论坛管理后台:Mock工程化与接口平滑切换实战

纯页面论坛管理后台:Mock工程化与接口平滑切换实战 后端接口还在联调产品明天就要看效果——这是我做论坛信息管理系统这类后台需求时最常撞上的场面。前端项目里论坛信息管理系统纯页面这种形态说白了就是不依赖真实服务端靠前端自己造的一层数据把整条交互链路跑通板块管理、帖子列表、发帖编辑、回复审核、用户封禁、批量操作、权限控制一个都不少。它解决的不是能不能显示数据而是在接口没到位之前业务逻辑、交互细节、边界状态能不能先被验证一遍。这套东西适合三类人上手一是想练一个完整度够高的前端项目实战案例、用来充实作品集或简历的同学二是正在做前后端并行开发需要先出一版可点可看的原型给业务方确认三是刚转前端、想搞明白一个后台系统到底由哪些零件拼起来的开发者。我下面要拆的不是照着某个模板抄一遍页面而是把数据建模、假数据工程化、列表交互、表单校验、权限路由、适配方案、以及后期切换真接口的替换清单一层一层掰开讲。你看完之后应该能自己从零搭出一套结构清晰、后期能平滑接入真实后端的论坛管理台而不是做一堆点两下就散架的静态页。1. 纯页面论坛管理系统的真实定位它不是玩具是接口契约的先行验证1.1 为什么没有后端反而更考验架构能力很多人对纯页面项目的第一反应是没意思就是写几个 HTML 。我刚开始也这么想直到踩过一次坑早期我做一个社区后台页面写得飞快三天铺完了十几个界面结果后端接口一出字段名对不上、分页参数名不一样、状态码语义冲突返工的时间比重新写还长。那次之后我才明白纯页面项目的核心价值不在界面而在它逼着你先把数据长什么样、怎么流动想清楚。没有后端给你兜底你就必须自己回答几个问题一个帖子对象到底有哪些字段列表接口的分页结构是{list, total}还是{records, totalCount}删除是物理删除还是软删除、前端要不要做乐观更新这些问题的答案一旦写在 mock 层里就等于一份可执行的数据契约。后端同学拿到这份契约照着实现就行联调时基本不会出现我以为你返回的是数组这种低级拉扯。所以我在做这类项目时第一步从来不是打开编辑器写页面而是先在纸上或文档里把接口清单列出来。一个论坛管理系统的接口大致就这几组模块典型接口说明认证登录、登出、获取当前用户纯页面可以只做状态模拟但路由守卫要按真实逻辑写板块列表、新增、编辑、排序、显隐板块数量少通常不分页帖子列表多条件、详情、新增、编辑、删除、置顶、加精、批量状态流转全项目最复杂的一块回复列表、删除、屏蔽、恢复常和帖子详情页嵌套用户列表、封禁、解封、调整角色涉及权限边界统计今日发帖、待审核数、活跃用户首页看板用纯页面阶段可以直接算把这张表列出来你会发现工作量的一半其实在帖子这一块。这也就是为什么很多纯页面项目做着做着就烂尾——前面几个简单模块做得飞快一到帖子的多条件筛选加批量操作整个状态就管不住了。1.2 把请求层做成一个可拔插的开关让纯页面项目后期能平滑切换真实接口的关键是在最初就把请求层抽出来而不是让每个组件自己去import假数据。我习惯的做法是加一个环境变量开关请求函数内部判断走 mock 还是走真实 HTTP。// src/api/request.js const USE_MOCK import.meta.env.VITE_USE_MOCK true export async function request(config) { if (USE_MOCK) { // 动态引入生产构建时会被 tree-shaking 掉 const { mockAdapter } await import(/mock) return mockAdapter(config) } const res await fetch(config.url, { method: config.method || GET, headers: { Content-Type: application/json }, body: config.data ? JSON.stringify(config.data) : undefined }) if (!res.ok) throw new Error(HTTP ${res.status}) const json await res.json() if (json.code ! 0) throw new Error(json.message) return json.data }这里有个细节值得说mock 适配器要用动态import。我吃过亏早期直接静态引入结果打包后整个假数据种子文件和几十 KB 的 mock 逻辑全进了生产包被同事 review 时一眼看出来。动态引入之后构建工具会把它拆成独立 chunk真接口上线时这份代码根本不会被加载。另外request的返回我统一剥了一层只把data吐给业务层。这样组件里写的是const list await getPostList(params)至于底层返回是{code, data, message}还是{success, result}组件完全不关心。后面接口协议变了只改request一处。还有一个容易被忽略的点错误处理的位置。我的原则是网络层只负责抛错业务层负责提示。不要在request里直接弹 Toast不然批量操作时十个请求失败会弹十个提示框用户直接崩溃。批量场景要等Promise.allSettled收口后统一汇报。1.3 目录结构的划分逻辑纯页面项目因为没有服务端反而更容易把目录搞乱——反正数据随便放。我的划分是api/只放接口函数声明mock/放适配器和种子数据stores/放跨页面共享的状态比如当前用户、权限、字典views/放页面components/放可复用的业务无关组件比如状态标签、操作确认弹窗。有个判断标准很好用如果一个组件在别的项目里也能直接用它就该放components/如果它绑定了帖子的字段结构就放在对应views的子目录里。我见过太多项目把所有东西都堆进全局components/最后里面塞了三十多个只能在一个页面用的组件维护起来一塌糊涂。2. 论坛业务的数据建模从用户动作倒推字段而不是从界面倒推2.1 四个核心实体的字段该怎么定数据建模最容易犯的错是看着页面设计表——页面上显示什么字段表里就放什么字段。这样做的后果是等要做筛选、排序、统计时发现缺少关键字段只能临时在一堆地方打补丁。我习惯反过来先列出用户在这个系统里会做的所有动作再倒推需要哪些字段。论坛管理系统的用户动作无非这些浏览帖子列表、按板块/状态/关键词筛选、排序、进入详情、审核通过或驳回、置顶、加精、删除、回复、查看某个用户发过的帖子。把这些动作拆开四个核心实体的字段就出来了。帖子post是全项目的核心字段我一般这么定{ id: p_1001, boardId: b_3, // 所属板块 title: Vue3 组合式 API 踩坑记录, content: p.../p, // 富文本 HTML 或 Markdown excerpt: 纯文本摘要用于列表展示, authorId: u_22, authorName: 老张, // 冗余字段避免列表页 N1 查询 status: published, // draft | pending | published | rejected | deleted isTop: false, // 置顶 isEssence: true, // 加精 tags: [vue, 踩坑], viewCount: 1288, replyCount: 34, likeCount: 56, createdAt: 1735660800000, updatedAt: 1735747200000, publishedAt: null // 审核通过时间统计用 }这里有两个字段值得单独解释。authorName是冗余字段。正常数据库设计里不该存作者名但管理后台的列表页一页展示 20 条帖子如果每条都要去查一次用户表拿昵称就是典型的 N1 问题——纯页面阶段你看不出性能差异因为假数据都在内存里但一旦接了真接口这个设计缺陷会立刻暴露。所以我在建模阶段就把它写进契约让后端返回时直接带上。status用一个字符串枚举而不是数字好处是可读性高坏处是前端要做类型约束。我一般会在 store 里维护一份字典把枚举和中文标签、颜色映射统一管理export const POST_STATUS { draft: { label: 草稿, type: info }, pending: { label: 待审核, type: warning }, published: { label: 已发布, type: success }, rejected: { label: 已驳回, type: danger }, deleted: { label: 已删除, type: info } }板块board除了基础字段要特别留order和visible。板块排序在后台是高频操作纯页面阶段我一般直接做拖拽排序改order值visible控制前台是否展示后台要能切换所以列表里得有一个开关列。回复reply的关键是postId和parentId。如果只做一层回复parentId可以为空如果要做楼中楼就得保留它同时加一个floorNumber记录楼层号。楼层号的生成逻辑我在 mock 层里是按帖子分组取最大楼层加一真实环境里应由后端保证原子性这点要提前跟后端说清楚不然并发发帖会出现重复楼层。用户user除了基础信息role和status决定了权限判断。role我一般分admin、moderator、user三档status分active和banned。注意被封禁的用户历史帖子要不要隐藏这是个业务决策我倾向于保留显示但标记作者状态否则会出现帖子内容还在但作者名变空的诡异情况。2.2 时间字段的统一一个被低估的踩坑点时间字段看起来最简单实际上是最容易出问题的地方。我在不同项目里见过至少四种形态秒级时间戳、毫秒级时间戳、2024-01-01 12:00:00字符串、ISO 8601 字符串。纯页面阶段如果你随手用new Date()存了Date对象序列化到 localStorage 就变成字符串取出来一比较就出错。我的统一约定是存储和传输一律用毫秒时间戳展示时统一走格式化函数。同时区分createdAt和updatedAt列表页默认按updatedAt倒序——因为管理员更关心最近被动过的帖子而不是最近被创建的帖子。这个细节很多项目没想清楚默认按创建时间排结果管理员刚审核完一条半年前的帖子列表刷完它还在最下面体验很差。排序字段还有一个坑isTop置顶帖永远排在最前这个排序逻辑在 mock 层就要实现因为前端分页时如果只排当前页会出现第二页有置顶帖而第一页没有的错乱。正确做法是先全量排序再切分页这个顺序不能反。3. Mock 数据层的工程化让假数据扛得住真交互3.1 路由匹配式适配器而不是一堆 if-else最原始的 mock 写法是在request里一堆if (url /api/posts)请求一多就变成几百行的面条代码还没法处理路径参数。我现在的做法是写一个轻量的路由表用正则匹配路径。// src/mock/index.js const routes [ { method: GET, pattern: /^\/api\/posts$/, handler: listPosts }, { method: GET, pattern: /^\/api\/posts\/([\w-])$/, handler: getPost }, { method: POST, pattern: /^\/api\/posts$/, handler: createPost }, { method: PATCH, pattern: /^\/api\/posts\/([\w-])$/, handler: updatePost }, { method: DELETE, pattern: /^\/api\/posts\/([\w-])$/, handler: deletePost }, { method: POST, pattern: /^\/api\/posts\/batch$/, handler: batchUpdatePost } ] export async function mockAdapter({ url, method GET, data, params }) { const path url.split(?)[0] for (const route of routes) { if (route.method ! method) continue const matched path.match(route.pattern) if (matched) { await delay(120 Math.random() * 200) // 模拟网络延迟 return route.handler({ params: matched.slice(1), query: params, body: data }) } } throw new Error(Mock 未匹配到路由: ${method} ${path}) }这段代码里有两点是刻意设计的。第一是延迟模拟120ms 到 320ms的随机延迟。别小看这个它能让 loading 状态、按钮禁用、防重复点击这些交互问题在纯页面阶段就暴露出来。我见过太多项目假数据是同步返回的loading 动画根本没机会出现切了真接口才发现请求期间用户能连点五次提交按钮。第二是随机延迟而不是固定延迟能验证你的骨架屏和 loading 是不是会闪一下就没——如果固定 100ms基本看不到闪烁随机之后偶尔出现 300ms就能看出过渡动画是否顺滑。3.2 用 localStorage 做内存数据库但要知道它的边界种子数据生成之后如果只放在内存变量里一刷新页面所有操作就没了。纯页面阶段最实用的方案是把它序列化进 localStorage。const DB_KEY forum_mock_db_v1 function loadDB() { const raw localStorage.getItem(DB_KEY) if (raw) { try { return JSON.parse(raw) } catch (e) { localStorage.removeItem(DB_KEY) } } const seed buildSeedData() localStorage.setItem(DB_KEY, JSON.stringify(seed)) return seed } let db loadDB() function persist() { localStorage.setItem(DB_KEY, JSON.stringify(db)) }版本号v1一定要带。你在开发过程中一定会改数据结构比如给帖子加个tags字段这时候如果用户或者你自己的另一台设备还留着老数据读出来缺字段就会莫名其妙报错。我的做法是改结构就升版本号老 key 读到不匹配直接丢弃重建种子数据。这招在演示场景下非常好用产品经理点了半天把数据改乱了你只要改一下版本号刷新就是干净的数据。但这里有个必须提前知道的边界localStorage 一般只有 5MB 左右而且只存字符串。帖子内容如果带图片你把 base64 塞进去一两张稍大的图就能把配额撑爆然后setItem直接抛异常整个应用写操作全部失效。我的处理是内容里的图片在纯页面阶段只用本地预览 URLURL.createObjectURL不落库给persist加一层 try-catch超配额时提示演示数据已满请重置并提供一键清空按钮图片字段存成一个占位标记[image]展示时替换成占位图。另外说一下重置数据这个功能。看起来是个小按钮但它是纯页面项目的救命稻草。测试过程中状态被改乱了、想回到初始状态演示没有重置按钮就得手动清 localStorage非常尴尬。我一般在顶部工具栏放一个不显眼的重置演示数据配一个二次确认。3.3 分页、筛选、排序必须在 mock 层真实实现这是区分认真做的纯页面项目和糊弄的静态页的分水岭。很多人的 mock 直接返回全部数据前端用slice分页看起来也能跑。但这样做的后果是你永远发现不了分页边界的问题——比如最后一页删除全部数据后页码应该回退一页、筛选条件变化时页码应该重置为 1、总数变化时当前页超出范围该怎么处理。在 mock 层里老老实实实现一遍逻辑大概是这样function listPosts({ query }) { const { page 1, pageSize 20, boardId, status, keyword, sort -updatedAt } query let list db.posts.slice() if (boardId) list list.filter(p p.boardId boardId) if (status) list list.filter(p p.status status) if (keyword) { const kw keyword.trim().toLowerCase() list list.filter(p p.title.toLowerCase().includes(kw) || p.authorName.toLowerCase().includes(kw) ) } const desc sort.startsWith(-) const field desc ? sort.slice(1) : sort list.sort((a, b) (a[field] b[field] ? 1 : -1) * (desc ? -1 : 1)) list.sort((a, b) Number(b.isTop) - Number(a.isTop)) // 置顶恒在最前 const total list.length const start (Number(page) - 1) * Number(pageSize) return { list: list.slice(start, start Number(pageSize)), total, page: Number(page) } }注意排序那里做了两次sort。第一次按用户选的字段排第二次按置顶排。因为Array.prototype.sort在现代引擎里是稳定排序所以第二次置顶排序不会打乱第一次的结果顺序最终效果是置顶优先组内按选中字段排。这个小技巧比写一个复杂的比较函数清爽得多。关键词搜索这里我用了toLowerCase()做不区分大小写的匹配。中文其实不受影响但项目里往往混着英文标签和技术名词用户搜vue和Vue都该出结果。这个细节在真实后端里通常由数据库的排序规则处理但前端 mock 里不写测试时就会有人提 bug。4. 帖子列表页多条件筛选和批量操作的完整实现思路4.1 查询条件必须和 URL 同步而不是只存在组件里列表页最容易做错的一件事是把筛选条件全放在组件的ref里。这样做的直接后果是用户在第三页筛选了待审核点进某条帖子详情按浏览器返回键回来筛选条件全没了又回到第一页。用户会觉得这个系统很不好用但又说不上哪里不对。正确做法是让 URL 成为查询状态的唯一真相来源。也就是把page、pageSize、boardId、status、keyword、sort这些全部同步到 query string 里。实现上Vue 项目里可以用useRoute和useRouter配合一个watchimport { useRoute, useRouter } from vue-router import { reactive, watch } from vue const route useRoute() const router useRouter() const query reactive({ page: Number(route.query.page) || 1, pageSize: Number(route.query.pageSize) || 20, boardId: route.query.boardId || , status: route.query.status || , keyword: route.query.keyword || , sort: route.query.sort || -updatedAt }) // 条件变化时重置页码并写入 URL watch(query, (val) { router.replace({ query: { ...val } }) }, { deep: true })这里有个必须注意的细节筛选条件变化时要重置页码但翻页时不能重置。如果无脑地watch所有字段然后重置page 1用户点第二页就会被打回第一页陷入死循环。我的处理是把筛选字段和分页字段分开 watchwatch(() [query.boardId, query.status, query.keyword], () { query.page 1 }) watch(() [query.page, query.pageSize, query.sort, query.boardId, query.status, query.keyword], fetchList)另外关键词输入框不能每敲一个字母就请求一次。要加防抖我一般用 300ms 到 500ms。注意防抖之后仍然要重置页码否则会出现搜出来的结果只有三条但当前还在第五页页面空白的经典 bug。这个问题我在至少三个项目里见过排查时容易往接口方向想其实是前端状态没处理干净。4.2 批量操作并发、部分失败、以及别让用户等批量删除、批量审核、批量置顶这些是管理后台的必要功能也是最容易写出问题的地方。我见过的典型错误写法是这样的// 反面教材 for (const id of selectedIds) { await deletePost(id) }逐条串行请求选中 50 条就是 50 次往返用户盯着 loading 干等。更糟的是中间某一条失败了前面的已经删了后面的没删处于一个说不清楚的中间状态还没有任何提示。我的做法是三个改动。第一并发发起用Promise.allSettled而不是Promise.all因为all遇到第一个失败就整体 reject剩下的结果拿不到第二收集成功和失败的结果统一汇报第三失败项保留选中状态让用户能直接重试。async function handleBatchDelete() { const ids [...selectedIds] batchLoading.value true try { const results await Promise.allSettled(ids.map(id deletePost(id))) const failed ids.filter((id, i) results[i].status rejected) const successCount ids.length - failed.length if (failed.length 0) { message.success(已删除 ${successCount} 条) selectedIds.clear() } else { message.warning(成功 ${successCount} 条失败 ${failed.length} 条) selectedIds new Set(failed) // 只保留失败项 } fetchList() } finally { batchLoading.value false } }这段代码里最值得学的是失败项保留选中这个交互。看起来是个小细节但在真实使用中价值很高——管理员批量处理 200 条待审核帖其中 3 条因为并发冲突失败了如果选中状态被清空他得从头找那 3 条保留选中他直接再点一次就行。还有一个实践中的经验批量操作前一定要有二次确认而且确认弹窗要把数量说清楚。我见过那种只写确认删除吗的弹窗用户根本不知道影响多少条手一抖点了确定几十条数据没了。正确写法是把数量和关键信息带进弹窗文案比如确定删除选中的 12 条帖子删除后可在回收站恢复如果做了回收站的话。顺便说一句删除这件事我强烈建议做软删除哪怕是纯页面项目也做加个status: deleted就行成本极低但能救很多次手。4.3 列表的性能边界什么时候该考虑虚拟滚动纯页面阶段数据量一般不大种子数据造个两三百条足够了。但有一个操作会让数据瞬间膨胀——如果你做了重置并批量生成数据的功能或者测试时反复点批量操作条数可能上千。这时候如果直接把所有数据渲染进表格滚动会明显卡顿。判断标准很好把握我一般以单页 100 条为界。默认分页 20 条即使用户把每页改成 50、100普通表格也能扛住。如果业务上确实需要一页显示 200 条以上或者滚动到底部无限加载才需要考虑虚拟滚动。不过有一个更常见、更容易被忽略的性能问题表格里的每一行都有操作按钮每个按钮都绑定了事件处理函数和权限指令行数一多内存占用和首次渲染时间会显著上升。我在一个 500 条数据的测试里对比过去掉每行的权限判断后渲染时间大概能降三成。所以如果列表要做批量选择选择框和按钮的事件最好用事件委托——把点击监听挂在表格容器上通过 DOM 属性拿 id而不是每行单独绑。这算是列表页的一个进阶优化纯页面阶段可以先不做但心里要有这根弦。5. 发帖和编辑表单富文本、草稿、校验三件事怎么不打架5.1 校验规则要区分格式正确和业务合理表单校验最容易走极端要么完全不校验全靠后端纯页面阶段没有后端等于没校验要么校验得过于严格用户写个正常标题都被拦下来。我的原则是格式类校验必须做业务类校验给建议不给拦截。具体的规则我一般这么定字段规则类型标题去空格后 5 到 80 字符不能全是符号格式校验硬拦截内容去 HTML 标签后至少 10 字格式校验硬拦截板块必选格式校验硬拦截标签最多 5 个单个不超过 12 字格式校验硬拦截标题重复同板块内相似标题业务提醒软提示最后一条最有意思。同板块里有人发过几乎一样的标题要不要拦我的做法是提示但不拦给一个黄色的提示该板块已有相似标题《xxx》确认要继续吗因为很多时候用户就是要在同一个话题下发新帖硬拦会让人很烦。相似度判断也很简单去标点后比较前 10 个字符或者算一下编辑距离。纯页面阶段用最简单的包含判断就够了。富文本这块内容最终要转成 HTML 或 Markdown 存储。这里有个必须提前想清楚的问题XSS。纯页面阶段没有后端过滤你如果直接把用户输入的 HTML 用v-html渲染出来自己塞个script或img onerror就能触发。虽然是自己用的演示项目风险不大但这个习惯很危险一旦代码被复制到真实项目里就是漏洞。我的处理是三件事一是粘贴内容时做清洗用DOMParser解析后递归移除script、iframe、on*属性二是渲染时统一走一个sanitize函数而不是裸v-html三是允许多少标签就白名单多少标签比如只允许p、br、strong、em、ul、ol、li、a、code、pre、blockquote、img。这个白名单机制实现起来不到 50 行但能挡掉绝大多数问题。const ALLOWED new Set([P,BR,STRONG,EM,UL,OL,LI,A,CODE,PRE,BLOCKQUOTE,IMG]) export function sanitize(html) { const doc new DOMParser().parseFromString(html, text/html) const walk (node) { [...node.children].forEach(child { if (!ALLOWED.has(child.tagName)) { child.replaceWith(...child.childNodes) return } [...child.attributes].forEach(attr { const name attr.name.toLowerCase() if (name.startsWith(on) || name style || name srcdoc) { child.removeAttribute(attr.name) } }) walk(child) }) } walk(doc.body) return doc.body.innerHTML }注意replaceWith(...child.childNodes)这一行它的作用是保留内容但去掉标签本身。比如一个div不在白名单里直接删掉会把里面的文字也删了用replaceWith展开子节点内容就保住了。这个细节我调试了好几次才想明白。5.2 草稿自动保存节流、指纹、以及保存失败怎么办编辑器里的内容用户可能在写了几百字之后手滑关掉页面或者网络抖了一下。草稿自动保存能极大提升体验。实现上有两个关键决策。第一个是保存时机。我不用固定间隔定时保存而是用内容变化后静默 3 秒保存的防抖策略。这样用户连续打字时不会有频繁的写操作停下来思考时自动落盘。用watch监听内容加上debounce就行。import { debounce } from lodash-es const autoSave debounce(async () { if (!form.title !form.content) return await saveDraft({ ...form, postId: currentPostId.value }) draftSavedAt.value Date.now() }, 3000) watch(() [form.title, form.content, form.boardId], autoSave)第二个是草稿的归属和清理。如果同时编辑多篇帖子草稿要按postId分开存新帖用一个固定的临时 key发布成功后自动删除。这里有个坑如果用户有多个标签页开着同一个编辑页两个页面会互相覆盖草稿。我一般用一个sessionStorage里存的随机tabId作为草稿 key 的一部分来隔离或者干脆在文档标题上加个提示不处理多标签场景——纯页面项目里后者成本更低。还有一点经验草稿保存后要给出明确但不打扰的反馈。不要弹 Toast每 3 秒弹一次草稿已保存用户会疯。正确做法是在编辑器角落显示一行灰色小字草稿已保存 14:32或者显示一个对勾图标。如果保存失败比如 localStorage 满了这个位置改成红色文字草稿保存失败并保留localStorage里上一次成功的版本绝不能覆盖成空。6. 权限与路由纯前端怎么做看起来很像真的权限体系6.1 权限的本质是数据可见性不是按钮隐藏纯前端权限控制有一个必须想清楚的前提它本质上是体验优化不是安全边界。任何前端权限都能被绕过真正的安全在后端。想清楚这点设计就不会跑偏——前端权限的目标是让不同角色看到符合自己职责的界面避免误操作而不是防止越权。在这个前提下我一般设计三层控制路由级、菜单级、按钮级。三层用的是同一份权限数据只是作用点不同。// src/config/permissions.js export const ROLE_PERMISSIONS { admin: [*], moderator: [ post:list, post:view, post:audit, post:top, post:essence, reply:list, reply:delete, board:view ], user: [post:list, post:view] } export function hasPerm(role, perm) { const perms ROLE_PERMISSIONS[role] || [] return perms.includes(*) || perms.includes(perm) }路由守卫里读取路由meta.permission和当前用户的权限比对不通过就跳到一个统一的无权限页而不是静默跳首页。静默跳转是最糟的处理用户点了菜单什么都没发生会以为系统坏了。给出明确的你没有访问该页面的权限请联系管理员反而体验更好。菜单渲染则是在路由表上做一次filter把当前用户没有权限的路由过滤掉。这样侧边栏自然只显示可访问的项不用维护两份配置。这里有个细节如果有父级菜单但子菜单全部无权限父级也要隐藏否则会出现一个点进去是空的折叠菜单。这个逻辑要递归处理。6.2 按钮级权限的指令怎么写才不留坑按钮级权限最常见的实现是一个自定义指令// src/directives/permission.js export const permission { mounted(el, binding) { const { value } binding const userStore useUserStore() if (value !hasPerm(userStore.role, value)) { el.parentNode el.parentNode.removeChild(el) } } }这个写法有坑我在项目里踩过两次。第一次是用el.style.display none隐藏看起来没问题但如果同时用了v-if或者 Element Plus 的表格列隐藏的元素仍然会影响布局——表格里会留一个空的单元格。改成removeChild之后布局正常了但引出了第二个问题在el-table的行内使用指令会报错因为表格的行是虚拟渲染的mounted时机拿不到稳定的父节点。我的解决方案是行内按钮不用指令改成在模板里判断el-table-column label操作 width180 template #default{ row } el-button v-ifcan(post:audit) clickaudit(row)审核/el-button el-button v-ifcan(post:top) clicktoggleTop(row)置顶/el-button /template /el-table-columncan就是hasPerm(role, perm)的一个包装。多了几个v-if但行为可预测也不会和表格的渲染机制打架。能用显式判断解决的就不要用指令这种隐式魔法这是我在权限这块最大的心得。还有一个容易被忽略的场景权限变化后的响应式更新。如果用户角色在运行时被切换比如演示用的以某某身份查看功能菜单和按钮要立刻刷新。用v-if判断的话只要role是响应式的切换后自动重渲染用指令的话就麻烦了因为指令只在mounted执行一次得手动实现updated钩子。这又是一个显式判断优于隐式指令的理由。7. 适配方案管理后台在大屏和笔记本上都得能用7.1 表格的横向空间分配是最费脑子的事后台系统里表格是绝对主角而表格的适配问题几乎全在列宽怎么分配上。我的经验是只给关键列设固定宽度其余列用自适应并且永远留一列允许换行。具体来说操作列、状态列、时间列这种宽度可预期的设固定值比如操作列 160px、状态列 100px、时间列 170px。标题列用min-width让它占据剩余空间。如果总宽度超出容器让表格出现横向滚动而不是硬挤压每一列。这里要特别注意如果不设置min-widthElement Plus 的表格会把窄屏下的列压得非常窄标题变成三四个字一行非常难看。列宽度策略推荐值选择框固定48px标题min-width 自适应min-width 240px板块固定120px作者固定120px状态固定100px数据浏览/回复固定120px更新时间固定170px操作固定按按钮数量算160-220px时间列如果宽度不够会出现换行或者被截断成省略号用户根本看不清具体时间。我的做法是格式化成01-15 14:32这种紧凑格式年份只在跨年时显示。看起来是小优化但一行能省下 60px 左右对窄屏很关键。7.2 大屏场景字号、留白、以及图表的 resize有些后台是投在大屏上看的数据看板、监控中心这类场景和普通笔记本使用完全是两套逻辑。大屏意味着分辨率高1920 甚至 3840、观看距离远所以字号要大、留白要足、信息密度要低。我的做法是用clamp()做弹性字号而不是写死 px.dashboard-title { font-size: clamp(20px, 1.6vw, 32px); } .dashboard-value { font-size: clamp(32px, 3vw, 64px); font-weight: 600; }这样在 1366 宽度的笔记本上是 20px 和 32px在 2560 的大屏上自动放大到 32px 和 64px不用写媒体查询。vw和clamp的组合是我目前觉得最省事的方案。图表如果有统计看板必须处理resize。这个问题非常经典窗口变化时图表不跟着变或者左侧菜单折叠后图表容器宽度变了但图表还是老宽度右边被裁掉一块。解决方案是监听容器尺寸变化而不是窗口用ResizeObserver比window.resize更准因为它能感知到菜单折叠这种容器变了但窗口没变的情况。const ro new ResizeObserver(() chart?.resize()) ro.observe(containerRef.value) onUnmounted(() ro.disconnect())一定要在onUnmounted里disconnect否则组件销毁后观察器还在跑切换页面几次就会有一堆僵尸观察器内存持续增长。另外大屏场景下侧边菜单的行为也要调整。笔记本上折叠菜单是为了省空间大屏上折叠反而显得空。我的判断逻辑是默认展开用户手动折叠后把状态存进 localStorage下次进入沿用。这里有个细节宽屏下折叠状态要单独存一份不能和小屏共用否则用户在大屏上折叠了换到笔记本上打开也是折叠的很别扭。8. 从纯页面切到真实接口一份可执行的替换清单8.1 切换时要动的文件其实很少如果你前面按我说的方式做了分层切接口这件事的工作量会小得让你意外。核心动作就三个把开关关掉、把mock目录从构建里排除、逐个核对接口的入参和出参。我整理过一份实际的替换清单每次切接口时照着走一遍检查项纯页面阶段切接口时要做的开关VITE_USE_MOCKtrue改成false检查构建产物里没有 mock chunk请求地址/api/posts核对是否有统一前缀如/admin是否需要走代理字段命名驼峰authorName后端常返回下划线author_name需要转换层分页参数page/pageSize后端常见的是pageNum/pageSize或offset/limit分页返回{ list, total }可能是{ records, total }或{ rows, count }时间字段毫秒时间戳可能是 ISO 字符串格式化函数要能兼容错误结构{ code, message }核对业务错误码比如 401 要触发重新登录空数据返回空数组有的后端返回null前端要做兜底布尔值true/false有的后端返回0/1注意if判断这里面最耗时间的是字段命名转换。一个可选的做法是在request层做一次统一的键名转换写两个递归函数把下划线和驼峰互相转换。但我更推荐直接让后端改能不改就不加这层转换因为转换层会带来三个问题性能开销、调试时看到的字段和网络面板里的不一致、以及嵌套深了之后容易出错。只有在后端确实不愿意改的情况下才加转换层而且要加就加得彻底别一半转一半不转。8.2 联调阶段最容易崩的四个地方切换真接口之后我遇到问题最多的四个场景基本每次都会撞上至少两个。第一个是 loading 状态的竞态。纯页面阶段延迟是可控的快速切换筛选条件可能看不出问题。接上真接口后网络时延不确定很容易出现先发的请求后返回——用户先筛选 A 再筛选 BA 的结果却覆盖了 B 的。解决方案是加请求序号每次发请求时记一个自增 ID回来时比对当前 ID不是最新的就丢弃。这个改动不到 10 行但能避免一类非常难排查的诡异 bug。let reqSeq 0 async function fetchList() { const seq reqSeq const res await getPostList(query) if (seq ! reqSeq) return // 丢弃过期响应 list.value res.list total.value res.total }第二个是批量接口的语义差异。纯页面阶段我是循环调用单个删除接口接了真接口后后端可能提供批量接口也可能不提供。如果有批量接口要注意它返回的是全部成功还是逐条结果。我遇到过批量接口直接返回总数不返回失败详情的那就需要在后端补或者前端退回循环调用。这类问题在联调会议上一定要提出来别等到上线。第三个是时间格式化的时区问题。后端返回 ISO 字符串带时区如2024-01-15T06:32:00Z你如果直接用new Date(str).toLocaleString()会按浏览器本地时区显示看起来少了几小时。正确做法是明确转换到目标时区或者让后端直接返回已经格式化好的展示字符串。我倾向于让后端返回时间戳展示层自己格式化这样时区问题只在一处处理。第四个是空状态和错误状态。纯页面阶段你造的种子数据总是有内容的接口接上后才发现很多页面的没数据展示根本没做——表格空白一片用户不知道是加载完了没数据还是卡住了。所以每个列表页都要有明确的空状态文案和图标错误时要有重试按钮。这个工作量加起来不小但它是产品完成度的分水岭。8.3 那些我踩过、希望你别再踩的坑最后说几个具体的、文档里基本不会写的坑。中文排序。[张三,李四].sort()的结果是按 Unicode 码点排的不是按拼音。要按拼音排得用localeCompare(zh-Hans-CN)。这个在用户列表按昵称排序时会被提出来。数字输入框的空值。表单里浏览量这类数字字段用户清空输入框后绑定值会变成undefined或空字符串Number()是 0但Number(undefined)是NaN。这个NaN传到接口里会变成null或者被序列化成null字符串导致参数异常。我的处理是统一在提交前过一遍清洗函数把NaN、undefined、空字符串都转成null并删掉这个键。表格列的顺序调整。如果做了列显示/隐藏功能用户调好的顺序要持久化。用数组存列 key 的顺序比存一堆布尔值更好管理加新列时也容易插到默认位置。富文本粘贴。从 Word 里粘贴会带一大堆内联样式和mso-开头的属性体积能膨胀十几倍。我的做法是监听paste事件preventDefault后从clipboardData里取text/html过一遍清洗或者干脆只取text/plain手动转段落。后者的结果更干净代价是丢失加粗等格式。我一般做成可配置的。删除后的页码回退。当前是第 5 页删掉了这页的最后一条再刷新列表时第 5 页是空的。要在删除成功后判断当前页剩余数量是否为 0 且当前页大于 1是的话页码减一。这个小逻辑不写用户会以为数据没删掉。我个人在这类项目上体会最深的一点是纯页面项目的价值不在于逼真而在于把不确定性提前消灭掉。接口字段、分页语义、权限边界、空状态、并发请求、时区这些东西在后端没到位的时候想清楚成本极低等联调的时候再处理每一条都要拉着前后端两个人对半天。我现在的习惯是纯页面阶段就把字段字典和接口清单两份文档整理出来一起交付给后端。做完这个动作之后联调的时间基本能压缩到原来的三分之一而且过程会平静得多——不会再有那种接口回来了但全对不上的崩溃时刻。
返回列表