Browse Source

新增文章管理功能,优化文章列表和详情页面,支持分类、标签和搜索功能,更新相关视图模板,添加开发文档

re
dash 3 months ago
parent
commit
724a001607
  1. BIN
      bun.lockb
  2. BIN
      database/development.sqlite3-shm
  3. BIN
      database/development.sqlite3-wal
  4. 6
      docs/dev.md
  5. 1
      package.json
  6. 123
      src/controllers/Page/ArticleController.js
  7. 6
      src/controllers/Page/PageController.js
  8. 92
      src/db/models/ArticleModel.js
  9. 6
      src/views/layouts/empty.pug
  10. 4
      src/views/layouts/root.pug
  11. 70
      src/views/page/articles/article.pug
  12. 29
      src/views/page/articles/category.pug
  13. 234
      src/views/page/articles/index.pug
  14. 34
      src/views/page/articles/search.pug
  15. 32
      src/views/page/articles/tag.pug
  16. 2
      src/views/page/auth/no-auth.pug
  17. 6
      src/views/page/index/index.pug
  18. 2
      src/views/page/login/index.pug
  19. 2
      src/views/page/register/index.pug

BIN
bun.lockb

Binary file not shown.

BIN
database/development.sqlite3-shm

Binary file not shown.

BIN
database/development.sqlite3-wal

Binary file not shown.

6
docs/dev.md

@ -0,0 +1,6 @@
# 开发文档
## 记录说明
本系统存在token和session两种机制,因此,ctx.session表明session,会影响到客户端的cookie, 由于此机制,ctx.session.user和ctx.state.user都存放了用户信息,注意区别使用。

1
package.json

@ -36,6 +36,7 @@
"koa-session": "^7.0.2", "koa-session": "^7.0.2",
"lodash": "^4.17.21", "lodash": "^4.17.21",
"log4js": "^6.9.1", "log4js": "^6.9.1",
"marked": "^16.2.1",
"minimatch": "^9.0.0", "minimatch": "^9.0.0",
"node-cron": "^4.1.0", "node-cron": "^4.1.0",
"path-to-regexp": "^8.2.0", "path-to-regexp": "^8.2.0",

123
src/controllers/Page/ArticleController.js

@ -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 }

6
src/controllers/Page/PageController.js

