优化后台文章页的标签表现
This commit is contained in:
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 编辑器 ============
|
||||
|
||||
// 在选区两侧插入包裹符号(加粗、斜体、链接等)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user