Flex——命令管理,Undo来Redo去

前言

Undo,Redo是编辑环境里常见的并且非常重要的功能,下面介绍【命令模式】在Flex/AS3下的实现。

ICommand接口

定义ICommand接口,其中Execute和UnExecute是相反的2个操作,Title属性用于命令显示,例如显示在操作历史列表里。

package cwn.wb.ui.core.command

{

import cwn.core.IDispose;

public interface ICommand extends IDispose

{

function get Title():String

function Execute():void

function UnExecute():void

}

}

CommandBase基类

CommandBase类主要实现ICommand接口,所有Command类都将继承CommandBase类。

package cwn.wb.ui.core.command

{

import cwn.core.IDispose;

public class CommandBase implements ICommand

{

public function CommandBase()

{

}

//===================ICommand========================

protected var _Title:String;

public function get Title():String

{

return _Title;

}

public function Execute():void

{

throw new Error("Not implementation.");

}

public function UnExecute():void

{

throw new Error("Not implementation.");

}

//========================IDispose==========================

private var _Disposed:Boolean = false;

protected function Disposing():void

{

}

public function Dispose():void

{

if (_Disposed)

return;

Disposing();

_Disposed = true;

}

}

}

AddCommand类

AddCommand用于处理向界面容器添加子元件的操作,定义如下:

package cwn.wb.ui.core.command

{

import mx.core.UIComponent;

public class AddCommand extends CommandBase

{

private var _Parent:UIComponent;

private var _Target:UIComponent;

private var _Index:int;

public function AddCommand(parent:UIComponent, target:UIComponent)

{

_Parent = parent;

_Target = target;

_Index = _Parent.numChildren;

if (_Parent.contains(_Target))

_Index = _Parent.getChildIndex(_Target);

_Title = "添加" + _Target.name;

}

override public function Execute():void

{

if(_Parent.contains(_Target))

return;

_Parent.addChildAt(_Target, Math.min(_Parent.numChildren, _Index));

}

override public function UnExecute():void

{

if(!_Parent.contains(_Target))

return;

_Parent.removeChild(_Target);

}

override protected function Disposing():void

{

_Parent = null;

_Target = null;

super.Disposing();

}

}

}

DeleteCommand类

DeleteCommand用于处理向界面容器删除子元件的操作,效果与AddCommand相反,定义如下:

package cwn.wb.ui.core.command

{

import mx.core.UIComponent;

public class DeleteCommand extends CommandBase

{

private var _Parent:UIComponent;

private var _Target:UIComponent;

private var _Index:int;

public function DeleteCommand(parent:UIComponent, target:UIComponent)

{

_Parent = parent;

_Target = target;

_Index = _Parent.numChildren;

if (_Parent.contains(_Target))

_Index = _Parent.getChildIndex(_Target);

_Title = "删除" + _Target.name;

}

override public function Execute():void

{

if (!_Parent.contains(_Target))

return;

_Parent.removeChild(_Target);

}

override public function UnExecute():void

{

if (_Parent.contains(_Target))

return;

_Parent.addChildAt(_Target, Math.min(_Parent.numChildren, _Index));

}

override protected function Disposing():void

{

_Parent = null;

_Target = null;

super.Disposing();

}

}

}

EditCommand类

EditCommand用于处理元件的属性编辑操作,定义如下:

package cwn.wb.ui.core.command

{

import mx.core.UIComponent;

public class EditCommand extends CommandBase

{

private var _Property:String;

private var _Target:Object;

private var _OldValue:Object;

private var _NewValue:Object;

public function EditCommand(property:String, target:Object, oldValue:Object, newValue:Object)

{

_Property = property;

_Target = target;

_OldValue = oldValue;

_NewValue = newValue;

_Title = "编辑" + _Property;

}

override public function Execute():void

{

_Target[_Property] = _NewValue;

}

override public function UnExecute():void

{

_Target[_Property] = _OldValue;

}

override protected function Disposing():void

{

_Target = null;

_OldValue = null;

_NewValue = null;

super.Disposing();

}

}

}

例子:编辑宽度

var edit:EditCommand = new EditCommand("width", target, target.width, 500);

CommandManager类

添加,删除,编辑3个主要命令已经定义完成,现在要定义一个命令管理的类对命令进行管理,集中调用,其中Undo,Redo操作都在该类实现。

package cwn.wb.ui.core.command

{

import cwn.core.DisposeUtil;

import flash.events.EventDispatcher;

import mx.collections.ArrayCollection;

import mx.core.UIComponent;

public class CommandManager extends EventDispatcher

{

private static var g_Created:Boolean = false;

public static var Instance:CommandManager = new CommandManager();

public function CommandManager()

{

super();

if (g_Created)

throw new Error("Singleton class. Please use Instance static filed.");

g_Created = true;

}

//======================命令管理:UndoRedo=============================

private var _UndoList:ArrayCollection = new ArrayCollection();

private var _RedoList:ArrayCollection = new ArrayCollection();

public function get CanRedo():Boolean

{

return _RedoList.length > 0;

}

public function get CanUndo():Boolean

{

return _UndoList.length > 0;

}

public function Redo():void

{

if (!CanRedo)

return;

var command:ICommand = _RedoList.removeItemAt(_RedoList.length - 1) as ICommand;

command.Execute();

_UndoList.addItem(command);

}

public function Undo():void

{

if (!CanUndo)

return;

var command:ICommand = _UndoList.removeItemAt(_UndoList.length - 1) as ICommand;

command.UnExecute();

_RedoList.addItem(command);

}

//======================命令调用=============================

private function ExecuteCommand(command:ICommand):void

{

command.Execute();

AppendCommand(command);

}

private function AppendCommand(command:ICommand):void

{

//有新命令添加时清空RedoList

DisposeUtil.Dispose(_RedoList);

_UndoList.addItem(command);

}

//======================添加、删除、编辑命令=============================

public function Add(parent:UIComponent, target:UIComponent):void

{

var command:ICommand = new AddCommand(parent, target);

ExecuteCommand(command);

}

public function Delete(parent:UIComponent, target:UIComponent):void

{

var command:ICommand = new DeleteCommand(parent, target);

ExecuteCommand(command);

}

public function Edit(property:String, target:Object, oldValue:Object, newValue:Object):void

{

var command:ICommand = new EditCommand(property, target, oldValue, newValue);

ExecuteCommand(command);

}

}

}

进阶

一个命令管理的框架基本完成了,根据实际使用,再进行扩展。

复合命令

有时,一个操作就调用了多个命令,例如,同时删除多个元件,同时移动多个元件。这需要多个命令进行复合,可以定义一个MultiCommand命令来完成多个命令同时调用。

package cwn.wb.ui.core.command

{

import cwn.core.DisposeUtil;

import mx.collections.ArrayCollection;

public class MultiCommand extends CommandBase

{

private var _Commands:ArrayCollection;

public function MultiCommand(commands:ArrayCollection)

{

_Commands = commands;

if (_Commands.length > 0)

_Title = ICommand(_Commands[0]).Title + "...";

}

override public function Execute():void

{

for each (var cmd:ICommand in _Commands)

{

cmd.Execute();

}

}

override public function UnExecute():void

{

for each (var cmd:ICommand in _Commands)

{

cmd.UnExecute();

}

}

override protected function Disposing():void

{

cwn.core.DisposeUtil.Dispose(_Commands);

super.Disposing();

}

}

}

事件通知

CommandManager是继承事件派发类EventDispatcher的,当有新的命令执行时还应该派发相应的事件,通知界面等进行刷新,数据同步。

相关资料

IDispose接口

