段内评论功能技术实现详解

8 阅读9分钟

功能概述与设计理念

段内评论系统是一种创新的交互功能,它允许用户选中任意文本段落进行精准评论,实现了类似现代文档协作工具的交互体验。与传统的整页评论不同,段内评论能够精确定位到具体的文本内容,为用户提供更加细致的讨论空间。

核心设计思路:

  • 基于文本选择的精确评论定位
  • 支持选中任意长度的文本段落
  • 评论内容与原文本段落关联存储
  • 实时高亮显示评论区域
  • 支持评论回复与盖楼机制

技术架构与实现原理

整体架构

段内评论功能采用前后端分离的实现方式,主要包括以下组件:

  1. 前端交互层:负责文本选择监听、评论弹窗、高亮显示等用户交互
  2. 后端API层:提供评论的增删改查等RESTful接口
  3. 数据存储层:使用MySQL存储评论数据,支持关联查询
graph TD
    subgraph 前端交互层
        A[用户浏览器]
        B[文本选择监听]
        C[评论弹窗]
        D[高亮显示]
        E[侧边抽屉]
    end
    
    subgraph 后端API层
        F[RESTful API]
        G[评论管理]
        H[权限控制]
    end
    
    subgraph 数据存储层
        I[MySQL数据库]
        J[评论表]
        K[用户表]
    end
    
    A --> B
    B --> C
    C --> F
    F --> G
    G --> I
    I --> J
    I --> K
    G --> H
    H --> F
    F --> D
    D --> E

核心流程

  1. 文本选择:用户在页面上选中任意文本
  2. 触发评论:系统显示评论工具提示,用户点击后弹出评论模态框
  3. 提交评论:用户输入评论内容并提交
  4. 数据存储:后端接收评论数据并存储到数据库
  5. 前端更新:刷新评论列表并高亮显示评论区域
flowchart TD
    A[用户选择文本] --> B[显示评论工具提示]
    B --> C[点击评论按钮]
    C --> D[弹出评论模态框]
    D --> E[用户输入评论内容]
    E --> F[提交评论]
    F --> G[前端发送API请求]
    G --> H[后端接收请求]
    H --> I[验证用户权限]
    I --> J[存储评论数据]
    J --> K[返回成功响应]
    K --> L[前端关闭模态框]
    L --> M[刷新评论列表]
    M --> N[高亮显示评论区域]

前端实现细节

核心交互逻辑

前端实现主要集中在页面文件中,包含以下关键功能:

// 文本选择事件监听
document.addEventListener('selectionchange', function() {
    const selection = window.getSelection();
    const selectedText = selection.toString().trim();
    
    if (selectedText.length > 0) {
        // 显示评论工具提示
        showInlineCommentTooltip(selection);
        currentSelectionText = selectedText;
    } else {
        // 隐藏工具提示
        hideInlineCommentTooltip();
        currentSelectionText = "";
    }
});

// 显示评论弹窗
function showInlineCommentModal() {
    if (!currentSelectionText) return;
    
    document.getElementById('inline-comment-selected-text').innerText = currentSelectionText;
    document.getElementById('inline-comment-modal').style.display = 'flex';
    document.getElementById('inline-comment-tooltip').style.display = 'none';
    
    // 重置输入框
    document.getElementById('inline-comment-input').value = "";
}

// 提交段内评论
async function submitInlineComment() {
    const content = document.getElementById('inline-comment-input').value;
    if (!content) return;

    try {
        const response = await fetch('/api/inline_comment/add', {
            method: 'POST',
            headers: { 'Content-Type': 'application/json' },
            body: JSON.stringify({
                post_id: currentPostId,
                content: content,
                selected_text: currentSelectionText
            })
        });
        const res = await response.json();
        if (res.status === 0) {
            closeInlineCommentModal();
            loadInlineComments(); // 刷新高亮
            alert('评论发布成功');
        } else if (res.status === 401) {
            alert('请先登录');
        } else {
            alert('发布失败: ' + res.reason);
        }
    } catch (e) {
        console.error(e);
        alert('发布失败,请稍后重试');
    }
}

评论高亮与展示

前端实现了评论区域的高亮显示和侧边抽屉式评论展示:

function highlightInlineComments(comments) {
    // 清除现有高亮
    const content = document.getElementById('post-markdown-content');
    const existingHighlights = content.querySelectorAll('.inline-comment-target');
    existingHighlights.forEach(el => {
        const text = el.textContent;
        el.outerHTML = text;
    });
    
    // 为每个选中的文本添加高亮
    const text = content.textContent;
    let lastIndex = 0;
    let result = '';
    
    // 简单实现:查找文本并添加高亮
    // 注意:实际实现中需要考虑文本重复的情况
    comments.forEach(comment => {
        const selectedText = comment.selected_text;
        const index = text.indexOf(selectedText, lastIndex);
        if (index !== -1) {
            result += text.substring(lastIndex, index);
            result += `<span class="inline-comment-target" onclick="showCommentDrawer('${selectedText}')">${selectedText}</span>`;
            lastIndex = index + selectedText.length;
        }
    });
    
    result += text.substring(lastIndex);
    content.innerHTML = result;
}

