diff --git a/handlers/backend/post.go b/handlers/backend/post.go index c392716..ed5379d 100644 --- a/handlers/backend/post.go +++ b/handlers/backend/post.go @@ -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() } } diff --git a/routers/router.go b/routers/router.go index 1ec5f40..b305075 100644 --- a/routers/router.go +++ b/routers/router.go @@ -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) diff --git a/static/css/admin.css b/static/css/admin.css index df23478..8c1c846 100644 --- a/static/css/admin.css +++ b/static/css/admin.css @@ -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; +} + diff --git a/static/js/admin.js b/static/js/admin.js index 6cba735..b89a2fd 100644 --- a/static/js/admin.js +++ b/static/js/admin.js @@ -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 编辑器 ============ // 在选区两侧插入包裹符号(加粗、斜体、链接等) diff --git a/templates/admin/index.html b/templates/admin/index.html index 45c7066..f6a04a8 100644 --- a/templates/admin/index.html +++ b/templates/admin/index.html @@ -5,7 +5,7 @@