<?php /** * PHP 5.4.x 文件编辑器 - 双模式编辑器 * 支持代码编辑和所见即所得(WYSIWYG)编辑模式 */ // ================= 配置区域 ================= header('Content-Type: text/html; charset=UTF-8'); $PASSWORD = 'SecurePass123!'; // 默认登录密码 $RESET_TOKEN = 'RESET_TOKEN_ABC'; // 密码重置令牌 $LOG_FILE = 'editor_log.txt'; // 操作日志文件 $ALLOWED_EXT = array('php', 'txt', 'html', 'css', 'js', 'htm', 'xml', 'json'); // 允许的文件类型 $MAX_ATTEMPTS = 5; // 最大登录尝试次数 $LOCK_TIME = 300; // 锁定时间(秒) = 5分钟 $ENCODING = 'UTF-8'; // 系统默认编码 // ================= 初始化 ================= @date_default_timezone_set('Asia/Shanghai'); if (!isset($_SESSION)) @session_start(); // 初始化变量 $currentFile = isset($_GET['file']) ? $_GET['file'] : 'index.php'; $content = ''; $message = ''; $isLoggedIn = false; $rootDir = realpath(dirname(__FILE__)) ?: dirname(__FILE__); $editorMode = isset($_SESSION['editor_mode']) ? $_SESSION['editor_mode'] : 'code'; // 检查登录状态 if (isset($_SESSION['editor_logged_in']) && $_SESSION['editor_logged_in'] === true) { $isLoggedIn = true; } // ================= 核心功能函数 ================= function logAction($action, $file, $details = '') { global $LOG_FILE; $ip = isset($_SERVER['REMOTE_ADDR']) ? $_SERVER['REMOTE_ADDR'] : 'unknown'; $time = date('Y-m-d H:i:s'); $logEntry = "[$time][$ip] $action: $file | $details\n"; if (!file_exists($LOG_FILE) || filesize($LOG_FILE) === 0) { $logEntry = "\xEF\xBB\xBF" . $logEntry; // UTF-8 BOM } @file_put_contents($LOG_FILE, $logEntry, FILE_APPEND); } function fixPathEncoding($path) { if (function_exists('mb_detect_encoding')) { $encodings = array('UTF-8', 'GBK', 'GB2312', 'BIG5', 'CP936'); $encoding = @mb_detect_encoding($path, $encodings); if ($encoding && $encoding != 'UTF-8') { return @mb_convert_encoding($path, 'UTF-8', $encoding); } } return $path; } function sanitizePath($path, $baseDir) { $path = fixPathEncoding($path); $baseDir = fixPathEncoding($baseDir); $baseDir = str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $baseDir); $path = str_replace(array('/', '\\'), DIRECTORY_SEPARATOR, $path); $fullPath = $baseDir . DIRECTORY_SEPARATOR . $path; $realPath = realpath($fullPath); if ($realPath === false) { $realPath = $fullPath; } $baseLower = strtolower($baseDir); $realLower = strtolower($realPath); if (strpos($realLower, $baseLower) === 0) { return $realPath; } $logMsg = "安全阻止: baseDir=$baseDir, path=$path, realPath=" . ($realPath ?: 'false'); logAction('路径安全', '系统', $logMsg); return false; } function safeOutput($str) { global $ENCODING; if (function_exists('mb_detect_encoding')) { $encodings = array('UTF-8', 'GB18030', 'GBK', 'GB2312', 'BIG5'); $encoding = @mb_detect_encoding($str, $encodings, true); if ($encoding && $encoding != $ENCODING) { $str = @mb_convert_encoding($str, $ENCODING, $encoding); } } return htmlspecialchars($str, ENT_QUOTES, $ENCODING); } function safeFileGetContents($path) { $content = @file_get_contents($path); if ($content === false) { if ($handle = @fopen($path, "r")) { $content = ''; while (!feof($handle)) { $content .= fread($handle, 8192); } fclose($handle); } } if (!empty($content) && function_exists('mb_check_encoding')) { if (!@mb_check_encoding($content, 'UTF-8')) { $encodings = array('GB18030', 'GBK', 'GB2312', 'BIG5', 'CP936'); $encoding = @mb_detect_encoding($content, $encodings, true); if ($encoding) { $content = @mb_convert_encoding($content, 'UTF-8', $encoding); } else { $content = utf8_encode($content); } } } return $content; } function getFiles() { global $rootDir, $ALLOWED_EXT; $files = array(); if ($handle = @opendir($rootDir)) { while (false !== ($entry = readdir($handle))) { if ($entry == '.' || $entry == '..') continue; $fixedEntry = fixPathEncoding($entry); $ext = pathinfo($fixedEntry, PATHINFO_EXTENSION); $fullPath = $rootDir . DIRECTORY_SEPARATOR . $fixedEntry; if (is_file($fullPath) && in_array(strtolower($ext), $ALLOWED_EXT)) { $files[] = $fixedEntry; } } closedir($handle); } sort($files); return $files; } // ================= 登录验证 ================= if (isset($_POST['login'])) { $password = isset($_POST['password']) ? trim($_POST['password']) : ''; if (!isset($_SESSION['login_attempts'])) { $_SESSION['login_attempts'] = 0; $_SESSION['last_attempt'] = 0; } $lockRemaining = $_SESSION['last_attempt'] + $LOCK_TIME - time(); if ($_SESSION['login_attempts'] >= $MAX_ATTEMPTS && $lockRemaining > 0) { $message = "账户已锁定!请等待 " . ceil($lockRemaining/60) . " 分钟后重试"; logAction('登录锁定', '系统', "剩余: {$lockRemaining}秒"); } elseif ($password === $PASSWORD) { $_SESSION['editor_logged_in'] = true; $_SESSION['login_attempts'] = 0; $isLoggedIn = true; $message = "登录成功!"; logAction('登录成功', '系统'); } else { $_SESSION['login_attempts']++; $_SESSION['last_attempt'] = time(); $remaining = $MAX_ATTEMPTS - $_SESSION['login_attempts']; $message = "密码错误!剩余尝试次数: $remaining"; if ($remaining <= 0) { $message = "账户已锁定!请等待 5 分钟后重试"; } logAction('登录失败', '系统', "尝试: {$_SESSION['login_attempts']}"); } } if (isset($_GET['logout'])) { session_destroy(); session_start(); $isLoggedIn = false; $message = "您已退出系统"; logAction('退出系统', '系统'); } if (isset($_POST['reset_password'])) { $token = isset($_POST['reset_token']) ? trim($_POST['reset_token']) : ''; if ($token === $RESET_TOKEN) { $message = "密码已重置为:$PASSWORD"; $_SESSION['login_attempts'] = 0; logAction('密码重置', '系统', "成功"); } else { $message = "重置令牌无效!"; logAction('密码重置失败', '系统', "无效令牌"); } } if (!$isLoggedIn) { // 显示登录表单 ?> <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>PHP文件编辑器 - 登录</title> <style> * { box-sizing: border-box; margin: 0; padding: 0; font-family: 'Microsoft YaHei', sans-serif; } body { background: #f0f2f5; display: flex; justify-content: center; align-items: center; height: 100vh; } .login-container { background: white; border-radius: 10px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); width: 380px; padding: 30px; } .logo { text-align: center; margin-bottom: 25px; } .logo span { font-size: 48px; display: block; } .logo h1 { font-size: 22px; color: #333; margin-top: 10px; } .form-group { margin-bottom: 20px; } .form-group label { display: block; margin-bottom: 8px; color: #555; font-weight: bold; } .form-group input { width: 100%; padding: 12px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; } .form-group input:focus { border-color: #3498db; outline: none; box-shadow: 0 0 0 2px rgba(52,152,219,0.2); } .btn { background: #3498db; color: white; border: none; padding: 12px; width: 100%; border-radius: 6px; font-size: 16px; cursor: pointer; transition: background 0.3s; } .btn:hover { background: #2980b9; } .message { padding: 12px; margin-bottom: 20px; border-radius: 6px; text-align: center; } .error { background: #ffebee; color: #c62828; border: 1px solid #ffcdd2; } .success { background: #e8f5e9; color: #2e7d32; border: 1px solid #c8e6c9; } .reset-link { text-align: center; margin-top: 20px; } .reset-link a { color: #3498db; text-decoration: none; } .reset-link a:hover { text-decoration: underline; } .footer { text-align: center; margin-top: 20px; color: #777; font-size: 14px; } </style> </head> <body> <div class="login-container"> <div class="logo"> <span>🔒</span> <h1>PHP文件编辑器</h1> </div> <?php if (!empty($message)): ?> <div class="message error"><?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?></div> <?php endif; ?> <form method="post"> <div class="form-group"> <label for="password">密码</label> <input type="password" id="password" name="password" required placeholder="输入默认密码"> </div> <button type="submit" name="login" class="btn">登录</button> <div class="reset-link"> <a href="#" onclick="document.getElementById('resetForm').style.display='block'; return false;">忘记密码?</a> </div> <div class="footer"> PHP文件编辑器 v1.0 © <?= date('Y') ?> </div> </form> <form method="post" id="resetForm" style="display:none; margin-top:20px; padding-top:20px; border-top:1px solid #eee;"> <div class="form-group"> <label for="reset_token">重置令牌</label> <input type="text" id="reset_token" name="reset_token" required placeholder="输入重置令牌"> </div> <button type="submit" name="reset_password" class="btn">重置密码</button> </form> </div> </body> </html> <?php exit; } // ================= 文件操作 ================= $files = getFiles(); // 处理模式切换请求 if (isset($_POST['switch_mode'])) { $newMode = isset($_POST['editor_mode']) ? $_POST['editor_mode'] : 'code'; $_SESSION['editor_mode'] = $newMode; $editorMode = $newMode; // 保存当前编辑内容 if (isset($_POST['content'])) { $saveContent = $_POST['content']; $saveFile = isset($_POST['file']) ? basename($_POST['file']) : $currentFile; $fullPath = sanitizePath($saveFile, $rootDir); if ($fullPath !== false) { // 处理文件编码 if (!empty($saveContent) && function_exists('mb_check_encoding')) { if (!@mb_check_encoding($saveContent, 'UTF-8')) { $saveContent = @mb_convert_encoding($saveContent, 'UTF-8'); } $saveContent = preg_replace('/^\xEF\xBB\xBF/', '', $saveContent); } @file_put_contents($fullPath, $saveContent); } } $message = "已切换到" . ($editorMode === 'wysiwyg' ? "所见即所得" : "代码") . "编辑模式"; } // 确保$content始终有值 $fullPath = sanitizePath($currentFile, $rootDir); if ($fullPath === false) { die("<div style='padding:20px;font-family:Microsoft YaHei;'> <h2>⚠️ 文件访问错误</h2> <p>安全错误:禁止访问外部目录!</p> <p>请求文件: " . safeOutput($currentFile) . "</p> <p>根目录: " . safeOutput($rootDir) . "</p> <p><a href='?'>返回首页</a></p> </div>"); } if (file_exists($fullPath)) { $content = safeFileGetContents($fullPath); logAction('打开文件', $currentFile); } else { $content = "<?php\n// 新文件: $currentFile\n\necho '欢迎使用PHP文件编辑器';\n"; if (@file_put_contents($fullPath, $content) !== false) { $message = "已创建文件:" . safeOutput($currentFile); $files[] = $currentFile; sort($files); logAction('创建文件', $currentFile); } else { $message = "无法创建文件!请检查目录权限"; $content = "<?php\n// 文件创建失败 - 请检查目录写权限\n"; } } if (isset($_POST['save'])) { $saveFile = isset($_POST['file']) ? basename($_POST['file']) : $currentFile; $saveContent = isset($_POST['content']) ? $_POST['content'] : ''; $fullPath = sanitizePath($saveFile, $rootDir); if ($fullPath === false) { die("<div class='error-box'> <h2>⚠️ 文件保存错误</h2> <p>安全错误:禁止访问外部目录!</p> <p>请求文件: " . safeOutput($saveFile) . "</p> <p><a href='javascript:history.back()'>返回</a></p> </div>"); } if (!empty($saveContent) && function_exists('mb_check_encoding')) { if (!@mb_check_encoding($saveContent, 'UTF-8')) { $saveContent = @mb_convert_encoding($saveContent, 'UTF-8'); } $saveContent = preg_replace('/^\xEF\xBB\xBF/', '', $saveContent); } if (@file_put_contents($fullPath, $saveContent) !== false) { $content = $saveContent; $message = "成功保存文件:" . safeOutput($saveFile); logAction('保存文件', $saveFile, "大小: " . strlen($saveContent) . "字节"); } else { $message = "保存失败!请检查文件权限"; } } // ================= 主编辑器界面 ================= ?> <!DOCTYPE html> <html lang="zh"> <head> <meta charset="UTF-8"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>PHP文件编辑器 - <?= htmlspecialchars($currentFile, ENT_QUOTES, 'UTF-8') ?></title> <!-- 引入TinyMCE库 --> <script src="https://cdn.tiny.cloud/1/no-api-key/tinymce/5/tinymce.min.js" referrerpolicy="origin"></script> <style> * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: 'Microsoft YaHei', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: #f0f2f5; color: #333; line-height: 1.6; } .header { background: #2c3e50; color: white; padding: 12px 20px; display: flex; justify-content: space-between; align-items: center; box-shadow: 0 2px 10px rgba(0,0,0,0.2); } .logo { display: flex; align-items: center; gap: 15px; } .logo-icon { font-size: 28px; } .header-actions a { color: white; text-decoration: none; padding: 8px 15px; background: #e74c3c; border-radius: 4px; transition: background 0.3s; } .header-actions a:hover { background: #c0392b; } .container { max-width: 1400px; margin: 20px auto; padding: 0 20px; } .message { padding: 15px; margin-bottom: 20px; border-radius: 6px; text-align: center; } .success { background: #d4edda; color: #155724; border: 1px solid #c3e6cb; } .error { background: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; } .toolbar { background: white; padding: 15px; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); display: flex; flex-wrap: wrap; gap: 15px; margin-bottom: 20px; } .file-select { flex: 1; min-width: 250px; } .file-select select { width: 100%; padding: 10px; border: 1px solid #ddd; border-radius: 6px; font-size: 16px; } .action-buttons { display: flex; gap: 10px; flex-wrap: wrap; } .btn { padding: 10px 18px; background: #3498db; color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 16px; transition: background 0.3s; display: flex; align-items: center; gap: 6px; } .btn:hover { background: #2980b9; } .btn-new { background: #2ecc71; } .btn-new:hover { background: #27ae60; } .btn-save { background: #9b59b6; } .btn-save:hover { background: #8e44ad; } .mode-selector { display: flex; gap: 10px; } .mode-btn { padding: 10px 16px; border: none; border-radius: 6px; background: #e0e0e0; cursor: pointer; font-weight: bold; transition: all 0.3s; } .mode-btn.active { background: #3498db; color: white; } .editor-container { background: white; border-radius: 8px; box-shadow: 0 2px 8px rgba(0,0,0,0.1); overflow: hidden; margin-bottom: 20px; } .editor-header { padding: 15px 20px; background: #f8f9fa; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 15px; } .file-name { font-weight: bold; color: #2c3e50; } .file-info { color: #666; } #code-editor { display: <?= $editorMode === 'code' ? 'block' : 'none' ?>; width: 100%; height: 65vh; min-height: 500px; padding: 20px; border: none; resize: vertical; font-family: Consolas, Monaco, 'Courier New', monospace; font-size: 15px; line-height: 1.5; background: #f8f9fa; border-top: 1px solid #eee; border-bottom: 1px solid #eee; outline: none; white-space: pre; overflow-wrap: normal; overflow-x: auto; } #wysiwyg-editor { display: <?= $editorMode === 'wysiwyg' ? 'block' : 'none' ?>; height: 65vh; min-height: 500px; padding: 20px; background: white; overflow: auto; border: 1px solid #ddd; border-radius: 4px; } .editor-footer { padding: 12px 20px; background: #f8f9fa; border-top: 1px solid #eee; display: flex; justify-content: space-between; flex-wrap: wrap; gap: 15px; } .status-info { color: #666; } .footer { text-align: center; padding: 20px; color: #777; font-size: 14px; border-top: 1px solid #eee; margin-top: 20px; } @media (max-width: 768px) { .toolbar { flex-direction: column; } .action-buttons { width: 100%; } .file-select { width: 100%; } .editor-header, .editor-footer { flex-direction: column; } } </style> </head> <body> <header class="header"> <div class="logo"> <span class="logo-icon">📝</span> <h1>PHP文件编辑器</h1> </div> <div class="header-actions"> <a href="?logout">退出登录</a> </div> </header> <div class="container"> <?php if (!empty($message)): ?> <div class="message <?= strpos($message, '成功') !== false ? 'success' : 'error' ?>"> <?= htmlspecialchars($message, ENT_QUOTES, 'UTF-8') ?> </div> <?php endif; ?> <div class="toolbar"> <div class="file-select"> <form method="get" id="fileForm"> <select name="file" onchange="document.getElementById('fileForm').submit()"> <?php foreach ($files as $file): ?> <option value="<?= htmlspecialchars($file, ENT_QUOTES, 'UTF-8') ?>" <?= $file == $currentFile ? 'selected' : '' ?>> <?= htmlspecialchars($file, ENT_QUOTES, 'UTF-8') ?> </option> <?php endforeach; ?> </select> </form> </div> <div class="action-buttons"> <button class="btn btn-new" onclick="document.getElementById('newFileForm').submit()"> <span>📄</span> 新建文件 </button> <div class="mode-selector"> <button class="mode-btn <?= $editorMode === 'code' ? 'active' : '' ?>" onclick="switchEditorMode('code')">代码模式</button> <button class="mode-btn <?= $editorMode === 'wysiwyg' ? 'active' : '' ?>" onclick="switchEditorMode('wysiwyg')">所见即所得</button> </div> <button type="button" onclick="saveFile()" class="btn btn-save"> <span>💾</span> 保存文件 (Ctrl+S) </button> </div> </div> <form method="get" id="newFileForm"> <input type="hidden" name="file" value="newfile.php"> </form> <div class="editor-container"> <div class="editor-header"> <div class="file-name"> 当前文件: <?= htmlspecialchars($currentFile, ENT_QUOTES, 'UTF-8') ?> </div> <div class="file-info"> 大小: <?= number_format(strlen($content)) ?> 字节 | 模式: <?= $editorMode === 'wysiwyg' ? '所见即所得' : '代码' ?> </div> </div> <!-- 编辑器模式切换表单 --> <form method="post" id="modeForm" style="display:none"> <input type="hidden" name="switch_mode" value="1"> <input type="hidden" name="editor_mode" id="newEditorMode" value="<?= $editorMode ?>"> <input type="hidden" name="file" value="<?= htmlspecialchars($currentFile, ENT_QUOTES, 'UTF-8') ?>"> <textarea name="content" id="modeFormContent"></textarea> </form> <!-- 文件保存表单 --> <form method="post" id="saveForm" style="display:none"> <input type="hidden" name="save" value="1"> <input type="hidden" name="file" value="<?= htmlspecialchars($currentFile, ENT_QUOTES, 'UTF-8') ?>"> <textarea name="content" id="saveFormContent"></textarea> </form> <!-- 代码编辑器 --> <textarea id="code-editor"><?= htmlspecialchars($content, ENT_QUOTES, 'UTF-8') ?></textarea> <!-- WYSIWYG编辑器 --> <div id="wysiwyg-editor"><?= $content ?></div> <div class="editor-footer"> <div class="status-info"> 编码: UTF-8 | 行数: <span id="lineCount">1</span> </div> <div> 根目录: <?= htmlspecialchars($rootDir, ENT_QUOTES, 'UTF-8') ?> </div> </div> </div> <div class="footer"> PHP文件编辑器 v2.0 (含WYSIWYG功能) © <?= date('Y') ?> | PHP版本: <?= PHP_VERSION ?> </div> </div> <script> // 初始化TinyMCE编辑器 tinymce.init({ selector: '#wysiwyg-editor', height: 'calc(65vh - 40px)', min_height: 460, menubar: false, plugins: [ 'advlist autolink lists link image charmap print preview anchor', 'searchreplace visualblocks code fullscreen', 'insertdatetime media table paste code help wordcount' ], toolbar: 'undo redo | formatselect | ' + 'bold italic backcolor | alignleft aligncenter ' + 'alignright alignjustify | bullist numlist outdent indent | ' + 'removeformat | help | code', content_style: 'body { font-family:Helvetica,Arial,sans-serif; font-size:14px }', setup: function(editor) { // 添加自定义保存按钮 editor.ui.registry.addButton('customSave', { text: '保存', icon: 'save', onAction: function() { saveFile(); } }); // 添加自定义代码查看按钮 editor.ui.registry.addButton('viewSource', { text: '查看源码', icon: 'sourcecode', onAction: function() { var content = editor.getContent({format: 'html'}); alert('HTML源码:\n\n' + content); } }); } }); // 编辑器模式切换 function switchEditorMode(mode) { // 在切换前保存当前编辑器的内容 if (mode !== '<?= $editorMode ?>') { if ('<?= $editorMode ?>' === 'code') { // 从代码编辑器获取内容 document.getElementById('modeFormContent').value = document.getElementById('code-editor').value; } else { // 从WYSIWYG编辑器获取内容 document.getElementById('modeFormContent').value = tinymce.get('wysiwyg-editor').getContent(); } // 设置新编辑器模式 document.getElementById('newEditorMode').value = mode; // 提交模式切换表单 document.getElementById('modeForm').submit(); } } // 保存文件 function saveFile() { if ('<?= $editorMode ?>' === 'code') { // 保存代码编辑器内容 document.getElementById('saveFormContent').value = document.getElementById('code-editor').value; } else { // 保存WYSIWYG编辑器内容 document.getElementById('saveFormContent').value = tinymce.get('wysiwyg-editor').getContent(); } // 提交保存表单 document.getElementById('saveForm').submit(); } // 保存快捷键支持 document.addEventListener('keydown', function(e) { if ((e.ctrlKey || e.metaKey) && e.key === 's') { e.preventDefault(); saveFile(); } }); // 实时行数统计(仅代码模式) const editor = document.getElementById('code-editor'); const lineCount = document.getElementById('lineCount'); function updateLineCount() { if ('<?= $editorMode ?>' === 'code') { const lines = editor.value.split('\n').length; lineCount.textContent = lines; } } editor.addEventListener('input', updateLineCount); updateLineCount(); // 编辑器自动高度 function resizeEditor() { const containerHeight = window.innerHeight - 300; editor.style.height = Math.max(containerHeight, 500) + 'px'; } window.addEventListener('resize', resizeEditor); resizeEditor(); </script> </body> </html> 帮我完成所见即得的编辑功能
11-26
<template> <div class="hot-table-wps"> <!-- 工具栏 --> <div class="toolbar" v-if="flag"> <select v-model="fontFamily" @change="applyStyle('fontFamily')"> <option value="Arial">Arial</option> <option value="Times New Roman">Times New Roman</option> <option value="Courier New">Courier New</option> <option value="Verdana">Verdana</option> <option value="SimSun">宋体</option> <option value="Microsoft YaHei">微软雅黑</option> </select> <select v-model="fontSize" @change="applyStyle('fontSize')"> <option v-for="size in [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]" :value="size" :key="size">{{ size }} </option> </select> <button @click="toggleBold" :class="{ 'active': fontWeight === 'bold' }"> <b>B</b> </button> <button @click="toggleItalic" :class="{ 'active': fontStyle === 'italic' }"> <i>I</i> </button> <button @click="toggleUnderline" :class="{ 'active': textDecoration === 'underline' }"> <u>U</u> </button> <div class="color-picker"> <label>文字颜色</label> <!-- <input type="color" v-model="color" @change="applyStyle('color')"> --> <el-color-picker v-model="color" show-alpha :predefine="predefineColors" @change="applyStyle('color')"/> </div> <div class="color-picker"> <label>背景颜色</label> <!-- <input type="color" v-model="backgroundColor" @change="applyStyle('backgroundColor')"> --> {{ backgroundColor }} <el-color-picker v-model="backgroundColor" show-alpha :predefine="predefineColors" @change="applyStyle('backgroundColor')"/> </div> <select v-model="textAlign" @change="applyStyle('textAlign')"> <option value="left">左对齐</option> <option value="center">居中</option> <option value="right">右对齐</option> </select> <select v-model="verticalAlign" @change="applyStyle('verticalAlign')"> <option value="top">顶部对齐</option> <option value="middle">垂直居中</option> <option value="bottom">底部对齐</option> </select> <!-- <button @click="exportToExcel" class="export-btn"> <span>导出Excel</span> </button> --> <button class="export-btn" type="success" @click="flag = false"> <span>取消</span> </button> </div> <div class="toolbar" v-if="rowFlage"> <!-- 添加部分文字字体和字号选择 --> <select v-model="partialFontFamily" @change="applyPartialStyle('fontFamily')"> <option value="Arial">Arial</option> <option value="Times New Roman">Times New Roman</option> <option value="Courier New">Courier New</option> <option value="Verdana">Verdana</option> <option value="SimSun">宋体</option> <option value="Microsoft YaHei">微软雅黑</option> </select> <select v-model="partialFontSize" @change="applyPartialStyle('fontSize')"> <option v-for="size in [8, 9, 10, 11, 12, 14, 16, 18, 20, 22, 24, 26, 28, 36, 48, 72]" :value="size" :key="size">{{ size }} </option> </select> <button @click="applyPartialStyle('fontWeight', 'bold')" :class="{ 'active': partialFontWeight === 'bold' }"> <b>B</b> </button> <button @click="applyPartialStyle('fontStyle', 'italic')" :class="{ 'active': partialFontStyle === 'italic' }"> <i>I</i> </button> <button @click="applyPartialStyle('textDecoration', 'underline')" :class="{ 'active': partialTextDecoration === 'underline' }"> <u>U</u> </button> <div class="color-picker"> <label>文字颜色</label> <input type="color" v-model="partialColor" @change="applyPartialStyle('color')"> </div> <div class="color-picker"> <label>背景颜色</label> <input type="color" v-model="partialBackgroundColor" @change="applyPartialStyle('backgroundColor')"> </div> <select v-model="partialTextAlign" @change="applyPartialStyle('textAlign')"> <option value="left">左对齐</option> <option value="center">居中</option> <option value="right">右对齐</option> </select> <!-- <select v-model="partialVerticalAlign" @change="applyPartialStyle('verticalAlign')">--> <!-- <option value="top">顶部对齐</option>--> <!-- <option value="middle">垂直居中</option>--> <!-- <option value="bottom">底部对齐</option>--> <!-- </select>--> <button class="export-btn" type="success" @click="rowFlage = false"> <span>取消</span> </button> </div> <!-- Handsontable 表格容器 --> <div ref="hotTable" class="hot-table-container"></div> </div> </template> <script> import Handsontable from 'handsontable'; import 'handsontable/dist/handsontable.full.css'; import { exportToExcel } from './exporter'; // 导入中文语言包 import { registerLanguageDictionary, zhCN } from 'handsontable/i18n'; // 注册中文语言包 registerLanguageDictionary('zh-CN', zhCN); export default { name: 'HotTableWPS', props: { colHeaders: { type: [Boolean, Array], default: true }, rowHeaders: { type: Boolean, default: true }, mergeSameStation: { type: Boolean, default: true } , tableData: { type: Object, default: () => { } }, active: { type: Number, default: 0 }, }, data() { return { predefineColors:[ '#ff4500', '#ff8c00', '#ffd700', '#90ee90', '#00ced1', '#1e90ff', '#c71585', 'rgba(255, 69, 0, 0.68)', 'rgb(255, 120, 0)', 'hsv(51, 100, 98)', 'hsva(120, 40, 94, 0.5)', 'hsl(181, 100%, 37%)', 'hsla(209, 100%, 56%, 0.73)', '#c7158577', ], flag: false, rowFlage: false, partialColor: '#000000', partialBackgroundColor: '#ffffff', partialFontWeight: 'normal', partialFontStyle: 'normal', partialTextDecoration: 'none', partialFontFamily: 'Arial', partialTextAlign: 'left', partialVerticalAlign: 'middle', partialFontSize: 14, currentTextSelection: { text: '', start: -1, end: -1, row: -1, col: -1 }, partialTextStyles: {}, hot: null, cellStyles: {}, // 添加撤销/重做历史记录 historyStack: [], historyIndex: 0, maxHistorySize: 50, fontFamily: 'Arial', fontSize: 14, fontWeight: 'normal', fontStyle: 'normal', textDecoration: 'none', color: '#000000', backgroundColor: '#ffffff', textAlign: 'center', verticalAlign: 'middle', selectedCells: [], height: 'auto', tableList: [], newRemarks: '', // 推荐的数据格式 // tableData: { // // 表头数据(日期列) // headers: [ // "时间", "", "21日", '22日', '23日', '24日', '25日', '26日', '27日', '28日', // '29日', '30日', '31日', '1日', '2日', '3日', '4日', '昨日实况', '昨日预报' // ], // // 行数据(按水电站和指标分组) // rows: [ // { // stationName: '桐子林', // type: '出库', // values: [1950, 2050, 2200, 2250, 2150, 2000, 1900, 1900, 1800, 1800, 1800, 1800, 1800, 1800, 1830, 1850, 1850, 1850] // // }, // { // stationName: '观音岩', // type: '出库', // values: [3100, 3200, 3400, 3500, 3500, 3500, 3400, 3400, 3000, 3000, 3000, 2800, 2800, 2800, 3030, 3400] // }, // { // stationName: '观音岩', // type: '区间', // values: [450, 450, 500, 650, 750, 600, 500, 200, 600, 500, 400, 600, 400, 400, 450, 350] // }, // { // stationName: '观音岩', // type: '入库', // values: [5500, 5700, 6100, 6400, 6400, 6100, 5800, 5500, 5400, 5300, 5200, 5200, 5000, 5000, 5310, 5600] // }, // { // stationName: '观音岩', // type: '出库', // values: [5450, 5450, 5480, 5450, 6080, 6070, 6070, 6100, 6130, 5480, 5490, 5170, 4740, 4730, 4730] // }, // { // stationName: '乌东德', // type: '时段初水位', // values: [960.03, 960.07, 960.29, 960.82, 961.63, 961.90, 961.93, 961.69, 961.19, 960.57, 960.41, 960.16, 960.41, 960.64, 960.64] // }, // { // stationName: '乌东德', // type: '时段末水位', // values: [960.07, 960.29, 960.82, 961.63, 961.90, 961.93, 961.69, 961.19, 960.57, 960.41, 960.16, 960.19, 960.41, 960.41, 960.87] // }, // { // stationName: '乌东德', // type: '日变幅', // values: [0.04, 0.22, 0.53, 0.81, 0.27, 0.03, -0.24, -0.50, -0.62, -0.16, -0.25, 0.03, 0.22, 0.23, 0.23] // }, // { // stationName: '乌东德', // type: '耗水率', // values: [2.89, 2.89, 2.87, 2.85, 2.87, 2.86, 2.87, 2.87, 2.89, 2.87, 2.83, 2.86, 2.84, 2.84, 2.84] // }, // { // stationName: '乌东德', // type: '建议电量', // values: [1.63, 1.63, 1.65, 1.65, 1.83, 1.83, 1.83, 1.83, 1.83, 1.65, 1.65, 1.56, 1.44, 1.44, 1.44] // }, // { // stationName: '白鹤滩', // type: '交易电量', // values: [1.63, 1.63, 1.65, 1.65, 1.83, 1.83, 1.83, 1.83, 1.65, 1.65, '/', '/', '/', '/', '/'] // }, // { // stationName: '白鹤滩', // type: '入库', // values: [5850, 5950, 6080, 5850, 6380, 6370, 6370, 6400] // } // ] // } }; }, mounted() { this.initializeHotTable(); // 添加键盘事件监听 this.addKeyboardListeners(); }, beforeDestroy() { if (this.hot) { this.hot.destroy(); } document.removeEventListener('selectionchange', this.handleTextSelection); // 移除键盘事件监听 this.removeKeyboardListeners(); }, methods: { /** * 检查文本选择范围的样式状态 * 判断选择范围是完全应用、部分应用还是未应用目标样式,用于决定后续是应用还是移除样式 * @param {string} cellKey 单元格标识 * @param {number} start 选择起始索引 * @param {number} end 选择结束索引 * @param {string} property 样式属性 * @param {any} value 目标样式值 * @returns {Object} 样式状态对象(fullyStyled: 是否完全应用, mixed: 是否部分应用) */ findExactStyle(cellKey, start, end, property, value) { if (!this.partialTextStyles[cellKey] || this.partialTextStyles[cellKey].length === 0) { return { fullyStyled: false, start: start, end: end, property: property, value: value !== null ? value : this.getStyleValue(property) }; } const targetValue = value !== null ? value : this.getStyleValue(property); let fullyStyled = true;// 是否完全应用目标样式 let partiallyStyled = false;// 是否部分应用目标样式 // 逐位置检查选择范围内的样式状态 for (let pos = start; pos < end; pos++) { let hasStyleAtPos = false; // 检查当前位置是否有目标样式 for (const style of this.partialTextStyles[cellKey]) { if (pos >= style.start && pos < style.end && style[property] === targetValue) { hasStyleAtPos = true; break; } } if (!hasStyleAtPos) { fullyStyled = false;// 存在未应用样式的位置,标记为非完全应用 } else { partiallyStyled = true;// 存在已应用样式的位置,标记为部分应用 } } // 返回判断结果 if (fullyStyled) { return { fullyStyled: true, start: start, end: end, property: property, value: targetValue }; } else if (partiallyStyled) { return { fullyStyled: false, start: start, end: end, property: property, value: targetValue, mixed: true// 混合状态(部分应用) }; } else { return { fullyStyled: false, start: start, end: end, property: property, value: targetValue }; } }, getStyleValue(property) { switch (property) { case 'fontFamily': return this.partialFontFamily; case 'fontSize': return this.partialFontSize; case 'color': return this.partialColor; case 'backgroundColor': return this.partialBackgroundColor; case 'fontWeight': // 切换加粗状态 return this.partialFontWeight === 'bold' ? 'normal' : 'bold'; case 'fontStyle': // 切换斜体状态 return this.partialFontStyle === 'italic' ? 'normal' : 'italic'; case 'textDecoration': // 切换下划线状态 return this.partialTextDecoration === 'underline' ? 'none' : 'underline'; case 'textAlign': return this.partialTextAlign; case 'verticalAlign': return this.partialVerticalAlign; default: return null; } }, /** * 移除部分文本样式(取消已应用的样式) * 逻辑:拆分包含目标样式的片段 -> 移除目标属性 -> 保留其他属性 -> 合并剩余片段 * @param {string} cellKey 单元格标识 * @param {Object} foundStyle 要移除的样式信息(包含start, end, property, value) * @param {string} property 要移除的样式属性 */ removePartialStyle(cellKey, foundStyle, property) { const { start, end, value } = foundStyle; const styles = this.partialTextStyles[cellKey]; // 收集需要处理的样式(与选择范围重叠且属性值匹配的样式) const stylesToProcess = []; for (let i = 0; i < styles.length; i++) { const style = styles[i]; // 检查范围是否重叠且属性值匹配 if (start < style.end && end > style.start && style[property] === value) { const overlapStart = Math.max(start, style.start); const overlapEnd = Math.min(end, style.end); if (overlapStart < overlapEnd) { stylesToProcess.push({ index: i, style: { ...style }, overlapStart, overlapEnd }); } } } // 从后往前处理,避免索引偏移问题 stylesToProcess.sort((a, b) => b.index - a.index); for (const { index, style, overlapStart, overlapEnd } of stylesToProcess) { // 移除原样式 styles.splice(index, 1); // 创建新的样式片段(拆分原样式,移除目标属性) const newStyles = []; // 1. 重叠部分之前的片段(保留所有属性) if (style.start < overlapStart) { const beforeStyle = { ...style }; beforeStyle.end = overlapStart; newStyles.push(beforeStyle); } // 2. 重叠部分(移除目标属性,保留其他属性) const middleStyle = { ...style }; delete middleStyle[property]; // 核心:移除目标样式属性 middleStyle.start = overlapStart; middleStyle.end = overlapEnd; // 仅当还有其他样式属性时保留此片段(避免空样式) const hasOtherProperties = Object.keys(middleStyle).some( key => !['start', 'end'].includes(key) && middleStyle[key] !== undefined ); if (hasOtherProperties) { newStyles.push(middleStyle); } // 3. 重叠部分之后的片段(保留所有属性) if (style.end > overlapEnd) { const afterStyle = { ...style }; afterStyle.start = overlapEnd; newStyles.push(afterStyle); } // 添加所有新片段到样式数组 styles.push(...newStyles); } // 重新渲染单元格,使样式变更生效 const [row, col] = cellKey.split('-').map(Number); this.renderCellWithPartialStyles(row, col); }, /** * 合并相邻且样式完全相同的片段(优化方法) * 减少冗余样式片段,提升渲染性能和减少DOM节点数量 * @param {string} cellKey 单元格标识 */ mergeAdjacentStyles(cellKey) { if (!this.partialTextStyles[cellKey] || this.partialTextStyles[cellKey].length <= 1) { return; // 无需合并:无样式或只有一个样式片段 } const styles = this.partialTextStyles[cellKey]; // 按起始位置排序(确保从左到右处理) styles.sort((a, b) => a.start - b.start); let i = 0; while (i < styles.length - 1) { const current = styles[i]; const next = styles[i + 1]; // 检查是否相邻(当前结束=下一个开始)且所有样式属性完全相同 if (current.end === next.start && this.areStylesCompletelyEqual(current, next)) { // 合并样式:扩展当前样式的结束位置,移除下一个样式 current.end = next.end; styles.splice(i + 1, 1); } else { i++; // 不满足合并条件,移动到下一个 } } }, areStylesCompletelyEqual(style1, style2) { const properties = ['color', 'backgroundColor', 'fontWeight', 'fontStyle', 'textDecoration', 'fontFamily', 'fontSize','textAlign', 'verticalAlign']; for (const prop of properties) { const val1 = style1[prop]; const val2 = style2[prop]; // 处理 undefined 和 null 的情况 if ((val1 === undefined || val1 === null) && (val2 === undefined || val2 === null)) { continue; } if (val1 !== val2) { return false; } } return true; }, /** * 应用部分文本样式(主入口方法) * @param {string} property 样式属性名(如 'color', 'fontWeight') * @param {any} value 样式值(如 '#ff0000', 'bold',为null时自动切换状态) */ applyPartialStyle(property, value = null) { if (!this.hot) return; // 保存当前状态到历史记录,支持撤销操作 this.saveToHistory(); // 检查是否有文本选择 if (!this.currentTextSelection.text) { alert('请先选择要修改样式的文本:\n1. 双击单元格\n2. 选择部分文本\n3. 点击样式按钮'); return; } const { start, end, row, col } = this.currentTextSelection; const cellValue = this.hot.getDataAtCell(row, col) || ''; // 验证选择范围 if (start < 0 || end > cellValue.length || start >= end) { alert('选择范围无效'); return; } const cellKey = `${row}-${col}`; const styleValue = value !== null ? value : this.getStyleValue(property); // 检查当前选择范围的样式状态 const foundStyle = this.findExactStyle(cellKey, start, end, property, styleValue); if (foundStyle.fullyStyled) { // 如果已经完全应用了相同的样式,则移除它(取消样式) this.removePartialStyle(cellKey, foundStyle, property); } else { // 应用新样式 this.applyNewPartialStyle(cellKey, start, end, property, styleValue); } // 重新渲染单元格 this.renderCellWithPartialStyles(row, col); // 清除选择 // this.currentTextSelection = { // text: '', // start: -1, // end: -1, // row: -1, // col: -1 // }; }, /** * 应用新的部分文本样式(核心实现) * 处理逻辑:拆分重叠样式 -> 保留非重叠部分 -> 合并新样式 -> 优化相邻样式 * @param {string} cellKey 单元格唯一标识(格式:"row-col") * @param {number} start 文本选择起始索引 * @param {number} end 文本选择结束索引 * @param {string} property 样式属性名 * @param {any} value 样式值 */ applyNewPartialStyle(cellKey, start, end, property, value, overlappingStyles = []) { if (!this.partialTextStyles[cellKey]) { this.partialTextStyles[cellKey] = []; } const styles = this.partialTextStyles[cellKey]; // 第一步:收集所有受影响的样式(与当前选择范围重叠的样式) const affectedStyles = []; for (let i = 0; i < styles.length; i++) { const style = styles[i]; if (style.start < end && style.end > start) { affectedStyles.push({ index: i, style: { ...style } }); } } // 第二步:从后往前移除受影响的样式(避免索引偏移问题) affectedStyles.sort((a, b) => b.index - a.index); for (const { index } of affectedStyles) { styles.splice(index, 1); } // 第三步:重新创建所有样式片段(拆分原样式为:前半部分+重叠部分+后半部分) const allFragments = []; // 处理受影响的样式,拆分为非重叠部分和重叠部分(重叠部分应用新样式 for (const { style } of affectedStyles) { // 样式在新范围之前的部分(保留原样式) if (style.start < start) { const beforeFragment = { ...style }; beforeFragment.end = Math.min(style.end, start); allFragments.push(beforeFragment); } // 样式与新范围重叠的部分(应用新属性值) const overlapStart = Math.max(style.start, start); const overlapEnd = Math.min(style.end, end); if (overlapStart < overlapEnd) { const overlapFragment = { ...style, [property]: value, // 同时支持textAlign和verticalAlign的父元素对齐 ...(property === 'textAlign' ? { parentAlign: value } : {}), ...(property === 'verticalAlign' ? { parentVerticalAlign: value } : {}) }; overlapFragment.start = overlapStart; overlapFragment.end = overlapEnd; allFragments.push(overlapFragment); } // 样式在新范围之后的部分(保留原样式) if (style.end > end) { const afterFragment = { ...style }; afterFragment.start = Math.max(style.start, end); allFragments.push(afterFragment); } } // 第四步:处理新范围中未被任何现有样式覆盖的空白区域(直接应用新样式) let coveredRanges = allFragments.map(f => ({ start: f.start, end: f.end })); coveredRanges.sort((a, b) => a.start - b.start); let lastCoveredEnd = start; for (const range of coveredRanges) { if (range.start > lastCoveredEnd) { // 添加未被覆盖的部分 const newFragment = { start: lastCoveredEnd, end: range.start, [property]: value }; allFragments.push(newFragment); } lastCoveredEnd = Math.max(lastCoveredEnd, range.end); } // 检查最后一段未覆盖的空白区域 if (lastCoveredEnd < end) { const newFragment = { start: lastCoveredEnd, end: end, [property]: value }; allFragments.push(newFragment); } // 第五步:添加所有不受影响的现有样式和新创建的片段 for (const { style } of affectedStyles) { if (style.end <= start || style.start >= end) { // 添加完全不受影响的样式 styles.push(style); } } // 添加新创建的片段 styles.push(...allFragments); // 合并相邻且样式完全相同的片段(优化渲染性能) this.mergeAdjacentStyles(cellKey); }, /** * 渲染带部分样式的单元格文本 * 将存储的样式片段转换为带<span>标签的HTML,实现局部文本样式 * @param {number} row 行索引 * @param {number} col 列索引 */ renderCellWithPartialStyles(row, col) { const cell = this.hot.getCell(row, col); if (!cell) return; let cellValue = this.hot.getDataAtCell(row, col); // 确保cellValue是字符串(处理数字/undefined等情况) cellValue = cellValue != null ? cellValue.toString() : ''; const cellKey = `${row}-${col}`; const styles = this.partialTextStyles[cellKey] || []; // 如果没有样式,直接显示纯文本 if (styles.length === 0) { cell.textContent = cellValue; return; } // 按起始位置排序样式(确保从左到右渲染) const sortedStyles = [...styles].sort((a, b) => a.start - b.start); // 过滤无效样式(范围超出文本长度或起始>=结束的样式) const validStyles = sortedStyles.filter(style => style.start >= 0 && style.end <= cellValue.length && style.start < style.end ); // 构建带样式的HTML内容 let htmlContent = ''; let lastIndex = 0; validStyles.forEach(style => { // 添加样式片段前的普通文本(未被样式覆盖的部分) if (style.start > lastIndex) { htmlContent += this.escapeHtml(cellValue.substring(lastIndex, style.start)); } // 添加带样式的文本(确保范围正确) if (style.start < cellValue.length) { // 计算安全的文本范围(避免超出字符串长度) const styledText = cellValue.substring( Math.max(style.start, 0), Math.min(style.end, cellValue.length) ); // 构建样式字符串 let styleString = ''; if (style.color) styleString += `color: ${style.color};`; if (style.backgroundColor) styleString += `background-color: ${style.backgroundColor};`; if (style.fontWeight) styleString += `font-weight: ${style.fontWeight};`; if (style.fontStyle) styleString += `font-style: ${style.fontStyle};`; if (style.textDecoration) styleString += `text-decoration: ${style.textDecoration};`; if (style.fontFamily) styleString += `font-family: ${style.fontFamily};`; if (style.fontSize) styleString += `font-size: ${style.fontSize}px;`; if (style.textAlign) { styleString += `text-align: ${style.textAlign};`; // 对于内联元素,需要设置display为inline-block才能应用textAlign styleString += `display: inline-block; width: 100%;`; } if (style.verticalAlign) { styleString += `vertical-align: ${style.verticalAlign};`; styleString += `display: inline-block; height: 100%;`; } // 添加带样式的span标签(使用escapeHtml防止XSS和HTML解析问题) htmlContent += `<span style="${styleString}">${this.escapeHtml(styledText)}</span>`; } // 更新最后处理的索引,避免重复渲染 lastIndex = Math.min(style.end, cellValue.length); }); // 添加剩余未处理的文本(样式片段之后的部分) if (lastIndex < cellValue.length) { htmlContent += this.escapeHtml(cellValue.substring(lastIndex)); } // 更新单元格内容 cell.innerHTML = htmlContent; }, escapeHtml(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; }, setupTextSelectionListener() { // 移除旧的监听器(如果存在) document.removeEventListener('selectionchange', this.handleTextSelection); // 添加新的选择变化监听 document.addEventListener('selectionchange', this.handleTextSelection); console.log('文本选择监听器已启用'); }, handleTextSelection() { const selection = window.getSelection(); const selectedText = selection.toString().trim(); if (selectedText) { // 获取当前激活的单元格 const selectedRange = this.hot.getSelectedLast(); if (!selectedRange) return; const [row, col] = selectedRange; let cellValue = this.hot.getDataAtCell(row, col); // 确保cellValue是字符串 cellValue = cellValue != null ? cellValue.toString() : ''; // 修复:直接获取文本框选择位置而非使用indexOf const activeElement = document.activeElement; if (activeElement && activeElement.tagName === 'TEXTAREA') { const start = activeElement.selectionStart; const end = activeElement.selectionEnd; if (start !== end) { // 确保有实际选择 this.currentTextSelection = { text: selectedText, start: start, end: end, row: row, col: col }; return; } } // 仅在无法获取文本框选择位置时使用indexOf作为回退方案 const start = cellValue.indexOf(selectedText); if (start !== -1) { this.currentTextSelection = { text: selectedText, start: start, end: start + selectedText.length, row: row, col: col }; } } }, initStyles() { const styles = {}; const { data } = this.generateTableFromTableData(); data.forEach((row, rowIndex) => { row.forEach((cell, colIndex) => { styles[`${rowIndex}-${colIndex}`] = { fontFamily: 'Arial', fontSize: '14px', fontWeight: 'normal', fontStyle: 'normal', textDecoration: 'none', textAlign: 'center', verticalAlign: 'middle', backgroundColor: '#ffffff', color: '#000000', padding: '5px', // minWidth: "100px", }; }); }); this.cellStyles = styles; }, initializeHotTable() { const container = this.$refs.hotTable; const self = this; // 初始化样式 this.initStyles(); // 从 tableData 生成表格数据 const { data, columns, nestedHeaders, mergeCells } = this.generateTableFromTableData(); console.log('mergeCells', mergeCells) this.hot = new Handsontable(container, { language: 'zh-CN', data: this.tableList, columns: columns, nestedHeaders: nestedHeaders, width: '100%', autoColumnSize: true, // 自动调整列宽以适应内容 stretchH: 'all', mergeCells: mergeCells, // 动态设置mergeCells // colHeaders: true, // rowHeaders: true, height: 700, licenseKey: 'non-commercial-and-evaluation', contextMenu: { items: { // alignment: true, // 自定义菜单项 custom_insert_above: { name: '插入计算行', callback: function (key, selection) { // 您的自定义插入逻辑 // console.log('自定义上方插入行', key, selection, data); self.$emit('changeRowDialogVisible', true); self.$emit('custom-insert-row', key, selection, data); }.bind(this) }, // 自定义菜单项 custom_insert_itself: { name: '插入单独行', callback: function (key, selection) { // 您的自定义插入逻辑 // console.log('自定义上方插入行', key, selection, JSON.stringify(data)); self.$emit('changeRowDialogVisible', true); // 插入单独行 self.$emit('custom-insert-row', key, selection, data, 'itself'); }.bind(this) }, // 自定义菜单项 custom_insert_column: { name: '插入列', callback: function (key, selection) { // 您的自定义插入逻辑 console.log('自定义上方插入行', key, selection, data); self.$emit('changeRowDialogVisible', true); self.$emit('custom-insert-row', key, selection, data, "column"); }.bind(this) }, } }, // dropdownMenu: true, // multiColumnSorting: true, // filters: true, // manualRowResize: true, // manualColumnResize: true, // autoWrapRow: true, // autoWrapCol: true, afterBeginEditing: () => { // 开始编辑时设置监听 self.flag = false self.rowFlage = true setTimeout(() => this.setupTextSelectionListener(), 100); }, afterSelectionEnd: (r1, c1, r2, c2) => { // 选择结束时如果是单元格选择而非文本选择,清除文本选择信息 if (r1 !== r2 || c1 !== c2) { this.currentTextSelection = { text: '', start: -1, end: -1, row: -1, col: -1 }; } }, // 在 initializeHotTable 方法的 cells 渲染器中更新部分文字样式处理 cells: function (row, col) { const cellMeta = {}; const style = self.cellStyles[`${row}-${col}`]; const cellKey = `${row}-${col}`; const partialStyles = self.partialTextStyles[cellKey] || []; if (style) { cellMeta.renderer = function (instance, td, row, col, prop, value, cellProperties) { // 调用基础渲染器 Handsontable.renderers.TextRenderer.apply(this, arguments); // 应用基本样式 td.style.fontFamily = style.fontFamily || 'Arial'; td.style.fontSize = style.fontSize || '12px'; td.style.fontWeight = style.fontWeight || 'normal'; td.style.fontStyle = style.fontStyle || 'normal'; td.style.textDecoration = style.textDecoration || 'none'; td.style.color = style.color || '#000000'; td.style.backgroundColor = style.backgroundColor || '#ffffff'; td.style.textAlign = style.textAlign || 'center'; td.style.verticalAlign = style.verticalAlign || 'middle'; td.style.padding = style.padding || '5px'; // td.style.minWidth = "50px"; // 在 cells 渲染器中更新部分文字样式处理 if (partialStyles.length > 0 && value != null) { // 确保value是字符串 const stringValue = value.toString(); // 清空单元格内容 td.innerHTML = ''; // 按起始位置排序样式 const sortedStyles = [...partialStyles].sort((a, b) => a.start - b.start); // 验证样式范围 const validStyles = sortedStyles.filter(style => style.start >= 0 && style.end <= stringValue.length && style.start < style.end ); let lastIndex = 0; let newContent = document.createDocumentFragment(); validStyles.forEach(style => { // 添加样式前的文本 if (style.start > lastIndex) { const normalText = document.createTextNode( stringValue.substring(lastIndex, style.start) ); newContent.appendChild(normalText); } // 添加带样式的文本 if (style.start < stringValue.length) { const styledSpan = document.createElement('span'); const styledText = stringValue.substring( Math.max(style.start, 0), Math.min(style.end, stringValue.length) ); // 应用所有部分文字样式 if (style.color) styledSpan.style.color = style.color; if (style.fontWeight) styledSpan.style.fontWeight = style.fontWeight; if (style.fontStyle) styledSpan.style.fontStyle = style.fontStyle; if (style.textDecoration) styledSpan.style.textDecoration = style.textDecoration; if (style.fontFamily) styledSpan.style.fontFamily = style.fontFamily; if (style.fontSize) styledSpan.style.fontSize = `${style.fontSize}px`; if (style.verticalAlign) styledSpan.style.verticalAlign = style.verticalAlign; styledSpan.textContent = styledText; newContent.appendChild(styledSpan); } lastIndex = Math.min(style.end, stringValue.length); }); // 添加剩余文本 if (lastIndex < stringValue.length) { const remainingText = document.createTextNode( stringValue.substring(lastIndex) ); newContent.appendChild(remainingText); } td.appendChild(newContent); } }; } return cellMeta; }, afterSelection: (r1, c1, r2, c2) => { // console.log('r1, c1, r2, c2',r1, c1, r2, c2) this.handleSelection(r1, c1, r2, c2); }, // 设置表头样式类名 headerClassName: 'custom-header', afterChange: (changes, source) => { if (source !== 'loadData' && changes && source !== 'MergeCells') { console.log('2222', source) console.log('afterChange', source, JSON.stringify(changes)) // console.log('this.tableList',this.tableList) this.calculateWudongdeInterval(); // this.$emit('data-change', this.hot.getData()); } } }); }, generateTableFromTableData() { const that = this const { headers, rows, updateTime, remarks } = this.tableData; console.log('this.tableData',this.tableData) if (!headers || !headers.length) return { data: [], columns: [], nestedHeaders: [], mergeCells: [] } // 生成列配置 const columns = headers.length && headers.map((header, index) => ({ data: index, title: header, // type: 'numeric', numericFormat: { pattern: '0,0' } })); // heaert("","") // 生成嵌套表头 - 修正这里让列数匹配 const nestedHeaders = [ [{ label: '时间', colspan: 2 }, ...headers.slice(2).map(header => ({ label: header, colspan: 0 }))], ]; // console.log('consconscons', headers, nestedHeaders) // 生成表格数据(支持相同station行合并) let data = []; const mergeCells = []; let firstOutgoingRowIndex = -1; //记录第一个"出库"行索引 const targetCol = headers.indexOf('昨日实况'); if (this.mergeSameStation) { // 按station分组 const groupedRows = {}; rows.forEach((row, index) => { if (!groupedRows[row.stationName]) { groupedRows[row.stationName] = []; } groupedRows[row.stationName].push({ ...row, originalIndex: index }); }); // 生成合并后的数据 Object.keys(groupedRows).forEach(stationName => { const stationRows = groupedRows[stationName]; stationRows.forEach((row, rowIndex) => { const rowData = []; console.log(' data',data) // 只在第一行显示station名称 if (rowIndex === 0) { rowData.push(row.stationName || ''); // rowData.push(updateTime); // 添加合并单元格配置 if (stationRows.length > 0) { if (row.configName === row.stationName) { mergeCells.push({ row: data.length, col: 0, rowspan: 1, colspan: 2 }); } else { mergeCells.push({ row: data.length, col: 0, rowspan: stationRows.length, colspan: 1 }); } } } else { rowData.push(row.stationName); // 合并行后续行不显示station } rowData.push(row.configName); row.values.forEach(value => { rowData.push(value); }); console.log('rowData',rowData) if (this.active == 2) { if (row && row.configName === '出库' && firstOutgoingRowIndex === -1) { firstOutgoingRowIndex = data.length; } } data.push(rowData); }); }); } else { console.log('3333333333') // 不启用合并的原始逻辑 data = rows.map(row => { const rowData = []; if (row.stationName) { rowData.push(row.stationName); } else { rowData.push(row.stationName); } rowData.push(row.configName); row.values.forEach(value => { rowData.push(value); }); if (this.active == 2) { if (row && row.type && row.configName.toString().trim() === '出库' && firstOutgoingRowIndex === -1) { firstOutgoingRowIndex = index; } } return rowData; }); } if (this.active == 2) { if (firstOutgoingRowIndex !== -1 && targetCol !== -1) { mergeCells.push({ row: firstOutgoingRowIndex, col: targetCol, rowspan: data.length - firstOutgoingRowIndex, colspan: headers.length - targetCol }); // console.log('nestedHeaders', nestedHeaders); // console.log('mergeCells', mergeCells); if (data[firstOutgoingRowIndex]) { data[firstOutgoingRowIndex][targetCol] = remarks; const cellKey = `${firstOutgoingRowIndex}-${targetCol}`; if (!this.cellStyles[cellKey]) { this.cellStyles[cellKey] = { ...this.cellStyles[`0-0`] }; } this.cellStyles[cellKey].textAlign = 'left'; } } } data && data.length && data[0].push(updateTime) if (remarks && this.active === 15) { let newRemarks = remarks const length = data.length; const tzOutRow = rows.find(row => row.configName === '桐子林出库'); const gyOutRow = rows.find(row => row.configName === '观音岩出库'); if (gyOutRow && gyOutRow.values && gyOutRow.values.length > 0) { const gyTodayValue = gyOutRow.values[0]; const gyLaterValues = gyOutRow.values.slice(1,-3).filter(value => value !== null); const gyMaxValue = gyLaterValues.length > 0 ?Math.max(...gyLaterValues):0; const gyMinValue = gyLaterValues.length > 0 ?Math.min(...gyLaterValues):0; newRemarks = remarks.replace('观音岩今日出库预计?', `观音岩今日出库预计${gyTodayValue?? 0}`) .replace('观音岩今日出库预计?立方米每秒', `观音岩今日出库预计${gyTodayValue}立方米每秒`) .replace('预计出库?-?立方米每秒', `预计出库${gyMinValue}-${gyMaxValue}立方米每秒`); } if (tzOutRow && tzOutRow.values && tzOutRow.values.length > 0) { const tzTodayValue = tzOutRow.values[0]; const tzLaterValues = tzOutRow.values.slice(1,-3).filter(value => value !== null); const tzMaxValue = tzLaterValues.length > 0 ?Math.max(...tzLaterValues):0; const tzMinValue = tzLaterValues.length > 0 ?Math.min(...tzLaterValues):0; newRemarks = newRemarks.replace('桐子林今日出库?', `桐子林今日出库${tzTodayValue?? 0}`) .replace('桐子林今日出库?立方米每秒', `桐子林今日出库${tzTodayValue}立方米每秒`) .replace('出库?-?立方米每秒', `出库${tzMinValue}-${tzMaxValue}立方米每秒`); } that.newRemarks = newRemarks for (let i = 0; i < 3; i++) { const newRow = Array(headers.length).fill(''); if (i === 0 && remarks) { newRow[0] = newRemarks; } data.push(newRow); } mergeCells.push({ row: length, col: 0, rowspan: 3, colspan: headers.length }); const startRow = length; const endRow = length + 2; for (let row = startRow; row <= endRow; row++) { for (let col = 0; col < headers.length; col++) { const cellKey = `${row}-${col}`; if (!this.cellStyles[cellKey]) { this.cellStyles[cellKey] = { ...this.cellStyles[`0-0`] }; } this.cellStyles[cellKey].textAlign = 'left'; } } // 更新到数据中 if (firstOutgoingRowIndex !== -1 && targetCol !== -1) { data[firstOutgoingRowIndex][targetCol] = newRemarks; } } this.tableList = data console.log('this.tableList', this.tableList) // return return { data, columns, nestedHeaders, mergeCells: mergeCells }; }, handleSelection(r1, c1, r2, c2) { this.selectedCells = []; const startRow = Math.min(r1, r2); const endRow = Math.max(r1, r2); const startCol = Math.min(c1, c2); const endCol = Math.max(c1, c2); for (let row = startRow; row <= endRow; row++) { for (let col = startCol; col <= endCol; col++) { this.selectedCells.push({ row, col }); } } this.updateToolbarState(); }, // 在methods部分添加乌东德区间计算方法 // 修改乌东德区间计算方法,使其更新所有相关列 calculateWudongdeInterval() { try { // 遍历表格数据,查找相关行 let wudongdeInRowIndex = -1; // 乌东德入库行索引 let guanyinyanOutRowIndex = -1; // 观音岩出库行索引 let tongzilinOutRowIndex = -1; // 桐子林出库行索引 let intervalRowIndex = -1; // 区间行索引 const list =this.tableData.rows for (let i = 0; i < this.tableList.length; i++) { const row = list[i]; // 检查是否存在第二列数据 console.log('row',row) if (row ) { // 查找乌东德入库行 if (row.stationName === '乌东德' && row.configName === '入库') { wudongdeInRowIndex = i; } // 查找观音岩出库行 else if (row.stationName === "乌东德" && row.configName === '观音岩出库') { guanyinyanOutRowIndex = i; } // 查找桐子林出库行 else if (row.stationName === "乌东德" && row.configName === '桐子林出库') { tongzilinOutRowIndex = i; } // 查找乌东德区间行 else if (row.stationName === "乌东德" && row.configName === '区间') { intervalRowIndex = i; } } } console.log('intervalValue', wudongdeInRowIndex, guanyinyanOutRowIndex, tongzilinOutRowIndex, intervalRowIndex) // 检查是否找到了所有需要的行 if (wudongdeInRowIndex !== -1 && guanyinyanOutRowIndex !== -1 && tongzilinOutRowIndex !== -1 && intervalRowIndex !== -1) { // 获取区间行和各源数据行的长度,确保有足够的列可以处理 const intervalRowLength = this.tableList[intervalRowIndex]?.length || null; const wudongdeInRowLength = this.tableList[wudongdeInRowIndex]?.length || null; const guanyinyanOutRowLength = this.tableList[guanyinyanOutRowIndex]?.length || null; const tongzilinOutRowLength = this.tableList[tongzilinOutRowIndex]?.length || null; // 确定需要处理的最大列数(从第3列开始,索引为2) const maxColumnIndex = Math.min(intervalRowLength, wudongdeInRowLength, guanyinyanOutRowLength, tongzilinOutRowLength); // 遍历所有相关列(从索引2开始,对应第3列及以后的列) for (let colIndex = 2; colIndex < maxColumnIndex; colIndex++) { // 获取当前列的相关数据值 const wudongdeInValue = parseFloat(this.tableList[wudongdeInRowIndex][colIndex]) || null; const guanyinyanOutValue = parseFloat(this.tableList[guanyinyanOutRowIndex][colIndex]) || null; const tongzilinOutValue = parseFloat(this.tableList[tongzilinOutRowIndex][colIndex]) || null; // 计算区间值:区间 = 入库 - 观音岩出库 - 桐子林出库 const intervalValue = (guanyinyanOutValue === null && tongzilinOutValue === null && wudongdeInValue === null) ? null : wudongdeInValue - guanyinyanOutValue - tongzilinOutValue; // 更新区间单元格值 if (this.tableList[intervalRowIndex] && colIndex < this.tableList[intervalRowIndex].length) { this.tableList[intervalRowIndex][colIndex] = intervalValue; } } // 触发表格重新渲染 if (this.hot) { this.hot.render(); } console.log('乌东德区间所有列计算完成'); } else { console.warn('未找到计算乌东德区间所需的所有行'); } } catch (error) { console.error('计算乌东德区间时出错:', error); } }, updateToolbarState() { // console.log('this.selectedCells',JSON.stringify(this.tableList),JSON.stringify(this.selectedCells)) if (this.selectedCells.length === 0) return; this.flag = true this.rowFlage = false const firstCell = this.selectedCells[0]; const style = this.cellStyles[`${firstCell.row}-${firstCell.col}`] || {}; this.fontFamily = style.fontFamily || 'Arial'; this.fontSize = parseInt(style.fontSize || '12'); this.fontWeight = style.fontWeight || 'normal'; this.fontStyle = style.fontStyle || 'normal'; this.textDecoration = style.textDecoration || 'none'; this.color = style.color || '#000000'; this.backgroundColor = style.backgroundColor || '#ffffff'; this.textAlign = style.textAlign || 'left'; this.verticalAlign = style.verticalAlign || 'middle'; }, // 添加键盘事件监听 addKeyboardListeners() { this.keyboardHandler = (event) => { // 在键盘事件监听器中添加错误处理 try { if (event.ctrlKey || event.metaKey) { if (event.key === 'z' && !event.shiftKey) { event.preventDefault(); this.undo(); } else if ((event.key === 'z' && event.shiftKey) || event.key === 'y') { event.preventDefault(); this.redo(); } } } catch (error) { console.warn('Keyboard event error:', error); // 静默处理扩展相关的错误 } }; document.addEventListener('keydown', this.keyboardHandler); }, removeKeyboardListeners() { document.removeEventListener('keydown', this.keyboardHandler); }, // 保存当前状态到历史记录 saveToHistory() { // 移除当前索引之后的历史记录 if (this.historyIndex < this.historyStack.length - 1) { this.historyStack = this.historyStack.slice(0, this.historyIndex + 1); } // 添加新状态(同时保存 cellStyles 和 partialTextStyles) this.historyStack.push({ cellStyles: JSON.parse(JSON.stringify(this.cellStyles)), partialTextStyles: JSON.parse(JSON.stringify(this.partialTextStyles)) }); // 限制历史记录大小 if (this.historyStack.length > this.maxHistorySize) { this.historyStack.shift(); } // console.log('this.historyStack.this.historyStack.222222222222', this.historyStack.length) this.historyIndex = this.historyStack.length; }, // 撤销 undo() { console.log('this.historyIndex', this.historyIndex) if (this.historyIndex > 0) { this.historyIndex--; const historyState = this.historyStack[this.historyIndex]; console.log('historyState', historyState.cellStyles) // 只恢复当前历史状态对应的样式,不同时恢复两种样式 if (historyState.cellStyles) { this.cellStyles = JSON.parse(JSON.stringify(historyState.cellStyles)); } if (historyState.partialTextStyles) { this.partialTextStyles = JSON.parse(JSON.stringify(historyState.partialTextStyles)); } this.hot.render(); } }, // 重做 redo() { // console.log('this.historyIndex < this.historyStack.length', this.historyStack) if (this.historyIndex < this.historyStack.length - 1) { this.historyIndex++; const historyState = this.historyStack[this.historyIndex]; // 只恢复当前历史状态对应的样式,不同时恢复两种样式 if (historyState.cellStyles) { this.cellStyles = JSON.parse(JSON.stringify(historyState.cellStyles)); } if (historyState.partialTextStyles) { this.partialTextStyles = JSON.parse(JSON.stringify(historyState.partialTextStyles)); } this.hot.render(); } }, applyStyle(property) { // 保存当前状态到历史记录 this.saveToHistory(); const newStyles = { ...this.cellStyles }; this.selectedCells.forEach(({ row, col }) => { const key = `${row}-${col}`; if (!newStyles[key]) { newStyles[key] = {}; } switch (property) { case 'fontFamily': newStyles[key].fontFamily = this.fontFamily; break; case 'fontSize': newStyles[key].fontSize = `${this.fontSize}px`; break; case 'fontWeight': newStyles[key].fontWeight = this.fontWeight; break; case 'fontStyle': newStyles[key].fontStyle = this.fontStyle; break; case 'textDecoration': newStyles[key].textDecoration = this.textDecoration; break; case 'color': newStyles[key].color = this.color; break; case 'backgroundColor': newStyles[key].backgroundColor = this.backgroundColor; break; case 'textAlign': newStyles[key].textAlign = this.textAlign; break; case 'verticalAlign': newStyles[key].verticalAlign = this.verticalAlign; break; } }); this.cellStyles = newStyles; this.hot.render(); // 强制重新渲染 }, toggleBold() { this.fontWeight = this.fontWeight === 'bold' ? 'normal' : 'bold'; this.applyStyle('fontWeight'); }, toggleItalic() { this.fontStyle = this.fontStyle === 'italic' ? 'normal' : 'italic'; this.applyStyle('fontStyle'); }, toggleUnderline() { this.textDecoration = this.textDecoration === 'underline' ? 'none' : 'underline'; this.applyStyle('textDecoration'); }, exportToExcel(getFileName) { const that = this const data = this.hot.getData(); // 获取表头信息 const headers = this.tableData.headers || this.initialData[0]; // 获取合并单元格信息 const mergeCells = this.hot.getPlugin('mergeCells').mergedCellsCollection.mergedCells; // 获取嵌套表头信息 const nestedHeaders = this.hot.getSettings().nestedHeaders; // console.log('nestedHeaders', mergeCells, headers, nestedHeaders); exportToExcel(data, this.cellStyles, headers, mergeCells, nestedHeaders, getFileName); } }, watch: { initialData: { handler(newVal) { if (this.hot) { this.hot.loadData(JSON.parse(JSON.stringify(newVal))); // this.initStyles(); } }, deep: true } } }; </script> <style scoped> .hot-table-wps { font-family: Arial, sans-serif; display: flex; flex-direction: column; /* height: 100vh; */ } /* 自定义表头样式 */ :deep(.custom-header) { height: 32px; /* 可与 columnHeaderHeight 保持一致 */ line-height: 32px; /* 垂直居中 */ background-color: #47a8c8; /* 自定义背景色 */ font-size: 16px; color: #fff; /* 自定义字体大小 */ } .toolbar { display: flex; gap: 8px; padding: 8px; background: #f0f0f0; border-bottom: 1px solid #ddd; flex-wrap: wrap; align-items: center; } .toolbar select, .toolbar input, .toolbar button { padding: 4px 8px; border: 1px solid #ccc; border-radius: 4px; background: white; height: 30px; } .toolbar button { min-width: 30px; cursor: pointer; } .toolbar button.active { background: #e0e0e0; } .color-picker { display: flex; align-items: center; gap: 4px; } .color-picker label { font-size: 12px; } .color-picker input[type="color"] { width: 30px; height: 30px; padding: 0; border: 1px solid #ccc; } .export-btn { background: #4CAF50 !important; color: white; border: none; padding: 6px 12px; margin-left: auto; } .export-btn:hover { background: #45a049; } .hot-table-container { width: 100%; height: 400px; /* overflow: hidden; */ } </style> 代码可以实现双击后选中部分文本修改背景色功能,但是有一个bug,当修改后如果再选中一段文本,点击修改文本颜色或者背景色,之前的背景色修改会消失
09-18
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值