Explorar o código

feature-add-node-visual (#2637)

* feature-add-node-visual

* adj

* adj

* adj

* fix dir index bug
chendapao hai 2 meses
pai
achega
924451b452

+ 2 - 1
.gitignore

@@ -17,4 +17,5 @@ build
 build.*
 build*
 .DS_Store
-.vscode
+.vscode
+dev.sh

+ 1 - 0
src/core/CMakeLists.txt

@@ -44,6 +44,7 @@ target_sources(vnote PRIVATE
     notebook/inotebookfactory.h
     notebook/node.cpp notebook/node.h
     notebook/nodeparameters.cpp notebook/nodeparameters.h
+    notebook/nodevisual.cpp notebook/nodevisual.h
     notebook/notebook.cpp notebook/notebook.h
     notebook/notebookdatabaseaccess.cpp notebook/notebookdatabaseaccess.h
     notebook/notebookparameters.cpp notebook/notebookparameters.h

+ 72 - 0
src/core/notebook/node.cpp

@@ -27,6 +27,7 @@ Node::Node(Flags p_flags,
       m_modifiedTimeUtc(p_paras.m_modifiedTimeUtc),
       m_tags(p_paras.m_tags),
       m_attachmentFolder(p_paras.m_attachmentFolder),
+      m_visual(p_paras.m_visual),
       m_parent(p_parent)
 {
     Q_ASSERT(m_notebook);
@@ -66,6 +67,8 @@ void Node::loadCompleteInfo(const NodeParameters &p_paras,
     m_modifiedTimeUtc = p_paras.m_modifiedTimeUtc;
     Q_ASSERT(p_paras.m_tags.isEmpty());
     Q_ASSERT(p_paras.m_attachmentFolder.isEmpty());
+    
+    m_visual = p_paras.m_visual;
 
     m_children = p_children;
     m_loaded = true;
@@ -493,3 +496,72 @@ void Node::checkSignature()
         m_signature = generateSignature();
     }
 }
+
+// 视觉效果相关方法
+const NodeVisual &Node::getVisual() const
+{
+    return m_visual;
+}
+
+void Node::setVisual(const NodeVisual &p_visual)
+{
+    m_visual = p_visual;
+}
+
+// 视觉效果便捷访问方法
+const QString &Node::getBackgroundColor() const
+{
+    return m_visual.getBackgroundColor();
+}
+
+void Node::setBackgroundColor(const QString &p_backgroundColor)
+{
+    m_visual.setBackgroundColor(p_backgroundColor);
+}
+
+const QString &Node::getBorderColor() const
+{
+    return m_visual.getBorderColor();
+}
+
+void Node::setBorderColor(const QString &p_borderColor)
+{
+    m_visual.setBorderColor(p_borderColor);
+}
+
+const QString &Node::getNameColor() const
+{
+    return m_visual.getNameColor();
+}
+
+void Node::setNameColor(const QString &p_nameColor)
+{
+    m_visual.setNameColor(p_nameColor);
+}
+
+QString Node::getEffectiveBackgroundColor() const
+{
+    return getBackgroundColor();
+}
+
+QString Node::getEffectiveBorderColor() const
+{
+    return getBorderColor();
+}
+
+void Node::updateNodeVisual(const NodeVisual &p_visual)
+{
+    if (m_visual.getBackgroundColor() == p_visual.getBackgroundColor() && 
+        m_visual.getBorderColor() == p_visual.getBorderColor() &&
+        m_visual.getNameColor() == p_visual.getNameColor()) {
+        return;
+    }
+
+    m_visual = p_visual;
+
+    // 持久化更新
+    getConfigMgr()->updateNodeVisual(this, p_visual);
+
+    // 界面更新
+    emit m_notebook->nodeUpdated(this);
+}

+ 23 - 0
src/core/notebook/node.h

@@ -8,6 +8,7 @@
 #include <QEnableSharedFromThis>
 
 #include <global.h>
+#include "nodevisual.h"
 
 namespace vnotex
 {
@@ -140,6 +141,26 @@ namespace vnotex
 
         QString fetchAttachmentFolderPath();
 
+        // 视觉效果相关方法
+        const NodeVisual &getVisual() const;
+        void setVisual(const NodeVisual &p_visual);
+
+        // 视觉效果便捷访问方法
+        const QString &getBackgroundColor() const;
+        void setBackgroundColor(const QString &p_backgroundColor);
+
+        const QString &getBorderColor() const;
+        void setBorderColor(const QString &p_borderColor);
+
+        const QString &getNameColor() const;
+        void setNameColor(const QString &p_nameColor);
+
+        // 获取有效颜色(直接返回设置的颜色)
+        QString getEffectiveBackgroundColor() const;
+        QString getEffectiveBorderColor() const;
+
+        void updateNodeVisual(const NodeVisual &p_visual);
+
         virtual QStringList addAttachment(const QString &p_destFolderPath, const QStringList &p_files) = 0;
 
         virtual QString newAttachmentFile(const QString &p_destFolderPath, const QString &p_name) = 0;
@@ -204,6 +225,8 @@ namespace vnotex
 
         QString m_attachmentFolder;
 
+        NodeVisual m_visual;
+
         Node *m_parent = nullptr;
 
         QVector<QSharedPointer<Node>> m_children;

+ 37 - 34
src/core/notebook/nodeparameters.h

@@ -1,34 +1,37 @@
-#ifndef NODEPARAMETERS_H
-#define NODEPARAMETERS_H
-
-#include <QDateTime>
-#include <QStringList>
-
-#include <core/global.h>
-
-#include "node.h"
-
-namespace vnotex
-{
-    class NodeParameters
-    {
-    public:
-        NodeParameters() = default;
-
-        NodeParameters(ID p_id);
-
-        ID m_id = Node::InvalidId;
-
-        ID m_signature = Node::InvalidId;
-
-        QDateTime m_createdTimeUtc = QDateTime::currentDateTimeUtc();
-
-        QDateTime m_modifiedTimeUtc = QDateTime::currentDateTimeUtc();
-
-        QStringList m_tags;
-
-        QString m_attachmentFolder;
-    };
-}
-
-#endif // NODEPARAMETERS_H
+#ifndef NODEPARAMETERS_H
+#define NODEPARAMETERS_H
+
+#include <QDateTime>
+#include <QStringList>
+
+#include <core/global.h>
+
+#include "node.h"
+#include "nodevisual.h"
+
+namespace vnotex
+{
+    class NodeParameters
+    {
+    public:
+        NodeParameters() = default;
+
+        NodeParameters(ID p_id);
+
+        ID m_id = Node::InvalidId;
+
+        ID m_signature = Node::InvalidId;
+
+        QDateTime m_createdTimeUtc = QDateTime::currentDateTimeUtc();
+
+        QDateTime m_modifiedTimeUtc = QDateTime::currentDateTimeUtc();
+
+        QStringList m_tags;
+
+        QString m_attachmentFolder;
+
+        NodeVisual m_visual;
+    };
+}
+
+#endif // NODEPARAMETERS_H

+ 29 - 0
src/core/notebook/nodevisual.cpp

@@ -0,0 +1,29 @@
+#include "nodevisual.h"
+
+#include <QColor>
+#include <QtMath>
+
+using namespace vnotex;
+
+NodeVisual::NodeVisual(const QString &p_backgroundColor, 
+                       const QString &p_borderColor,
+                       const QString &p_nameColor)
+    : m_backgroundColor(p_backgroundColor)
+    , m_borderColor(p_borderColor)
+    , m_nameColor(p_nameColor)
+{
+}
+
+bool NodeVisual::hasAnyVisualEffect() const
+{
+    return !m_backgroundColor.isEmpty() || 
+           !m_borderColor.isEmpty() || 
+           !m_nameColor.isEmpty();
+}
+
+void NodeVisual::clearAllColors()
+{
+    m_backgroundColor.clear();
+    m_borderColor.clear();
+    m_nameColor.clear();
+}

+ 48 - 0
src/core/notebook/nodevisual.h

@@ -0,0 +1,48 @@
+#ifndef NODEVISUAL_H
+#define NODEVISUAL_H
+
+#include <QString>
+
+/*
+ * 节点视觉效果类
+ * 自定义节点名称,背景颜色,边框颜色,节点名称颜色
+ * 支持清除所有颜色,包括背景颜色,边框颜色,节点名称颜色
+ * 支持级联修改,包括背景颜色,边框颜色,节点名称颜色
+*/
+namespace vnotex
+{
+    class NodeVisual
+    {
+    public:
+        NodeVisual() = default;
+        
+        NodeVisual(const QString &p_backgroundColor, 
+                   const QString &p_borderColor,
+                   const QString &p_nameColor);
+        
+        // 背景颜色
+        const QString &getBackgroundColor() const { return m_backgroundColor; }
+        void setBackgroundColor(const QString &p_color) { m_backgroundColor = p_color; }
+        
+        // 边框颜色
+        const QString &getBorderColor() const { return m_borderColor; }
+        void setBorderColor(const QString &p_color) { m_borderColor = p_color; }
+        
+        // 节点名称颜色
+        const QString &getNameColor() const { return m_nameColor; }
+        void setNameColor(const QString &p_color) { m_nameColor = p_color; }
+        
+        // 判断是否有任何视觉效果
+        bool hasAnyVisualEffect() const;
+        
+        // 清除所有颜色
+        void clearAllColors();
+        
+    private:
+        QString m_backgroundColor;        // 背景颜色
+        QString m_borderColor;            // 边框颜色  
+        QString m_nameColor;              // 节点名称颜色
+    };
+}
+
+#endif // NODEVISUAL_H

+ 3 - 0
src/core/notebookconfigmgr/inotebookconfigmgr.h

@@ -5,6 +5,7 @@
 #include <QSharedPointer>
 
 #include "notebook/node.h"
+#include "notebook/nodevisual.h"
 
 namespace vnotex
 {
@@ -84,6 +85,8 @@ namespace vnotex
 
         virtual QStringList scanAndImportExternalFiles(Node *p_node) = 0;
 
+        virtual void updateNodeVisual(Node *p_node, const NodeVisual &p_visual) = 0;
+
         // Version of the config processing code.
         virtual int getCodeVersion() const = 0;
 

+ 96 - 0
src/core/notebookconfigmgr/vxnodeconfig.cpp

@@ -27,6 +27,12 @@ const QString NodeConfig::c_attachmentFolder = "attachment_folder";
 
 const QString NodeConfig::c_tags = "tags";
 
+const QString NodeConfig::c_backgroundColor = "background_color";
+
+const QString NodeConfig::c_borderColor = "border_color";
+
+const QString NodeConfig::c_nameColor = "name_color";
+
 static ID stringToNodeId(const QString &p_idStr)
 {
     auto ret = stringToID(p_idStr);
@@ -48,6 +54,17 @@ QJsonObject NodeFileConfig::toJson() const
     jobj[NodeConfig::c_attachmentFolder] = m_attachmentFolder;
     jobj[NodeConfig::c_tags] = QJsonArray::fromStringList(m_tags);
 
+    // Visual settings
+    if (!m_backgroundColor.isEmpty()) {
+        jobj[NodeConfig::c_backgroundColor] = m_backgroundColor;
+    }
+    if (!m_borderColor.isEmpty()) {
+        jobj[NodeConfig::c_borderColor] = m_borderColor;
+    }
+    if (!m_nameColor.isEmpty()) {
+        jobj[NodeConfig::c_nameColor] = m_nameColor;
+    }
+
     return jobj;
 }
 
@@ -69,6 +86,17 @@ void NodeFileConfig::fromJson(const QJsonObject &p_jobj)
             m_tags << arr[i].toString();
         }
     }
+
+    // Visual settings (check if fields exist for backward compatibility)
+    if (p_jobj.contains(NodeConfig::c_backgroundColor)) {
+        m_backgroundColor = p_jobj[NodeConfig::c_backgroundColor].toString();
+    }
+    if (p_jobj.contains(NodeConfig::c_borderColor)) {
+        m_borderColor = p_jobj[NodeConfig::c_borderColor].toString();
+    }
+    if (p_jobj.contains(NodeConfig::c_nameColor)) {
+        m_nameColor = p_jobj[NodeConfig::c_nameColor].toString();
+    }
 }
 
 NodeParameters NodeFileConfig::toNodeParameters() const
@@ -80,6 +108,12 @@ NodeParameters NodeFileConfig::toNodeParameters() const
     paras.m_modifiedTimeUtc = m_modifiedTimeUtc;
     paras.m_tags = m_tags;
     paras.m_attachmentFolder = m_attachmentFolder;
+    
+    // Visual settings
+    paras.m_visual.setBackgroundColor(m_backgroundColor);
+    paras.m_visual.setBorderColor(m_borderColor);
+    paras.m_visual.setNameColor(m_nameColor);
+    
     return paras;
 }
 
@@ -89,12 +123,46 @@ QJsonObject NodeFolderConfig::toJson() const
 
     jobj[NodeConfig::c_name] = m_name;
 
+    // Visual settings
+    if (!m_backgroundColor.isEmpty()) {
+        jobj[NodeConfig::c_backgroundColor] = m_backgroundColor;
+    }
+    if (!m_borderColor.isEmpty()) {
+        jobj[NodeConfig::c_borderColor] = m_borderColor;
+    }
+    if (!m_nameColor.isEmpty()) {
+        jobj[NodeConfig::c_nameColor] = m_nameColor;
+    }
+
     return jobj;
 }
 
 void NodeFolderConfig::fromJson(const QJsonObject &p_jobj)
 {
     m_name = p_jobj[NodeConfig::c_name].toString();
+    
+    // Visual settings (check if fields exist for backward compatibility)
+    if (p_jobj.contains(NodeConfig::c_backgroundColor)) {
+        m_backgroundColor = p_jobj[NodeConfig::c_backgroundColor].toString();
+    }
+    if (p_jobj.contains(NodeConfig::c_borderColor)) {
+        m_borderColor = p_jobj[NodeConfig::c_borderColor].toString();
+    }
+    if (p_jobj.contains(NodeConfig::c_nameColor)) {
+        m_nameColor = p_jobj[NodeConfig::c_nameColor].toString();
+    }
+}
+
+NodeParameters NodeFolderConfig::toNodeParameters() const
+{
+    NodeParameters paras;
+    
+    // Visual settings
+    paras.m_visual.setBackgroundColor(m_backgroundColor);
+    paras.m_visual.setBorderColor(m_borderColor);
+    paras.m_visual.setNameColor(m_nameColor);
+    
+    return paras;
 }
 
 NodeConfig::NodeConfig()
