feat: @提及功能优化 + 默认头像改用SVG图标
- 评论区有效@提及渲染为蓝色链接跳转/space/{uid},无效@保持纯文本
- 输入@触发悬浮窗,根据输入实时搜索用户(含头像+等级)
- 无匹配时显示未搜索到用户,选中后插入为@用户名 格式(尾部空格)
- 悬浮窗定位于输入光标上方,不遮挡输入内容
- 后端Comment新增Mentions字段,批量加载提及映射供前端判断
- SearchUsers/parseMentions 统一限制LV2+用户
- 所有默认头像由首字符文字改为统一SVG人形图标(nav/home/space/mention等7处)
This commit is contained in:
@ -230,11 +230,13 @@ header {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-accent);
|
||||
color: #fff;
|
||||
font-size: .85rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,.75);
|
||||
object-fit: unset;
|
||||
}
|
||||
.nav-avatar-default svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.nav-avatar-wrapper:hover .nav-avatar {
|
||||
border-color: var(--color-accent);
|
||||
@ -279,9 +281,11 @@ header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #fff;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: rgba(255,255,255,.75);
|
||||
}
|
||||
.nav-dropdown-avatar svg {
|
||||
width: 52%;
|
||||
height: 52%;
|
||||
}
|
||||
|
||||
.nav-dropdown-avatar img {
|
||||
|
||||
@ -239,14 +239,15 @@
|
||||
height: 64px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, var(--color-accent), #2980b9);
|
||||
color: #fff;
|
||||
color: rgba(255,255,255,.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin: 0 auto .75rem;
|
||||
font-size: 1.6rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.user-avatar-fallback svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.guest-avatar {
|
||||
|
||||
@ -1127,6 +1127,18 @@
|
||||
margin-right: 6px;
|
||||
}
|
||||
|
||||
/* 有效@提及链接 */
|
||||
.mention-link {
|
||||
color: #6366f1;
|
||||
text-decoration: none;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.mention-link:hover {
|
||||
text-decoration: underline;
|
||||
color: #4f46e5;
|
||||
}
|
||||
|
||||
.comment-actions {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
@ -1238,6 +1250,9 @@
|
||||
}
|
||||
|
||||
.mention-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 14px;
|
||||
font-size: 13px;
|
||||
cursor: pointer;
|
||||
@ -1250,14 +1265,47 @@
|
||||
color: #6366f1;
|
||||
}
|
||||
|
||||
.mention-item .mention-username {
|
||||
font-weight: 500;
|
||||
.mention-item-avatar {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
object-fit: cover;
|
||||
flex-shrink: 0;
|
||||
background: #f3f4f6;
|
||||
}
|
||||
|
||||
.mention-item .mention-uid {
|
||||
.mention-item-avatar-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #e5e7eb;
|
||||
flex-shrink: 0;
|
||||
color: #9ca3af;
|
||||
}
|
||||
.mention-item-avatar-placeholder svg {
|
||||
width: 60%;
|
||||
height: 60%;
|
||||
}
|
||||
|
||||
.mention-item .mention-username {
|
||||
font-weight: 500;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mention-item .mention-level {
|
||||
color: #9ca3af;
|
||||
font-size: 11px;
|
||||
margin-left: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.mention-no-result {
|
||||
padding: 12px 14px;
|
||||
font-size: 13px;
|
||||
color: #9ca3af;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* ========== 右下角悬浮操作栏 ========== */
|
||||
|
||||
@ -75,14 +75,16 @@
|
||||
width: 56px; height: 56px;
|
||||
border-radius: 50%;
|
||||
background: rgba(255,255,255,.25);
|
||||
color: #fff;
|
||||
color: rgba(255,255,255,.75);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.4rem;
|
||||
font-weight: 700;
|
||||
border: 2px solid rgba(255,255,255,.4);
|
||||
}
|
||||
.space-avatar-placeholder svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.space-header-info {
|
||||
color: #fff;
|
||||
@ -377,9 +379,12 @@
|
||||
}
|
||||
.space-user-avatar-placeholder {
|
||||
width: 48px; height: 48px; border-radius: 50%;
|
||||
background: var(--color-accent); color: #fff;
|
||||
background: var(--color-accent); color: rgba(255,255,255,.75);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 1.15rem; font-weight: 700;
|
||||
}
|
||||
.space-user-avatar-placeholder svg {
|
||||
width: 55%;
|
||||
height: 55%;
|
||||
}
|
||||
|
||||
.space-user-info { flex: 1; min-width: 0; }
|
||||
|
||||
@ -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() {
|
||||
|
||||
Reference in New Issue
Block a user