feat: 分类系统 + 标签系统 — Phase 4 完成

分类:Model/Repo/Service/Controller + 管理后台树形页面 + 首页导航栏
标签:Model/Repo/Service/Controller + /tags/{slug} 落地页 + 写文章页输入
Post 加 CategoryID,Create/Update 支持分类和标签
SQL 迁移含 categories/tags/post_tags 表及预设未分类
This commit is contained in:
2026-06-22 00:30:29 +08:00
parent 977c52513a
commit 0b8f6f890d
30 changed files with 988 additions and 13 deletions

View File

@ -29,6 +29,16 @@
</div>
{{end}}
{{if .CategoryNav}}
<!-- Category Nav -->
<div class="category-nav">
<a href="/" class="category-tag active">全部</a>
{{range .CategoryNav}}
<a href="/posts?category={{.ID}}" class="category-tag">{{.Name}}</a>
{{end}}
</div>
{{end}}
<!-- Main Content -->
<div class="container home-main">
<div class="home-grid">

View File

@ -64,6 +64,19 @@
<span class="meta-label"></span>
<label class="meta-checkbox"><input type="checkbox" id="reprintProhibited"> 未经作者许可,禁止转载</label>
</div>
<div class="meta-row">
<span class="meta-label">分类</span>
<select id="categoryId" style="width:auto">
<option value="">(未分类)</option>
{{range .Categories}}
<option value="{{.ID}}" {{if and $.Post $.Post.CategoryID}}{{if eq .ID (derefUint $.Post.CategoryID)}}selected{{end}}{{end}}>{{.Name}}</option>
{{end}}
</select>
</div>
<div class="meta-row">
<span class="meta-label">标签</span>
<input type="text" id="tagInput" placeholder="逗号分隔,最多 10 个" style="width:300px">
</div>
<div class="meta-row">
<span class="meta-label">声明</span>
<select id="declaration" style="width:auto">

View File

@ -0,0 +1,57 @@
{{template "layout/header.html" .}}
<body>
{{template "layout/nav.html" .}}
<div class="container home-main">
<div class="home-grid">
<main class="home-feed">
<div class="feed-header">
<h2 class="feed-title">标签:{{.Tag.Name}}</h2>
<span class="feed-count">{{.Total}} 篇文章</span>
</div>
{{if .Posts}}
<div class="article-list">
{{range .Posts}}
<article class="article-card">
<div class="article-body">
<h3 class="article-title">
<a href="/posts/{{.ID}}">{{.Title}}</a>
</h3>
<p class="article-excerpt">{{.Excerpt}}</p>
<div class="article-meta">
<span class="article-author">{{.AuthorName}}</span>
<span class="meta-dot">·</span>
<span class="article-time">{{.CreatedAt.Format "2006-01-02"}}</span>
</div>
</div>
</article>
{{end}}
</div>
{{else}}
<div class="empty-feed">暂无相关文章</div>
{{end}}
{{if gt .TotalPages 1}}
<div class="pagination">
{{if gt .Page 1}}<a href="?page={{.PrevPage}}" class="page-btn">上一页</a>{{end}}
<span class="page-info">{{.Page}} / {{.TotalPages}}</span>
{{if lt .Page .TotalPages}}<a href="?page={{.NextPage}}" class="page-btn">下一页</a>{{end}}
</div>
{{end}}
</main>
<aside class="home-sidebar">
<div class="sidebar-card links-card">
<h3 class="links-title">快捷入口</h3>
<a href="/" class="links-item">← 返回首页</a>
<a href="/posts" class="links-item">浏览全部文章</a>
</div>
</aside>
</div>
</div>
{{template "layout/footer.html" .}}
</body>
</html>

View File

