Tio Boot + Enjoy:分页与 SEO 实战指南
背景与目的
在构建现代 Web 应用时,我们往往需要同时兼顾 性能、用户体验 和 搜索引擎优化 (SEO)。
- 性能:后端要能高效分页,避免一次性返回海量数据,保证前端加载流畅;
- 用户体验:分页导航要直观、简洁,支持键盘快捷键与自定义每页大小;
- SEO:页面内容应能被搜索引擎爬虫直接识别,不依赖复杂的 JavaScript 动态渲染。
本文面向使用 Tio Boot 框架 与 Enjoy 模板引擎 的开发者,提供一套完整的分页实现方案:
后端
- 使用
Db.paginate
与Page<T>
封装查询结果; - 提供总页数、总记录数等分页元信息;
- 支持
/{pageNo:\\d+}/{pageSize:\\d+}
路由,灵活访问/
或指定分页地址;
- 使用
前端模板
- 首页使用
<a>
卡片链接而非onclick
,让搜索引擎可直接抓取; - 支持分页条、总页数/总记录数显示;
- 提供响应式布局和键盘快捷翻页。
- 首页使用
SEO 优化
- 链接静态化(如
/preview/123
); - 首页卡片内容可被直接爬取;
- 模板中包含
<title>
与<meta description>
,利于搜索引擎收录。
- 链接静态化(如
通过本文,你可以快速为自己的 Tio Boot 项目增加一个 可分页、可被搜索引擎收录 的首页与预览页面,兼顾用户体验与 SEO 效果。
接下来的章节,逐步介绍 拦截器与路由配置、Handler、Service 分页逻辑、首页模板 index.html、以及可选的 预览页优化。
1. 拦截器与路由配置
1.1 放行规则(支持可选段)
将首页与详情页加入白名单,且首页支持 /{pageNo:\\d+}/{pageSize:\\d+}
可选段(?
表示该段可省略,末尾连续可选)。
// Tio Boot Enjoy SEO
String[] permitUrls = { "/", "/{pageNo:\\d+}/{pageSize:\\d+}", "/preview/**", "/ping"};
new TioAdminInterceptorConfiguration(permitUrls).config();
这样即可避免使用
/**
大范围放行,精确授权首页与预览页。
1.2 路由(只需两条)
首页 1 条路由即可覆盖 /
与 /pageNo/pageSize
;预览页按 ID 访问:
HttpRequestRouter r = server.getRequestRouter();
VideoPageHandler videoPageHandler = new VideoPageHandler();
r.add("/{pageNo:\\d+}/{pageSize:\\d+}", videoPageHandler::index);
r.add("/preview/{id}", videoPageHandler::preview);
若你希望页码/页大小必须为数字,可写成:
/ {pageNo:\d+}? / {pageSize:\d+}?
2. Handler:接收可选段参数并渲染页面
VideoPageHandler
从路由注入的 pageNo/pageSize/id
中取值(不存在则用默认值),调用 Service 渲染 HTML。
package com.litongjava.manim.handler;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.manim.services.VideoPageService;
import com.litongjava.tio.boot.http.TioRequestContext;
import com.litongjava.tio.http.common.HttpRequest;
import com.litongjava.tio.http.common.HttpResponse;
import com.litongjava.tio.http.server.util.Resps;
import lombok.extern.slf4j.Slf4j;
@Slf4j
public class VideoPageHandler {
VideoPageService srv = Aop.get(VideoPageService.class);
public HttpResponse index(HttpRequest request) {
HttpResponse response = TioRequestContext.getResponse();
Integer pageNo = null;
Integer pageSize = null;
try {
pageNo = request.getInt("pageNo", 1);
pageSize = request.getInt("pageSize", 10);
} catch (Exception e) {
return null; // 返回 null 交由后续链路(如 404/500)处理
}
VideoPageService indexService = Aop.get(VideoPageService.class);
String html = indexService.index(pageNo, pageSize);
Resps.html(response, html);
return response;
}
public HttpResponse preview(HttpRequest request) {
Long id = request.getLong("id");
HttpResponse response = TioRequestContext.getResponse();
String html = srv.getPreviewHtmlById(id);
return Resps.html(response, html);
}
}
3. Service:分页查询与模板变量注入
利用已有的 Page<T>
与 Db.paginate(...)
,一次性把分页变量(如 totalPage/totalRow/hasPrev/hasNext
等)注入模板,页面逻辑更简洁。
package com.litongjava.manim.services;
import java.util.List;
import com.jfinal.kit.Kv;
import com.litongjava.db.SqlPara;
import com.litongjava.db.activerecord.Db;
import com.litongjava.db.activerecord.Row;
import com.litongjava.jfinal.aop.Aop;
import com.litongjava.manim.consts.EfTableName;
import com.litongjava.model.page.Page;
import com.litongjava.template.EnjoyTemplate;
public class VideoPageService {
VideoService videoService = Aop.get(VideoService.class);
/** 渲染首页 */
public String index(int pageNo, int pageSize) {
pageNo = Math.max(1, pageNo);
pageSize = Math.max(1, pageSize);
// 注意:Db.paginate 会自动拼接 limit/offset
String sql = "select id,topic,language,view_count,create_time from %s where video_url is not null order by id";
sql = String.format(sql, EfTableName.ef_generated_video);
SqlPara sqlPara = SqlPara.by(sql);
Page<Row> page = Db.paginate(pageNo, pageSize, sqlPara);
List<Row> data = page.getList();
int pn = page.getPageNumber();
int ps = page.getPageSize();
int tp = page.getTotalPage();
int tr = page.getTotalRow();
boolean hasPrev = pn > 1;
boolean hasNext = pn < Math.max(1, tp);
int prevPage = hasPrev ? (pn - 1) : 1;
int nextPage = hasNext ? (pn + 1) : pn;
Kv kv = Kv.by("data", data)
.set("pageNo", pn).set("pageSize", ps)
.set("totalPage", tp).set("totalRow", tr)
.set("hasPrev", hasPrev).set("hasNext", hasNext)
.set("prevPage", prevPage).set("nextPage", nextPage);
return EnjoyTemplate.renderToString("index.html", kv);
}
/** 渲染预览页 */
public String getPreviewHtmlById(Long id) {
Row row = Db.findById(EfTableName.ef_generated_video, id);
Kv kv;
if (row != null) {
kv = row.toKv();
videoService.convert(kv);
kv.set("answer", Aop.get(AnswerService.class).queryAnserById(id));
List<String> transcript = Aop.get(ManimSceneCodeService.class).getTranscriptByGroupId(id);
String subTitle = String.join("", transcript);
kv.set("transcript", subTitle);
} else {
kv = Kv.by("title", "Not Found");
}
return EnjoyTemplate.renderToString("preview.html", kv);
}
}
若要最新在前,SQL 改为
order by id desc
; 模板变量totalPage
与totalRow
已注入,可直接用于显示。
4. 首页模板:index.html
(可爬取 + 分页)
- 卡片改为
<a>
,便于 SEO 爬取与可访问性。 - 底部分页条直接跳转
/{pageNo}/{pageSize}
。 - 文末显示当前页、总页、总条。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Study11 Learning Platform</title>
<meta name="description" content="Study11 — Explaining knowledge through animations. Browse generated videos with topics and languages.">
<style>
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family:'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg,#667eea 0%,#764ba2 100%);
min-height:100vh; padding:20px;
}
.container { max-width:1200px; margin:0 auto; }
.header { text-align:center;}
.header h1 {
color:#fff; font-size:2.5rem; margin-bottom:10px;
text-shadow:2px 2px 4px rgba(0,0,0,.3);
}
.header p { color:rgba(255,255,255,.9); font-size:1.1rem; max-width:600px; margin:0 auto; }
.content-grid {
display:grid; grid-template-columns:repeat(auto-fit,minmax(350px,1fr));
gap:25px; padding:20px 0;
}
/* 卡片为 <a>,方便 SEO */
.card {
display:block;
background:#fff; border-radius:15px; padding:25px;
box-shadow:0 10px 30px rgba(0,0,0,.15);
transition:all .3s ease; position:relative; overflow:hidden;
text-decoration:none; color:inherit; cursor:pointer;
}
.card:hover { transform: translateY(-5px); box-shadow:0 15px 40px rgba(0,0,0,.25); }
.card::before { content:''; position:absolute; top:0; left:0; right:0; height:4px;
background:linear-gradient(90deg,#667eea,#764ba2); }
.card-title {
font-size:1.4rem; color:#333; margin-bottom:15px; line-height:1.4; transition:color .3s ease;
white-space:nowrap; overflow:hidden; text-overflow:ellipsis;
}
.card:hover .card-title { color:#667eea; }
.card-meta {
display:flex; justify-content:space-between; align-items:center;
margin-top:20px; padding-top:15px; border-top:1px solid #eee;
}
.card-language {
background:linear-gradient(45deg,#667eea,#764ba2); color:#fff;
padding:6px 12px; border-radius:20px; font-size:.85rem; font-weight:500;
}
.card-views { color:#666; font-size:.9rem; display:flex; align-items:center; gap:5px; }
.card-time { color:#999; font-size:.85rem; margin-top:8px; font-style:italic; }
.empty-state { text-align:center; color:#fff; padding:60px 20px; grid-column:1 / -1; }
.empty-state h2 { font-size:1.5rem; margin-bottom:10px; }
@media (max-width:768px) {
.content-grid { grid-template-columns:1fr; gap:20px; }
.header h1 { font-size:2rem; }
.card { padding:20px; }
}
.view-icon { width:16px; height:16px; display:inline-block; vertical-align:middle; margin-right:5px; }
/* 分页条 */
.pagination {
display:flex; gap:10px; justify-content:center; align-items:center;
margin: 24px 0 8px;
}
.pag-btn {
padding:8px 14px; border:0; border-radius:10px; cursor:pointer;
background:#fff; box-shadow:0 6px 18px rgba(0,0,0,.12);
font-weight:600; transition: transform .15s ease, box-shadow .15s ease;
text-decoration:none; color:#333;
}
.pag-btn:hover { transform: translateY(-2px); box-shadow:0 10px 24px rgba(0,0,0,.18); }
.pag-btn[disabled] { opacity:.45; cursor:not-allowed; transform:none; box-shadow:none; }
.page-info { color:#fff; font-size:.95rem; margin:6px 0 0; text-align:center; opacity:.9; }
.size-select {
padding:8px 10px; border-radius:10px; border:0; outline:none;
box-shadow:0 6px 18px rgba(0,0,0,.12); background:#fff; font-weight:600;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Study11 Learning Platform</h1>
<p>Explaining knowledge through animations</p>
</div>
<div class="content-grid">
#if(data && data.size() > 0)
#for(item : data)
<a class="card" href="/preview/#(item.id)" aria-label="Open #(item.topic)">
<h3 class="card-title">#(item.topic)</h3>
<div class="card-time">Created: #date(item.create_time, "yyyy-MM-dd HH:mm:ss")</div>
<div class="card-meta">
<span class="card-language">#(item.language??"Unknown")</span>
<span class="card-views">
<svg class="view-icon" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M12 4.5C7 4.5 2.73 7.61 1 12c1.73 4.39 6 7.5 11 7.5s9.27-3.11 11-7.5c-1.73-4.39-6-7.5-11-7.5zM12 17c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5zm0-8c-1.66 0-3 1.34-3 3s1.34 3 3 3 3-1.34 3-3-1.34-3-3-3z"/>
</svg>
#(item.view_count??0) views
</span>
</div>
</a>
#end
#else
<div class="empty-state">
<h2>No Data Available</h2>
<p>There is no learning content yet. Please check back later.</p>
</div>
#end
</div>
<!-- 分页条 -->
<div class="pagination">
#if(hasPrev)
<a class="pag-btn" href="/#(prevPage)/#(pageSize)">← Prev</a>
#else
<button class="pag-btn" disabled>← Prev</button>
#end
<span class="pag-btn" style="pointer-events:none;">Page #(pageNo)</span>
#if(hasNext)
<a class="pag-btn" href="/#(nextPage)/#(pageSize)">Next →</a>
#else
<button class="pag-btn" disabled>Next →</button>
#end
<select id="sizeSelect" class="size-select" onchange="onPageSizeChange(this.value)">
<option value="10" #(pageSize==10 ? 'selected' : '')>10 / page</option>
<option value="20" #(pageSize==20 ? 'selected' : '')>20 / page</option>
<option value="50" #(pageSize==50 ? 'selected' : '')>50 / page</option>
<option value="100" #(pageSize==100 ? 'selected' : '')>100 / page</option>
</select>
</div>
<p class="page-info">
Page #(pageNo) of #(totalPage) |
Total #(totalRow) records
</p>
</div>
<script>
// 仅负责 hover 动画
document.addEventListener('DOMContentLoaded', function() {
const cards = document.querySelectorAll('.card');
cards.forEach(card => {
card.addEventListener('mouseenter', function(){ this.style.transform = 'translateY(-5px)'; });
card.addEventListener('mouseleave', function(){ this.style.transform = 'translateY(0)'; });
});
});
function onPageSizeChange(size){
var pageNo = #(pageNo);
var s = parseInt(size,10) || 10;
location.href = '/' + pageNo + '/' + s;
}
// 键盘 ← → 快捷翻页(可选)
document.addEventListener('keydown', function(e){
if (e.target && (e.target.tagName === 'INPUT' || e.target.tagName === 'SELECT' || e.target.isContentEditable)) return;
if (e.key === 'ArrowLeft' && #(hasPrev)) location.href = '/#(prevPage)/#(pageSize)';
if (e.key === 'ArrowRight' && #(hasNext)) location.href = '/#(nextPage)/#(pageSize)';
});
</script>
</body>
</html>
5. 预览页
preview.html
,做到:
<a href="/preview/{id}">
能 SSR 渲染出可索引的 200 HTML(你现在就是);<title>
与<meta name="description">
使用title/description
模板变量;- 地加
<link rel="canonical" href="/preview/{id}">
和结构化数据(VideoObject
),利于收录。
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>#(title)</title>
<meta name="description">#(transcript)</description>
<link rel="canonical" href="/preview/#(id)">
<!-- DPlayer & HLS -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dplayer/dist/DPlayer.min.css" />
<script src="https://cdn.jsdelivr.net/npm/hls.js@latest"></script>
<script src="https://cdn.jsdelivr.net/npm/dplayer/dist/DPlayer.min.js"></script>
<!-- KaTeX for math -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css" />
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"></script>
<!-- Marked (Markdown 渲染) + Prism (代码高亮) -->
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/prismjs/themes/prism-okaidia.min.css" />
<script src="https://cdn.jsdelivr.net/npm/prismjs/prism.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs/components/prism-markup.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs/components/prism-javascript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs/components/prism-typescript.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs/components/prism-python.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs/components/prism-java.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/prismjs/components/prism-json.min.js"></script>
<style>
/* ========== 页面样式(从 React 版本移植并简化) ========== */
:root{--indigo:#4f46e5;--text:#334155;--muted:#64748b}
body{margin:0;background:#f8fafc;color:#333;font-family:'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif}
.page{max-width:1200px;margin:0 auto;padding:20px;min-height:100vh}
.header{display:flex;align-items:center;gap:20px;margin:10px 0 20px}
.back{background:transparent;border:none;color:var(--indigo);padding:8px 16px;border-radius:8px;cursor:pointer}
.back:hover{background:#eef2ff}
h1{margin:0;font-size:24px;color:#1e293b}
.video-card{position:relative;background:#fff;border-radius:16px;overflow:hidden;box-shadow:0 10px 25px rgba(0,0,0,.05);margin-bottom:20px}
.video-card::before{content:"";display:block;padding-top:56.25%}
#dplayer{position:absolute;inset:0}
.tabs{display:flex;gap:8px;margin:14px 0}
.tab{flex:1;padding:14px;background:#f1f5f9;border:none;border-radius:12px;color:#64748b;font-weight:600;cursor:pointer}
.tab.active{background:var(--indigo);color:#fff}
.panel{background:#fff;border-radius:16px;padding:24px;box-shadow:0 10px 25px rgba(0,0,0,.05);margin-bottom:24px}
.section{background:#f8fafc;border-radius:12px;padding:18px}
.section h3{margin:0 0 12px;color:#1e293b}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(280px,1fr));gap:16px}
.item{background:#fff;border-radius:12px;padding:16px;box-shadow:0 4px 6px rgba(0,0,0,.03)}
.item label{display:flex;align-items:center;justify-content:space-between;color:#475569;font-weight:600;margin-bottom:6px}
.link{display:block;color:var(--indigo);overflow-wrap:anywhere;text-decoration:none}
.link:hover{text-decoration:underline}
.copy{margin-left:8px;border:none;border-radius:6px;background:#e2e8f0;padding:4px 8px;font-size:12px;cursor:pointer}
.copy:hover{background:#cbd5e1}
.answer{white-space:pre-wrap;line-height:1.65;color:var(--text);background:#fff;border-radius:8px;padding:16px;max-height:420px;overflow:auto}
.table-wrap{overflow:auto;margin:12px 0}
table{width:100%;border-collapse:collapse;background:#fff}
th,td{border:1px solid #e5e7eb;padding:10px 12px;text-align:left}
th{background:#f8f9fa}
.trans{list-style:none;margin:0;padding:0}
.trans li{display:flex;gap:10px;padding:10px 0;border-bottom:1px solid #e5e7eb}
.ln{min-width:28px;font-weight:600}
@media (max-width:768px){.page{padding:14px}.tabs{flex-direction:column}}
</style>
</head>
<body>
<div class="page">
<div class="header">
<button class="back" onclick="history.back()">← 返回</button>
<h1 id="title">#(title)</h1>
</div>
<div class="video-card"><div id="dplayer"></div></div>
<div class="tabs">
<button class="tab active" data-tab="info">📋 信息</button>
<button class="tab" data-tab="answer">💬 答案</button>
<button class="tab" data-tab="transcript">📝 字幕</button>
</div>
<!-- 信息面板 -->
<div class="panel" id="panel-info">
<div class="section">
<h3>视频信息</h3>
<div class="grid">
<div class="item">
<label>视频地址 <button class="copy" data-copy="videoUrl">复制</button></label>
<a id="video-link" class="link" href="#" target="_blank" rel="noopener"></a>
</div>
<div class="item">
<label>封面地址 <button class="copy" data-copy="coverUrl">复制</button></label>
<a id="cover-link" class="link" href="#" target="_blank" rel="noopener"></a>
</div>
<div class="item">
<label>Provider</label>
<div id="provider" style="font-weight:600;color:#334155;background:#ede9fe;padding:6px 10px;border-radius:6px;display:inline-block">#(provider)</div>
</div>
</div>
</div>
</div>
<!-- 答案面板 -->
<div class="panel" id="panel-answer" style="display:none">
<div class="section">
<h3 style="display:flex;align-items:center;justify-content:space-between">答案文本 <button class="copy" data-copy="answer">复制</button></h3>
<div id="answer" class="answer"></div>
</div>
</div>
<!-- 字幕面板 -->
<div class="panel" id="panel-transcript" style="display:none">
<div class="section">
<h3 style="display:flex;align-items:center;justify-content:space-between">视频字幕 <button class="copy" data-copy="transcript">复制</button></h3>
<ul id="transcript" class="trans"><span class="ln"> #(transcript) </span></ul>
</div>
</div>
</div>
<script>
// ===================== 可被 Enjoy 注入的占位变量 =====================
// 必填:视频 URL(支持 .m3u8)
const VIDEO_URL = '#(video_url)';
// 可选:封面图片 URL
const COVER_URL = '#(cover_url)';
// Provider 信息(可选)
const PROVIDER = '#(provider)';
// 答案 Markdown 文本(可包含行内/块级 LaTeX,如 $x^2$、$$\int...$$)
const ANSWER_MARKDOWN = `answer`;
// ===================== 工具函数 =====================
function setText(id, text){ const el=document.getElementById(id); if(el) el.textContent = text || ''; }
function setLink(id, href){ const el=document.getElementById(id); if(!el) return; if(href){ el.href = href; el.textContent = href; } else { el.textContent = '无'; el.removeAttribute('href'); } }
function copy(text){ if(!text) return; navigator.clipboard && navigator.clipboard.writeText(text); }
// Markdown 渲染(借助 Marked + Prism + KaTeX)
function renderMarkdownTo(targetEl, md){
if(!targetEl) return;
// 支持 \( \) 与 \[ \] 转换
md = md || '';
md = md.replace(/\\\((.*?)\\\)/g, '$$$1$$');
md = md.replace(/\\\[(.*?)\\\]/gs, '$$$$\n$1\n$$$$');
// 渲染为 HTML
const html = marked.parse(md, { breaks:true, gfm:true, headerIds:true, mangle:false, highlight(code, lang){
try{ if(lang && Prism.languages[lang]){ return Prism.highlight(code, Prism.languages[lang], lang); } }
catch(e){}
return code;
}});
targetEl.innerHTML = html;
// 渲染公式
try{ renderMathInElement(targetEl, { delimiters:[ {left:'$$', right:'$$', display:true}, {left:'$', right:'$', display:false} ], throwOnError:false }); }catch(e){}
}
// Tabs 切换
document.querySelectorAll('.tab').forEach(btn=>{
btn.addEventListener('click', ()=>{
document.querySelectorAll('.tab').forEach(b=>b.classList.remove('active'));
btn.classList.add('active');
const tab = btn.getAttribute('data-tab');
['info','answer','transcript'].forEach(name=>{
const p = document.getElementById('panel-'+name);
if(p) p.style.display = (name===tab)?'block':'none';
});
});
});
// 复制按钮
document.querySelectorAll('.copy').forEach(btn=>{
btn.addEventListener('click',()=>{
const key = btn.getAttribute('data-copy');
if(key==='videoUrl') copy(VIDEO_URL);
else if(key==='coverUrl') copy(COVER_URL);
else if(key==='answer') copy(ANSWER_MARKDOWN);
else if(key==='transcript') copy(tryParseTranscript().join('\n'));
btn.textContent = '✓ 已复制';
setTimeout(()=>btn.textContent='复制',1500);
});
});
// 初始化静态信息
setText('title', #(titile));
setLink('video-link', VIDEO_URL);
setLink('cover-link', COVER_URL);
if(PROVIDER && PROVIDER !== '#(provider)') { setText('provider', PROVIDER); }
// 渲染答案与字幕
renderMarkdownTo(document.getElementById('answer'), ANSWER_MARKDOWN);
// HTML 转义(防 XSS)
function escapeHtml(str){ return String(str||'').replace(/[&<>"']/g,(ch)=>({"&":"&","<":"<",">":">","\"":""","'":"'"}[ch])); }
// 初始化 DPlayer(支持 HLS)
(function initPlayer(){
if(!VIDEO_URL || VIDEO_URL === '#(videoUrl)'){ return; }
let type = 'auto';
if(/\.m3u8($|\?)/i.test(VIDEO_URL)){
type = 'hls';
window.Hls = window.Hls || Hls; // 提供给 DPlayer
}
const dp = new DPlayer({
container: document.getElementById('dplayer'),
autoplay: false,
preload: 'auto',
video: { url: VIDEO_URL, pic: (COVER_URL && COVER_URL !== '#(cover_url)') ? COVER_URL : 'https://via.placeholder.com/800x450?text=Video+Cover', type },
pluginOptions: { hls: { debug:false, enableWorker:true, lowLatencyMode:true, maxBufferLength:60, maxMaxBufferLength:600, maxBufferSize:50*1000*1000, liveSyncDurationCount:3, liveMaxLatencyDurationCount:10 } }
});
if(type==='hls' && dp.video){
dp.video.addEventListener('loadedmetadata', ()=>{ dp.video.currentTime = 0.1; dp.play(); });
}
})();
</script>
</body>
</html>
7. 结语
- 后端:
Db.paginate + Page<T>
→ 注入pageNo/pageSize/totalPage/totalRow/hasPrev/hasNext
; - 路由:一条
/{pageNo:\\d+}/{pageSize:\\d+}
覆盖/
与分页; - 前端:卡片使用
<a>
,分页跳转即刻生效,底部显示“第几页 / 总页数 / 总条数”。