@@ -136,6 +204,17 @@ QJsonObject NodeConfig::toJson() const
     }
     jobj[NodeConfig::c_folders] = folders;
 
+    // Visual settings for the container node itself
+    if (!m_backgroundColor.isEmpty()) {
+        jobj[NodeConfig::c_backgroundColor] = m_backgroundColor;
+    }
+    if (!m_borderColor.isEmpty()) {
+        jobj[NodeConfig::c_borderColor] = m_borderColor;
+    }
+    if (!m_nameColor.isEmpty()) {
+        jobj[NodeConfig::c_nameColor] = m_nameColor;
+    }
+
     return jobj;
 }
 
@@ -160,6 +239,17 @@ void NodeConfig::fromJson(const QJsonObject &p_jobj)
     for (int i = 0; i < foldersJson.size(); ++i) {
         m_folders[i].fromJson(foldersJson[i].toObject());
     }
+
+    // Visual settings for the container node itself (check if fields exist for backward compatibility)
+    if (p_jobj.contains(NodeConfig::c_backgroundColor)) {
+        m_backgroundColor = p_jobj[NodeConfig::c_backgroundColor].toString();
+    }
+    if (p_jobj.contains(NodeConfig::c_borderColor)) {
+        m_borderColor = p_jobj[NodeConfig::c_borderColor].toString();
+    }
+    if (p_jobj.contains(NodeConfig::c_nameColor)) {
+        m_nameColor = p_jobj[NodeConfig::c_nameColor].toString();
+    }
 }
 
 NodeParameters NodeConfig::toNodeParameters() const