function showCommentDrawer(text) {
    const drawer = document.getElementById('comment-drawer');
    const backdrop = document.getElementById('drawer-backdrop');
    const body = document.getElementById('drawer-body');
    
    // 过滤此文本的评论
    const comments = allInlineComments.filter(c => c.selected_text === text);
    const commentTree = buildCommentTree(comments);
    
    body.innerHTML = `
        <div class="selected-text-quote">"${escapeHtml(text)}"</div>
        <div class="comments-container">
            ${commentTree.length > 0 ? commentTree.map(c => renderCommentNode(c)).join('') : '<div style="color:var(--text-secondary); text-align:center; padding:20px;">暂无评论</div>'}
        </div>
    `;
    
    drawer.classList.add('visible');
    backdrop.classList.add('visible');
}

样式设计

CSS样式定义包括评论工具提示、高亮样式和侧边抽屉:

/* Inline Comment Tooltip Arrow */
.inline-comment-tooltip::after {
    content: '';
    position: absolute;
    top: 100%;
    left: 50%;
    margin-left: -5px;
    border-width: 5px;
    border-style: solid;
    border-color: #333 transparent transparent transparent;
}

.inline-comment-target {
    border-bottom: 1px dashed #999;
    cursor: pointer;
    position: relative;
}
.inline-comment-target:hover {
    border-bottom-color: #2cbb5d;
    background-color: rgba(44, 187, 93, 0.1);
}

/* Side Drawer */
.comment-drawer {
    position: fixed;
    top: 0;
    right: -400px;
    width: 400px;
    height: 100%;
    background: var(--card-bg);
    box-shadow: -2px 0 10px rgba(0,0,0,0.5);
    z-index: 1500;
    transition: right 0.3s ease;
    display: flex;
    flex-direction: column;
    border-left: 1px solid rgba(255,255,255,0.1);
}
.comment-drawer.visible {
    right: 0;
}

后端实现细节

API接口设计

后端定义了段内评论的API接口:

// API Add Inline Comment
svr.Post("/api/inline_comment/add", [&ctrl](const Request &req, Response &resp){
    User user;
    Json::Value res_json;
    if (!ctrl.AuthCheck(req, &user)) {
         res_json["status"] = 401;
         res_json["reason"] = "请先登录";
         
         resp.set_content(SerializeJson(res_json), "application/json;charset=utf-8");
         return;
    }

    Json::Reader reader;
    Json::Value root;
    reader.parse(req.body, root);
    std::string post_id = root["post_id"].asString();
    std::string content = root["content"].asString();
    std::string selected_text = root["selected_text"].asString();
    std::string parent_id = root.isMember("parent_id") ? root["parent_id"].asString() : "0";

    std::string json_out;
    ctrl.AddInlineComment(user.id, post_id, content, selected_text, parent_id, &json_out);
    resp.set_content(json_out, "application/json;charset=utf-8");
});

// API Delete Inline Comment
svr.Post("/api/inline_comment/delete", [&ctrl](const Request &req, Response &resp){
    User user;
    Json::Value res_json;
    if (!ctrl.AuthCheck(req, &user)) {
         res_json["status"] = 401;
         res_json["reason"] = "请先登录";
         
         resp.set_content(SerializeJson(res_json), "application/json;charset=utf-8");
         return;
    }

    Json::Reader reader;
    Json::Value root;
    reader.parse(req.body, root);
    std::string comment_id = root["comment_id"].asString();

    std::string json_out;
    ctrl.DeleteInlineComment(comment_id, user.id, user.role, &json_out);
    resp.set_content(json_out, "application/json;charset=utf-8");
});

// API Get Inline Comments
svr.Get(R"(/api/inline_comments/(\d+))", [&ctrl](const Request &req, Response &resp){
    std::string post_id = req.matches[1];
    std::string json_out;
    ctrl.GetInlineComments(post_id, &json_out);
    resp.set_content(json_out, "application/json;charset=utf-8");
});

业务逻辑实现

业务逻辑实现:

bool AddInlineComment(const std::string &user_id, const std::string &post_id, 
                     const std::string &content, const std::string &selected_text, 
                     const std::string &parent_id, std::string *json_out)
{
    InlineComment c;
    c.user_id = user_id;
    c.post_id = post_id;
    c.content = content;
    c.selected_text = selected_text;
    c.parent_id = parent_id;
    
    if (model_.AddInlineComment(c)) {
        Json::Value res;
        res["status"] = 0;
        res["reason"] = "Success";
        *json_out = SerializeJson(res);
        return true;
    } else {
        Json::Value res;
        res["status"] = 1;
        res["reason"] = "Database Error";
        *json_out = SerializeJson(res);
        return false;
    }
}

bool GetInlineComments(const std::string &post_id, std::string *json_out)
{
    std::vector<InlineComment> comments;
    if (model_.GetInlineComments(post_id, &comments)) {
        Json::Value root;
        root["status"] = 0;
        Json::Value list;
        for (const auto &c : comments) {
            Json::Value item;
            item["id"] = c.id;
            item["user_id"] = c.user_id;
            item["username"] = c.username;
            item["avatar"] = c.user_avatar;
            item["post_id"] = c.post_id;
            item["content"] = c.content;
            item["selected_text"] = c.selected_text;
            item["parent_id"] = c.parent_id;
            item["created_at"] = c.created_at;
            list.append(item);
        }
        root["data"] = list;
        
        *json_out = SerializeJson(root);
        return true;
    } else {
        Json::Value res;
        res["status"] = 1;
        res["reason"] = "Database Error";
        
        *json_out = SerializeJson(res);
        return false;
    }
}

数据库设计

表结构

数据模型和表结构定义:

erDiagram
    USERS ||--o{ INLINE_COMMENTS : writes
    INLINE_COMMENTS ||--o{ INLINE_COMMENTS : replies_to
    
    USERS {
        int id PK
        varchar username
        varchar password
        varchar avatar
        timestamp created_at
    }
    
    INLINE_COMMENTS {
        int id PK
        int user_id FK
        int post_id FK
        text content
        text selected_text
        int parent_id FK
        timestamp created_at
    }
struct InlineComment
{
    std::string id;
    std::string user_id;
    std::string username; // Join query result
    std::string user_avatar; // Join query result
    std::string post_id;
    std::string content;
    std::string selected_text;
    std::string parent_id; // For nested replies
    std::string created_at;
};

void InitInlineCommentTable() {
    std::string sql = "CREATE TABLE IF NOT EXISTS `inline_comments` ("
                      "`id` int(11) NOT NULL AUTO_INCREMENT,"
                      "`user_id` int(11) NOT NULL,"
                      "`post_id` int(11) NOT NULL,"
                      "`content` TEXT NOT NULL,"
                      "`selected_text` TEXT,"
                      "`parent_id` int(11) DEFAULT 0,"
                      "`created_at` TIMESTAMP DEFAULT CURRENT_TIMESTAMP,"
                      "PRIMARY KEY (`id`),"
                      "INDEX `idx_post_id` (`post_id`)"
                      ") ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;";
    ExecuteSql(sql);
}

数据操作

bool AddInlineComment(const InlineComment &comment)
{
    ConnectionGuard guard;
    MYSQL *my = guard.get();
    if(!my){
        return false;
    }
    
    auto escape = [&](const std::string &s) -> std::string {
        char *buf = new char[s.length() * 2 + 1];
        mysql_real_escape_string(my, buf, s.c_str(), s.length());
        std::string res(buf);
        delete[] buf;
        return res;
    };
    
    std::string parent_val = comment.parent_id.empty() ? "0" : comment.parent_id;

    std::string sql = "INSERT INTO " + oj_inline_comments + " (user_id, post_id, content, selected_text, parent_id) VALUES ('"
        + comment.user_id + "', '"
        + comment.post_id + "', '"
        + escape(comment.content) + "', '"
        + escape(comment.selected_text) + "', "
        + parent_val + ")";
    
    if(0 != mysql_query(my, sql.c_str())) {
        LOG(WARNING) << sql << " execute error: " << mysql_error(my) << "\n";

        return false;
    }

    return true;
}

bool GetInlineComments(const std::string &post_id, std::vector<InlineComment> *out)
{
    std::string sql = "SELECT c.id, c.user_id, u.username, c.post_id, c.content, c.selected_text, c.created_at, c.parent_id, u.avatar FROM " 
                      + oj_inline_comments + " c LEFT JOIN " + oj_users + " u ON c.user_id = u.id " 
                      + "WHERE c.post_id='" + post_id + "' ORDER BY c.created_at ASC";
    
    ConnectionGuard guard;
    MYSQL *my = guard.get();
    if(!my){
        return false;
    }
    
    if(0 != mysql_query(my, sql.c_str())) {

        return false;
    }
    
    MYSQL_RES *res = mysql_store_result(my);
    int rows = mysql_num_rows(res);
    int fields = mysql_num_fields(res);
    
    for(int i = 0; i < rows; i++)
    {
        MYSQL_ROW row = mysql_fetch_row(res);
        if(row == nullptr) continue;
        
        InlineComment c;
        c.id = row[0] ? row[0] : "";
        c.user_id = row[1] ? row[1] : "";
        c.username = row[2] ? row[2] : "";
        c.post_id = row[3] ? row[3] : "";
        c.content = row[4] ? row[4] : "";
        c.selected_text = row[5] ? row[5] : "";
        c.created_at = row[6] ? row[6] : "";
        c.parent_id = row[7] ? row[7] : "0";
        c.user_avatar = row[8] ? row[8] : "";
        
        out->push_back(c);
    }
    mysql_free_result(res);
    
    return true;
}

核心功能演示

使用流程

  1. 选择文本:用户在文章中选中任意文本段落
  2. 触发评论:系统自动显示评论工具提示
  3. 输入评论:点击工具提示中的评论按钮,在弹出的模态框中输入评论内容
  4. 提交评论:点击提交按钮,系统将评论保存到数据库
  5. 查看评论:评论区域会自动高亮,点击高亮区域可在侧边抽屉中查看所有相关评论
  6. 回复评论:在侧边抽屉中可以对评论进行回复,支持多层嵌套
sequenceDiagram
    participant User as 用户
    participant UI as 前端界面
    participant API as 后端API
    participant DB as 数据库
    
    User->>UI: 选择文本段落
    UI->>UI: 显示评论工具提示
    User->>UI: 点击评论按钮
    UI->>UI: 弹出评论模态框
    User->>UI: 输入评论内容
    User->>UI: 点击提交按钮
    UI->>API: 发送评论数据
    API->>DB: 存储评论
    DB-->>API: 存储成功
    API-->>UI: 返回成功响应
    UI->>UI: 关闭模态框
    UI->>UI: 高亮显示评论区域
    User->>UI: 点击高亮区域
    UI->>API: 获取评论列表
    API->>DB: 查询评论
    DB-->>API: 返回评论数据
    API-->>UI: 返回评论列表
    UI->>UI: 显示侧边抽屉
    User->>UI: 回复评论
    UI->>API: 发送回复数据
    API->>DB: 存储回复
    DB-->>API: 存储成功
    API-->>UI: 返回成功响应
    UI->>UI: 更新评论列表

交互效果

  • 实时高亮:评论的文本段落会自动高亮显示
  • 侧边抽屉:点击高亮文本会从右侧滑出评论抽屉,显示所有相关评论
  • 嵌套回复:支持评论的多层嵌套,形成讨论线程
  • 即时反馈:评论提交后立即更新显示,无需刷新页面

技术亮点与优化

技术亮点

  1. 精准定位:基于文本选择的评论定位,实现了对具体内容的精准讨论
  2. 用户体验:流畅的交互体验,包括工具提示、模态框和侧边抽屉
  3. 数据关联:评论与选中文本的关联存储,确保评论的上下文完整性
  4. 权限控制:实现了评论的权限管理,只有评论作者和管理员可以删除评论
  5. 性能优化:使用索引加速查询,减少数据库负载

性能优化

  1. 数据库索引:在 post_id 字段上创建索引,加速评论查询
  2. 前端缓存:将评论数据缓存到前端,减少重复请求
  3. 批量操作:一次性加载所有评论,避免多次请求
  4. 延迟加载:仅在需要时加载评论数据,提高页面加载速度

未来扩展与改进

功能扩展

  1. 评论点赞:添加评论点赞功能,增加用户互动
  2. 评论排序:支持按时间、热度等方式排序评论
  3. 评论通知:当评论被回复时,向用户发送通知
  4. 评论编辑:允许用户编辑自己的评论
  5. 评论举报:添加评论举报功能,维护社区秩序

技术改进

  1. 前端框架迁移:将前端从原生JS迁移到React,提高代码可维护性
  2. WebSocket集成:使用WebSocket实现评论的实时更新
  3. 缓存优化:集成Redis缓存,进一步提高性能
  4. 搜索功能:添加评论搜索功能,方便用户查找相关讨论
  5. 响应式设计:优化移动端体验,确保在各种设备上的良好表现

总结

段内评论功能是一种重要的技术创新,它为用户提供了一种全新的互动方式,使讨论更加精准和深入。通过前后端的紧密配合,实现了从文本选择到评论展示的完整流程,为在线内容的讨论和协作提供了有力支持。

该功能的实现充分利用了现代Web技术,包括原生JS的选择API、CSS的过渡动画、后端的RESTful API以及数据库的关联查询。同时,通过合理的架构设计和性能优化,确保了在高并发场景下的稳定运行。

未来,段内评论技术将继续发展,添加更多交互特性,提高用户体验,成为各类在线平台的核心功能之一。