@ -423,6 +423,32 @@
}
.announcement-error .announcement-title { color: #dc2626; }
/* --- Category Nav --- */
.category-nav {
max-width: 900px;
margin: 0 auto 1.5rem;
padding: 0 16px;
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.category-tag {
padding: 4px 14px;
border-radius: 20px;
font-size: 13px;
color: #4b5563;
background: #f3f4f6;
text-decoration: none;
transition: all .15s;
}
.category-tag:hover,
.category-tag.active {
background: var(--color-accent);
color: #fff;
}
/* ---------- Responsive ---------- */
@media (max-width: 960px) {
.home-grid {

View File

@ -190,6 +190,8 @@ async function handleSubmit(e) {
const reprintSource = document.getElementById('reprintSource')?.value.trim() || ''
const declaration = document.getElementById('declaration')?.value || ''
const reprintProhibited = document.getElementById('reprintProhibited')?.checked || false
const categoryId = document.getElementById('categoryId')?.value || ''
const tags = document.getElementById('tagInput')?.value.trim() || ''
const isEdit = !!postId
const url = isEdit ? '/api/studio/posts/' + postId : '/api/studio/posts'
@ -207,6 +209,8 @@ async function handleSubmit(e) {
reprint_source: reprintSource,
declaration,
reprint_prohibited: reprintProhibited,
category_id: categoryId,
tags,
}),
})
const data = await resp.json()

View File

@ -0,0 +1,80 @@
{{template "admin/layout/base.html" .}}
<div class="page-header">
<h1>分类管理</h1>
<p class="page-desc">两级树形分类,仅叶子节点可被文章选择</p>
</div>
<div class="toolbar">
<button class="btn btn-primary" id="btnNewCategory">+ 新建主分类</button>
</div>
<div id="categoryTree" class="category-tree">
{{range .Tree}}
<div class="cat-node cat-parent">
<div class="cat-row">
<span class="cat-name">{{.Name}}</span>
<span class="cat-slug">/{{.Slug}}</span>
<span class="cat-actions">
<a href="#" class="cat-btn-edit" data-id="{{.ID}}" data-name="{{.Name}}" data-slug="{{.Slug}}" data-desc="{{.Description}}" data-sort="{{.Sort}}">编辑</a>
<a href="#" class="cat-btn-delete" data-id="{{.ID}}">删除</a>
<a href="#" class="cat-btn-add-child" data-parent-id="{{.ID}}">+ 子分类</a>
</span>
</div>
{{if .Children}}
<div class="cat-children">
{{range .Children}}
<div class="cat-row cat-child">
<span class="cat-name">{{.Name}}</span>
<span class="cat-slug">/{{$.Slug}}/{{.Slug}}</span>
<span class="cat-actions">
<a href="#" class="cat-btn-edit" data-id="{{.ID}}" data-name="{{.Name}}" data-slug="{{.Slug}}" data-desc="{{.Description}}" data-sort="{{.Sort}}" data-parent-id="{{.ParentID}}">编辑</a>
<a href="#" class="cat-btn-delete" data-id="{{.ID}}">删除</a>
</span>
</div>
{{end}}
</div>
{{end}}
</div>
{{else}}
<div class="cat-empty">暂无分类</div>
{{end}}
</div>
<!-- 编辑弹窗 -->
<div class="modal-overlay" id="modalOverlay">
<div class="modal">
<div class="modal-header">
<h3 id="modalTitle">新建分类</h3>
<button class="modal-close" id="modalClose">&times;</button>
</div>
<div class="modal-body">
<form id="categoryForm">
<input type="hidden" id="catId">
<input type="hidden" id="catParentId">
<div class="form-group">
<label for="catName">名称</label>
<input type="text" id="catName" class="form-input" maxlength="50" required>
</div>
<div class="form-group">
<label for="catSlug">SlugURL 标识)</label>
<input type="text" id="catSlug" class="form-input" maxlength="50" pattern="[a-z0-9\-]+">
</div>
<div class="form-group">
<label for="catDesc">简介</label>
<input type="text" id="catDesc" class="form-input" maxlength="200">
</div>
<div class="form-group">
<label for="catSort">排序</label>
<input type="number" id="catSort" class="form-input" value="0" min="0" style="width:100px">
</div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-secondary" id="modalCancel">取消</button>
<button class="btn btn-primary" id="modalSave">保存</button>
</div>
</div>
</div>
<div class="toast" id="toast"></div>
{{template "admin/layout/footer.html" .}}

View File

@ -55,6 +55,10 @@
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v4M12 19v4M4.22 4.22l2.83 2.83M16.95 16.95l2.83 2.83M1 12h4M19 12h4M4.22 19.78l2.83-2.83M16.95 7.05l2.83-2.83"/></svg></span>
公户流水
</a>
<a href="/admin/categories" class="sidebar-item {{if eq .CurrentPath "/admin/categories"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3h7v7H3V3z"/><path d="M14 3h7v7h-7V3z"/><path d="M3 14h7v7H3v-7z"/><path d="M14 14h7v7h-7v-7z"/></svg></span>
分类管理
</a>
<a href="/admin/announcements" class="sidebar-item {{if eq .CurrentPath "/admin/announcements"}}active{{end}}">
<span class="sidebar-icon"><svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg></span>
公告管理

View File

@ -0,0 +1,18 @@
/* Categories Admin */
.category-tree { margin-top: 16px; }
.cat-node { margin-bottom: 12px; }
.cat-parent { background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 8px; padding: 12px 16px; }
.cat-children { margin-left: 24px; margin-top: 8px; }
.cat-row {
display: flex; align-items: center; gap: 12px;
padding: 8px 0; border-bottom: 1px solid #f3f4f6;
}
.cat-children .cat-row:last-child { border-bottom: none; }
.cat-name { font-weight: 600; font-size: 14px; color: #111827; min-width: 80px; }
.cat-slug { font-size: 12px; color: #9ca3af; }
.cat-actions { margin-left: auto; display: flex; gap: 8px; font-size: 13px; }
.cat-actions a { color: #3b82f6; text-decoration: none; }
.cat-actions a:hover { text-decoration: underline; }
.cat-btn-delete { color: #ef4444 !important; }
.cat-btn-add-child { color: #22c55e !important; }
.cat-empty { text-align: center; color: #9ca3af; padding: 40px 0; }

View File

@ -0,0 +1,118 @@
(function () {
'use strict';
var modalOverlay = document.getElementById('modalOverlay');
var modalTitle = document.getElementById('modalTitle');
var modalClose = document.getElementById('modalClose');
var modalCancel = document.getElementById('modalCancel');
var modalSave = document.getElementById('modalSave');
var catId = document.getElementById('catId');
var catParentId = document.getElementById('catParentId');
var catName = document.getElementById('catName');
var catSlug = document.getElementById('catSlug');
var catDesc = document.getElementById('catDesc');
var catSort = document.getElementById('catSort');
var toast = document.getElementById('toast');
function showToast(msg, isError) {
toast.textContent = msg;
toast.className = 'toast' + (isError ? ' toast-error' : ' toast-success');
toast.style.display = 'block';
setTimeout(function () { toast.style.display = 'none'; }, 2500);
}
function closeModal() { modalOverlay.classList.remove('active'); }
modalClose.addEventListener('click', closeModal);
modalCancel.addEventListener('click', closeModal);
modalOverlay.addEventListener('click', function (e) { if (e.target === modalOverlay) closeModal(); });
function openModal(data) {
if (data) {
modalTitle.textContent = '编辑分类';
catId.value = data.id || '';
catParentId.value = data.parentId || '';
catName.value = data.name || '';
catSlug.value = data.slug || '';
catDesc.value = data.desc || '';
catSort.value = data.sort || 0;
} else {
modalTitle.textContent = '新建分类';
catId.value = '';
// Keep parentId for child categories
catName.value = '';
catSlug.value = '';
catDesc.value = '';
catSort.value = 0;
}
modalOverlay.classList.add('active');
}
document.getElementById('btnNewCategory').addEventListener('click', function () {
catParentId.value = '';
openModal(null);
});
document.addEventListener('click', function (e) {
if (e.target.classList.contains('cat-btn-add-child')) {
e.preventDefault();
catParentId.value = e.target.getAttribute('data-parent-id');
openModal(null);
}
if (e.target.classList.contains('cat-btn-edit')) {
e.preventDefault();
openModal({
id: e.target.getAttribute('data-id'),
parentId: e.target.getAttribute('data-parent-id') || '',
name: e.target.getAttribute('data-name'),
slug: e.target.getAttribute('data-slug'),
desc: e.target.getAttribute('data-desc'),
sort: e.target.getAttribute('data-sort'),
});
}
if (e.target.classList.contains('cat-btn-delete')) {
e.preventDefault();
var id = e.target.getAttribute('data-id');
if (!confirm('确定删除此分类?')) return;
fetch('/api/admin/categories/' + id, { method: 'DELETE' })
.then(function (r) { return r.json(); })
.then(function (resp) {
showToast(resp.message || '已删除', !resp.success);
if (resp.success) window.location.reload();
});
}
});
modalSave.addEventListener('click', function () {
var data = {
name: catName.value.trim(),
slug: catSlug.value.trim() || slugify(catName.value.trim()),
description: catDesc.value.trim(),
sort: parseInt(catSort.value) || 0,
};
var pid = catParentId.value;
if (pid) data.parent_id = parseInt(pid);
if (!data.name) { showToast('请输入名称', true); return; }
var url = '/api/admin/categories';
var method = 'POST';
var id = catId.value;
if (id) { url += '/' + id; method = 'PUT'; }
fetch(url, {
method: method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
})
.then(function (r) { return r.json(); })
.then(function (resp) {
showToast(resp.message || '保存成功', !resp.success);
if (resp.success) window.location.reload();
})
.catch(function () { showToast('网络异常', true); });
});
function slugify(s) {
return s.toLowerCase().replace(/\s+/g, '-').replace(/[^a-z0-9\-]/g, '');
}
})();