@@ -169,5 +259,11 @@ NodeParameters NodeConfig::toNodeParameters() const
     paras.m_signature = m_signature;
     paras.m_createdTimeUtc = m_createdTimeUtc;
     paras.m_modifiedTimeUtc = m_modifiedTimeUtc;
+    
+    // Visual settings for the container node itself
+    paras.m_visual.setBackgroundColor(m_backgroundColor);
+    paras.m_visual.setBorderColor(m_borderColor);
+    paras.m_visual.setNameColor(m_nameColor);
+    
     return paras;
 }

+ 23 - 0
src/core/notebookconfigmgr/vxnodeconfig.h

@@ -36,6 +36,11 @@ namespace vnotex
             QString m_attachmentFolder;
 
             QStringList m_tags;
+
+            // Visual settings
+            QString m_backgroundColor;
+            QString m_borderColor;
+            QString m_nameColor;
         };
 
 
@@ -46,7 +51,14 @@ namespace vnotex
 
             void fromJson(const QJsonObject &p_jobj);
 
+            NodeParameters toNodeParameters() const;
+
             QString m_name;
+
+            // Visual settings
+            QString m_backgroundColor;
+            QString m_borderColor;
+            QString m_nameColor;
         };
 
 
@@ -81,6 +93,11 @@ namespace vnotex
 
             QVector<NodeFolderConfig> m_folders;
 