@ -16,7 +16,7 @@ class PageController {
// 首页 // 首页
async indexGet(ctx) { async indexGet(ctx) {
const blogs = await this.articleService.getAllArticles() const blogs = await this.articleService.getPublishedArticles()
return await ctx.render( return await ctx.render(
"page/index/index", "page/index/index",
{ {
@ -176,8 +176,8 @@ class PageController {
// 未授权报错页 // 未授权报错页
router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false }) router.get("/no-auth", controller.indexNoAuth.bind(controller), { auth: false })
router.get("/article/:id", controller.pageGet("page/articles/index"), { auth: false }) // router.get("/article/:id", controller.pageGet("page/articles/index"), { auth: false })
router.get("/articles", controller.pageGet("page/articles/index"), { auth: false }) // router.get("/articles", controller.pageGet("page/articles/index"), { auth: false })
router.get("/about", controller.pageGet("page/about/index"), { auth: false }) router.get("/about", controller.pageGet("page/about/index"), { auth: false })
router.get("/terms", controller.pageGet("page/extra/terms"), { auth: false }) router.get("/terms", controller.pageGet("page/extra/terms"), { auth: false })

92
src/db/models/ArticleModel.js

@ -5,17 +5,22 @@ class ArticleModel {
return db("articles").orderBy("created_at", "desc") return db("articles").orderBy("created_at", "desc")
} }
static async findPublished() { static async findPublished(offset, limit) {
return db("articles") let query = db("articles")
.where("status", "published") .where("status", "published")
.whereNotNull("published_at") .whereNotNull("published_at")
.orderBy("published_at", "desc") .orderBy("published_at", "desc")
if (typeof offset === "number") {
query = query.offset(offset)
}
if (typeof limit === "number") {
query = query.limit(limit)
}
return query
} }
static async findDrafts() { static async findDrafts() {
return db("articles") return db("articles").where("status", "draft").orderBy("updated_at", "desc")
.where("status", "draft")
.orderBy("updated_at", "desc")
} }
static async findById(id) { static async findById(id) {
@ -27,22 +32,16 @@ class ArticleModel {
} }
static async findByAuthor(author) { static async findByAuthor(author) {
return db("articles") return db("articles").where("author", author).where("status", "published").orderBy("published_at", "desc")
.where("author", author)
.where("status", "published")
.orderBy("published_at", "desc")
} }
static async findByCategory(category) { static async findByCategory(category) {
return db("articles") return db("articles").where("category", category).where("status", "published").orderBy("published_at", "desc")
.where("category", category)
.where("status", "published")
.orderBy("published_at", "desc")
} }
static async findByTags(tags) { static async findByTags(tags) {
// 支持多个标签搜索,标签以逗号分隔 // 支持多个标签搜索,标签以逗号分隔
const tagArray = tags.split(',').map(tag => tag.trim()) const tagArray = tags.split(",").map(tag => tag.trim())
return db("articles") return db("articles")
.where("status", "published") .where("status", "published")
.whereRaw("tags LIKE ?", [`%${tagArray[0]}%`]) .whereRaw("tags LIKE ?", [`%${tagArray[0]}%`])
@ -71,7 +70,11 @@ class ArticleModel {
// 处理标签,确保格式一致 // 处理标签,确保格式一致
let tags = data.tags let tags = data.tags
if (tags && typeof tags === "string") { if (tags && typeof tags === "string") {
tags = tags.split(',').map(tag => tag.trim()).filter(tag => tag).join(', ') tags = tags
.split(",")
.map(tag => tag.trim())
.filter(tag => tag)
.join(", ")
} }
// 生成slug(如果未提供) // 生成slug(如果未提供)
@ -92,7 +95,8 @@ class ArticleModel {
excerpt = this.generateExcerpt(data.content) excerpt = this.generateExcerpt(data.content)
} }
return db("articles").insert({ return db("articles")
.insert({
...data, ...data,
tags, tags,
slug, slug,
@ -102,7 +106,8 @@ class ArticleModel {
view_count: 0, view_count: 0,
created_at: db.fn.now(), created_at: db.fn.now(),
updated_at: db.fn.now(), updated_at: db.fn.now(),
}).returning("*") })
.returning("*")
} }
static async update(id, data) { static async update(id, data) {
@ -114,7 +119,11 @@ class ArticleModel {
// 处理标签,确保格式一致 // 处理标签,确保格式一致
let tags = data.tags let tags = data.tags
if (tags && typeof tags === "string") { if (tags && typeof tags === "string") {
tags = tags.split(',').map(tag => tag.trim()).filter(tag => tag).join(', ') tags = tags
.split(",")
.map(tag => tag.trim())
.filter(tag => tag)
.join(", ")
} }
// 生成slug(如果标题改变且未提供slug) // 生成slug(如果标题改变且未提供slug)
@ -141,7 +150,9 @@ class ArticleModel {
publishedAt = db.fn.now() publishedAt = db.fn.now()
} }
return db("articles").where("id", id).update({ return db("articles")
.where("id", id)
.update({
...data, ...data,
tags: tags || current.tags, tags: tags || current.tags,
slug: slug || current.slug, slug: slug || current.slug,
@ -149,7 +160,8 @@ class ArticleModel {
excerpt: excerpt || current.excerpt, excerpt: excerpt || current.excerpt,
published_at: publishedAt || current.published_at, published_at: publishedAt || current.published_at,
updated_at: db.fn.now(), updated_at: db.fn.now(),
}).returning("*") })
.returning("*")
} }
static async delete(id) { static async delete(id) {
@ -183,10 +195,7 @@ class ArticleModel {
} }
static async incrementViewCount(id) { static async incrementViewCount(id) {
return db("articles") return db("articles").where("id", id).increment("view_count", 1).returning("*")
.where("id", id)
.increment("view_count", 1)
.returning("*")
} }
static async findByDateRange(startDate, endDate) { static async findByDateRange(startDate, endDate) {
@ -202,10 +211,7 @@ class ArticleModel {
} }
static async getPublishedArticleCount() { static async getPublishedArticleCount() {
const result = await db("articles") const result = await db("articles").where("status", "published").count("id as count").first()
.where("status", "published")
.count("id as count")
.first()
return result ? result.count : 0 return result ? result.count : 0
} }
@ -219,33 +225,19 @@ class ArticleModel {
} }
static async getArticleCountByStatus() { static async getArticleCountByStatus() {
return db("articles") return db("articles").select("status").count("id as count").groupBy("status").orderBy("count", "desc")
.select("status")
.count("id as count")
.groupBy("status")
.orderBy("count", "desc")
} }
static async getRecentArticles(limit = 10) { static async getRecentArticles(limit = 10) {
return db("articles") return db("articles").where("status", "published").orderBy("published_at", "desc").limit(limit)
.where("status", "published")
.orderBy("published_at", "desc")
.limit(limit)
} }
static async getPopularArticles(limit = 10) { static async getPopularArticles(limit = 10) {
return db("articles") return db("articles").where("status", "published").orderBy("view_count", "desc").limit(limit)
.where("status", "published")
.orderBy("view_count", "desc")
.limit(limit)
} }
static async getFeaturedArticles(limit = 5) { static async getFeaturedArticles(limit = 5) {
return db("articles") return db("articles").where("status", "published").whereNotNull("featured_image").orderBy("published_at", "desc").limit(limit)
.where("status", "published")
.whereNotNull("featured_image")
.orderBy("published_at", "desc")
.limit(limit)
} }
static async getRelatedArticles(articleId, limit = 5) { static async getRelatedArticles(articleId, limit = 5) {
@ -260,7 +252,7 @@ class ArticleModel {
this.orWhere("category", current.category) this.orWhere("category", current.category)
} }
if (current.tags) { if (current.tags) {
const tags = current.tags.split(',').map(tag => tag.trim()) const tags = current.tags.split(",").map(tag => tag.trim())
tags.forEach(tag => { tags.forEach(tag => {
this.orWhereRaw("tags LIKE ?", [`%${tag}%`]) this.orWhereRaw("tags LIKE ?", [`%${tag}%`])
}) })
@ -274,9 +266,9 @@ class ArticleModel {
static generateSlug(title) { static generateSlug(title) {
return title return title
.toLowerCase() .toLowerCase()
.replace(/[^\w\s-]/g, '') .replace(/[^\w\s-]/g, "")
.replace(/\s+/g, '-') .replace(/\s+/g, "-")
.replace(/-+/g, '-') .replace(/-+/g, "-")
.trim() .trim()
} }

6
src/views/layouts/empty.pug

@ -15,8 +15,7 @@ block $$content
#{$site.site_title} #{$site.site_title}
// 桌面端菜单 // 桌面端菜单
.left.menu.desktop-only .left.menu.desktop-only
a.menu-item(href="/about") 明月照佳人 a.menu-item(href="/articles") 所有文章
a.menu-item(href="/about") 岁月催人老
if !isLogin if !isLogin
.right.menu.desktop-only .right.menu.desktop-only
a.menu-item(href="/login") 登录 a.menu-item(href="/login") 登录
@ -35,8 +34,7 @@ block $$content
// 移动端菜单内容(与桌面端一致) // 移动端菜单内容(与桌面端一致)
.mobile-menu.container .mobile-menu.container
.left.menu .left.menu
a.menu-item(href="/about") 明月照佳人 a.menu-item(href="/articles") 所有文章
a.menu-item(href="/about") 岁月催人老
if !isLogin if !isLogin
.right.menu .right.menu
a.menu-item(href="/login") 登录 a.menu-item(href="/login") 登录

4
src/views/layouts/root.pug

@ -12,12 +12,12 @@ html(lang="zh-CN")
meta(charset="utf-8") meta(charset="utf-8")
meta(name="viewport" content="width=device-width, initial-scale=1") meta(name="viewport" content="width=device-width, initial-scale=1")
+css('lib/reset.css') +css('lib/reset.css')
+css('lib/simplebar.css', true) +css('lib/simplebar.css')
+css('lib/simplebar-shim.css') +css('lib/simplebar-shim.css')
+css('css/layouts/root.css') +css('css/layouts/root.css')
+js('lib/htmx.min.js') +js('lib/htmx.min.js')
+js('lib/tailwindcss.3.4.17.js') +js('lib/tailwindcss.3.4.17.js')
+js('lib/simplebar.min.js', true) +js('lib/simplebar.min.js')
body body
noscript noscript
style. style.

70
src/views/page/articles/article.pug

@ -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

29
src/views/page/articles/category.pug

@ -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 该分类下暂无文章

234
src/views/page/articles/index.pug

@ -1,113 +1,133 @@
extends /layouts/page.pug extends /layouts/empty.pug
block pageContent block pageContent
.article-list-container-full .flex.flex-col
- const articles = [] .flex-1.p-8.bg-gray-50
- articles.push({ id: 1, title: '文章标题1', author: '作者1', created_at: '2023-08-01', summary: '这是文章摘要...' }) .container.mx-auto
- articles.push({ id: 2, title: '文章标题2', author: '作者2', created_at: '2023-08-02', summary: '这是另一篇文章摘要...' }) // 页头
//- 文章列表 .flex.justify-between.items-center.mb-8
if articles && articles.length 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
// 视图切换按钮
.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 each article in articles
.article-item-full .bg-white.rounded-lg.shadow-sm.overflow-hidden.transition.duration-300.transform(class="hover:-translate-y-1 hover:shadow-md")
h2.article-title-full if article.featured_image
a(href=`/articles/${article.id}`) #{article.title} .relative.h-48
.article-meta-full img.w-full.h-full.object-cover(src=article.featured_image alt=article.title)
span 作者:#{article.author} | 发布时间:#{article.created_at} if article.category
p.article-summary-full #{article.summary} a.absolute.top-3.right-3.px-3.py-1.bg-blue-600.text-white.text-sm.rounded-full.opacity-90(
else href=`/articles/category/${article.category}`
p.no-articles 暂无文章 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 if totalPages > 1
.pagination-full .flex.justify-center.mt-8
if page > 1 nav.flex.items-center.gap-1(aria-label="Pagination")
a.page-btn-full(href=`?page=${page-1}`) 上一页 // 上一页
else if currentPage > 1
span.page-btn-full.disabled 上一页 a.px-3.py-1.rounded-md.bg-white.border(
span.page-info-full 第 #{page} / #{totalPages} 页 href=`/articles?page=${currentPage - 1}`
if page < totalPages class="text-gray-500 hover:text-gray-700 hover:bg-gray-50"
a.page-btn-full(href=`?page=${page+1}`) 下一页 ) 上一页
// 页码
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 else
span.page-btn-full.disabled 下一页 a.px-3.py-1.rounded-md.bg-white.border(
style. href=`/articles?page=${page}`
.article-list-container-full { class="text-gray-500 hover:text-gray-700 hover:bg-gray-50"
width: 100%; )= page
max-width: 100%;
margin: 40px 0 0 0; // 下一页
background: transparent; if currentPage < totalPages
border-radius: 0; a.px-3.py-1.rounded-md.bg-white.border(
box-shadow: none; href=`/articles?page=${currentPage + 1}`
padding: 0; class="text-gray-500 hover:text-gray-700 hover:bg-gray-50"
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;
}

34
src/views/page/articles/search.pug

@ -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 未找到相关文章

32
src/views/page/articles/tag.pug

@ -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 该标签下暂无文章

2
src/views/page/auth/no-auth.pug

@ -1,4 +1,4 @@
extends /layouts/page.pug extends /layouts/empty.pug
block pageContent block pageContent
.no-auth-container .no-auth-container

6
src/views/page/index/index.pug

@ -14,7 +14,9 @@ mixin item(url, desc)
mixin card(blog) mixin card(blog)
.article-card(class="bg-white rounded-[12px] shadow p-6 transition hover:shadow-lg border border-gray-100") .article-card(class="bg-white rounded-[12px] shadow p-6 transition hover:shadow-lg border border-gray-100")
h3.article-title(class="text-lg font-semibold text-gray-900 mb-2") h3.article-title(class="text-lg font-semibold text-gray-900 mb-2")
a(href="/article/1" class="hover:text-blue-600 transition-colors duration-200") #{blog.title} a(href="/articles/"+blog.slug class="hover:text-blue-600 transition-colors duration-200") #{blog.title}
if blog.status === "draft"
span(class="ml-2 px-2 py-0.5 text-xs bg-yellow-200 text-yellow-800 rounded align-middle") 未发布
p.article-meta(class="text-sm text-gray-400 mb-3 flex") p.article-meta(class="text-sm text-gray-400 mb-3 flex")
span(class="mr-2 line-clamp-1" title=blog.author) span(class="mr-2 line-clamp-1" title=blog.author)
span 作者: span 作者:
@ -30,7 +32,7 @@ mixin card(blog)
style="height: 2.8em; overflow: hidden;" style="height: 2.8em; overflow: hidden;"
) )
| #{blog.description} | #{blog.description}
a(href="/article/1" class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 → a(href="/articles/"+blog.slug class="inline-block text-sm text-blue-600 hover:underline transition-colors duration-200") 阅读全文 →
mixin empty() mixin empty()
.div-placeholder(class="h-[100px] w-full bg-gray-100 text-center flex items-center justify-center text-[16px]") .div-placeholder(class="h-[100px] w-full bg-gray-100 text-center flex items-center justify-center text-[16px]")

2
src/views/page/login/index.pug

@ -1,4 +1,4 @@
extends /layouts/pure.pug extends /layouts/empty.pug
block pageScripts block pageScripts
script(src="js/login.js") script(src="js/login.js")

2
src/views/page/register/index.pug

@ -1,4 +1,4 @@
extends /layouts/pure.pug extends /layouts/empty.pug
block pageHead block pageHead
style. style.

Loading…
Cancel
Save