CommonTree.vue 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831
  1. <template>
  2. <div
  3. class="common_tree_wrapper common_tree"
  4. v-loading="loading"
  5. element-loading-text="加载中……"
  6. element-loading-spinner="el-icon-loading"
  7. element-loading-background="#ffffff"
  8. >
  9. <el-tree
  10. ref="commonTree"
  11. class="tree_container"
  12. :data="treeData"
  13. :props="treeProps"
  14. :node-key="nodeKey"
  15. :show-checkbox="showCheckbox"
  16. :highlight-current="highlightCurrent"
  17. :default-expand-all="defaultExpandAll"
  18. :default-expanded-keys="defaultExpandedKeys"
  19. :current-node-key="currentNodeKey"
  20. :expand-on-click-node="expandOnClickNode"
  21. :check-strictly="true"
  22. @check="handleNodeCheck"
  23. @node-click="handleNodeClick"
  24. >
  25. <!--
  26. 自定义树节点内容插槽(slot-scope)
  27. - node: 当前节点的完整数据对象(label、children、data 等)
  28. - data: 当前节点绑定的用户数据(即 treeData 中的每一项)
  29. -->
  30. <span class="custom_tree_node" slot-scope="{ node, data }">
  31. <!-- 节点名称区域 -->
  32. <span class="node_label" :title="data[treeProps.label]">
  33. <!-- 名称文本单独包裹一层,确保省略号只作用于真正承载文字的元素 -->
  34. <span class="node_label_text">{{ data[treeProps.label] }}</span>
  35. <!-- 右侧计数(可选,数据存在 count 字段时显示) -->
  36. <span
  37. class="node_count"
  38. v-if="data.count !== undefined && data.count !== null"
  39. >
  40. ({{ data.count }})
  41. </span>
  42. </span>
  43. <!--
  44. AI 讲解按钮(Element UI 文字按钮 type="text")
  45. - 默认隐藏(opacity: 0)
  46. - 通过父级 hover 显示
  47. - @click.stop 阻止冒泡,避免触发树节点点击
  48. -->
  49. <!-- <el-button
  50. class="ai_explain_btn"
  51. type="text"
  52. size="mini"
  53. icon=""
  54. @click.stop="HandleAiExplain(node, data)"
  55. >
  56. <img
  57. src="@/assets/knowledgeGraph/aiIcon.png"
  58. alt="AI讲解"
  59. style="width: 16px; height: 16px"
  60. />
  61. <span>AI讲解</span>
  62. </el-button> -->
  63. </span>
  64. </el-tree>
  65. </div>
  66. </template>
  67. <script>
  68. /**
  69. * CommonTree 公共树形组件
  70. *
  71. * 功能说明:
  72. * 1. 基于 Element UI el-tree 二次封装,支持数据传入渲染
  73. * 2. 支持多选复选框
  74. * 3. 节点勾选回传(仅发送勾选节点的 key 数组,如 knowledgeId 列表)
  75. * 4. 鼠标悬停节点行显示 "AI讲解" 按钮,点击打印并回传当前节点
  76. *
  77. * 父组件使用示例:
  78. * <CommonTree
  79. * :tree-data="knowledgeTreeData"
  80. * :tree-props="{ label: 'knowledgeName', children: 'children' }"
  81. * node-key="knowledgeId"
  82. * show-checkbox
  83. * @nodeCheck="handleTreeCheck"
  84. * @ai-explain="handleAiExplain"
  85. * />
  86. *
  87. * 父组件 handleTreeCheck 接收参数:
  88. * - checkedKeys: Array — 所有勾选节点的 key 数组(勾选父节点时包含其全部子节点 key)
  89. */
  90. export default {
  91. name: "CommonTree",
  92. // ==================== Props 配置(由父组件传入) ====================
  93. props: {
  94. /**
  95. * 树形数据源
  96. * 数据结构示例:
  97. * [
  98. * { id: 1, label: '节点1', count: 10, children: [
  99. * { id: 11, label: '子节点1', count: 5 }
  100. * ]}
  101. * ]
  102. */
  103. treeData: {
  104. type: Array,
  105. default: () => [],
  106. },
  107. /**
  108. * el-tree 的 props 配置,用于映射数据结构的字段
  109. * 默认:{ label: 'label', children: 'children' }
  110. * 如使用其他字段名,可传:{ label: 'knowledgeName', children: 'childList' }
  111. */
  112. treeProps: {
  113. type: Object,
  114. default: () => ({
  115. label: "label",
  116. children: "children",
  117. }),
  118. },
  119. /**
  120. * 节点唯一标识字段名
  121. * 用于选中、高亮、展开等操作的定位
  122. */
  123. nodeKey: {
  124. type: String,
  125. default: "id",
  126. },
  127. /** 是否显示多选复选框 */
  128. showCheckbox: {
  129. type: Boolean,
  130. default: false,
  131. },
  132. /** 是否高亮当前点击的节点 */
  133. highlightCurrent: {
  134. type: Boolean,
  135. default: true,
  136. },
  137. /** 是否默认展开所有节点 */
  138. defaultExpandAll: {
  139. type: Boolean,
  140. default: true,
  141. },
  142. /**
  143. * 默认展开的节点 key 数组
  144. * 与 defaultExpandAll 互斥使用
  145. */
  146. defaultExpandedKeys: {
  147. type: Array,
  148. default: () => [],
  149. },
  150. /** 当前高亮选中的节点 key */
  151. currentNodeKey: {
  152. type: [String, Number],
  153. default: "",
  154. },
  155. /**
  156. * 点击节点时是否展开/收起
  157. * false:只有点击小箭头才展开;点击节点文字只触发选中
  158. * true:点击节点文字也会触发展开/收起
  159. */
  160. expandOnClickNode: {
  161. type: Boolean,
  162. default: false,
  163. },
  164. /** 树数据加载中(父组件请求期间传 true) */
  165. loading: {
  166. type: Boolean,
  167. default: false,
  168. },
  169. },
  170. inject: ["openAiRobot"], //注入Ai机器人组件调用方法
  171. // ==================== 数据状态 ====================
  172. data() {
  173. return {
  174. // 用户直接勾选的节点 key 列表(不含级联自动勾选的祖先/后代)
  175. manuallyCheckedKeys: [],
  176. // 防止程序化设置勾选状态时递归触发 @check 事件
  177. _isUpdating: false,
  178. };
  179. },
  180. computed: {
  181. subjectName()
  182. {
  183. return this.$store.state.target.subjectName;
  184. }
  185. },
  186. // ==================== 生命周期 ====================
  187. mounted() {
  188. if (Array.isArray(this.treeData) && this.treeData.length > 0) {
  189. this.scheduleDefaultFirstNode(this.treeData);
  190. }
  191. },
  192. beforeDestroy() {
  193. clearTimeout(this._defaultSelectTimer);
  194. },
  195. // ==================== Watch ====================
  196. watch: {
  197. treeData: {
  198. handler(newVal) {
  199. // 数据切换(换人/换科目/重新筛选)时,清空旧勾选并重新默认勾选+展开第一个节点
  200. this.manuallyCheckedKeys = [];
  201. if (!Array.isArray(newVal) || newVal.length === 0) {
  202. this.clearChecked();
  203. return;
  204. }
  205. this.scheduleDefaultFirstNode(newVal);
  206. },
  207. },
  208. },
  209. // ==================== 方法 ====================
  210. methods: {
  211. /**
  212. * 点击节点名称时切换勾选状态
  213. * 如果节点已被置灰(disabled),则不执行任何操作
  214. * @param {Object} data - 当前点击的节点数据
  215. */
  216. handleNodeClick(data) {
  217. if (!this.showCheckbox) return;
  218. const treeRef = this.$refs.commonTree;
  219. if (!treeRef) return;
  220. const node = treeRef.getNode(data[this.nodeKey]);
  221. if (!node || node.disabled) return;
  222. // 切换勾选状态:已勾选 → 取消,未勾选 → 勾选
  223. const newChecked = !node.checked;
  224. treeRef.setChecked(data[this.nodeKey], newChecked);
  225. // 手动触发 handleNodeCheck(check-strictly 模式下 setChecked 不触发 @check)
  226. const checkedInfo = {
  227. checkedKeys: newChecked
  228. ? [...this.manuallyCheckedKeys, data[this.nodeKey]]
  229. : this.manuallyCheckedKeys.filter((k) => k !== data[this.nodeKey]),
  230. };
  231. this.handleNodeCheck(data, checkedInfo);
  232. },
  233. /**
  234. * 树节点勾选事件(自定义级联逻辑)
  235. *
  236. * 核心规则:
  237. * - 同级节点可多选,只传递用户直接勾选的节点 knowledgeId
  238. * - 勾选某节点时:其所有祖先和后代自动勾选并置灰(禁止操作),只传递当前节点
  239. * - 取消勾选时:其所有祖先和后代恢复可操作状态
  240. * - 已被置灰(因上下级被勾选)的节点禁止勾选/取消
  241. *
  242. * @param {Object} data - 当前勾选/取消的节点数据
  243. * @param {Object} checkedInfo - 勾选状态详情
  244. */
  245. handleNodeCheck(data, checkedInfo) {
  246. // 程序化设置状态时跳过,防止递归
  247. if (this._isUpdating) return;
  248. const treeRef = this.$refs.commonTree;
  249. if (!treeRef) return;
  250. const key = data[this.nodeKey];
  251. const isChecked = checkedInfo.checkedKeys.includes(key);
  252. if (isChecked) {
  253. // 用户勾选了该节点 → 加入手动勾选列表
  254. if (!this.manuallyCheckedKeys.includes(key)) {
  255. this.manuallyCheckedKeys.push(key);
  256. }
  257. } else {
  258. // 用户取消勾选 → 从手动勾选列表移除
  259. const index = this.manuallyCheckedKeys.indexOf(key);
  260. if (index > -1) {
  261. this.manuallyCheckedKeys.splice(index, 1);
  262. }
  263. }
  264. // 重新应用所有勾选/置灰状态
  265. this._isUpdating = true;
  266. this.reapplyCheckStates();
  267. this.$nextTick(() => {
  268. this._isUpdating = false;
  269. });
  270. // 只传递用户直接勾选的节点 key
  271. this.$emit("nodeCheck", [...this.manuallyCheckedKeys]);
  272. },
  273. /**
  274. * 重新应用所有勾选和置灰状态
  275. * 策略:先全部清空,再根据 manuallyCheckedKeys 逐个应用
  276. * 注意:node.disabled 是只读计算属性,需通过 this.$set(node.data, 'disabled', ...) 设置
  277. */
  278. reapplyCheckStates() {
  279. const treeRef = this.$refs.commonTree;
  280. if (!treeRef) return;
  281. // 1. 清空所有节点的 checked 和 disabled 状态
  282. this.clearAllCheckAndDisabled();
  283. // 2. 遍历每个手动勾选的节点,设置其祖先和后代的勾选+置灰
  284. this.manuallyCheckedKeys.forEach((key) => {
  285. const node = treeRef.getNode(key);
  286. if (!node) return;
  287. // 当前节点:勾选,不置灰(用户可取消勾选)
  288. node.checked = true;
  289. this.$set(node.data, "disabled", false);
  290. // 祖先节点:勾选 + 置灰,并展开(确保勾选节点可见)
  291. let parent = node.parent;
  292. while (parent && parent.level > 0) {
  293. parent.checked = true;
  294. this.$set(parent.data, "disabled", true);
  295. parent.expanded = true;
  296. parent = parent.parent;
  297. }
  298. // 后代节点:勾选 + 置灰
  299. const checkDescendants = (n) => {
  300. if (n.childNodes && n.childNodes.length > 0) {
  301. n.childNodes.forEach((child) => {
  302. child.checked = true;
  303. this.$set(child.data, "disabled", true);
  304. checkDescendants(child);
  305. });
  306. }
  307. };
  308. checkDescendants(node);
  309. });
  310. },
  311. /**
  312. * 清空整棵树的 checked 和 disabled 状态
  313. * 注意:node.disabled 是只读计算属性,需通过 this.$set(node.data, 'disabled', false) 清除
  314. */
  315. clearAllCheckAndDisabled() {
  316. const treeRef = this.$refs.commonTree;
  317. if (!treeRef || !treeRef.store || !treeRef.store.root) return;
  318. const clearNode = (node) => {
  319. node.checked = false;
  320. if (node.data) {
  321. this.$set(node.data, "disabled", false);
  322. }
  323. if (node.childNodes && node.childNodes.length > 0) {
  324. node.childNodes.forEach((child) => clearNode(child));
  325. }
  326. };
  327. clearNode(treeRef.store.root);
  328. },
  329. /**
  330. * AI 讲解按钮点击事件
  331. * 1. 打印当前节点数据(方便调试)
  332. * 2. 通过 $emit 回传给父组件处理
  333. *
  334. * @param {Object} node - el-tree 节点对象
  335. * @param {Object} data - 当前节点绑定的用户数据
  336. */
  337. HandleAiExplain(node, data) {
  338. let content =
  339. "科目:" +
  340. this.subjectName +
  341. ",知识点:" +
  342. data.knowledgeName;
  343. this.openAiRobot(content);
  344. },
  345. /**
  346. * 对外暴露方法:获取所有勾选的节点(仅多选模式 showCheckbox=true 有效)
  347. * @param {Boolean} leafOnly - 是否只返回叶子节点
  348. * @returns {Array} 选中的节点对象数组
  349. */
  350. getCheckedNodes(leafOnly = false) {
  351. if (!this.$refs.commonTree) return [];
  352. return this.$refs.commonTree.getCheckedNodes(leafOnly);
  353. },
  354. /**
  355. * 对外暴露方法:获取所有勾选节点的 key 数组
  356. * 新逻辑:只返回用户直接勾选的节点 key
  357. * @param {Boolean} leafOnly - 保留兼容参数
  358. * @returns {Array} 手动勾选的节点 key 数组
  359. */
  360. getCheckedKeys(leafOnly = false) {
  361. return [...this.manuallyCheckedKeys];
  362. },
  363. /**
  364. * 对外暴露方法:通过 keys 设置哪些节点被选中
  365. * 新逻辑:keys 即手动勾选的节点列表
  366. * @param {Array} keys - 要选中的节点 key 数组
  367. * @param {Boolean} leafOnly - 是否只设置叶子节点(保留兼容,新逻辑中忽略)
  368. */
  369. setCheckedKeys(keys, leafOnly = false) {
  370. if (!this.$refs.commonTree) return;
  371. this.manuallyCheckedKeys = Array.isArray(keys) ? [...keys] : [];
  372. this._isUpdating = true;
  373. this.reapplyCheckStates();
  374. this.$nextTick(() => {
  375. this._isUpdating = false;
  376. });
  377. },
  378. /**
  379. * 对外暴露方法:清空所有选中节点
  380. */
  381. clearChecked() {
  382. if (!this.$refs.commonTree) return;
  383. this.manuallyCheckedKeys = [];
  384. this._isUpdating = true;
  385. this.clearAllCheckAndDisabled();
  386. this.$nextTick(() => {
  387. this._isUpdating = false;
  388. });
  389. },
  390. /**
  391. * 对外暴露方法:设置当前高亮的节点
  392. * @param {String|Number} key - 节点 key
  393. */
  394. setCurrentKey(key) {
  395. if (!this.$refs.commonTree) return;
  396. this.$refs.commonTree.setCurrentKey(key);
  397. },
  398. /**
  399. * 延迟执行默认勾选/展开,确保 el-tree 内部节点已渲染完成
  400. * @param {Array} data - 树形数据
  401. */
  402. scheduleDefaultFirstNode(data) {
  403. clearTimeout(this._defaultSelectTimer);
  404. this._defaultSelectTimer = setTimeout(() => {
  405. this.$nextTick(() => {
  406. this.applyDefaultFirstNode(data);
  407. });
  408. }, 100);
  409. },
  410. /**
  411. * 默认勾选并展开第一个一级节点
  412. * - 无论树只有 1 个节点还是多个节点,都勾选第一个顶层节点
  413. * - 展开该节点下全部子孙节点
  414. * @param {Array} data - 树形数据
  415. * @param {Number} retry - 内部重试次数(树尚未渲染完时)
  416. */
  417. applyDefaultFirstNode(data, retry = 0) {
  418. if (!data || data.length === 0) return;
  419. const treeRef = this.$refs.commonTree;
  420. const childNodes =
  421. treeRef && treeRef.store && treeRef.store.root
  422. ? treeRef.store.root.childNodes
  423. : null;
  424. // 树尚未渲染出节点时短暂重试,避免切换数据后漏勾选/漏展开
  425. if (!childNodes || childNodes.length === 0) {
  426. if (retry < 8) {
  427. clearTimeout(this._defaultSelectTimer);
  428. this._defaultSelectTimer = setTimeout(() => {
  429. this.applyDefaultFirstNode(data, retry + 1);
  430. }, 50);
  431. }
  432. return;
  433. }
  434. const firstNodeInstance = childNodes[0];
  435. if (!firstNodeInstance) return;
  436. const firstKey =
  437. firstNodeInstance.key != null
  438. ? firstNodeInstance.key
  439. : firstNodeInstance.data && firstNodeInstance.data[this.nodeKey];
  440. if (firstKey == null || firstKey === "") return;
  441. // 1. 默认勾选第一个节点(含「只有一个节点」的场景)
  442. if (this.showCheckbox) {
  443. this.manuallyCheckedKeys = [firstKey];
  444. this._isUpdating = true;
  445. this.reapplyCheckStates();
  446. this.$nextTick(() => {
  447. this._isUpdating = false;
  448. });
  449. }
  450. // 2. 默认高亮选中第一个节点
  451. treeRef.setCurrentKey(firstKey);
  452. // 3. 默认展开第一个节点及其全部子节点(所有层级;单节点叶子也设 expanded)
  453. const expandAllChildren = (node) => {
  454. if (!node) return;
  455. node.expanded = true;
  456. const subs = node.childNodes;
  457. if (Array.isArray(subs) && subs.length > 0) {
  458. subs.forEach((s) => expandAllChildren(s));
  459. }
  460. };
  461. expandAllChildren(firstNodeInstance);
  462. // 4. 回传手动勾选的节点 key(即第一个节点本身)
  463. if (this.showCheckbox) {
  464. this.$emit("nodeCheck", [...this.manuallyCheckedKeys]);
  465. }
  466. },
  467. },
  468. };
  469. </script>
  470. <!--
  471. ==========================================================================
  472. 样式分两块:
  473. 1. 下面这个不带 scoped 的 style 块:专门覆盖 Element UI 原生类名(连接线、padding、间距、选中态)
  474. - scoped + :deep 在 el-tree 深层级嵌套下经常不生效,改用非 scoped 搭配组件唯一根类
  475. .common_tree_wrapper 做命名空间隔离,不会污染全局其他 el-tree
  476. 2. 后面带 scoped 的 style 块:放自定义节点内容(custom_tree_node / node_label / ai_explain_btn 等)
  477. ==========================================================================
  478. -->
  479. <style lang="scss">
  480. /* ============== 可配置常量 ============== */
  481. $line_color: #dcdfe6; // 连接线颜色(虚线)
  482. $line_style: dashed; // 连接线样式
  483. $line_width: 1px; // 连接线宽度
  484. $indent_size: 35px; // 每层缩进
  485. $node_height: 36px; // 单行节点高度
  486. $node_padding_l: 15px; // 节点左侧 padding (用户要求:15px)
  487. $node_gap: 10px; // 节点上下间距 (用户要求:10px)
  488. $label_color: #333333; // 节点文字 (用户要求)
  489. $label_active: #2e64fa; // 选中节点文字 (用户要求)
  490. $label_fs: 14px; // 文字字号 (用户要求)
  491. $label_fw: 400; // 文字字重 (用户要求)
  492. $btn_color: #4f3eff; // AI讲解按钮颜色 (用户要求)
  493. /* =========================================================
  494. 命名空间:.common_tree_wrapper(只影响本组件)
  495. ========================================================= */
  496. .common_tree_wrapper {
  497. width: 100%;
  498. height: 100%;
  499. box-sizing: border-box;
  500. padding: 8px 0;
  501. overflow-x: auto;
  502. overflow-y: auto;
  503. /* ===================== 基础 ===================== */
  504. .tree_container,
  505. .el-tree {
  506. width: 100%;
  507. min-width: max-content; /* 整棵树按最宽节点内容撑开,所有节点行宽度一致 */
  508. background: transparent;
  509. }
  510. /* 允许每个节点内容宽度超过容器,从而触发父容器横向滚动条 */
  511. .el-tree-node__content {
  512. padding-left: 0 !important;
  513. padding-right: 0px;
  514. height: $node_height;
  515. line-height: $node_height;
  516. background: transparent;
  517. border-radius: 4px;
  518. margin-top: 0;
  519. margin-bottom: 0;
  520. font-weight: $label_fw;
  521. font-size: $label_fs;
  522. color: $label_color;
  523. width: 100%; /* 所有节点行跟随树宽度,保持一致 */
  524. overflow: visible; /* 取消默认内容裁剪 */
  525. &:hover {
  526. background-color: #f0f7ff;
  527. }
  528. }
  529. /* 第一级也统一 15px */
  530. .el-tree > .el-tree-node > .el-tree-node__content {
  531. padding-left: $node_padding_l !important;
  532. }
  533. /* ===================== 自定义展开收起图标样式 ===================== */
  534. .el-tree-node__expand-icon {
  535. width: 16px;
  536. height: 16px;
  537. display: flex !important;
  538. align-items: center !important;
  539. justify-content: center !important;
  540. cursor: pointer;
  541. margin-right: 8px;
  542. padding: 0 !important;
  543. border: none !important;
  544. /* 去掉Element UI默认的transform旋转过渡动画,直接切换图标 */
  545. transform: none !important;
  546. transition: none !important;
  547. }
  548. /* 去掉展开状态的transform旋转 */
  549. .el-tree-node__expand-icon.expanded {
  550. transform: none !important;
  551. -webkit-transform: none !important;
  552. }
  553. /* 隐藏Element UI默认的箭头伪元素 */
  554. .el-tree-node__expand-icon::before {
  555. display: none !important;
  556. }
  557. /* 收起状态:显示加号 icon_jia */
  558. .el-tree-node__expand-icon:not(.expanded):not(.is-leaf)::after {
  559. content: "\e64e";
  560. font-family: "iconfont" !important;
  561. font-size: 16px;
  562. color: #e0e3e9;
  563. display: inline-block;
  564. width: 16px;
  565. height: 16px;
  566. line-height: 16px;
  567. text-align: center;
  568. }
  569. /* 展开状态:显示减号 icon_jian */
  570. .el-tree-node__expand-icon.expanded:not(.is-leaf)::after {
  571. content: "\e64f";
  572. font-family: "iconfont" !important;
  573. font-size: 16px;
  574. color: #2e64fa;
  575. display: inline-block;
  576. width: 16px;
  577. height: 16px;
  578. line-height: 16px;
  579. text-align: center;
  580. }
  581. /* 叶子节点的占位符,保持对齐一致 */
  582. .el-tree-node__expand-icon.is-leaf {
  583. width: 0 !important;
  584. height: 0 !important;
  585. display: inline-block !important;
  586. cursor: default;
  587. visibility: hidden;
  588. margin-right: 8px;
  589. padding: 0 !important;
  590. }
  591. /* ===================== 虚线连接线(核心) ===================== */
  592. /* 1. 竖虚线:画在 children 容器的左侧,贯穿所有子节点 */
  593. .el-tree-node .el-tree-node__children {
  594. overflow: visible;
  595. position: relative;
  596. margin-left: 0;
  597. padding-left: 20px; // 层级缩进
  598. &::before {
  599. content: "";
  600. position: absolute;
  601. top: 0;
  602. bottom: $node_gap + $node_height / 2; // 末端停在最后一个节点"半高"处,和横线接上
  603. left: 18px; // 竖线 x 坐标
  604. width: $line_width;
  605. border-left: $line_width $line_style $line_color;
  606. }
  607. }
  608. /* 2. 横虚线:画在每个节点行的左侧,从竖线方向延伸到文字左边 */
  609. .el-tree-node {
  610. position: relative;
  611. > .el-tree-node__content::before {
  612. content: "";
  613. position: absolute;
  614. top: 50%;
  615. left: -($indent_size - 18px); // 起点对齐竖线
  616. width: $indent_size - 20px; // 横线长度
  617. height: 0;
  618. border-top: $line_width $line_style $line_color;
  619. pointer-events: none;
  620. }
  621. }
  622. /* 3. 第一级节点不画横线 */
  623. .el-tree > .el-tree-node > .el-tree-node__content::before {
  624. display: none;
  625. }
  626. /* 4. 最后一个子节点:竖线在横线之下的部分用白色盖掉,形成"L"拐角 */
  627. .el-tree-node > .el-tree-node__children > .el-tree-node:last-child {
  628. > .el-tree-node__content::after {
  629. content: "";
  630. position: absolute;
  631. top: 50%;
  632. bottom: -$node_gap;
  633. left: 18px - $indent_size; // 与竖线同 x
  634. width: $line_width + 2px;
  635. background: #fff; // 白色遮挡(面板必须是白色背景)
  636. z-index: 1;
  637. }
  638. }
  639. /* ===================== 选中 & 复选框 ===================== */
  640. .el-tree-node.is-current > .el-tree-node__content {
  641. background-color: #e6f0ff;
  642. color: $label_active; // 让文字内容(含原生 el-tree label 模式)也变色
  643. /* 额外命中自定义 slot 中的 .node_label,防止 scoped 里写的 #333 覆盖掉选中色 */
  644. .node_label {
  645. color: $label_active !important;
  646. font-weight: $label_fw;
  647. }
  648. }
  649. .el-checkbox {
  650. margin-right: 6px;
  651. }
  652. /* ===================== AI 讲解按钮(Element UI 文字按钮) =====================
  653. 字体 400 / 14 / #4F3EFF;opacity 0 默认隐藏,hover 节点行显示
  654. */
  655. .ai_explain_btn.el-button--text {
  656. display: inline-flex !important;
  657. align-items: center;
  658. justify-content: center;
  659. gap: 4px;
  660. padding: 4px 6px !important;
  661. height: auto !important;
  662. min-height: 24px;
  663. margin-left: 8px;
  664. opacity: 0;
  665. transition: opacity 0.2s ease;
  666. white-space: nowrap;
  667. flex-shrink: 0;
  668. position: relative;
  669. z-index: 3;
  670. font-weight: 400; // 用户要求
  671. font-size: 14px; // 用户要求
  672. color: $btn_color !important; // 用户要求:#4F3EFF
  673. // 确保内部img和span都居中对齐
  674. img {
  675. display: inline-block;
  676. vertical-align: middle;
  677. flex-shrink: 0;
  678. margin-right: 4px;
  679. }
  680. span {
  681. display: inline-block;
  682. vertical-align: middle;
  683. line-height: 1;
  684. }
  685. }
  686. .ai_explain_btn.el-button--text:hover {
  687. color: darken($btn_color, 10%) !important;
  688. }
  689. .ai_explain_btn .ai_icon {
  690. display: inline-block;
  691. width: 16px;
  692. height: 16px;
  693. line-height: 16px;
  694. text-align: center;
  695. font-size: 10px;
  696. font-weight: 600;
  697. color: #fff;
  698. background: $btn_color;
  699. border-radius: 3px;
  700. font-style: normal;
  701. flex-shrink: 0;
  702. }
  703. .ai_explain_btn.el-button--text:hover .ai_icon {
  704. background: darken($btn_color, 10%);
  705. }
  706. /* hover 节点行时,让 AI 讲解按钮淡入 */
  707. .el-tree-node__content:hover .ai_explain_btn.el-button--text {
  708. opacity: 1 !important;
  709. }
  710. }
  711. </style>
  712. <!-- ===============================================================
  713. 以下 scoped 样式块:只处理"自定义节点内容"(我们自己模板里写的)
  714. 类名是自定义的,能被 scoped 正常命中,无需 :deep
  715. =============================================================== -->
  716. <style lang="scss" scoped>
  717. .custom_tree_node {
  718. display: flex;
  719. align-items: center;
  720. justify-content: space-between;
  721. flex: 1;
  722. width: 100%; /* 占满整个节点行宽度(跟随树整体宽度) */
  723. padding-right: 8px;
  724. height: 36px;
  725. line-height: 36px;
  726. white-space: nowrap;
  727. position: relative;
  728. z-index: 2;
  729. /* ===== 节点文字(用户要求:400 / 14px / #333333) ===== */
  730. .node_label {
  731. flex: 1;
  732. display: inline-flex;
  733. align-items: center;
  734. font-weight: 400;
  735. font-size: 14px;
  736. color: #333333;
  737. white-space: nowrap;
  738. /* 名称文本:完整显示,不省略 */
  739. .node_label_text {
  740. display: inline-block;
  741. white-space: nowrap;
  742. line-height: 1;
  743. }
  744. /* 计数(60):灰色小字,不收缩 */
  745. .node_count {
  746. color: #909399;
  747. margin-left: 4px;
  748. font-size: 13px;
  749. flex-shrink: 0;
  750. }
  751. }
  752. }
  753. </style>