+            // Visual settings for the container node itself
+            QString m_backgroundColor;
+            QString m_borderColor;
+            QString m_nameColor;
+
             static const QString c_version;
 
             static const QString c_id;
@@ -100,6 +117,12 @@ namespace vnotex
             static const QString c_attachmentFolder;
 
             static const QString c_tags;
+
+            static const QString c_backgroundColor;
+
+            static const QString c_borderColor;
+
+            static const QString c_nameColor;
         };
     }
 }

+ 33 - 0
src/core/notebookconfigmgr/vxnotebookconfigmgr.cpp

@@ -12,6 +12,7 @@
 #include <notebook/externalnode.h>
 #include <notebook/bundlenotebook.h>
 #include <notebook/notebookdatabaseaccess.h>
+#include <notebook/nodevisual.h>
 #include <utils/utils.h>
 #include <utils/fileutils.h>
 #include <utils/pathutils.h>
@@ -197,6 +198,10 @@ void VXNotebookConfigMgr::loadFolderNode(Node *p_node, const NodeConfig &p_confi
                                                          p_node);
         inheritNodeFlags(p_node, folderNode.data());
         folderNode->setExists(getBackend()->existsDir(PathUtils::concatenateFilePath(basePath, folder.m_name)));
+        
+        // 设置视觉效果信息
+        NodeParameters visualParams = folder.toNodeParameters();
+        folderNode->setVisual(visualParams.m_visual);
         children.push_back(folderNode);
     }
 
@@ -376,6 +381,11 @@ QSharedPointer<NodeConfig> VXNotebookConfigMgr::nodeToNodeConfig(const Node *p_n
                                                      p_node->getCreatedTimeUtc(),
                                                      p_node->getModifiedTimeUtc());
 
