Commit ff03e1af by pangchong

chore(deps): 添加 diff 库依赖并清理 editor 静态资源

- 在 package.json 和 package-lock.json 中新增 diff 库版本 7.0.0 依赖
- 删除 public/editor 目录下的 favicon.svg、index.html 及相关静态资源文件
- 优化 vite.config.ts 移除不再使用的自定义静态资源中间件和 fs 导入
- 新增 husky 钩子配置,添加 commit-msg 检查和提交格式提示的 pre-commit 钩子
- 更新 .gemini/.rules.md,完善逻辑 Hook 内函数声明规范的文档说明
- 新增 scratch_test.js 脚本,用于读取 XML 文件并统计标签数量
parent f55c6aab
......@@ -35,6 +35,7 @@
- **Vue 单文件 TS 提取要求**:Vue 页面主入口或下属组件的 `.vue` 文件中,禁止书写冗长的业务处理函数,必须将其全部提取到各自目录下的 `functionals/index.ts` 中。
- **避免硬编码与混杂 Class**:在 `functionals/index.ts` 中编写业务逻辑时,组件级及非跨页面的功能逻辑**不要使用 Class 进行封装**,而应当采用 Vue 风格的以 `use` 开头的 **逻辑 Hook 方法**(例如 `export function useListEditor() { ... }`),并在 `.vue` 中通过解构调用。Class 应当仅被用来定义一类具备具体强实体特征或需要持久状态维持的特定全局/跨模块方法服务。
- **Hook 内部函数声明规范**:在 `use` 开头的逻辑 Hook 方法内部声明的其他业务函数或辅助函数,**必须**使用 `const` 箭头函数形式进行定义(例如 `const updateAttributes = (...) => { ... }`),**禁止**在 Hook 内部使用 `function` 关键字声明函数。
- **TypeScript 类型存放约束**:模块或组件相关的类型/接口(如 `interface``type`)必须统一放在同级 `constants/index.ts` 中,严禁在 `functionals/index.ts``.vue` 单文件中硬编码声明。
---
......
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
npx --no-install commitlint --edit $1
#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"
echo '***************************************************'
echo '********************注意提交格式*******************'
echo '***************************************************'
echo 'git commit -m <type>[optional scope]: <description>'
echo '***************************************************
'feat': 新功能 feature
'fix': 修复 bug
'docs': 文档注释
'style': 代码格式(不影响代码运行的变动)
'refactor': 重构(既不增加新功能,也不是修复bug)
'perf': 性能优化
'test': 增加测试
'chore': 构建过程或辅助工具的变动
'revert': 回退
'build': 打包'
echo '***************************************************'
......@@ -16,6 +16,7 @@
"bpmn-js-properties-panel": "^5.53.0",
"camunda-bpmn-moddle": "^7.0.1",
"dayjs": "^1.11.19",
"diff": "^7.0.0",
"docx-preview": "^0.3.7",
"less": "^4.5.1",
"lodash-es": "^4.18.1",
......@@ -2892,6 +2893,15 @@
"dev": true,
"license": "Apache-2.0"
},
"node_modules/diff": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/diff/-/diff-7.0.0.tgz",
"integrity": "sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
......
......@@ -36,7 +36,8 @@
"vue-i18n": "^11.2.8",
"vue-router": "^5.0.3",
"vuedraggable": "^4.1.0",
"xlsx": "^0.18.5"
"xlsx": "^0.18.5",
"diff": "^7.0.0"
},
"devDependencies": {
"@commitlint/cli": "^19.8.0",
......
This source diff could not be displayed because it is too large. You can view the blob instead.
import{r as e,i as t,g as r,h as n}from"./Button-ByITIhW2.js";import{j as o,b3 as a,f as i,g as s,u as l,h as p,b4 as d,v as g,b5 as c,az as C}from"./index-BGNM-WBG.js";import{u as m}from"./use-rtl-iN3przWb.js";const u=e("error",()=>o("svg",{viewBox:"0 0 48 48",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M24,4 C35.045695,4 44,12.954305 44,24 C44,35.045695 35.045695,44 24,44 C12.954305,44 4,35.045695 4,24 C4,12.954305 12.954305,4 24,4 Z M17.8838835,16.1161165 L17.7823881,16.0249942 C17.3266086,15.6583353 16.6733914,15.6583353 16.2176119,16.0249942 L16.1161165,16.1161165 L16.0249942,16.2176119 C15.6583353,16.6733914 15.6583353,17.3266086 16.0249942,17.7823881 L16.1161165,17.8838835 L22.233,24 L16.1161165,30.1161165 L16.0249942,30.2176119 C15.6583353,30.6733914 15.6583353,31.3266086 16.0249942,31.7823881 L16.1161165,31.8838835 L16.2176119,31.9750058 C16.6733914,32.3416647 17.3266086,32.3416647 17.7823881,31.9750058 L17.8838835,31.8838835 L24,25.767 L30.1161165,31.8838835 L30.2176119,31.9750058 C30.6733914,32.3416647 31.3266086,32.3416647 31.7823881,31.9750058 L31.8838835,31.8838835 L31.9750058,31.7823881 C32.3416647,31.3266086 32.3416647,30.6733914 31.9750058,30.2176119 L31.8838835,30.1161165 L25.767,24 L31.8838835,17.8838835 L31.9750058,17.7823881 C32.3416647,17.3266086 32.3416647,16.6733914 31.9750058,16.2176119 L31.8838835,16.1161165 L31.7823881,16.0249942 C31.3266086,15.6583353 30.6733914,15.6583353 30.2176119,16.0249942 L30.1161165,16.1161165 L24,22.233 L17.8838835,16.1161165 L17.7823881,16.0249942 L17.8838835,16.1161165 Z"}))))),f=e("info",()=>o("svg",{viewBox:"0 0 28 28",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M14,2 C20.6274,2 26,7.37258 26,14 C26,20.6274 20.6274,26 14,26 C7.37258,26 2,20.6274 2,14 C2,7.37258 7.37258,2 14,2 Z M14,11 C13.4477,11 13,11.4477 13,12 L13,12 L13,20 C13,20.5523 13.4477,21 14,21 C14.5523,21 15,20.5523 15,20 L15,20 L15,12 C15,11.4477 14.5523,11 14,11 Z M14,6.75 C13.3096,6.75 12.75,7.30964 12.75,8 C12.75,8.69036 13.3096,9.25 14,9.25 C14.6904,9.25 15.25,8.69036 15.25,8 C15.25,7.30964 14.6904,6.75 14,6.75 Z"}))))),L=e("success",()=>o("svg",{viewBox:"0 0 48 48",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M24,4 C35.045695,4 44,12.954305 44,24 C44,35.045695 35.045695,44 24,44 C12.954305,44 4,35.045695 4,24 C4,12.954305 12.954305,4 24,4 Z M32.6338835,17.6161165 C32.1782718,17.1605048 31.4584514,17.1301307 30.9676119,17.5249942 L30.8661165,17.6161165 L20.75,27.732233 L17.1338835,24.1161165 C16.6457281,23.6279612 15.8542719,23.6279612 15.3661165,24.1161165 C14.9105048,24.5717282 14.8801307,25.2915486 15.2749942,25.7823881 L15.3661165,25.8838835 L19.8661165,30.3838835 C20.3217282,30.8394952 21.0415486,30.8698693 21.5323881,30.4750058 L21.6338835,30.3838835 L32.6338835,19.3838835 C33.1220388,18.8957281 33.1220388,18.1042719 32.6338835,17.6161165 Z"}))))),v=e("warning",()=>o("svg",{viewBox:"0 0 24 24",version:"1.1",xmlns:"http://www.w3.org/2000/svg"},o("g",{stroke:"none","stroke-width":"1","fill-rule":"evenodd"},o("g",{"fill-rule":"nonzero"},o("path",{d:"M12,2 C17.523,2 22,6.478 22,12 C22,17.522 17.523,22 12,22 C6.477,22 2,17.522 2,12 C2,6.478 6.477,2 12,2 Z M12.0018002,15.0037242 C11.450254,15.0037242 11.0031376,15.4508407 11.0031376,16.0023869 C11.0031376,16.553933 11.450254,17.0010495 12.0018002,17.0010495 C12.5533463,17.0010495 13.0004628,16.553933 13.0004628,16.0023869 C13.0004628,15.4508407 12.5533463,15.0037242 12.0018002,15.0037242 Z M11.99964,7 C11.4868042,7.00018474 11.0642719,7.38637706 11.0066858,7.8837365 L11,8.00036004 L11.0018003,13.0012393 L11.00857,13.117858 C11.0665141,13.6151758 11.4893244,14.0010638 12.0021602,14.0008793 C12.514996,14.0006946 12.9375283,13.6145023 12.9951144,13.1171428 L13.0018002,13.0005193 L13,7.99964009 L12.9932303,7.8830214 C12.9352861,7.38570354 12.5124758,6.99981552 11.99964,7 Z"})))));const w={name:"Space",self:function(){return a}};let h;function x(){if(!t)return!0;if(void 0===h){const e=document.createElement("div");e.style.display="flex",e.style.flexDirection="column",e.style.rowGap="1px",e.appendChild(document.createElement("div")),e.appendChild(document.createElement("div")),document.body.appendChild(e);const t=1===e.scrollHeight;return document.body.removeChild(e),h=t}return h}const y=i({name:"Space",props:Object.assign(Object.assign({},l.props),{align:String,justify:{type:String,default:"start"},inline:Boolean,vertical:Boolean,reverse:Boolean,size:{type:[String,Number,Array],default:"medium"},wrapItem:{type:Boolean,default:!0},itemClass:String,itemStyle:[String,Object],wrap:{type:Boolean,default:!0},internalUseGap:{type:Boolean,default:void 0}}),setup(e){const{mergedClsPrefixRef:t,mergedRtlRef:r}=s(e),n=l("Space","-space",void 0,w,e,t),o=m("Space",r,t);return{useGap:x(),rtlEnabled:o,mergedClsPrefix:t,margin:p(()=>{const{size:t}=e;if(Array.isArray(t))return{horizontal:t[0],vertical:t[1]};if("number"==typeof t)return{horizontal:t,vertical:t};const{self:{[g("gap",t)]:r}}=n.value,{row:o,col:a}=c(r);return{horizontal:C(a),vertical:C(o)}})}},render(){const{vertical:e,reverse:t,align:a,inline:i,justify:s,itemClass:l,itemStyle:p,margin:g,wrap:c,mergedClsPrefix:C,rtlEnabled:m,useGap:u,wrapItem:f,internalUseGap:L}=this,v=r(n(this),!1);if(!v.length)return null;const w=`${g.horizontal}px`,h=g.horizontal/2+"px",x=`${g.vertical}px`,y=g.vertical/2+"px",b=v.length-1,B=s.startsWith("space-");return o("div",{role:"none",class:[`${C}-space`,m&&`${C}-space--rtl`],style:{display:i?"inline-flex":"flex",flexDirection:e&&!t?"column":e&&t?"column-reverse":!e&&t?"row-reverse":"row",justifyContent:["start","end"].includes(s)?`flex-${s}`:s,flexWrap:!c||e?"nowrap":"wrap",marginTop:u||e?"":`-${y}`,marginBottom:u||e?"":`-${y}`,alignItems:a,gap:u?`${g.vertical}px ${g.horizontal}px`:""}},f||!u&&!L?v.map((t,r)=>t.type===d?t:o("div",{role:"none",class:l,style:[p,{maxWidth:"100%"},u?"":e?{marginBottom:r!==b?x:""}:m?{marginLeft:B?"space-between"===s&&r===b?"":h:r!==b?w:"",marginRight:B?"space-between"===s&&0===r?"":h:"",paddingTop:y,paddingBottom:y}:{marginRight:B?"space-between"===s&&r===b?"":h:r!==b?w:"",marginLeft:B?"space-between"===s&&0===r?"":h:"",paddingTop:y,paddingBottom:y}]},t)):v)}});export{u as E,f as I,L as S,v as W,y as _};
const o=(o,t)=>{const c=o.__vccOpts||o;for(const[s,n]of t)c[s]=n;return c};export{o as _};
This source diff could not be displayed because it is too large. You can view the blob instead.
import{j as t,d as e,q as s,f as n,g as l,u as i,h as r,i as o,t as c,v as a,o as u,x as f,n as d,w as p,y as h,z as v}from"./index-BGNM-WBG.js";import{N as x,_ as g}from"./Button-ByITIhW2.js";import{I as z,S as m,W as w,E as y,_ as C}from"./Space-BiFpHGv7.js";import"./use-rtl-iN3przWb.js";const B=e("result","\n color: var(--n-text-color);\n line-height: var(--n-line-height);\n font-size: var(--n-font-size);\n transition:\n color .3s var(--n-bezier);\n",[e("result-icon","\n display: flex;\n justify-content: center;\n transition: color .3s var(--n-bezier);\n ",[s("status-image","\n font-size: var(--n-icon-size);\n width: 1em;\n height: 1em;\n "),e("base-icon","\n color: var(--n-icon-color);\n font-size: var(--n-icon-size);\n ")]),e("result-content",{marginTop:"24px"}),e("result-footer","\n margin-top: 24px;\n text-align: center;\n "),e("result-header",[s("title","\n margin-top: 16px;\n font-weight: var(--n-title-font-weight);\n transition: color .3s var(--n-bezier);\n text-align: center;\n color: var(--n-title-text-color);\n font-size: var(--n-title-font-size);\n "),s("description","\n margin-top: 4px;\n text-align: center;\n font-size: var(--n-font-size);\n ")])]),F={403:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("path",{fill:"#EF9645",d:"M15.5 2.965c1.381 0 2.5 1.119 2.5 2.5v.005L20.5.465c1.381 0 2.5 1.119 2.5 2.5V4.25l2.5-1.535c1.381 0 2.5 1.119 2.5 2.5V8.75L29 18H15.458L15.5 2.965z"}),t("path",{fill:"#FFDC5D",d:"M4.625 16.219c1.381-.611 3.354.208 4.75 2.188.917 1.3 1.187 3.151 2.391 3.344.46.073 1.234-.313 1.234-1.397V4.5s0-2 2-2 2 2 2 2v11.633c0-.029 1-.064 1-.082V2s0-2 2-2 2 2 2 2v14.053c0 .017 1 .041 1 .069V4.25s0-2 2-2 2 2 2 2v12.638c0 .118 1 .251 1 .398V8.75s0-2 2-2 2 2 2 2V24c0 6.627-5.373 12-12 12-4.775 0-8.06-2.598-9.896-5.292C8.547 28.423 8.096 26.051 8 25.334c0 0-.123-1.479-1.156-2.865-1.469-1.969-2.5-3.156-3.125-3.866-.317-.359-.625-1.707.906-2.384z"}))},404:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("circle",{fill:"#FFCB4C",cx:"18",cy:"17.018",r:"17"}),t("path",{fill:"#65471B",d:"M14.524 21.036c-.145-.116-.258-.274-.312-.464-.134-.46.13-.918.59-1.021 4.528-1.021 7.577 1.363 7.706 1.465.384.306.459.845.173 1.205-.286.358-.828.401-1.211.097-.11-.084-2.523-1.923-6.182-1.098-.274.061-.554-.016-.764-.184z"}),t("ellipse",{fill:"#65471B",cx:"13.119",cy:"11.174",rx:"2.125",ry:"2.656"}),t("ellipse",{fill:"#65471B",cx:"24.375",cy:"12.236",rx:"2.125",ry:"2.656"}),t("path",{fill:"#F19020",d:"M17.276 35.149s1.265-.411 1.429-1.352c.173-.972-.624-1.167-.624-1.167s1.041-.208 1.172-1.376c.123-1.101-.861-1.363-.861-1.363s.97-.4 1.016-1.539c.038-.959-.995-1.428-.995-1.428s5.038-1.221 5.556-1.341c.516-.12 1.32-.615 1.069-1.694-.249-1.08-1.204-1.118-1.697-1.003-.494.115-6.744 1.566-8.9 2.068l-1.439.334c-.54.127-.785-.11-.404-.512.508-.536.833-1.129.946-2.113.119-1.035-.232-2.313-.433-2.809-.374-.921-1.005-1.649-1.734-1.899-1.137-.39-1.945.321-1.542 1.561.604 1.854.208 3.375-.833 4.293-2.449 2.157-3.588 3.695-2.83 6.973.828 3.575 4.377 5.876 7.952 5.048l3.152-.681z"}),t("path",{fill:"#65471B",d:"M9.296 6.351c-.164-.088-.303-.224-.391-.399-.216-.428-.04-.927.393-1.112 4.266-1.831 7.699-.043 7.843.034.433.231.608.747.391 1.154-.216.405-.74.546-1.173.318-.123-.063-2.832-1.432-6.278.047-.257.109-.547.085-.785-.042zm12.135 3.75c-.156-.098-.286-.243-.362-.424-.187-.442.023-.927.468-1.084 4.381-1.536 7.685.48 7.823.567.415.26.555.787.312 1.178-.242.39-.776.495-1.191.238-.12-.072-2.727-1.621-6.267-.379-.266.091-.553.046-.783-.096z"}))},418:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("ellipse",{fill:"#292F33",cx:"18",cy:"26",rx:"18",ry:"10"}),t("ellipse",{fill:"#66757F",cx:"18",cy:"24",rx:"18",ry:"10"}),t("path",{fill:"#E1E8ED",d:"M18 31C3.042 31 1 16 1 12h34c0 2-1.958 19-17 19z"}),t("path",{fill:"#77B255",d:"M35 12.056c0 5.216-7.611 9.444-17 9.444S1 17.271 1 12.056C1 6.84 8.611 3.611 18 3.611s17 3.229 17 8.445z"}),t("ellipse",{fill:"#A6D388",cx:"18",cy:"13",rx:"15",ry:"7"}),t("path",{d:"M21 17c-.256 0-.512-.098-.707-.293-2.337-2.337-2.376-4.885-.125-8.262.739-1.109.9-2.246.478-3.377-.461-1.236-1.438-1.996-1.731-2.077-.553 0-.958-.443-.958-.996 0-.552.491-.995 1.043-.995.997 0 2.395 1.153 3.183 2.625 1.034 1.933.91 4.039-.351 5.929-1.961 2.942-1.531 4.332-.125 5.738.391.391.391 1.023 0 1.414-.195.196-.451.294-.707.294zm-6-2c-.256 0-.512-.098-.707-.293-2.337-2.337-2.376-4.885-.125-8.262.727-1.091.893-2.083.494-2.947-.444-.961-1.431-1.469-1.684-1.499-.552 0-.989-.447-.989-1 0-.552.458-1 1.011-1 .997 0 2.585.974 3.36 2.423.481.899 1.052 2.761-.528 5.131-1.961 2.942-1.531 4.332-.125 5.738.391.391.391 1.023 0 1.414-.195.197-.451.295-.707.295z",fill:"#5C913B"}))},500:function(){return t("svg",{xmlns:"http://www.w3.org/2000/svg",viewBox:"0 0 36 36"},t("path",{fill:"#FFCC4D",d:"M36 18c0 9.941-8.059 18-18 18-9.94 0-18-8.059-18-18C0 8.06 8.06 0 18 0c9.941 0 18 8.06 18 18"}),t("ellipse",{fill:"#664500",cx:"18",cy:"27",rx:"5",ry:"6"}),t("path",{fill:"#664500",d:"M5.999 11c-.208 0-.419-.065-.599-.2-.442-.331-.531-.958-.2-1.4C8.462 5.05 12.816 5 13 5c.552 0 1 .448 1 1 0 .551-.445.998-.996 1-.155.002-3.568.086-6.204 3.6-.196.262-.497.4-.801.4zm24.002 0c-.305 0-.604-.138-.801-.4-2.64-3.521-6.061-3.598-6.206-3.6-.55-.006-.994-.456-.991-1.005C22.006 5.444 22.45 5 23 5c.184 0 4.537.05 7.8 4.4.332.442.242 1.069-.2 1.4-.18.135-.39.2-.599.2zm-16.087 4.5l1.793-1.793c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0L12.5 14.086l-1.793-1.793c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l1.793 1.793-1.793 1.793c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l1.793-1.793 1.793 1.793c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414L13.914 15.5zm11 0l1.793-1.793c.391-.391.391-1.023 0-1.414s-1.023-.391-1.414 0L23.5 14.086l-1.793-1.793c-.391-.391-1.023-.391-1.414 0s-.391 1.023 0 1.414l1.793 1.793-1.793 1.793c-.391.391-.391 1.023 0 1.414.195.195.451.293.707.293s.512-.098.707-.293l1.793-1.793 1.793 1.793c.195.195.451.293.707.293s.512-.098.707-.293c.391-.391.391-1.023 0-1.414L24.914 15.5z"}))},info:()=>t(z,null),success:()=>t(m,null),warning:()=>t(w,null),error:()=>t(y,null)},b=n({name:"Result",props:Object.assign(Object.assign({},i.props),{size:{type:String,default:"medium"},status:{type:String,default:"info"},title:String,description:String}),slots:Object,setup(t){const{mergedClsPrefixRef:e,inlineThemeDisabled:s}=l(t),n=i("Result","-result",B,c,t,e),u=r(()=>{const{size:e,status:s}=t,{common:{cubicBezierEaseInOut:l},self:{textColor:i,lineHeight:r,titleTextColor:o,titleFontWeight:c,[a("iconColor",s)]:u,[a("fontSize",e)]:f,[a("titleFontSize",e)]:d,[a("iconSize",e)]:p}}=n.value;return{"--n-bezier":l,"--n-font-size":f,"--n-icon-size":p,"--n-line-height":r,"--n-text-color":i,"--n-title-font-size":d,"--n-title-font-weight":c,"--n-title-text-color":o,"--n-icon-color":u||""}}),f=s?o("result",r(()=>{const{size:e,status:s}=t;let n="";return e&&(n+=e[0]),s&&(n+=s[0]),n}),u,t):void 0;return{mergedClsPrefix:e,cssVars:s?void 0:u,themeClass:null==f?void 0:f.themeClass,onRender:null==f?void 0:f.onRender}},render(){var e;const{status:s,$slots:n,mergedClsPrefix:l,onRender:i}=this;return null==i||i(),t("div",{class:[`${l}-result`,this.themeClass],style:this.cssVars},t("div",{class:`${l}-result-icon`},(null===(e=n.icon)||void 0===e?void 0:e.call(n))||t(x,{clsPrefix:l},{default:()=>F[s]()})),t("div",{class:`${l}-result-header`},this.title?t("div",{class:`${l}-result-header__title`},this.title):null,this.description?t("div",{class:`${l}-result-header__description`},this.description):null),n.default&&t("div",{class:`${l}-result-content`},n),n.footer&&t("div",{class:`${l}-result-footer`},n.footer()))}}),_={class:"flex justify-center items-center h-full"},j=n({__name:"index",setup(t){const e=v(),s=()=>{e.push("/")};return(t,e)=>{const n=g,l=C,i=b;return u(),f("div",_,[d(i,{status:"404",title:"页面不存在"},{footer:p(()=>[d(l,null,{default:p(()=>[d(n,{type:"primary",onClick:s},{default:p(()=>[h("返回")]),_:1})]),_:1})]),_:1})])}}});export{j as default};
import{_ as o}from"./_plugin-vue_export-helper-BCo6x5W8.js";import{c as e,a as r,s as t,b as l,d as n,e as s,f as a,u as i,r as d,g as c,p as u,h as b,i as h,j as v,k as f,l as C,o as p,m,w as g,n as x}from"./index-BGNM-WBG.js";import{u as y,N as S}from"./Scrollbar-DmIJvlAu.js";import"./use-rtl-iN3przWb.js";const B=e({name:"Layout",common:r,peers:{Scrollbar:t},self:function(o){const{baseColor:e,textColor2:r,bodyColor:t,cardColor:n,dividerColor:s,actionColor:a,scrollbarColor:i,scrollbarColorHover:d,invertedColor:c}=o;return{textColor:r,textColorInverted:"#FFF",color:t,colorEmbedded:a,headerColor:n,headerColorInverted:c,footerColor:a,footerColorInverted:c,headerBorderColor:s,headerBorderColorInverted:c,footerBorderColor:s,footerBorderColorInverted:c,siderBorderColor:s,siderBorderColorInverted:c,siderColor:n,siderColorInverted:c,siderToggleButtonBorder:`1px solid ${s}`,siderToggleButtonColor:e,siderToggleButtonIconColor:r,siderToggleButtonIconColorInverted:r,siderToggleBarColor:l(t,i),siderToggleBarColorHover:l(t,d),__invertScrollbar:"true"}}}),z={type:String,default:"static"},I=n("layout","\n color: var(--n-text-color);\n background-color: var(--n-color);\n box-sizing: border-box;\n position: relative;\n z-index: auto;\n flex: auto;\n overflow: hidden;\n transition:\n box-shadow .3s var(--n-bezier),\n background-color .3s var(--n-bezier),\n color .3s var(--n-bezier);\n",[n("layout-scroll-container","\n overflow-x: hidden;\n box-sizing: border-box;\n height: 100%;\n "),s("absolute-positioned","\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n bottom: 0;\n ")]),T={embedded:Boolean,position:z,nativeScrollbar:{type:Boolean,default:!0},scrollbarProps:Object,onScroll:Function,contentClass:String,contentStyle:{type:[String,Object],default:""},hasSider:Boolean,siderPlacement:{type:String,default:"left"}},$=f("n-layout");function R(o){return a({name:o?"LayoutContent":"Layout",props:Object.assign(Object.assign({},i.props),T),setup(o){const e=d(null),r=d(null),{mergedClsPrefixRef:t,inlineThemeDisabled:l}=c(o),n=i("Layout","-layout",I,B,o,t);u($,o);let s=0,a=0;y(()=>{if(o.nativeScrollbar){const o=e.value;o&&(o.scrollTop=a,o.scrollLeft=s)}});const v={scrollTo:function(t,l){if(o.nativeScrollbar){const{value:o}=e;o&&(void 0===l?o.scrollTo(t):o.scrollTo(t,l))}else{const{value:o}=r;o&&o.scrollTo(t,l)}}},f=b(()=>{const{common:{cubicBezierEaseInOut:e},self:r}=n.value;return{"--n-bezier":e,"--n-color":o.embedded?r.colorEmbedded:r.color,"--n-text-color":r.textColor}}),C=l?h("layout",b(()=>o.embedded?"e":""),f,o):void 0;return Object.assign({mergedClsPrefix:t,scrollableElRef:e,scrollbarInstRef:r,hasSiderStyle:{display:"flex",flexWrap:"nowrap",width:"100%",flexDirection:"row"},mergedTheme:n,handleNativeElScroll:e=>{var r;const t=e.target;s=t.scrollLeft,a=t.scrollTop,null===(r=o.onScroll)||void 0===r||r.call(o,e)},cssVars:l?void 0:f,themeClass:null==C?void 0:C.themeClass,onRender:null==C?void 0:C.onRender},v)},render(){var e;const{mergedClsPrefix:r,hasSider:t}=this;null===(e=this.onRender)||void 0===e||e.call(this);const l=t?this.hasSiderStyle:void 0,n=[this.themeClass,o&&`${r}-layout-content`,`${r}-layout`,`${r}-layout--${this.position}-positioned`];return v("div",{class:n,style:this.cssVars},this.nativeScrollbar?v("div",{ref:"scrollableElRef",class:[`${r}-layout-scroll-container`,this.contentClass],style:[this.contentStyle,l],onScroll:this.handleNativeElScroll},this.$slots):v(S,Object.assign({},this.scrollbarProps,{onScroll:this.onScroll,ref:"scrollbarInstRef",theme:this.mergedTheme.peers.Scrollbar,themeOverrides:this.mergedTheme.peerOverrides.Scrollbar,contentClass:this.contentClass,contentStyle:[this.contentStyle,l]}),this.$slots))}})}const j=R(!1),O=R(!0),w=n("layout-footer","\n transition:\n box-shadow .3s var(--n-bezier),\n color .3s var(--n-bezier),\n background-color .3s var(--n-bezier),\n border-color .3s var(--n-bezier);\n color: var(--n-text-color);\n background-color: var(--n-color);\n box-sizing: border-box;\n",[s("absolute-positioned","\n position: absolute;\n left: 0;\n right: 0;\n bottom: 0;\n "),s("bordered","\n border-top: solid 1px var(--n-border-color);\n ")]),P=Object.assign(Object.assign({},i.props),{inverted:Boolean,position:z,bordered:Boolean}),L=a({name:"LayoutFooter",props:P,setup(o){const{mergedClsPrefixRef:e,inlineThemeDisabled:r}=c(o),t=i("Layout","-layout-footer",w,B,o,e),l=b(()=>{const{common:{cubicBezierEaseInOut:e},self:r}=t.value,l={"--n-bezier":e};return o.inverted?(l["--n-color"]=r.footerColorInverted,l["--n-text-color"]=r.textColorInverted,l["--n-border-color"]=r.footerBorderColorInverted):(l["--n-color"]=r.footerColor,l["--n-text-color"]=r.textColor,l["--n-border-color"]=r.footerBorderColor),l}),n=r?h("layout-footer",b(()=>o.inverted?"a":"b"),l,o):void 0;return{mergedClsPrefix:e,cssVars:r?void 0:l,themeClass:null==n?void 0:n.themeClass,onRender:null==n?void 0:n.onRender}},render(){var o;const{mergedClsPrefix:e}=this;return null===(o=this.onRender)||void 0===o||o.call(this),v("div",{class:[`${e}-layout-footer`,this.themeClass,this.position&&`${e}-layout-footer--${this.position}-positioned`,this.bordered&&`${e}-layout-footer--bordered`],style:this.cssVars},this.$slots)}}),E=n("layout-header","\n transition:\n color .3s var(--n-bezier),\n background-color .3s var(--n-bezier),\n box-shadow .3s var(--n-bezier),\n border-color .3s var(--n-bezier);\n box-sizing: border-box;\n width: 100%;\n background-color: var(--n-color);\n color: var(--n-text-color);\n",[s("absolute-positioned","\n position: absolute;\n left: 0;\n right: 0;\n top: 0;\n "),s("bordered","\n border-bottom: solid 1px var(--n-border-color);\n ")]),k={position:z,inverted:Boolean,bordered:{type:Boolean,default:!1}},_=a({name:"LayoutHeader",props:Object.assign(Object.assign({},i.props),k),setup(o){const{mergedClsPrefixRef:e,inlineThemeDisabled:r}=c(o),t=i("Layout","-layout-header",E,B,o,e),l=b(()=>{const{common:{cubicBezierEaseInOut:e},self:r}=t.value,l={"--n-bezier":e};return o.inverted?(l["--n-color"]=r.headerColorInverted,l["--n-text-color"]=r.textColorInverted,l["--n-border-color"]=r.headerBorderColorInverted):(l["--n-color"]=r.headerColor,l["--n-text-color"]=r.textColor,l["--n-border-color"]=r.headerBorderColor),l}),n=r?h("layout-header",b(()=>o.inverted?"a":"b"),l,o):void 0;return{mergedClsPrefix:e,cssVars:r?void 0:l,themeClass:null==n?void 0:n.themeClass,onRender:null==n?void 0:n.onRender}},render(){var o;const{mergedClsPrefix:e}=this;return null===(o=this.onRender)||void 0===o||o.call(this),v("div",{class:[`${e}-layout-header`,this.themeClass,this.position&&`${e}-layout-header--${this.position}-positioned`,this.bordered&&`${e}-layout-header--bordered`],style:this.cssVars},this.$slots)}});const V=o({},[["render",function(o,e){const r=_,t=C("router-view"),l=O,n=L,s=j;return p(),m(s,{class:"h-full overflow-hidden"},{default:g(()=>[x(r),x(l,{"content-class":"flex flex-col flex-auto overflow-x-auto bg-codeColor",class:"h-full flex flex-col"},{default:g(()=>[x(t)]),_:1}),x(n)]),_:1})}]]);export{V as default};
This source diff could not be displayed because it is too large. You can view the blob instead.
import{_ as r}from"./_plugin-vue_export-helper-BCo6x5W8.js";import{o as e,x as o}from"./index-BGNM-WBG.js";const t=r({},[["render",function(r,t){return e(),o("div")}]]);export{t as default};
import{f as a,A as e,h as l,o as t,x as s,n as r,w as o,y as n,B as u,F as i,j as m,C as c,D as p,E as d}from"./index-BGNM-WBG.js";import{_ as h,a as f}from"./DataTable-CEJURamz.js";import{_ as b}from"./Button-ByITIhW2.js";import"./use-rtl-iN3przWb.js";import"./Scrollbar-DmIJvlAu.js";const y=a({__name:"theme",setup(a){const y=e(),j=()=>{y.changeTheme()},k=[{title:"变量名",key:"label"},{title:"变量值",key:"value"},{title:"颜色",key:"color",render:a=>m("div",{style:{width:"30px",height:"30px",backgroundColor:a.value}})},{title:"值",key:"value",render:a=>m("span",{},y.theme?c[a.label]:p[a.label])}],x=l(()=>Object.entries(d.colors).map(([a,e])=>({label:a,value:e})));return(a,e)=>{const l=b,m=h,c=f;return t(),s(i,null,[r(m,null,{default:o(()=>[r(l,{type:"primary",onClick:j},{default:o(()=>[n("主题切换")]),_:1})]),_:1}),r(c,{columns:k,data:u(x),"flex-height":"",class:"h-full"},null,8,["data"])],64)}}});export{y as default};
import{b_ as t,b7 as n,h as r,J as s,bC as e,H as a,a4 as o,bW as u}from"./index-BGNM-WBG.js";function i(i,l,c){if(!l)return;const f=n(),d=r(()=>{const{value:t}=l;if(!t)return;const n=t[i];return n||void 0}),v=s(e,null),b=()=>{o(()=>{const{value:n}=c,r=`${n}${i}Rtl`;if(function(n,r){if(void 0===n)return!1;if(r){const{context:{ids:t}}=r;return t.has(n)}return null!==t(n)}(r,f))return;const{value:s}=d;s&&s.style.mount({id:r,head:!0,anchorMetaName:u,props:{bPrefix:n?`.${n}-`:void 0},ssr:f,parent:null==v?void 0:v.styleMountTarget})})};return f?b():a(b),d}export{i as u};
<?xml version="1.0" standalone="no"?><!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd"><svg t="1724118561720" class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="4707" xmlns:xlink="http://www.w3.org/1999/xlink" width="200" height="200"><path d="M922.85749971 610.25000029C845.74999971 509.75 730.62500029 406.54999971 698.89249971 381.42500029 676.25 363.5 654.46250029 354.5 632.69749971 354.5a95.30250029 95.30250029 0 0 0-61.65749971 25.12500029c-3.62999971 3.59250029-9.0675 8.0775-15.4125 13.46249971-45.3375 37.69499971-186.795 166.0275-190.4175 169.61249971a58.94250029 58.94250029 0 0 1-39.89999971 16.155 69.21749971 69.21749971 0 0 1-43.50000058-20.63999971A1055.0475 1055.0475 0 0 1 166.625 448.73c-34.45499971-38.5875-63.4725-72.69000029-63.4725-72.69000029s-6.345 12.56249971-9.97499971 22.4325a17.02500029 17.02500029 0 0 0 0.90749971 14.36250058C113.12749971 439.75999971 199.25 541.16749971 317.15 642.57499971A100.26749971 100.26749971 0 0 0 383.33750029 669.5a95.30250029 95.30250029 0 0 0 61.65749971-25.12500029c3.62999971-3.59250029 9.0675-8.0775 15.4125-13.46249971 45.3375-37.69499971 131.48250029-115.76999971 171.37500029-152.56500029 9.0675-8.0775 15.4125-13.46249971 18.135-16.155a58.94250029 58.94250029 0 0 1 39.89999971-16.155 74.295 74.295 0 0 1 43.49999971 20.64000058c25.38749971 21.53999971 66.195 59.24999971 115.155 109.49999942 35.36250029 37.69499971 63.4725 71.7975 63.4725 72.69000029 0 0 6.345-13.46249971 9.97500058-22.4325a16.12500029 16.12500029 0 0 0 0.93749942-16.18499971z" fill="#8DC21F" p-id="4708"></path><path d="M469.61 235.0475c-39.8025 28.95000029-202.635 180.0225-234.29999971 216.20999971a550.50000029 550.50000029 0 0 0 56.99999971 53.40000058 19.8 19.8 0 0 0 28.95000029-3.61500029c62.42249971-63.32249971 190.88250029-176.40749971 208.06499971-189.97499971 47.9475-40.70999971 104.94-44.325 155.59499971-6.33000058 124.83749971 94.05 236.10750029 241.50750029 255.105 268.65 0 0 2.71500029-13.5675 4.5-25.32749942a28.5975 28.5975 0 0 0-3.61499942-20.80500029c-117.60000029-169.1775-242.42249971-274.11000029-290.37000058-300.34500029-54.27749971-28.04249971-117.60000029-38.0025-180.92999971 8.13750029z" fill="#0084CF" p-id="4709"></path><path d="M337.50500029 147.2975c-80.4825 65.13000029-184.5 161.02500029-210.75000029 189.97499971a706.5 706.5 0 0 0 50.66250029 54.27750058 11.12249971 11.12249971 0 0 0 17.19-1.80750058c79.605-76.89750029 165.54750029-155.60250029 216.2025-194.50499942 84.13499971-65.1375 183.645-71.46749971 280.44 5.42999971a1192.66499971 1192.66499971 0 0 1 114.89249971 104.94 1622.99999971 1622.99999971 0 0 1 141.12 180 206.505 206.505 0 0 0-4.5-37.99500029 57.83249971 57.83249971 0 0 0-5.42999971-14.47499971 857.025 857.025 0 0 0-100.43250029-145.59000029C728.3375 160.865 660.49250029 122.86999971 616.16750029 99.35 557.36 69.49999971 444.28249971 60.4475 337.50500029 147.2975z" fill="#0084CF" p-id="4710"></path><path d="M554.6375 782.36c39.8025-28.95000029 202.64249971-180 234.29999971-216.20999971a615.465 615.465 0 0 0-56.99999971-54.27750058 19.8 19.8 0 0 0-28.95000029 3.61500029c-62.3925 63.32249971-190.85249971 176.40749971-208.04249971 189.97499971-47.9475 40.70999971-104.94 44.325-156.50250029 6.33000058C213.605 617.71249971 103.23500029 470.25500029 84.23749971 442.21249971c0 0-2.71500029 13.5675-4.5 25.32750029a28.5975 28.5975 0 0 0 3.6 20.8125c117.60000029 169.16249971 242.4375 274.11000029 290.385 300.35999971 54.27749971 29.82750029 117.6075 39.75000029 180.91500029-6.35249971z" fill="#0084CF" p-id="4711"></path><path d="M686.75000029 870.10250029c80.51249971-65.1375 184.545-161.02500029 210.74999942-189.97500058a706.5 706.5 0 0 0-50.66249942-54.27749971 11.12249971 11.12249971 0 0 0-17.19 1.80749971C750.05 704.555 664.10749971 783.26 613.4525 822.1625c-84.13499971 65.1375-183.645 71.46749971-280.44-5.42999971a1192.67250029 1192.67250029 0 0 1-114.89249971-104.9625A1622.99999971 1622.99999971 0 0 1 77.00000029 531.77000029a214.43249971 214.43249971 0 0 0 4.5 37.99499942 57.83249971 57.83249971 0 0 0 5.45249971 14.50500029 857.025 857.025 0 0 0 100.4175 145.64999971c108.55500029 126.60000029 176.4 164.61 220.725 188.1 58.80750029 29.88749971 171.88499971 38.93249971 278.65500029-47.91749942z" fill="#0084CF" p-id="4712"></path></svg>
\ No newline at end of file
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="./favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="Cache-Control" content="no-cache, no-store">
<script type="module" crossorigin src="./assets/index-BGNM-WBG.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-CKDo6wGH.css">
</head>
<body>
<div id="app"></div>
</body>
</html>
\ No newline at end of file
armo-xm项目重构-前端
import fs from 'fs';
const xml = fs.readFileSync('e:/refactor-Editor/Ifar-Xml-Editor/src/assets/file/WORK_CARD_20260318095803.xml', 'utf-8');
// Count tags using regex
const matches = xml.match(/<[a-zA-Z0-9_-]+/g);
console.log('Total tags count:', matches ? matches.length : 0);
// Let's count specific tags to see if there is anything huge
const tagCounts = {};
if (matches) {
for (const match of matches) {
const tag = match.slice(1);
tagCounts[tag] = (tagCounts[tag] || 0) + 1;
}
}
console.log('Tag counts:', Object.entries(tagCounts).sort((a, b) => b[1] - a[1]).slice(0, 20));
This source diff could not be displayed because it is too large. You can view the blob instead.
This source diff could not be displayed because it is too large. You can view the blob instead.
......@@ -20,7 +20,9 @@
<n-scrollbar :style="{ maxHeight: typeof maxHeight === 'number' ? `${maxHeight}px` : maxHeight }">
<n-spin :show="loading" :style="{ padding: typeof padding === 'number' ? `${padding}px` : padding }">
<div class="space-y-4">
<slot></slot>
</div>
</n-spin>
</n-scrollbar>
......@@ -92,4 +94,20 @@ const handleCancel = () => {
}
</script>
<style></style>
<style scoped></style>
<style scoped>
:deep(.rule-op) {
@apply text-primary font-bold;
}
:deep(.rule-quantifier) {
@apply text-warning;
}
:deep(.rule-group) {
@apply text-color3;
}
:deep(.rule-element) {
@apply text-success font-semibold;
}
:deep(.rule-pcdata) {
@apply text-danger;
}
</style>
<template>
<n-layout has-sider class="h-screen">
<n-layout-sider
collapse-mode="width"
:collapsed-width="64"
:width="240"
:collapsed="collapsed"
:native-scrollbar="true"
@collapse="collapsed = true"
@expand="collapsed = false"
bordered
class="sider-with-trigger"
>
<div class="sider-inner">
<!-- Logo 区 -->
<div class="h-16 px-4 flex items-center justify-center border-b flex-shrink-0" :style="{ borderColor: themeVars.dividerColor }">
<n-icon size="32" :color="themeVars.primaryColor"><airplane-outline /></n-icon>
<span
v-if="!collapsed"
class="text-xl font-black ml-3 truncate tracking-widest transition-colors"
:style="{ color: themeVars.textColor1 }"
>
AMRO 系统
</span>
</div>
<!-- 底部固定按钮 -->
<div class="sider-collapse-btn-wrap flex-shrink-0" :style="{ borderColor: themeVars.dividerColor }">
<div
class="sider-collapse-btn"
:style="{ borderColor: themeVars.dividerColor, background: themeVars.cardColor }"
@click="collapsed = !collapsed"
>
<n-icon size="18" :color="themeVars.textColor2">
<menu-outline />
</n-icon>
</div>
</div>
</div>
</n-layout-sider>
<n-layout class="flex flex-col h-screen overflow-hidden">
<n-layout-header
bordered
:position="appStore.fixedHeader ? 'absolute' : 'static'"
class="h-16 flex items-center justify-between px-6 flex-shrink-0 transition-colors"
:style="{ zIndex: appStore.fixedHeader ? 10 : 'auto' }"
>
<div class="flex items-center space-x-3">
<!-- 主题切换 -->
<CommonButton quaternary circle @click="appStore.isDark = !appStore.isDark">
<template #icon>
<n-icon>
<sunny-outline v-if="appStore.isDark" />
<moon-outline v-else />
</n-icon>
</template>
</CommonButton>
<!-- 偏好设置 -->
<SettingsDrawer />
</div>
</n-layout-header>
<n-layout-content
:content-style="{
paddingTop: appStore.contentCompact ? '6px' : '12px',
paddingBottom: appStore.contentCompact ? '6px' : '12px',
paddingLeft: appStore.contentCompact ? '6px' : '12px',
paddingRight: appStore.settingsPinned && appStore.settingsOpen ? '332px' : appStore.contentCompact ? '6px' : '12px',
display: 'flex',
flexDirection: 'column',
transition: 'all 0.3s cubic-bezier(0.25, 0.8, 0.25, 1)'
}"
class="transition-colors"
:style="{ backgroundColor: themeVars.bodyColor }"
>
<div class="h-screen flex flex-col overflow-hidden" :style="{ backgroundColor: themeVars.bodyColor }">
<!-- 主页面视图内容区 -->
<router-view v-slot="{ Component, route }">
<transition :name="appStore.transitionName || 'none'" mode="out-in">
<keep-alive>
......@@ -78,25 +8,20 @@
</keep-alive>
</transition>
</router-view>
</n-layout-content>
</n-layout>
</n-layout>
<!-- 全局弹窗组件,供 window 全局挂载调用 -->
<CommonImportModal ref="globalImportModalRef" />
<CommonExportModal ref="globalExportModalRef" />
<CommonUploadModal ref="globalUploadModalRef" />
<CommonDownloadModal ref="globalDownloadModalRef" />
</div>
</template>
<script setup lang="ts">
import { useAppStore } from '@/store/app/index'
import SettingsDrawer from './components/SettingsDrawer.vue'
import CommonDownloadModal from '@/components/CommonDownloadModal.vue'
import { AirplaneOutline, SunnyOutline, MoonOutline, MenuOutline } from '@vicons/ionicons5'
import router from '@/router/index'
const themeVars = useThemeVars()
const route = useRoute()
const appStore = useAppStore()
const globalImportModalRef = ref()
const globalExportModalRef = ref()
......@@ -104,18 +29,10 @@ const globalAttachmentModalRef = ref()
const globalUploadModalRef = ref()
const globalPreviewModalRef = ref()
const globalDownloadModalRef = ref()
const collapsed = computed({
get: () => appStore.collapsed,
set: (val) => (appStore.collapsed = val)
})
const activeKey = ref(route.path)
// 菜单数据已通过 Pinia 持久化,无需在此重复获取
// 全局快捷键处理
const handleGlobalKeydown = (e: KeyboardEvent) => {
const ctrl = e.ctrlKey || e.metaKey
const alt = e.altKey
const shift = e.shiftKey
// Ctrl+Shift+D: 切换深色/浅色主题
......@@ -124,13 +41,6 @@ const handleGlobalKeydown = (e: KeyboardEvent) => {
appStore.isDark = !appStore.isDark
return
}
// Alt+H: 返回首页
if (alt && e.key.toLowerCase() === 'h') {
e.preventDefault()
router.push('/')
return
}
}
onMounted(() => {
......@@ -141,7 +51,11 @@ onMounted(() => {
window.$uploadModal = globalUploadModalRef.value
window.$previewModal = globalPreviewModalRef.value
window.$downloadModal = globalDownloadModalRef.value
// 初始化应用主题
appStore.applyTheme()
})
onUnmounted(() => {
window.removeEventListener('keydown', handleGlobalKeydown)
})
......@@ -190,79 +104,4 @@ onUnmounted(() => {
opacity: 0;
transform: scale(0.96);
}
/* 沉浸式风格:选中背景充满侧边栏宽度 */
:deep(.n-menu-item-content) {
border-radius: 0 !important;
margin: 1px 0 !important; /* 保持极小的上下间隙以区分项 */
transition: all 0.3s ease !important;
}
/* 修复折叠状态下的全宽居中问题 */
:deep(.n-menu.n-menu--collapsed .n-menu-item-content) {
padding: 0 !important;
margin: 1px 0 !important;
display: flex !important;
justify-content: center !important;
align-items: center !important;
width: 64px !important; /* 必须显式指定宽度以覆盖 Naive UI 内部计算值 */
}
:deep(.n-menu.n-menu--collapsed .n-menu-item-content .n-menu-item-content__icon) {
margin-right: 0 !important;
}
/* 确保折叠时文字和箭头不占位 */
:deep(.n-menu.n-menu--collapsed .n-menu-item-content-header),
:deep(.n-menu.n-menu--collapsed .n-menu-item-content__arrow) {
display: none !important;
}
:deep(.n-menu-item-content--selected) {
font-weight: 600;
}
/* 自定义折叠按钮区域 */
.sider-collapse-btn-wrap {
padding: 10px 0 12px;
display: flex;
justify-content: center;
border-top: 1px solid;
}
.sider-collapse-btn {
width: 40px;
height: 36px;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid;
border-radius: 6px;
cursor: pointer;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
transition: all 0.2s ease;
}
.sider-collapse-btn:hover {
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
transform: translateY(-1px);
}
/* sider 内部 flex 列布局,按钮固定底部 */
.sider-with-trigger :deep(.n-layout-sider-scroll-container) {
overflow: hidden !important;
}
.sider-inner {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.sider-menu-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
}
</style>
......@@ -14,6 +14,7 @@ const constantRoutes: Array<RouteRecordRaw> = [
path: '/',
name: 'layout',
component: MainLayout,
redirect: '/editor',
children: [
{
path: '/theme',
......@@ -22,16 +23,16 @@ const constantRoutes: Array<RouteRecordRaw> = [
meta: { title: '主题色板' }
},
{
path: '/xml-editor',
name: 'xml-editor',
component: () => import('@/views/xmlEditor.vue'),
meta: { title: '工卡 XML 编辑器' }
path: '/editor',
name: 'editor',
component: () => import('@/views/editor/index.vue'),
meta: { title: '工卡 XML 编辑器', fullScreen: true }
}
]
},
{
path: '/views/:pathMatch(.*)*',
component: MainLayout
path: '/:pathMatch(.*)*',
redirect: '/404'
}
]
......
......@@ -3,6 +3,7 @@ import type { XmlNode } from '@/types/xmlNode'
export interface EditorState {
xmlTree: XmlNode | null
selectedNodeId: string | null
undoStack: string[] // 保存 XML 字符串的历史快照
redoStack: string[] // 保存 XML 字符串的重做快照
undoStack: XmlNode[] // 保存 XmlNode 历史快照
redoStack: XmlNode[] // 保存 XmlNode 重做快照
lastUndoRedoTime: number // 回退重做动作的时间戳,用于触发视口重定位
}
......@@ -13,7 +13,8 @@ function generateId(): string {
const INLINE_ELEMENTS = new Set([
'REFBLOCK', 'REFINT', 'REFEXT', 'EIN', 'PAN', 'STDNAME', 'TED',
'TOOLNBR', 'TOOLNAME', 'ZONE', 'EFFECT', 'CONEFFECT',
'CB', 'CBNAME', 'CBLOC', 'GRPHCREF'
'CB', 'CBNAME', 'CBLOC', 'GRPHCREF',
'SUPER', 'SUPERSCRIPT', 'SUB', 'SUBSCRIPT'
])
export function isInlineElement(tagName: string): boolean {
......@@ -21,7 +22,7 @@ export function isInlineElement(tagName: string): boolean {
}
/**
* 将 XML 字符串解析为 XmlNode 树
* 将 XML 字符串解析为 XmlNode 树(同步版,适合小文件)
*/
export function parseXmlToTree(xmlString: string): XmlNode {
const parser = new DOMParser()
......@@ -38,6 +39,23 @@ export function parseXmlToTree(xmlString: string): XmlNode {
}
/**
* 将 XML 字符串解析为 XmlNode 树(异步版本)
* 通过 setTimeout 延迟执行,使主线程有机会渲染 UI(如加载动画),然后进行解析
*/
export function parseXmlToTreeAsync(xmlString: string): Promise<XmlNode> {
return new Promise((resolve, reject) => {
setTimeout(() => {
try {
const tree = parseXmlToTree(xmlString)
resolve(tree)
} catch (err: any) {
reject(err instanceof Error ? err : new Error(String(err)))
}
}, 50) // 给主线程留出足够的绘图帧时间
})
}
/**
* 递归将 DOM Element 转换为 XmlNode
*/
function domElementToXmlNode(element: Element, parentId: string | null): XmlNode {
......
......@@ -6,7 +6,7 @@ import { useEditorStore } from '@/store/editor'
export function useAttributeEditor() {
const store = useEditorStore()
function updateAttributes(model: Record<string, string>): void {
const updateAttributes = (model: Record<string, string>): void => {
const updatedAttrs: Record<string, string> = {}
for (const [k, v] of Object.entries(model)) {
if (v !== '' && v !== null && v !== undefined) {
......
import type { XmlNode } from '@/types/xmlNode'
// 列表节点标签定义
export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
// 表格节点标签定义
export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP']
// 容器节点:直接穿透,不独立成块,而是遍历其子节点
export const TRANSPARENT_TAGS = new Set(['JOBCARD', 'CEP', 'TASK'])
/**
* 虚拟滚动:各类节点的默认估计高度(px)
* 合理的预估可减少布局跳变,提升大文件初始滚动精度
*/
export const ESTIMATED_HEIGHT = 200
/** 按节点类型获取预估高度(用于虚拟列表初始高度计算) */
export const getEstimatedHeight = (tagName: string): number => {
switch (tagName) {
case 'TABLE': return 400 // 表格通常较高
case 'GRAPHIC': return 300 // 图片/图纸
case 'WARNING': return 180 // 警告块
case 'CAUTION': return 180
case 'NOTE': return 150
case 'PRETOPIC': return 160 // 模板段落
case 'UNLIST':
case 'LIST1':
case 'LIST2':
case 'LIST3': return 200 // 列表
case 'PARA':
case 'PARAC': return 80 // 普通段落
case 'SMUC-HEADER': return 120
case 'FINLIST': return 100
default: return ESTIMATED_HEIGHT
}
}
// 编辑器文档块接口
export interface EditorBlock {
id: string
tagName: string
rawNode: XmlNode
}
// 虚拟滚动坐标接口
export interface BlockPosition {
id: string
top: number
bottom: number
height: number
}
<template>
<div class="flex-1 flex flex-col min-h-0 bg-transparent">
<template v-if="editorStore.xmlTree">
<!-- 顶部面包屑与属性面板控制 -->
<div
class="px-4 py-2 border-b border-divider bg-fill-2 flex items-center justify-between text-xs shrink-0"
>
<div class="flex items-center space-x-2">
<span class="text-color3 select-none">当前路径:</span>
<n-breadcrumb v-if="nodePath.length > 0">
<n-breadcrumb-item
v-for="n in nodePath"
:key="n.id"
@click="editorStore.setSelectedNodeId(n.id)"
class="cursor-pointer hover:text-primary transition-colors text-color2 font-medium"
>
{{ n.tagName }}
</n-breadcrumb-item>
</n-breadcrumb>
<span v-else class="text-color3 italic">未选择节点</span>
</div>
<!-- 修改属性按钮(触发弹框) -->
<CommonButton
size="tiny"
secondary
:disabled="!selectedNode"
@click="handleEditSelectedNode"
>
<template #icon>
<n-icon>
<settings-outline />
</n-icon>
</template>
修改属性
</CommonButton>
</div>
<!-- 文档编辑区(虚拟滚动容器) -->
<div
ref="viewportRef"
class="flex-1 overflow-y-auto min-h-0 leading-relaxed relative"
@scroll="handleScroll"
>
<!-- 占位撑高,模拟全量内容总高度 -->
<div :style="{ height: totalHeight + 'px', position: 'relative' }">
<!-- 仅渲染可视区块,通过 translateY 定位 -->
<div
:style="{ transform: `translateY(${startOffset}px)` }"
class="absolute top-0 left-0 right-0"
>
<div
v-for="block in visibleBlocks"
:key="block.id"
:ref="(el: any) => setBlockRef(el, block.id)"
class="w-full px-6 py-2 border-b border-divider/30"
>
<DocNodeRenderer :node="block.rawNode" />
</div>
</div>
</div>
</div>
</template>
<template v-else>
<div class="flex-1 flex flex-col items-center justify-center text-color3">
<n-empty size="large" description="请导入或选择文档以开始编辑" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { DocumentTextOutline, SettingsOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import DocNodeRenderer from '../DocNodeRenderer/index.vue'
import { useEditorPanel } from './functionals'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../NodeTree/functionals'
const themeVars = useThemeVars()
const editorStore = useEditorStore()
const {
selectedNode,
nodePath,
editorTitle,
viewportRef,
totalHeight,
startOffset,
visibleBlocks,
handleScroll,
setBlockRef
} = useEditorPanel()
const handleEditSelectedNode = () => {
if (!selectedNode.value) return
addNodeTargetId.value = selectedNode.value.id
addNodeMode.value = 'edit'
addNodeAllowedTags.value = [selectedNode.value.tagName]
addNodeVisible.value = true
}
</script>
<template>
<div class="editor-toolbar flex flex-wrap items-center justify-between px-4 py-2 border-b border-divider bg-fill-2 gap-3 select-none">
<!-- 左侧:内容插入操作区 -->
<div class="flex flex-wrap items-center gap-2">
<!-- 插入位置设置 -->
<div class="flex items-center gap-1.5 bg-fill-3 px-2.5 py-1 rounded-md border border-divider text-xs">
<span class="font-medium text-color3">插入</span>
<span class="font-semibold" :class="insertBelow ? 'text-primary' : 'text-color2'">
{{ insertBelow ? '下方' : '上方' }}
</span>
<n-switch v-model:value="insertBelow" size="small" />
</div>
<n-divider vertical class="!mx-0" />
<!-- 插入元素按钮组 -->
<div class="flex flex-wrap items-center gap-1">
<CommonButton
v-for="btn in greenButtons"
:key="btn.tag"
type="primary"
size="small"
class="insert-btn"
@click="handleInsert(btn.tag)"
>
<template #icon>
<n-icon><component :is="btn.icon" /></n-icon>
</template>
{{ btn.label.replace('插入', '') }}
</CommonButton>
</div>
</div>
<!-- 右侧:全局工具与管理操作区 -->
<div class="flex flex-wrap items-center gap-1.5">
<!-- 翻译辅助组 -->
<div class="flex items-center gap-1 bg-fill-3 px-1 py-0.5 rounded-md border border-divider">
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('batch')">
<template #icon>
<n-icon><language-outline /></n-icon>
</template>
批量翻译
</CommonButton>
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('extract')">
<template #icon>
<n-icon><download-outline /></n-icon>
</template>
提取翻译
</CommonButton>
<CommonButton size="small" quaternary class="util-btn" @click="handleTranslate('search')">
<template #icon>
<n-icon><search-outline /></n-icon>
</template>
搜索翻译
</CommonButton>
</div>
<n-divider vertical class="!mx-0" />
<!-- XML文件管理 -->
<CommonButton size="small" secondary class="xml-btn" :disabled="isUploading" :loading="isUploading" @click="triggerUpload">
<template #icon>
<n-icon><cloud-upload-outline /></n-icon>
</template>
{{ isUploading ? '解析中…' : '导入 XML' }}
</CommonButton>
<input type="file" ref="fileInputRef" style="display: none" accept=".xml" @change="handleFileUpload" />
<CommonButton size="small" secondary class="xml-btn" @click="emit('export')">
<template #icon>
<n-icon><cloud-download-outline /></n-icon>
</template>
导出 XML
</CommonButton>
<CommonButton size="small" type="primary" class="preview-btn" @click="emit('preview')">
<template #icon>
<n-icon><eye-outline /></n-icon>
</template>
预览工卡
</CommonButton>
<n-divider vertical class="!mx-0" />
<!-- 撤销 / 重做 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="small" quaternary :disabled="!canUndo" @click="editorStore.undo()">
<template #icon>
<n-icon><arrow-undo-outline /></n-icon>
</template>
后退
</CommonButton>
</template>
撤销 (Ctrl+Z)
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="small" quaternary :disabled="!canRedo" @click="editorStore.redo()">
<template #icon>
<n-icon><arrow-redo-outline /></n-icon>
</template>
前进
</CommonButton>
</template>
重做 (Ctrl+Y)
</n-tooltip>
<n-divider vertical class="!mx-0" />
<!-- 主题切换 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton size="small" quaternary circle @click="appStore.isDark = !appStore.isDark">
<template #icon>
<n-icon>
<sunny-outline v-if="appStore.isDark" />
<moon-outline v-else />
</n-icon>
</template>
</CommonButton>
</template>
{{ appStore.isDark ? '切换亮色主题' : '切换暗色主题' }}
</n-tooltip>
<!-- 偏好设置 -->
<SettingsDrawer />
</div>
</div>
</template>
<script setup lang="ts">
import {
ImageOutline,
GridOutline,
DocumentTextOutline,
ListOutline,
CalendarOutline,
CalculatorOutline,
CheckboxOutline,
CreateOutline,
LanguageOutline,
DownloadOutline,
SearchOutline,
CloudUploadOutline,
CloudDownloadOutline,
EyeOutline,
ArrowUndoOutline,
ArrowRedoOutline,
SunnyOutline,
MoonOutline
} from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import { useAppStore } from '@/store/app/index'
import SettingsDrawer from '@/layouts/components/SettingsDrawer.vue'
import { parseXmlToTreeAsync } from '@/utils/xmlParser'
const emit = defineEmits(['save', 'validate', 'export', 'preview'])
const editorStore = useEditorStore()
const appStore = useAppStore()
const insertBelow = ref(true)
const fileInputRef = ref<HTMLInputElement | null>(null)
const canUndo = computed(() => editorStore.undoStack.length > 0)
const canRedo = computed(() => editorStore.redoStack.length > 0)
const greenButtons = [
{ label: '插入图片', tag: 'GRAPHIC', icon: ImageOutline },
{ label: '插入表格', tag: 'TABLE', icon: GridOutline },
{ label: '插入模板', tag: 'PRETOPIC', icon: DocumentTextOutline },
{ label: '插入记录项', tag: 'RECORD-LINE', icon: ListOutline },
{ label: '插入校验日期', tag: 'DATE', icon: CalendarOutline },
{ label: '插入单位记录项', tag: 'UNIT-RECORD', icon: CalculatorOutline },
{ label: '插入选项组', tag: 'SELECTION', icon: CheckboxOutline },
{ label: '插入签字点', tag: 'SIGNOFF', icon: CreateOutline }
]
const handleInsert = (tag: string) => editorStore.insertNode(tag, insertBelow.value)
const handleTranslate = (type: 'batch' | 'extract' | 'search') => {
const labelMap = { batch: '批量翻译', extract: '提取翻译', search: '搜索翻译' }
window.$message.info(`已触发 ${labelMap[type]} 功能,自动匹配双语对照。`)
}
const triggerUpload = () => fileInputRef.value?.click()
const isUploading = ref(false)
const handleFileUpload = async (e: Event) => {
const target = e.target as HTMLInputElement
const file = target.files?.[0]
if (!file) return
target.value = ''
const text = await file.text()
isUploading.value = true
window.$message.info(`正在解析 ${file.name}${(file.size / 1024).toFixed(0)} KB),请稍候…`)
try {
const tree = await parseXmlToTreeAsync(text)
editorStore.setXmlTree(tree)
window.$message.success(`${file.name} 解析成功!`)
} catch (err: any) {
window.$message.error('XML 解析失败: ' + err.message)
} finally {
isUploading.value = false
}
}
</script>
<style scoped>
/* 消除工具栏容器本身的 focus outline */
.editor-toolbar {
outline: none;
}
/* 插入按钮:深色文字 + 更紧凑的圆角 */
.editor-toolbar :deep(.insert-btn.n-button) {
--n-border-radius: 6px;
font-weight: 600;
font-size: 12px;
}
/* 翻译辅助按钮:去掉 margin,更紧凑 */
.editor-toolbar :deep(.util-btn.n-button) {
--n-border-radius: 6px;
font-size: 12px;
}
/* XML 管理按钮 */
.editor-toolbar :deep(.xml-btn.n-button) {
--n-border-radius: 6px;
font-size: 12px;
font-weight: 600;
}
/* 预览工卡按钮:突出主色 */
.editor-toolbar :deep(.preview-btn.n-button) {
--n-border-radius: 6px;
font-size: 12px;
font-weight: 700;
}
/* 全局清除 n-button 间的默认 margin-right */
.editor-toolbar :deep(.n-button + .n-button) {
margin-right: 0 !important;
}
/* n-divider vertical 高度统一 */
.editor-toolbar :deep(.n-divider--vertical) {
height: 20px;
margin: 0 4px;
}
</style>
......@@ -12,14 +12,11 @@ export function useListEditor() {
/**
* 判断当前节点是否为列表容器
*/
function isListContainer(tagName: string): boolean {
const isListContainer = (tagName: string): boolean => {
return ALL_LIST_TAGS.includes(tagName)
}
/**
* 根据容器标签获取相对应的列表项子标签
*/
function getItemTagName(containerTag: string): string {
const getItemTagName = (containerTag: string): string => {
switch (containerTag) {
case 'LIST1': return 'L1ITEM'
case 'LIST2': return 'L2ITEM'
......@@ -29,10 +26,7 @@ export function useListEditor() {
}
}
/**
* 提取列表容器内的所有子列表项
*/
function parseListItems(node: XmlNode): ListItemModel[] {
const parseListItems = (node: XmlNode): ListItemModel[] => {
if (!isListContainer(node.tagName)) return []
const itemTagName = getItemTagName(node.tagName)
......@@ -53,10 +47,7 @@ export function useListEditor() {
})
}
/**
* 修改具体列表项的内容,实时同步到列表项子树中
*/
function updateItemText(node: XmlNode, itemId: string, text: string): void {
const updateItemText = (node: XmlNode, itemId: string, text: string): void => {
const itemNode = node.children.find(c => c.id === itemId)
if (!itemNode) return
......@@ -73,10 +64,7 @@ export function useListEditor() {
store.triggerSync()
}
/**
* 添加一个新的列表项
*/
function addItem(node: XmlNode): void {
const addItem = (node: XmlNode): void => {
const itemTag = getItemTagName(node.tagName)
const itemId = crypto.randomUUID()
const newItem: XmlNode = {
......@@ -106,10 +94,7 @@ export function useListEditor() {
store.triggerSync()
}
/**
* 删除一个列表项
*/
function deleteItem(node: XmlNode, itemId: string): void {
const deleteItem = (node: XmlNode, itemId: string): void => {
const idx = node.children.findIndex(c => c.id === itemId)
if (idx !== -1) {
node.children.splice(idx, 1)
......@@ -117,10 +102,7 @@ export function useListEditor() {
}
}
/**
* 移动列表项顺序
*/
function moveItem(node: XmlNode, itemId: string, direction: 'up' | 'down'): void {
const moveItem = (node: XmlNode, itemId: string, direction: 'up' | 'down'): void => {
const idx = node.children.findIndex(c => c.id === itemId)
if (idx === -1) return
......
import type { FormInst } from 'naive-ui'
import { getElementAttributes, createDefaultAttributes, isTextOnlyElement, isMixedContentElement } from '@/utils/dtdManager'
import { useEditorStore } from '@/store/editor'
import type { XmlNode, DtdAttribute } from '@/types/xmlNode'
import type { AttrDef } from '../../../constants'
import { addNodeVisible, addNodeMode, addNodeTargetId, addNodeAllowedTags } from '../../../functionals'
/**
* AddNodeModal 逻辑 Hook
*/
export function useAddNodeModal(formRef: Ref<FormInst | null>) {
const store = useEditorStore()
const saving = ref(false)
// 表单数据
const form = reactive<{
tagName: string
attrs: Record<string, string>
textContent: string
}>({
tagName: '',
attrs: {},
textContent: ''
})
const showTextContentField = computed(() => {
if (!form.tagName) return false
return isTextOnlyElement(form.tagName) || isMixedContentElement(form.tagName)
})
const rules = {
tagName: [{ required: true, message: '请选择标签', trigger: 'change' }]
}
// 弹窗标题
const modalTitle = computed(() => {
const map: Record<string, string> = {
child: '添加子节点',
before: '插入到上方',
after: '插入到下方',
edit: '编辑节点'
}
const nodeId = addNodeTargetId.value
const node = store.nodeMap.get(nodeId)?.node ?? null
if (addNodeMode.value === 'edit') {
return node ? `编辑节点 <${node.tagName}>` : '编辑节点'
}
return node ? `${node.tagName} ${map[addNodeMode.value]}` : map[addNodeMode.value]
})
// 标签下拉选项
const tagOptions = computed(() => {
if (addNodeMode.value === 'edit') {
return form.tagName ? [{ label: form.tagName, value: form.tagName }] : []
}
return addNodeAllowedTags.value.map((tag) => ({ label: tag, value: tag }))
})
// 属性定义列表
const attributeDefs = computed<AttrDef[]>(() => {
if (!form.tagName) return []
const defs = getElementAttributes(form.tagName)
return Object.entries(defs).map(([name, def]: [string, DtdAttribute]) => ({
name,
typeDefinition: def.typeDefinition,
enumValues: def.enumValues,
required: def.requirement === 'REQUIRED',
defaultValue: def.defaultValue
}))
})
// 切换标签时重置属性表单,填入默认值
const onTagChange = (tag: string) => {
const defaults = createDefaultAttributes(tag)
form.attrs = { ...defaults }
}
// 重置表单,在弹窗关闭后自动被 v-model 关联或事件重置
watch(addNodeVisible, (visible) => {
if (visible) {
if (addNodeMode.value === 'edit') {
const nodeId = addNodeTargetId.value
const node = store.nodeMap.get(nodeId)?.node ?? null
if (node) {
form.tagName = node.tagName
form.textContent = node.textContent || ''
const defs = getElementAttributes(node.tagName)
const attrs: Record<string, string> = {}
for (const name of Object.keys(defs)) {
attrs[name] = node.attributes[name] || ''
}
form.attrs = attrs
}
}
} else {
form.tagName = ''
form.attrs = {}
form.textContent = ''
}
})
// 确认编辑/插入
const handleConfirm = async () => {
try {
await formRef.value?.validate()
} catch {
return
}
saving.value = true
try {
const tree = store.xmlTree
if (!tree) return
const targetNode = store.nodeMap.get(addNodeTargetId.value)?.node
if (!targetNode) return
store.saveSnapshot()
if (addNodeMode.value === 'edit') {
targetNode.attributes = { ...form.attrs }
targetNode.textContent = form.textContent
// 如果当前编辑的正是选中的节点,强制更新 store 的选中节点引用以重新渲染视图
if (store.selectedNodeId === targetNode.id) {
store.setSelectedNodeId(targetNode.id)
}
window.$message?.success(`成功更新节点 <${targetNode.tagName}> 的属性与内容`)
addNodeVisible.value = false
} else {
const newId = crypto.randomUUID()
const newNode: XmlNode = {
id: newId,
tagName: form.tagName,
attributes: { ...form.attrs },
children: [],
textContent: form.textContent,
mixedContent: [],
parentId: null
}
if (addNodeMode.value === 'child') {
newNode.parentId = targetNode.id
targetNode.children.push(newNode)
} else {
const parent = store.nodeMap.get(addNodeTargetId.value)?.parent
if (!parent) return
newNode.parentId = parent.id
const idx = parent.children.findIndex((c) => c.id === addNodeTargetId.value)
const insertIdx = addNodeMode.value === 'before' ? idx : idx + 1
parent.children.splice(insertIdx, 0, newNode)
}
store.setSelectedNodeId(newId)
window.$message?.success(`成功插入节点 <${form.tagName}>`)
addNodeVisible.value = false
}
} finally {
saving.value = false
}
}
return {
form,
rules,
saving,
modalTitle,
tagOptions,
attributeDefs,
showTextContentField,
onTagChange,
handleConfirm
}
}
<template>
<CommonModal
v-model="addNodeVisible"
:title="modalTitle"
:width="520"
:loading="saving"
@confirm="handleConfirm"
>
<n-form ref="formRef" :model="form" :rules="rules" label-placement="top" require-mark-placement="right-hanging">
<!-- 标签选择 -->
<n-form-item label="标签" path="tagName">
<n-select
v-model:value="form.tagName"
:options="tagOptions"
placeholder="请选择"
filterable
:disabled="addNodeMode === 'edit'"
@update:value="onTagChange"
/>
</n-form-item>
<!-- 动态属性表单 -->
<template v-if="form.tagName && attributeDefs.length > 0">
<n-divider class="!my-2">
<span class="text-xs text-color3">属性配置</span>
</n-divider>
<n-form-item
v-for="attr in attributeDefs"
:key="attr.name"
:label="attr.name"
:path="`attrs.${attr.name}`"
>
<!-- 枚举类型:select -->
<n-select
v-if="attr.enumValues && attr.enumValues.length > 0"
v-model:value="form.attrs[attr.name]"
:options="attr.enumValues.map(v => ({ label: v, value: v }))"
placeholder="请选择"
/>
<!-- 普通文本 -->
<n-input
v-else
v-model:value="form.attrs[attr.name]"
placeholder="请输入"
/>
<!-- 属性说明 -->
<template v-if="attr.typeDefinition" #feedback>
<span class="text-[10px] text-color3">{{ attr.typeDefinition }}</span>
</template>
</n-form-item>
</template>
<!-- 文本内容编辑 (PCDATA) -->
<template v-if="showTextContentField">
<n-divider class="!my-2">
<span class="text-xs text-color3">文本内容 (PCDATA)</span>
</n-divider>
<n-form-item label="内容" path="textContent">
<n-input
v-model:value="form.textContent"
type="textarea"
placeholder="请输入文本内容"
:autosize="{ minRows: 2, maxRows: 6 }"
/>
</n-form-item>
</template>
</n-form>
</CommonModal>
</template>
<script setup lang="ts">
import type { FormInst } from 'naive-ui'
import { addNodeVisible, addNodeMode } from '../../functionals'
import { useAddNodeModal } from './functionals'
const formRef = ref<FormInst | null>(null)
const {
form,
rules,
saving,
modalTitle,
tagOptions,
attributeDefs,
showTextContentField,
onTagChange,
handleConfirm
} = useAddNodeModal(formRef)
</script>
import { checkRuleData } from '../../../functionals'
/**
* CheckRuleModal 逻辑 Hook
*/
export function useCheckRuleModal() {
// 语法高亮:关键字 + 标签名(使用全局主题样式,禁止硬编码 Hex)
const highlightedRule = computed(() => {
const text = checkRuleData.value.humanReadable || checkRuleData.value.rawModel
return text
.replace(/\|/g, '<span class="text-primary font-bold">|</span>')
.replace(/[?*+]/g, '<span class="text-warning">$&</span>')
.replace(/[()]/g, '<span class="text-color3">$&</span>')
.replace(/([A-Z][A-Z0-9\-]*)/g, '<span class="text-success font-semibold">$1</span>')
.replace(/#PCDATA/g, '<span class="text-danger">#PCDATA</span>')
})
return {
highlightedRule
}
}
<template>
<CommonModal v-model="checkRuleVisible" :title="`查看节点规则:${checkRuleData.nodeName}`" :width="640" :show-confirm="false" cancel-text="关闭">
<!-- 原始内容模型 -->
<div class="px-3 py-2 rounded bg-fill-2 border border-divider font-mono text-xs text-color2 leading-relaxed break-all">
{{ checkRuleData.rawModel }}
</div>
<!-- 人类可读的格式化规则 -->
<div class="rounded overflow-hidden border border-divider">
<div class="bg-fill-3 p-4 font-mono text-xs text-color1 leading-loose whitespace-pre">
<span v-html="highlightedRule"></span>
</div>
</div>
</CommonModal>
</template>
<script setup lang="ts">
import { checkRuleVisible, checkRuleData } from '../../functionals'
import { useCheckRuleModal } from './functionals'
const { highlightedRule } = useCheckRuleModal()
</script>
// 列表/文档型叶子节点标签判定
export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'WARNING', 'CAUTION', 'NOTE']
// 查看规则弹窗数据定义
export interface CheckRuleData {
nodeName: string
rawModel: string
humanReadable: string
}
// 插入节点模式类型
export type InsertMode = 'child' | 'before' | 'after' | 'edit'
// 属性定义接口 (用于 AddNodeModal.vue)
export interface AttrDef {
name: string
typeDefinition: string
enumValues: string[] | null
required: boolean
defaultValue: string | null
}
import type { XmlNode } from '@/types/xmlNode'
export interface CellParagraphModel {
id: string;
tagName: string;
text: string;
}
export interface TableCellModel {
id: string;
attributes: Record<string, string>;
paragraphs: CellParagraphModel[];
}
export interface TableRowModel {
id: string;
cells: TableCellModel[];
}
export interface TableStructureModel {
cols: number;
colSpecs: XmlNode[];
theadRows: TableRowModel[];
tbodyRows: TableRowModel[];
tgroupId: string;
theadId: string;
tbodyId: string;
}
// 表格组件相关的提示文本定义
export const CELL_EDIT_TIP = '提示:表格支持直接点击单元格进行双击/聚焦修改,光标离开时自动保存文本。'
export const DEFAULT_CELL_TEXT = '新单元格'
import { useEditorStore } from '@/store/editor'
import type { XmlNode } from '@/types/xmlNode'
import type { TableRowModel, TableStructureModel } from '../constants'
import type { TableCellModel, TableRowModel, TableStructureModel } from '../constants'
const getDeepText = (node: XmlNode): string => {
if (node.textContent) return node.textContent
if (node.mixedContent && node.mixedContent.length > 0) {
return node.mixedContent
.map(item => {
if (item.type === 'text') return item.text || ''
if (item.type === 'element' && item.nodeId) {
const child = node.children.find(c => c.id === item.nodeId)
return child ? getDeepText(child) : ''
}
return ''
})
.join('')
}
if (node.children && node.children.length > 0) {
return node.children.map(getDeepText).join('')
}
return ''
}
const getCellText = (cellNode: XmlNode): string => {
if (cellNode.textContent) return cellNode.textContent
const parac = cellNode.children.find(c => c.tagName === 'PARAC')
if (parac) {
return getDeepText(parac)
}
const para = cellNode.children.find(c => c.tagName === 'PARA')
if (para) {
return getDeepText(para)
}
if (cellNode.children.length > 0) {
return getDeepText(cellNode.children[0])
}
return ''
}
const setDeepText = (node: XmlNode, text: string): void => {
if (node.children && node.children.length > 0) {
for (const child of node.children) {
setDeepText(child, text)
}
node.textContent = ''
node.mixedContent = []
} else {
node.textContent = text
if (node.mixedContent.length > 0) {
node.mixedContent = [{ type: 'text', text }]
}
}
}
/**
* CALS 表格编辑器 (TableEditor) 组件专用 Hook 逻辑
......@@ -11,7 +63,7 @@ export function useTableEditor() {
/**
* 辅助查找当前节点树中的 TGROUP 节点
*/
function findTgroup(node: XmlNode): XmlNode | null {
const findTgroup = (node: XmlNode): XmlNode | null => {
if (node.tagName === 'TGROUP') return node
if (node.tagName === 'TABLE') {
const tgroup = node.children.find(c => c.tagName === 'TGROUP')
......@@ -20,13 +72,10 @@ export function useTableEditor() {
return null
}
/**
* 解析 TABLE/TGROUP 结构,转化为可视化渲染的模型
*/
function parseTable(node: XmlNode): TableStructureModel {
const parseTable = (node: XmlNode): TableStructureModel => {
const tgroup = findTgroup(node)
if (!tgroup) {
return { cols: 0, colSpecs: [], theadRows: [], tbodyRows: [] }
return { cols: 0, colSpecs: [], theadRows: [], tbodyRows: [], tgroupId: '', theadId: '', tbodyId: '' }
}
const cols = parseInt(tgroup.attributes.COLS || '0', 10) || 1
......@@ -40,20 +89,43 @@ export function useTableEditor() {
return sectionNode.children
.filter(c => c.tagName === 'ROW')
.map(rowNode => {
const cells = rowNode.children
const cells: TableCellModel[] = rowNode.children
.filter(c => c.tagName === 'ENTRY')
.map(cellNode => ({
id: cellNode.id,
text: cellNode.textContent || '',
attributes: { ...cellNode.attributes }
.map(cellNode => {
let paragraphs = cellNode.children
.filter(c => c.tagName === 'PARAC' || c.tagName === 'PARA')
.map(pNode => ({
id: pNode.id,
tagName: pNode.tagName,
text: getDeepText(pNode)
}))
if (paragraphs.length === 0) {
paragraphs.push({
id: cellNode.id,
tagName: 'ENTRY',
text: cellNode.textContent || ''
})
}
return {
id: cellNode.id,
attributes: { ...cellNode.attributes },
paragraphs
}
})
// 补齐缺少的列,避免渲染空洞
while (cells.length < cols) {
const cellId = crypto.randomUUID()
cells.push({
id: crypto.randomUUID(),
text: '',
attributes: {}
id: cellId,
attributes: {},
paragraphs: [{
id: cellId,
tagName: 'ENTRY',
text: ''
}]
})
}
......@@ -68,14 +140,14 @@ export function useTableEditor() {
cols,
colSpecs,
theadRows: parseRows(thead),
tbodyRows: parseRows(tbody)
tbodyRows: parseRows(tbody),
tgroupId: tgroup.id,
theadId: thead?.id || '',
tbodyId: tbody?.id || ''
}
}
/**
* 创建一个默认的 ENTRY 单元格
*/
function createDefaultEntry(parentRowId: string): XmlNode {
const createDefaultEntry = (parentRowId: string): XmlNode => {
return {
id: crypto.randomUUID(),
tagName: 'ENTRY',
......@@ -87,19 +159,22 @@ export function useTableEditor() {
}
}
/**
* 更新具体单元格的文本值
*/
function updateCellText(node: XmlNode, cellId: string, text: string): void {
const updateCellText = (node: XmlNode, cellId: string, text: string): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
const findAndChange = (currentNode: XmlNode): boolean => {
if (currentNode.id === cellId) {
if (currentNode.children && currentNode.children.length > 0) {
for (const child of currentNode.children) {
setDeepText(child, text)
}
} else {
currentNode.textContent = text
if (currentNode.mixedContent.length > 0) {
currentNode.mixedContent = [{ type: 'text', text }]
}
}
return true
}
for (const child of currentNode.children) {
......@@ -112,10 +187,7 @@ export function useTableEditor() {
store.triggerSync()
}
/**
* 添加一行数据
*/
function addRow(node: XmlNode, section: 'THEAD' | 'TBODY' = 'TBODY'): void {
const addRow = (node: XmlNode, section: 'THEAD' | 'TBODY' = 'TBODY'): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
......@@ -153,10 +225,7 @@ export function useTableEditor() {
store.triggerSync()
}
/**
* 删除一行数据
*/
function deleteRow(node: XmlNode, rowId: string): void {
const deleteRow = (node: XmlNode, rowId: string): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
......@@ -178,10 +247,7 @@ export function useTableEditor() {
}
}
/**
* 增加一列数据
*/
function addColumn(node: XmlNode): void {
const addColumn = (node: XmlNode): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
......@@ -227,10 +293,7 @@ export function useTableEditor() {
store.triggerSync()
}
/**
* 删除一列数据
*/
function deleteColumn(node: XmlNode, colIndex: number): void {
const deleteColumn = (node: XmlNode, colIndex: number): void => {
const tgroup = findTgroup(node)
if (!tgroup) return
......
......@@ -14,7 +14,7 @@ import { DEFAULT_INSERT_TEXT_VAL } from '../constants'
export function useTextBlockEditor() {
const store = useEditorStore()
function parseSlices(node: XmlNode): SliceItem[] {
const parseSlices = (node: XmlNode): SliceItem[] => {
const isMixed = isMixedContentElement(node.tagName)
if (!isMixed) return []
......@@ -49,7 +49,7 @@ export function useTextBlockEditor() {
return list
}
function syncSlices(nodeId: string, slicesList: SliceItem[]): void {
const syncSlices = (nodeId: string, slicesList: SliceItem[]): void => {
const newMixedContent: MixedContentItem[] = []
const newChildren: XmlNode[] = []
......@@ -80,7 +80,7 @@ export function useTextBlockEditor() {
store.updateSelectedNodeMixedContent(newMixedContent, newChildren)
}
function insertSlice(slicesList: SliceItem[], key: string, index: number): SliceItem[] {
const insertSlice = (slicesList: SliceItem[], key: string, index: number): SliceItem[] => {
const newList = [...slicesList]
if (key === 'insert-text') {
newList.splice(index, 0, {
......@@ -100,7 +100,7 @@ export function useTextBlockEditor() {
return newList
}
function moveSlice(slicesList: SliceItem[], index: number, direction: 'up' | 'down'): SliceItem[] {
const moveSlice = (slicesList: SliceItem[], index: number, direction: 'up' | 'down'): SliceItem[] => {
const newList = [...slicesList]
const target = direction === 'up' ? index - 1 : index + 1
if (target < 0 || target >= newList.length) return newList
......
......@@ -3,7 +3,6 @@ import { loadDtdSchema, getElementRule } from '@/utils/dtdManager'
import { parseXmlToTree, serializeTreeToXml } from '@/utils/xmlParser'
import type { XmlNode } from '@/types/xmlNode'
import { DEFAULT_FILE_NAME } from '../constants'
import { h } from 'vue'
// 静态导入 DTD JSON
import dtdJson from '@/assets/json/dtd.json'
......@@ -13,10 +12,10 @@ import xmlText from '@/assets/file/AMEA-A282400-02-1_0_0.xml?raw'
/**
* 工卡 XML 编辑器核心业务逻辑 Hook
*/
export function useXmlEditor() {
export function useEditor() {
const store = useEditorStore()
function initialize(): void {
const initialize = (): void => {
try {
loadDtdSchema(dtdJson as any)
const tree = parseXmlToTree(xmlText)
......@@ -29,14 +28,14 @@ export function useXmlEditor() {
}
}
function save(): void {
const save = (): void => {
if (!store.xmlTree) return
const xml = serializeTreeToXml(store.xmlTree)
console.log('保存的 XML 数据:\n', xml)
window.$message.success('本地修改已保存(可查看浏览器控制台输出)')
}
function exportXml(): void {
const exportXml = (): void => {
if (!store.xmlTree) return
try {
const xml = serializeTreeToXml(store.xmlTree)
......@@ -54,7 +53,7 @@ export function useXmlEditor() {
}
}
function getAllNodeKeys(): string[] {
const getAllNodeKeys = (): string[] => {
if (!store.xmlTree) return []
const keys: string[] = []
const collect = (node: XmlNode) => {
......@@ -65,7 +64,7 @@ export function useXmlEditor() {
return keys
}
function validate(): void {
const validate = (): void => {
if (!store.xmlTree) return
const warnings: string[] = []
......
<template>
<div class="flex-1 flex flex-col min-h-0 bg-transparent">
<!-- 头部工具栏 -->
<EditorToolbar
@save="handleSave"
@validate="handleValidate"
@export="handleExport"
@expand-all="handleExpandAll"
@collapse-all="handleCollapseAll"
/>
<!-- 主体布局:自定义可拖拽分割面板 -->
<div
ref="containerRef"
class="flex-1 flex min-h-0 overflow-hidden relative"
>
<!-- 左侧:节点树 -->
<div
class="flex flex-col overflow-hidden shrink-0 transition-[width] duration-150"
:class="isCollapsed ? '' : 'border-r border-divider'"
:style="{ width: isCollapsed ? '0px' : leftWidthPx + 'px' }"
>
<NodeTree v-model:expandedKeys="expandedKeys" />
</div>
<!-- 拖动分割条 -->
<div
class="split-divider group"
:class="{ 'is-dragging': isDragging, 'is-collapsed': isCollapsed }"
@mousedown.prevent="startDrag"
@click="handleDividerClick"
>
<!-- 折叠状态:展开箭头 -->
<div v-if="isCollapsed" class="split-expand-btn">
<svg class="w-3 h-3" viewBox="0 0 12 12" fill="currentColor">
<path d="M4 2l4 4-4 4" stroke="currentColor" stroke-width="1.5" fill="none" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<!-- 展开状态:可视指示线 + 手柄圆点 -->
<template v-else>
<div class="split-divider-line" />
<div class="split-divider-handle">
<div class="handle-dot" />
<div class="handle-dot" />
<div class="handle-dot" />
</div>
</template>
</div>
<!-- 右侧:编辑区 -->
<div
class="flex-1 flex flex-col overflow-hidden"
:style="{ background: themeVars.colorBg1 }"
>
<EditorPanel />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEditorStore } from '@/store/editor'
import { useEditor } from './functionals'
import EditorToolbar from './components/EditorToolbar/index.vue'
import NodeTree from './components/NodeTree/index.vue'
import EditorPanel from './components/EditorPanel/index.vue'
const themeVars = useThemeVars()
const editorStore = useEditorStore()
const expandedKeys = ref<string[]>([])
// 实例化业务逻辑 Hook
const { initialize, save, exportXml, getAllNodeKeys, validate } = useEditor()
onMounted(() => {
initialize()
})
// ── 自定义拖动分割逻辑 ────────────────────────────────────────────────────────
const containerRef = ref<HTMLElement | null>(null)
const COLLAPSE_THRESHOLD = 150 // px:低于此宽度时自动折叠
const DEFAULT_WIDTH = 560 // px:初始/恢复宽度
const MAX_RATIO = 0.6
const leftWidthPx = ref(DEFAULT_WIDTH)
const isCollapsed = ref(false)
const isDragging = ref(false)
let startX = 0
let startWidth = 0
let hasDragged = false // 区分拖动与点击
const startDrag = (e: MouseEvent) => {
isDragging.value = true
hasDragged = false
startX = e.clientX
// 折叠状态下从 0 开始拖动
startWidth = isCollapsed.value ? 0 : leftWidthPx.value
document.body.style.cursor = 'col-resize'
document.body.style.userSelect = 'none'
window.addEventListener('mousemove', onDrag)
window.addEventListener('mouseup', stopDrag)
}
const onDrag = (e: MouseEvent) => {
if (!isDragging.value || !containerRef.value) return
const delta = e.clientX - startX
if (Math.abs(delta) > 3) hasDragged = true
const raw = startWidth + delta
const containerWidth = containerRef.value.clientWidth
const maxWidth = containerWidth * MAX_RATIO
if (raw < COLLAPSE_THRESHOLD) {
// 低于阈值:预览折叠
leftWidthPx.value = Math.max(0, raw)
isCollapsed.value = raw < COLLAPSE_THRESHOLD / 2
} else {
isCollapsed.value = false
leftWidthPx.value = Math.min(maxWidth, raw)
}
}
const stopDrag = () => {
isDragging.value = false
document.body.style.cursor = ''
document.body.style.userSelect = ''
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
// 松手后:如果宽度低于阈值,完全折叠
if (!isCollapsed.value && leftWidthPx.value < COLLAPSE_THRESHOLD) {
isCollapsed.value = true
}
// 如果宽度非常小但没折叠,恢复到最小可用宽度
if (!isCollapsed.value && leftWidthPx.value < COLLAPSE_THRESHOLD) {
leftWidthPx.value = COLLAPSE_THRESHOLD
}
}
// 点击分割条:折叠时展开,展开时无操作(避免误触)
const handleDividerClick = () => {
if (hasDragged) return
if (isCollapsed.value) {
isCollapsed.value = false
leftWidthPx.value = DEFAULT_WIDTH
}
}
onUnmounted(() => {
window.removeEventListener('mousemove', onDrag)
window.removeEventListener('mouseup', stopDrag)
})
// ── 工具栏事件 ────────────────────────────────────────────────────────────────
const handleSave = () => save()
const handleExport = () => exportXml()
const handleExpandAll = () => { expandedKeys.value = getAllNodeKeys() }
const handleCollapseAll = () => {
if (editorStore.xmlTree) {
expandedKeys.value = [editorStore.xmlTree.id]
}
}
const handleValidate = () => validate()
</script>
<style scoped>
/* ── 分割条容器 ─────────────────────────────────────────────────────────────── */
.split-divider {
position: relative;
width: 10px;
flex-shrink: 0;
cursor: col-resize;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
transition: background-color 0.2s;
}
.split-divider:hover,
.split-divider.is-dragging {
background-color: var(--primary-color, #18a058)1a;
}
/* ── 可视线 ───────────────────────────────────────────────────────────────── */
.split-divider-line {
position: absolute;
top: 0;
bottom: 0;
left: 50%;
width: 1px;
transform: translateX(-50%);
background-color: var(--divider-color, rgba(0, 0, 0, 0.08));
transition: background-color 0.2s, width 0.2s;
}
.split-divider:hover .split-divider-line,
.split-divider.is-dragging .split-divider-line {
background-color: var(--primary-color, #18a058);
width: 2px;
}
/* ── 手柄圆点 ────────────────────────────────────────────────────────────── */
.split-divider-handle {
position: relative;
z-index: 1;
display: flex;
flex-direction: column;
gap: 3px;
padding: 4px 3px;
border-radius: 8px;
background: var(--fill-color-2, rgba(0, 0, 0, 0.04));
border: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
opacity: 0;
transform: scaleY(0.8);
transition: opacity 0.2s, transform 0.2s, background 0.2s;
}
.split-divider:hover .split-divider-handle,
.split-divider.is-dragging .split-divider-handle {
opacity: 1;
transform: scaleY(1);
background: var(--primary-color, #18a058);
border-color: var(--primary-color, #18a058);
}
.handle-dot {
width: 4px;
height: 4px;
border-radius: 50%;
background-color: var(--divider-color, rgba(0, 0, 0, 0.2));
transition: background-color 0.2s;
}
.split-divider:hover .handle-dot,
.split-divider.is-dragging .handle-dot {
background-color: #fff;
}
/* ── 折叠状态 ─────────────────────────────────────────────────────────────── */
.split-divider.is-collapsed {
width: 16px;
cursor: pointer;
background-color: var(--fill-color-3, rgba(0, 0, 0, 0.06));
border-right: 1px solid var(--divider-color, rgba(0, 0, 0, 0.08));
}
.split-divider.is-collapsed:hover {
background-color: color-mix(in srgb, var(--primary-color, #18a058) 12%, transparent);
}
.split-expand-btn {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
color: var(--text-color-3, rgba(0, 0, 0, 0.38));
transition: color 0.2s;
}
.split-divider.is-collapsed:hover .split-expand-btn {
color: var(--primary-color, #18a058);
}
</style>
<template>
<div class="flex-1 flex flex-col min-h-0 bg-transparent">
<!-- 头部工具栏 -->
<EditorToolbar
@save="handleSave"
@validate="handleValidate"
@export="handleExport"
@expand-all="handleExpandAll"
@collapse-all="handleCollapseAll"
/>
<!-- 主体布局 -->
<div class="flex-1 flex min-h-0">
<!-- 左侧节点树导航 -->
<div class="w-80 flex-shrink-0 h-full flex flex-col">
<NodeTree v-model:expandedKeys="expandedKeys" />
</div>
<!-- 右侧核心编辑区 -->
<div class="flex-1 h-full flex flex-col overflow-hidden" :style="{ background: themeVars.bodyColor }">
<EditorPanel />
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { useEditorStore } from '@/store/editor'
import { useXmlEditor } from './xmlEditor/functionals'
import EditorToolbar from './xmlEditor/components/EditorToolbar/index.vue'
import NodeTree from './xmlEditor/components/NodeTree/index.vue'
import EditorPanel from './xmlEditor/components/EditorPanel/index.vue'
const themeVars = useThemeVars()
const editorStore = useEditorStore()
const expandedKeys = ref<string[]>([])
// 实例化业务逻辑 Hook
const { initialize, save, exportXml, getAllNodeKeys, validate } = useXmlEditor()
onMounted(() => {
initialize()
})
// 保存操作
function handleSave() {
save()
}
// 导出文件操作
function handleExport() {
exportXml()
}
// 全部展开
function handleExpandAll() {
expandedKeys.value = getAllNodeKeys()
}
// 全部折叠
function handleCollapseAll() {
if (editorStore.xmlTree) {
expandedKeys.value = [editorStore.xmlTree.id]
}
}
// 基于 DTD 的静态语法检测
function handleValidate() {
validate()
}
</script>
<style scoped>
</style>
// 列表节点标签定义
export const ALL_LIST_TAGS = ['LIST1', 'LIST2', 'LIST3', 'UNLIST']
// 表格节点标签定义
export const CALS_TABLE_TAGS = ['TABLE', 'TGROUP']
/**
* EditorPanel 组件级业务方法
*/
export class EditorPanelClass {
// 暂无组件私有逻辑
}
<template>
<div class="flex-1 flex flex-col min-h-0 bg-transparent">
<template v-if="selectedNode">
<!-- 顶部面包屑路径 -->
<div
class="px-4 py-2 border-b border-divider bg-fill-2 flex items-center space-x-2 text-xs"
>
<span class="text-color3 select-none">当前路径:</span>
<n-breadcrumb>
<n-breadcrumb-item
v-for="n in nodePath"
:key="n.id"
@click="editorStore.setSelectedNodeId(n.id)"
class="cursor-pointer hover:text-primary transition-colors text-color2 font-medium"
>
{{ n.tagName }}
</n-breadcrumb-item>
</n-breadcrumb>
</div>
<!-- 编辑主体区域 -->
<div class="flex-1 overflow-auto p-4 space-y-4 flex flex-col min-h-0">
<!-- 属性面板 -->
<AttributeEditor :node="selectedNode" />
<!-- 内容面板 -->
<div
class="flex-1 flex flex-col rounded-lg border border-divider shadow-sm overflow-hidden bg-fill-2"
>
<div class="px-4 py-3 border-b border-divider flex items-center space-x-1">
<n-icon :color="themeVars.primaryColor"><document-text-outline /></n-icon>
<span class="text-sm font-bold text-color1">{{ editorTitle }}</span>
</div>
<!-- 可视化编辑器按节点类型动态分流 -->
<component :is="activeEditor" :node="selectedNode" class="flex-1" />
</div>
</div>
</template>
<template v-else>
<div class="flex-1 flex flex-col items-center justify-center text-color3">
<n-empty size="large" description="请在左侧菜单树选择节点以开始编辑" />
</div>
</template>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { DocumentTextOutline } from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import { getNodePath } from '@/utils/xmlParser'
import AttributeEditor from '../AttributeEditor/index.vue'
import TextBlockEditor from '../TextBlockEditor/index.vue'
import TableEditor from '../TableEditor/index.vue'
import ListEditor from '../ListEditor/index.vue'
import { CALS_TABLE_TAGS, ALL_LIST_TAGS } from './constants'
const themeVars = useThemeVars()
const editorStore = useEditorStore()
const selectedNode = computed(() => editorStore.selectedNode)
const nodePath = computed(() => {
if (!editorStore.xmlTree || !editorStore.selectedNodeId) return []
return getNodePath(editorStore.xmlTree, editorStore.selectedNodeId)
})
// 根据不同节点,展示对应样式的标题
const editorTitle = computed(() => {
if (!selectedNode.value) return '内容编辑'
const tag = selectedNode.value.tagName
if (CALS_TABLE_TAGS.includes(tag)) return 'CALS 可视化表格编辑'
if (ALL_LIST_TAGS.includes(tag)) return '层级列表可视化编辑'
return '内容编辑'
})
// 核心:分流渲染逻辑
const activeEditor = computed(() => {
if (!selectedNode.value) return TextBlockEditor
const tag = selectedNode.value.tagName
if (CALS_TABLE_TAGS.includes(tag)) {
return TableEditor
}
if (ALL_LIST_TAGS.includes(tag)) {
return ListEditor
}
return TextBlockEditor
})
</script>
<style scoped>
</style>
<template>
<div
class="flex items-center justify-between px-4 py-3 border-b border-divider bg-fill-2 transition-colors"
>
<!-- 左侧操作组 -->
<div class="flex items-center space-x-2">
<div class="flex items-center border border-divider rounded overflow-hidden">
<CommonButton type="primary" size="small" @click="emit('save')">
<template #icon>
<n-icon><save-outline /></n-icon>
</template>
保存
</CommonButton>
<CommonButton size="small" @click="emit('validate')" secondary class="border-l border-divider">
<template #icon>
<n-icon><checkmark-circle-outline /></n-icon>
</template>
验证 DTD
</CommonButton>
</div>
<n-divider vertical />
<!-- 撤销/重做 -->
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="small"
quaternary
:disabled="!canUndo"
@click="editorStore.undo()"
>
<template #icon>
<n-icon><arrow-undo-outline /></n-icon>
</template>
</CommonButton>
</template>
撤销 (Ctrl+Z)
</n-tooltip>
<n-tooltip trigger="hover">
<template #trigger>
<CommonButton
size="small"
quaternary
:disabled="!canRedo"
@click="editorStore.redo()"
>
<template #icon>
<n-icon><arrow-redo-outline /></n-icon>
</template>
</CommonButton>
</template>
重做 (Ctrl+Y)
</n-tooltip>
<n-divider vertical />
<!-- 展开/折叠 -->
<CommonButton size="small" secondary @click="emit('expand-all')">
展开全部
</CommonButton>
<CommonButton size="small" secondary @click="emit('collapse-all')" class="ml-2">
折叠全部
</CommonButton>
</div>
<!-- 右侧辅助组 -->
<div class="flex items-center space-x-2">
<n-tag :type="isValid ? 'success' : 'warning'" size="small" round>
{{ isValid ? 'DTD 验证通过' : '未验证 / 存在警告' }}
</n-tag>
<CommonButton size="small" tertiary @click="emit('export')">
<template #icon>
<n-icon><download-outline /></n-icon>
</template>
导出 XML
</CommonButton>
</div>
</div>
</template>
<script setup lang="ts">
import {
SaveOutline,
CheckmarkCircleOutline,
ArrowUndoOutline,
ArrowRedoOutline,
DownloadOutline
} from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
const emit = defineEmits(['save', 'validate', 'export', 'expand-all', 'collapse-all'])
const editorStore = useEditorStore()
const canUndo = computed(() => editorStore.undoStack.length > 0)
const canRedo = computed(() => editorStore.redoStack.length > 0)
const isValid = ref(true)
</script>
<style scoped>
</style>
// 列表/文档型叶子节点标签判定
export const DOCUMENT_LIKE_TAGS = ['PARA', 'PARAC', 'TITLE', 'TITLEC', 'WARNING', 'CAUTION', 'NOTE']
import { useEditorStore } from '@/store/editor'
import { h } from 'vue'
import type { TreeOption, DropdownOption } from 'naive-ui'
import type { XmlNode } from '@/types/xmlNode'
import { findNodeById, findParentNode } from '@/utils/xmlParser'
import {
getAllowedChildren,
getInsertableChildren,
canDeleteChild,
createDefaultAttributes
} from '@/utils/dtdManager'
/**
* 节点树 (NodeTree) 组件专用 Hook 逻辑
*/
export function useNodeTree() {
const store = useEditorStore()
/**
* 将 XML 内部节点树结构,转换为 Naive UI n-tree 组件接收的 Option 格式
*/
function convertNodeToOption(node: XmlNode): TreeOption {
let subtitle = ''
if (node.attributes.ID) {
subtitle = ` : ${node.attributes.ID}`
} else if (node.attributes.EFFECT) {
subtitle = ` : ${node.attributes.EFFECT}`
} else if (node.textContent && node.textContent.trim().length > 0) {
const clean = node.textContent.trim()
subtitle = ` : ${clean.length > 25 ? clean.substring(0, 25) + '...' : clean}`
}
const option: TreeOption = {
key: node.id,
label: node.tagName,
tagName: node.tagName,
subtitle: subtitle,
children: []
}
if (node.children && node.children.length > 0) {
option.children = node.children.map(c => convertNodeToOption(c))
}
return option
}
/**
* 自定义树节点渲染 Label 方法
*/
function renderLabel({ option }: { option: TreeOption }) {
const tagName = (option.tagName as string) || (option.label as string)
const subtitle = (option.subtitle as string) || ''
return h('div', { class: 'flex items-center space-x-1' }, [
h('span', { class: 'font-bold text-sm' }, tagName),
subtitle ? h('span', { class: 'text-xs text-secondary italic' }, subtitle) : null
])
}
/**
* 动态生成右键菜单选项,支持基于 DTD 的添加和删除校验
*/
function getDropdownOptions(nodeId: string): DropdownOption[] {
const tree = store.xmlTree
if (!tree) return []
const node = findNodeById(tree, nodeId)
if (!node) return []
const options: DropdownOption[] = []
const allowed = getAllowedChildren(node.tagName)
if (allowed.length > 0) {
const insertable = getInsertableChildren(node.tagName, node.children.map(c => c.tagName))
const subOptions = insertable.map(tag => ({
label: tag,
key: `add-child-${tag}`
}))
options.push({
label: '添加子节点',
key: 'add-child',
children: subOptions.length > 0 ? subOptions : [{ label: '无可用子节点 (超约束上限)', key: 'none', disabled: true }]
})
}
const parent = findParentNode(tree, nodeId)
if (parent) {
const childCount = parent.children.filter(c => c.tagName === node.tagName).length
const deletable = canDeleteChild(parent.tagName, node.tagName, childCount)
options.push({
label: deletable ? '删除该节点' : '删除该节点 (不可删,受 DTD 约束)',
key: 'delete-node',
disabled: !deletable
})
}
if (parent) {
const parentAllowed = getAllowedChildren(parent.tagName)
const parentInsertable = getInsertableChildren(parent.tagName, parent.children.map(c => c.tagName))
const insertableOptions = parentInsertable.map(tag => ({
label: tag,
key: `insert-sibling-${tag}`
}))
if (insertableOptions.length > 0) {
options.push({
label: '在后方插入兄弟节点',
key: 'insert-sibling',
children: insertableOptions
})
}
}
return options
}
/**
* 分发并执行右键菜单指令
*/
async function handleDropdownAction(key: string, nodeId: string): Promise<void> {
const tree = store.xmlTree
if (!tree) return
const node = findNodeById(tree, nodeId)
if (!node) return
const parent = findParentNode(tree, nodeId)
if (key.startsWith('add-child-')) {
const childTag = key.replace('add-child-', '')
const childId = crypto.randomUUID()
const newChild: XmlNode = {
id: childId,
tagName: childTag,
attributes: createDefaultAttributes(childTag),
children: [],
textContent: '',
mixedContent: [],
parentId: nodeId
}
store.addChildNode(newChild)
store.setSelectedNodeId(childId)
window.$message.success(`成功添加子节点 <${childTag}>`)
}
else if (key === 'delete-node') {
if (nodeId === tree.id) {
window.$message.warning('不能删除根节点')
return
}
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: `确定要删除节点 <${node.tagName}> 吗?该操作将连带删除其所有子节点!`
})
store.deleteSelectedNode()
window.$message.success('节点删除成功')
} catch (e) {
// 取消删除
}
}
else if (key.startsWith('insert-sibling-')) {
if (!parent) return
const sibTag = key.replace('insert-sibling-', '')
const sibId = crypto.randomUUID()
const newSib: XmlNode = {
id: sibId,
tagName: sibTag,
attributes: createDefaultAttributes(sibTag),
children: [],
textContent: '',
mixedContent: [],
parentId: parent.id
}
const currIdx = parent.children.findIndex(c => c.id === nodeId)
store.saveSnapshot()
parent.children.splice(currIdx + 1, 0, newSib)
store.setSelectedNodeId(sibId)
window.$message.success(`成功在后方插入兄弟节点 <${sibTag}>`)
}
}
return {
convertNodeToOption,
renderLabel,
getDropdownOptions,
handleDropdownAction
}
}
<template>
<div class="flex flex-col h-full border-r border-divider relative">
<!-- 搜索过滤 -->
<div class="p-3 border-b border-divider">
<n-input v-model:value="pattern" placeholder="搜索节点名称..." size="small">
<template #prefix>
<n-icon><search-outline /></n-icon>
</template>
</n-input>
</div>
<!-- 树组件容器 -->
<div class="flex-1 overflow-auto p-2">
<n-tree
v-if="treeData.length > 0"
block-line
expand-on-click
:data="treeData"
:expanded-keys="expandedKeys"
:selected-keys="selectedKeys"
:pattern="pattern"
:render-label="renderLabel"
:render-prefix="renderPrefix"
@update:selected-keys="handleSelect"
@update:expanded-keys="handleExpand"
@node-contextmenu="handleContextMenu"
/>
<div v-else class="h-full flex items-center justify-center text-color3">
<n-empty description="暂无节点数据" />
</div>
</div>
<!-- 右键下拉菜单 -->
<n-dropdown
trigger="manual"
placement="bottom-start"
:show="showDropdown"
:options="dropdownOptions"
:x="dropdownX"
:y="dropdownY"
@clickoutside="showDropdown = false"
@select="handleDropdownSelect"
/>
</div>
</template>
<script setup lang="ts">
import type { TreeOption, DropdownOption } from 'naive-ui'
import { NIcon } from 'naive-ui'
import {
SearchOutline,
CodeWorkingOutline,
DocumentTextOutline,
FolderOpenOutline
} from '@vicons/ionicons5'
import { useEditorStore } from '@/store/editor'
import { useNodeTree } from './functionals'
import { DOCUMENT_LIKE_TAGS } from './constants'
const props = defineProps<{
expandedKeys: string[]
}>()
const emit = defineEmits(['update:expandedKeys'])
const editorStore = useEditorStore()
const { convertNodeToOption, renderLabel, getDropdownOptions, handleDropdownAction } = useNodeTree()
const pattern = ref('')
const selectedKeys = computed(() => editorStore.selectedNodeId ? [editorStore.selectedNodeId] : [])
// 下拉菜单状态
const showDropdown = ref(false)
const dropdownX = ref(0)
const dropdownY = ref(0)
const contextNodeId = ref<string | null>(null)
// 转换数据源,委托给 Hook
const treeData = computed(() => {
if (!editorStore.xmlTree) return []
return [convertNodeToOption(editorStore.xmlTree)]
})
// 默认展开所有一级子节点
const expandedKeys = ref<string[]>([])
watch(() => editorStore.xmlTree, (newVal) => {
if (newVal && expandedKeys.value.length === 0) {
const keys = [newVal.id, ...newVal.children.map((c: any) => c.id)]
expandedKeys.value = keys
emit('update:expandedKeys', keys)
}
}, { immediate: true })
watch(() => props.expandedKeys, (keys) => {
expandedKeys.value = keys
})
function handleSelect(keys: string[]) {
if (keys.length > 0) {
editorStore.setSelectedNodeId(keys[0])
}
}
function handleExpand(keys: string[]) {
expandedKeys.value = keys
emit('update:expandedKeys', keys)
}
function renderPrefix({ option }: { option: TreeOption }) {
const tagName = option.tagName as string
let icon = CodeWorkingOutline
if (option.children && option.children.length > 0) {
icon = FolderOpenOutline
} else if (DOCUMENT_LIKE_TAGS.includes(tagName)) {
icon = DocumentTextOutline
}
return h(NIcon, { size: 16 }, { default: () => h(icon) })
}
// 右键下拉菜单数据
const dropdownOptions = computed<DropdownOption[]>(() => {
if (!contextNodeId.value) return []
return getDropdownOptions(contextNodeId.value)
})
function handleContextMenu(e: MouseEvent, option: TreeOption) {
e.preventDefault()
showDropdown.value = false
contextNodeId.value = option.key as string
nextTick(() => {
dropdownX.value = e.clientX
dropdownY.value = e.clientY
showDropdown.value = true
})
}
// 菜单选择处理,委托给 Hook
async function handleDropdownSelect(key: string) {
showDropdown.value = false
if (!contextNodeId.value) return
await handleDropdownAction(key, contextNodeId.value)
// 如果添加了节点,确保父级节点展开状态
if (key.startsWith('add-child-')) {
if (!expandedKeys.value.includes(contextNodeId.value)) {
const nextKeys = [...expandedKeys.value, contextNodeId.value]
expandedKeys.value = nextKeys
emit('update:expandedKeys', nextKeys)
}
}
}
</script>
<style scoped>
:deep(.n-tree-node-content) {
padding: 4px 8px !important;
border-radius: 4px;
transition: all 0.2s ease;
}
:deep(.n-tree-node-content:hover) {
background-color: var(--primary-color-hover) !important;
opacity: 0.85;
}
:deep(.n-tree-node-content--selected) {
background-color: var(--primary-color) !important;
color: white !important;
}
:deep(.n-tree-node-content--selected .n-text) {
color: white !important;
}
</style>
import type { XmlNode } from '@/types/xmlNode'
export interface TableCellModel {
id: string
text: string
attributes: Record<string, string>
}
export interface TableRowModel {
id: string
cells: TableCellModel[]
}
export interface TableStructureModel {
cols: number
colSpecs: XmlNode[]
theadRows: TableRowModel[]
tbodyRows: TableRowModel[]
}
// 表格组件相关的提示文本定义
export const CELL_EDIT_TIP = '提示:表格支持直接点击单元格进行双击/聚焦修改,光标离开时自动保存文本。'
export const DEFAULT_CELL_TEXT = '新单元格'
<template>
<div class="flex-1 flex flex-col p-4 space-y-4 overflow-auto bg-transparent min-h-0">
<!-- 表格工具栏 -->
<div class="flex items-center justify-between pb-2 border-b border-divider">
<div class="flex items-center space-x-2">
<n-tag type="info" size="small">{{ node.tagName }}</n-tag>
<span class="text-xs text-color3">{{ CELL_EDIT_TIP }}</span>
</div>
<div class="flex items-center space-x-2">
<CommonButton size="tiny" secondary type="primary" @click="handleColumnAdd">
<template #icon><n-icon><add-outline /></n-icon></template>
添加列
</CommonButton>
<CommonButton size="tiny" secondary type="primary" @click="handleRowAdd('TBODY')">
<template #icon><n-icon><add-outline /></n-icon></template>
添加行
</CommonButton>
</div>
</div>
<!-- 可视化二维表格视图 -->
<div class="flex-1 overflow-auto border border-divider rounded-lg shadow-inner bg-fill-2 p-2">
<table class="w-full border-collapse text-sm table-fixed min-w-[600px]">
<!-- 表头规格 -->
<colgroup>
<col v-for="i in structure.cols" :key="i" class="min-w-[120px]" />
<col class="w-[80px]" />
</colgroup>
<!-- THEAD 渲染 -->
<thead v-if="structure.theadRows.length > 0">
<tr
v-for="row in structure.theadRows"
:key="row.id"
class="border-b border-divider hover:bg-fill-3 group"
>
<th
v-for="(cell, cIdx) in row.cells"
:key="cell.id"
class="p-2 text-left font-bold bg-fill-4 border border-divider text-color1"
>
<div
contenteditable="true"
class="w-full min-h-[28px] px-1.5 py-1 rounded focus:outline-none focus:ring-1 focus:ring-primary focus:bg-fill-1 text-color1"
@blur="(e) => handleCellBlur(cell.id, e)"
v-text="cell.text"
></div>
</th>
<!-- 表头操作栏 -->
<th class="p-2 border border-divider bg-fill-4 text-center">
<CommonButton size="tiny" quaternary circle type="error" @click="handleRowDelete(row.id)">
<template #icon><n-icon><trash-outline /></n-icon></template>
</CommonButton>
</th>
</tr>
</thead>
<!-- TBODY 渲染 -->
<tbody>
<tr
v-for="row in structure.tbodyRows"
:key="row.id"
class="border-b border-divider hover:bg-fill-3 group"
>
<td
v-for="cell in row.cells"
:key="cell.id"
class="p-2 border border-divider text-color2"
>
<div
contenteditable="true"
class="w-full min-h-[28px] px-1.5 py-1 rounded focus:outline-none focus:ring-1 focus:ring-primary focus:bg-fill-1 text-color2 leading-relaxed"
@blur="(e) => handleCellBlur(cell.id, e)"
v-text="cell.text"
></div>
</td>
<!-- 行删除按钮 -->
<td class="p-2 border border-divider text-center">
<CommonButton size="tiny" quaternary circle type="error" @click="handleRowDelete(row.id)">
<template #icon><n-icon><trash-outline /></n-icon></template>
</CommonButton>
</td>
</tr>
<!-- 列操作管理辅助行 -->
<tr class="hover:bg-transparent">
<td
v-for="(_, cIdx) in structure.cols"
:key="cIdx"
class="p-1 text-center bg-transparent border-none"
>
<CommonButton
size="tiny"
quaternary
circle
type="error"
:disabled="structure.cols <= 1"
@click="handleColumnDelete(cIdx)"
>
<template #icon><n-icon><trash-outline /></n-icon></template>
</CommonButton>
</td>
<td class="p-1 bg-transparent border-none"></td>
</tr>
</tbody>
</table>
</div>
</div>
</template>
<script setup lang="ts">
import { AddOutline, TrashOutline } from '@vicons/ionicons5'
import type { XmlNode } from '@/types/xmlNode'
import { useTableEditor } from './functionals'
import { CELL_EDIT_TIP } from './constants'
const props = defineProps<{
node: XmlNode
}>()
const { parseTable, updateCellText, addRow, deleteRow, addColumn, deleteColumn } = useTableEditor()
const structure = ref(parseTable(props.node))
watch(() => props.node, (newVal) => {
structure.value = parseTable(newVal)
}, { deep: true, immediate: true })
function handleCellBlur(cellId: string, e: FocusEvent) {
const el = e.target as HTMLElement
const val = el.innerText || ''
updateCellText(props.node, cellId, val)
}
function handleRowAdd(section: 'THEAD' | 'TBODY') {
addRow(props.node, section)
}
async function handleRowDelete(rowId: string) {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除这一行吗?该行内所有的单元格数据将被同时清除。'
})
deleteRow(props.node, rowId)
window.$message.success('删除行成功')
} catch (e) {
// 取消删除
}
}
function handleColumnAdd() {
addColumn(props.node)
}
async function handleColumnDelete(colIndex: number) {
try {
await window.$dialog.warning({
title: '敏感删除确认',
content: '确定要删除这一列吗?整列中所有行对应的单元格数据将被彻底清除!'
})
deleteColumn(props.node, colIndex)
window.$message.success('删除列成功')
} catch (e) {
// 取消删除
}
}
</script>
<style scoped>
</style>
<!-- 废弃,已迁移至 src/views/xmlEditor.vue -->
<template>
<div></div>
</template>
/**
* XML 解析 Web Worker
* 在后台线程执行耗时的 DOM 解析 + 树结构转换,避免阻塞主线程 UI
* 注意:Worker 环境中 DOMParser 与 crypto.randomUUID 均可用
*/
// ── 与 xmlParser.ts 保持一致的类型定义(Worker 不能 import 外部模块)───────
interface MixedContentItem {
type: 'text' | 'element'
text?: string
nodeId?: string
}
interface XmlNode {
id: string
tagName: string
attributes: Record<string, string>
children: XmlNode[]
textContent: string
mixedContent: MixedContentItem[]
parentId: string | null
}
// ── 工具函数 ──────────────────────────────────────────────────────────────────
function generateId(): string {
return (self as any).crypto?.randomUUID?.()
?? `node_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`
}
// ── DOM → XmlNode 递归转换 ────────────────────────────────────────────────────
function domElementToXmlNode(element: Element, parentId: string | null): XmlNode {
const id = generateId()
const attributes: Record<string, string> = {}
for (const attr of Array.from(element.attributes)) {
attributes[attr.name] = attr.value
}
const children: XmlNode[] = []
const mixedContent: MixedContentItem[] = []
let textContent = ''
const childNodes = Array.from(element.childNodes)
const hasElementChildren = childNodes.some(n => n.nodeType === 1 /* ELEMENT_NODE */)
const hasTextChildren = childNodes.some(n => n.nodeType === 3 /* TEXT_NODE */ && n.textContent?.trim())
if (hasElementChildren && hasTextChildren) {
for (const child of childNodes) {
if (child.nodeType === 3) {
const text = child.textContent || ''
if (text) mixedContent.push({ type: 'text', text })
} else if (child.nodeType === 1) {
const childNode = domElementToXmlNode(child as Element, id)
children.push(childNode)
mixedContent.push({ type: 'element', nodeId: childNode.id })
}
}
} else if (hasElementChildren) {
for (const child of childNodes) {
if (child.nodeType === 1) {
children.push(domElementToXmlNode(child as Element, id))
}
}
} else {
textContent = element.textContent || ''
}
return { id, tagName: element.tagName, attributes, children, textContent, mixedContent, parentId }
}
// ── Worker 消息处理 ───────────────────────────────────────────────────────────
self.onmessage = (e: MessageEvent<{ xmlString: string }>) => {
try {
const parser = new DOMParser()
const doc = parser.parseFromString(e.data.xmlString, 'application/xml')
const parseError = doc.querySelector('parsererror')
if (parseError) {
self.postMessage({ error: `XML 解析错误: ${parseError.textContent}` })
return
}
const tree = domElementToXmlNode(doc.documentElement, null)
self.postMessage({ tree })
} catch (err: any) {
self.postMessage({ error: err?.message ?? '未知解析错误' })
}
}
import { defineConfig, loadEnv } from 'vite'
import vue from '@vitejs/plugin-vue'
import path from 'path'
import fs from 'fs'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { NaiveUiResolver } from 'unplugin-vue-components/resolvers'
// https://vite.dev/config/
export default defineConfig(({ mode }) => {
const env = loadEnv(mode, process.cwd())
const proxyUrl = env.VITE_APP_PROXY_URL
loadEnv(mode, process.cwd())
return {
resolve: {
......@@ -46,60 +44,7 @@ export default defineConfig(({ mode }) => {
],
// 生成 components.d.ts 类型声明文件
dts: 'src/components.d.ts'
}),
// 自定义中间件用于在开发阶段优先从本地 shtml 目录读取静态资源
{
name: 'serve-shtml-static',
configureServer(server) {
server.middlewares.use((req, res, next) => {
const shtmlRegex = /^\/(views|css|fonts|img|js|locales|plugins|scripts)\//
if (req.url && shtmlRegex.test(req.url)) {
const pathname = req.url.split('?')[0]
const localFilePath = path.join(__dirname, 'shtml/src/main/resources/static', pathname)
if (fs.existsSync(localFilePath) && fs.statSync(localFilePath).isFile()) {
const ext = path.extname(localFilePath).toLowerCase()
let contentType = 'text/plain'
if (ext === '.html' || ext === '.shtml') {
contentType = 'text/html; charset=utf-8'
res.setHeader('Content-Type', contentType)
const processSSI = (filePath: string): string => {
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
return `<!-- SSI Error: ${filePath} not found -->`
}
let text = fs.readFileSync(filePath, 'utf8')
const ssiRegex = /<!--#include\s+virtual="([^"]+)"\s*-->/g
return text.replace(ssiRegex, (_, includePath) => {
const resolvedPath = path.join(
__dirname,
'shtml/src/main/resources/static',
includePath.startsWith('/') ? includePath.slice(1) : includePath
)
return processSSI(resolvedPath)
})
}
res.end(processSSI(localFilePath))
return
}
if (ext === '.js') contentType = 'application/javascript; charset=utf-8'
else if (ext === '.css') contentType = 'text/css; charset=utf-8'
else if (ext === '.json') contentType = 'application/json; charset=utf-8'
else if (ext === '.png') contentType = 'image/png'
else if (ext === '.jpg' || ext === '.jpeg') contentType = 'image/jpeg'
else if (ext === '.gif') contentType = 'image/gif'
else if (ext === '.svg') contentType = 'image/svg+xml'
else if (ext === '.woff') contentType = 'font/woff'
else if (ext === '.woff2') contentType = 'font/woff2'
res.setHeader('Content-Type', contentType)
fs.createReadStream(localFilePath).pipe(res)
return
}
}
next()
})
}
}
],
base: './', // 打包路径
server: {
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment