优化后台文章页的标签表现

This commit is contained in:
2026-09-04 17:21:45 +08:00
parent 533f31e868
commit 7f5bb974fa
6 changed files with 473 additions and 14 deletions
+10 -6
View File
@@ -294,12 +294,16 @@ func UpdatePost(c *gin.Context) {
updates["is_top"] = req.IsTop
}
// 处理标签
if len(req.Tags) > 0 {
tags := findOrCreateTags(req.Tags)
if err := database.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章标签失败"})
return
// 处理标签(nil 表示本次请求不涉及标签;空数组表示移除全部标签)
if req.Tags != nil {
if len(req.Tags) > 0 {
tags := findOrCreateTags(req.Tags)
if err := database.DB.Model(&post).Association("Tags").Replace(tags); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": "更新文章标签失败"})
return
}
} else {
database.DB.Model(&post).Association("Tags").Clear()
}
}
+3
View File
@@ -76,6 +76,9 @@ func setupAuthenticatedAPIRoutes(api *gin.RouterGroup) {
// 分类读取
auth.GET("/categories", backend.GetCategories)
// 标签读取(文章表单的标签候选,所有登录用户可用)
auth.GET("/tags", backend.GetTags)
// 评论管理(查看所有评论,需登录)
auth.GET("/comments", backend.GetComments)
auth.PUT("/comments/:id/approve", backend.ApproveComment)
+141
View File
@@ -1129,3 +1129,144 @@ a.stat-card:hover {
margin: 0;
}
/* ==================== 文章标签选择器 ==================== */
.tag-select {
position: relative;
}
/* 外观与普通输入框一致,内部容纳标签胶囊与输入框 */
.tag-select-box {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 6px;
min-height: 38px;
padding: 4px 8px;
border: 1px solid #d9d9d9;
border-radius: 4px;
background: #fff;
cursor: text;
}
.tag-select-box:focus-within {
border-color: #1890ff;
}
.tag-chips {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 200px;
padding: 2px 4px 2px 10px;
border: 1px solid #91caff;
border-radius: 12px;
background: #e6f4ff;
color: #0958d9;
font-size: 13px;
line-height: 20px;
}
.tag-chip-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
/* 标签右上角删除按钮 */
.tag-chip-remove {
flex-shrink: 0;
width: 16px;
height: 16px;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: #0958d9;
font-size: 14px;
line-height: 1;
cursor: pointer;
}
.tag-chip-remove:hover {
background: #0958d9;
color: #fff;
}
/* 输入框:覆盖 .form-group input 的默认样式,融入胶囊容器 */
.tag-select-box .tag-input {
flex: 1 1 140px;
min-width: 140px;
width: auto;
padding: 4px 0;
border: none;
border-radius: 0;
background: transparent;
font-size: 14px;
}
.tag-select-box .tag-input:focus {
outline: none;
border: none;
}
/* 候选下拉列表 */
.tag-suggest {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
z-index: 100;
max-height: 240px;
overflow-y: auto;
padding: 4px 0;
border: 1px solid #e5e5e5;
border-radius: 6px;
background: #fff;
box-shadow: 0 4px 12px rgba(0,0,0,0.12);
}
.tag-suggest-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 6px 12px;
color: #333;
font-size: 14px;
cursor: pointer;
}
.tag-suggest-item.active {
background: #f0f7ff;
}
.tag-suggest-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.tag-suggest-count {
flex-shrink: 0;
color: #999;
font-size: 12px;
}
/* 输入内容不存在时,作为新建标签的候选 */
.tag-suggest-new .tag-suggest-name {
color: #1890ff;
}
.tag-hint {
display: block;
margin-top: 6px;
color: #999;
font-size: 12px;
}
+307 -3
View File
@@ -117,6 +117,9 @@ document.addEventListener('DOMContentLoaded', () => {
coverFileInput.addEventListener('change', handleCoverFileChange);
}
// 文章标签选择器
bindTagSelector();
// 文件管理上传
const fileUploadInput = document.getElementById('file-upload-input');
if (fileUploadInput) {
@@ -383,6 +386,8 @@ async function showPostForm(isEdit = false) {
_coverFileID = null;
_contentFileIDs.clear();
setCover('');
resetTagSelector([]);
loadAllTags();
mdShowEdit();
// 非管理员隐藏置顶选项
@@ -421,7 +426,9 @@ async function editPost(id) {
setCover(post.cover || '');
document.getElementById('post-status').value = post.status;
document.getElementById('post-istop').checked = post.is_top;
document.getElementById('post-tags').value = post.tags?.map(t => t.name).join(', ') || '';
// 回填已选标签,并加载全部标签供输入时筛选
resetTagSelector(post.tags?.map(t => t.name));
loadAllTags();
// 加载分类并设置选中
const catRes = await fetch(`${API_BASE}/categories`);
@@ -444,8 +451,9 @@ async function handlePostSubmit(e) {
e.preventDefault();
const id = document.getElementById('post-id').value;
const tagsStr = document.getElementById('post-tags').value;
const tags = tagsStr ? tagsStr.split(',').map(t => t.trim()).filter(t => t) : [];
// 输入框中尚未确认的内容也一并作为标签提交,标签增删由后端统一处理
commitTagInput();
const tags = getSelectedTags();
const data = {
title: document.getElementById('post-title').value,
@@ -482,6 +490,302 @@ async function handlePostSubmit(e) {
}
}
// ==================== 标签选择器 ====================
// 已选标签与候选筛选均在前端完成,保存时把完整标签名列表交给后端处理增删
// 全部标签(用于输入时筛选候选)
let _allTags = [];
// 当前文章已选标签名(保持添加顺序)
let _selectedTags = [];
// 下拉当前展示的候选项
let _tagOptions = [];
// 键盘高亮的候选索引
let _tagActiveIndex = -1;
// 下拉最多展示条数
const TAG_SUGGEST_LIMIT = 20;
// 拉取全部标签(每次打开文章表单时刷新,保证新建的标签也能被搜到)
async function loadAllTags() {
try {
const res = await fetch(`${API_BASE}/tags`, { headers: getHeaders() });
if (!res.ok) return;
const data = await res.json();
_allTags = (data.data || [])
.filter(t => t && t.name)
.map(t => ({ name: String(t.name), count: t.post_count || 0 }));
} catch (err) {
console.error('加载标签失败:', err);
}
}
// 规整标签名列表:去空白、忽略大小写去重
function normalizeTagNames(names) {
const result = [];
names.forEach(name => {
const value = String(name === null || name === undefined ? '' : name).trim();
if (value && !result.some(t => t.toLowerCase() === value.toLowerCase())) {
result.push(value);
}
});
return result;
}
// 重置选择器(names 为编辑文章时回填的已有标签)
function resetTagSelector(names) {
_selectedTags = Array.isArray(names) ? normalizeTagNames(names) : [];
const input = document.getElementById('post-tags-input');
if (input) input.value = '';
renderSelectedTags();
hideTagSuggest();
}
// 获取已选标签(提交时使用)
function getSelectedTags() {
return _selectedTags.slice();
}
// 渲染已选标签胶囊(右上角 × 可移除)
function renderSelectedTags() {
const box = document.getElementById('post-tags-selected');
if (!box) return;
box.innerHTML = '';
_selectedTags.forEach(name => {
const chip = document.createElement('span');
chip.className = 'tag-chip';
chip.title = name;
const label = document.createElement('span');
label.className = 'tag-chip-name';
label.textContent = name;
const remove = document.createElement('button');
remove.type = 'button';
remove.className = 'tag-chip-remove';
remove.textContent = '×';
remove.setAttribute('aria-label', `移除标签 ${name}`);
remove.addEventListener('click', () => removeTag(name));
chip.appendChild(label);
chip.appendChild(remove);
box.appendChild(chip);
});
}
// 添加标签,空值或重复时忽略
function addTag(name) {
const value = String(name === null || name === undefined ? '' : name).trim();
if (!value) return false;
if (_selectedTags.some(t => t.toLowerCase() === value.toLowerCase())) return false;
_selectedTags.push(value);
renderSelectedTags();
return true;
}
// 移除标签
function removeTag(name) {
const index = _selectedTags.indexOf(name);
if (index === -1) return;
_selectedTags.splice(index, 1);
renderSelectedTags();
// 下拉处于打开状态时重新筛选,被移除的标签可以立即再次选择
const suggest = document.getElementById('post-tags-suggest');
if (suggest && !suggest.classList.contains('hidden')) {
updateTagSuggest();
}
}
// 确认输入框内容(支持中英文逗号一次输入多个标签)
function commitTagInput() {
const input = document.getElementById('post-tags-input');
if (!input) return;
input.value.split(/[,,]/).forEach(part => addTag(part));
input.value = '';
hideTagSuggest();
}
// 计算候选项:排除已选标签,按“包含”匹配输入内容;无匹配时把输入内容作为一条新建候选
function buildTagOptions(keyword) {
const raw = String(keyword || '').trim();
const kw = raw.toLowerCase();
const selected = new Set(_selectedTags.map(t => t.toLowerCase()));
const matched = _allTags.filter(t =>
!selected.has(t.name.toLowerCase()) && (!kw || t.name.toLowerCase().includes(kw))
);
const options = matched.slice(0, TAG_SUGGEST_LIMIT)
.map(t => ({ name: t.name, count: t.count, isNew: false }));
// 输入的内容不是已有标签时,作为下拉列表的第一条(保存后由后端创建)
if (raw && !selected.has(kw) && !matched.some(t => t.name.toLowerCase() === kw)) {
options.unshift({ name: raw, count: 0, isNew: true });
}
return options;
}
// 按输入框内容刷新下拉候选
function updateTagSuggest() {
const input = document.getElementById('post-tags-input');
if (!input) return;
_tagOptions = buildTagOptions(input.value);
_tagActiveIndex = _tagOptions.length > 0 ? 0 : -1;
renderTagSuggest();
}
// 渲染下拉候选
function renderTagSuggest() {
const box = document.getElementById('post-tags-suggest');
if (!box) return;
box.innerHTML = '';
if (_tagOptions.length === 0) {
box.classList.add('hidden');
return;
}
_tagOptions.forEach((option, index) => {
const item = document.createElement('div');
item.className = 'tag-suggest-item'
+ (option.isNew ? ' tag-suggest-new' : '')
+ (index === _tagActiveIndex ? ' active' : '');
const name = document.createElement('span');
name.className = 'tag-suggest-name';
name.textContent = option.isNew ? `新建标签「${option.name}」` : option.name;
item.appendChild(name);
if (!option.isNew && option.count > 0) {
const count = document.createElement('span');
count.className = 'tag-suggest-count';
count.textContent = `${option.count} 篇文章`;
item.appendChild(count);
}
// mousedown 早于输入框 blur,避免点击时候选已被隐藏
item.addEventListener('mousedown', (e) => {
e.preventDefault();
chooseTagOption(index);
});
item.addEventListener('mouseenter', () => {
_tagActiveIndex = index;
highlightTagOption();
});
box.appendChild(item);
});
box.classList.remove('hidden');
}
// 只切换高亮项,避免整个下拉重绘
function highlightTagOption() {
const box = document.getElementById('post-tags-suggest');
if (!box) return;
box.querySelectorAll('.tag-suggest-item').forEach((item, index) => {
item.classList.toggle('active', index === _tagActiveIndex);
});
// 键盘移动时保证高亮项可见
box.querySelector('.tag-suggest-item.active')?.scrollIntoView({ block: 'nearest' });
}
// 选中一条候选
function chooseTagOption(index) {
const option = _tagOptions[index];
if (!option) return;
addTag(option.name);
const input = document.getElementById('post-tags-input');
if (input) {
input.value = '';
input.focus();
}
// 保持下拉打开,便于连续选择多个标签
updateTagSuggest();
}
// 收起下拉候选
function hideTagSuggest() {
const box = document.getElementById('post-tags-suggest');
if (box) box.classList.add('hidden');
_tagOptions = [];
_tagActiveIndex = -1;
}
// 绑定标签选择器交互(文章表单未渲染时跳过)
function bindTagSelector() {
const input = document.getElementById('post-tags-input');
const select = document.getElementById('post-tag-select');
if (!input || !select) return;
// 点击容器空白处也聚焦输入框
document.getElementById('post-tag-box')?.addEventListener('mousedown', (e) => {
if (e.target === input) return;
e.preventDefault();
input.focus();
});
input.addEventListener('input', () => {
// 输入逗号即把前面的内容作为标签添加,保留最后一段继续输入
if (/[,,]/.test(input.value)) {
const parts = input.value.split(/[,,]/);
input.value = parts.pop() || '';
parts.forEach(part => addTag(part));
}
updateTagSuggest();
});
// 聚焦时列出已有标签,可直接选择
input.addEventListener('focus', () => updateTagSuggest());
input.addEventListener('keydown', (e) => {
switch (e.key) {
case 'ArrowDown':
e.preventDefault();
if (_tagOptions.length === 0) {
updateTagSuggest();
return;
}
_tagActiveIndex = (_tagActiveIndex + 1) % _tagOptions.length;
highlightTagOption();
break;
case 'ArrowUp':
e.preventDefault();
if (_tagOptions.length === 0) return;
_tagActiveIndex = (_tagActiveIndex - 1 + _tagOptions.length) % _tagOptions.length;
highlightTagOption();
break;
case 'Enter':
// 阻止回车提交表单:有高亮项则选中,否则直接采用输入内容
e.preventDefault();
if (_tagActiveIndex >= 0 && _tagOptions[_tagActiveIndex]) {
chooseTagOption(_tagActiveIndex);
} else {
commitTagInput();
}
break;
case 'Escape':
hideTagSuggest();
break;
case 'Backspace':
// 输入框为空时删除最后一个已选标签
if (input.value === '' && _selectedTags.length > 0) {
removeTag(_selectedTags[_selectedTags.length - 1]);
}
break;
}
});
input.addEventListener('blur', () => hideTagSuggest());
// 点击选择器外部时收起下拉
document.addEventListener('mousedown', (e) => {
if (!select.contains(e.target)) {
hideTagSuggest();
}
});
}
// ============ Markdown 编辑器 ============
// 在选区两侧插入包裹符号(加粗、斜体、链接等)
+11 -4
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>后台管理 - GoBlog</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/admin.css?v=3">
<link rel="stylesheet" href="/static/css/admin.css?v=4">
</head>
<body>
<div class="admin-header">
@@ -58,7 +58,7 @@
{{template "admin_modal_password" .}}
<script>window.__currentUser = {{.CurrentUser }}</script>
<script src="/static/js/admin.js?v=21"></script>
<script src="/static/js/admin.js?v=22"></script>
</body>
</html>
{{end}}
@@ -193,8 +193,15 @@
</div>
</div>
<div class="form-group">
<label>标签(用逗号分隔)</label>
<input type="text" id="post-tags" placeholder="标签1, 标签2, 标签3">
<label>标签</label>
<div class="tag-select" id="post-tag-select">
<div class="tag-select-box" id="post-tag-box">
<span class="tag-chips" id="post-tags-selected"></span>
<input type="text" id="post-tags-input" class="tag-input" placeholder="输入标签名,回车添加" autocomplete="off">
</div>
<div class="tag-suggest hidden" id="post-tags-suggest"></div>
</div>
<small class="tag-hint">点击标签右上角 × 可移除;输入的内容若不存在,将作为新标签在保存时创建</small>
</div>
<div class="form-group">
<label>封面图</label>
+1 -1
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>登录 - GoBlog 后台管理</title>
<link rel="icon" href="/static/images/favicon.ico" type="image/x-icon">
<link rel="stylesheet" href="/static/css/admin.css?v=3">
<link rel="stylesheet" href="/static/css/admin.css?v=4">
</head>
<body>
<div id="login-page">