+    // Set visual settings for the container node itself
+    config->m_backgroundColor = p_node->getBackgroundColor();
+    config->m_borderColor = p_node->getBorderColor();
+    config->m_nameColor = p_node->getNameColor();
+
     for (const auto &child : p_node->getChildrenRef()) {
         if (child->hasContent()) {
             NodeFileConfig fileConfig;
@@ -386,12 +396,22 @@ QSharedPointer<NodeConfig> VXNotebookConfigMgr::nodeToNodeConfig(const Node *p_n
             fileConfig.m_modifiedTimeUtc = child->getModifiedTimeUtc();
             fileConfig.m_attachmentFolder = child->getAttachmentFolder();
             fileConfig.m_tags = child->getTags();
+            
+            // Visual settings
+            fileConfig.m_backgroundColor = child->getBackgroundColor();
+            fileConfig.m_borderColor = child->getBorderColor();
+            fileConfig.m_nameColor = child->getNameColor();
 
             config->m_files.push_back(fileConfig);
         } else {
             Q_ASSERT(child->isContainer());
             NodeFolderConfig folderConfig;
             folderConfig.m_name = child->getName();
+            
+            // Visual settings
+            folderConfig.m_backgroundColor = child->getBackgroundColor();
+            folderConfig.m_borderColor = child->getBorderColor();
+            folderConfig.m_nameColor = child->getNameColor();
 
             config->m_folders.push_back(folderConfig);
         }
@@ -1112,3 +1132,16 @@ bool VXNotebookConfigMgr::sameNotebook(const Node *p_node) const
 {
     return p_node ? p_node->getNotebook() == getNotebook() : true;
 }
+
+void VXNotebookConfigMgr::updateNodeVisual(Node *p_node, const NodeVisual &p_visual)
+{
+    Q_ASSERT(sameNotebook(p_node));
+    
+    p_node->setVisual(p_visual);
+    
+    if (p_node->isRoot()) {
+        return;
+    }
+    
+    saveNode(p_node);
+}

+ 2 - 0
src/core/notebookconfigmgr/vxnotebookconfigmgr.h

@@ -83,6 +83,8 @@ namespace vnotex
 
         QStringList scanAndImportExternalFiles(Node *p_node) Q_DECL_OVERRIDE;
 
+        void updateNodeVisual(Node *p_node, const NodeVisual &p_visual) Q_DECL_OVERRIDE;
+
     private:
         void createEmptyRootNode();
 

+ 389 - 0
src/widgets/notebooknodeexplorer.cpp

@@ -6,11 +6,17 @@
 #include <QAction>
 #include <QSet>
 #include <QShortcut>
+#include <QColorDialog>
+#include <QStyledItemDelegate>
+#include <QPainter>
+#include <QStyleOptionViewItem>
+#include <functional>
 
 #include <notebook/notebook.h>
 #include <notebook/node.h>
 #include <notebook/externalnode.h>
 #include <notebook/nodeparameters.h>
+#include <notebookconfigmgr/inotebookconfigmgr.h>
 #include <core/exception.h>
 #include "messageboxhelper.h"
 #include "vnotex.h"
@@ -45,6 +51,39 @@ using namespace vnotex;
 
 QIcon NotebookNodeExplorer::s_nodeIcons[NodeIcon::MaxIcons];
 
+// 节点视觉委托类 避免修改颜色后整个树刷新
+NodeColorDelegate::NodeColorDelegate(QObject *parent)
+    : QStyledItemDelegate(parent)
+{
+}
+
+void NodeColorDelegate::paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const
+{
+    // 先绘制默认内容
+    QStyledItemDelegate::paint(painter, option, index);
+    
+    // 获取节点数据
+    auto item = static_cast<QTreeWidgetItem*>(index.internalPointer());
+    if (!item) {
+        return;
+    }
+    
+    // 从 item 的 UserRole 中获取颜色信息
+    QVariant borderColorVar = item->data(0, Qt::UserRole + 1000); // 使用特殊的角色存储边框颜色
+    if (borderColorVar.isValid() && !borderColorVar.toString().isEmpty()) {
+        QString borderColor = borderColorVar.toString();
+        QColor color(borderColor);
+        
+        if (color.isValid()) {
+            painter->save();
+            QPen pen(color, 2, Qt::SolidLine);
+            painter->setPen(pen);
+            painter->drawRect(option.rect.adjusted(1, 1, -1, -1));
+            painter->restore();
+        }
+    }
+}
+
 NotebookNodeExplorer::NodeData::NodeData()
 {
 }
@@ -285,6 +324,9 @@ void NotebookNodeExplorer::setupMasterExplorer(QWidget *p_parent)
                 auto data = getItemNodeData(p_item);
                 activateItemNode(data);
             });
+
+    // 设置自定义委托以支持边框绘制
+    m_masterExplorer->setItemDelegate(new NodeColorDelegate(this));
 }
 
 void NotebookNodeExplorer::activateItemNode(const NodeData &p_data)
@@ -543,6 +585,7 @@ void NotebookNodeExplorer::fillMasterItem(QTreeWidgetItem *p_item, Node *p_node,
     p_item->setText(Column::Name, p_node->getName());
     p_item->setIcon(Column::Name, getIcon(p_node));
     p_item->setToolTip(Column::Name, generateToolTip(p_node));
+    applyNodeColors(p_item, p_node);
 }
 
 void NotebookNodeExplorer::fillMasterItem(QTreeWidgetItem *p_item, const QSharedPointer<ExternalNode> &p_node) const
@@ -559,6 +602,7 @@ void NotebookNodeExplorer::fillSlaveItem(QListWidgetItem *p_item, Node *p_node)
     p_item->setText(p_node->getName());
     p_item->setIcon(getIcon(p_node));
     p_item->setToolTip(generateToolTip(p_node));
+    applyNodeColors(p_item, p_node);
 }
 
 void NotebookNodeExplorer::fillSlaveItem(QListWidgetItem *p_item, const QSharedPointer<ExternalNode> &p_node) const
@@ -569,6 +613,175 @@ void NotebookNodeExplorer::fillSlaveItem(QListWidgetItem *p_item, const QSharedP
     p_item->setToolTip(tr("[External] %1").arg(p_node->getName()));
 }
 
