Unity读书系列《Unity3D游戏开发》——拓展编辑器(一)

文章目录

  • 前言
  • 一、扩展Project视图
    • 1、右键扩展菜单(Asset)
    • 2、监听事件
    • 3、拓展布局
  • 二、扩展Hierarchy视图
    • 1、拓展菜单(GameObject)
    • 2、拓展布局
    • 3、重写菜单
  • 三、扩展Inspector视图
    • 1、扩展原生组件
    • 2、扩展继承组件
  • 四、扩展Scene视图
    • 1、绘制辅助元素
    • 2、辅助UI
    • 3、常驻辅助UI
  • 五、扩展Game视图
  • 总结


前言

本篇文章是对前文关于编辑器拓展的探讨的延伸。即使内置的Unity编辑器再强大,也无法满足所有不同产品和游戏的需求。为了解决这个问题,Unity提供了编辑器拓展的API接口。我们可以通过代码反射的方式修改内置的系统编辑器,同时,游戏开发者也可以利用EditorGUI接口编写适合自己的专属游戏编辑器。这涵盖了从简单的一键换字体、材质、一键打包、管理、优化,到复杂的技能编辑器、关卡编辑器等功能。

特别需要注意的是,由于内容涉及较多且较为复杂,会分2节进行详细讨论。在本文的第一部分中,我们将总结最基础和最实用的编辑器拓展知识。

本文所有代码均在Gitee参考工程,如有需要请自取。


一、扩展Project视图

Project视图是掌握Unity项目的生死大权的地方,包括创建、删除等重要操作。在这里,我们可以通过右键点击实现Asset菜单的拓展。在进行这项任务之前,首先需要将脚本文件保存到名为Editor的文件夹下,并引入UnityEditor命名空间。

1、右键扩展菜单(Asset)

右键创建物体

using UnityEngine;
using UnityEditor;

public class AssetEditor
{
    [MenuItem("Assets/Tools/CreateSphere",false,1)]//数值越小越靠前
    static void Createxx() {
        GameObject.CreatePrimitive(PrimitiveType.Sphere);
    }
}

如图我点击CreateSphere按钮就创建了一个球体到场景当中。
在这里插入图片描述

2、监听事件

在大型或规范的项目中,通常会有严格的项目规范,包括对资源的归类等方面。例如,如果你将贴图移动到了脚本文件夹,项目可能会判断这样的操作是不合法的,并阻止你进行修改。
1、监听资源的删除、创建、移动、保存等操作,在进行操作后会输出绑定的委托。

    //监听事件
    [InitializeOnLoadMethod]
    static void InitializeOnLoadMethod() {
	EditorApplication.projectChanged += delegate ()
	{
	    Debug.Log("怎么回事,老弟。你是不是刚动了资源?");
	};
    }

嘿嘿,知识点还没完,[InitializeOnLoadMethod]写在方法 前则会使该方法在C#代码编译完成后首先调用。
2、当需要重新具体的删除、创建方法时必须继承UnityEditor.AssetModificationProcessor,具体方法如下:

public class AssetEventEditor : UnityEditor.AssetModificationProcessor
{
    //监听事件
    [InitializeOnLoadMethod]
    static void InitializeOnLoadMethod()
    {
        EditorApplication.projectChanged += delegate ()
        {
            Debug.Log("怎么回事,老弟。你是不是刚动了资源?");
        };
    }

    //监听"双击左键打开资源"事件
    public static bool IsOpenForEdit(string assetPath, out string message)
    {
        message = null;
        Debug.LogFormat("assetPath:{0}", assetPath);
        return true;//true表示该资源可以打开,false表示不允许打开

    }

    //监听"资源即将被创建"事件
    public static void OnWillCreateEdit(string path)
    {
        Debug.LogFormat("创建资源的路径:{0}", path);
    }

    //监听"资源即将被保存"事件
    public static string[] OnWillSaveAssets(string[] paths) {
        if (paths != null)
        {
            Debug.LogFormat("保存资源的路径:{0}",string.Join(",",paths));
        }
        return paths;
    }

