Browse Source

可视化编辑器

moshaorui 3 months ago
parent
commit
f5272759cd

+ 1 - 1
app/Admin/Forms/AppearanceImPortForm.php

@@ -85,7 +85,7 @@ class AppearanceImPortForm extends Form implements LazyRenderable
         }
         $zip->close();
         //解压后,停0.1秒
-        usleep(100000);
+        usleep(200000);
         //判断tmp下是否只有一个文件夹,如果是,移动到trulyPath
         // 递归删除 $trulyPath 下的所有文件和文件夹
         $deleteDirectoryContents = $this->deleteDirectoryContents($trulyPath);

+ 58 - 2
app/Distributor/Controllers/VisualEditorController.php

@@ -3,16 +3,23 @@
 namespace App\Distributor\Controllers;
 
 use App\Distributor\Metrics\DistMessage;
+use App\Distributor\Repositories\DistAppearanceTemplate;
 use App\Distributor\Repositories\SiteMenu;
 use App\Http\Controllers\Controller;
+use App\Libraries\CommonHelper;
 use Dcat\Admin\Layout\Column;
 use Dcat\Admin\Layout\Content;
 use Dcat\Admin\Layout\Row;
+use Dcat\Admin\Traits\HasUploadedFile;
 
 
 class VisualEditorController extends Controller
 {
+    use HasUploadedFile;
 
+    /*
+     * 可视化编辑器
+     */
     public function index()
     {
         $mid = isset($_GET['mid'])? intval($_GET['mid']) : 0;
@@ -26,10 +33,59 @@ class VisualEditorController extends Controller
         return view('distributor.pages-custom.visual-editor',['html'=>$html,'siteMenu'=>json_encode($siteMenu),'mid'=>$mid]);
     }
 
+    /*
+     * 保存预览修改
+     */
+    public function previewSave()
+    {
+        $html = isset($_POST['html'])? $_POST['html'] : '';
+        if (empty($html) == false) {
+            DistAppearanceTemplate::previewSave($html);
+        }
+        return response()->json(['status' => 1]);
+    }
+
+    /*
+     * 上传图片
+     */
+    public function upload()
+    {
+        $disk = $this->disk('oss');
+        // 判断是否是删除文件请求
+        if ($this->isDeleteRequest()) {
+            // 删除文件并响应
+            //return $this->deleteFileAndResponse($disk);
+            return $this->responseDeleted();
+        }
+        // 获取上传的文件
+        $file = $this->file();
+        // 获取上传的字段名称
+        // $column = $this->uploader()->upload_column;
+        $column = uniqueCode("img");
+        $dir = config("distributor.upload.gjs_directory.image").'/'.date('Ymd');
+        $newName = $column.'.'.$file->getClientOriginalExtension();
+        $result = $disk->putFileAs($dir, $file, $newName);
+        $result = CommonHelper::ossUrl($result);
+        $response =  [
+            'data'=>[[
+                'src' => $result,
+                'type'=>'image',
+            ]]
+        ];
+        return json_encode($response);
+    }
+
     private function getURlHTML($url,$uri)
     {
-        //echo $url;exit;
         $siteUrl = $url . $uri;
+        // 检查URL中是否已经包含?
+        if (strpos($siteUrl, '?') !== false) {
+            // 如果包含?则添加&mtb-preview=1
+            $siteUrl .= "&mtb-preview=1";
+        } else {
+            // 如果不包含?则添加?mtb-preview=1
+            $siteUrl .= "?mtb-preview=1";
+        }
         $ch = curl_init($siteUrl);
         curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); // 返回原生的(Raw)输出
         curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, false); // 跳过证书检查(在生产环境中,应将其设置为true并指定CA证书路径)
@@ -37,7 +93,7 @@ class VisualEditorController extends Controller
 
         $html = curl_exec($ch);
         if (curl_errno($ch)) {
-            echo 'cURL error: ' . curl_error($ch);
+            echo 'cURL error: ' . curl_error($ch);exit;
         }
         curl_close($ch);
         $pattern = '/<(link|script|img)[^>]*\s+(href|src)=["\'](\/[^"\']+)["\'][^>]*>/i';

+ 108 - 2
app/Distributor/Repositories/DistAppearanceTemplate.php

@@ -6,7 +6,9 @@ use App\Admin\Repositories\DistAppearanceTemplateLog;
 use App\Models\DistAppearanceTemplate as Model;
 use App\Models\SiteAppearanceTemplate;
 use Dcat\Admin\Repositories\EloquentRepository;
