☰
纯页面论坛管理后台:Mock工程化与接口平滑切换实战
2026/9/30 5:52:40 网站建设 项目流程

"后端接口还在联调,产品明天就要看效果"——这是我做论坛信息管理系统这类后台需求时最常撞上的场面。前端项目里,论坛信息管理系统(纯页面)这种形态,说白了就是不依赖真实服务端,靠前端自己造的一层数据把整条交互链路跑通:板块管理、帖子列表、发帖编辑、回复审核、用户封禁、批量操作、权限控制,一个都不少。它解决的不是"能不能显示数据",而是"在接口没到位之前,业务逻辑、交互细节、边界状态能不能先被验证一遍"。

这套东西适合三类人上手:一是想练一个完整度够高的前端项目实战案例、用来充实作品集或简历的同学;二是正在做前后端并行开发,需要先出一版可点可看的原型给业务方确认;三是刚转前端、想搞明白"一个后台系统到底由哪些零件拼起来"的开发者。我下面要拆的,不是照着某个模板抄一遍页面,而是把数据建模、假数据工程化、列表交互、表单校验、权限路由、适配方案、以及后期切换真接口的替换清单,一层一层掰开讲。你看完之后,应该能自己从零搭出一套结构清晰、后期能平滑接入真实后端的论坛管理台,而不是做一堆点两下就散架的静态页。

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: '老张', // 冗余字段,避免列表页 N+1 查询 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 条帖子,如果每条都要去查一次用户表拿昵称,就是典型的 N+1 问题——纯页面阶段你看不出性能差异,因为假数据都在内存里,但一旦接了真接口,这个设计缺陷会立刻暴露。所以我在建模阶段就把它写进契约,让后端返回时直接带上。

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直接抛异常,整个应用写操作全部失效。我的处理是:

  • 内容里的图片在纯页面阶段只用本地预览 URL(URL.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配合一个watch:

import { 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,用户点第二页就会被打回第一页,陷入死循环。我的处理是把"筛选字段"和"分页字段"分开 watch:

watch(() => [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="操作" width="180"> <template #default="{ row }"> <el-button v-if="can('post:audit')" @click="audit(row)">审核</el-button> <el-button v-if="can('post:top')" @click="toggleTop(row)">置顶</el-button> </template> </el-table-column>

can就是hasPerm(role, perm)的一个包装。多了几个v-if,但行为可预测,也不会和表格的渲染机制打架。能用显式判断解决的,就不要用指令这种隐式魔法,这是我在权限这块最大的心得。

还有一个容易被忽略的场景:权限变化后的响应式更新。如果用户角色在运行时被切换(比如演示用的"以某某身份查看"功能),菜单和按钮要立刻刷新。用v-if判断的话,只要role是响应式的,切换后自动重渲染;用指令的话就麻烦了,因为指令只在mounted执行一次,得手动实现updated钩子。这又是一个"显式判断优于隐式指令"的理由。

7. 适配方案:管理后台在大屏和笔记本上都得能用

7.1 表格的横向空间分配是最费脑子的事

后台系统里表格是绝对主角,而表格的适配问题几乎全在"列宽怎么分配"上。我的经验是:只给关键列设固定宽度,其余列用自适应,并且永远留一列允许换行。

具体来说,操作列、状态列、时间列这种宽度可预期的,设固定值,比如操作列 160px、状态列 100px、时间列 170px。标题列用min-width,让它占据剩余空间。如果总宽度超出容器,让表格出现横向滚动,而不是硬挤压每一列。这里要特别注意,如果不设置min-width,Element 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_MOCK=true改成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 再筛选 B,A 的结果却覆盖了 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",是的话页码减一。这个小逻辑不写,用户会以为数据没删掉。

我个人在这类项目上体会最深的一点是:纯页面项目的价值不在于逼真,而在于把不确定性提前消灭掉。接口字段、分页语义、权限边界、空状态、并发请求、时区,这些东西在后端没到位的时候想清楚,成本极低;等联调的时候再处理,每一条都要拉着前后端两个人对半天。我现在的习惯是,纯页面阶段就把"字段字典"和"接口清单"两份文档整理出来,一起交付给后端。做完这个动作之后,联调的时间基本能压缩到原来的三分之一,而且过程会平静得多——不会再有那种"接口回来了但全对不上"的崩溃时刻。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询