    //监听"资源即将被移动"事件
    public static AssetMoveResult OnWillMoveAsset(string oldPath,string newPath) {
      
        Debug.LogFormat("资源从路径{0}移动到路径{1}", oldPath,newPath);
        return AssetMoveResult.DidNotMove;//DidNotMove表示可以移动,DidMove表示不可以移动
    }
    //监听"资源即将被删除"事件
    public static AssetDeleteResult OnWillDeleteAsset(string assetPath) {
      
        Debug.LogFormat("资源从路径{0}删除", assetPath);
        return AssetDeleteResult.DidNotDelete;//DidNotDelete表示可以移动,DidDelete表示不可以移动
    }

}

3、拓展布局

选中资源后出现按钮,并监听按钮的点击事件

    //选中资源后出现按钮,并监听按钮的点击事件
    [InitializeOnLoadMethod]
    static void InitializeOnLoadMethod()
    {
	EditorApplication.projectWindowItemOnGUI = delegate (string guid, Rect selectionRect)
	{
	    //在Project试图中选择一个资源
	    if (Selection.activeObject && guid == AssetDatabase.AssetPathToGUID(AssetDatabase.GetAssetPath(Selection.activeObject)))
	    {
		//设置拓展按钮区域
		float width = 80f;
		selectionRect.x += (selectionRect.width - width);
		selectionRect.y += 2;
		selectionRect.width = width;
		GUI.color = Color.red;
		//点击事件
		if (GUI.Button(selectionRect,"click"))
		{
		    Debug.LogFormat("点击:{0}", Selection.activeObject.name);
		}
		GUI.color = Color.white;

	    }
	};
    }

在这里插入图片描述

二、扩展Hierarchy视图

在Hierarchy(层次)视图中,右键点击相当于打开菜单栏的GameObject栏目。

1、拓展菜单(GameObject)

细心的读者已经看出来了,下面代码对比上面写的仅仅将菜单栏目从"Assets"换成了"GameObject"。

    //右键创建物体
    [MenuItem("GameObject/Tools/CreateSphere", false, 1)]//数值越小越靠前
    static void Createxx()
    {
        GameObject.CreatePrimitive(PrimitiveType.Sphere);
    }

2、拓展布局

粗心的读者这下也已经看出来了,下面的代码复刻了之前的代码,将 EditorApplication 后的 GUI 委托修改为 Hierarchy 窗口专属的,并将参数从资源的 GUID 变为 instanceID 实例 ID。此外,按钮引入了本地图片。在各种插件中,编辑器引入图片的操作屡见不鲜,有时为了资源规范会整理插件的图标和图片位置,别忘了根据实际情况修改相关代码。
在这里插入图片描述

3、重写菜单

通过以上学习,我们了解了如何在原有基础上扩展编辑器。那么,能否完全重写呢?当然可以。

1、下面,我们将学习如何重新创建 Image 的逻辑。因为在创建 Image 时,Unity 默认会自动勾选 RaycastTarget,如果我们不需要它具有点击功能,就会有额外的性能开销。使用下面的代码,我们可以创建不勾选 RaycastTarget 的 Image。

  //创建Image默认不勾选RaycastTarget
  [MenuItem("GameObject/UI/Image0")]
  static void CreateImage() {
      if (Selection.activeTransform)
      {
          if (Selection.activeTransform.GetComponentInParent<Canvas>())
          {
              Image image = new GameObject("image").AddComponent<Image>();
              image.raycastTarget = false;
              image.transform.SetParent(Selection.activeTransform, false);
              //设置选中状态
              Selection.activeTransform = image.transform;
          }
      }
  }

完整版会有检测视图是否有Canvas组件,没有则自动创建等功能。

2、重写菜单:

   //重写菜单
   [InitializeOnLoadMethod]
   static void StartInitializeOnLoadMethod()
   {
       EditorApplication.hierarchyWindowItemOnGUI += OnHierarchyGUI;
   }

   static void OnHierarchyGUI(int instanceID, Rect selectionRect)
   {
       //Event.current监听当前事件,如果监听到鼠标抬起则执行自定义事件(也就是我们的自定义菜单)
       if (Event.current != null && selectionRect.Contains(Event.current.mousePosition) && Event.current.button == 1 && Event.current.type <= EventType.MouseUp)
       {
           GameObject selectedGameObject = EditorUtility.InstanceIDToObject(instanceID) as GameObject;
           //判断是否满足条件
           if (selectedGameObject)
           {
               Vector2 mousePosition = Event.current.mousePosition;
               EditorUtility.DisplayPopupMenu(new Rect(mousePosition.x, mousePosition.y, 0, 0), "Window/Test", null);
               Event.current.Use();
           }
       }
   }

   [MenuItem("Window/Test/Test1")]
   static void Test1()
   {

   }

   [MenuItem("Window/Test/Test2")]
   static void Test2()
   {

   }

