feat: @提及功能优化 + 默认头像改用SVG图标

- 评论区有效@提及渲染为蓝色链接跳转/space/{uid},无效@保持纯文本
- 输入@触发悬浮窗,根据输入实时搜索用户(含头像+等级)
- 无匹配时显示未搜索到用户,选中后插入为@用户名 格式(尾部空格)
- 悬浮窗定位于输入光标上方,不遮挡输入内容
- 后端Comment新增Mentions字段,批量加载提及映射供前端判断
- SearchUsers/parseMentions 统一限制LV2+用户
- 所有默认头像由首字符文字改为统一SVG人形图标(nav/home/space/mention等7处)
This commit is contained in:
2026-06-03 00:49:24 +08:00
parent 2d05da6709
commit 4ac4f64f33
12 changed files with 249 additions and 57 deletions

View File

@ -14,13 +14,28 @@
var currentPage = 1;
var totalPages = 1;
// 渲染@提及:有效提及渲染为蓝色链接,无效提及保持纯文本
function renderMentions(body, mentions) {
body = escapeHtml(body);
if (!mentions || Object.keys(mentions).length === 0) {
// 无 mentions 数据时仍高亮@,但不加链接(降级处理)
return body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
}
return body.replace(/@(\S{1,16})/g, function(match, username) {
var uid = mentions[username];
if (uid) {
return '<a class="mention-link" href="/space/' + uid + '">@' + escapeHtml(username) + '</a>';
}
return match;
});
}
function buildCommentCard(c) {
var time = c.created_at ? formatTime(c.created_at) : '';
var author = c.author_name ? escapeHtml(c.author_name) : '用户已注销';
var body = c.body || '';
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
body = renderMentions(body, c.mentions);
var html = '<div class="comment-card" data-id="' + c.id + '">' +
'<div class="comment-header">' +
@ -53,8 +68,8 @@
var time = r.created_at ? formatTime(r.created_at) : '';
var author = r.author_name ? escapeHtml(r.author_name) : '用户已注销';
var body = r.body || '';
body = escapeHtml(body);
body = body.replace(/@(\S{1,16})/g, '<span class="reply-to">@$1</span>');
body = renderMentions(body, r.mentions);
var prefix = '';
if (r.reply_to_name) {
@ -236,17 +251,42 @@
var mentionPos = 0;
var searchTimer = null;
// 计算 textarea 中光标的近似像素位置(相对视口)
function getCursorPixelPos(textarea, cursorIdx) {
var text = textarea.value.substring(0, cursorIdx);
var lines = text.split('\n');
var lineCount = lines.length;
var lastLine = lines[lineCount - 1];
var style = window.getComputedStyle(textarea);
var lineHeight = parseFloat(style.lineHeight) || parseFloat(style.fontSize) * 1.5 || 21;
var paddingTop = parseFloat(style.paddingTop) || 0;
var paddingLeft = parseFloat(style.paddingLeft) || 0;
// 近似字符宽度 (中文字符约 2x 英文字符)
var charWidth = parseFloat(style.fontSize) * 0.6 || 8.4;
var rect = textarea.getBoundingClientRect();
var top = rect.top + paddingTop + (lineCount - 1) * lineHeight + lineHeight;
var left = rect.left + paddingLeft + lastLine.length * charWidth;
return { top: top, left: left, bottom: rect.bottom };
}
function bindMention(input) {
input.addEventListener('input', function() {
var val = input.value;
var cursor = input.selectionStart;
var atPos = val.lastIndexOf('@', cursor - 1);
// 检查 @ 前是否为空格或行首(合法位置)
if (atPos === -1 || (atPos > 0 && val[atPos - 1] !== ' ' && val[atPos - 1] !== '\n')) {
hideMention();
return;
}
var query = val.substring(atPos + 1, cursor);
if (query.length === 0 || query.includes(' ')) {
// 查询中包含空格 -> 隐藏下拉
if (query.includes(' ') || query.includes('\n')) {
hideMention();
return;
}
@ -255,6 +295,14 @@
mentionPos = atPos;
clearTimeout(searchTimer);
// 仅输入 @ 时,显示空白下拉
if (query.length === 0) {
showMention(input, [], '');
return;
}
// 有查询内容时,延时搜索
searchTimer = setTimeout(function() {
fetch('/api/users/search?q=' + encodeURIComponent(query))
.then(function(r) { return r.json(); })
@ -262,7 +310,7 @@
if (d.success && d.data && d.data.length > 0) {
showMention(input, d.data, query);
} else {
hideMention();
showMention(input, [], query);
}
})
.catch(function() { hideMention(); });
@ -275,26 +323,47 @@
}
function showMention(input, users, query) {
var rect = input.getBoundingClientRect();
var dropdown = mentionDropdown;
dropdown.innerHTML = '';
dropdown.style.display = 'block';
dropdown.style.top = (rect.bottom + window.scrollY + 4) + 'px';
dropdown.style.left = (rect.left + window.scrollX) + 'px';
dropdown.style.width = Math.max(rect.width, 200) + 'px';
for (var i = 0; i < users.length; i++) {
(function(u) {
var item = document.createElement('div');
item.className = 'mention-item';
item.innerHTML = '<span class="mention-username">' + escapeHtml(u.username) + '</span><span class="mention-uid">' + escapeHtml(u.uid) + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
});
dropdown.appendChild(item);
})(users[i]);
if (users.length === 0) {
// 无匹配结果:显示提示(仅在有查询时)
if (query.length > 0) {
var emptyItem = document.createElement('div');
emptyItem.className = 'mention-no-result';
emptyItem.textContent = '未搜索到用户';
dropdown.appendChild(emptyItem);
}
} else {
for (var i = 0; i < users.length; i++) {
(function(u) {
var item = document.createElement('div');
item.className = 'mention-item';
var avatarHtml = u.avatar
? '<img class="mention-item-avatar" src="' + escapeHtml(u.avatar) + '" alt="" />'
: '<span class="mention-item-avatar mention-item-avatar-placeholder"><svg viewBox="0 0 24 24" fill="currentColor"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg></span>';
item.innerHTML = avatarHtml +
'<span class="mention-username">' + escapeHtml(u.username) + '</span>' +
'<span class="mention-level">LV' + u.level + '</span>';
item.addEventListener('mousedown', function(e) {
e.preventDefault();
selectMention(u);
});
dropdown.appendChild(item);
})(users[i]);
}
}
// 计算光标位置并定位(先渲染内容,再用 offsetHeight 定位)
var cursorIdx = input.selectionStart;
var pos = getCursorPixelPos(input, cursorIdx);
dropdown.style.display = 'block';
dropdown.style.width = '220px';
// 悬浮在光标上方(不遮挡输入内容)
var dropHeight = dropdown.offsetHeight || 60;
dropdown.style.top = (pos.top - dropHeight - 4 + window.scrollY) + 'px';
dropdown.style.left = Math.min(pos.left, window.innerWidth - 230) + 'px';
}
function hideMention() {