JSP九宫格日志网站实战:布局、数据与分页设计 九宫格日志这个形态最早我是从一个做个人站点的朋友那儿看到的。他当时的需求很朴素每天写点东西但不想用那种一打开就是长长时间线的博客系统觉得太重排版也太单调。后来他自己折腾了一个九宫格布局的日志页面每天一个格子点进去是当天的记录首页就是一面日志墙。这个思路一下子把我点醒了——原来日志网站不一定非得是列表流九宫格这种网格化呈现既保留了时间维度的秩序感又让整个首页有了视觉上的呼吸感。这篇要聊的就是用JSP搭一个九宫格日志网站。JSP 这个技术栈现在听起来有点复古但它恰恰是很多高校课程设计、小型内部系统、个人练手项目里绕不开的一环。用 JSP 做日志网站核心要解决的无非几件事日志的增删改查、九宫格首页的布局渲染、分页与日期归档、以及用户登录态的管理。听起来不复杂但真动手做坑一个接一个。下面我按自己实际搭这套东西的顺序把每个环节的取舍、原理和踩过的坑都摊开讲适合刚接触 JSP 的同学照着复现也适合想快速搭个轻量日志站的老手直接抄作业。1. 为什么九宫格布局值得单独设计一套渲染逻辑很多人拿到九宫格日志这个需求第一反应是首页放九个div用 CSS Grid 一摆就完事了。这个想法没错但只对了一半。九宫格的难点从来不在摆九个格子而在于格子里放什么、格子怎么和后台数据对应、格子数量不够或超了怎么办。这三件事决定了你不能把它当成纯静态页面来做必须为它设计一套独立的渲染逻辑。1.1 九宫格本质是固定容量 动态填充的分页模型九宫格的字面意思是 3×3 共 9 个格子。但日志是持续增长的今天写了 3 篇明天可能写 5 篇一个格子到底代表一篇日志还是一天这是第一个要拍板的设计决策。我试过两种方案。第一种是一格一日每个格子对应一个日期格子里显示当天日志的标题摘要点进去看当天全部内容。第二种是一格一篇每个格子就是一篇日志9 个格子正好是最近 9 篇。两种方案我都跑过最后选了第一种原因很实际——日志这个东西天然带日期属性用户的心理预期是我某天写了什么而不是我第 37 篇写了什么。一格一日更符合日志的使用直觉。选定一格一日之后九宫格就变成了一个固定容量为 9 的分页容器。首页展示最近 9 天有日志的日期第 10 天开始就要翻页。这里有个容易忽略的细节如果某天没写日志这一天要不要占格子我的处理是不占只渲染有日志的日期否则首页会出现大量空格子视觉上很空。这就意味着后台查询不能简单地limit 9 offset 0而要先按日期分组、过滤掉空日期再取前 9 组。这个查询逻辑后面会详细讲。提示一格一日的方案下9这个数字最好做成配置项而不是硬编码。我一开始写死了 9后来想改成 124×3适配宽屏结果发现分页、查询、前端布局里到处是魔法数字改起来很痛苦。1.2 用 CSS Grid 还是传统浮动布局确定了数据模型接下来是布局实现。JSP 时代的老项目很多用float或者table布局但我强烈建议用CSS Grid。原因很简单九宫格是典型的二维网格Grid 天生就是干这个的三行代码就能定义.log-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; }对比浮动布局Grid 不需要清除浮动、不需要计算百分比宽度、gap 直接控制间距格子内容多少都不会撑破布局。唯一要注意的是老浏览器兼容但现在的项目基本不用管 IE 了。如果你确实要兼容很老的環境那就退回float: left; width: 33.33%加box-sizing: border-box再配合nth-child(3n1)清除浮动麻烦但能用。我实测下来Grid 方案在格子内容长度不一致时表现最稳。因为日志摘要有的长有的短浮动布局下高度不齐会导致换行错位而 Grid 的grid-auto-rows: 1fr能让同一行的格子自动等高视觉上整齐得多。1.3 格子内部的信息密度控制一个格子就那么大塞太多信息会挤塞太少又浪费。我的经验是每个格子固定放四样东西日期大号、星期小号、日志标题最多两行超出省略、摘要最多三行超出省略。标题和摘要的截断用 CSS 的-webkit-line-clamp实现比在后台截字符串更灵活因为它是按渲染后的行数算的不受中英文混排影响。.log-card .title { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }这里有个小坑line-clamp需要配合display: -webkit-box才生效而且它截断后不会自动加省略号之外的提示。如果你希望用户知道还有更多可以在格子右下角放一个查看的小链接或者干脆让整个格子可点击。我选的是整格可点用a包住整个卡片交互上最省事用户也不用去找那个小小的。2. 日志数据表的设计日期维度怎么落库九宫格按日期分组展示这个需求直接决定了数据表的设计。很多人上来就建一张log表字段是id, title, content, create_time然后查询时用DATE(create_time)分组。这个做法能用但有几个隐患我在实际项目里踩过值得单独拎出来说。2.1 单独存一个 log_date 字段的必要性用create_time的时间戳直接DATE()分组问题在于同一天多篇日志的排序、按日期查询的性能、以及补录旧日志的场景。补录这个场景很关键——用户可能今天想补写上周三的日志这时候create_time是今天但日志归属日期应该是上周三。如果只有create_time你就没法区分写作时间和日志归属日期。所以我的表结构里单独加了log_date字段类型用DATE专门存日志归属的那一天。create_time保留作为记录创建时间。查询九宫格时按log_date分组补录场景下只要改log_date就行逻辑清晰。CREATE TABLE daily_log ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, log_date DATE NOT NULL, title VARCHAR(120) NOT NULL, content TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, INDEX idx_user_date (user_id, log_date) );注意那个联合索引idx_user_date。九宫格查询永远是某个用户的最近 N 天日志user_id log_date的联合索引能让这个查询走索引数据量大了之后差别非常明显。我做过一个粗略对比十万条数据下有索引的日期分组查询在几十毫秒没索引直接飙到秒级。2.2 一天多篇日志怎么处理一格一日的方案下如果用户某天写了三篇日志格子里显示哪篇我的处理是格子里显示当天最新一篇的标题和摘要点进去看当天全部。这样既保证了格子信息不空又不会丢失数据。对应到查询上九宫格首页需要的是每个日期取最新一篇的结果。这个用 SQL 实现有几种写法我推荐用窗口函数MySQL 8.0SELECT log_date, title, content FROM ( SELECT log_date, title, content, ROW_NUMBER() OVER (PARTITION BY log_date ORDER BY create_time DESC) AS rn FROM daily_log WHERE user_id ? ) t WHERE rn 1 ORDER BY log_date DESC LIMIT 9;如果你的数据库版本不支持窗口函数那就退回到子查询取每组的最大 id或者干脆在应用层查当天所有日志再取第一条。应用层处理虽然多查了点数据但逻辑最直观小数据量下完全够用。我早期就是用应用层处理的后来数据涨上来才换成窗口函数。2.3 分页与跳过空日期的查询配合前面提到九宫格只渲染有日志的日期所以分页不能按自然日算要按有日志的日期算。这意味着LIMIT 9 OFFSET 9里的 offset 是第几个有日志的日期而不是第几天。用上面的窗口函数查询外层直接LIMIT 9 OFFSET ?就能实现因为内层已经过滤成每个日期一行了。这里有个边界情况要处理最后一页可能不足 9 个格子。前端布局上不足的格子留空还是让已有格子拉伸我选的是留空用 Grid 的话天然就是留空视觉上反而有种这页就这么多的自然感。如果你希望格子始终填满那就得在后台补空对象前端渲染成占位卡片稍微麻烦点看个人审美。3. JSP 页面与后端 Servlet 的职责切分JSP 项目最容易写乱的地方就是把 Java 代码、SQL、HTML 全塞进一个.jsp文件里。我见过不少课程设计一个 jsp 文件几百行% %脚本块里连数据库查询都写了。这种写法能跑但维护起来是灾难。我的原则是JSP 只负责渲染Servlet 负责流程控制DAO 负责数据访问三层各司其职。3.1 Servlet 里做参数校验和分页计算以九宫格首页为例请求进来先到IndexServlet它要做的事是读取页码参数、校验合法性、调用 DAO 查数据、把结果放进 request 域、转发到 JSP。页码校验这块很多人偷懒不写结果用户手动改 URL 传个page-1或者pageabc后台直接抛异常。public class IndexServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int page 1; String pageStr req.getParameter(page); if (pageStr ! null) { try { page Integer.parseInt(pageStr); } catch (NumberFormatException e) { page 1; } } if (page 1) page 1; LogDao dao new LogDao(); int totalDays dao.countLogDays(userId); int totalPages (totalDays 8) / 9; if (page totalPages totalPages 0) page totalPages; ListLogDay days dao.findRecentDays(userId, page, 9); req.setAttribute(days, days); req.setAttribute(currentPage, page); req.setAttribute(totalPages, totalPages); req.getRequestDispatcher(/WEB-INF/views/index.jsp).forward(req, resp); } }注意totalPages的计算用了(totalDays 8) / 9这是向上取整的经典写法比Math.ceil(totalDays / 9.0)更不容易出精度问题。还有page totalPages的兜底防止用户翻到超出范围的页导致空页面。3.2 JSP 里用 JSTL 替代脚本块JSP 里渲染列表最干净的方式是 JSTL 的c:forEach配合 EL 表达式取值。这样页面里几乎看不到 Java 代码前端同学也能看懂。div classlog-grid c:forEach items${days} varday a classlog-card hrefdetail?date${day.logDate} div classdate${day.logDate}/div div classweekday${day.weekday}/div div classtitle${day.title}/div div classsummary${day.summary}/div /a /c:forEach /div这里day.weekday是后台算好放进去的不要在 JSP 里用脚本块算星期几。后台用java.time.LocalDate的getDayOfWeek()拿到枚举再映射成中文逻辑集中在一处改起来方便。我见过在 JSP 里写new java.util.Date().getDay()的不仅代码丑还容易因为时区问题算错。注意JSP 文件放在WEB-INF/views/下这样用户不能直接通过 URL 访问 jsp必须经过 Servlet 转发。这是 JSP 项目的基本安全习惯很多人图省事把 jsp 放根目录结果用户绕过 Servlet 直接访问参数没初始化就报错。3.3 日期格式化与中文星期的处理日期在九宫格里要显示成2024-06-15这种格式星期显示成星期六。后台统一处理成字符串再传给前端比在 JSP 里格式化更可控。我封装了一个小工具方法public static String toChineseWeekday(LocalDate date) { String[] weeks {星期一,星期二,星期三,星期四,星期五,星期六,星期日}; return weeks[date.getDayOfWeek().getValue() - 1]; }getValue()返回 1 到 7 对应周一到周日减 1 正好对上数组下标。这个映射关系要记牢DayOfWeek的枚举值MONDAY是 1 不是 0跟Calendar的SUNDAY1完全不一样混用会错位。我当初从Calendar迁到LocalDate时就因为这个踩过坑星期整体偏了一天。4. 日志详情页与当天多篇的展示策略九宫格点进去是详情页但详情页展示的不是一篇日志而是某一天的全部日志。这个设计上的差异决定了详情页的查询和渲染逻辑跟普通博客完全不同。4.1 按日期查全部日志的查询设计详情页接收的参数是date比如detail?date2024-06-15。后台拿到日期后查这个用户这一天的所有日志按create_time升序排列先写的在前符合阅读顺序。SELECT id, title, content, create_time FROM daily_log WHERE user_id ? AND log_date ? ORDER BY create_time ASC;这里参数date的解析要小心。用户可能传2024-6-5这种不规范的格式LocalDate.parse默认要求2024-06-05。我的处理是用DateTimeFormatter.ofPattern(yyyy-M-d)来解析它能兼容一位和两位的月份日期。解析失败就跳转到错误页不要让它抛异常到用户脸上。4.2 详情页的锚点与目录一天多篇日志时详情页会很长。我在页面顶部加了一个简单的目录列出当天所有日志的标题点击跳到对应锚点。锚点用日志 id 生成比如#log-37每篇日志的容器idlog-37。这个功能实现简单但对阅读体验提升很大尤其是某天写了五六篇的时候。div classtoc c:forEach items${logs} varlog a href#log-${log.id}${log.title}/a /c:forEach /div目录的样式我建议做成横向排列的小标签不要做成竖排长列表否则详情页顶部会被目录占掉大半屏。横向标签在移动端可以横向滚动体验也还行。4.3 内容渲染的转义与换行日志内容是用户输入的纯文本渲染到页面时必须做 HTML 转义否则用户输入script就会出问题。JSTL 的c:out默认会转义直接用就行。换行的话纯文本里的\n在 HTML 里不生效要么用pre标签要么把\n替换成br。我选的是pre配合 CSS 的white-space: pre-wrap这样既保留换行又自动折行还不用做字符串替换。pre-wrap这个值很关键只用pre的话长行不会折行会撑出横向滚动条。.log-content { white-space: pre-wrap; word-wrap: break-word; }word-wrap: break-word是给超长英文单词或 URL 兜底的防止它们撑破容器。这两个属性搭配基本能覆盖日志内容的绝大多数排版情况。5. 登录态管理与多用户日志隔离日志网站通常要区分用户每个人只能看自己的日志。JSP 时代最常用的登录态方案是Session配合一个登录拦截器。这块逻辑不复杂但细节没处理好会出现串号或者未登录也能访问的问题。5.1 Session 存什么、怎么校验登录成功后把userId和username放进 Session不要放整个用户对象避免序列化问题和数据过期。每个需要登录的 Servlet 在开头校验 SessionHttpSession session req.getSession(false); if (session null || session.getAttribute(userId) null) { resp.sendRedirect(req.getContextPath() /login); return; } int userId (Integer) session.getAttribute(userId);注意getSession(false)的用法它表示如果没有 Session 就返回 null不要新建。如果用getSession()无参版本未登录用户访问时会自动创建一个空 Session虽然不影响功能但会浪费服务器内存也容易在统计在线人数时算错。5.2 用 Filter 统一拦截而不是每个 Servlet 都写每个 Servlet 都写一遍登录校验重复且容易漏。标准做法是写一个LoginFilter在web.xml或注解里配置拦截路径统一处理。WebFilter(urlPatterns {/index, /detail, /edit, /delete}) public class LoginFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; HttpSession session request.getSession(false); if (session null || session.getAttribute(userId) null) { response.sendRedirect(request.getContextPath() /login); return; } chain.doFilter(req, resp); } }拦截路径要列全新增 Servlet 时别忘了加进来。我吃过这个亏后来加了个写日志的 Servlet 忘了配拦截结果未登录也能提交数据全挂在一个空 userId 下清理起来很麻烦。5.3 数据隔离每个查询都必须带 user_id登录态只是第一道关真正的隔离靠的是每条 SQL 都带user_id条件。这一点听起来是常识但实际项目里因为漏加条件导致用户看到别人数据的案例太多了。我的习惯是 DAO 层所有查询方法的第一个参数固定是userId从方法签名上就强制带上减少遗漏。public ListLogDay findRecentDays(int userId, int page, int size) { ... } public ListDailyLog findByDate(int userId, LocalDate date) { ... } public void deleteById(int userId, int logId) { ... }删除和修改尤其要注意deleteById必须同时匹配id和user_id否则用户 A 猜到用户 B 的日志 id 就能删掉别人的日志。这种越权在小型项目里经常被忽略但确实是实打实的安全漏洞。6. 实际搭建中反复踩到的几个坑前面按模块讲了设计和实现这一节专门说说我在实际跑这套东西时踩到的、文档里不会写的坑。这些细节往往决定了项目是能跑还是好用。6.1 中文乱码的三处来源JSP 项目的中文乱码是老生常谈但来源其实有三处缺一处就乱。第一处是JSP 页面编码文件头要写% page contentTypetext/html;charsetUTF-8 %。第二处是请求参数编码POST 请求要在读参数前设置req.setCharacterEncoding(UTF-8)GET 请求在 Tomcat 8 之后默认就是 UTF-8但老版本要在server.xml里配URIEncoding。第三处是数据库连接编码JDBC URL 里要带characterEncodingutf8。三处都配齐中文才不会乱。我当初只配了页面编码结果表单提交的中文进数据库变成问号排查了半天才发现是请求编码没设。建议在 Filter 里统一设一次请求编码比每个 Servlet 写一遍靠谱。6.2 日期参数在 URL 里的编码问题详情页的 URL 是detail?date2024-06-15日期本身没有特殊字符一般不会出问题。但如果你在 URL 里传中文比如按标题搜索就必须URLEncoder.encode处理。我建议日期参数统一用yyyy-MM-dd格式避免各种解析歧义前端生成链接时也统一格式别一会儿2024-6-5一会儿2024/06/05。6.3 分页边界只有一页时要不要显示翻页控件这个细节很小但影响观感。如果总共只有 5 天日志一页就装下了翻页控件还显示上一页 下一页就很怪。我的处理是totalPages 1时才渲染翻页控件否则隐藏。另外当前页是第一页时上一页置灰最后一页时下一页置灰这些状态判断在 JSP 里用c:if做就行。6.4 空状态的处理新用户刚注册一条日志都没有九宫格首页就是一片空白。这时候要给个友好的空状态提示引导用户去写第一篇日志而不是让用户对着一片空白发懵。空状态判断就是days列表为空时渲染一段提示文案加一个写日志的按钮几行代码的事但体验差别很大。7. 从能跑到好用几个提升体验的细节项目能跑起来之后我花了一些时间打磨体验下面这几个改动投入产出比最高值得优先做。7.1 九宫格格子的悬停反馈格子默认是静态的鼠标移上去没有任何反馈用户不确定能不能点。加一个:hover效果轻微上浮加阴影点击意图立刻清晰。.log-card { transition: transform 0.2s, box-shadow 0.2s; } .log-card:hover { transform: translateY(-4px); box-shadow: 0 8px 20px rgba(0,0,0,0.12); }transition让变化平滑translateY(-4px)上浮一点点就够幅度大了反而晃眼。这个效果在所有现代浏览器上都支持成本几乎为零。7.2 今天这一格的视觉强调九宫格里今天这一格可以做个特殊标记比如边框加粗或者角标显示今天。用户打开首页第一眼就能定位到当天符合日志的使用习惯。实现上后台传一个isToday标志前端加个 class 就行。7.3 写日志页面的日期默认值写日志时日期默认填今天但允许用户改成过去的日期补录场景。用input typedate原生控件浏览器自带日期选择器不用引第三方库。默认值在后台渲染时填LocalDate.now()用户不改就是今天。input typedate namelogDate value${today} max${today}max设成今天防止用户选未来日期。日志这个东西写未来的没意义限制一下更合理。7.4 删除操作的二次确认删除日志是不可逆操作直接点一下就删太危险。前端加个confirm确认框是最低成本的防护function confirmDelete(id) { if (confirm(确定删除这篇日志吗删除后不可恢复)) { location.href delete?id id; } }confirm虽然样式朴素但胜在零依赖、全兼容。如果追求美观可以换成自定义弹窗但功能上confirm完全够用。删除接口本身也要做登录校验和user_id匹配前端确认只是防误触后端校验才是防越权。8. 这套结构还能往哪些方向长九宫格日志网站作为一个基础形态骨架搭好之后扩展空间其实挺大。我自己后续加过几个功能也想过一些没来得及做的方向这里一并说说给想继续折腾的人一点参考。8.1 按月份归档的二级视图九宫格首页是最近 9 天那更早的日志怎么看我加了一个按月归档的页面左侧列出有日志的月份点进去看该月所有日期。这个页面用列表布局比九宫格更合适因为一个月可能有二十多天有日志九宫格装不下。归档页和首页共用同一套 DAO只是查询条件从最近 N 天变成某月全部改动很小。8.2 日志标签与检索给日志加标签然后支持按标签筛选是提升可用性的常见做法。表结构上加一张标签关联表查询时 join 一下。不过标签体系一旦引入录入时的交互就要设计好是自由输入还是预设选择这块我建议先想清楚再动手否则后期改起来牵扯面很大。8.3 导出为纯文本或 Markdown日志数据存在数据库里用户会有想把数据拿走的需求。加一个导出功能把某段时间的日志拼成 Markdown 文本下载实现不难但对用户来说是实实在在的安全感。导出时注意文件名和编码中文文件名在下载时要URLEncoder处理否则部分浏览器会乱码。8.4 移动端的九宫格适配九宫格在手机上三列会太挤我的处理是媒体查询下改成两列甚至一列。Grid 改列数只需要改grid-template-columns一个属性非常方便media (max-width: 640px) { .log-grid { grid-template-columns: repeat(2, 1fr); } }两列在手机上格子宽度适中信息还能看清。如果内容再精简一些一列大卡片也是不错的选择看具体内容密度决定。这套东西我从零搭到能日常用前后断断续续花了不少时间大部分时间其实不是花在写功能上而是花在调那些看起来能跑但用起来别扭的细节上。九宫格布局本身不复杂真正让它好用的是日期维度的数据设计、分页与空日期的处理、以及那些悬停反馈、空状态、二次确认之类的小打磨。如果你也在用 JSP 做类似的东西建议先把数据表和查询逻辑想清楚前端布局反而是最容易调整的部分。真动手时遇到查询结果对不上格子数的情况八成是分页 offset 按自然日算了而不是按有日志的日期算回头检查一下那个窗口函数查询就明白了。