重写完成后,右键视图中的实例将会弹出自定义菜单
在这里插入图片描述

三、扩展Inspector视图

Inspector(检视)视图是用来展示组件及资源的详细信息面板。unity自身提供的各类组件的面板能够满足我们正常的需求,但我们偶尔会希望在某些面板上添加快捷按钮或者某些逻辑。

1、扩展原生组件

摄像机是典型的原生组件,我们CustomEditor进行自定义组件,重写OnInspectorGUI在base.OnInspectorGUI()这个原有元素接口上下添加按钮。

using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(Camera))]
public class CameraEditor : Editor
{
    public override void OnInspectorGUI()
    {
	if (GUILayout.Button("拓展按钮-上"))
	{

	}
        base.OnInspectorGUI(); 
        if (GUILayout.Button("拓展按钮-下"))
        {

        }
    }
}

如下便绘制了两个按钮,不过要注意该组件限制了按钮必须加在最上面或者最下面。
在这里插入图片描述

2、扩展继承组件

1、Unity将大量的Editor绘制方法封装进了DLL,通常来讲我们无法调用其中方法。想要解决可以使用反射获取内部对象,然后调用想要使用的未公开的方法。

using System.Reflection;
using UnityEditor;
using UnityEngine;

[CustomEditor(typeof(Transform))]
public class TransformEditor : Editor
{
    private Editor m_Editor;
    private void OnEnable()
    {
        m_Editor = Editor.CreateEditor(target, Assembly.GetAssembly(typeof(Editor)).GetType("UnityEditor.TransformInspector", true));
    }
    public override void OnInspectorGUI()
    {
        if (GUILayout.Button("拓展按钮"))
        {
        }
        m_Editor.OnInspectorGUI();//原有信息面板
        // base.OnInspectorGUI();

    }
}

2、Context菜单
点击组件的设置按钮(或鼠标右键),会弹出Context菜单,里面有Copy、Reset等操作按钮。我们有时候想对特定组件进行自定义的操作,例如我想在Transform的Context菜单添加NewContext按钮,只需更改MenuItem里第一个参数为"CONTEXT/Transform/NewContext"接口。想给Camer加就将Transform替换成Camera,想给所有组件加就替换成"Compoment"。

    [MenuItem("CONTEXT/Transform/New Context")]
    static void NewContext(MenuCommand menuCommand)
    {
        Debug.LogFormat("组件名称:{0}",menuCommand.context.name);
    }

下面演示如何重写特定脚本的系统方法。作者建议最好延迟一帧以防止在编辑模式下代码同步出现问题。在测试 Unity 2021 版本时,貌似没有出现问题。此外,需要注意的是,书中的部分接口可能已经过时,个人已经替换成最新的版本(以2021.3为准)。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class ContextScript : MonoBehaviour
{

    [ContextMenu("Remove Component")]
    void RemoveComponent()
    {
        Debug.Log("RemoveComponent");
        //等一帧再删除自己,防止引擎底层错误
        UnityEditor.EditorApplication.delayCall = delegate () {
            DestroyImmediate(this);
        };
    }
}

脚本中使用宏定义(使用宏定义的原因是为了在发布后剔除无效代码),联动脚本中的变量在编辑模式下实现功能。下面代码就让NewContext按钮操作了脚本中的变量——将ContextScript脚本中的str变量从"原始"改成了"原神"。

using System.Collections;
using System.Collections.Generic;
#if UNITY_EDITOR
using UnityEditor;
#endif
using UnityEngine;

public class ContextScript : MonoBehaviour
{
    public string str = "原始";
#if UNITY_EDITOR
    //宏定义操作脚本变量
    [MenuItem("CONTEXT/ContextScript/New Context")]
    static void NewContext(MenuCommand menuCommand)
    {
        ContextScript contextScript = menuCommand.context as ContextScript;
        contextScript.str = "原神";
    }
#endif
}

