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