+void NotebookNodeExplorer::applyNodeColors(QTreeWidgetItem *p_item, Node *p_node) const
+{
+    if (!p_item || !p_node) {
+        return;
+    }
+    
+    QString backgroundColor = p_node->getEffectiveBackgroundColor();
+    QString borderColor = p_node->getEffectiveBorderColor();
+    QString nameColor = p_node->getNameColor();
+    
+    // 设置背景色
+    if (!backgroundColor.isEmpty()) {
+        p_item->setBackground(Column::Name, QBrush(QColor(backgroundColor)));
+    } else {
+        p_item->setBackground(Column::Name, QBrush());
+    }
+    
+    // 设置节点名称颜色
+    if (!nameColor.isEmpty()) {
+        p_item->setForeground(Column::Name, QBrush(QColor(nameColor)));
+    } else {
+        p_item->setForeground(Column::Name, QBrush());
+    }
+    
+    // 设置边框色(通过 UserRole 存储,由自定义委托绘制)
+    if (!borderColor.isEmpty()) {
+        p_item->setData(Column::Name, Qt::UserRole + 1000, borderColor);
+    } else {
+        p_item->setData(Column::Name, Qt::UserRole + 1000, QVariant());
+    }
+}
+
+void NotebookNodeExplorer::applyNodeColors(QListWidgetItem *p_item, Node *p_node) const
+{
+    if (!p_item || !p_node) {
+        return;
+    }
+    
+    QString backgroundColor = p_node->getEffectiveBackgroundColor();
+    QString nameColor = p_node->getNameColor();
+    
+    // 设置背景色
+    if (!backgroundColor.isEmpty()) {
+        p_item->setBackground(QBrush(QColor(backgroundColor)));
+    } else {
+        p_item->setBackground(QBrush());
+    }
+    
+    // 设置节点名称颜色
+    if (!nameColor.isEmpty()) {
+        p_item->setForeground(QBrush(QColor(nameColor)));
+    } else {
+        p_item->setForeground(QBrush());
+    }
+    
+    // 对于 ListWidget,边框效果暂时不实现
+    // TODO: 如果需要,也可以为 ListWidget 创建自定义委托
+}
+
+void NotebookNodeExplorer::updateCurrentNodeVisualDirectly(Node *p_node, const NodeVisual &p_visual)
+{
+    if (!p_node) {
+        return;
+    }
+    
+    // 直接更新 Node 对象的视觉信息,但不发送刷新信号
+    p_node->setVisual(p_visual);
+    
+    // 更新数据库
+    p_node->getConfigMgr()->updateNodeVisual(p_node, p_visual);
+    
+    // 直接更新当前显示的 item,避免整个树刷新
+    auto treeItem = findCurrentTreeWidgetItem(p_node);
+    if (treeItem) {
+        applyNodeColors(treeItem, p_node);
+    }
+    
+    auto listItem = findCurrentListWidgetItem(p_node);
+    if (listItem) {
+        applyNodeColors(listItem, p_node);
+    }
+}
+
+void NotebookNodeExplorer::setCascadeColorRecursively(Node *p_node, const QString &p_backgroundColor, const QString &p_borderColor, const QString &p_nameColor)
+{
+    if (!p_node) {
+        return;
+    }
+    
+    // 设置当前节点的颜色
+    NodeVisual visual = p_node->getVisual();
+    
+    visual.setBackgroundColor(p_backgroundColor);
+    visual.setBorderColor(p_borderColor);
+    visual.setNameColor(p_nameColor);
+    
+    updateCurrentNodeVisualDirectly(p_node, visual);
+    
+    // 如果是容器节点,递归设置所有子节点
+    if (p_node->isContainer() && p_node->isLoaded()) {
+        for (const auto &child : p_node->getChildrenRef()) {
+            setCascadeColorRecursively(child.data(), p_backgroundColor, p_borderColor, p_nameColor);
+        }
+    }
+}
+
+QTreeWidgetItem *NotebookNodeExplorer::findCurrentTreeWidgetItem(Node *p_node) const
+{
+    if (!p_node || !m_masterExplorer) {
+        return nullptr;
+    }
+    
+    // 遍历所有可见的 TreeWidgetItem 查找匹配的节点
+    std::function<QTreeWidgetItem*(QTreeWidgetItem*)> findInTree = [&](QTreeWidgetItem *parent) -> QTreeWidgetItem* {
+        if (!parent) {
+            // 搜索顶级项目
+            for (int i = 0; i < m_masterExplorer->topLevelItemCount(); ++i) {
+                auto item = m_masterExplorer->topLevelItem(i);
+                auto data = getItemNodeData(item);
+                if (data.isNode() && data.getNode() == p_node) {
+                    return item;
+                }
+                
+                // 递归搜索子项目
+                auto result = findInTree(item);
+                if (result) {
+                    return result;
+                }
+            }
+        } else {
+            // 搜索子项目
+            for (int i = 0; i < parent->childCount(); ++i) {
+                auto item = parent->child(i);
+                auto data = getItemNodeData(item);
+                if (data.isNode() && data.getNode() == p_node) {
+                    return item;
+                }
+                
+                // 递归搜索子项目
+                auto result = findInTree(item);
+                if (result) {
+                    return result;
+                }
+            }
+        }
+        return nullptr;
+    };
+    
+    return findInTree(nullptr);
+}
+
+QListWidgetItem *NotebookNodeExplorer::findCurrentListWidgetItem(Node *p_node) const
+{
+    if (!p_node || !m_slaveExplorer) {
+        return nullptr;
+    }
+    
+    // 遍历所有 ListWidgetItem 查找匹配的节点
+    for (int i = 0; i < m_slaveExplorer->count(); ++i) {
+        auto item = m_slaveExplorer->item(i);
+        auto data = getItemNodeData(item);
+        if (data.isNode() && data.getNode() == p_node) {
+            return item;
+        }
+    }
+    
+    return nullptr;
+}
+
 const QIcon &NotebookNodeExplorer::getIcon(const Node *p_node) const
 {
     if (p_node->hasContent()) {
@@ -1012,6 +1225,26 @@ void NotebookNodeExplorer::createContextMenuOnNode(QMenu *p_menu, const Node *p_
 
         createAndAddAction(Action::OpenLocation, p_menu, p_master);
 
+        // 添加视觉设置子菜单
+        auto visualMenu = WidgetsFactory::createMenu(tr("Visual Settings"), p_menu);
+        createAndAddAction(Action::SetBackgroundColor, visualMenu, p_master);
+        createAndAddAction(Action::SetBorderColor, visualMenu, p_master);
+        createAndAddAction(Action::SetNameColor, visualMenu, p_master);
+        
+        // 只有文件夹节点才能设置级联色
+        if (p_node->isContainer()) {
+            visualMenu->addSeparator();
+            auto cascadeMenu = WidgetsFactory::createMenu(tr("Cascade Color Settings"), visualMenu);
+            createAndAddAction(Action::SetCascadeBackgroundColor, cascadeMenu, p_master);
+            createAndAddAction(Action::SetCascadeBorderColor, cascadeMenu, p_master);
+            createAndAddAction(Action::ClearCascadeColors, cascadeMenu, p_master);
+            visualMenu->addMenu(cascadeMenu);
+        }
+        
+        visualMenu->addSeparator();
+        createAndAddAction(Action::ClearColors, visualMenu, p_master);
+        p_menu->addMenu(visualMenu);
+
         createAndAddAction(Action::Properties, p_menu, p_master);
     }
 }
@@ -1387,6 +1620,162 @@ QAction *NotebookNodeExplorer::createAction(Action p_act, QObject *p_parent, boo
                 });
         break;
 