请添加图片描述

四、扩展Scene视图

Unity的Scene视图是一个用于编辑场景的窗口。在Scene视图中,你可以直观地查看、编辑和组织你的游戏场景。

1、绘制辅助元素

场景编辑中我们有时会需要线段、不同形状的元素来帮助我们快速编辑。下面我们将使用Gizmos.cs工具类绘制简单元素。

using UnityEngine;

public class GizmoScirpt : MonoBehaviour
{ 
	//在鼠标点击到脚本挂载的物体的身上的时候运行
    private void OnDrawGizmosSelected()
    {
        Gizmos.color = Color.red;
        //画线
        Gizmos.DrawLine(transform.position, Vector3.one);
        //立方体
        Gizmos.DrawCube(Vector3.one, Vector3.one);
    }
}

在这里插入图片描述
我们发现未点击挂载脚本的物体时,立方体和线条消失了。如果想让绘制的物体一直出现,可以使用 OnDrawGizmos 方法。具体用法有很多,比如技能范围展示、地形和陷阱的实际范围绘制等,都能使项目更高效进行。

   //不依赖对象,会一直执行
   private void OnDrawGizmos()
   {
       Gizmos.DrawSphere(transform.position, 2.0f);
   }

2、辅助UI

我们在Scene视图中可以在各种组件中添加EditorGUI以获得便利。下面演示如何在Scene中给Camer添加位置信息。

using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEditor;

[CustomEditor(typeof(Camera))]
public class GUIEditor : Editor
{
    private void OnSceneGUI()
    {
        Camera camera = target as Camera;
	if (camera != null)
	{
	    Handles.color = Color.red;
	    Handles.Label(camera.transform.position, camera.transform.position.ToString());

	    Handles.BeginGUI();
	    GUI.backgroundColor = Color.red;
	    if (GUILayout.Button("click",GUILayout.Width(200f)))
	    {
		Debug.LogFormat("click = {0}", camera.name);
	    }
	    GUILayout.Label("Label");
	    Handles.EndGUI();
	}
    }
}

在这里插入图片描述

最后注意如果你的脚本不生效,可能是该脚本与CameraEditor脚本互斥冲突,因为都用了"[CustomEditor(typeof(Camera))]",默认先创建的脚本会生效。

3、常驻辅助UI

常驻辅助UI或者说固定辅助UI,顾名思义,无需游戏对象即可常驻Scene视图,有些类似OnDrawGizmosSelected和OnDrawGizmos。

using UnityEngine;
using UnityEditor;

//常驻辅助UI
public class GUIEditor2 : MonoBehaviour
{
    [InitializeOnLoadMethod]
    static void InitializeOnLoadMethod() {
        SceneView.duringSceneGui += delegate (SceneView sceneView)
        {
            Handles.BeginGUI();
            GUI.Label(new Rect(0, 0, 50f, 50f), "标题");
            GUI.Button(new Rect(0, 20f, 50f, 50f), AssetDatabase.LoadAssetAtPath<Texture>("Assets/unity.png"));
            Handles.EndGUI();
        };
    }
}

如下,Scene视图左上角多出了一个UI
在这里插入图片描述

五、扩展Game视图

通常来讲运行游戏才能执行脚本的生命周期。如果想在非运行模式下也可以执行脚本,在脚本上添加[ExecuteInEditMode],那么该脚本可以在编辑模式下生效,如果不想在发布后出现可以使用宏定义来剔除。

using UnityEngine;

#if UNITY_EDITOR

//编辑器模式下依然执行生命周期
[ExecuteInEditMode]
public class GameScript : MonoBehaviour
{
    private void OnGUI()
    {
	if (GUILayout.Button("Click"))
	{
	    Debug.Log("Click");
	}
	GUILayout.Label("Click!");
    }
}

#endif

总结

累死了,本篇详细讲述了 Unity 编辑器的五大视图的拓展方法。原本想分为多篇,但为了整体性,将其整合在一起。下一篇文章字数减少但会更加深入地探讨,并详细解释面板和编辑器源码的相关内容。有不愿透露姓氏的杨姓砖家建议认真阅读完本篇并亲自进行代码试验,然后再查看下一篇。
创作不易,觉得有用的请大家多点赞、评论、收藏,毕竟不收钱,甚至说不定因为哪个知识点恰巧能在面试里帮助到你,提升你的薪资,哈哈。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mfbz.cn/a/346494.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

redis-持久化-1

Redis 提供了2个不同形式的持久化方式。 RDB&#xff08;Redis DataBase&#xff09; AOF&#xff08;Append Of File&#xff09; 一、Redis持久化之RDB 1.什么是RDB 在指定的时间间隔内将内存中的数据集快照写入磁盘&#xff0c; 也就是行话讲的Snapshot快照&#xff0c…

Windows10上通过MSYS2编译FFmpeg 6.1.1源码操作步骤

1.从github上clone代码&#xff0c;并切换到n6.1.1版本&#xff1a;clone到D:\DownLoad目录下 git clone https://github.com/FFmpeg/FFmpeg.git git checkout n6.1.1 2.安装MSYS2并编译FFmpeg源码: (1).从https://www.msys2.org/ 下载msys2-x86_64-20240113.exe &#…

JS高频面试题(下)

11. 线程和进程的区别 进程是资源分配的最小单元&#xff0c;线程是代码执行的最小单元。 一个应用程序可能会开启多个进程&#xff0c;进程之间数据不共享&#xff0c;一个进程内部可以开启多个线程&#xff0c;线程之间的数据可以共享的&#xff0c;所以多线程的情况下&…

Qt/QML编程之路:ListView实现横排图片列表的示例(40)

ListView列表,在QML中使用非常多,排列一个行,一个列或者一个表格,都会用到ListView。 ListView显示从内置QML类型(如ListModel和XmlListModel)创建的模型中的数据,或在C++中定义的从QAbstractItemModel或QAbstract ListModel继承的自定义模型类中的数据。 ListView有一…

搭建通讯猫类似的TCP服务端

最近需要一个公网的TCP服务端平台来做4G模组的发包测验&#xff0c;通讯猫(http://www.tongxinmao.com/App/Detail/id/1)貌似使用不了&#xff0c;就干脆在自己的腾讯云上搭建了简单的TCP服务端。 我们搭建可以在服务器上使用Python、Java、C#等语言自行编写服务器程序。 目前是…

element-ui 树形控件 通过点击某个节点,遍历获取上级的所有父节点和本身节点

1、需求&#xff1a;点击树形控件的某个节点&#xff0c;需要拿到它上级的所有父节点进行操作 2、代码&#xff1a; 树形控件代码 <el-tree:data"deptOptions"node-click"getVisitCheckedNodes"ref"target_tree_Speech"node-key"id&qu…

javaSSMmysql电影推荐系统03073-计算机毕业设计项目选题推荐(附源码)

目 录 摘 要 Abstract 第1章 前 言 1.1 研究背景 1.2 研究现状 1.3 系统开发目标 第2章 技术与原理 2.1 开发技术 2 2.2 ssm框架介绍 2 2.3 MySQL数据库 2 2.4 B/S结构 2 第3章 需求分析 3.1 需求分析 3.2 系统可行性分析 3.3 项目设计目标与原则 3.4…

阿里云 ACK 云原生 AI 套件中的分布式弹性训练实践

作者&#xff1a;霍智鑫 众所周知&#xff0c;随着时间的推移&#xff0c;算力成为了 AI 行业演进一个不可或缺的因素。在数据量日益庞大、模型体量不断增加的今天&#xff0c;企业对分布式算力和模型训练效率的需求成为了首要的任务。如何更好的、更高效率的以及更具性价比的…

数学建模常见算法的通俗理解(3)

11 Logistic模型&#xff08;计算是/否的概率&#xff09; 11.1 粗浅理解 我们有m张图片&#xff0c;并且获取了这些图片的特征向量的矩阵&#xff0c;我们需要判断这些图片中是否满足我们某个要求&#xff0c;如是否含有猫&#x1f431;这种动物。那么此时我们的每张图片传…

《统计学习方法:李航》笔记 从原理到实现(基于python)-- 第3章 k邻近邻法

文章目录 第3章 k邻近邻法3.1 k近邻算法3.2 k近邻模型3.2.1 模型3.2.2 距离度量3.2.3 k值的选择3.2.4 分类决策规则 3.3 k近邻法的实现&#xff1a;kd树3.3.1 构造kd树3.3.2 搜索kd树 算法实现课本例3.1iris数据集scikit-learn实例kd树:构造平衡kd树算法例3.2 《统计学习方法&a…

Docker中安装 RabbitMQ

1、下载 RabbitMQ 镜像 下载最新版本的镜像&#xff1a; docker pull rabbitmq更多版本的镜像可以访问 Docker 官网&#xff1a;https://hub.docker.com/_/rabbitmq?tabtags 2、创建并运行 RabbitMQ 容器 启动命令&#xff1a; docker run -d -p 15672:15672 -p 5672:567…

Netty Reactor 模式解析

目录 Reactor 模式 具体流程 配置 初始化 NioEventLoop ServerBootstrapAcceptor 分发 Reactor 模式 在刚学 Netty 的时候&#xff0c;我们肯定都很熟悉下面这张图&#xff0c;它就是单Reactor多线程模型。 在写Netty 服务端代码的时候&#xff0c;下面…

『OpenCV-Python|鼠标作画笔』

Opencv-Python教程链接&#xff1a;https://opencv-python-tutorials.readthedocs.io/ 本文主要介绍OpenCV-Python如何将鼠标作画笔绘制圆或者矩形。 示例一&#xff1a;图片上双击的位置绘制一个圆圈 首先创建一个鼠标事件回调函数&#xff0c;鼠标事件发生时就会被执行。鼠标…

php 文件上传

目录 1 php.ini 配置文件的修改 2.系统返回码详解 错误级别 4.上传简单示例 5.php代码简单优化 1 php.ini 配置文件的修改 配置项说明file_uploads on 为 开启文件上传功能&#xff0c; off 为关闭 post_max_size 系统允许的 POST 传参的最大值 &#xff0c;默认 8M upl…

(二十八)ATP应用测试平台——使用electron集成vue3桌面应用程序

前言 Electron 是一个开源的框架&#xff0c;它允许使用 Web 技术&#xff08;HTML、CSS 和 JavaScript&#xff09;构建跨平台的桌面应用程序。通过 Electron&#xff0c;开发者可以使用前端技术栈来创建具有原生应用程序体验的桌面应用。 Electron可以在 Windows、Mac 和 L…

爬虫是什么 怎么预防

爬虫是一种自动化程序&#xff0c;用于从网页或网站中提取数据。它们通过模拟人类用户的行为&#xff0c;发送HTTP请求并解析响应&#xff0c;以获取所需的信息。 爬虫可以用于各种合法用途&#xff0c;如搜索引擎索引、数据采集和监测等。然而&#xff0c;有些爬虫可能是恶意的…

【Web前端实操11】定位实操_照片墙(无序摆放)

设置一个板块&#xff0c;将照片随意无序摆放在墙上&#xff0c;从而形成照片墙。本来效果应该是很唯美好看的&#xff0c;就像这种&#xff0c;但是奈何本人手太笨&#xff0c;只好设置能达到照片墙的效果就可。 代码如下&#xff1a; <!DOCTYPE html> <html lang&…

使用dcdiag 和 netdiag确保域控的复制和网络是健康的

dcdiag 和 netdiag 是 Windows 操作系统中的两个命令行工具&#xff0c;主要用于诊断和验证活动目录&#xff08;Active Directory&#xff09;环境的健康状况&#xff0c;包括复制、连接以及其他网络服务。 以下是如何运行这两个工具的步骤&#xff1a; 运行 dcdiag&#xf…

PMP考试刷题记录20240125

1、所有干系人都在开会讨论一个新项目&#xff0c;该项目预计将在一个月内启动&#xff0c;并持续至少10次迭代&#xff0c;其中一个干系人提到应该有人负责开发和维护产品路线图。谁应该承担这个责任? A.项目经理 B.开发团队 C.ScrumMaster D.产品负责人 答案&#xff1…

推荐HuoCMS多站点多语言CMS系统源码

HuoCMS是一套内容管理系统同时也是一套企业官网建设系统&#xff0c;能够帮过用户快速搭建自己的网站。可以满足企业站&#xff0c;外贸站&#xff0c;个人博客等一系列的建站需求。HuoCMS的优势: 可以使用统一后台管理多个网站的内容&#xff0c;统一维护&#xff0c;不同内容…