+use DOMDocument;
 use Illuminate\Support\Carbon;
+use Symfony\Component\DomCrawler\Crawler;
 
 class DistAppearanceTemplate extends EloquentRepository
 {
@@ -30,7 +32,7 @@ class DistAppearanceTemplate extends EloquentRepository
     /*
      * 得到独立页的模版数组
      */
-    public static function getLandingPageTemplateOptions() {
+    public static function getLandingPageTemplateOptions()
     {
         $distInfo = DistAdminDistributor::getInfo();
         $distId = $distInfo->id;
@@ -51,8 +53,112 @@ class DistAppearanceTemplate extends EloquentRepository
         return $options;
     }
 
-}
+    public static function previewSave($data)
+    {
+        //echo $data;exit;
+        $distInfo = DistAdminDistributor::getInfo();
+        $distId = $distInfo->id;
+        $appearanceId = $distInfo->appearance_id;
+        //查找$data中是否存在mtb_id
+        $inputElements = self::findElementsWithMtbId($data);
+        foreach ($inputElements as $prefix => $elements) {
+            //找查找对应的模板
+            $template = Model::where('dist_id', $distId)->where('appearance_id', $appearanceId)->where('template_code',$prefix)->first();
+            if ($template) {
+                //替换模板内容
+                $oldContent = self::contentChange($template->content,$template->template_code);//旧模板内容
+                $newContent = self::contentReplace($oldContent,$elements);
+
+                //更新模板内容
+                $template->content = $newContent;
+                $template->updated_at = Carbon::now();
+                $template->save();
+                //记录日志
+                DistAppearanceTemplateLog::insertLog($appearanceId,$distId,$template->file_name,$template->file_path,$template->template_code,$newContent,$oldContent);
+            }
+        }
+    }
+
+
+
+    /*
+     * 替换模板内容
+     * @param $content 模板内容
+     * @param $elements Array([mtb_id] => 676a6d11f1a57_1,[outHtml] => <div mtb_id="abc_2"><p>Again3</p></div>)
+     */
+    public static function contentReplace($content,$elements) {
+
+        foreach ($elements as $element) {
+            $mtbId = $element['mtb_id'];
+            $outHtml = $element['outHtml'];
+            $content = preg_replace_callback(
+                '/<([a-zA-Z0-9]+)[^>]*mtb_id="' . preg_quote($mtbId, '/') . '"[^>]*>(.*?)<\/\1>/s',
+                function ($matches) use ($outHtml) {
+                    // 返回替换后的内容
+                    return $outHtml;
+                },
+                $content
+            );
+        }
+
+        return $content;
+    }
+
 
+    /*
+     * 查找带有 mtb_id 属性的 HTML 元素
+     */
+    public static function findElementsWithMtbId($html) {
+        // 定义正则表达式来匹配所有带有 mtb_id 属性的 HTML 元素
+        $pattern = '/<([a-zA-Z0-9-_]+)([^>]*\smtb_id="([^"]+)")([^>]*)>(.*?)<\/\1>/is';
+        preg_match_all($pattern, $html, $matches, PREG_SET_ORDER);
+        // 存储结果
+        $result = [];
+        foreach ($matches as $match) {
+            // 提取 mtb_id 和匹配的 HTML 内容
+            $outHtml = $match[0];
+            //去掉mtb_id属性
+            $outHtml = preg_replace('/mtb_id="[^"]*"/', '', $outHtml);
+            $result[] = [
+                'mtb_id' => $match[3],  // mtb_id 的值
+                'outHtml' => $outHtml, // 匹配到的完整 HTML 元素
+            ];
+        }
 
+        $result = array_reduce($result, function ($carry, $item) {
+            // 获取 mtb_id 的前缀
+            $prefix = explode('_', $item['mtb_id'])[0];
+            // 如果前缀不存在,初始化一个空数组
+            if (!isset($carry[$prefix])) {
+                $carry[$prefix] = [];
+            }
+            // 将当前项添加到对应的前缀数组中
+            $carry[$prefix][] = $item;
+            return $carry;
+        }, []);
+
+
+        return $result;
+    }
+
+
+
+    /*
+     * TODO: 模板内容增加 mtb_id 属性,用于区分不同模板
+     */
+    public static function contentChange($content,$templateCode)
+    {
+        $count = 1;
+        $newContent = preg_replace_callback(
+            '/(<[^>]+?mtb_edit=[\'"][^\'"]+[\'"][^>]*>)/',
+            function ($matches) use (&$count, $templateCode) {
+                // 在匹配到的 HTML 元素后添加 mtb_id 属性
+                return preg_replace('/(mtb_edit=[\'"][^\'"]+[\'"])([^>]*)>/', '$1 mtb_id="' . $templateCode .'_'. $count++ . '"$2>', $matches[0]);
+            },
+            $content
+        );
+
+        return $newContent;
+    }
 
 }