+    case Action::SetBackgroundColor:
+        act = new QAction(tr("Set Background Color"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this, p_master]() {
+                    auto node = p_master ? getCurrentMasterNode() : getCurrentSlaveNode();
+                    if (!node) {
+                        return;
+                    }
+                    
+                    if (checkInvalidNode(node)) {
+                        return;
+                    }
+                    
+                    QColor defaultColor = node->getBackgroundColor().isEmpty() ? QColor(Qt::white) : QColor(node->getBackgroundColor());
+                    auto color = QColorDialog::getColor(defaultColor, this, tr("Select Background Color"));
+                    if (color.isValid()) {
+                        NodeVisual visual = node->getVisual();
+                        visual.setBackgroundColor(color.name());
+                        updateCurrentNodeVisualDirectly(node, visual);
+                    }
+                });
+        break;
+
+    case Action::SetBorderColor:
+        act = new QAction(tr("Set Border Color"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this, p_master]() {
+                    auto node = p_master ? getCurrentMasterNode() : getCurrentSlaveNode();
+                    if (!node) {
+                        return;
+                    }
+                    
+                    if (checkInvalidNode(node)) {
+                        return;
+                    }
+                    
+                    QColor defaultColor = node->getBorderColor().isEmpty() ? QColor(Qt::black) : QColor(node->getBorderColor());
+                    auto color = QColorDialog::getColor(defaultColor, this, tr("Select Border Color"));
+                    if (color.isValid()) {
+                        NodeVisual visual = node->getVisual();
+                        visual.setBorderColor(color.name());
+                        updateCurrentNodeVisualDirectly(node, visual);
+                    }
+                });
+        break;
+
+    case Action::SetNameColor:
+        act = new QAction(tr("Set Name Color"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this, p_master]() {
+                    auto node = p_master ? getCurrentMasterNode() : getCurrentSlaveNode();
+                    if (!node) {
+                        return;
+                    }
+                    
+                    if (checkInvalidNode(node)) {
+                        return;
+                    }
+                    
+                    QColor defaultColor = node->getNameColor().isEmpty() ? QColor(Qt::black) : QColor(node->getNameColor());
+                    auto color = QColorDialog::getColor(defaultColor, this, tr("Select Name Color"));
+                    if (color.isValid()) {
+                        NodeVisual visual = node->getVisual();
+                        visual.setNameColor(color.name());
+                        updateCurrentNodeVisualDirectly(node, visual);
+                    }
+                });
+        break;
+
+    case Action::SetCascadeBackgroundColor:
+        act = new QAction(tr("Set Cascade Background Color"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this, p_master]() {
+                    auto node = p_master ? getCurrentMasterNode() : getCurrentSlaveNode();
+                    if (!node) {
+                        return;
+                    }
+                    
+                    if (checkInvalidNode(node)) {
+                        return;
+                    }
+                    
+                    if (!node->isContainer()) {
+                        return; // 只有文件夹才能设置级联色
+                    }
+                    
+                    QColor defaultColor = node->getBackgroundColor().isEmpty() ? QColor("#e6f3ff") : QColor(node->getBackgroundColor());
+                    auto color = QColorDialog::getColor(defaultColor, this, tr("Select Cascade Background Color"));
+                    if (color.isValid()) {
+                        setCascadeColorRecursively(node, color.name(), QString(), QString());
+                    }
+                });
+        break;
+
+    case Action::SetCascadeBorderColor:
+        act = new QAction(tr("Set Cascade Border Color"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this, p_master]() {
+                    auto node = p_master ? getCurrentMasterNode() : getCurrentSlaveNode();
+                    if (!node) {
+                        return;
+                    }
+                    
+                    if (checkInvalidNode(node)) {
+                        return;
+                    }
+                    
+                    if (!node->isContainer()) {
+                        return; // 只有文件夹才能设置级联色
+                    }
+                    
+                    QColor defaultColor = node->getBorderColor().isEmpty() ? QColor("#3A75F2") : QColor(node->getBorderColor());
+                    auto color = QColorDialog::getColor(defaultColor, this, tr("Select Cascade Border Color"));
+                    if (color.isValid()) {
+                        setCascadeColorRecursively(node, QString(), color.name(), QString());
+                    }
+                });
+        break;
+    
+    case Action::ClearCascadeColors:
+        act = new QAction(tr("Clear Cascade Colors"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this, p_master]() {
+                    auto node = p_master ? getCurrentMasterNode() : getCurrentSlaveNode();
+                    if (!node) {
+                        return;
+                    }
+                    
+                    if (checkInvalidNode(node)) {
+                        return;
+                    }
+                    
+                    // 递归清除子节点
+                    setCascadeColorRecursively(node, QString(), QString(), QString());
+                });
+        break;
+
+    case Action::ClearColors:
+        act = new QAction(tr("Clear Colors"), p_parent);
+        connect(act, &QAction::triggered,
+                this, [this, p_master]() {
+                    auto node = p_master ? getCurrentMasterNode() : getCurrentSlaveNode();
+                    if (!node) {
+                        return;
+                    }
+                    
+                    if (checkInvalidNode(node)) {
+                        return;
+                    }
+                    
+                    NodeVisual visual;
+                    visual.clearAllColors();
+                    updateCurrentNodeVisualDirectly(node, visual);
+                });
+        break;
+
     default:
         Q_ASSERT(false);
         break;

+ 41 - 1
src/widgets/notebooknodeexplorer.h

@@ -6,14 +6,19 @@
 #include <QHash>
 #include <QScopedPointer>
 #include <QPair>
+#include <QStyledItemDelegate>
 
 #include "qtreewidgetstatecache.h"
 #include "clipboarddata.h"
 #include "navigationmodewrapper.h"
 #include "global.h"
+#include <notebook/nodevisual.h>
 
 class QSplitter;
 class QMenu;
+class QPainter;
+class QStyleOptionViewItem;
+class QModelIndex;
 
 namespace vnotex
 {
@@ -25,6 +30,21 @@ namespace vnotex
     class Event;
     class ExternalNode;
 
+    /*
+     * 自定义委托类,用于绘制节点边框
+     * 支持节点名称,背景颜色,边框颜色,节点名称颜色
+     * 支持级联修改,包括背景颜色,边框颜色,节点名称颜色
+    */
+    class NodeColorDelegate : public QStyledItemDelegate
+    {
+        Q_OBJECT
+    public:
+        explicit NodeColorDelegate(QObject *parent = nullptr);
+        
+    protected:
+        void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override;
+    };
+
     class NotebookNodeExplorer : public QWidget
     {
         Q_OBJECT
@@ -153,7 +173,15 @@ namespace vnotex
             Read,
             ExpandAll,
             PinToQuickAccess,
-            Tag
+            Tag,
+            VisualSettings,
+            SetBackgroundColor,
+            SetBorderColor,
+            SetNameColor,
+            SetCascadeBackgroundColor,
+            SetCascadeBorderColor,
+            ClearCascadeColors,
+            ClearColors
         };
 
         struct CacheData
@@ -195,6 +223,18 @@ namespace vnotex
 
         void fillSlaveItem(QListWidgetItem *p_item, const QSharedPointer<ExternalNode> &p_node) const;
 
+        void applyNodeColors(QTreeWidgetItem *p_item, Node *p_node) const;
+
+        void applyNodeColors(QListWidgetItem *p_item, Node *p_node) const;
+
+        void updateCurrentNodeVisualDirectly(Node *p_node, const NodeVisual &p_visual);
+
+        void setCascadeColorRecursively(Node *p_node, const QString &p_backgroundColor, const QString &p_borderColor, const QString &p_nameColor);
+
+        QTreeWidgetItem *findCurrentTreeWidgetItem(Node *p_node) const;
+
+        QListWidgetItem *findCurrentListWidgetItem(Node *p_node) const;
+
         const QIcon &getIcon(const Node *p_node) const;
 
         const QIcon &getIcon(const ExternalNode *p_node) const;