19 changed files with 499 additions and 192 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -0,0 +1,6 @@ |
|||
|
|||
# 开发文档 |
|||
|
|||
## 记录说明 |
|||
|
|||
本系统存在token和session两种机制,因此,ctx.session表明session,会影响到客户端的cookie, 由于此机制,ctx.session.user和ctx.state.user都存放了用户信息,注意区别使用。 |
|||
@ -0,0 +1,123 @@ |
|||
import { ArticleModel } from "../../db/models/ArticleModel.js" |
|||
import Router from "utils/router.js" |
|||
import { marked } from "marked" |
|||
|
|||
class ArticleController { |
|||
async index(ctx) { |
|||
const { page = 1, view = 'grid' } = ctx.query |
|||
const limit = 12 // 每页显示的文章数量
|
|||
const offset = (page - 1) * limit |
|||
|
|||
// 获取文章总数
|
|||
const total = await ArticleModel.getPublishedArticleCount() |
|||
const totalPages = Math.ceil(total / limit) |
|||
|
|||
// 获取分页文章
|
|||
const articles = await ArticleModel.findPublished(offset, limit) |
|||
|
|||
// 获取所有分类和标签
|
|||
const categories = await ArticleModel.getArticleCountByCategory() |
|||
const allArticles = await ArticleModel.findPublished() |
|||
const tags = new Set() |
|||
allArticles.forEach(article => { |
|||
if (article.tags) { |
|||
article.tags.split(',').forEach(tag => { |
|||
tags.add(tag.trim()) |
|||
}) |
|||
} |
|||
}) |
|||
|
|||
return ctx.render("page/articles/index", { |
|||
articles, |
|||
categories: categories.map(c => c.category), |
|||
tags: Array.from(tags), |
|||
currentPage: parseInt(page), |
|||
totalPages, |
|||
view, |
|||
title: "文章列表", |
|||
}, { |
|||
includeUser: true, |
|||
includeSite: true, |
|||
}) |
|||
} |
|||
|
|||
async show(ctx) { |
|||
const { slug } = ctx.params |
|||
console.log(slug); |
|||
|
|||
const article = await ArticleModel.findBySlug(slug) |
|||
|
|||
if (!article) { |
|||
ctx.throw(404, "文章不存在") |
|||
} |
|||
|
|||
// 增加阅读次数
|
|||
await ArticleModel.incrementViewCount(article.id) |
|||
|
|||
// 将文章内容解析为HTML
|
|||
article.content = marked(article.content || '') |
|||
|
|||
// 获取相关文章
|
|||
const relatedArticles = await ArticleModel.getRelatedArticles(article.id) |
|||
|
|||
return ctx.render("page/articles/article", { |
|||
article, |
|||
relatedArticles, |
|||
title: article.title, |
|||
}, { |
|||
includeUser: true, |
|||
}) |
|||
} |
|||
|
|||
async byCategory(ctx) { |
|||
const { category } = ctx.params |
|||
const articles = await ArticleModel.findByCategory(category) |
|||
|
|||
return ctx.render("page/articles/category", { |
|||
articles, |
|||
category, |
|||
title: `${category} - 分类文章`, |
|||
}, { |
|||
includeUser: true, |
|||
}) |
|||
} |
|||
|
|||
async byTag(ctx) { |
|||
const { tag } = ctx.params |
|||
const articles = await ArticleModel.findByTags(tag) |
|||
|
|||
return ctx.render("page/articles/tag", { |
|||
articles, |
|||
tag, |
|||
title: `${tag} - 标签文章`, |
|||
}, { |
|||
includeUser: true, |
|||
}) |
|||
} |
|||
|
|||
async search(ctx) { |
|||
const { q } = ctx.query |
|||
const articles = await ArticleModel.searchByKeyword(q) |
|||
|
|||
return ctx.render("page/articles/search", { |
|||
articles, |
|||
keyword: q, |
|||
title: `搜索:${q}`, |
|||
}) |
|||
} |
|||
|
|||
static createRoutes() { |
|||
const controller = new ArticleController() |
|||
const router = new Router({ auth: true, prefix: "/articles" }) |
|||
router.get("", controller.index, { auth: false }) // 允许未登录访问
|
|||
router.get("/", controller.index, { auth: false }) // 允许未登录访问
|
|||
router.get("/search", controller.search) |
|||
router.get("/category/:category", controller.byCategory) |
|||
router.get("/tag/:tag", controller.byTag) |
|||
router.get("/:slug", controller.show) |
|||
return router |
|||
} |
|||
} |
|||
|
|||
export default ArticleController |
|||
export { ArticleController } |
|||
@ -0,0 +1,70 @@ |
|||
extends /layouts/empty.pug |
|||
|
|||
block pageContent |
|||
.container.mx-auto.px-4.py-8 |
|||
article.max-w-4xl.mx-auto |
|||
header.mb-8 |
|||
h1.text-4xl.font-bold.mb-4= article.title |
|||
.flex.flex-wrap.items-center.text-gray-600.mb-4 |
|||
span.mr-4 |
|||
i.fas.fa-calendar-alt.mr-1 |
|||
= new Date(article.published_at).toLocaleDateString() |
|||
span.mr-4 |
|||
i.fas.fa-eye.mr-1 |
|||
= article.view_count + " 阅读" |
|||
if article.reading_time |
|||
span.mr-4 |
|||
i.fas.fa-clock.mr-1 |
|||
= article.reading_time + " 分钟阅读" |
|||
if article.category |
|||
a.text-blue-600.mr-4(href=`/articles/category/${article.category}` class="hover:text-blue-800") |
|||
i.fas.fa-folder.mr-1 |
|||
= article.category |
|||
if article.status === "draft" |
|||
span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布 |
|||
|
|||
if article.tags |
|||
.flex.flex-wrap.gap-2.mb-4 |
|||
each tag in article.tags.split(',') |
|||
a.bg-gray-100.text-gray-700.px-3.py-1.rounded-full.text-sm(href=`/articles/tag/${tag.trim()}` class="hover:bg-gray-200") |
|||
i.fas.fa-tag.mr-1 |
|||
= tag.trim() |
|||
|
|||
if article.featured_image |
|||
.mb-8 |
|||
img.w-full.rounded-lg.shadow-lg(src=article.featured_image alt=article.title) |
|||
|
|||
.prose.prose-lg.max-w-none.mb-8.markdown-content(class="prose-pre:bg-gray-100 prose-pre:p-4 prose-pre:rounded-lg prose-code:text-blue-600 prose-blockquote:border-l-4 prose-blockquote:border-gray-300 prose-blockquote:pl-4 prose-blockquote:italic prose-img:rounded-lg prose-img:shadow-md") |
|||
!= article.content |
|||
|
|||
if article.keywords || article.description |
|||
.bg-gray-50.rounded-lg.p-6.mb-8 |
|||
if article.keywords |
|||
.mb-4 |
|||
h3.text-lg.font-semibold.mb-2 关键词 |
|||
.flex.flex-wrap.gap-2 |
|||
each keyword in article.keywords.split(',') |
|||
span.bg-white.px-3.py-1.rounded-full.text-sm= keyword.trim() |
|||
if article.description |
|||
h3.text-lg.font-semibold.mb-2 描述 |
|||
p.text-gray-600= article.description |
|||
|
|||
if relatedArticles && relatedArticles.length |
|||
section.border-t.pt-8.mt-8 |
|||
h2.text-2xl.font-bold.mb-6 相关文章 |
|||
.grid.grid-cols-1.gap-6(class="md:grid-cols-2") |
|||
each related in relatedArticles |
|||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|||
if related.featured_image |
|||
img.w-full.h-48.object-cover(src=related.featured_image alt=related.title) |
|||
.p-6 |
|||
h3.text-xl.font-semibold.mb-2 |
|||
a(href=`/articles/${related.slug}` class="hover:text-blue-600")= related.title |
|||
if related.excerpt |
|||
p.text-gray-600.text-sm.mb-4= related.excerpt |
|||
.flex.justify-between.items-center.text-sm.text-gray-500 |
|||
span |
|||
i.fas.fa-calendar-alt.mr-1 |
|||
= new Date(related.published_at).toLocaleDateString() |
|||
if related.category |
|||
a.text-blue-600(href=`/articles/category/${related.category}` class="hover:text-blue-800")= related.category |
|||
@ -0,0 +1,29 @@ |
|||
extends /layouts/empty.pug |
|||
|
|||
block pageContent |
|||
.container.mx-auto.px-4.py-8 |
|||
h1.text-3xl.font-bold.mb-8 |
|||
span.text-gray-600 分类: |
|||
= category |
|||
|
|||
.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") |
|||
each article in articles |
|||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|||
if article.featured_image |
|||
img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) |
|||
.p-6 |
|||
h2.text-xl.font-semibold.mb-2 |
|||
a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title |
|||
if article.excerpt |
|||
p.text-gray-600.mb-4= article.excerpt |
|||
.flex.justify-between.items-center.text-sm.text-gray-500 |
|||
span |
|||
i.fas.fa-calendar-alt.mr-1 |
|||
= new Date(article.published_at).toLocaleDateString() |
|||
span |
|||
i.fas.fa-eye.mr-1 |
|||
= article.view_count + " 阅读" |
|||
|
|||
if !articles.length |
|||
.text-center.py-8 |
|||
p.text-gray-500 该分类下暂无文章 |
|||
@ -1,113 +1,133 @@ |
|||
extends /layouts/page.pug |
|||
extends /layouts/empty.pug |
|||
|
|||
block pageContent |
|||
.article-list-container-full |
|||
- const articles = [] |
|||
- articles.push({ id: 1, title: '文章标题1', author: '作者1', created_at: '2023-08-01', summary: '这是文章摘要...' }) |
|||
- articles.push({ id: 2, title: '文章标题2', author: '作者2', created_at: '2023-08-02', summary: '这是另一篇文章摘要...' }) |
|||
//- 文章列表 |
|||
if articles && articles.length |
|||
each article in articles |
|||
.article-item-full |
|||
h2.article-title-full |
|||
a(href=`/articles/${article.id}`) #{article.title} |
|||
.article-meta-full |
|||
span 作者:#{article.author} | 发布时间:#{article.created_at} |
|||
p.article-summary-full #{article.summary} |
|||
else |
|||
p.no-articles 暂无文章 |
|||
.flex.flex-col |
|||
.flex-1.p-8.bg-gray-50 |
|||
.container.mx-auto |
|||
// 页头 |
|||
.flex.justify-between.items-center.mb-8 |
|||
h1.text-2xl.font-bold 文章列表 |
|||
.flex.gap-4 |
|||
// 搜索框 |
|||
.relative |
|||
input#searchInput.w-64.pl-10.pr-4.py-2.border.rounded-lg( |
|||
type="text" |
|||
placeholder="搜索文章..." |
|||
hx-get="/articles/search" |
|||
hx-trigger="keyup changed delay:500ms" |
|||
hx-target="#articleList" |
|||
name="q" |
|||
class="focus:outline-none focus:ring-blue-500 focus:ring-2" |
|||
) |
|||
i.fas.fa-search.absolute.left-3.top-3.text-gray-400 |
|||
|
|||
//- 分页控件 |
|||
if totalPages > 1 |
|||
.pagination-full |
|||
if page > 1 |
|||
a.page-btn-full(href=`?page=${page-1}`) 上一页 |
|||
else |
|||
span.page-btn-full.disabled 上一页 |
|||
span.page-info-full 第 #{page} / #{totalPages} 页 |
|||
if page < totalPages |
|||
a.page-btn-full(href=`?page=${page+1}`) 下一页 |
|||
else |
|||
span.page-btn-full.disabled 下一页 |
|||
style. |
|||
.article-list-container-full { |
|||
width: 100%; |
|||
max-width: 100%; |
|||
margin: 40px 0 0 0; |
|||
background: transparent; |
|||
border-radius: 0; |
|||
box-shadow: none; |
|||
padding: 0; |
|||
display: flex; |
|||
flex-direction: column; |
|||
align-items: center; |
|||
} |
|||
.article-item-full { |
|||
width: 90vw; |
|||
max-width: 1200px; |
|||
background: #fff; |
|||
border-radius: 14px; |
|||
box-shadow: 0 2px 16px #e0e7ef; |
|||
margin-bottom: 28px; |
|||
padding: 28px 36px 18px 36px; |
|||
border-left: 6px solid #6dd5fa; |
|||
transition: box-shadow 0.2s, border-color 0.2s; |
|||
} |
|||
.article-item-full:hover { |
|||
box-shadow: 0 4px 32px #cbe7ff; |
|||
border-left: 6px solid #ff6a88; |
|||
} |
|||
.article-title-full { |
|||
margin: 0 0 8px 0; |
|||
font-size: 1.6em; |
|||
} |
|||
.article-title-full a { |
|||
color: #2b7cff; |
|||
text-decoration: none; |
|||
transition: color 0.2s; |
|||
} |
|||
.article-title-full a:hover { |
|||
color: #ff6a88; |
|||
} |
|||
.article-meta-full { |
|||
color: #888; |
|||
font-size: 1em; |
|||
margin-bottom: 8px; |
|||
} |
|||
.article-summary-full { |
|||
color: #444; |
|||
font-size: 1.13em; |
|||
} |
|||
.no-articles { |
|||
text-align: center; |
|||
color: #aaa; |
|||
margin: 40px 0; |
|||
} |
|||
.pagination-full { |
|||
display: flex; |
|||
justify-content: center; |
|||
align-items: center; |
|||
margin: 32px 0 0 0; |
|||
gap: 18px; |
|||
} |
|||
.page-btn-full { |
|||
padding: 7px 22px; |
|||
border-radius: 22px; |
|||
background: linear-gradient(90deg, #6dd5fa, #ff6a88); |
|||
color: #fff; |
|||
text-decoration: none; |
|||
font-weight: bold; |
|||
transition: background 0.2s; |
|||
cursor: pointer; |
|||
font-size: 1.08em; |
|||
} |
|||
.page-btn-full.disabled { |
|||
background: #eee; |
|||
color: #bbb; |
|||
cursor: not-allowed; |
|||
pointer-events: none; |
|||
} |
|||
.page-info-full { |
|||
color: #666; |
|||
font-size: 1.12em; |
|||
} |
|||
// 视图切换按钮 |
|||
.flex.items-center.gap-2.bg-white.p-1.rounded-lg.border |
|||
button.p-2.rounded( |
|||
class="hover:bg-gray-100" |
|||
hx-get="/articles?view=grid" |
|||
hx-target="#articleList" |
|||
) |
|||
i.fas.fa-th-large |
|||
button.p-2.rounded( |
|||
class="hover:bg-gray-100" |
|||
hx-get="/articles?view=list" |
|||
hx-target="#articleList" |
|||
) |
|||
i.fas.fa-list |
|||
|
|||
// 筛选栏 |
|||
.bg-white.rounded-lg.shadow-sm.p-4.mb-6 |
|||
.flex.flex-wrap.gap-4 |
|||
if categories && categories.length |
|||
.flex.items-center.gap-2 |
|||
span.text-gray-600 分类: |
|||
each cat in categories |
|||
a.px-3.py-1.rounded-full( |
|||
class="hover:bg-blue-50 hover:text-blue-600" + (cat === currentCategory ? " bg-blue-100 text-blue-600" : "") |
|||
href=`/articles/category/${cat}` |
|||
)= cat |
|||
|
|||
if tags && tags.length |
|||
.flex.items-center.gap-2 |
|||
span.text-gray-600 标签: |
|||
each tag in tags |
|||
a.px-3.py-1.rounded-full( |
|||
class="hover:bg-blue-50 hover:text-blue-600" + (tag === currentTag ? " bg-blue-100 text-blue-600" : "") |
|||
href=`/articles/tag/${tag}` |
|||
)= tag |
|||
|
|||
// 文章列表 |
|||
#articleList.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") |
|||
each article in articles |
|||
.bg-white.rounded-lg.shadow-sm.overflow-hidden.transition.duration-300.transform(class="hover:-translate-y-1 hover:shadow-md") |
|||
if article.featured_image |
|||
.relative.h-48 |
|||
img.w-full.h-full.object-cover(src=article.featured_image alt=article.title) |
|||
if article.category |
|||
a.absolute.top-3.right-3.px-3.py-1.bg-blue-600.text-white.text-sm.rounded-full.opacity-90( |
|||
href=`/articles/category/${article.category}` |
|||
class="hover:opacity-100" |
|||
)= article.category |
|||
.p-6 |
|||
h2.text-xl.font-bold.mb-3 |
|||
a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title |
|||
if article.excerpt |
|||
p.text-gray-600.text-sm.mb-4.line-clamp-2= article.excerpt |
|||
|
|||
.flex.flex-wrap.gap-2.mb-4 |
|||
if article.tags |
|||
each tag in article.tags.split(',') |
|||
a.text-sm.text-gray-500( |
|||
href=`/articles/tag/${tag.trim()}` |
|||
class="hover:text-blue-600" |
|||
) |
|||
i.fas.fa-tag.mr-1 |
|||
= tag.trim() |
|||
|
|||
.flex.justify-between.items-center.text-sm.text-gray-500 |
|||
.flex.items-center.gap-4 |
|||
span |
|||
i.far.fa-calendar.mr-1 |
|||
= new Date(article.published_at).toLocaleDateString() |
|||
if article.reading_time |
|||
span |
|||
i.far.fa-clock.mr-1 |
|||
= article.reading_time + "分钟" |
|||
span |
|||
i.far.fa-eye.mr-1 |
|||
= article.view_count + " 阅读" |
|||
|
|||
if !articles.length |
|||
.col-span-full.py-16.text-center |
|||
.text-gray-400.mb-4 |
|||
i.fas.fa-inbox.text-6xl |
|||
p.text-gray-500 暂无文章 |
|||
|
|||
// 分页 |
|||
if totalPages > 1 |
|||
.flex.justify-center.mt-8 |
|||
nav.flex.items-center.gap-1(aria-label="Pagination") |
|||
// 上一页 |
|||
if currentPage > 1 |
|||
a.px-3.py-1.rounded-md.bg-white.border( |
|||
href=`/articles?page=${currentPage - 1}` |
|||
class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" |
|||
) 上一页 |
|||
|
|||
// 页码 |
|||
each page in Array.from({length: totalPages}, (_, i) => i + 1) |
|||
if page === currentPage |
|||
span.px-3.py-1.rounded-md.bg-blue-50.text-blue-600.border.border-blue-200= page |
|||
else |
|||
a.px-3.py-1.rounded-md.bg-white.border( |
|||
href=`/articles?page=${page}` |
|||
class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" |
|||
)= page |
|||
|
|||
// 下一页 |
|||
if currentPage < totalPages |
|||
a.px-3.py-1.rounded-md.bg-white.border( |
|||
href=`/articles?page=${currentPage + 1}` |
|||
class="text-gray-500 hover:text-gray-700 hover:bg-gray-50" |
|||
) 下一页 |
|||
|
|||
@ -0,0 +1,34 @@ |
|||
extends /layouts/empty.pug |
|||
|
|||
block pageContent |
|||
.container.mx-auto.px-4.py-8 |
|||
.mb-8 |
|||
h1.text-3xl.font-bold.mb-4 |
|||
span.text-gray-600 搜索结果: |
|||
= keyword |
|||
p.text-gray-500 找到 #{articles.length} 篇相关文章 |
|||
|
|||
.grid.grid-cols-1.md:grid-cols-2.lg:grid-cols-3.gap-6 |
|||
each article in articles |
|||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|||
if article.featured_image |
|||
img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) |
|||
.p-6 |
|||
h2.text-xl.font-semibold.mb-2 |
|||
a.hover:text-blue-600(href=`/articles/${article.slug}`)= article.title |
|||
if article.excerpt |
|||
p.text-gray-600.mb-4= article.excerpt |
|||
.flex.justify-between.items-center |
|||
.text-sm.text-gray-500 |
|||
span.mr-4 |
|||
i.fas.fa-calendar-alt.mr-1 |
|||
= new Date(article.published_at).toLocaleDateString() |
|||
span |
|||
i.fas.fa-eye.mr-1 |
|||
= article.view_count + " 阅读" |
|||
if article.category |
|||
a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full.hover:bg-blue-200(href=`/articles/category/${article.category}`)= article.category |
|||
|
|||
if !articles.length |
|||
.text-center.py-8 |
|||
p.text-gray-500 未找到相关文章 |
|||
@ -0,0 +1,32 @@ |
|||
extends /layouts/empty.pug |
|||
|
|||
block pageContent |
|||
.container.mx-auto.px-4.py-8 |
|||
h1.text-3xl.font-bold.mb-8 |
|||
span.text-gray-600 标签: |
|||
= tag |
|||
|
|||
.grid.grid-cols-1.gap-6(class="md:grid-cols-2 lg:grid-cols-3") |
|||
each article in articles |
|||
.bg-white.shadow-md.rounded-lg.overflow-hidden |
|||
if article.featured_image |
|||
img.w-full.h-48.object-cover(src=article.featured_image alt=article.title) |
|||
.p-6 |
|||
h2.text-xl.font-semibold.mb-2 |
|||
a(href=`/articles/${article.slug}` class="hover:text-blue-600")= article.title |
|||
if article.excerpt |
|||
p.text-gray-600.mb-4= article.excerpt |
|||
.flex.justify-between.items-center |
|||
.text-sm.text-gray-500 |
|||
span.mr-4 |
|||
i.fas.fa-calendar-alt.mr-1 |
|||
= new Date(article.published_at).toLocaleDateString() |
|||
span |
|||
i.fas.fa-eye.mr-1 |
|||
= article.view_count + " 阅读" |
|||
if article.category |
|||
a.text-sm.bg-blue-100.text-blue-600.px-3.py-1.rounded-full(href=`/articles/category/${article.category}` class="hover:bg-blue-200")= article.category |
|||
|
|||
if !articles.length |
|||
.text-center.py-8 |
|||
p.text-gray-500 该标签下暂无文章 |
|||
Loading…
Reference in new issue