+ 71 - 0
app/Distributor/Repositories/DistAppearanceTemplateLog.php

@@ -0,0 +1,71 @@
+<?php
+
+namespace App\Distributor\Repositories;
+
+use App\Admin\Repositories\DistAppearanceTemplate;
+use App\Models\DistAppearanceTemplateLog as Model;
+use Dcat\Admin\Repositories\EloquentRepository;
+
+class DistAppearanceTemplateLog extends EloquentRepository
+{
+    /**
+     * Model.
+     *
+     * @var string
+     */
+    protected $eloquentClass = Model::class;
+
+    /*
+     *  添加模版修改日志
+     *  $currentContent 修改后的内容
+     *  $previousContent 修改前的内容
+     */
+    public static function insertLog($appearanceId,$distId,$fileName,$filePath,$templateCode,$currentContent,$previousContent)
+    {
+        $model = new Model();
+        $model->dist_id = $distId;
+        $model->file_name = $fileName;
+        $model->file_path = $filePath;
+        $model->appearance_id = $appearanceId;
+        $model->template_code = $templateCode;
+        $model->current_content = $currentContent;
+        $model->previous_content = $previousContent;
+        $model->version = generateVersionNumber();
+        $model->save();
+    }
+
+    /*
+     *  获取模版修改日志
+     */
+    public static function fetchTemplateLogs($appearanceId,$distId,$templateCode)
+    {
+        $model = new Model();
+        $model = $model->where('appearance_id','=',$appearanceId)->where('dist_id','=',$distId)->where('template_code','=',$templateCode)->orderBy('id','desc')->select('id','version','created_at')->get();
+        return $model;
+    }
+
+    /*
+     *  获取单条模版修改日志内容
+     */
+    public static function fetchTemplateLogContent($logId)
+    {
+        $model = new Model();
+        $model = $model->where('id','=',$logId)->first();
+        return $model;
+    }
+    /*
+     * 还原模版
+     */
+    public static function restoreTemplateLog($logId) {
+        $model = new Model();
+        $model = $model->where('id', '=', $logId)->first();
+        if ($model) {
+            $appearanceId = $model->appearance_id;
+            $distId = $model->dist_id;
+            $templateCode = $model->template_code;
+            $content = $model->previous_content;
+            DistAppearanceTemplate::saveContent($appearanceId, $distId, $templateCode, $content);
+        }
+    }
+
+}

+ 2 - 0
app/Distributor/routes.php

@@ -70,6 +70,8 @@ Route::group([
     $router->resource('messages', 'DistMessageController');
     //可视化编辑器
     $router->get('visual-editor', 'VisualEditorController@index');
+    $router->post('visual-editor/upload', 'VisualEditorController@upload');
+    $router->post('visual-editor/preview-save', 'VisualEditorController@previewSave');
 });
 
 /*

+ 6 - 0
config/distributor.php

@@ -271,10 +271,16 @@ return [
             'file'  => 'dist_files',
         ],
 
+        //tinymce编辑器上传图片路径
         'tinymce_directory' => [
             'image' => 'uploads/images',
         ],
 
+        //可视在线编辑器上传图片路径
+        'gjs_directory' => [
+            'image' => 'uploads/images',
+        ],
+
         'oss_image' => [
             'accept' => 'jpg,png,gif,jpeg,webp,svg',//允许上传的文件类型
             'max_size' => 500, // 上传文件大小限制,单位kB

+ 122 - 33
public/vendor/grapes/grapes.init.js

@@ -100,6 +100,11 @@ const myNewComponentTypes = (editor) => {
         model: {
             defaults: {
                 traits: [
+                    {
+                        type: 'text', // Type of the trait
+                        name: 'title', // (required) The name of the attribute/property to use on component
+                        label: 'title', // The label you will see in Settings
+                    },
                     {
                         type: 'text', // Type of the trait
                         name: 'width', // (required) The name of the attribute/property to use on component
@@ -115,7 +120,7 @@ const myNewComponentTypes = (editor) => {
         },
     });
 };
-
+const csrfToken = document.querySelector('meta[name="csrf-token"]').getAttribute('content');
 // 创建 GrapesJS 编辑器实例
 const editor = grapesjs.init({
     container: '#gjs', // 指定编辑器容器的 DOM 元素
@@ -125,13 +130,14 @@ const editor = grapesjs.init({
     storageManager: false,
     panels: panelsConfig,
     plugins: [myNewComponentTypes],
-    canvas: {
-        // 禁用对所有元素的选择
-        selectorManager: {
-            // 允许选择特定类名的元素
-            selector: '.your-class-name',
-        }
-    }
+    assetManager: {
+        upload: '/dist/visual-editor/upload',
+        uploadName: '_file_',
+        multiUpload:false,
+        headers: {
+            'X-CSRF-TOKEN': csrfToken,
+        },
+    },
 });
 const am = editor.AssetManager;
 // 锁定整个画布
@@ -157,15 +163,39 @@ editor.getComponents().each(component => {
         // 如果有交集,解锁组件
         if (hasIntersection || component.get('attributes').mtb_edit == 'true') {
             component.set({ locked: false });
-            component.setStyle({
-                border: '2px dashed  #3b97e3  '
-            });
+            setBorder(component);
+        }
+        if (component.get('attributes').mtb_edit == 'false') {
+            component.set({ locked: true });
+        }
+
+        if (component.get('attributes').mtb_edit_this == 'true') {
+            component.set({ locked: false });
+            setBorder(component);
+            //关闭子组件
+            component.forEachChild(child => {
+                child.set({ locked: true });
+            })
+        }
+        if (component.get('attributes').mtb_edit_this == 'false') {
+            component.set({ locked: true });
         }
 
     })
 
 });
 
+function setBorder(component) {
+    if (component.attributes.tagName == 'a') {
+        component.setStyle({
+            border: '3px dashed  #f08300'
+        });
+    } else {
+        component.setStyle({
+            border: '1px dashed  #3b97e3'
+        });
+    }
+}
 
 
 // 监听组件选择事件
@@ -179,26 +209,13 @@ editor.on('component:selected', (comp) => {
     });
 
     //如果是连接与图片,显示右边的属性面板
-    let tags = ['a', 'img'];
-    const hasScheduleAttribute = selectedComponent.attributes;
-    if (tags.includes(selectedComponent.attributes.tagName)) {
-        editor.runCommand('open-tm');
-        // 获取 .gjs-cv-canvas-bg 元素
-        canvasBg = document.querySelector('.gjs-cv-canvas');
-        canvasBg.classList.remove('gjs-cv-canvas-bg');
+    // let tags = ['a', 'img'];
+    // const hasScheduleAttribute = selectedComponent.attributes;
+    // if (tags.includes(selectedComponent.attributes.tagName)) {
+    //     //editor.runCommand('open-tm');
+    // } else {
+    // }
 
-        var elements = document.querySelectorAll('.gjs-pn-views-container');
-        elements.forEach(function(element) {
-            element.style.display = 'block'; // 或者 'flex',取决于你希望如何显示这些元素
-        });
-    } else {
-        canvasBg = document.querySelector('.gjs-cv-canvas');
-        canvasBg.classList.add('gjs-cv-canvas-bg');
-        var elements = document.querySelectorAll('.gjs-pn-views-container');
-        elements.forEach(function(element) {
-            element.style.display = 'none'; // 或者 'flex',取决于你希望如何显示这些元素
-        });
-    }
     // 双击图片时弹出图片管理器
     if (selectedComponent.attributes.tagName == 'img') {
         //双击图片时弹出图片管理器
@@ -221,8 +238,50 @@ editor.on('component:selected', (comp) => {
         });
     }
     //其他
+
+});
+
+var open_tm_show = true;
+var cogIcons = document.querySelectorAll('.fa-cog');
+cogIcons.forEach(function(icon) {
+    icon.addEventListener('click', function() {
+        // 在这里执行你想要的操作
+        if (open_tm_show) {
+            open_tm_show = false;
+        } else {
+            open_tm_show = true;
+        }
+        leftPanelDisplay(open_tm_show);
+    });
 });
 
+function leftPanelDisplay(isShow = true) {
+    if (isShow) {
+        // canvasBg = document.querySelector('.gjs-cv-canvas');
+        // canvasBg.classList.remove('gjs-cv-canvas-bg');
+        let elements1 = document.querySelectorAll('.gjs-pn-views-container');
+        elements1.forEach(function(element) {
+            element.style.display = 'block'; // 或者 'flex',取决于你希望如何显示这些元素
+        });
+        let elements2 = document.querySelectorAll('.gjs-pn-views');
+        elements2.forEach(function(element) {
+            element.style = 'border_bottom: 2px solid var(--gjs-main-dark-color)';
+        });
+    } else {
+        // canvasBg = document.querySelector('.gjs-cv-canvas');
+        // canvasBg.classList.add('gjs-cv-canvas-bg');
+        let elements1 = document.querySelectorAll('.gjs-pn-views-container');
+        elements1.forEach(function(element) {
+            element.style.display = 'none'; // 或者 'flex',取决于你希望如何显示这些元素
+        });
+        let elements2 = document.querySelectorAll('.gjs-pn-views');
+        elements2.forEach(function(element) {
+            element.style = 'border:none';
+        });
+    }
+}
+
+
 // 监听 load 事件
 editor.on('load', () => {
     // 在这里执行你的初始化逻辑
@@ -245,7 +304,7 @@ editor.on('load', () => {
     dropdown.innerHTML = options;
     // 将下拉选择框插入到目标元素之后
     targetElement.insertAdjacentElement('afterend', dropdown);
-    //
+
     dropdownel = document.getElementById('dropdown');
     // 监听 change 事件
     dropdownel.addEventListener('change', function () {
@@ -262,12 +321,42 @@ editor.on('load', () => {
             window.location.href = redirectUrl;
         }
     });
-
 });
 
 //更新时调用接口保存数据
 editor.on('update', () => {
     const html = editor.getHtml();
     // 你可以在这里添加自定义逻辑
-    console.log('GrapesJS updated the editor content', html);
+    //ajax提交数据
+    $.ajax({
+        url: '/dist/visual-editor/preview-save',
+        type: 'POST',
+        headers: {
+            'X-CSRF-TOKEN': csrfToken,
+        },
+        data: {   // 发送数据
+            html: html
+        },
+        success: function (res) {
+            if (res.status != 1) {
+                //alert('save error');
+            }
+        },
+        error: function (err) {
+            alert(err);
+        }
+    });
+});
+
+// 上传图片时触发
+var layer = layui.layer;
+var loadIndex = null;
+editor.on('asset:upload:start', () => {
+    loadIndex = layer.load(0, {shade: [0.5, '#000']});
 });
+
+// The upload is ended (completed or not)
+editor.on('asset:upload:end', () => {
+    layer.close(loadIndex);
+});
+

File diff suppressed because it is too large
+ 0 - 0
public/vendor/grapes/grapes.min.css


File diff suppressed because it is too large
+ 1 - 0
public/vendor/grapes/jquery-3.6.0.min.js


File diff suppressed because it is too large
+ 0 - 0
public/vendor/grapes/layui/css/layui.css


BIN
public/vendor/grapes/layui/font/iconfont.eot


File diff suppressed because it is too large
+ 16 - 0
public/vendor/grapes/layui/font/iconfont.svg


BIN
public/vendor/grapes/layui/font/iconfont.ttf


BIN
public/vendor/grapes/layui/font/iconfont.woff


BIN
public/vendor/grapes/layui/font/iconfont.woff2


File diff suppressed because it is too large
+ 0 - 0
public/vendor/grapes/layui/layui.js


+ 10 - 6
resources/views/distributor/pages-custom/visual-editor.blade.php

@@ -4,8 +4,12 @@
 <head>
     <meta charset="utf-8">
     <title>Editor</title>
+    <meta name="csrf-token" content="{{ csrf_token() }}">
     <link rel="stylesheet" href="/vendor/grapes/grapes.min.css">
     <script src="/vendor/grapes/grapes.min.js"></script>
+    <script src="/vendor/grapes/jquery-3.6.0.min.js"></script>
+    <link href="/vendor/grapes/layui/css/layui.css" rel="stylesheet">
+    <script src="/vendor/grapes/layui/layui.js"></script>
     <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0-beta3/css/font-awesome.min.css">
     <style>
         body, html {
@@ -19,12 +23,12 @@
         .gjs-cv-canvas-bg {
             width: 100%;
         }
-        .gjs-pn-views-container {
-            display: none;
-        }
-        .gjs-pn-views {
-            border: none;
-        }
+        /*.gjs-pn-views-container {*/
+        /*    display: none;*/
+        /*}*/
+        /*.gjs-pn-views {*/
+        /*    border: none;*/
+        /*}*/
 
         .gjs-pn-options {
             right: unset;

Some files were not shown because too many files changed in this diff