Prechádzať zdrojové kódy

feat: add history management and template handling in the editor

- Implemented a history store for undo/redo functionality with a maximum history limit.
- Created a template store to manage templates, file pairs, and data source configurations.
- Developed the EditorView component to handle file uploads, template applications, and batch processing.
- Added global styles for the editor layout and components.
- Defined TypeScript interfaces for editor state, templates, and data sources.
- Configured TypeScript and Vite for the frontend project.
zhch158_admin 2 mesiacov pred
rodič
commit
e0f3bbe92d
47 zmenil súbory, kde vykonal 9297 pridanie a 0 odobranie
  1. 13 0
      table_line_generator/frontend/index.html
  2. 2450 0
      table_line_generator/frontend/package-lock.json
  3. 30 0
      table_line_generator/frontend/package.json
  4. 16 0
      table_line_generator/frontend/src/App.vue
  5. 100 0
      table_line_generator/frontend/src/api/batch.ts
  6. 32 0
      table_line_generator/frontend/src/api/config.ts
  7. 63 0
      table_line_generator/frontend/src/api/directory.ts
  8. 89 0
      table_line_generator/frontend/src/api/editor.ts
  9. 5 0
      table_line_generator/frontend/src/api/index.ts
  10. 99 0
      table_line_generator/frontend/src/api/template.ts
  11. 724 0
      table_line_generator/frontend/src/components/canvas/TableCanvas.vue
  12. 190 0
      table_line_generator/frontend/src/components/common/FileUploader.vue
  13. 11 0
      table_line_generator/frontend/src/components/index.ts
  14. 481 0
      table_line_generator/frontend/src/components/panels/SavePanel.vue
  15. 192 0
      table_line_generator/frontend/src/components/panels/StructurePanel.vue
  16. 110 0
      table_line_generator/frontend/src/components/panels/TemplatePanel.vue
  17. 333 0
      table_line_generator/frontend/src/components/panels/ToolPanel.vue
  18. 328 0
      table_line_generator/frontend/src/components/panels/template-panel/BatchProcessSection.vue
  19. 173 0
      table_line_generator/frontend/src/components/panels/template-panel/DataSourceSection.vue
  20. 189 0
      table_line_generator/frontend/src/components/panels/template-panel/DirectoryBrowserSection.vue
  21. 96 0
      table_line_generator/frontend/src/components/panels/template-panel/FileListSection.vue
  22. 231 0
      table_line_generator/frontend/src/components/panels/template-panel/TemplateListSection.vue
  23. 456 0
      table_line_generator/frontend/src/components/panels/template-panel/composables/README.md
  24. 15 0
      table_line_generator/frontend/src/components/panels/template-panel/composables/index.ts
  25. 227 0
      table_line_generator/frontend/src/components/panels/template-panel/composables/useBatchProcess.ts
  26. 139 0
      table_line_generator/frontend/src/components/panels/template-panel/composables/useDataSource.ts
  27. 152 0
      table_line_generator/frontend/src/components/panels/template-panel/composables/useDirectory.ts
  28. 153 0
      table_line_generator/frontend/src/components/panels/template-panel/composables/useFileList.ts
  29. 183 0
      table_line_generator/frontend/src/components/panels/template-panel/composables/useTemplate.ts
  30. 2 0
      table_line_generator/frontend/src/composables/index.ts
  31. 141 0
      table_line_generator/frontend/src/composables/useCanvasInteraction.ts
  32. 196 0
      table_line_generator/frontend/src/composables/useKeyboardShortcuts.ts
  33. 25 0
      table_line_generator/frontend/src/main.ts
  34. 14 0
      table_line_generator/frontend/src/router/index.ts
  35. 342 0
      table_line_generator/frontend/src/stores/editorStore.ts
  36. 77 0
      table_line_generator/frontend/src/stores/historyStore.ts
  37. 4 0
      table_line_generator/frontend/src/stores/index.ts
  38. 278 0
      table_line_generator/frontend/src/stores/templateStore.ts
  39. 184 0
      table_line_generator/frontend/src/styles/main.scss
  40. 134 0
      table_line_generator/frontend/src/types/editor.ts
  41. 2 0
      table_line_generator/frontend/src/types/index.ts
  42. 71 0
      table_line_generator/frontend/src/types/template.ts
  43. 488 0
      table_line_generator/frontend/src/views/EditorView.vue
  44. 1 0
      table_line_generator/frontend/src/views/index.ts
  45. 25 0
      table_line_generator/frontend/tsconfig.json
  46. 11 0
      table_line_generator/frontend/tsconfig.node.json
  47. 22 0
      table_line_generator/frontend/vite.config.ts

+ 13 - 0
table_line_generator/frontend/index.html

@@ -0,0 +1,13 @@
+<!DOCTYPE html>
+<html lang="zh-CN">
+  <head>
+    <meta charset="UTF-8" />
+    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>表格线编辑器</title>
+  </head>
+  <body>
+    <div id="app"></div>
+    <script type="module" src="/src/main.ts"></script>
+  </body>
+</html>

+ 2450 - 0
table_line_generator/frontend/package-lock.json

@@ -0,0 +1,2450 @@
+{
+  "name": "table-line-editor",
+  "version": "0.1.0",
+  "lockfileVersion": 3,
+  "requires": true,
+  "packages": {
+    "": {
+      "name": "table-line-editor",
+      "version": "0.1.0",
+      "dependencies": {
+        "@element-plus/icons-vue": "^2.3.1",
+        "axios": "^1.6.2",
+        "element-plus": "^2.4.4",
+        "konva": "^9.2.3",
+        "pinia": "^2.1.7",
+        "vue": "^3.4.0",
+        "vue-konva": "^3.0.2",
+        "vue-router": "^4.2.5"
+      },
+      "devDependencies": {
+        "@types/node": "^20.10.5",
+        "@vitejs/plugin-vue": "^4.5.2",
+        "sass": "^1.69.5",
+        "typescript": "^5.3.3",
+        "vite": "^5.0.10",
+        "vue-tsc": "^1.8.25"
+      }
+    },
+    "node_modules/@babel/helper-string-parser": {
+      "version": "7.27.1",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
+      "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/helper-validator-identifier": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
+      "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@babel/parser": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/parser/-/parser-7.28.5.tgz",
+      "integrity": "sha512-KKBU1VGYR7ORr3At5HAtUQ+TV3SzRCXmA/8OdDZiLDBIZxVyzXuztPjfLd3BV1PRAQGCMWWSHYhL0F8d5uHBDQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/types": "^7.28.5"
+      },
+      "bin": {
+        "parser": "bin/babel-parser.js"
+      },
+      "engines": {
+        "node": ">=6.0.0"
+      }
+    },
+    "node_modules/@babel/types": {
+      "version": "7.28.5",
+      "resolved": "https://registry.npmmirror.com/@babel/types/-/types-7.28.5.tgz",
+      "integrity": "sha512-qQ5m48eI/MFLQ5PxQj4PFaprjyCTLI37ElWMmNs0K8Lk3dVeOdNpB3ks8jc7yM5CDmVC73eMVk/trk3fgmrUpA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/helper-string-parser": "^7.27.1",
+        "@babel/helper-validator-identifier": "^7.28.5"
+      },
+      "engines": {
+        "node": ">=6.9.0"
+      }
+    },
+    "node_modules/@ctrl/tinycolor": {
+      "version": "3.6.1",
+      "resolved": "https://registry.npmmirror.com/@ctrl/tinycolor/-/tinycolor-3.6.1.tgz",
+      "integrity": "sha512-SITSV6aIXsuVNV3f3O0f2n/cgyEDWoSqtZMYiAmcsYHydcKrOz3gUxB/iXd/Qf08+IZX4KpgNbvUdMBmWz+kcA==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/@element-plus/icons-vue": {
+      "version": "2.3.2",
+      "resolved": "https://registry.npmmirror.com/@element-plus/icons-vue/-/icons-vue-2.3.2.tgz",
+      "integrity": "sha512-OzIuTaIfC8QXEPmJvB4Y4kw34rSXdCJzxcD1kFStBvr8bK6X1zQAYDo0CNMjojnfTqRQCJ0I7prlErcoRiET2A==",
+      "license": "MIT",
+      "peerDependencies": {
+        "vue": "^3.2.0"
+      }
+    },
+    "node_modules/@esbuild/aix-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "aix"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz",
+      "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz",
+      "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/android-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz",
+      "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz",
+      "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/darwin-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz",
+      "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz",
+      "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/freebsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz",
+      "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz",
+      "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz",
+      "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz",
+      "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-loong64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz",
+      "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-mips64el": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz",
+      "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==",
+      "cpu": [
+        "mips64el"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-ppc64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz",
+      "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-riscv64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz",
+      "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-s390x": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz",
+      "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/linux-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz",
+      "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/netbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "netbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/openbsd-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz",
+      "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openbsd"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/sunos-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz",
+      "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "sunos"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-arm64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz",
+      "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-ia32": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz",
+      "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@esbuild/win32-x64": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz",
+      "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">=12"
+      }
+    },
+    "node_modules/@floating-ui/core": {
+      "version": "1.7.3",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/core/-/core-1.7.3.tgz",
+      "integrity": "sha512-sGnvb5dmrJaKEZ+LDIpguvdX3bDlEllmv4/ClQ9awcmCZrlx5jQyyMWFM5kBI+EyNOCDDiKk8il0zeuX3Zlg/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/dom": {
+      "version": "1.7.4",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/dom/-/dom-1.7.4.tgz",
+      "integrity": "sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA==",
+      "license": "MIT",
+      "dependencies": {
+        "@floating-ui/core": "^1.7.3",
+        "@floating-ui/utils": "^0.2.10"
+      }
+    },
+    "node_modules/@floating-ui/utils": {
+      "version": "0.2.10",
+      "resolved": "https://registry.npmmirror.com/@floating-ui/utils/-/utils-0.2.10.tgz",
+      "integrity": "sha512-aGTxbpbg8/b5JfU1HXSrbH3wXZuLPJcNEcZQFMxLs3oSzgtVu6nFPkbbGGUvBcUjKV2YyB9Wxxabo+HEH9tcRQ==",
+      "license": "MIT"
+    },
+    "node_modules/@jridgewell/sourcemap-codec": {
+      "version": "1.5.5",
+      "resolved": "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
+      "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
+      "license": "MIT"
+    },
+    "node_modules/@parcel/watcher": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher/-/watcher-2.5.1.tgz",
+      "integrity": "sha512-dfUnCxiN9H4ap84DvD2ubjw+3vUNpstxa0TneY/Paat8a3R4uQZDLSvWjmznAY/DoahqTHl9V46HF/Zs3F29pg==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "detect-libc": "^1.0.3",
+        "is-glob": "^4.0.3",
+        "micromatch": "^4.0.5",
+        "node-addon-api": "^7.0.0"
+      },
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher-android-arm64": "2.5.1",
+        "@parcel/watcher-darwin-arm64": "2.5.1",
+        "@parcel/watcher-darwin-x64": "2.5.1",
+        "@parcel/watcher-freebsd-x64": "2.5.1",
+        "@parcel/watcher-linux-arm-glibc": "2.5.1",
+        "@parcel/watcher-linux-arm-musl": "2.5.1",
+        "@parcel/watcher-linux-arm64-glibc": "2.5.1",
+        "@parcel/watcher-linux-arm64-musl": "2.5.1",
+        "@parcel/watcher-linux-x64-glibc": "2.5.1",
+        "@parcel/watcher-linux-x64-musl": "2.5.1",
+        "@parcel/watcher-win32-arm64": "2.5.1",
+        "@parcel/watcher-win32-ia32": "2.5.1",
+        "@parcel/watcher-win32-x64": "2.5.1"
+      }
+    },
+    "node_modules/@parcel/watcher-android-arm64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-android-arm64/-/watcher-android-arm64-2.5.1.tgz",
+      "integrity": "sha512-KF8+j9nNbUN8vzOFDpRMsaKBHZ/mcjEjMToVMJOhTozkDonQFFrRcfdLWn6yWKCmJKmdVxSgHiYvTCef4/qcBA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-arm64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-arm64/-/watcher-darwin-arm64-2.5.1.tgz",
+      "integrity": "sha512-eAzPv5osDmZyBhou8PoF4i6RQXAfeKL9tjb3QzYuccXFMQU0ruIc/POh30ePnaOyD1UXdlKguHBmsTs53tVoPw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-darwin-x64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-darwin-x64/-/watcher-darwin-x64-2.5.1.tgz",
+      "integrity": "sha512-1ZXDthrnNmwv10A0/3AJNZ9JGlzrF82i3gNQcWOzd7nJ8aj+ILyW1MTxVk35Db0u91oD5Nlk9MBiujMlwmeXZg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-freebsd-x64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-freebsd-x64/-/watcher-freebsd-x64-2.5.1.tgz",
+      "integrity": "sha512-SI4eljM7Flp9yPuKi8W0ird8TI/JK6CSxju3NojVI6BjHsTyK7zxA9urjVjEKJ5MBYC+bLmMcbAWlZ+rFkLpJQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-glibc": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-glibc/-/watcher-linux-arm-glibc-2.5.1.tgz",
+      "integrity": "sha512-RCdZlEyTs8geyBkkcnPWvtXLY44BCeZKmGYRtSgtwwnHR4dxfHRG3gR99XdMEdQ7KeiDdasJwwvNSF5jKtDwdA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm-musl": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm-musl/-/watcher-linux-arm-musl-2.5.1.tgz",
+      "integrity": "sha512-6E+m/Mm1t1yhB8X412stiKFG3XykmgdIOqhjWj+VL8oHkKABfu/gjFj8DvLrYVHSBNC+/u5PeNrujiSQ1zwd1Q==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-glibc": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-glibc/-/watcher-linux-arm64-glibc-2.5.1.tgz",
+      "integrity": "sha512-LrGp+f02yU3BN9A+DGuY3v3bmnFUggAITBGriZHUREfNEzZh/GO06FF5u2kx8x+GBEUYfyTGamol4j3m9ANe8w==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-arm64-musl": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-arm64-musl/-/watcher-linux-arm64-musl-2.5.1.tgz",
+      "integrity": "sha512-cFOjABi92pMYRXS7AcQv9/M1YuKRw8SZniCDw0ssQb/noPkRzA+HBDkwmyOJYp5wXcsTrhxO0zq1U11cK9jsFg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-glibc": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-glibc/-/watcher-linux-x64-glibc-2.5.1.tgz",
+      "integrity": "sha512-GcESn8NZySmfwlTsIur+49yDqSny2IhPeZfXunQi48DMugKeZ7uy1FX83pO0X22sHntJ4Ub+9k34XQCX+oHt2A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-linux-x64-musl": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-linux-x64-musl/-/watcher-linux-x64-musl-2.5.1.tgz",
+      "integrity": "sha512-n0E2EQbatQ3bXhcH2D1XIAANAcTZkQICBPVaxMeaCVBtOpBZpWJuf7LwyWPSBDITb7In8mqQgJ7gH8CILCURXg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-arm64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-arm64/-/watcher-win32-arm64-2.5.1.tgz",
+      "integrity": "sha512-RFzklRvmc3PkjKjry3hLF9wD7ppR4AKcWNzH7kXR7GUe0Igb3Nz8fyPwtZCSquGrhU5HhUNDr/mKBqj7tqA2Vw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-ia32": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-ia32/-/watcher-win32-ia32-2.5.1.tgz",
+      "integrity": "sha512-c2KkcVN+NJmuA7CGlaGD1qJh1cLfDnQsHjE89E60vUEMlqduHGCdCLJCID5geFVM0dOtA3ZiIO8BoEQmzQVfpQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@parcel/watcher-win32-x64": {
+      "version": "2.5.1",
+      "resolved": "https://registry.npmmirror.com/@parcel/watcher-win32-x64/-/watcher-win32-x64-2.5.1.tgz",
+      "integrity": "sha512-9lHBdJITeNR++EvSQVUcaZoWupyHfXe1jZvGZ06O/5MflPcuPLtEphScIBL+AiCWBO46tDSHzWyD0uDmmZqsgA==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ],
+      "engines": {
+        "node": ">= 10.0.0"
+      },
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/parcel"
+      }
+    },
+    "node_modules/@popperjs/core": {
+      "name": "@sxzz/popperjs-es",
+      "version": "2.11.7",
+      "resolved": "https://registry.npmmirror.com/@sxzz/popperjs-es/-/popperjs-es-2.11.7.tgz",
+      "integrity": "sha512-Ccy0NlLkzr0Ex2FKvh2X+OyERHXJ88XJ1MXtsI9y9fGexlaXaVTPzBCRBwIxFkORuOb+uBqeu+RqnpgYTEZRUQ==",
+      "license": "MIT",
+      "funding": {
+        "type": "opencollective",
+        "url": "https://opencollective.com/popperjs"
+      }
+    },
+    "node_modules/@rollup/rollup-android-arm-eabi": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.54.0.tgz",
+      "integrity": "sha512-OywsdRHrFvCdvsewAInDKCNyR3laPA2mc9bRYJ6LBp5IyvF3fvXbbNR0bSzHlZVFtn6E0xw2oZlyjg4rKCVcng==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-android-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.54.0.tgz",
+      "integrity": "sha512-Skx39Uv+u7H224Af+bDgNinitlmHyQX1K/atIA32JP3JQw6hVODX5tkbi2zof/E69M1qH2UoN3Xdxgs90mmNYw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "android"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.54.0.tgz",
+      "integrity": "sha512-k43D4qta/+6Fq+nCDhhv9yP2HdeKeP56QrUUTW7E6PhZP1US6NDqpJj4MY0jBHlJivVJD5P8NxrjuobZBJTCRw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-darwin-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.54.0.tgz",
+      "integrity": "sha512-cOo7biqwkpawslEfox5Vs8/qj83M/aZCSSNIWpVzfU2CYHa2G3P1UN5WF01RdTHSgCkri7XOlTdtk17BezlV3A==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.54.0.tgz",
+      "integrity": "sha512-miSvuFkmvFbgJ1BevMa4CPCFt5MPGw094knM64W9I0giUIMMmRYcGW/JWZDriaw/k1kOBtsWh1z6nIFV1vPNtA==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-freebsd-x64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.54.0.tgz",
+      "integrity": "sha512-KGXIs55+b/ZfZsq9aR026tmr/+7tq6VG6MsnrvF4H8VhwflTIuYh+LFUlIsRdQSgrgmtM3fVATzEAj4hBQlaqQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "freebsd"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-gnueabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.54.0.tgz",
+      "integrity": "sha512-EHMUcDwhtdRGlXZsGSIuXSYwD5kOT9NVnx9sqzYiwAc91wfYOE1g1djOEDseZJKKqtHAHGwnGPQu3kytmfaXLQ==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm-musleabihf": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.54.0.tgz",
+      "integrity": "sha512-+pBrqEjaakN2ySv5RVrj/qLytYhPKEUwk+e3SFU5jTLHIcAtqh2rLrd/OkbNuHJpsBgxsD8ccJt5ga/SeG0JmA==",
+      "cpu": [
+        "arm"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.54.0.tgz",
+      "integrity": "sha512-NSqc7rE9wuUaRBsBp5ckQ5CVz5aIRKCwsoa6WMF7G01sX3/qHUw/z4pv+D+ahL1EIKy6Enpcnz1RY8pf7bjwng==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-arm64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.54.0.tgz",
+      "integrity": "sha512-gr5vDbg3Bakga5kbdpqx81m2n9IX8M6gIMlQQIXiLTNeQW6CucvuInJ91EuCJ/JYvc+rcLLsDFcfAD1K7fMofg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-loong64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.54.0.tgz",
+      "integrity": "sha512-gsrtB1NA3ZYj2vq0Rzkylo9ylCtW/PhpLEivlgWe0bpgtX5+9j9EZa0wtZiCjgu6zmSeZWyI/e2YRX1URozpIw==",
+      "cpu": [
+        "loong64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-ppc64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.54.0.tgz",
+      "integrity": "sha512-y3qNOfTBStmFNq+t4s7Tmc9hW2ENtPg8FeUD/VShI7rKxNW7O4fFeaYbMsd3tpFlIg1Q8IapFgy7Q9i2BqeBvA==",
+      "cpu": [
+        "ppc64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.54.0.tgz",
+      "integrity": "sha512-89sepv7h2lIVPsFma8iwmccN7Yjjtgz0Rj/Ou6fEqg3HDhpCa+Et+YSufy27i6b0Wav69Qv4WBNl3Rs6pwhebQ==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-riscv64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.54.0.tgz",
+      "integrity": "sha512-ZcU77ieh0M2Q8Ur7D5X7KvK+UxbXeDHwiOt/CPSBTI1fBmeDMivW0dPkdqkT4rOgDjrDDBUed9x4EgraIKoR2A==",
+      "cpu": [
+        "riscv64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-s390x-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.54.0.tgz",
+      "integrity": "sha512-2AdWy5RdDF5+4YfG/YesGDDtbyJlC9LHmL6rZw6FurBJ5n4vFGupsOBGfwMRjBYH7qRQowT8D/U4LoSvVwOhSQ==",
+      "cpu": [
+        "s390x"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-WGt5J8Ij/rvyqpFexxk3ffKqqbLf9AqrTBbWDk7ApGUzaIs6V+s2s84kAxklFwmMF/vBNGrVdYgbblCOFFezMQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-linux-x64-musl": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.54.0.tgz",
+      "integrity": "sha512-JzQmb38ATzHjxlPHuTH6tE7ojnMKM2kYNzt44LO/jJi8BpceEC8QuXYA908n8r3CNuG/B3BV8VR3Hi1rYtmPiw==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "linux"
+      ]
+    },
+    "node_modules/@rollup/rollup-openharmony-arm64": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.54.0.tgz",
+      "integrity": "sha512-huT3fd0iC7jigGh7n3q/+lfPcXxBi+om/Rs3yiFxjvSxbSB6aohDFXbWvlspaqjeOh+hx7DDHS+5Es5qRkWkZg==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "openharmony"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-arm64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.54.0.tgz",
+      "integrity": "sha512-c2V0W1bsKIKfbLMBu/WGBz6Yci8nJ/ZJdheE0EwB73N3MvHYKiKGs3mVilX4Gs70eGeDaMqEob25Tw2Gb9Nqyw==",
+      "cpu": [
+        "arm64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-ia32-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.54.0.tgz",
+      "integrity": "sha512-woEHgqQqDCkAzrDhvDipnSirm5vxUXtSKDYTVpZG3nUdW/VVB5VdCYA2iReSj/u3yCZzXID4kuKG7OynPnB3WQ==",
+      "cpu": [
+        "ia32"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-gnu": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.54.0.tgz",
+      "integrity": "sha512-dzAc53LOuFvHwbCEOS0rPbXp6SIhAf2txMP5p6mGyOXXw5mWY8NGGbPMPrs4P1WItkfApDathBj/NzMLUZ9rtQ==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@rollup/rollup-win32-x64-msvc": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.54.0.tgz",
+      "integrity": "sha512-hYT5d3YNdSh3mbCU1gwQyPgQd3T2ne0A3KG8KSBdav5TiBg6eInVmV+TeR5uHufiIgSFg0XsOWGW5/RhNcSvPg==",
+      "cpu": [
+        "x64"
+      ],
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "win32"
+      ]
+    },
+    "node_modules/@types/estree": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/@types/estree/-/estree-1.0.8.tgz",
+      "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmmirror.com/@types/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-FOvQ0YPD5NOfPgMzJihoT+Za5pdkDJWcbpuj1DjaKZIr/gxodQjY/uWEFlTNqW2ugXHUiL8lRQgw63dzKHZdeQ==",
+      "license": "MIT"
+    },
+    "node_modules/@types/lodash-es": {
+      "version": "4.17.12",
+      "resolved": "https://registry.npmmirror.com/@types/lodash-es/-/lodash-es-4.17.12.tgz",
+      "integrity": "sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/lodash": "*"
+      }
+    },
+    "node_modules/@types/node": {
+      "version": "20.19.27",
+      "resolved": "https://registry.npmmirror.com/@types/node/-/node-20.19.27.tgz",
+      "integrity": "sha512-N2clP5pJhB2YnZJ3PIHFk5RkygRX5WO/5f0WC08tp0wd+sv0rsJk3MqWn3CbNmT2J505a5336jaQj4ph1AdMug==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "undici-types": "~6.21.0"
+      }
+    },
+    "node_modules/@types/web-bluetooth": {
+      "version": "0.0.20",
+      "resolved": "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.20.tgz",
+      "integrity": "sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==",
+      "license": "MIT"
+    },
+    "node_modules/@vitejs/plugin-vue": {
+      "version": "4.6.2",
+      "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-4.6.2.tgz",
+      "integrity": "sha512-kqf7SGFoG+80aZG6Pf+gsZIVvGSCKE98JbiWqcCV9cThtg91Jav0yvYFC9Zb+jKetNGF6ZKeoaxgZfND21fWKw==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": "^14.18.0 || >=16.0.0"
+      },
+      "peerDependencies": {
+        "vite": "^4.0.0 || ^5.0.0",
+        "vue": "^3.2.25"
+      }
+    },
+    "node_modules/@volar/language-core": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-1.11.1.tgz",
+      "integrity": "sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/source-map": "1.11.1"
+      }
+    },
+    "node_modules/@volar/source-map": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-1.11.1.tgz",
+      "integrity": "sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "muggle-string": "^0.3.1"
+      }
+    },
+    "node_modules/@volar/typescript": {
+      "version": "1.11.1",
+      "resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-1.11.1.tgz",
+      "integrity": "sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "1.11.1",
+        "path-browserify": "^1.0.1"
+      }
+    },
+    "node_modules/@vue/compiler-core": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.26.tgz",
+      "integrity": "sha512-vXyI5GMfuoBCnv5ucIT7jhHKl55Y477yxP6fc4eUswjP8FG3FFVFd41eNDArR+Uk3QKn2Z85NavjaxLxOC19/w==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/shared": "3.5.26",
+        "entities": "^7.0.0",
+        "estree-walker": "^2.0.2",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-dom": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.26.tgz",
+      "integrity": "sha512-y1Tcd3eXs834QjswshSilCBnKGeQjQXB6PqFn/1nxcQw4pmG42G8lwz+FZPAZAby6gZeHSt/8LMPfZ4Rb+Bd/A==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-core": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/compiler-sfc": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.26.tgz",
+      "integrity": "sha512-egp69qDTSEZcf4bGOSsprUr4xI73wfrY5oRs6GSgXFTiHrWj4Y3X5Ydtip9QMqiCMCPVwLglB9GBxXtTadJ3mA==",
+      "license": "MIT",
+      "dependencies": {
+        "@babel/parser": "^7.28.5",
+        "@vue/compiler-core": "3.5.26",
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/compiler-ssr": "3.5.26",
+        "@vue/shared": "3.5.26",
+        "estree-walker": "^2.0.2",
+        "magic-string": "^0.30.21",
+        "postcss": "^8.5.6",
+        "source-map-js": "^1.2.1"
+      }
+    },
+    "node_modules/@vue/compiler-ssr": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.26.tgz",
+      "integrity": "sha512-lZT9/Y0nSIRUPVvapFJEVDbEXruZh2IYHMk2zTtEgJSlP5gVOqeWXH54xDKAaFS4rTnDeDBQUYDtxKyoW9FwDw==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/devtools-api": {
+      "version": "6.6.4",
+      "resolved": "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-6.6.4.tgz",
+      "integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==",
+      "license": "MIT"
+    },
+    "node_modules/@vue/language-core": {
+      "version": "1.8.27",
+      "resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-1.8.27.tgz",
+      "integrity": "sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/language-core": "~1.11.1",
+        "@volar/source-map": "~1.11.1",
+        "@vue/compiler-dom": "^3.3.0",
+        "@vue/shared": "^3.3.0",
+        "computeds": "^0.0.1",
+        "minimatch": "^9.0.3",
+        "muggle-string": "^0.3.1",
+        "path-browserify": "^1.0.1",
+        "vue-template-compiler": "^2.7.14"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/@vue/reactivity": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.26.tgz",
+      "integrity": "sha512-9EnYB1/DIiUYYnzlnUBgwU32NNvLp/nhxLXeWRhHUEeWNTn1ECxX8aGO7RTXeX6PPcxe3LLuNBFoJbV4QZ+CFQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/runtime-core": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.26.tgz",
+      "integrity": "sha512-xJWM9KH1kd201w5DvMDOwDHYhrdPTrAatn56oB/LRG4plEQeZRQLw0Bpwih9KYoqmzaxF0OKSn6swzYi84e1/Q==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.26",
+        "@vue/shared": "3.5.26"
+      }
+    },
+    "node_modules/@vue/runtime-dom": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.26.tgz",
+      "integrity": "sha512-XLLd/+4sPC2ZkN/6+V4O4gjJu6kSDbHAChvsyWgm1oGbdSO3efvGYnm25yCjtFm/K7rrSDvSfPDgN1pHgS4VNQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/reactivity": "3.5.26",
+        "@vue/runtime-core": "3.5.26",
+        "@vue/shared": "3.5.26",
+        "csstype": "^3.2.3"
+      }
+    },
+    "node_modules/@vue/server-renderer": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.26.tgz",
+      "integrity": "sha512-TYKLXmrwWKSodyVuO1WAubucd+1XlLg4set0YoV+Hu8Lo79mp/YMwWV5mC5FgtsDxX3qo1ONrxFaTP1OQgy1uA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-ssr": "3.5.26",
+        "@vue/shared": "3.5.26"
+      },
+      "peerDependencies": {
+        "vue": "3.5.26"
+      }
+    },
+    "node_modules/@vue/shared": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.26.tgz",
+      "integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
+      "license": "MIT"
+    },
+    "node_modules/@vueuse/core": {
+      "version": "10.11.1",
+      "resolved": "https://registry.npmmirror.com/@vueuse/core/-/core-10.11.1.tgz",
+      "integrity": "sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==",
+      "license": "MIT",
+      "dependencies": {
+        "@types/web-bluetooth": "^0.0.20",
+        "@vueuse/metadata": "10.11.1",
+        "@vueuse/shared": "10.11.1",
+        "vue-demi": ">=0.14.8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/metadata": {
+      "version": "10.11.1",
+      "resolved": "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-10.11.1.tgz",
+      "integrity": "sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/@vueuse/shared": {
+      "version": "10.11.1",
+      "resolved": "https://registry.npmmirror.com/@vueuse/shared/-/shared-10.11.1.tgz",
+      "integrity": "sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==",
+      "license": "MIT",
+      "dependencies": {
+        "vue-demi": ">=0.14.8"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      }
+    },
+    "node_modules/async-validator": {
+      "version": "4.2.5",
+      "resolved": "https://registry.npmmirror.com/async-validator/-/async-validator-4.2.5.tgz",
+      "integrity": "sha512-7HhHjtERjqlNbZtqNqy2rckN/SpOOlmDliet+lP7k+eKZEjPk3DgyeU9lIXLdeLz0uBbbVp+9Qdow9wJWgwwfg==",
+      "license": "MIT"
+    },
+    "node_modules/asynckit": {
+      "version": "0.4.0",
+      "resolved": "https://registry.npmmirror.com/asynckit/-/asynckit-0.4.0.tgz",
+      "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==",
+      "license": "MIT"
+    },
+    "node_modules/axios": {
+      "version": "1.13.2",
+      "resolved": "https://registry.npmmirror.com/axios/-/axios-1.13.2.tgz",
+      "integrity": "sha512-VPk9ebNqPcy5lRGuSlKx752IlDatOjT9paPlm8A7yOuW2Fbvp4X3JznJtT4f0GzGLLiWE9W8onz51SqLYwzGaA==",
+      "license": "MIT",
+      "dependencies": {
+        "follow-redirects": "^1.15.6",
+        "form-data": "^4.0.4",
+        "proxy-from-env": "^1.1.0"
+      }
+    },
+    "node_modules/balanced-match": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
+      "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/brace-expansion": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.0.2.tgz",
+      "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "balanced-match": "^1.0.0"
+      }
+    },
+    "node_modules/braces": {
+      "version": "3.0.3",
+      "resolved": "https://registry.npmmirror.com/braces/-/braces-3.0.3.tgz",
+      "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "fill-range": "^7.1.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/call-bind-apply-helpers": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
+      "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/chokidar": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/chokidar/-/chokidar-4.0.3.tgz",
+      "integrity": "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "readdirp": "^4.0.1"
+      },
+      "engines": {
+        "node": ">= 14.16.0"
+      },
+      "funding": {
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/combined-stream": {
+      "version": "1.0.8",
+      "resolved": "https://registry.npmmirror.com/combined-stream/-/combined-stream-1.0.8.tgz",
+      "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
+      "license": "MIT",
+      "dependencies": {
+        "delayed-stream": "~1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.8"
+      }
+    },
+    "node_modules/computeds": {
+      "version": "0.0.1",
+      "resolved": "https://registry.npmmirror.com/computeds/-/computeds-0.0.1.tgz",
+      "integrity": "sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/csstype": {
+      "version": "3.2.3",
+      "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
+      "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
+      "license": "MIT"
+    },
+    "node_modules/dayjs": {
+      "version": "1.11.19",
+      "resolved": "https://registry.npmmirror.com/dayjs/-/dayjs-1.11.19.tgz",
+      "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==",
+      "license": "MIT"
+    },
+    "node_modules/de-indent": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/de-indent/-/de-indent-1.0.2.tgz",
+      "integrity": "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/delayed-stream": {
+      "version": "1.0.0",
+      "resolved": "https://registry.npmmirror.com/delayed-stream/-/delayed-stream-1.0.0.tgz",
+      "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">=0.4.0"
+      }
+    },
+    "node_modules/detect-libc": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-1.0.3.tgz",
+      "integrity": "sha512-pGjwhsmsp4kL2RTz08wcOlGN83otlqHeD/Z5T8GXZB+/YcpQ/dgo+lbU8ZsGxV0HIvqqxo9l7mqYwyYMD9bKDg==",
+      "dev": true,
+      "license": "Apache-2.0",
+      "optional": true,
+      "bin": {
+        "detect-libc": "bin/detect-libc.js"
+      },
+      "engines": {
+        "node": ">=0.10"
+      }
+    },
+    "node_modules/dunder-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/dunder-proto/-/dunder-proto-1.0.1.tgz",
+      "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "gopd": "^1.2.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/element-plus": {
+      "version": "2.13.0",
+      "resolved": "https://registry.npmmirror.com/element-plus/-/element-plus-2.13.0.tgz",
+      "integrity": "sha512-qjxS+SBChvqCl6lU6ShiliLMN6WqFHiXQENYbAY3GKNflG+FS3jqn8JmQq0CBZq4koFqsi95NT1M6SL4whZfrA==",
+      "license": "MIT",
+      "dependencies": {
+        "@ctrl/tinycolor": "^3.4.1",
+        "@element-plus/icons-vue": "^2.3.2",
+        "@floating-ui/dom": "^1.0.1",
+        "@popperjs/core": "npm:@sxzz/popperjs-es@^2.11.7",
+        "@types/lodash": "^4.17.20",
+        "@types/lodash-es": "^4.17.12",
+        "@vueuse/core": "^10.11.0",
+        "async-validator": "^4.2.5",
+        "dayjs": "^1.11.19",
+        "lodash": "^4.17.21",
+        "lodash-es": "^4.17.21",
+        "lodash-unified": "^1.0.3",
+        "memoize-one": "^6.0.0",
+        "normalize-wheel-es": "^1.2.0"
+      },
+      "peerDependencies": {
+        "vue": "^3.3.0"
+      }
+    },
+    "node_modules/entities": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.0.tgz",
+      "integrity": "sha512-FDWG5cmEYf2Z00IkYRhbFrwIwvdFKH07uV8dvNy0omp/Qb1xcyCWp2UDtcwJF4QZZvk0sLudP6/hAu42TaqVhQ==",
+      "license": "BSD-2-Clause",
+      "engines": {
+        "node": ">=0.12"
+      },
+      "funding": {
+        "url": "https://github.com/fb55/entities?sponsor=1"
+      }
+    },
+    "node_modules/es-define-property": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/es-define-property/-/es-define-property-1.0.1.tgz",
+      "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-errors": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/es-errors/-/es-errors-1.3.0.tgz",
+      "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-object-atoms": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
+      "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/es-set-tostringtag": {
+      "version": "2.1.0",
+      "resolved": "https://registry.npmmirror.com/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz",
+      "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
+      "license": "MIT",
+      "dependencies": {
+        "es-errors": "^1.3.0",
+        "get-intrinsic": "^1.2.6",
+        "has-tostringtag": "^1.0.2",
+        "hasown": "^2.0.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/esbuild": {
+      "version": "0.21.5",
+      "resolved": "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz",
+      "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "esbuild": "bin/esbuild"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "optionalDependencies": {
+        "@esbuild/aix-ppc64": "0.21.5",
+        "@esbuild/android-arm": "0.21.5",
+        "@esbuild/android-arm64": "0.21.5",
+        "@esbuild/android-x64": "0.21.5",
+        "@esbuild/darwin-arm64": "0.21.5",
+        "@esbuild/darwin-x64": "0.21.5",
+        "@esbuild/freebsd-arm64": "0.21.5",
+        "@esbuild/freebsd-x64": "0.21.5",
+        "@esbuild/linux-arm": "0.21.5",
+        "@esbuild/linux-arm64": "0.21.5",
+        "@esbuild/linux-ia32": "0.21.5",
+        "@esbuild/linux-loong64": "0.21.5",
+        "@esbuild/linux-mips64el": "0.21.5",
+        "@esbuild/linux-ppc64": "0.21.5",
+        "@esbuild/linux-riscv64": "0.21.5",
+        "@esbuild/linux-s390x": "0.21.5",
+        "@esbuild/linux-x64": "0.21.5",
+        "@esbuild/netbsd-x64": "0.21.5",
+        "@esbuild/openbsd-x64": "0.21.5",
+        "@esbuild/sunos-x64": "0.21.5",
+        "@esbuild/win32-arm64": "0.21.5",
+        "@esbuild/win32-ia32": "0.21.5",
+        "@esbuild/win32-x64": "0.21.5"
+      }
+    },
+    "node_modules/estree-walker": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz",
+      "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==",
+      "license": "MIT"
+    },
+    "node_modules/fill-range": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
+      "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "to-regex-range": "^5.0.1"
+      },
+      "engines": {
+        "node": ">=8"
+      }
+    },
+    "node_modules/follow-redirects": {
+      "version": "1.15.11",
+      "resolved": "https://registry.npmmirror.com/follow-redirects/-/follow-redirects-1.15.11.tgz",
+      "integrity": "sha512-deG2P0JfjrTxl50XGCDyfI97ZGVCxIpfKYmfyrQ54n5FO/0gfIES8C/Psl6kWVDolizcaaxZJnTS0QSMxvnsBQ==",
+      "funding": [
+        {
+          "type": "individual",
+          "url": "https://github.com/sponsors/RubenVerborgh"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">=4.0"
+      },
+      "peerDependenciesMeta": {
+        "debug": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/form-data": {
+      "version": "4.0.5",
+      "resolved": "https://registry.npmmirror.com/form-data/-/form-data-4.0.5.tgz",
+      "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
+      "license": "MIT",
+      "dependencies": {
+        "asynckit": "^0.4.0",
+        "combined-stream": "^1.0.8",
+        "es-set-tostringtag": "^2.1.0",
+        "hasown": "^2.0.2",
+        "mime-types": "^2.1.12"
+      },
+      "engines": {
+        "node": ">= 6"
+      }
+    },
+    "node_modules/fsevents": {
+      "version": "2.3.3",
+      "resolved": "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz",
+      "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
+      "dev": true,
+      "hasInstallScript": true,
+      "license": "MIT",
+      "optional": true,
+      "os": [
+        "darwin"
+      ],
+      "engines": {
+        "node": "^8.16.0 || ^10.6.0 || >=11.0.0"
+      }
+    },
+    "node_modules/function-bind": {
+      "version": "1.1.2",
+      "resolved": "https://registry.npmmirror.com/function-bind/-/function-bind-1.1.2.tgz",
+      "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-intrinsic": {
+      "version": "1.3.0",
+      "resolved": "https://registry.npmmirror.com/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
+      "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "call-bind-apply-helpers": "^1.0.2",
+        "es-define-property": "^1.0.1",
+        "es-errors": "^1.3.0",
+        "es-object-atoms": "^1.1.1",
+        "function-bind": "^1.1.2",
+        "get-proto": "^1.0.1",
+        "gopd": "^1.2.0",
+        "has-symbols": "^1.1.0",
+        "hasown": "^2.0.2",
+        "math-intrinsics": "^1.1.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/get-proto": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/get-proto/-/get-proto-1.0.1.tgz",
+      "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
+      "license": "MIT",
+      "dependencies": {
+        "dunder-proto": "^1.0.1",
+        "es-object-atoms": "^1.0.0"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/gopd": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/gopd/-/gopd-1.2.0.tgz",
+      "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-symbols": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/has-symbols/-/has-symbols-1.1.0.tgz",
+      "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/has-tostringtag": {
+      "version": "1.0.2",
+      "resolved": "https://registry.npmmirror.com/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
+      "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
+      "license": "MIT",
+      "dependencies": {
+        "has-symbols": "^1.0.3"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/ljharb"
+      }
+    },
+    "node_modules/hasown": {
+      "version": "2.0.2",
+      "resolved": "https://registry.npmmirror.com/hasown/-/hasown-2.0.2.tgz",
+      "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
+      "license": "MIT",
+      "dependencies": {
+        "function-bind": "^1.1.2"
+      },
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/he": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/he/-/he-1.2.0.tgz",
+      "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==",
+      "dev": true,
+      "license": "MIT",
+      "bin": {
+        "he": "bin/he"
+      }
+    },
+    "node_modules/immutable": {
+      "version": "5.1.4",
+      "resolved": "https://registry.npmmirror.com/immutable/-/immutable-5.1.4.tgz",
+      "integrity": "sha512-p6u1bG3YSnINT5RQmx/yRZBpenIl30kVxkTLDyHLIMk0gict704Q9n+thfDI7lTRm9vXdDYutVzXhzcThxTnXA==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/is-extglob": {
+      "version": "2.1.1",
+      "resolved": "https://registry.npmmirror.com/is-extglob/-/is-extglob-2.1.1.tgz",
+      "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-glob": {
+      "version": "4.0.3",
+      "resolved": "https://registry.npmmirror.com/is-glob/-/is-glob-4.0.3.tgz",
+      "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "is-extglob": "^2.1.1"
+      },
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/is-number": {
+      "version": "7.0.0",
+      "resolved": "https://registry.npmmirror.com/is-number/-/is-number-7.0.0.tgz",
+      "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=0.12.0"
+      }
+    },
+    "node_modules/konva": {
+      "version": "9.3.22",
+      "resolved": "https://registry.npmmirror.com/konva/-/konva-9.3.22.tgz",
+      "integrity": "sha512-yQI5d1bmELlD/fowuyfOp9ff+oamg26WOCkyqUyc+nczD/lhRa3EvD2MZOoc4c1293TAubW9n34fSQLgSeEgSw==",
+      "funding": [
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/lavrton"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/konva"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/lavrton"
+        }
+      ],
+      "license": "MIT"
+    },
+    "node_modules/lodash": {
+      "version": "4.17.21",
+      "resolved": "https://registry.npmmirror.com/lodash/-/lodash-4.17.21.tgz",
+      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-es": {
+      "version": "4.17.22",
+      "resolved": "https://registry.npmmirror.com/lodash-es/-/lodash-es-4.17.22.tgz",
+      "integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
+      "license": "MIT"
+    },
+    "node_modules/lodash-unified": {
+      "version": "1.0.3",
+      "resolved": "https://registry.npmmirror.com/lodash-unified/-/lodash-unified-1.0.3.tgz",
+      "integrity": "sha512-WK9qSozxXOD7ZJQlpSqOT+om2ZfcT4yO+03FuzAHD0wF6S0l0090LRPDx3vhTTLZ8cFKpBn+IOcVXK6qOcIlfQ==",
+      "license": "MIT",
+      "peerDependencies": {
+        "@types/lodash-es": "*",
+        "lodash": "*",
+        "lodash-es": "*"
+      }
+    },
+    "node_modules/magic-string": {
+      "version": "0.30.21",
+      "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
+      "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==",
+      "license": "MIT",
+      "dependencies": {
+        "@jridgewell/sourcemap-codec": "^1.5.5"
+      }
+    },
+    "node_modules/math-intrinsics": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
+      "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.4"
+      }
+    },
+    "node_modules/memoize-one": {
+      "version": "6.0.0",
+      "resolved": "https://registry.npmmirror.com/memoize-one/-/memoize-one-6.0.0.tgz",
+      "integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
+      "license": "MIT"
+    },
+    "node_modules/micromatch": {
+      "version": "4.0.8",
+      "resolved": "https://registry.npmmirror.com/micromatch/-/micromatch-4.0.8.tgz",
+      "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "braces": "^3.0.3",
+        "picomatch": "^2.3.1"
+      },
+      "engines": {
+        "node": ">=8.6"
+      }
+    },
+    "node_modules/mime-db": {
+      "version": "1.52.0",
+      "resolved": "https://registry.npmmirror.com/mime-db/-/mime-db-1.52.0.tgz",
+      "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==",
+      "license": "MIT",
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/mime-types": {
+      "version": "2.1.35",
+      "resolved": "https://registry.npmmirror.com/mime-types/-/mime-types-2.1.35.tgz",
+      "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
+      "license": "MIT",
+      "dependencies": {
+        "mime-db": "1.52.0"
+      },
+      "engines": {
+        "node": ">= 0.6"
+      }
+    },
+    "node_modules/minimatch": {
+      "version": "9.0.5",
+      "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.5.tgz",
+      "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+      "dev": true,
+      "license": "ISC",
+      "dependencies": {
+        "brace-expansion": "^2.0.1"
+      },
+      "engines": {
+        "node": ">=16 || 14 >=14.17"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/isaacs"
+      }
+    },
+    "node_modules/muggle-string": {
+      "version": "0.3.1",
+      "resolved": "https://registry.npmmirror.com/muggle-string/-/muggle-string-0.3.1.tgz",
+      "integrity": "sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/nanoid": {
+      "version": "3.3.11",
+      "resolved": "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz",
+      "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
+      "funding": [
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "bin": {
+        "nanoid": "bin/nanoid.cjs"
+      },
+      "engines": {
+        "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
+      }
+    },
+    "node_modules/node-addon-api": {
+      "version": "7.1.1",
+      "resolved": "https://registry.npmmirror.com/node-addon-api/-/node-addon-api-7.1.1.tgz",
+      "integrity": "sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true
+    },
+    "node_modules/normalize-wheel-es": {
+      "version": "1.2.0",
+      "resolved": "https://registry.npmmirror.com/normalize-wheel-es/-/normalize-wheel-es-1.2.0.tgz",
+      "integrity": "sha512-Wj7+EJQ8mSuXr2iWfnujrimU35R2W4FAErEyTmJoJ7ucwTn2hOUSsRehMb5RSYkxXGTM7Y9QpvPmp++w5ftoJw==",
+      "license": "BSD-3-Clause"
+    },
+    "node_modules/path-browserify": {
+      "version": "1.0.1",
+      "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
+      "integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/picocolors": {
+      "version": "1.1.1",
+      "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
+      "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
+      "license": "ISC"
+    },
+    "node_modules/picomatch": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/picomatch/-/picomatch-2.3.1.tgz",
+      "integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "engines": {
+        "node": ">=8.6"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/jonschlinkert"
+      }
+    },
+    "node_modules/pinia": {
+      "version": "2.3.1",
+      "resolved": "https://registry.npmmirror.com/pinia/-/pinia-2.3.1.tgz",
+      "integrity": "sha512-khUlZSwt9xXCaTbbxFYBKDc/bWAGWJjOgvxETwkTN7KRm66EeT1ZdZj6i2ceh9sP2Pzqsbc704r2yngBrxBVug==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.3",
+        "vue-demi": "^0.14.10"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "typescript": ">=4.4.4",
+        "vue": "^2.7.0 || ^3.5.11"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/postcss": {
+      "version": "8.5.6",
+      "resolved": "https://registry.npmmirror.com/postcss/-/postcss-8.5.6.tgz",
+      "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
+      "funding": [
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/postcss/"
+        },
+        {
+          "type": "tidelift",
+          "url": "https://tidelift.com/funding/github/npm/postcss"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/ai"
+        }
+      ],
+      "license": "MIT",
+      "dependencies": {
+        "nanoid": "^3.3.11",
+        "picocolors": "^1.1.1",
+        "source-map-js": "^1.2.1"
+      },
+      "engines": {
+        "node": "^10 || ^12 || >=14"
+      }
+    },
+    "node_modules/proxy-from-env": {
+      "version": "1.1.0",
+      "resolved": "https://registry.npmmirror.com/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
+      "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==",
+      "license": "MIT"
+    },
+    "node_modules/readdirp": {
+      "version": "4.1.2",
+      "resolved": "https://registry.npmmirror.com/readdirp/-/readdirp-4.1.2.tgz",
+      "integrity": "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==",
+      "dev": true,
+      "license": "MIT",
+      "engines": {
+        "node": ">= 14.18.0"
+      },
+      "funding": {
+        "type": "individual",
+        "url": "https://paulmillr.com/funding/"
+      }
+    },
+    "node_modules/rollup": {
+      "version": "4.54.0",
+      "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.54.0.tgz",
+      "integrity": "sha512-3nk8Y3a9Ea8szgKhinMlGMhGMw89mqule3KWczxhIzqudyHdCIOHw8WJlj/r329fACjKLEh13ZSk7oE22kyeIw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@types/estree": "1.0.8"
+      },
+      "bin": {
+        "rollup": "dist/bin/rollup"
+      },
+      "engines": {
+        "node": ">=18.0.0",
+        "npm": ">=8.0.0"
+      },
+      "optionalDependencies": {
+        "@rollup/rollup-android-arm-eabi": "4.54.0",
+        "@rollup/rollup-android-arm64": "4.54.0",
+        "@rollup/rollup-darwin-arm64": "4.54.0",
+        "@rollup/rollup-darwin-x64": "4.54.0",
+        "@rollup/rollup-freebsd-arm64": "4.54.0",
+        "@rollup/rollup-freebsd-x64": "4.54.0",
+        "@rollup/rollup-linux-arm-gnueabihf": "4.54.0",
+        "@rollup/rollup-linux-arm-musleabihf": "4.54.0",
+        "@rollup/rollup-linux-arm64-gnu": "4.54.0",
+        "@rollup/rollup-linux-arm64-musl": "4.54.0",
+        "@rollup/rollup-linux-loong64-gnu": "4.54.0",
+        "@rollup/rollup-linux-ppc64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-gnu": "4.54.0",
+        "@rollup/rollup-linux-riscv64-musl": "4.54.0",
+        "@rollup/rollup-linux-s390x-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-gnu": "4.54.0",
+        "@rollup/rollup-linux-x64-musl": "4.54.0",
+        "@rollup/rollup-openharmony-arm64": "4.54.0",
+        "@rollup/rollup-win32-arm64-msvc": "4.54.0",
+        "@rollup/rollup-win32-ia32-msvc": "4.54.0",
+        "@rollup/rollup-win32-x64-gnu": "4.54.0",
+        "@rollup/rollup-win32-x64-msvc": "4.54.0",
+        "fsevents": "~2.3.2"
+      }
+    },
+    "node_modules/sass": {
+      "version": "1.97.1",
+      "resolved": "https://registry.npmmirror.com/sass/-/sass-1.97.1.tgz",
+      "integrity": "sha512-uf6HoO8fy6ClsrShvMgaKUn14f2EHQLQRtpsZZLeU/Mv0Q1K5P0+x2uvH6Cub39TVVbWNSrraUhDAoFph6vh0A==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "chokidar": "^4.0.0",
+        "immutable": "^5.0.2",
+        "source-map-js": ">=0.6.2 <2.0.0"
+      },
+      "bin": {
+        "sass": "sass.js"
+      },
+      "engines": {
+        "node": ">=14.0.0"
+      },
+      "optionalDependencies": {
+        "@parcel/watcher": "^2.4.1"
+      }
+    },
+    "node_modules/semver": {
+      "version": "7.7.3",
+      "resolved": "https://registry.npmmirror.com/semver/-/semver-7.7.3.tgz",
+      "integrity": "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q==",
+      "dev": true,
+      "license": "ISC",
+      "bin": {
+        "semver": "bin/semver.js"
+      },
+      "engines": {
+        "node": ">=10"
+      }
+    },
+    "node_modules/source-map-js": {
+      "version": "1.2.1",
+      "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
+      "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
+      "license": "BSD-3-Clause",
+      "engines": {
+        "node": ">=0.10.0"
+      }
+    },
+    "node_modules/to-regex-range": {
+      "version": "5.0.1",
+      "resolved": "https://registry.npmmirror.com/to-regex-range/-/to-regex-range-5.0.1.tgz",
+      "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
+      "dev": true,
+      "license": "MIT",
+      "optional": true,
+      "dependencies": {
+        "is-number": "^7.0.0"
+      },
+      "engines": {
+        "node": ">=8.0"
+      }
+    },
+    "node_modules/typescript": {
+      "version": "5.9.3",
+      "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz",
+      "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
+      "devOptional": true,
+      "license": "Apache-2.0",
+      "bin": {
+        "tsc": "bin/tsc",
+        "tsserver": "bin/tsserver"
+      },
+      "engines": {
+        "node": ">=14.17"
+      }
+    },
+    "node_modules/undici-types": {
+      "version": "6.21.0",
+      "resolved": "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz",
+      "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
+      "dev": true,
+      "license": "MIT"
+    },
+    "node_modules/vite": {
+      "version": "5.4.21",
+      "resolved": "https://registry.npmmirror.com/vite/-/vite-5.4.21.tgz",
+      "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "esbuild": "^0.21.3",
+        "postcss": "^8.4.43",
+        "rollup": "^4.20.0"
+      },
+      "bin": {
+        "vite": "bin/vite.js"
+      },
+      "engines": {
+        "node": "^18.0.0 || >=20.0.0"
+      },
+      "funding": {
+        "url": "https://github.com/vitejs/vite?sponsor=1"
+      },
+      "optionalDependencies": {
+        "fsevents": "~2.3.3"
+      },
+      "peerDependencies": {
+        "@types/node": "^18.0.0 || >=20.0.0",
+        "less": "*",
+        "lightningcss": "^1.21.0",
+        "sass": "*",
+        "sass-embedded": "*",
+        "stylus": "*",
+        "sugarss": "*",
+        "terser": "^5.4.0"
+      },
+      "peerDependenciesMeta": {
+        "@types/node": {
+          "optional": true
+        },
+        "less": {
+          "optional": true
+        },
+        "lightningcss": {
+          "optional": true
+        },
+        "sass": {
+          "optional": true
+        },
+        "sass-embedded": {
+          "optional": true
+        },
+        "stylus": {
+          "optional": true
+        },
+        "sugarss": {
+          "optional": true
+        },
+        "terser": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue": {
+      "version": "3.5.26",
+      "resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.26.tgz",
+      "integrity": "sha512-SJ/NTccVyAoNUJmkM9KUqPcYlY+u8OVL1X5EW9RIs3ch5H2uERxyyIUI4MRxVCSOiEcupX9xNGde1tL9ZKpimA==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/compiler-dom": "3.5.26",
+        "@vue/compiler-sfc": "3.5.26",
+        "@vue/runtime-dom": "3.5.26",
+        "@vue/server-renderer": "3.5.26",
+        "@vue/shared": "3.5.26"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      },
+      "peerDependenciesMeta": {
+        "typescript": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-demi": {
+      "version": "0.14.10",
+      "resolved": "https://registry.npmmirror.com/vue-demi/-/vue-demi-0.14.10.tgz",
+      "integrity": "sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==",
+      "hasInstallScript": true,
+      "license": "MIT",
+      "bin": {
+        "vue-demi-fix": "bin/vue-demi-fix.js",
+        "vue-demi-switch": "bin/vue-demi-switch.js"
+      },
+      "engines": {
+        "node": ">=12"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/antfu"
+      },
+      "peerDependencies": {
+        "@vue/composition-api": "^1.0.0-rc.1",
+        "vue": "^3.0.0-0 || ^2.6.0"
+      },
+      "peerDependenciesMeta": {
+        "@vue/composition-api": {
+          "optional": true
+        }
+      }
+    },
+    "node_modules/vue-konva": {
+      "version": "3.2.6",
+      "resolved": "https://registry.npmmirror.com/vue-konva/-/vue-konva-3.2.6.tgz",
+      "integrity": "sha512-QtiM/BYHs4gyRqhg6w0lHYGONb936qD5PeGpSFFnFzTLcOaHXd3EAas2PafrWbcILdHAUNm+0j7+djJw6g6XCw==",
+      "funding": [
+        {
+          "type": "patreon",
+          "url": "https://www.patreon.com/lavrton"
+        },
+        {
+          "type": "opencollective",
+          "url": "https://opencollective.com/konva"
+        },
+        {
+          "type": "github",
+          "url": "https://github.com/sponsors/lavrton"
+        }
+      ],
+      "license": "MIT",
+      "engines": {
+        "node": ">= 4.0.0",
+        "npm": ">= 3.0.0"
+      },
+      "peerDependencies": {
+        "konva": ">7",
+        "vue": "^3"
+      }
+    },
+    "node_modules/vue-router": {
+      "version": "4.6.4",
+      "resolved": "https://registry.npmmirror.com/vue-router/-/vue-router-4.6.4.tgz",
+      "integrity": "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg==",
+      "license": "MIT",
+      "dependencies": {
+        "@vue/devtools-api": "^6.6.4"
+      },
+      "funding": {
+        "url": "https://github.com/sponsors/posva"
+      },
+      "peerDependencies": {
+        "vue": "^3.5.0"
+      }
+    },
+    "node_modules/vue-template-compiler": {
+      "version": "2.7.16",
+      "resolved": "https://registry.npmmirror.com/vue-template-compiler/-/vue-template-compiler-2.7.16.tgz",
+      "integrity": "sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "de-indent": "^1.0.2",
+        "he": "^1.2.0"
+      }
+    },
+    "node_modules/vue-tsc": {
+      "version": "1.8.27",
+      "resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-1.8.27.tgz",
+      "integrity": "sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==",
+      "dev": true,
+      "license": "MIT",
+      "dependencies": {
+        "@volar/typescript": "~1.11.1",
+        "@vue/language-core": "1.8.27",
+        "semver": "^7.5.4"
+      },
+      "bin": {
+        "vue-tsc": "bin/vue-tsc.js"
+      },
+      "peerDependencies": {
+        "typescript": "*"
+      }
+    }
+  }
+}

+ 30 - 0
table_line_generator/frontend/package.json

@@ -0,0 +1,30 @@
+{
+  "name": "table-line-editor",
+  "version": "0.1.0",
+  "private": true,
+  "type": "module",
+  "scripts": {
+    "dev": "vite",
+    "build": "vue-tsc -b && vite build",
+    "preview": "vite preview",
+    "lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix"
+  },
+  "dependencies": {
+    "vue": "^3.4.0",
+    "vue-router": "^4.2.5",
+    "pinia": "^2.1.7",
+    "axios": "^1.6.2",
+    "konva": "^9.2.3",
+    "vue-konva": "^3.0.2",
+    "element-plus": "^2.4.4",
+    "@element-plus/icons-vue": "^2.3.1"
+  },
+  "devDependencies": {
+    "@vitejs/plugin-vue": "^4.5.2",
+    "@types/node": "^20.10.5",
+    "typescript": "^5.3.3",
+    "vite": "^5.0.10",
+    "vue-tsc": "^1.8.25",
+    "sass": "^1.69.5"
+  }
+}

+ 16 - 0
table_line_generator/frontend/src/App.vue

@@ -0,0 +1,16 @@
+<template>
+  <router-view />
+</template>
+
+<script setup lang="ts">
+// App root component
+</script>
+
+<style>
+html, body, #app {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  width: 100%;
+}
+</style>

+ 100 - 0
table_line_generator/frontend/src/api/batch.ts

@@ -0,0 +1,100 @@
+import axios from 'axios'
+
+const api = axios.create({
+  baseURL: '/api',
+  timeout: 300000  // 5分钟超时,批量处理可能需要较长时间
+})
+
+/**
+ * 文件对
+ */
+export interface FilePair {
+  json_path: string
+  image_path: string
+}
+
+/**
+ * 批量处理请求
+ */
+export interface BatchProcessRequest {
+  template_name: string
+  file_pairs: FilePair[]
+  output_dir: string
+  parallel?: boolean
+  adjust_rows?: boolean
+}
+
+/**
+ * 批量处理结果
+ */
+export interface BatchProcessResult {
+  success: boolean
+  json_path: string
+  image_path: string
+  structure_path?: string
+  filename: string
+  rows?: number
+  cols?: number
+  error?: string
+}
+
+/**
+ * 批量处理响应
+ */
+export interface BatchProcessResponse {
+  success: boolean
+  total: number
+  processed: number
+  failed: number
+  results: BatchProcessResult[]
+  message?: string
+}
+
+/**
+ * 批量绘图请求
+ */
+export interface DrawBatchRequest {
+  results: BatchProcessResult[]
+  line_width?: number
+  line_color?: number[]
+}
+
+/**
+ * 批量绘图响应
+ */
+export interface DrawBatchResponse {
+  success: boolean
+  total: number
+  drawn: number
+  results: Array<{
+    success: boolean
+    image_path?: string
+    filename: string
+    error?: string
+  }>
+  message?: string
+}
+
+/**
+ * 批量处理文件
+ */
+export async function batchProcess(request: BatchProcessRequest): Promise<BatchProcessResponse> {
+  const response = await api.post<BatchProcessResponse>('/batch/process', request)
+  return response.data
+}
+
+/**
+ * 批量绘制表格线
+ */
+export async function batchDraw(request: DrawBatchRequest): Promise<DrawBatchResponse> {
+  const response = await api.post<DrawBatchResponse>('/batch/draw', request)
+  return response.data
+}
+
+/**
+ * 健康检查
+ */
+export async function healthCheck(): Promise<{ status: string; service: string }> {
+  const response = await api.get('/batch/health')
+  return response.data
+}

+ 32 - 0
table_line_generator/frontend/src/api/config.ts

@@ -0,0 +1,32 @@
+import axios from 'axios'
+import type { DataSourceConfig } from '@/stores/templateStore'
+
+const api = axios.create({
+  baseURL: '/api',
+  timeout: 30000
+})
+
+/**
+ * 获取所有数据源配置
+ */
+export async function listDataSources(): Promise<{ sources: DataSourceConfig[] }> {
+  const response = await api.get<{ sources: DataSourceConfig[] }>('/config/data-sources')
+  return response.data
+}
+
+/**
+ * 获取指定数据源配置
+ * @param filename 配置文件名(不含 .yaml 扩展名)
+ */
+export async function getDataSource(filename: string): Promise<DataSourceConfig> {
+  const response = await api.get<DataSourceConfig>(`/config/data-sources/${filename}`)
+  return response.data
+}
+
+/**
+ * 健康检查
+ */
+export async function healthCheck(): Promise<{ status: string; service: string }> {
+  const response = await api.get('/config/health')
+  return response.data
+}

+ 63 - 0
table_line_generator/frontend/src/api/directory.ts

@@ -0,0 +1,63 @@
+/**
+ * 目录浏览 API
+ */
+import axios from 'axios'
+import type { DirectoryItem, FilePair } from '@/stores'
+
+const API_BASE = 'http://localhost:8000/api/directories'
+
+export interface BrowseResponse {
+  current_path: string
+  parent_path: string | null
+  items: DirectoryItem[]
+}
+
+export interface ScanResponse {
+  total: number
+  pairs: FilePair[]
+  labeled_count: number
+}
+
+/**
+ * 浏览目录
+ */
+export async function browseDirectory(
+  path: string = '~',
+  showHidden: boolean = false
+): Promise<BrowseResponse> {
+  const response = await axios.get(`${API_BASE}/browse`, {
+    params: {
+      path,
+      show_hidden: showHidden
+    }
+  })
+  return response.data
+}
+
+/**
+ * 扫描目录下的图片+JSON文件对
+ */
+export async function scanDirectory(
+  imageDir: string,
+  jsonDir: string,
+  imagePattern?: string,
+  jsonPattern?: string,
+  outputDir?: string
+): Promise<ScanResponse> {
+  const response = await axios.post(`${API_BASE}/scan`, {
+    image_dir: imageDir,
+    json_dir: jsonDir,
+    image_pattern: imagePattern,
+    json_pattern: jsonPattern,
+    output_dir: outputDir
+  })
+  return response.data
+}
+
+/**
+ * 获取用户主目录
+ */
+export async function getHomeDirectory(): Promise<string> {
+  const response = await axios.get(`${API_BASE}/home`)
+  return response.data.path
+}

+ 89 - 0
table_line_generator/frontend/src/api/editor.ts

@@ -0,0 +1,89 @@
+import axios from 'axios'
+import type { 
+  UploadResponse, 
+  AnalyzeOptions, 
+  AnalyzeResponse, 
+  SaveRequest, 
+  SaveResponse,
+  ImageSize
+} from '@/types'
+
+const api = axios.create({
+  baseURL: '/api',
+  timeout: 60000
+})
+
+/**
+ * 上传文件并分析
+ */
+export async function uploadFiles(
+  jsonFile: File, 
+  imageFile: File
+): Promise<UploadResponse> {
+  const formData = new FormData()
+  formData.append('json_file', jsonFile)
+  formData.append('image_file', imageFile)
+  
+  const response = await api.post<UploadResponse>('/upload', formData, {
+    headers: {
+      'Content-Type': 'multipart/form-data'
+    }
+  })
+  
+  return response.data
+}
+
+/**
+ * 重新分析结构
+ */
+export async function reanalyzeStructure(
+  request: {
+    ocr_data: Record<string, unknown>
+    image_size: ImageSize
+    options?: AnalyzeOptions
+  }
+): Promise<AnalyzeResponse> {
+  const response = await api.post<AnalyzeResponse>('/analyze', {
+    ocr_data: request.ocr_data,
+    image_size: request.image_size,
+    ...request.options
+  })
+  
+  return response.data
+}
+
+/**
+ * 保存结果
+ */
+export async function saveResult(request: SaveRequest): Promise<SaveResponse> {
+  const response = await api.post<SaveResponse>('/save', request)
+  return response.data
+}
+
+/**
+ * 健康检查
+ */
+export async function healthCheck(): Promise<{ status: string }> {
+  const response = await api.get('/health')
+  return response.data
+}
+
+/**
+ * 通过文件路径加载数据
+ */
+export async function loadByPath(
+  imagePath: string,
+  jsonPath: string,
+  outputDir: string = './output',
+  structureSuffix: string = '_structure',
+  imageSuffix: string = ''
+): Promise<UploadResponse> {
+  const response = await api.post<UploadResponse>('/load-by-path', {
+    image_path: imagePath,
+    json_path: jsonPath,
+    output_dir: outputDir,
+    structure_suffix: structureSuffix,
+    image_suffix: imageSuffix
+  })
+  return response.data
+}

+ 5 - 0
table_line_generator/frontend/src/api/index.ts

@@ -0,0 +1,5 @@
+export * from './editor'
+export * as templateApi from './template'
+export * from './directory'
+export * as configApi from './config'
+export * as batchApi from './batch'

+ 99 - 0
table_line_generator/frontend/src/api/template.ts

@@ -0,0 +1,99 @@
+/**
+ * 模板 API
+ */
+import axios from 'axios'
+import type { TableStructure, ImageSize, TemplateInfo } from '@/types'
+
+const API_BASE = 'http://localhost:8000/api/templates'
+
+/**
+ * 获取模板列表
+ */
+export async function listTemplates(): Promise<TemplateInfo[]> {
+  const response = await axios.get(API_BASE)
+  return response.data.templates || []
+}
+
+/**
+ * 创建模板
+ */
+export async function createTemplate(
+  name: string,
+  structure: TableStructure,
+  imageSize: ImageSize,
+  description: string = ''
+): Promise<{ name: string; path: string; stats: object; created_at: string }> {
+  const response = await axios.post(API_BASE, {
+    name,
+    structure,
+    image_size: imageSize,
+    description
+  })
+  if (!response.data.success) {
+    throw new Error(response.data.detail || '创建模板失败')
+  }
+  return response.data.data
+}
+
+/**
+ * 获取模板详情
+ */
+export async function getTemplate(name: string): Promise<object> {
+  const response = await axios.get(`${API_BASE}/${encodeURIComponent(name)}`)
+  if (!response.data.success) {
+    throw new Error(response.data.detail || '获取模板失败')
+  }
+  return response.data.data
+}
+
+/**
+ * 删除模板
+ */
+export async function deleteTemplate(name: string): Promise<void> {
+  const response = await axios.delete(`${API_BASE}/${encodeURIComponent(name)}`)
+  if (!response.data.success) {
+    throw new Error(response.data.detail || '删除模板失败')
+  }
+}
+
+/**
+ * 预览应用模板
+ * @param targetTableBbox 目标页面的 table_bbox,如果提供则将模板映射到此区域
+ */
+export async function previewApplyTemplate(
+  name: string,
+  targetImageSize: ImageSize,
+  targetTableBbox?: number[] | null,
+  mode: 'relative' | 'absolute' = 'relative'
+): Promise<TableStructure> {
+  const response = await axios.post(`${API_BASE}/${encodeURIComponent(name)}/preview`, {
+    target_image_size: targetImageSize,
+    target_table_bbox: targetTableBbox,
+    mode
+  })
+  if (!response.data.success) {
+    throw new Error(response.data.detail || '预览失败')
+  }
+  return response.data.data
+}
+
+/**
+ * 确认应用模板
+ * @param targetTableBbox 目标页面的 table_bbox,如果提供则将模板映射到此区域
+ */
+export async function applyTemplate(
+  name: string,
+  targetImageSize: ImageSize,
+  targetTableBbox?: number[] | null,
+  mode: 'relative' | 'absolute' = 'relative'
+): Promise<TableStructure> {
+  const response = await axios.post(`${API_BASE}/${encodeURIComponent(name)}/apply`, {
+    target_image_size: targetImageSize,
+    target_table_bbox: targetTableBbox,
+    mode
+  })
+  if (!response.data.success) {
+    throw new Error(response.data.detail || '应用模板失败')
+  }
+  return response.data.data
+}

+ 724 - 0
table_line_generator/frontend/src/components/canvas/TableCanvas.vue

@@ -0,0 +1,724 @@
+<template>
+  <div class="table-canvas" ref="containerRef">
+    <v-stage 
+      ref="stageRef"
+      :config="stageConfig"
+      @mousedown="handleStageMouseDown"
+      @mousemove="handleStageMouseMove"
+      @mouseup="handleStageMouseUp"
+      @dragend="handleStageDragEnd"
+    >
+      <!-- 图片层(带旋转变换) -->
+      <v-layer :config="imageLayerConfig">
+        <v-image 
+          v-if="imageObj"
+          :config="{
+            image: imageObj,
+            width: displayWidth,
+            height: displayHeight
+          }"
+        />
+      </v-layer>
+      
+      <!-- 表格边界框层(不跟随旋转,保持横平竖直) -->
+      <v-layer>
+        <!-- 已定义的边界框 -->
+        <v-rect
+          v-if="editorStore.hasDefinedBbox"
+          :config="{
+            x: displayTableBbox[0],
+            y: displayTableBbox[1],
+            width: displayTableBbox[2] - displayTableBbox[0],
+            height: displayTableBbox[3] - displayTableBbox[1],
+            // stroke: '#67c23a',
+            stroke: '#ff0000', // 使用红色边框更醒目
+            strokeWidth: 2,
+            dash: [8, 4],
+            listening: false
+          }"
+        />
+        
+        <!-- 正在绘制的边界框 -->
+        <v-rect
+          v-if="isDrawingBbox && bboxStart && bboxEnd"
+          :config="{
+            x: Math.min(bboxStart.x, bboxEnd.x),
+            y: Math.min(bboxStart.y, bboxEnd.y),
+            width: Math.abs(bboxEnd.x - bboxStart.x),
+            height: Math.abs(bboxEnd.y - bboxStart.y),
+            stroke: '#ff9900', // 使用橙色边框更醒目
+            strokeWidth: 2,
+            dash: [5, 5],
+            listening: false
+          }"
+        />
+      </v-layer>
+      
+      <!-- 线条层(不跟随旋转,保持横平竖直) -->
+      <v-layer>
+        <!-- 横线 -->
+        <v-group
+          v-for="(y, index) in displayHorizontalLines"
+          :key="'h-' + index"
+          :config="{
+            x: 0,
+            y: y,
+            draggable: !editorStore.isDefiningBbox,
+            dragBoundFunc: (pos: { x: number; y: number }) => horizontalDragBound(pos, index)
+          }"
+          @click="() => selectLine('h', index)"
+          @dragstart="handleLineDragStart"
+          @dragmove="(e: KonvaEventObject<DragEvent>) => handleHorizontalDragMove(e)"
+          @dragend="(e: KonvaEventObject<DragEvent>) => handleHorizontalDragEnd(e, index)"
+        >
+          <v-line
+            :config="{
+              points: [horizontalLineEndpoints.x1, 0, horizontalLineEndpoints.x2, 0],
+              stroke: getLineColor('h', index),
+              strokeWidth: isLineSelected('h', index) ? 3 : 2,
+              hitStrokeWidth: 10
+            }"
+          />
+          <!-- 左侧标签 -->
+          <v-label
+            :config="{
+              x: horizontalLineEndpoints.x1 - 40,
+              y: -10
+            }"
+          >
+            <v-tag
+              :config="{
+                fill: 'white',
+                stroke: 'black',
+                strokeWidth: 1,
+                cornerRadius: 3
+              }"
+            />
+            <v-text
+              :config="{
+                text: 'R' + (index + 1),
+                fontSize: 12,
+                fontFamily: 'Arial',
+                fill: getLineColor('h', index),
+                padding: 3
+              }"
+            />
+          </v-label>
+          <!-- 右侧标签 -->
+          <v-label
+            :config="{
+              x: horizontalLineEndpoints.x2 + 10,
+              y: -10
+            }"
+          >
+            <v-tag
+              :config="{
+                fill: 'white',
+                stroke: 'black',
+                strokeWidth: 1,
+                cornerRadius: 3
+              }"
+            />
+            <v-text
+              :config="{
+                text: 'R' + (index + 1),
+                fontSize: 12,
+                fontFamily: 'Arial',
+                fill: getLineColor('h', index),
+                padding: 3
+              }"
+            />
+          </v-label>
+        </v-group>
+        
+        <!-- 竖线 -->
+        <v-group
+          v-for="(x, index) in displayVerticalLines"
+          :key="'v-' + index"
+          :config="{
+            x: x,
+            y: 0,
+            draggable: !editorStore.isDefiningBbox,
+            dragBoundFunc: (pos: { x: number; y: number }) => verticalDragBound(pos, index)
+          }"
+          @click="() => selectLine('v', index)"
+          @dragstart="handleLineDragStart"
+          @dragmove="(e: KonvaEventObject<DragEvent>) => handleVerticalDragMove(e)"
+          @dragend="(e: KonvaEventObject<DragEvent>) => handleVerticalDragEnd(e, index)"
+        >
+          <v-line
+            :config="{
+              points: [0, verticalLineEndpoints.y1, 0, verticalLineEndpoints.y2],
+              stroke: getLineColor('v', index),
+              strokeWidth: isLineSelected('v', index) ? 3 : 2,
+              hitStrokeWidth: 10
+            }"
+          />
+          <!-- 上方标签 -->
+          <v-label
+            :config="{
+              x: -12,
+              y: verticalLineEndpoints.y1 - 25
+            }"
+          >
+            <v-tag
+              :config="{
+                fill: 'white',
+                stroke: 'black',
+                strokeWidth: 1,
+                cornerRadius: 3
+              }"
+            />
+            <v-text
+              :config="{
+                text: 'C' + (index + 1),
+                fontSize: 12,
+                fontFamily: 'Arial',
+                fill: getLineColor('v', index),
+                padding: 3
+              }"
+            />
+          </v-label>
+          <!-- 下方标签 -->
+          <v-label
+            :config="{
+              x: -12,
+              y: verticalLineEndpoints.y2 + 10
+            }"
+          >
+            <v-tag
+              :config="{
+                fill: 'white',
+                stroke: 'black',
+                strokeWidth: 1,
+                cornerRadius: 3
+              }"
+            />
+            <v-text
+              :config="{
+                text: 'C' + (index + 1),
+                fontSize: 12,
+                fontFamily: 'Arial',
+                fill: getLineColor('v', index),
+                padding: 3
+              }"
+            />
+          </v-label>
+        </v-group>
+      </v-layer>
+    </v-stage>
+    
+    <!-- 工具栏 -->
+    <div class="canvas-toolbar">
+      <el-button-group>
+        <el-button size="small" @click="zoomIn" title="放大">
+          <el-icon><ZoomIn /></el-icon>
+        </el-button>
+        <el-button size="small" @click="zoomOut" title="缩小">
+          <el-icon><ZoomOut /></el-icon>
+        </el-button>
+        <el-button size="small" @click="resetView" title="重置视图">
+          <el-icon><Refresh /></el-icon>
+        </el-button>
+      </el-button-group>
+      <span class="zoom-display">{{ Math.round(editorStore.zoom * 100) }}%</span>
+
+      <el-button-group>
+        <el-button
+          size="small"
+          :type="viewMode === 'table' ? 'primary' : 'default'"
+          @click="focusTableView(true)"
+          :disabled="!hasTableBbox"
+          title="聚焦表格区域"
+        >
+          <el-icon><View /></el-icon>
+          表格视图
+        </el-button>
+        <el-button
+          size="small"
+          :type="viewMode === 'image' ? 'primary' : 'default'"
+          @click="focusFullImage(true)"
+          title="查看整张图片"
+        >
+          <el-icon><FullScreen /></el-icon>
+          全图视图
+        </el-button>
+      </el-button-group>
+      
+      <!-- 定义边界框按钮 -->
+      <el-button 
+        size="small" 
+        :type="editorStore.isDefiningBbox ? 'warning' : 'default'"
+        @click="toggleDefineBbox"
+        title="框选表格区域"
+      >
+        <el-icon><Crop /></el-icon>
+        {{ editorStore.isDefiningBbox ? '完成框选' : '框选表格' }}
+      </el-button>
+    </div>
+    
+    <!-- 提示信息 -->
+    <div v-if="editorStore.isDefiningBbox" class="bbox-hint">
+      按住鼠标左键拖动绘制表格边界框
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
+import { useEditorStore } from '@/stores'
+import { useCanvasInteraction } from '@/composables'
+import { ZoomIn, ZoomOut, Refresh, Crop, View, FullScreen } from '@element-plus/icons-vue'
+import type { KonvaEventObject } from 'konva/lib/Node'
+
+const editorStore = useEditorStore()
+const { 
+  displayToOriginal, 
+  displayHorizontalLines,
+  displayVerticalLines,
+  displayTableBbox,
+  horizontalLineEndpoints,
+  verticalLineEndpoints
+} = useCanvasInteraction()
+
+const containerRef = ref<HTMLElement | null>(null)
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const stageRef = ref<any>(null)
+const imageObj = ref<HTMLImageElement | null>(null)
+const viewMode = ref<'table' | 'image' | 'free'>('image')
+const hasTableBbox = computed(() => editorStore.hasDefinedBbox)
+
+// 边界框绘制状态
+const isDrawingBbox = ref(false)
+const bboxStart = ref<{ x: number; y: number } | null>(null)
+const bboxEnd = ref<{ x: number; y: number } | null>(null)
+
+// 计算画布尺寸
+const stageConfig = computed(() => ({
+  width: containerRef.value?.clientWidth || 800,
+  height: containerRef.value?.clientHeight || 600,
+  scaleX: editorStore.zoom,
+  scaleY: editorStore.zoom,
+  x: editorStore.stagePos.x,
+  y: editorStore.stagePos.y,
+  draggable: !editorStore.isDefiningBbox  // 定义边界框时禁止拖拽画布
+}))
+
+// 显示尺寸(原图尺寸 * scaleFactor)
+const displayWidth = computed(() => editorStore.imageSize.width * editorStore.scaleFactor)
+const displayHeight = computed(() => editorStore.imageSize.height * editorStore.scaleFactor)
+
+// 旋转角度(度)供 Konva 使用
+const imageRotation = computed(() => editorStore.skewAngle)
+
+// 图片、边界框、线条的统一旋转配置(绕图片中心)
+const imageLayerConfig = computed(() => ({
+  rotation: imageRotation.value,
+  offsetX: displayWidth.value / 2,
+  offsetY: displayHeight.value / 2,
+  x: displayWidth.value / 2,
+  y: displayHeight.value / 2
+}))
+
+// 加载图片并应用表格视图
+watch(() => editorStore.imageSrc, (newSrc: string | null) => {
+  if (newSrc) {
+    const img = new Image()
+    img.onload = () => {
+      imageObj.value = img
+      // 图片加载完成后,设置默认表格视图
+      viewMode.value = 'table'
+      // 使用 setTimeout 确保 Vue 响应式更新和 DOM 渲染完成
+      setTimeout(() => {
+        applyViewMode()
+      }, 50)
+    }
+    img.src = `data:image/png;base64,${newSrc}`
+  } else {
+    imageObj.value = null
+    viewMode.value = 'image'
+  }
+}, { immediate: true })
+
+function clampZoom(value: number) {
+  return Math.max(0.25, Math.min(2.0, value))
+}
+
+function getContainerSize() {
+  const width = containerRef.value?.clientWidth || 0
+  const height = containerRef.value?.clientHeight || 0
+  // console.log('Container size:', { width, height })  // 添加日志
+  return { width, height }
+}
+
+function focusFullImage(setMode = false) {
+  const { width: containerWidth, height: containerHeight } = getContainerSize()
+  if (!containerWidth || !containerHeight || !displayWidth.value || !displayHeight.value) return
+
+  const zoomX = containerWidth / displayWidth.value
+  const zoomY = containerHeight / displayHeight.value
+  const targetZoom = clampZoom(Math.min(zoomX, zoomY) * 0.95)
+  const centerX = displayWidth.value / 2
+  const centerY = displayHeight.value / 2
+
+  editorStore.setZoom(targetZoom)
+  editorStore.setStagePos(
+    containerWidth / 2 - centerX * targetZoom,
+    containerHeight / 2 - centerY * targetZoom
+  )
+
+  if (setMode) {
+    viewMode.value = 'image'
+  }
+}
+
+function focusTableView(setMode = false) {
+  const { width: containerWidth, height: containerHeight } = getContainerSize()
+  if (!containerWidth || !containerHeight) return
+
+  if (!hasTableBbox.value) {
+    focusFullImage(setMode)
+    return
+  }
+
+  const hLines = displayHorizontalLines.value
+  const vLines = displayVerticalLines.value
+  
+  if (hLines.length === 0 || vLines.length === 0) {
+    focusFullImage(setMode)
+    return
+  }
+  
+  const x1 = Math.min(...vLines)
+  const x2 = Math.max(...vLines)
+  const y1 = Math.min(...hLines)
+  const y2 = Math.max(...hLines)
+
+  // 考虑标签占用的空间和底部遮挡
+  const PADDING = 15
+  const BOTTOM_OVERLAY = 60 // 底部工具栏高度
+  
+  const paddedX1 = x1 - PADDING
+  const paddedX2 = x2 + PADDING
+  const paddedY1 = y1 - PADDING
+  const paddedY2 = y2 + PADDING
+
+  const bboxWidth = paddedX2 - paddedX1
+  const bboxHeight = paddedY2 - paddedY1
+
+  if (bboxWidth <= 0 || bboxHeight <= 0) {
+    focusFullImage(setMode)
+    return
+  }
+
+  // 计算可视区域高度
+  const visibleHeight = Math.max(100, containerHeight - BOTTOM_OVERLAY)
+
+  // 计算缩放比例
+  const zoomX = containerWidth / bboxWidth
+  const zoomY = visibleHeight / bboxHeight
+  // 使用 0.9 的系数,留出一点边距
+  const targetZoom = clampZoom(Math.min(zoomX, zoomY) * 0.9)
+  
+  // 计算表格中心点
+  const centerX = (paddedX1 + paddedX2) / 2
+  const centerY = (paddedY1 + paddedY2) / 2
+  
+  // 将表格中心对齐到可视区域中心
+  const offsetX = containerWidth / 2 - centerX * targetZoom
+  const offsetY = visibleHeight / 2 - centerY * targetZoom
+
+  // console.log('Lines range:', { x1, x2, y1, y2 })
+  // console.log('Padded bbox:', { paddedX1, paddedX2, paddedY1, paddedY2, bboxWidth, bboxHeight })
+  // console.log('Zoom calculation:', { zoomX, zoomY, targetZoom, visibleHeight })
+  // console.log('Center:', { centerX, centerY })
+  // console.log('Offset:', { offsetX, offsetY })
+
+  editorStore.setZoom(targetZoom)
+  editorStore.setStagePos(offsetX, offsetY)
+
+  if (setMode) {
+    viewMode.value = 'table'
+  }
+}
+
+function applyViewMode() {
+  if (!editorStore.hasData) return
+  if (viewMode.value === 'free') return
+  if (viewMode.value === 'table') {
+    if (hasTableBbox.value) {
+      focusTableView()
+    } else {
+      viewMode.value = 'image'
+      focusFullImage()
+    }
+    return
+  }
+  focusFullImage()
+}
+
+watch(
+  () => [hasTableBbox.value, displayWidth.value, displayHeight.value, displayTableBbox.value.join(',')],
+  () => {
+    if (viewMode.value !== 'free') {
+      applyViewMode()
+    }
+  }
+)
+
+function handleStageDragEnd(e: KonvaEventObject<DragEvent>) {
+  const stageNode = stageRef.value?.getNode()
+  // 仅在舞台本身被拖拽时更新位置
+  if (stageNode && e.target === stageNode) {
+    editorStore.setStagePos(stageNode.x(), stageNode.y())
+    viewMode.value = 'free'
+  }
+}
+
+function handleLineDragStart() {
+  viewMode.value = 'free'
+}
+
+// 横线拖拽移动中 - 实时修正 X 轴偏移
+function handleHorizontalDragMove(e: KonvaEventObject<DragEvent>) {
+  const node = e.target
+  // 横线只能垂直移动,强制 X 轴为 0
+  if (node.x() !== 0) {
+    node.x(0)
+  }
+}
+
+// 竖线拖拽移动中 - 实时修正 Y 轴偏移
+function handleVerticalDragMove(e: KonvaEventObject<DragEvent>) {
+  const node = e.target
+  // 竖线只能水平移动,强制 Y 轴为 0
+  if (node.y() !== 0) {
+    node.y(0)
+  }
+}
+
+// 横线拖拽约束
+// pos 是 group 即将移动到的绝对位置
+function horizontalDragBound(pos: { x: number; y: number }, _index: number) {
+  // 约束 Y 在图片边界内(不是表格边界,允许调整到任意位置)
+  return {
+    x: 0,  // 横线 X 保持 0
+    y: Math.max(0, Math.min(pos.y, displayHeight.value))
+  }
+}
+
+// 竖线拖拽约束
+function verticalDragBound(pos: { x: number; y: number }, _index: number) {
+  // 约束 X 在图片边界内(不是表格边界,允许调整到任意位置)
+  return {
+    x: Math.max(0, Math.min(pos.x, displayWidth.value)),
+    y: 0  // 竖线 Y 保持 0
+  }
+}
+
+// 横线拖拽结束
+function handleHorizontalDragEnd(e: KonvaEventObject<DragEvent>, index: number) {
+  const node = e.target
+  const newY = node.y()  // group 的新 Y 位置就是线条的显示坐标
+  
+  // 转换为原图坐标
+  const originalY = displayToOriginal(newY)
+  editorStore.updateLinePosition('h', index, originalY)
+  
+  // 重置 x 位置(确保横线只垂直移动)
+  node.x(0)
+  viewMode.value = 'free'
+}
+
+// 竖线拖拽结束
+function handleVerticalDragEnd(e: KonvaEventObject<DragEvent>, index: number) {
+  const node = e.target
+  const newX = node.x()  // group 的新 X 位置就是线条的显示坐标
+  
+  // 转换为原图坐标
+  const originalX = displayToOriginal(newX)
+  editorStore.updateLinePosition('v', index, originalX)
+  
+  // 重置 y 位置(确保竖线只水平移动)
+  node.y(0)
+  viewMode.value = 'free'
+}
+
+// 选中线条
+function selectLine(type: 'h' | 'v', index: number) {
+  if (!editorStore.isDefiningBbox) {
+    editorStore.selectLine({ type, index })
+  }
+}
+
+// 检查线条是否被选中
+function isLineSelected(type: 'h' | 'v', index: number): boolean {
+  const selected = editorStore.selectedLine
+  return selected !== null && selected.type === type && selected.index === index
+}
+
+// 获取线条颜色
+function getLineColor(type: 'h' | 'v', index: number): string {
+  const isSelected = isLineSelected(type, index)
+  const isModified = type === 'h' 
+    ? editorStore.structure.modified_h_lines.includes(index)
+    : editorStore.structure.modified_v_lines.includes(index)
+  
+  if (isSelected) return '#f56c6c'  // 选中:红色
+  if (isModified) return '#e6a23c'  // 修改过:橙色
+  return '#409eff'  // 普通:蓝色
+}
+
+// 获取鼠标在画布上的位置
+function getPointerPosition(): { x: number; y: number } | null {
+  const stage = stageRef.value?.getNode()
+  if (!stage) return null
+  
+  const pos = stage.getPointerPosition()
+  if (!pos) return null
+  
+  // 考虑缩放和偏移
+  return {
+    x: (pos.x - editorStore.stagePos.x) / editorStore.zoom,
+    y: (pos.y - editorStore.stagePos.y) / editorStore.zoom
+  }
+}
+
+// 画布鼠标事件处理
+function handleStageMouseDown(e: KonvaEventObject<MouseEvent>) {
+  // 如果是定义边界框模式
+  if (editorStore.isDefiningBbox) {
+    const pos = getPointerPosition()
+    if (pos) {
+      isDrawingBbox.value = true
+      bboxStart.value = pos
+      bboxEnd.value = pos
+    }
+    return
+  }
+  
+  // 如果点击的是画布背景,取消选中
+  if (e.target === e.target.getStage()) {
+    editorStore.clearSelection()
+  }
+}
+
+function handleStageMouseMove() {
+  if (editorStore.isDefiningBbox && isDrawingBbox.value) {
+    const pos = getPointerPosition()
+    if (pos) {
+      bboxEnd.value = pos
+    }
+  }
+}
+
+function handleStageMouseUp() {
+  if (editorStore.isDefiningBbox && isDrawingBbox.value && bboxStart.value && bboxEnd.value) {
+    // 计算边界框(确保 x1 < x2, y1 < y2)
+    const x1 = Math.min(bboxStart.value.x, bboxEnd.value.x)
+    const y1 = Math.min(bboxStart.value.y, bboxEnd.value.y)
+    const x2 = Math.max(bboxStart.value.x, bboxEnd.value.x)
+    const y2 = Math.max(bboxStart.value.y, bboxEnd.value.y)
+    
+    // 检查边界框是否有效(至少有一定大小)
+    if (x2 - x1 > 10 && y2 - y1 > 10) {
+      // 转换为原图坐标
+      const originalBbox = [
+        displayToOriginal(x1),
+        displayToOriginal(y1),
+        displayToOriginal(x2),
+        displayToOriginal(y2)
+      ]
+      editorStore.updateTableBbox(originalBbox)
+      viewMode.value = 'table'
+      nextTick(() => applyViewMode())
+    }
+    
+    // 结束绘制
+    isDrawingBbox.value = false
+    bboxStart.value = null
+    bboxEnd.value = null
+  }
+}
+
+// 切换定义边界框模式
+function toggleDefineBbox() {
+  editorStore.setDefiningBbox(!editorStore.isDefiningBbox)
+  if (!editorStore.isDefiningBbox) {
+    isDrawingBbox.value = false
+    bboxStart.value = null
+    bboxEnd.value = null
+  }
+}
+
+// 缩放按钮
+function zoomIn() {
+  viewMode.value = 'free'
+  editorStore.setZoom(editorStore.zoom + 0.1)
+}
+
+function zoomOut() {
+  viewMode.value = 'free'
+  editorStore.setZoom(editorStore.zoom - 0.1)
+}
+
+function resetView() {
+  viewMode.value = hasTableBbox.value ? 'table' : 'image'
+  applyViewMode()
+}
+
+const handleResize = () => {
+  if (viewMode.value !== 'free') {
+    applyViewMode()
+  }
+}
+
+onMounted(() => {
+  window.addEventListener('resize', handleResize)
+})
+
+onBeforeUnmount(() => {
+  window.removeEventListener('resize', handleResize)
+})
+</script>
+
+<style scoped>
+.table-canvas {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  position: relative;
+  background: #e8e8e8;
+}
+
+.canvas-toolbar {
+  position: absolute;
+  top: 12px;
+  left: 12px;
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  z-index: 10;
+}
+
+.zoom-display {
+  background: rgba(0, 0, 0, 0.6);
+  color: #fff;
+  padding: 4px 12px;
+  border-radius: 4px;
+  font-size: 13px;
+}
+
+.bbox-hint {
+  position: absolute;
+  bottom: 20px;
+  left: 50%;
+  transform: translateX(-50%);
+  background: rgba(230, 162, 60, 0.9);
+  color: #fff;
+  padding: 8px 16px;
+  border-radius: 4px;
+  font-size: 14px;
+  z-index: 10;
+}
+</style>

+ 190 - 0
table_line_generator/frontend/src/components/common/FileUploader.vue

@@ -0,0 +1,190 @@
+<template>
+  <div class="file-uploader">
+    <div class="upload-area" v-if="!isUploading">
+      <el-upload
+        ref="uploadRef"
+        class="upload-dragger"
+        drag
+        :auto-upload="false"
+        :show-file-list="false"
+        :on-change="handleFileChange"
+        multiple
+        accept=".json,.png,.jpg,.jpeg,.bmp,.tiff"
+      >
+        <el-icon class="upload-icon"><Upload /></el-icon>
+        <div class="upload-text">将文件拖到此处,或点击上传</div>
+        <div class="upload-hint">
+          请同时上传 OCR JSON 文件和对应的图片文件
+        </div>
+      </el-upload>
+      
+      <!-- 已选择的文件 -->
+      <div class="selected-files" v-if="jsonFile || imageFile">
+        <div class="file-item" v-if="jsonFile">
+          <el-icon><Document /></el-icon>
+          <span>{{ jsonFile.name }}</span>
+          <el-button link type="danger" @click="jsonFile = null">
+            <el-icon><Close /></el-icon>
+          </el-button>
+        </div>
+        <div class="file-item" v-if="imageFile">
+          <el-icon><Picture /></el-icon>
+          <span>{{ imageFile.name }}</span>
+          <el-button link type="danger" @click="imageFile = null">
+            <el-icon><Close /></el-icon>
+          </el-button>
+        </div>
+        
+        <el-button 
+          type="primary" 
+          @click="handleUpload"
+          :disabled="!jsonFile || !imageFile"
+          class="upload-btn"
+        >
+          <el-icon><UploadFilled /></el-icon>
+          开始分析
+        </el-button>
+      </div>
+    </div>
+    
+    <!-- 上传中 -->
+    <div class="uploading" v-else>
+      <el-icon class="is-loading" :size="48"><Loading /></el-icon>
+      <div class="uploading-text">正在分析...</div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Upload, Document, Picture, Close, UploadFilled, Loading } from '@element-plus/icons-vue'
+import type { UploadFile } from 'element-plus'
+import { uploadFiles } from '@/api'
+import { useEditorStore } from '@/stores'
+
+const editorStore = useEditorStore()
+const jsonFile = ref<File | null>(null)
+const imageFile = ref<File | null>(null)
+const isUploading = ref(false)
+
+function handleFileChange(uploadFile: UploadFile) {
+  const file = uploadFile.raw
+  if (!file) return
+  
+  const ext = file.name.split('.').pop()?.toLowerCase()
+  
+  if (ext === 'json') {
+    jsonFile.value = file
+  } else if (['png', 'jpg', 'jpeg', 'bmp', 'tiff'].includes(ext || '')) {
+    imageFile.value = file
+  } else {
+    ElMessage.warning(`不支持的文件格式: ${ext}`)
+  }
+}
+
+async function handleUpload() {
+  if (!jsonFile.value || !imageFile.value) {
+    ElMessage.warning('请同时选择 JSON 文件和图片文件')
+    return
+  }
+  
+  isUploading.value = true
+  editorStore.setLoading(true)
+  
+  try {
+    const response = await uploadFiles(jsonFile.value, imageFile.value)
+    
+    if (response.success) {
+      editorStore.loadData(
+        response.image_base64,
+        response.image_size,
+        response.structure,
+        response.scale_factor,
+        response.ocr_data || null,
+        response.suggested_filename || undefined
+      )
+      
+      ElMessage.success('分析完成')
+      
+      // 清空选择
+      jsonFile.value = null
+      imageFile.value = null
+    } else {
+      ElMessage.error(response.message || '上传失败')
+    }
+  } catch (error: unknown) {
+    console.error('上传失败:', error)
+    const errorMessage = error instanceof Error ? error.message : '未知错误'
+    ElMessage.error(`上传失败: ${errorMessage}`)
+  } finally {
+    isUploading.value = false
+    editorStore.setLoading(false)
+  }
+}
+</script>
+
+<style scoped>
+.file-uploader {
+  width: 100%;
+  height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.upload-area {
+  text-align: center;
+}
+
+.upload-dragger {
+  width: 400px;
+}
+
+.upload-icon {
+  font-size: 64px;
+  color: #c0c4cc;
+  margin-bottom: 16px;
+}
+
+.upload-text {
+  font-size: 16px;
+  color: #606266;
+  margin-bottom: 8px;
+}
+
+.upload-hint {
+  font-size: 13px;
+  color: #909399;
+}
+
+.selected-files {
+  margin-top: 24px;
+  padding: 16px;
+  background: #f5f7fa;
+  border-radius: 8px;
+}
+
+.file-item {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  margin-bottom: 8px;
+  font-size: 14px;
+  color: #606266;
+}
+
+.upload-btn {
+  margin-top: 16px;
+}
+
+.uploading {
+  text-align: center;
+  color: #909399;
+}
+
+.uploading-text {
+  margin-top: 16px;
+  font-size: 14px;
+}
+</style>

+ 11 - 0
table_line_generator/frontend/src/components/index.ts

@@ -0,0 +1,11 @@
+// Canvas components
+export { default as TableCanvas } from './canvas/TableCanvas.vue'
+
+// Common components
+export { default as FileUploader } from './common/FileUploader.vue'
+
+// Panel components
+export { default as ToolPanel } from './panels/ToolPanel.vue'
+export { default as StructurePanel } from './panels/StructurePanel.vue'
+export { default as SavePanel } from './panels/SavePanel.vue'
+export { default as TemplatePanel } from './panels/TemplatePanel.vue'

+ 481 - 0
table_line_generator/frontend/src/components/panels/SavePanel.vue

@@ -0,0 +1,481 @@
+<template>
+  <div class="save-panel">
+    <!-- 面板头部 - 始终可见 -->
+    <div class="panel-header" @click="expandSettings = !expandSettings">
+      <div class="header-left">
+        <div class="panel-title">保存设置</div>
+        <div class="path-summary">
+          <span class="path-label">输出路径:</span>
+          <span class="path-value">{{ outputDir }}</span>
+          <el-icon 
+            class="expand-icon" 
+            :class="{ expanded: expandSettings }"
+          >
+            <ArrowDown />
+          </el-icon>
+        </div>
+      </div>
+      <div class="header-right">
+        <el-tag type="info" size="small">{{ baseFilename }}</el-tag>
+      </div>
+    </div>
+
+    <!-- 可配置的选项(可折叠) -->
+    <el-collapse-transition>
+      <div v-show="expandSettings" class="settings-panel">
+        <!-- 路径详情卡片 -->
+        <div class="path-detail-card">
+          <div class="card-title">输出配置</div>
+          <div class="detail-grid">
+            <div class="detail-item">
+              <div class="detail-label">📁 输出目录</div>
+              <div class="detail-value path-copy">
+                {{ outputDir }}
+              </div>
+            </div>
+            <div class="detail-item">
+              <div class="detail-label">📄 基础文件名</div>
+              <div class="detail-value">
+                {{ baseFilename }}
+              </div>
+            </div>
+          </div>
+          <div class="config-hint">
+            ℹ️ 这些设置由数据源配置指定,无法修改。修改请返回数据源设置。
+          </div>
+        </div>
+
+        <!-- 保存选项卡片 -->
+        <div class="options-card">
+          <div class="card-title">文件处理</div>
+          <el-form label-position="top" size="small" class="options-form">
+            <!-- 结构文件后缀 -->
+            <el-form-item label="结构文件名">
+              <div class="filename-preview">
+                <span class="filename">{{ baseFilename }}<span class="suffix">{{ structureSuffix }}</span>.json</span>
+              </div>
+              <div class="config-hint" style="margin-top: 8px;">
+                ℹ️ 后缀由数据源配置指定:{{ structureSuffix }}.json
+              </div>
+            </el-form-item>
+
+            <!-- 覆盖策略 -->
+            <el-form-item label="文件存在时">
+              <el-radio-group v-model="overwriteMode" size="small">
+                <el-radio-button label="overwrite">
+                  <el-icon><DocumentCopy /></el-icon>
+                  <span style="margin-left: 4px;">覆盖</span>
+                </el-radio-button>
+                <el-radio-button label="skip">
+                  <el-icon><CircleCloseFilled /></el-icon>
+                  <span style="margin-left: 4px;">跳过</span>
+                </el-radio-button>
+                <el-radio-button label="new">
+                  <el-icon><Plus /></el-icon>
+                  <span style="margin-left: 4px;">新文件</span>
+                </el-radio-button>
+              </el-radio-group>
+              <div class="strategy-hint">
+                <span v-if="overwriteMode === 'overwrite'">覆盖现有文件</span>
+                <span v-else-if="overwriteMode === 'skip'">跳过保存,保留现有文件</span>
+                <span v-else>创建新文件(添加时间戳)</span>
+              </div>
+            </el-form-item>
+
+            <!-- 图片保存 -->
+            <el-form-item label="图片处理">
+              <div class="image-option">
+                <el-switch v-model="saveImage" size="large" />
+                <span class="option-text">
+                  <span v-if="saveImage">保存图片 ({{ baseFilename }}<span class="suffix">{{ imageSuffix }}</span>.png)</span>
+                  <span v-else>不保存图片</span>
+                </span>
+              </div>
+            </el-form-item>
+
+            <!-- 图片后缀(仅当保存图片时显示) -->
+            <el-form-item v-if="saveImage" label="图片文件名">
+              <div class="filename-preview">
+                <span class="filename">{{ baseFilename }}<span class="suffix">{{ imageSuffix }}</span>.png</span>
+              </div>
+              <div class="config-hint" style="margin-top: 8px;">
+                ℹ️ 后缀由数据源配置指定:{{ imageSuffix || '(空)' }}.png
+              </div>
+            </el-form-item>
+          </el-form>
+        </div>
+
+        <!-- 保存按钮 -->
+        <el-button 
+          type="primary" 
+          @click="handleSave"
+          :loading="isSaving"
+          :disabled="!canSave"
+          size="default"
+          class="save-button"
+        >
+          <el-icon><Download /></el-icon>
+          保存结果
+        </el-button>
+      </div>
+    </el-collapse-transition>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { Download, ArrowDown, DocumentCopy, CircleCloseFilled, Plus } from '@element-plus/icons-vue'
+import { saveResult } from '@/api'
+import { useEditorStore, useTemplateStore } from '@/stores'
+
+const editorStore = useEditorStore()
+const templateStore = useTemplateStore()
+
+// UI 状态
+const expandSettings = ref(false)
+const isSaving = ref(false)
+
+// 只读:来自模板的输出目录
+const outputDir = computed(() => {
+  return templateStore.scanConfig.outputDir || './output'
+})
+
+// 只读:来自当前文件的基础文件名(去掉扩展名)
+const baseFilename = computed(() => {
+  const pair = templateStore.currentFilePair
+  if (pair && pair.image_name) {
+    return pair.image_name.split('.')[0]
+  }
+  return editorStore.suggestedFilename || 'table'
+})
+
+// 从数据源配置读取结构文件后缀
+const structureSuffix = computed(() => {
+  const activeSource = templateStore.activeDataSource
+  if (activeSource && activeSource.output && activeSource.output.structure_suffix) {
+    // 移除 .json 后缀(后端会自动添加)
+    return activeSource.output.structure_suffix.replace('.json', '')
+  }
+  return '_structure'
+})
+
+// 可配置:覆盖策略
+const overwriteMode = ref<'overwrite' | 'skip' | 'new'>('overwrite')
+
+// 可配置:是否保存图片
+const saveImage = ref(true)
+
+// 从数据源配置读取图片文件后缀
+const imageSuffix = computed(() => {
+  const activeSource = templateStore.activeDataSource
+  if (activeSource && activeSource.output && activeSource.output.image_suffix) {
+    // 移除 .png 后缀(后端会自动添加)
+    return activeSource.output.image_suffix.replace('.png', '')
+  }
+  return ''
+})
+
+const canSave = computed(() => {
+  return editorStore.hasData && outputDir.value && baseFilename.value
+})
+
+async function handleSave() {
+  if (!canSave.value) {
+    ElMessage.warning('请先上传文件')
+    return
+  }
+  
+  isSaving.value = true
+  
+  try {
+    const filename = baseFilename.value
+    const imageFilename = saveImage.value ? `${baseFilename.value}${imageSuffix.value}` : undefined
+    
+    const response = await saveResult({
+      structure: editorStore.structure,
+      image_base64: saveImage.value ? (editorStore.imageSrc || undefined) : undefined,
+      output_dir: outputDir.value,
+      filename: filename,
+      image_filename: imageFilename,
+      overwrite_mode: overwriteMode.value,
+      structure_suffix: structureSuffix.value,
+      image_suffix: imageSuffix.value,
+      line_width: 2,
+      line_color: [0, 0, 0]
+    })
+    
+    if (response.success) {
+      ElMessage.success(`保存成功!`)
+      if (response.structure_path) {
+        ElMessage.info(`✅ 结构: ${response.structure_path}`)
+      }
+      if (response.image_path) {
+        ElMessage.info(`✅ 图片: ${response.image_path}`)
+      }
+      
+      // 保存后更新文件对的标注状态
+      if (templateStore.hasFilePairs) {
+        const currentIndex = templateStore.currentPairIndex
+        const structurePath = response.structure_path || null
+        templateStore.updatePairStatus(currentIndex, true, structurePath)
+        
+        // 自动移到下一个文件
+        if (templateStore.hasNextPair) {
+          templateStore.nextPair()
+          ElMessage.info('➡️  已移动到下一个文件')
+        } else {
+          ElMessage.success('🎉 所有文件处理完成!')
+        }
+      }
+      
+      // 保存后自动收起
+      expandSettings.value = false
+    } else {
+      ElMessage.error(response.message || '保存失败')
+    }
+  } catch (error: unknown) {
+    console.error('保存失败:', error)
+    const errorMessage = error instanceof Error ? error.message : '未知错误'
+    ElMessage.error(`保存失败: ${errorMessage}`)
+  } finally {
+    isSaving.value = false
+  }
+}
+</script>
+
+<style scoped>
+.save-panel {
+  border-top: 1px solid #e4e7ed;
+  background: #fafafa;
+}
+
+/* 面板头部 - 始终可见 */
+.panel-header {
+  padding: 12px 16px;
+  cursor: pointer;
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  transition: background-color 0.3s ease;
+  user-select: none;
+}
+
+.panel-header:hover {
+  background-color: #f5f5f5;
+}
+
+.header-left {
+  flex: 1;
+}
+
+.panel-title {
+  font-size: 14px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 6px;
+}
+
+.path-summary {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  font-size: 12px;
+}
+
+.path-label {
+  color: #909399;
+  font-weight: 500;
+}
+
+.path-value {
+  color: #409eff;
+  font-family: 'Monaco', 'Menlo', monospace;
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.expand-icon {
+  color: #909399;
+  transition: transform 0.3s ease;
+}
+
+.expand-icon.expanded {
+  transform: rotate(180deg);
+}
+
+.header-right {
+  margin-left: 8px;
+}
+
+/* 设置面板 - 可折叠 */
+.settings-panel {
+  padding: 16px;
+  animation: slideDown 0.3s ease;
+}
+
+@keyframes slideDown {
+  from {
+    opacity: 0;
+    transform: translateY(-10px);
+  }
+  to {
+    opacity: 1;
+    transform: translateY(0);
+  }
+}
+
+/* 路径详情卡片 */
+.path-detail-card {
+  background: #f0f9ff;
+  border: 1px solid #b3d8ff;
+  border-radius: 6px;
+  padding: 12px;
+  margin-bottom: 16px;
+}
+
+.card-title {
+  font-size: 12px;
+  font-weight: 600;
+  color: #303133;
+  margin-bottom: 8px;
+  text-transform: uppercase;
+  letter-spacing: 0.5px;
+}
+
+.detail-grid {
+  display: grid;
+  grid-template-columns: 1fr 1fr;
+  gap: 12px;
+  margin-bottom: 8px;
+}
+
+.detail-item {
+  background: white;
+  padding: 8px;
+  border-radius: 4px;
+  border-left: 3px solid #409eff;
+}
+
+.detail-label {
+  font-size: 11px;
+  color: #909399;
+  margin-bottom: 4px;
+  font-weight: 500;
+}
+
+.detail-value {
+  font-size: 13px;
+  color: #303133;
+  font-family: 'Monaco', 'Menlo', monospace;
+  word-break: break-all;
+  line-height: 1.4;
+}
+
+.detail-value.path-copy {
+  cursor: copy;
+  padding: 4px;
+  border-radius: 3px;
+  transition: background-color 0.2s;
+}
+
+.detail-value.path-copy:hover {
+  background-color: #ecf5ff;
+}
+
+.config-hint {
+  font-size: 11px;
+  color: #909399;
+  line-height: 1.5;
+  padding-top: 8px;
+  border-top: 1px solid #d9ecff;
+}
+
+/* 选项卡片 */
+.options-card {
+  background: white;
+  border: 1px solid #dcdfe6;
+  border-radius: 6px;
+  padding: 12px;
+  margin-bottom: 12px;
+}
+
+.options-form :deep(.el-form-item) {
+  margin-bottom: 12px;
+}
+
+.options-form :deep(.el-form-item:last-child) {
+  margin-bottom: 0;
+}
+
+.options-form :deep(.el-form-item__label) {
+  font-size: 12px;
+  color: #606266;
+  margin-bottom: 6px;
+}
+
+/* 文件名预览 */
+.filename-preview {
+  display: inline-block;
+  background: #f5f7fa;
+  padding: 6px 10px;
+  border-radius: 4px;
+  font-family: 'Monaco', 'Menlo', monospace;
+  font-size: 12px;
+  color: #303133;
+  margin-bottom: 6px;
+  border: 1px solid #dcdfe6;
+  max-width: 100%;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.filename {
+  display: block;
+}
+
+.suffix {
+  color: #409eff;
+  font-weight: 600;
+}
+
+/* 覆盖策略提示 */
+.strategy-hint {
+  font-size: 11px;
+  color: #909399;
+  margin-top: 4px;
+  padding: 4px 8px;
+  background: #f5f7fa;
+  border-radius: 3px;
+  border-left: 3px solid #e6a23c;
+}
+
+/* 图片选项 */
+.image-option {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.option-text {
+  font-size: 12px;
+  color: #606266;
+}
+
+.option-text .suffix {
+  color: #409eff;
+  font-weight: 600;
+}
+
+/* 保存按钮 */
+.save-button {
+  width: 100%;
+  height: 36px;
+  font-size: 13px;
+  font-weight: 600;
+  border-radius: 4px;
+}
+</style>

+ 192 - 0
table_line_generator/frontend/src/components/panels/StructurePanel.vue

@@ -0,0 +1,192 @@
+<template>
+  <div class="structure-panel">
+    <div class="panel-title">结构信息</div>
+    
+    <div class="info-section">
+      <div class="info-row">
+      <span class="label">图片尺寸</span>
+      <span class="value">{{ editorStore.imageSize.width }} × {{ editorStore.imageSize.height }}</span>
+      </div>
+      <div class="info-row" v-if="editorStore.scaleFactor < 1">
+      <span class="label">缩放比例</span>
+      <span class="value">{{ (editorStore.scaleFactor * 100).toFixed(1) }}%</span>
+      </div>
+      <div class="info-row">
+      <span class="label">行数</span>
+      <span class="value">{{ editorStore.rowCount }}</span>
+      </div>
+      <div class="info-row">
+      <span class="label">列数</span>
+      <span class="value">{{ editorStore.colCount }}</span>
+      </div>
+      <div class="info-row">
+      <span class="label">表格Bbox</span>
+      <span class="value">{{ editorStore.tableBbox?.join(', ') ?? '-' }}</span>
+      </div>
+    </div>
+    
+    <!-- 横线列表 -->
+    <div class="line-section">
+      <div class="section-title">
+        横线 ({{ editorStore.horizontalLineCount }})
+      </div>
+      <div class="line-list">
+        <div 
+          v-for="(y, index) in editorStore.structure.horizontal_lines"
+          :key="'h-' + index"
+          class="line-item"
+          :class="{
+            selected: isSelected('h', index),
+            modified: isModified('h', index)
+          }"
+          @click="selectLine('h', index)"
+        >
+          <span class="line-label">R{{ index + 1 }}</span>
+          <span class="line-value">Y = {{ y }}</span>
+        </div>
+      </div>
+    </div>
+    
+    <!-- 竖线列表 -->
+    <div class="line-section">
+      <div class="section-title">
+        竖线 ({{ editorStore.verticalLineCount }})
+      </div>
+      <div class="line-list">
+        <div 
+          v-for="(x, index) in editorStore.structure.vertical_lines"
+          :key="'v-' + index"
+          class="line-item"
+          :class="{
+            selected: isSelected('v', index),
+            modified: isModified('v', index)
+          }"
+          @click="selectLine('v', index)"
+        >
+          <span class="line-label">C{{ index + 1 }}</span>
+          <span class="line-value">X = {{ x }}</span>
+        </div>
+      </div>
+    </div>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { useEditorStore } from '@/stores'
+
+const editorStore = useEditorStore()
+
+function isSelected(type: 'h' | 'v', index: number): boolean {
+  const selected = editorStore.selectedLine
+  return selected !== null && selected.type === type && selected.index === index
+}
+
+function isModified(type: 'h' | 'v', index: number): boolean {
+  if (type === 'h') {
+    return editorStore.structure.modified_h_lines.includes(index)
+  } else {
+    return editorStore.structure.modified_v_lines.includes(index)
+  }
+}
+
+function selectLine(type: 'h' | 'v', index: number) {
+  editorStore.selectLine({ type, index })
+}
+</script>
+
+<style scoped>
+.structure-panel {
+  flex: 1;
+  padding: 16px;
+  overflow-y: auto;
+}
+
+.panel-title {
+  font-size: 14px;
+  font-weight: 600;
+  margin-bottom: 12px;
+  color: #303133;
+}
+
+.info-section {
+  margin-bottom: 16px;
+  padding-bottom: 16px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.info-row {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 8px;
+  font-size: 13px;
+}
+
+.info-row .label {
+  color: #909399;
+}
+
+.info-row .value {
+  color: #303133;
+  font-weight: 500;
+}
+
+.line-section {
+  margin-bottom: 16px;
+}
+
+.section-title {
+  font-size: 13px;
+  font-weight: 500;
+  color: #606266;
+  margin-bottom: 8px;
+}
+
+.line-list {
+  max-height: 200px;
+  overflow-y: auto;
+}
+
+.line-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 6px 10px;
+  margin-bottom: 4px;
+  background: #f5f7fa;
+  border-radius: 4px;
+  font-size: 12px;
+  cursor: pointer;
+  transition: all 0.2s;
+}
+
+.line-item:hover {
+  background: #e6f7ff;
+}
+
+.line-item.selected {
+  background: #409eff;
+  color: #fff;
+}
+
+.line-item.selected .line-label,
+.line-item.selected .line-value {
+  color: #fff;
+}
+
+.line-item.modified {
+  background: #fef0f0;
+}
+
+.line-item.modified.selected {
+  background: #f56c6c;
+}
+
+.line-label {
+  font-weight: 600;
+  color: #409eff;
+}
+
+.line-value {
+  color: #909399;
+}
+</style>

+ 110 - 0
table_line_generator/frontend/src/components/panels/TemplatePanel.vue

@@ -0,0 +1,110 @@
+<template>
+  <div class="template-panel">
+    <el-collapse v-model="activeCollapse">
+      <!-- 模板列表 -->
+      <TemplateListSection 
+        @template-applied="handleTemplateApplied"
+      />
+      
+      <!-- 数据源选择 -->
+      <DataSourceSection 
+        @data-source-selected="handleDataSourceSelected"
+      />
+      
+      <!-- 目录浏览 -->
+      <DirectoryBrowserSection 
+        @files-scanned="handleFilesScanned"
+      />
+      
+      <!-- 批量处理 -->
+      <BatchProcessSection 
+        @batch-started="handleBatchStarted"
+      />
+      
+      <!-- 文件列表 -->
+      <FileListSection 
+        @loadFile="emit('loadFile', $event)"
+      />
+    </el-collapse>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { useEditorStore, useTemplateStore } from '@/stores'
+import { 
+  useTemplate, 
+  useDirectory, 
+  useDataSource,
+  useBatchProcess
+} from './template-panel/composables'
+
+// 子组件导入
+import TemplateListSection from './template-panel/TemplateListSection.vue'
+import DataSourceSection from './template-panel/DataSourceSection.vue'
+import DirectoryBrowserSection from './template-panel/DirectoryBrowserSection.vue'
+import BatchProcessSection from './template-panel/BatchProcessSection.vue'
+import FileListSection from './template-panel/FileListSection.vue'
+
+const editorStore = useEditorStore()
+const templateStore = useTemplateStore()
+
+// UI 状态
+const activeCollapse = ref(['templates', 'data-sources'])
+
+// 初始化 Composables(用于事件回调)
+const {
+  loadTemplates,
+  loadDataSources,
+  selectDataSource
+} = useDataSource()
+
+const {
+  browsePath
+} = useDirectory()
+
+// 事件定义
+const emit = defineEmits<{
+  loadFile: [payload: { imagePath: string; jsonPath: string }]
+}>()
+
+// 事件处理器
+function handleTemplateApplied() {
+  // 模板已应用,重新加载模板列表
+  loadTemplates()
+}
+
+async function handleDataSourceSelected() {
+  // 数据源已选择,自动展开目录浏览
+  if (!activeCollapse.value.includes('directory')) {
+    activeCollapse.value.push('directory')
+  }
+}
+
+async function handleFilesScanned() {
+  // 文件扫描完成,自动展开文件列表
+  if (!activeCollapse.value.includes('files')) {
+    activeCollapse.value.push('files')
+  }
+}
+
+async function handleBatchStarted() {
+  // 批量处理开始,自动展开批量处理面板查看结果
+  if (!activeCollapse.value.includes('batch')) {
+    activeCollapse.value.push('batch')
+  }
+}
+
+// 初始化
+onMounted(async () => {
+  await loadTemplates()
+  await loadDataSources()
+  await browsePath('~')
+})
+</script>
+
+<style scoped lang="scss">
+.template-panel {
+  padding: 12px;
+}
+</style>

+ 333 - 0
table_line_generator/frontend/src/components/panels/ToolPanel.vue

@@ -0,0 +1,333 @@
+<template>
+  <div class="tool-panel">
+    <div class="panel-title">工具栏</div>
+    
+    <!-- 撤销/重做 -->
+    <div class="button-group">
+      <el-tooltip content="撤销 (Ctrl+Z)" placement="bottom">
+        <el-button 
+          :disabled="!historyStore.canUndo" 
+          @click="handleUndo"
+        >
+          <el-icon><RefreshLeft /></el-icon>
+          撤销 ({{ historyStore.undoCount }})
+        </el-button>
+      </el-tooltip>
+      
+      <el-tooltip content="重做 (Ctrl+Y)" placement="bottom">
+        <el-button 
+          :disabled="!historyStore.canRedo"
+          @click="handleRedo"
+        >
+          <el-icon><RefreshRight /></el-icon>
+          重做 ({{ historyStore.redoCount }})
+        </el-button>
+      </el-tooltip>
+    </div>
+    
+    <!-- 倾斜角度调整 -->
+    <div class="panel-title">倾斜调整</div>
+    <div class="skew-control">
+      <div class="skew-label">
+        <span>旋转角度</span>
+        <span class="skew-value">{{ skewAngleValue.toFixed(2) }}°</span>
+      </div>
+      <el-slider
+        v-model="skewAngleValue"
+        :min="-5"
+        :max="5"
+        :step="0.01"
+        :show-tooltip="false"
+        @change="handleSkewChange"
+      />
+      <div class="skew-buttons">
+        <el-button size="small" @click="adjustSkew(-0.1)">
+          <el-icon><Minus /></el-icon> 0.1°
+        </el-button>
+        <el-button size="small" @click="resetSkew">
+          重置
+        </el-button>
+        <el-button size="small" @click="adjustSkew(0.1)">
+          <el-icon><Plus /></el-icon> 0.1°
+        </el-button>
+      </div>
+    </div>
+    
+    <!-- 添加线条 -->
+    <div class="panel-title">添加线条</div>
+    <div class="button-group">
+      <el-button @click="showAddHorizontalDialog">
+        <el-icon><Minus /></el-icon>
+        添加横线
+      </el-button>
+      <el-button @click="showAddVerticalDialog">
+        <el-icon><More /></el-icon>
+        添加竖线
+      </el-button>
+    </div>
+    
+    <!-- 选中线条操作 -->
+    <template v-if="editorStore.selectedLine">
+      <div class="panel-title">选中线条</div>
+      <div class="selected-info">
+        <span class="line-label">
+          {{ editorStore.selectedLine.type === 'h' ? 'R' : 'C' }}{{ editorStore.selectedLine.index + 1 }}
+        </span>
+        <span class="line-value">
+          {{ getCurrentLineValue() }} px
+        </span>
+      </div>
+      
+      <div class="coordinate-input">
+        <el-input-number 
+          v-model="adjustValue"
+          :min="0"
+          :max="maxValue"
+          @change="handleAdjustChange"
+        />
+      </div>
+      
+      <div class="button-group">
+        <el-button type="danger" @click="handleDelete">
+          <el-icon><Delete /></el-icon>
+          删除 (Del)
+        </el-button>
+      </div>
+      
+      <div class="nudge-hint">
+        提示: 使用方向键微调 (Shift+方向键 微调10px)
+      </div>
+    </template>
+    
+    <!-- 添加横线对话框 -->
+    <el-dialog v-model="showHDialog" title="添加横线" width="300px">
+      <el-form>
+        <el-form-item label="Y 坐标 (px)">
+          <el-input-number 
+            v-model="newHLineValue" 
+            :min="0" 
+            :max="editorStore.imageSize.height"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showHDialog = false">取消</el-button>
+        <el-button type="primary" @click="addHorizontalLine">确定</el-button>
+      </template>
+    </el-dialog>
+    
+    <!-- 添加竖线对话框 -->
+    <el-dialog v-model="showVDialog" title="添加竖线" width="300px">
+      <el-form>
+        <el-form-item label="X 坐标 (px)">
+          <el-input-number 
+            v-model="newVLineValue" 
+            :min="0" 
+            :max="editorStore.imageSize.width"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showVDialog = false">取消</el-button>
+        <el-button type="primary" @click="addVerticalLine">确定</el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, watch } from 'vue'
+import { ElMessage } from 'element-plus'
+import { 
+  RefreshLeft, 
+  RefreshRight, 
+  Minus, 
+  More, 
+  Delete,
+  Plus
+} from '@element-plus/icons-vue'
+import { useEditorStore, useHistoryStore } from '@/stores'
+import { useKeyboardShortcuts } from '@/composables'
+
+const editorStore = useEditorStore()
+const historyStore = useHistoryStore()
+const { performUndo, performRedo } = useKeyboardShortcuts()
+
+// 添加线条对话框
+const showHDialog = ref(false)
+const showVDialog = ref(false)
+const newHLineValue = ref(0)
+const newVLineValue = ref(0)
+
+// 精确调整值
+const adjustValue = ref(0)
+
+// 倾斜角度
+const skewAngleValue = ref(0)
+
+// 监听 store 中的 skewAngle 变化
+watch(() => editorStore.skewAngle, (newVal) => {
+  skewAngleValue.value = newVal
+}, { immediate: true })
+
+// 最大值
+const maxValue = computed(() => {
+  if (!editorStore.selectedLine) return 0
+  return editorStore.selectedLine.type === 'h' 
+    ? editorStore.imageSize.height 
+    : editorStore.imageSize.width
+})
+
+// 监听选中变化,更新调整值
+watch(() => editorStore.selectedLine, (selected) => {
+  if (selected) {
+    adjustValue.value = getCurrentLineValue()
+  }
+}, { immediate: true })
+
+function getCurrentLineValue(): number {
+  const selected = editorStore.selectedLine
+  if (!selected) return 0
+  
+  if (selected.type === 'h') {
+    return editorStore.structure.horizontal_lines[selected.index] || 0
+  } else {
+    return editorStore.structure.vertical_lines[selected.index] || 0
+  }
+}
+
+function handleAdjustChange(value: number | undefined) {
+  if (value === undefined || !editorStore.selectedLine) return
+  
+  const { type, index } = editorStore.selectedLine
+  editorStore.updateLinePosition(type, index, value)
+}
+
+function handleUndo() {
+  performUndo()
+}
+
+function handleRedo() {
+  performRedo()
+}
+
+function handleDelete() {
+  editorStore.deleteSelectedLine()
+  ElMessage.success('已删除线条')
+}
+
+function showAddHorizontalDialog() {
+  // 默认在中间位置
+  newHLineValue.value = Math.round(editorStore.imageSize.height / 2)
+  showHDialog.value = true
+}
+
+function showAddVerticalDialog() {
+  newVLineValue.value = Math.round(editorStore.imageSize.width / 2)
+  showVDialog.value = true
+}
+
+function addHorizontalLine() {
+  editorStore.addLine('h', newHLineValue.value)
+  showHDialog.value = false
+  ElMessage.success(`已添加横线 Y=${newHLineValue.value}`)
+}
+
+function addVerticalLine() {
+  editorStore.addLine('v', newVLineValue.value)
+  showVDialog.value = false
+  ElMessage.success(`已添加竖线 X=${newVLineValue.value}`)
+}
+
+// 倾斜角度调整
+function handleSkewChange(value: number) {
+  editorStore.updateSkewAngle(value)
+}
+
+function adjustSkew(delta: number) {
+  const newAngle = Math.max(-5, Math.min(5, skewAngleValue.value + delta))
+  skewAngleValue.value = newAngle
+  editorStore.updateSkewAngle(newAngle)
+}
+
+function resetSkew() {
+  skewAngleValue.value = 0
+  editorStore.updateSkewAngle(0)
+}
+</script>
+
+<style scoped>
+.tool-panel {
+  padding: 16px;
+  border-bottom: 1px solid #e4e7ed;
+}
+
+.panel-title {
+  font-size: 14px;
+  font-weight: 600;
+  margin-bottom: 12px;
+  color: #303133;
+}
+
+.button-group {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 16px;
+  flex-wrap: wrap;
+}
+
+.selected-info {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  margin-bottom: 12px;
+  padding: 8px 12px;
+  background: #f5f7fa;
+  border-radius: 4px;
+}
+
+.line-label {
+  font-weight: 600;
+  color: #409eff;
+}
+
+.line-value {
+  color: #606266;
+}
+
+.coordinate-input {
+  margin-bottom: 12px;
+}
+
+.coordinate-input :deep(.el-input-number) {
+  width: 100%;
+}
+
+.nudge-hint {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 8px;
+}
+
+.skew-control {
+  margin-bottom: 16px;
+}
+
+.skew-label {
+  display: flex;
+  justify-content: space-between;
+  margin-bottom: 8px;
+  font-size: 13px;
+}
+
+.skew-value {
+  color: #409eff;
+  font-weight: 500;
+}
+
+.skew-buttons {
+  display: flex;
+  gap: 8px;
+  margin-top: 8px;
+}
+</style>

+ 328 - 0
table_line_generator/frontend/src/components/panels/template-panel/BatchProcessSection.vue

@@ -0,0 +1,328 @@
+<template>
+  <el-collapse-item 
+    v-if="templateStore.hasFilePairs && editorStore.hasData" 
+    title="批量处理" 
+    name="batch"
+  >
+    <div class="batch-processor">
+      <el-alert 
+        type="warning" 
+        :closable="false"
+        show-icon
+        style="margin-bottom: 12px"
+      >
+        <template #title>
+          使用选定模板的列结构应用到所有文件
+        </template>
+      </el-alert>
+      
+      <div class="batch-info">
+        <div class="info-item">
+          <label>选择模板:</label>
+          <el-select 
+            v-model="selectedTemplateName"
+            placeholder="请从已保存模板中选择"
+            style="flex: 1"
+            @change="onTemplateSelect"
+          >
+            <el-option 
+              v-for="tpl in templateList" 
+              :key="tpl.name" 
+              :label="`${tpl.name} (${tpl.stats?.col_count || 0} 列)`"
+              :value="tpl.name"
+            />
+          </el-select>
+        </div>
+        <div class="info-item">
+          <label>数据源:</label>
+          <span>{{ templateStore.activeDataSourceName || '手动配置' }}</span>
+        </div>
+        <div class="info-item">
+          <label>待处理文件:</label>
+          <span>{{ templateStore.filePairs.length }} 个</span>
+        </div>
+        <div class="info-item">
+          <label>模板列数:</label>
+          <span>{{ selectedTemplateColCount }} 列</span>
+        </div>
+      </div>
+      
+      <div class="batch-options">
+        <el-form label-width="100px" size="small">
+          <el-form-item label="自适应行">
+            <el-switch v-model="batchConfig.adjustRows" />
+            <span class="option-desc">每页根据实际内容调整行高度</span>
+          </el-form-item>
+          <el-form-item label="并行处理">
+            <el-switch v-model="batchConfig.parallel" />
+            <span class="option-desc">加速处理,推荐开启</span>
+          </el-form-item>
+          <el-form-item label="绘制表格线">
+            <el-switch v-model="batchConfig.drawLines" />
+            <span class="option-desc">保存可视化结果图片</span>
+          </el-form-item>
+        </el-form>
+      </div>
+      
+      <div class="batch-actions">
+        <el-button 
+          type="primary" 
+          size="default"
+          @click="handleStartBatchProcess"
+          :loading="isBatchProcessing"
+          :disabled="!canStartBatch || !selectedTemplateName"
+        >
+          <el-icon><VideoPlay /></el-icon>
+          开始批量处理
+        </el-button>
+        <el-button 
+          size="default"
+          @click="resetBatchState"
+          :disabled="isBatchProcessing"
+        >
+          重置
+        </el-button>
+      </div>
+      
+      <!-- 进度条 -->
+      <div v-if="batchProgress.total > 0" class="batch-progress">
+        <el-progress 
+          :percentage="batchProgressPercentage" 
+          :status="batchProgressStatus"
+        >
+          <span>{{ batchProgress.processed }} / {{ batchProgress.total }}</span>
+        </el-progress>
+        
+        <div class="progress-stats">
+          <el-tag type="success">成功: {{ batchProgress.success }}</el-tag>
+          <el-tag type="danger">失败: {{ batchProgress.failed }}</el-tag>
+        </div>
+      </div>
+      
+      <!-- 结果展示 -->
+      <div v-if="batchResults.length > 0" class="batch-results">
+        <div class="results-header">
+          <span>处理结果</span>
+          <el-button size="small" text @click="batchResults = []">清除</el-button>
+        </div>
+        <div class="results-list">
+          <div 
+            v-for="(result, index) in batchResults" 
+            :key="index"
+            class="result-item"
+            :class="{ success: result.success, failed: !result.success }"
+          >
+            <el-icon v-if="result.success"><CircleCheck /></el-icon>
+            <el-icon v-else><CircleClose /></el-icon>
+            <span class="filename">{{ result.filename }}</span>
+            <span v-if="result.success && result.rows" class="result-meta">
+              {{ result.rows }} 行 × {{ result.cols }} 列
+            </span>
+            <span v-if="!result.success && result.error" class="error-msg">
+              {{ result.error }}
+            </span>
+          </div>
+        </div>
+      </div>
+    </div>
+  </el-collapse-item>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted } from 'vue'
+import { VideoPlay, CircleCheck, CircleClose } from '@element-plus/icons-vue'
+import { ElMessage } from 'element-plus'
+import { useEditorStore, useTemplateStore } from '@/stores'
+import { useBatchProcess } from './composables'
+import { templateApi } from '@/api'
+
+const editorStore = useEditorStore()
+const templateStore = useTemplateStore()
+
+const selectedTemplateName = ref<string>('')
+const templateList = ref<any[]>([])
+const isLoadingTemplates = ref(false)
+
+const {
+  isBatchProcessing,
+  batchConfig,
+  batchProgress,
+  batchResults,
+  canStartBatch,
+  batchProgressPercentage,
+  batchProgressStatus,
+  startBatchProcess: originalStartBatchProcess,
+  resetBatchState
+} = useBatchProcess()
+
+const emit = defineEmits<{
+  batchStarted: []
+}>()
+
+const selectedTemplateColCount = computed(() => {
+  if (!selectedTemplateName.value) return 0
+  const tpl = templateList.value.find(t => t.name === selectedTemplateName.value)
+  return tpl?.stats?.col_count || 0
+})
+
+// 加载模板列表
+async function loadTemplates() {
+  isLoadingTemplates.value = true
+  try {
+    const templates = await templateApi.listTemplates()
+    templateList.value = templates || []
+    
+    if (templateList.value.length === 0) {
+      ElMessage.info('暂无已保存的模板,请先创建模板')
+    }
+  } catch (error: any) {
+    ElMessage.error(`加载模板列表失败: ${error.message}`)
+  } finally {
+    isLoadingTemplates.value = false
+  }
+}
+
+// 模板选择变更
+function onTemplateSelect(templateName: string) {
+  selectedTemplateName.value = templateName
+}
+
+// 处理开始批量处理
+async function handleStartBatchProcess() {
+  if (!selectedTemplateName.value) {
+    ElMessage.warning('请先选择模板')
+    return
+  }
+
+  await originalStartBatchProcess(selectedTemplateName.value)
+  emit('batchStarted')
+}
+
+// 组件挂载时加载模板
+onMounted(() => {
+  loadTemplates()
+})
+
+</script>
+
+<style scoped>
+.batch-processor {
+  .batch-info {
+    padding: 12px;
+    background: #f5f7fa;
+    border-radius: 4px;
+    margin-bottom: 12px;
+    
+    .info-item {
+      display: flex;
+      gap: 8px;
+      align-items: center;
+      margin-bottom: 8px;
+      font-size: 13px;
+      
+      &:first-child {
+        align-items: flex-start;
+      }
+      
+      &:last-child {
+        margin-bottom: 0;
+      }
+      
+      label {
+        color: #909399;
+        min-width: 90px;
+        flex-shrink: 0;
+      }
+      
+      span {
+        color: #303133;
+        font-weight: 500;
+      }
+      
+      :deep(.el-select) {
+        flex: 1;
+      }
+    }
+  }
+  
+  .batch-options {
+    margin-bottom: 12px;
+    
+    .option-desc {
+      font-size: 12px;
+      color: #909399;
+      margin-left: 8px;
+    }
+  }
+  
+  .batch-actions {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 16px;
+  }
+  
+  .batch-progress {
+    margin-bottom: 16px;
+    
+    .progress-stats {
+      display: flex;
+      gap: 8px;
+      margin-top: 8px;
+    }
+  }
+  
+  .batch-results {
+    .results-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 8px;
+      font-weight: 600;
+      color: #303133;
+    }
+    
+    .results-list {
+      max-height: 300px;
+      overflow-y: auto;
+      border: 1px solid #ebeef5;
+      border-radius: 4px;
+    }
+    
+    .result-item {
+      display: flex;
+      align-items: center;
+      gap: 8px;
+      padding: 8px 12px;
+      border-bottom: 1px solid #f5f7fa;
+      font-size: 12px;
+      
+      &:last-child {
+        border-bottom: none;
+      }
+      
+      &.success {
+        color: #67c23a;
+      }
+      
+      &.failed {
+        color: #f56c6c;
+      }
+      
+      .filename {
+        flex: 1;
+        color: #303133;
+      }
+      
+      .result-meta {
+        color: #909399;
+        font-size: 11px;
+      }
+      
+      .error-msg {
+        color: #f56c6c;
+        font-size: 11px;
+      }
+    }
+  }
+}
+</style>

+ 173 - 0
table_line_generator/frontend/src/components/panels/template-panel/DataSourceSection.vue

@@ -0,0 +1,173 @@
+<template>
+  <el-collapse-item title="预定义数据源" name="data-sources">
+    <div class="data-source-selector">
+      <el-alert 
+        type="info" 
+        :closable="false"
+        show-icon
+        style="margin-bottom: 12px"
+      >
+        <template #title>
+          选择预定义数据源可自动配置目录
+        </template>
+      </el-alert>
+      
+      <div class="data-source-actions">
+        <el-button 
+          type="primary" 
+          size="small" 
+          @click="loadDataSources"
+          :loading="isLoadingDataSources"
+        >
+          <el-icon><Refresh /></el-icon>
+          刷新数据源
+        </el-button>
+      </div>
+      
+      <div v-if="isLoadingDataSources" class="loading-state">
+        加载中...
+      </div>
+      
+      <el-empty 
+        v-else-if="!templateStore.hasDataSources" 
+        description="暂无数据源配置" 
+      />
+      
+      <div v-else class="data-source-list">
+        <div 
+          v-for="source in templateStore.dataSources" 
+          :key="source.filename"
+          class="data-source-item"
+          :class="{ active: templateStore.activeDataSourceFilename === source.filename }"
+          @click="handleSelectDataSource(source.filename)"
+        >
+          <div class="source-header">
+            <div class="source-name">{{ source.name }}</div>
+            <el-tag size="small" :type="source.json_format === 'mineru' ? 'success' : 'warning'">
+              {{ source.json_format }}
+            </el-tag>
+          </div>
+          <div class="source-description">{{ source.description }}</div>
+          <div class="source-paths">
+            <div class="path-item">
+              <el-icon><FolderOpened /></el-icon>
+              <span>{{ source.base_dir }}</span>
+            </div>
+          </div>
+          <div v-if="templateStore.activeDataSourceFilename === source.filename" class="source-config">
+            <el-descriptions :column="1" size="small" border>
+              <el-descriptions-item label="图片目录">
+                {{ templateStore.scanConfig.imageDir }}
+              </el-descriptions-item>
+              <el-descriptions-item label="JSON目录">
+                {{ templateStore.scanConfig.jsonDir }}
+              </el-descriptions-item>
+              <el-descriptions-item label="输出目录">
+                {{ templateStore.scanConfig.outputDir }}
+              </el-descriptions-item>
+            </el-descriptions>
+          </div>
+        </div>
+      </div>
+    </div>
+  </el-collapse-item>
+</template>
+
+<script setup lang="ts">
+import { Refresh, FolderOpened } from '@element-plus/icons-vue'
+import { useTemplateStore } from '@/stores'
+import { useDataSource } from './composables'
+
+const templateStore = useTemplateStore()
+
+const {
+  isLoadingDataSources,
+  loadDataSources,
+  selectDataSource
+} = useDataSource()
+
+const emit = defineEmits<{
+  dataSourceSelected: []
+}>()
+
+async function handleSelectDataSource(sourceName: string) {
+  await selectDataSource(sourceName)
+  emit('dataSourceSelected')
+}
+</script>
+
+<style scoped>
+.data-source-selector {
+  .data-source-actions {
+    margin-bottom: 12px;
+  }
+  
+  .loading-state {
+    text-align: center;
+    padding: 20px;
+    color: #909399;
+  }
+  
+  .data-source-list {
+    display: flex;
+    flex-direction: column;
+    gap: 8px;
+  }
+  
+  .data-source-item {
+    padding: 12px;
+    border: 1px solid #dcdfe6;
+    border-radius: 4px;
+    cursor: pointer;
+    transition: all 0.3s;
+    
+    &:hover {
+      border-color: #409eff;
+      background: #ecf5ff;
+    }
+    
+    &.active {
+      border-color: #409eff;
+      background: #ecf5ff;
+    }
+    
+    .source-header {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      margin-bottom: 8px;
+      
+      .source-name {
+        font-weight: 600;
+        color: #303133;
+      }
+    }
+    
+    .source-description {
+      font-size: 12px;
+      color: #606266;
+      margin-bottom: 8px;
+    }
+    
+    .source-paths {
+      .path-item {
+        display: flex;
+        align-items: center;
+        gap: 4px;
+        font-size: 11px;
+        color: #909399;
+        
+        i {
+          font-size: 12px;
+        }
+      }
+    }
+    
+    .source-config {
+      margin-top: 12px;
+      padding-top: 12px;
+      border-top: 1px dashed #dcdfe6;
+    }
+  }
+}
+</style>

+ 189 - 0
table_line_generator/frontend/src/components/panels/template-panel/DirectoryBrowserSection.vue

@@ -0,0 +1,189 @@
+<template>
+  <el-collapse-item title="目录浏览" name="directory">
+    <div class="directory-browser">
+      <div class="path-bar">
+        <el-button 
+          size="small" 
+          @click="goToParent" 
+          :disabled="!templateStore.parentPath || isBrowsing"
+          :loading="isBrowsing"
+        >
+          <el-icon><ArrowUp /></el-icon>
+        </el-button>
+        <el-input 
+          v-model.trim="pathInput" 
+          size="small" 
+          placeholder="输入路径或粘贴完整路径"
+          @keyup.enter.prevent="pathInput && browsePath(pathInput)"
+          @input="pathInput = (pathInput as string).trim()"
+          :disabled="isBrowsing"
+          clearable
+          autocomplete="off"
+        >
+          <template #append>
+            <el-button 
+              @click="pathInput && browsePath(pathInput)"
+              :loading="isBrowsing"
+              :disabled="isBrowsing || !pathInput"
+            >
+              <el-icon><FolderOpened /></el-icon>
+            </el-button>
+          </template>
+        </el-input>
+      </div>
+      
+      <div class="current-path">{{ templateStore.currentPath }}</div>
+      
+      <div class="directory-list">
+        <div v-if="isBrowsing" style="text-align: center; padding: 20px; color: #909399;">
+          加载中...
+        </div>
+        <div v-else-if="templateStore.directoryItems.length === 0" style="text-align: center; padding: 20px; color: #909399;">
+          目录为空
+        </div>
+        <div 
+          v-for="item in templateStore.directoryItems.filter(i => i.is_dir)"
+          v-else
+          :key="item.path"
+          class="directory-item"
+          @dblclick="browsePath(item.path)"
+        >
+          <el-icon><Folder /></el-icon>
+          <span>{{ item.name }}</span>
+        </div>
+      </div>
+      
+      <div class="directory-actions">
+        <el-button 
+          type="primary" 
+          size="small"
+          @click="setAsImageDir"
+          :disabled="!templateStore.currentPath"
+        >
+          设为图片目录
+        </el-button>
+        <el-button 
+          size="small"
+          @click="setAsJsonDir"
+          :disabled="!templateStore.currentPath"
+        >
+          设为JSON目录
+        </el-button>
+      </div>
+      
+      <div class="scan-config" v-if="templateStore.scanConfig.imageDir">
+        <div class="config-item">
+          <label>图片目录:</label>
+          <span>{{ templateStore.scanConfig.imageDir }}</span>
+        </div>
+        <div class="config-item" v-if="templateStore.scanConfig.jsonDir">
+          <label>JSON目录:</label>
+          <span>{{ templateStore.scanConfig.jsonDir }}</span>
+        </div>
+        <el-button 
+          type="success" 
+          size="small"
+          @click="handleScanFiles"
+        >
+          <el-icon><Search /></el-icon>
+          扫描文件
+        </el-button>
+      </div>
+    </div>
+  </el-collapse-item>
+</template>
+
+<script setup lang="ts">
+import { ArrowUp, Folder, FolderOpened, Search } from '@element-plus/icons-vue'
+import { useTemplateStore } from '@/stores'
+import { useDirectory } from './composables'
+
+const templateStore = useTemplateStore()
+
+const {
+  isBrowsing,
+  pathInput,
+  browsePath,
+  goToParent,
+  setAsImageDir,
+  setAsJsonDir,
+  scanFiles
+} = useDirectory()
+
+const emit = defineEmits<{
+  filesScanned: []
+}>()
+
+async function handleScanFiles() {
+  const result = await scanFiles()
+  if (result) {
+    emit('filesScanned')
+  }
+}
+</script>
+
+<style scoped>
+.directory-browser {
+  .path-bar {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 8px;
+  }
+  
+  .current-path {
+    font-size: 12px;
+    color: #909399;
+    margin-bottom: 8px;
+    word-break: break-all;
+  }
+  
+  .directory-list {
+    max-height: 200px;
+    overflow-y: auto;
+    border: 1px solid #ebeef5;
+    border-radius: 4px;
+    margin-bottom: 12px;
+  }
+  
+  .directory-item {
+    display: flex;
+    align-items: center;
+    gap: 8px;
+    padding: 8px 12px;
+    cursor: pointer;
+    
+    &:hover {
+      background: #f5f7fa;
+    }
+  }
+  
+  .directory-actions {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 12px;
+  }
+  
+  .scan-config {
+    padding: 12px;
+    background: #f5f7fa;
+    border-radius: 4px;
+    
+    .config-item {
+      display: flex;
+      gap: 8px;
+      margin-bottom: 8px;
+      font-size: 12px;
+      
+      label {
+        color: #909399;
+        min-width: 70px;
+      }
+      
+      span {
+        color: #303133;
+        word-break: break-all;
+      }
+    }
+  }
+}
+</style>

+ 96 - 0
table_line_generator/frontend/src/components/panels/template-panel/FileListSection.vue

@@ -0,0 +1,96 @@
+<template>
+  <el-collapse-item 
+    v-if="templateStore.hasFilePairs" 
+    title="文件列表" 
+    name="files"
+  >
+    <div class="file-stats">
+      <el-tag type="info">共 {{ templateStore.filePairs.length }} 个文件</el-tag>
+      <el-tag type="success">已标注 {{ templateStore.labeledCount }}</el-tag>
+      <el-tag type="warning">待处理 {{ templateStore.unlabeledCount }}</el-tag>
+    </div>
+    
+    <div class="file-navigator">
+      <el-button 
+        size="small" 
+        @click="templateStore.prevPair()" 
+        :disabled="templateStore.currentPairIndex === 0"
+      >
+        <el-icon><ArrowLeft /></el-icon>
+      </el-button>
+      <span>{{ templateStore.currentPairIndex + 1 }} / {{ templateStore.filePairs.length }}</span>
+      <el-button 
+        size="small" 
+        @click="templateStore.nextPair()" 
+        :disabled="templateStore.currentPairIndex >= templateStore.filePairs.length - 1"
+      >
+        <el-icon><ArrowRight /></el-icon>
+      </el-button>
+    </div>
+    
+    <div class="current-file" v-if="templateStore.currentFilePair">
+      <div class="file-name">{{ templateStore.currentFilePair.image_name }}</div>
+      <el-tag 
+        :type="templateStore.currentFilePair.has_structure ? 'success' : 'info'" 
+        size="small"
+      >
+        {{ templateStore.currentFilePair.has_structure ? '已标注' : '未标注' }}
+      </el-tag>
+      <el-button type="primary" size="small" @click="handleLoadFile">
+        加载
+      </el-button>
+    </div>
+  </el-collapse-item>
+</template>
+
+<script setup lang="ts">
+import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
+import { useTemplateStore } from '@/stores'
+
+const templateStore = useTemplateStore()
+
+const emit = defineEmits<{
+  loadFile: [payload: { imagePath: string; jsonPath: string }]
+}>()
+
+function handleLoadFile() {
+  const pair = templateStore.currentFilePair
+  if (pair) {
+    emit('loadFile', {
+      imagePath: pair.image_path,
+      jsonPath: pair.json_path
+    })
+  }
+}
+</script>
+
+<style scoped>
+.file-stats {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+
+.file-navigator {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 12px;
+  margin-bottom: 12px;
+}
+
+.current-file {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 12px;
+  background: #f5f7fa;
+  border-radius: 4px;
+  
+  .file-name {
+    flex: 1;
+    font-size: 13px;
+    word-break: break-all;
+  }
+}
+</style>

+ 231 - 0
table_line_generator/frontend/src/components/panels/template-panel/TemplateListSection.vue

@@ -0,0 +1,231 @@
+<template>
+  <div>
+    <!-- 模板列表 -->
+    <el-collapse-item title="模板列表" name="templates">
+      <div class="template-actions">
+        <el-button type="primary" size="small" @click="loadTemplates" :loading="isLoading">
+          <el-icon><Refresh /></el-icon>
+          刷新
+        </el-button>
+        <el-button 
+          type="success" 
+          size="small" 
+          @click="showCreateDialog = true"
+          :disabled="!editorStore.hasData"
+        >
+          <el-icon><Plus /></el-icon>
+          保存为模板
+        </el-button>
+      </div>
+      
+      <el-empty v-if="!templateStore.hasTemplates" description="暂无模板" />
+      
+      <div v-else class="template-list">
+        <div 
+          v-for="template in templateStore.templates" 
+          :key="template.name"
+          class="template-item"
+          :class="{ active: selectedTemplate === template.name }"
+          @click="selectTemplate(template.name)"
+        >
+          <div class="template-info">
+            <div class="template-name">{{ template.name }}</div>
+            <div class="template-meta">
+              {{ template.stats.row_count }} 行 × {{ template.stats.col_count }} 列
+            </div>
+            <div v-if="template.description" class="template-desc">
+              {{ template.description }}
+            </div>
+          </div>
+          <div class="template-actions-inline">
+            <el-button 
+              type="primary" 
+              size="small" 
+              text
+              @click.stop="handlePreviewTemplate(template.name)"
+              :disabled="!editorStore.hasData"
+            >
+              预览
+            </el-button>
+            <el-button 
+              type="danger" 
+              size="small" 
+              text
+              @click.stop="confirmDeleteTemplate(template.name)"
+            >
+              删除
+            </el-button>
+          </div>
+        </div>
+      </div>
+    </el-collapse-item>
+    
+    <!-- 预览区域 -->
+    <el-collapse-item v-if="templateStore.isPreviewMode" title="模板预览" name="preview">
+      <div class="preview-info">
+        <el-alert 
+          type="info" 
+          :closable="false"
+          show-icon
+        >
+          <template #title>
+            正在预览模板: <strong>{{ templateStore.previewTemplateName }}</strong>
+          </template>
+        </el-alert>
+        
+        <div class="preview-stats" v-if="templateStore.previewStructure">
+          <span>横线: {{ templateStore.previewStructure.horizontal_lines?.length || 0 }}</span>
+          <span>竖线: {{ templateStore.previewStructure.vertical_lines?.length || 0 }}</span>
+        </div>
+        
+        <div class="preview-actions">
+          <el-button type="success" @click="confirmApply">
+            <el-icon><Check /></el-icon>
+            确认应用
+          </el-button>
+          <el-button @click="cancelPreview">
+            <el-icon><Close /></el-icon>
+            取消
+          </el-button>
+        </div>
+      </div>
+    </el-collapse-item>
+    
+    <!-- 创建模板对话框 -->
+    <el-dialog 
+      v-model="showCreateDialog" 
+      title="保存为模板"
+      width="400px"
+    >
+      <el-form :model="createForm" label-width="80px">
+        <el-form-item label="模板名称" required>
+          <el-input v-model="createForm.name" placeholder="输入模板名称" />
+        </el-form-item>
+        <el-form-item label="描述">
+          <el-input 
+            v-model="createForm.description" 
+            type="textarea" 
+            :rows="3"
+            placeholder="可选的模板描述"
+          />
+        </el-form-item>
+      </el-form>
+      <template #footer>
+        <el-button @click="showCreateDialog = false">取消</el-button>
+        <el-button type="primary" @click="createTemplate" :loading="isCreating">
+          创建
+        </el-button>
+      </template>
+    </el-dialog>
+  </div>
+</template>
+
+<script setup lang="ts">
+import { Refresh, Plus, Check, Close } from '@element-plus/icons-vue'
+import { useEditorStore, useTemplateStore } from '@/stores'
+import { useTemplate } from './composables'
+
+const editorStore = useEditorStore()
+const templateStore = useTemplateStore()
+
+const {
+  isLoading,
+  isCreating,
+  selectedTemplate,
+  showCreateDialog,
+  createForm,
+  loadTemplates,
+  createTemplate,
+  selectTemplate,
+  previewTemplate,
+  confirmApply,
+  cancelPreview,
+  deleteTemplate: confirmDeleteTemplate
+} = useTemplate()
+
+const emit = defineEmits<{
+  templateApplied: []
+}>()
+
+async function handlePreviewTemplate(name: string) {
+  await previewTemplate(name)
+}
+</script>
+
+<style scoped>
+.template-actions {
+  display: flex;
+  gap: 8px;
+  margin-bottom: 12px;
+}
+
+.template-list {
+  max-height: 300px;
+  overflow-y: auto;
+}
+
+.template-item {
+  display: flex;
+  justify-content: space-between;
+  align-items: center;
+  padding: 10px;
+  border: 1px solid #ebeef5;
+  border-radius: 4px;
+  margin-bottom: 8px;
+  cursor: pointer;
+  transition: all 0.2s;
+  
+  &:hover {
+    border-color: #409eff;
+    background: #f5f7fa;
+  }
+  
+  &.active {
+    border-color: #409eff;
+    background: #ecf5ff;
+  }
+}
+
+.template-info {
+  flex: 1;
+}
+
+.template-name {
+  font-weight: 600;
+  color: #303133;
+}
+
+.template-meta {
+  font-size: 12px;
+  color: #909399;
+  margin-top: 4px;
+}
+
+.template-desc {
+  font-size: 12px;
+  color: #606266;
+  margin-top: 4px;
+}
+
+.template-actions-inline {
+  display: flex;
+  gap: 4px;
+}
+
+.preview-info {
+  padding: 12px;
+}
+
+.preview-stats {
+  display: flex;
+  gap: 16px;
+  margin: 12px 0;
+  color: #606266;
+}
+
+.preview-actions {
+  display: flex;
+  gap: 8px;
+  margin-top: 12px;
+}
+</style>

+ 456 - 0
table_line_generator/frontend/src/components/panels/template-panel/composables/README.md

@@ -0,0 +1,456 @@
+# TemplatePanel Composables
+
+## 概述
+
+这个目录包含了 TemplatePanel 的所有业务逻辑,采用 Vue 3 Composition API 的最佳实践,将视图层和逻辑层分离。
+
+## 架构设计
+
+```
+TemplatePanel (视图层)
+    ↓ 使用
+Composables (逻辑层)
+    ↓ 调用
+API & Store (数据层)
+```
+
+## Composables 列表
+
+### 1. useTemplate.ts
+**职责**: 模板的 CRUD 操作和状态管理
+
+**核心功能**:
+- 加载模板列表
+- 创建新模板
+- 预览模板
+- 应用模板
+- 删除模板
+
+**使用示例**:
+```typescript
+import { useTemplate } from './composables'
+
+const { 
+  templates, 
+  isLoading,
+  loadTemplates, 
+  createTemplate,
+  previewTemplate 
+} = useTemplate()
+
+// 加载模板
+await loadTemplates()
+
+// 创建模板
+showCreateDialog.value = true
+createForm.value.name = 'My Template'
+await createTemplate()
+
+// 预览模板
+await previewTemplate('My Template')
+```
+
+**导出内容**:
+| 名称 | 类型 | 说明 |
+|------|------|------|
+| `templates` | `Ref` | 模板列表 |
+| `isLoading` | `Ref<boolean>` | 加载状态 |
+| `selectedTemplate` | `Ref<string \| null>` | 当前选中的模板 |
+| `loadTemplates()` | `Function` | 加载模板列表 |
+| `createTemplate()` | `Function` | 创建新模板 |
+| `previewTemplate(name)` | `Function` | 预览模板 |
+| `confirmApply()` | `Function` | 确认应用模板 |
+| `deleteTemplate(name)` | `Function` | 删除模板 |
+
+---
+
+### 2. useDirectory.ts
+**职责**: 目录浏览、路径导航和文件扫描
+
+**核心功能**:
+- 浏览文件系统
+- 导航到父目录
+- 设置图片/JSON目录
+- 扫描文件对
+
+**使用示例**:
+```typescript
+import { useDirectory } from './composables'
+
+const { 
+  currentPath,
+  pathInput,
+  browsePath,
+  setAsImageDir,
+  scanFiles 
+} = useDirectory()
+
+// 浏览目录
+pathInput.value = '/Users/data'
+await browsePath(pathInput.value)
+
+// 设置目录
+setAsImageDir()
+setAsJsonDir()
+
+// 扫描文件
+const result = await scanFiles()
+```
+
+**导出内容**:
+| 名称 | 类型 | 说明 |
+|------|------|------|
+| `currentPath` | `ComputedRef<string>` | 当前路径 |
+| `isBrowsing` | `Ref<boolean>` | 浏览中状态 |
+| `pathInput` | `Ref<string>` | 路径输入框值 |
+| `browsePath(path)` | `Function` | 浏览指定路径 |
+| `goToParent()` | `Function` | 返回上级目录 |
+| `setAsImageDir()` | `Function` | 设为图片目录 |
+| `setAsJsonDir()` | `Function` | 设为JSON目录 |
+| `scanFiles()` | `Function` | 扫描文件对 |
+
+---
+
+### 3. useDataSource.ts
+**职责**: 预定义数据源的加载和选择
+
+**核心功能**:
+- 加载数据源配置
+- 选择数据源
+- 自动导航到数据源目录
+
+**使用示例**:
+```typescript
+import { useDataSource } from './composables'
+
+const { 
+  dataSources,
+  activeDataSourceName,
+  loadDataSources,
+  selectDataSource 
+} = useDataSource()
+
+// 加载数据源
+await loadDataSources()
+
+// 选择数据源
+await selectDataSource('流水分析数据源')
+```
+
+**导出内容**:
+| 名称 | 类型 | 说明 |
+|------|------|------|
+| `dataSources` | `ComputedRef` | 数据源列表 |
+| `hasDataSources` | `ComputedRef<boolean>` | 是否有数据源 |
+| `activeDataSource` | `ComputedRef` | 当前激活的数据源 |
+| `loadDataSources()` | `Function` | 加载数据源列表 |
+| `selectDataSource(name)` | `Function` | 选择数据源 |
+
+---
+
+### 4. useBatchProcess.ts
+**职责**: 批量处理的配置、执行和进度跟踪
+
+**核心功能**:
+- 配置批量处理选项
+- 执行批量处理
+- 跟踪处理进度
+- 展示处理结果
+
+**使用示例**:
+```typescript
+import { useBatchProcess } from './composables'
+
+const { 
+  batchConfig,
+  batchProgress,
+  batchProgressPercentage,
+  canStartBatch,
+  startBatchProcess 
+} = useBatchProcess()
+
+// 配置选项
+batchConfig.value.parallel = true
+batchConfig.value.adjustRows = true
+
+// 开始处理
+if (canStartBatch.value) {
+  await startBatchProcess()
+}
+
+// 监控进度
+console.log(`进度: ${batchProgressPercentage.value}%`)
+```
+
+**导出内容**:
+| 名称 | 类型 | 说明 |
+|------|------|------|
+| `batchConfig` | `Ref<BatchConfig>` | 批量配置 |
+| `batchProgress` | `Ref<BatchProgress>` | 处理进度 |
+| `batchResults` | `Ref<BatchResult[]>` | 处理结果 |
+| `canStartBatch` | `ComputedRef<boolean>` | 是否可以开始 |
+| `batchProgressPercentage` | `ComputedRef<number>` | 进度百分比 |
+| `startBatchProcess()` | `Function` | 开始批量处理 |
+| `resetBatchState()` | `Function` | 重置状态 |
+
+---
+
+### 5. useFileList.ts
+**职责**: 文件对的导航和加载
+
+**核心功能**:
+- 文件列表导航
+- 跳转到指定文件
+- 更新标注状态
+- 统计信息
+
+**使用示例**:
+```typescript
+import { useFileList } from './composables'
+
+const { 
+  filePairs,
+  currentFilePair,
+  currentPairIndex,
+  nextPair,
+  prevPair,
+  getFileStats 
+} = useFileList()
+
+// 导航
+nextPair()
+prevPair()
+
+// 跳转
+goToFirstUnlabeled()
+
+// 统计
+const stats = getFileStats()
+console.log(`完成度: ${stats.percentage}%`)
+```
+
+**导出内容**:
+| 名称 | 类型 | 说明 |
+|------|------|------|
+| `filePairs` | `ComputedRef` | 文件对列表 |
+| `currentFilePair` | `ComputedRef` | 当前文件对 |
+| `currentPairIndex` | `ComputedRef<number>` | 当前索引 |
+| `labeledCount` | `ComputedRef<number>` | 已标注数量 |
+| `unlabeledCount` | `ComputedRef<number>` | 未标注数量 |
+| `nextPair()` | `Function` | 下一个文件 |
+| `prevPair()` | `Function` | 上一个文件 |
+| `goToPair(index)` | `Function` | 跳转到指定文件 |
+| `getFileStats()` | `Function` | 获取统计信息 |
+
+---
+
+## 在 TemplatePanel 中使用
+
+### 完整示例
+
+```vue
+<script setup lang="ts">
+import { ref, onMounted } from 'vue'
+import { 
+  useTemplate, 
+  useDirectory, 
+  useDataSource,
+  useBatchProcess,
+  useFileList 
+} from './template-panel/composables'
+
+// 使用所有 composables
+const { 
+  templates, 
+  loadTemplates, 
+  previewTemplate 
+} = useTemplate()
+
+const { 
+  browsePath, 
+  scanFiles 
+} = useDirectory()
+
+const { 
+  loadDataSources, 
+  selectDataSource 
+} = useDataSource()
+
+const { 
+  startBatchProcess,
+  batchProgressPercentage 
+} = useBatchProcess()
+
+const { 
+  nextPair,
+  getCurrentFileParams 
+} = useFileList()
+
+// 初始化
+onMounted(async () => {
+  await loadTemplates()
+  await loadDataSources()
+  await browsePath('~')
+})
+
+// 事件处理
+async function handlePreview(name: string) {
+  await previewTemplate(name)
+}
+
+async function handleScan() {
+  await scanFiles()
+}
+
+async function handleBatch() {
+  await startBatchProcess()
+}
+</script>
+```
+
+## 优势
+
+### ✅ 代码组织
+- **关注点分离**: 视图和逻辑完全分离
+- **单一职责**: 每个 composable 只负责一个功能域
+- **易于维护**: 小文件,清晰的职责边界
+
+### ✅ 可测试性
+```typescript
+// 单元测试示例
+import { useTemplate } from './useTemplate'
+
+describe('useTemplate', () => {
+  it('should load templates', async () => {
+    const { loadTemplates, templates } = useTemplate()
+    await loadTemplates()
+    expect(templates.value.length).toBeGreaterThan(0)
+  })
+})
+```
+
+### ✅ 可复用性
+```typescript
+// 可以在其他组件中复用
+import { useDirectory } from '@/components/panels/template-panel/composables'
+
+export default {
+  setup() {
+    const { browsePath } = useDirectory()
+    // 复用目录浏览逻辑
+  }
+}
+```
+
+### ✅ 类型安全
+- 完整的 TypeScript 支持
+- 自动类型推导
+- IDE 智能提示
+
+## 迁移指南
+
+### 从原 TemplatePanel.vue 迁移
+
+**步骤1**: 导入 composables
+```typescript
+import { useTemplate, useDirectory } from './composables'
+```
+
+**步骤2**: 替换原有的 ref/computed/function
+```typescript
+// 原来
+const isLoading = ref(false)
+async function loadTemplates() { ... }
+
+// 现在
+const { isLoading, loadTemplates } = useTemplate()
+```
+
+**步骤3**: 删除原有的业务逻辑
+```vue
+<script setup lang="ts">
+// 删除所有业务逻辑代码
+// 只保留 composables 调用和事件处理
+</script>
+```
+
+**步骤4**: 测试验证
+- 确保所有功能正常
+- 检查类型提示
+- 验证性能
+
+## 最佳实践
+
+### 1. 按需导入
+```typescript
+// ✅ 好 - 按需导入
+import { useTemplate } from './composables'
+
+// ❌ 不好 - 导入全部
+import * as composables from './composables'
+```
+
+### 2. 解构使用
+```typescript
+// ✅ 好 - 清晰的依赖
+const { loadTemplates, templates } = useTemplate()
+
+// ❌ 不好 - 不透明的依赖
+const template = useTemplate()
+```
+
+### 3. 命名规范
+```typescript
+// ✅ 好 - 语义化命名
+const { isLoading: isLoadingTemplates } = useTemplate()
+
+// ❌ 不好 - 命名冲突
+const { isLoading } = useTemplate()
+const { isLoading } = useDirectory() // 冲突!
+```
+
+### 4. 错误处理
+```typescript
+// ✅ 好 - 统一错误处理
+const { loadTemplates } = useTemplate()
+try {
+  await loadTemplates()
+} catch (error) {
+  // composable 内部已处理,这里可选
+}
+```
+
+## 性能考虑
+
+- **响应式优化**: 使用 `computed` 而非 `watch`
+- **避免过度计算**: 复杂计算使用 `computed` 缓存
+- **按需加载**: 只在需要时调用 API
+- **批量更新**: 使用 `nextTick` 或批量操作
+
+## 后续计划
+
+### Phase 2: 拆分子组件
+- [ ] TemplateList.vue
+- [ ] BatchProcessor.vue
+- [ ] FileList.vue
+- [ ] DirectoryBrowser.vue
+
+### Phase 3: 完全模块化
+- [ ] 独立的子组件树
+- [ ] 组件间通信优化
+- [ ] 性能优化
+
+## 贡献指南
+
+添加新 composable 时:
+1. 创建新文件 `useXxx.ts`
+2. 遵循现有命名规范
+3. 添加完整的 JSDoc 注释
+4. 导出到 `index.ts`
+5. 更新此 README
+
+---
+
+**最后更新**: 2025-12-08
+**维护者**: GitHub Copilot
+**版本**: 1.0.0

+ 15 - 0
table_line_generator/frontend/src/components/panels/template-panel/composables/index.ts

@@ -0,0 +1,15 @@
+/**
+ * Composables 统一导出
+ * 提供所有 TemplatePanel 相关的业务逻辑钩子
+ */
+
+export { useTemplate } from './useTemplate'
+export { useDirectory } from './useDirectory'
+export { useDataSource } from './useDataSource'
+export { useBatchProcess } from './useBatchProcess'
+export { useFileList } from './useFileList'
+
+// 类型导出
+export type { 
+  // 可以在这里导出各个 composable 的类型定义
+}

+ 227 - 0
table_line_generator/frontend/src/components/panels/template-panel/composables/useBatchProcess.ts

@@ -0,0 +1,227 @@
+/**
+ * 批量处理 Composable
+ * 负责批量处理的配置、执行和进度跟踪
+ */
+
+import { ref, computed } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useTemplateStore, useEditorStore } from '@/stores'
+import { batchApi } from '@/api'
+
+interface BatchConfig {
+  adjustRows: boolean
+  parallel: boolean
+  drawLines: boolean
+}
+
+interface BatchProgress {
+  total: number
+  processed: number
+  success: number
+  failed: number
+}
+
+interface BatchResult {
+  success: boolean
+  filename: string
+  rows?: number
+  cols?: number
+  error?: string
+}
+
+export function useBatchProcess() {
+  const templateStore = useTemplateStore()
+  const editorStore = useEditorStore()
+  
+  // 状态
+  const isBatchProcessing = ref(false)
+  const batchConfig = ref<BatchConfig>({
+    adjustRows: true,
+    parallel: true,
+    drawLines: false
+  })
+  const batchProgress = ref<BatchProgress>({
+    total: 0,
+    processed: 0,
+    success: 0,
+    failed: 0
+  })
+  const batchResults = ref<BatchResult[]>([])
+  
+  // 计算属性
+  const canStartBatch = computed(() => {
+    return templateStore.hasFilePairs && 
+           editorStore.hasData && 
+           !isBatchProcessing.value &&
+           templateStore.scanConfig.outputDir
+  })
+  
+  const batchProgressPercentage = computed(() => {
+    if (batchProgress.value.total === 0) return 0
+    return Math.round((batchProgress.value.processed / batchProgress.value.total) * 100)
+  })
+  
+  const batchProgressStatus = computed(() => {
+    if (batchProgress.value.failed > 0) return 'exception'
+    if (batchProgress.value.processed === batchProgress.value.total && 
+        batchProgress.value.total > 0) {
+      return 'success'
+    }
+    return undefined
+  })
+  
+  const hasResults = computed(() => batchResults.value.length > 0)
+  
+  const successCount = computed(() => batchProgress.value.success)
+  const failedCount = computed(() => batchProgress.value.failed)
+  
+  /**
+   * 开始批量处理(接收模板名称参数)
+   */
+  async function startBatchProcess(templateName: string) {
+    if (!canStartBatch.value) {
+      ElMessage.warning('请先加载数据并扫描文件')
+      return false
+    }
+
+    if (!templateName || templateName.trim() === '') {
+      ElMessage.warning('请先选择模板')
+      return false
+    }
+
+    try {
+      await ElMessageBox.confirm(
+        `确定要批量处理 ${templateStore.filePairs.length} 个文件吗?将使用模板"${templateName}"应用列结构。`,
+        '确认批量处理',
+        { type: 'warning' }
+      )
+    } catch {
+      return false
+    }
+
+    isBatchProcessing.value = true
+    batchProgress.value = {
+      total: templateStore.filePairs.length,
+      processed: 0,
+      success: 0,
+      failed: 0
+    }
+    batchResults.value = []
+
+    try {
+      const response = await batchApi.batchProcess({
+        template_name: templateName,
+        file_pairs: templateStore.filePairs.map(pair => ({
+          json_path: pair.json_path,
+          image_path: pair.image_path
+        })),
+        output_dir: templateStore.scanConfig.outputDir,
+        parallel: batchConfig.value.parallel,
+        adjust_rows: batchConfig.value.adjustRows
+      })
+
+      // 更新进度
+      batchProgress.value.processed = response.processed
+      batchProgress.value.success = response.results.filter(r => r.success).length
+      batchProgress.value.failed = response.results.filter(r => !r.success).length
+
+      // 保存结果
+      batchResults.value = response.results.map(r => ({
+        success: r.success,
+        filename: r.filename || '未知',
+        rows: r.rows,
+        cols: r.cols,
+        error: r.error
+      }))
+
+      ElMessage.success(
+        `批量处理完成: 成功 ${batchProgress.value.success}/${batchProgress.value.total}`
+      )
+
+      // 如果需要绘制表格线
+      if (batchConfig.value.drawLines && batchProgress.value.success > 0) {
+        await drawBatchLines(response.results.filter(r => r.success))
+      }
+
+      return {
+        success: true,
+        results: batchResults.value
+      }
+    } catch (error: any) {
+      ElMessage.error(`批量处理失败: ${error.message}`)
+      return {
+        success: false,
+        error: error.message
+      }
+    } finally {
+      isBatchProcessing.value = false
+    }
+  }
+  
+  /**
+   * 绘制批量处理的表格线
+   */
+  async function drawBatchLines(successResults: any[]) {
+    try {
+      await batchApi.batchDraw({
+        results: successResults,
+        line_width: 2,
+        line_color: [0, 0, 0]
+      })
+      ElMessage.success('表格线绘制完成')
+      return true
+    } catch (error: any) {
+      ElMessage.error(`绘制表格线失败: ${error.message}`)
+      return false
+    }
+  }
+  
+  /**
+   * 重置批量处理状态
+   */
+  function resetBatchState() {
+    batchProgress.value = {
+      total: 0,
+      processed: 0,
+      success: 0,
+      failed: 0
+    }
+    batchResults.value = []
+  }
+  
+  /**
+   * 清除批量结果
+   */
+  function clearResults() {
+    batchResults.value = []
+  }
+  
+  /**
+   * 更新批量配置
+   */
+  function updateConfig(config: Partial<BatchConfig>) {
+    batchConfig.value = { ...batchConfig.value, ...config }
+  }
+  
+  return {
+    // 状态
+    isBatchProcessing,
+    batchConfig,
+    batchProgress,
+    batchResults,
+    
+    // 计算属性
+    canStartBatch,
+    batchProgressPercentage,
+    batchProgressStatus,
+    hasResults,
+    successCount,
+    failedCount,
+    
+    // 方法
+    startBatchProcess,
+    resetBatchState,
+    clearResults,
+    updateConfig
+  }
+}

+ 139 - 0
table_line_generator/frontend/src/components/panels/template-panel/composables/useDataSource.ts

@@ -0,0 +1,139 @@
+/**
+ * 数据源管理 Composable
+ * 负责预定义数据源的加载和选择
+ */
+
+import { ref, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useTemplateStore } from '@/stores'
+import { configApi } from '@/api'
+import * as directoryApi from '@/api/directory'
+
+export function useDataSource() {
+  const templateStore = useTemplateStore()
+  
+  // 状态
+  const isLoadingDataSources = ref(false)
+  
+  // 计算属性
+  const dataSources = computed(() => templateStore.dataSources)
+  const hasDataSources = computed(() => templateStore.hasDataSources)
+  const activeDataSource = computed(() => templateStore.activeDataSource)
+  const activeDataSourceName = computed(() => templateStore.activeDataSourceName)
+  
+  /**
+   * 加载数据源列表
+   */
+  async function loadDataSources() {
+    isLoadingDataSources.value = true
+    try {
+      const response = await configApi.listDataSources()
+      templateStore.setDataSources(response.sources)
+      ElMessage.success(`加载了 ${response.sources.length} 个数据源`)
+      return response.sources
+    } catch (error: any) {
+      ElMessage.error(`加载数据源失败: ${error.message}`)
+      throw error
+    } finally {
+      isLoadingDataSources.value = false
+    }
+  }
+  
+  /**
+   * 选择数据源
+   */
+  async function selectDataSource(sourceFilename: string) {
+    // 如果数据源列表为空,先加载
+    if (templateStore.dataSources.length === 0) {
+      try {
+        await loadDataSources()
+      } catch (error: any) {
+        ElMessage.error(`加载数据源失败: ${error.message}`)
+        return {
+          success: false,
+          error: `加载数据源失败: ${error.message}`
+        }
+      }
+    }
+    
+    // 先检查数据源是否存在
+    const source = templateStore.dataSources.find(ds => ds.filename === sourceFilename)
+    
+    if (!source) {
+      ElMessage.error(`数据源不存在: ${sourceFilename}`)
+      console.error('数据源查找失败:', {
+        sourceFilename,
+        availableFilenames: templateStore.dataSources.map(ds => ds.filename),
+        dataSourcesCount: templateStore.dataSources.length,
+        allDataSources: templateStore.dataSources.map(ds => ({
+          filename: ds.filename,
+          name: ds.name,
+          hasFilename: 'filename' in ds
+        }))
+      })
+      return {
+        success: false,
+        error: `数据源不存在: ${sourceFilename}`
+      }
+    }
+    
+    // 设置选中的数据源
+    templateStore.selectDataSource(sourceFilename)
+    
+    // 再次获取(应该能找到了)
+    const activeSource = templateStore.activeDataSource
+    const displayName = activeSource?.name || source.name || sourceFilename
+    ElMessage.success(`已选择数据源: ${displayName}`)
+    
+    // 自动导航到数据源的 base_dir
+    if (activeSource) {
+      try {
+        const result = await directoryApi.browseDirectory(activeSource.base_dir)
+        templateStore.setDirectory(result.current_path, result.items, result.parent_path)
+        return {
+          success: true,
+          path: result.current_path
+        }
+      } catch (error: any) {
+        ElMessage.warning(`无法浏览数据源目录: ${error.message}`)
+        return {
+          success: false,
+          error: error.message
+        }
+      }
+    }
+    
+    return { success: true }
+  }
+  
+  /**
+   * 清除数据源选择
+   */
+  function clearDataSource() {
+    templateStore.selectDataSource(null)
+  }
+  
+  /**
+   * 获取数据源配置
+   */
+  function getDataSourceConfig(sourceFilename: string) {
+    return templateStore.dataSources.find(s => s.filename === sourceFilename)
+  }
+  
+  return {
+    // 状态
+    isLoadingDataSources,
+    
+    // 计算属性
+    dataSources,
+    hasDataSources,
+    activeDataSource,
+    activeDataSourceName,
+    
+    // 方法
+    loadDataSources,
+    selectDataSource,
+    clearDataSource,
+    getDataSourceConfig
+  }
+}

+ 152 - 0
table_line_generator/frontend/src/components/panels/template-panel/composables/useDirectory.ts

@@ -0,0 +1,152 @@
+/**
+ * 目录浏览 Composable
+ * 负责目录浏览、路径导航和文件扫描
+ */
+
+import { ref, computed } from 'vue'
+import { ElMessage } from 'element-plus'
+import { useTemplateStore } from '@/stores'
+import * as directoryApi from '@/api/directory'
+
+export function useDirectory() {
+  const templateStore = useTemplateStore()
+  
+  // 状态
+  const isBrowsing = ref(false)
+  const pathInput = ref('')
+  
+  // 计算属性
+  const currentPath = computed(() => templateStore.currentPath)
+  const parentPath = computed(() => templateStore.parentPath)
+  const directoryItems = computed(() => templateStore.directoryItems)
+  const scanConfig = computed(() => templateStore.scanConfig)
+  
+  /**
+   * 浏览指定路径
+   */
+  async function browsePath(path: string) {
+    const trimmedPath = path.trim()
+    if (!trimmedPath) {
+      ElMessage.warning('请输入路径')
+      return false
+    }
+    
+    isBrowsing.value = true
+    try {
+      const result = await directoryApi.browseDirectory(trimmedPath)
+      templateStore.setDirectory(result.current_path, result.items, result.parent_path)
+      pathInput.value = result.current_path
+      return true
+    } catch (error: any) {
+      ElMessage.error(`浏览目录失败: ${error.message}`)
+      return false
+    } finally {
+      isBrowsing.value = false
+    }
+  }
+  
+  /**
+   * 返回上级目录
+   */
+  async function goToParent() {
+    if (templateStore.parentPath) {
+      return await browsePath(templateStore.parentPath)
+    }
+    return false
+  }
+  
+  /**
+   * 设置为图片目录
+   */
+  function setAsImageDir() {
+    if (!templateStore.currentPath) {
+      ElMessage.warning('请先浏览到目标目录')
+      return false
+    }
+    
+    templateStore.setScanConfig({
+      ...templateStore.scanConfig,
+      imageDir: templateStore.currentPath
+    })
+    ElMessage.success('已设置图片目录')
+    return true
+  }
+  
+  /**
+   * 设置为 JSON 目录
+   */
+  function setAsJsonDir() {
+    if (!templateStore.currentPath) {
+      ElMessage.warning('请先浏览到目标目录')
+      return false
+    }
+    
+    templateStore.setScanConfig({
+      ...templateStore.scanConfig,
+      jsonDir: templateStore.currentPath
+    })
+    ElMessage.success('已设置JSON目录')
+    return true
+  }
+  
+  /**
+   * 扫描文件对
+   */
+  async function scanFiles() {
+    if (!templateStore.scanConfig.imageDir || !templateStore.scanConfig.jsonDir) {
+      ElMessage.warning('请先配置图片目录和JSON目录')
+      return null
+    }
+    
+    try {
+      const result = await directoryApi.scanDirectory(
+        templateStore.scanConfig.imageDir,
+        templateStore.scanConfig.jsonDir,
+        undefined,
+        undefined,
+        templateStore.scanConfig.outputDir
+      )
+      templateStore.setFilePairs(result.pairs)
+      ElMessage.success(`扫描完成,找到 ${result.total} 个文件对`)
+      return result
+    } catch (error: any) {
+      ElMessage.error(`扫描失败: ${error.message}`)
+      return null
+    }
+  }
+  
+  /**
+   * 浏览到主目录
+   */
+  async function browseToHome() {
+    return await browsePath('~')
+  }
+  
+  /**
+   * 清除路径输入
+   */
+  function clearPathInput() {
+    pathInput.value = ''
+  }
+  
+  return {
+    // 状态
+    isBrowsing,
+    pathInput,
+    
+    // 计算属性
+    currentPath,
+    parentPath,
+    directoryItems,
+    scanConfig,
+    
+    // 方法
+    browsePath,
+    goToParent,
+    setAsImageDir,
+    setAsJsonDir,
+    scanFiles,
+    browseToHome,
+    clearPathInput
+  }
+}

+ 153 - 0
table_line_generator/frontend/src/components/panels/template-panel/composables/useFileList.ts

@@ -0,0 +1,153 @@
+/**
+ * 文件列表 Composable
+ * 负责文件对的导航和加载
+ */
+
+import { computed } from 'vue'
+import { useTemplateStore } from '@/stores'
+
+export function useFileList() {
+  const templateStore = useTemplateStore()
+  
+  // 计算属性
+  const filePairs = computed(() => templateStore.filePairs)
+  const hasFilePairs = computed(() => templateStore.hasFilePairs)
+  const currentFilePair = computed(() => templateStore.currentFilePair)
+  const currentPairIndex = computed(() => templateStore.currentPairIndex)
+  const totalPairs = computed(() => templateStore.filePairs.length)
+  
+  const labeledCount = computed(() => templateStore.labeledCount)
+  const unlabeledCount = computed(() => templateStore.unlabeledCount)
+  
+  const hasNextPair = computed(() => templateStore.hasNextPair)
+  const hasPrevPair = computed(() => currentPairIndex.value > 0)
+  
+  const isFirstPair = computed(() => currentPairIndex.value === 0)
+  const isLastPair = computed(() => currentPairIndex.value >= totalPairs.value - 1)
+  
+  /**
+   * 跳转到下一个文件对
+   */
+  function nextPair() {
+    if (hasNextPair.value) {
+      templateStore.nextPair()
+      return true
+    }
+    return false
+  }
+  
+  /**
+   * 跳转到上一个文件对
+   */
+  function prevPair() {
+    if (hasPrevPair.value) {
+      templateStore.prevPair()
+      return true
+    }
+    return false
+  }
+  
+  /**
+   * 跳转到指定索引的文件对
+   */
+  function goToPair(index: number) {
+    if (index >= 0 && index < totalPairs.value) {
+      while (currentPairIndex.value < index) {
+        templateStore.nextPair()
+      }
+      while (currentPairIndex.value > index) {
+        templateStore.prevPair()
+      }
+      return true
+    }
+    return false
+  }
+  
+  /**
+   * 跳转到第一个未标注的文件
+   */
+  function goToFirstUnlabeled() {
+    const index = filePairs.value.findIndex(pair => !pair.has_structure)
+    if (index !== -1) {
+      return goToPair(index)
+    }
+    return false
+  }
+  
+  /**
+   * 跳转到最后一个文件
+   */
+  function goToLastPair() {
+    return goToPair(totalPairs.value - 1)
+  }
+  
+  /**
+   * 跳转到第一个文件
+   */
+  function goToFirstPair() {
+    return goToPair(0)
+  }
+  
+  /**
+   * 获取当前文件的加载参数
+   */
+  function getCurrentFileParams() {
+    const pair = currentFilePair.value
+    if (pair) {
+      return {
+        imagePath: pair.image_path,
+        jsonPath: pair.json_path,
+        outputDir: templateStore.scanConfig.outputDir
+      }
+    }
+    return null
+  }
+  
+  /**
+   * 更新当前文件的标注状态
+   */
+  function updateCurrentPairStatus(hasStructure: boolean, structurePath: string | null) {
+    templateStore.updatePairStatus(currentPairIndex.value, hasStructure, structurePath)
+  }
+  
+  /**
+   * 获取文件统计信息
+   */
+  function getFileStats() {
+    return {
+      total: totalPairs.value,
+      labeled: labeledCount.value,
+      unlabeled: unlabeledCount.value,
+      current: currentPairIndex.value + 1,
+      percentage: totalPairs.value > 0 
+        ? Math.round((labeledCount.value / totalPairs.value) * 100) 
+        : 0
+    }
+  }
+  
+  return {
+    // 计算属性
+    filePairs,
+    hasFilePairs,
+    currentFilePair,
+    currentPairIndex,
+    totalPairs,
+    labeledCount,
+    unlabeledCount,
+    hasNextPair,
+    hasPrevPair,
+    isFirstPair,
+    isLastPair,
+    
+    // 方法
+    nextPair,
+    prevPair,
+    goToPair,
+    goToFirstUnlabeled,
+    goToLastPair,
+    goToFirstPair,
+    getCurrentFileParams,
+    updateCurrentPairStatus,
+    getFileStats
+  }
+}

+ 183 - 0
table_line_generator/frontend/src/components/panels/template-panel/composables/useTemplate.ts

@@ -0,0 +1,183 @@
+/**
+ * 模板管理 Composable
+ * 负责模板的 CRUD 操作和状态管理
+ */
+
+import { ref, computed } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { useTemplateStore, useEditorStore } from '@/stores'
+import * as templateApi from '@/api/template'
+
+export function useTemplate() {
+  const templateStore = useTemplateStore()
+  const editorStore = useEditorStore()
+  
+  // 状态
+  const isLoading = ref(false)
+  const isCreating = ref(false)
+  const selectedTemplate = ref<string | null>(null)
+  const showCreateDialog = ref(false)
+  const createForm = ref({
+    name: '',
+    description: ''
+  })
+  
+  // 计算属性
+  const templates = computed(() => templateStore.templates)
+  const hasTemplates = computed(() => templateStore.hasTemplates)
+  const isPreviewMode = computed(() => templateStore.isPreviewMode)
+  const previewStructure = computed(() => templateStore.previewStructure)
+  const previewTemplateName = computed(() => templateStore.previewTemplateName)
+  
+  /**
+   * 加载模板列表
+   */
+  async function loadTemplates() {
+    isLoading.value = true
+    try {
+      const templates = await templateApi.listTemplates()
+      templateStore.setTemplates(templates)
+      return templates
+    } catch (error: any) {
+      ElMessage.error(`加载模板失败: ${error.message}`)
+      throw error
+    } finally {
+      isLoading.value = false
+    }
+  }
+  
+  /**
+   * 创建新模板
+   */
+  async function createTemplate() {
+    if (!createForm.value.name.trim()) {
+      ElMessage.warning('请输入模板名称')
+      return false
+    }
+    
+    isCreating.value = true
+    try {
+      await templateApi.createTemplate(
+        createForm.value.name.trim(),
+        editorStore.structure,
+        editorStore.imageSize,
+        createForm.value.description
+      )
+      ElMessage.success('模板创建成功')
+      showCreateDialog.value = false
+      createForm.value = { name: '', description: '' }
+      await loadTemplates()
+      return true
+    } catch (error: any) {
+      ElMessage.error(`创建模板失败: ${error.message}`)
+      return false
+    } finally {
+      isCreating.value = false
+    }
+  }
+  
+  /**
+   * 选择模板(用于批量操作)
+   */
+  function selectTemplate(name: string) {
+    const newValue = selectedTemplate.value === name ? null : name
+    selectedTemplate.value = newValue
+    templateStore.selectTemplate(newValue)
+  }
+  
+  /**
+   * 预览模板
+   */
+  async function previewTemplate(name: string) {
+    try {
+      const structure = await templateApi.previewApplyTemplate(
+        name,
+        editorStore.imageSize,
+        editorStore.tableBbox,
+        'relative'
+      )
+      templateStore.setPreview(structure, name)
+      editorStore.updateStructure(structure)
+      return structure
+    } catch (error: any) {
+      ElMessage.error(`预览失败: ${error.message}`)
+      throw error
+    }
+  }
+  
+  /**
+   * 确认应用模板
+   */
+  function confirmApply() {
+    if (templateStore.previewStructure) {
+      editorStore.updateStructure(templateStore.previewStructure)
+      templateStore.clearPreview()
+      ElMessage.success('模板已应用')
+      return true
+    }
+    return false
+  }
+  
+  /**
+   * 取消预览
+   */
+  function cancelPreview() {
+    templateStore.clearPreview()
+  }
+  
+  /**
+   * 删除模板
+   */
+  async function deleteTemplate(name: string) {
+    try {
+      await ElMessageBox.confirm(
+        `确定要删除模板 "${name}" 吗?`,
+        '确认删除',
+        { type: 'warning' }
+      )
+      await templateApi.deleteTemplate(name)
+      templateStore.removeTemplate(name)
+      ElMessage.success('模板已删除')
+      return true
+    } catch (error: any) {
+      if (error !== 'cancel') {
+        ElMessage.error(`删除失败: ${error.message}`)
+      }
+      return false
+    }
+  }
+  
+  /**
+   * 重置创建表单
+   */
+  function resetCreateForm() {
+    createForm.value = { name: '', description: '' }
+    showCreateDialog.value = false
+  }
+  
+  return {
+    // 状态
+    isLoading,
+    isCreating,
+    selectedTemplate,
+    showCreateDialog,
+    createForm,
+    
+    // 计算属性
+    templates,
+    hasTemplates,
+    isPreviewMode,
+    previewStructure,
+    previewTemplateName,
+    
+    // 方法
+    loadTemplates,
+    createTemplate,
+    selectTemplate,
+    previewTemplate,
+    confirmApply,
+    cancelPreview,
+    deleteTemplate,
+    resetCreateForm
+  }
+}

+ 2 - 0
table_line_generator/frontend/src/composables/index.ts

@@ -0,0 +1,2 @@
+export { useKeyboardShortcuts } from './useKeyboardShortcuts'
+export { useCanvasInteraction } from './useCanvasInteraction'

+ 141 - 0
table_line_generator/frontend/src/composables/useCanvasInteraction.ts

@@ -0,0 +1,141 @@
+import { computed } from 'vue'
+import { useEditorStore } from '@/stores'
+
+/**
+ * 画布交互组合式函数
+ * 提供拖拽约束、缩放等功能
+ */
+export function useCanvasInteraction() {
+  const editorStore = useEditorStore()
+
+  /**
+   * 横线拖拽约束函数
+   * 只允许垂直方向移动
+   */
+  function horizontalLineDragBound(pos: { x: number; y: number }, originalX: number) {
+    return {
+      x: originalX,  // X 保持不变
+      y: Math.max(0, Math.min(pos.y, editorStore.imageSize.height * editorStore.scaleFactor))
+    }
+  }
+
+  /**
+   * 竖线拖拽约束函数
+   * 只允许水平方向移动
+   */
+  function verticalLineDragBound(pos: { x: number; y: number }, originalY: number) {
+    return {
+      x: Math.max(0, Math.min(pos.x, editorStore.imageSize.width * editorStore.scaleFactor)),
+      y: originalY  // Y 保持不变
+    }
+  }
+
+  /**
+   * 处理缩放
+   */
+  function handleZoom(delta: number) {
+    const newZoom = editorStore.zoom + delta
+    editorStore.setZoom(newZoom)
+  }
+
+  /**
+   * 重置视图
+   */
+  function resetView() {
+    editorStore.setZoom(1.0)
+    editorStore.setStagePos(0, 0)
+  }
+
+  /**
+   * 适应窗口
+   */
+  function fitToWindow(containerWidth: number, containerHeight: number) {
+    const { width, height } = editorStore.imageSize
+    const scale = editorStore.scaleFactor
+    
+    const imageDisplayWidth = width * scale
+    const imageDisplayHeight = height * scale
+    
+    const zoomX = containerWidth / imageDisplayWidth
+    const zoomY = containerHeight / imageDisplayHeight
+    const fitZoom = Math.min(zoomX, zoomY, 1.0) * 0.95  // 留一点边距
+    
+    editorStore.setZoom(fitZoom)
+    editorStore.setStagePos(0, 0)
+  }
+
+  /**
+   * 将显示坐标转换为原图坐标
+   */
+  function displayToOriginal(value: number): number {
+    return Math.round(value / editorStore.scaleFactor)
+  }
+
+  /**
+   * 将原图坐标转换为显示坐标
+   */
+  function originalToDisplay(value: number): number {
+    return value * editorStore.scaleFactor
+  }
+
+  /**
+   * 计算线条在画布上的显示坐标
+   */
+  const displayHorizontalLines = computed(() => {
+    return editorStore.structure.horizontal_lines.map(y => originalToDisplay(y))
+  })
+
+  const displayVerticalLines = computed(() => {
+    return editorStore.structure.vertical_lines.map(x => originalToDisplay(x))
+  })
+  
+  /**
+   * 表格边界框的显示坐标 [x1, y1, x2, y2]
+   */
+  const displayTableBbox = computed(() => {
+    const bbox = editorStore.effectiveTableBbox
+    return [
+      originalToDisplay(bbox[0]),
+      originalToDisplay(bbox[1]),
+      originalToDisplay(bbox[2]),
+      originalToDisplay(bbox[3])
+    ]
+  })
+  
+  /**
+   * 横线的起止点(受table_bbox限制)
+   */
+  const horizontalLineEndpoints = computed(() => {
+    const bbox = displayTableBbox.value
+    return {
+      x1: bbox[0],
+      x2: bbox[2]
+    }
+  })
+  
+  /**
+   * 竖线的起止点(受table_bbox限制)
+   */
+  const verticalLineEndpoints = computed(() => {
+    const bbox = displayTableBbox.value
+    return {
+      y1: bbox[1],
+      y2: bbox[3]
+    }
+  })
+
+  return {
+    horizontalLineDragBound,
+    verticalLineDragBound,
+    handleZoom,
+    resetView,
+    fitToWindow,
+    displayToOriginal,
+    originalToDisplay,
+    displayHorizontalLines,
+    displayVerticalLines,
+    displayTableBbox,
+    horizontalLineEndpoints,
+    verticalLineEndpoints
+  }
+}

+ 196 - 0
table_line_generator/frontend/src/composables/useKeyboardShortcuts.ts

@@ -0,0 +1,196 @@
+import { onMounted, onUnmounted } from 'vue'
+import { useEditorStore, useHistoryStore, useTemplateStore } from '@/stores'
+
+/**
+ * 键盘快捷键组合式函数
+ * 
+ * 支持的快捷键:
+ * - Ctrl+Z: 撤销
+ * - Ctrl+Y / Ctrl+Shift+Z: 重做
+ * - Delete / Backspace: 删除选中线条
+ * - 方向键: 微调 1px
+ * - Shift+方向键: 微调 10px
+ * - Ctrl+左右箭头: 上一个/下一个文件(批量模式)
+ * - Ctrl+T: 应用选中的模板
+ */
+export function useKeyboardShortcuts() {
+  const editorStore = useEditorStore()
+  const historyStore = useHistoryStore()
+  const templateStore = useTemplateStore()
+  
+  // 外部注入的回调函数
+  let onLoadFileCallback: ((pair: { imagePath: string; jsonPath: string }) => void) | null = null
+  let onApplyTemplateCallback: (() => void) | null = null
+  
+  function loadCurrentPair() {
+    const pair = templateStore.currentFilePair
+    if (pair && onLoadFileCallback) {
+      onLoadFileCallback({
+        imagePath: pair.image_path,
+        jsonPath: pair.json_path
+      })
+    }
+  }
+
+  function handleKeyDown(e: KeyboardEvent) {
+    // 如果焦点在输入框中,不处理快捷键
+    const target = e.target as HTMLElement
+    if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA') {
+      return
+    }
+
+    const isCtrl = e.ctrlKey || e.metaKey
+    const isShift = e.shiftKey
+
+    // Ctrl+Z: 撤销
+    if (isCtrl && e.key === 'z' && !isShift) {
+      e.preventDefault()
+      performUndo()
+      return
+    }
+
+    // Ctrl+Y 或 Ctrl+Shift+Z: 重做
+    if ((isCtrl && e.key === 'y') || (isCtrl && e.key === 'z' && isShift)) {
+      e.preventDefault()
+      performRedo()
+      return
+    }
+    
+    // Ctrl+左箭头: 上一个文件
+    if (isCtrl && e.key === 'ArrowLeft' && !isShift) {
+      if (templateStore.hasFilePairs && templateStore.hasPrevPair) {
+        e.preventDefault()
+        templateStore.prevPair()
+        loadCurrentPair()
+      }
+      return
+    }
+    
+    // Ctrl+右箭头: 下一个文件
+    if (isCtrl && e.key === 'ArrowRight' && !isShift) {
+      if (templateStore.hasFilePairs && templateStore.hasNextPair) {
+        e.preventDefault()
+        templateStore.nextPair()
+        loadCurrentPair()
+      }
+      return
+    }
+    
+    // Ctrl+T: 应用选中的模板
+    if (isCtrl && e.key === 't') {
+      e.preventDefault()
+      if (onApplyTemplateCallback && templateStore.selectedTemplateName && editorStore.hasData) {
+        onApplyTemplateCallback()
+      }
+      return
+    }
+
+    // Delete / Backspace: 删除选中线条
+    if (e.key === 'Delete' || e.key === 'Backspace') {
+      if (editorStore.selectedLine) {
+        e.preventDefault()
+        editorStore.deleteSelectedLine()
+      }
+      return
+    }
+
+    // 方向键: 微调选中线条
+    if (editorStore.selectedLine) {
+      const step = isShift ? 10 : 1
+      const { type } = editorStore.selectedLine
+
+      switch (e.key) {
+        case 'ArrowUp':
+          if (type === 'h') {
+            e.preventDefault()
+            editorStore.nudgeSelectedLine(-step)
+          }
+          break
+        case 'ArrowDown':
+          if (type === 'h') {
+            e.preventDefault()
+            editorStore.nudgeSelectedLine(step)
+          }
+          break
+        case 'ArrowLeft':
+          if (type === 'v') {
+            e.preventDefault()
+            editorStore.nudgeSelectedLine(-step)
+          }
+          break
+        case 'ArrowRight':
+          if (type === 'v') {
+            e.preventDefault()
+            editorStore.nudgeSelectedLine(step)
+          }
+          break
+      }
+    }
+  }
+
+  function performUndo() {
+    if (!historyStore.canUndo) return
+    
+    // 保存当前状态到重做栈
+    const currentStructure = JSON.parse(JSON.stringify(editorStore.structure))
+    historyStore.saveCurrentForRedo(currentStructure)
+    
+    // 恢复上一个状态
+    const previousState = historyStore.undo()
+    if (previousState) {
+      editorStore.applyStructure(previousState)
+    }
+  }
+
+  function performRedo() {
+    if (!historyStore.canRedo) return
+    
+    // 保存当前状态到撤销栈
+    const currentStructure = JSON.parse(JSON.stringify(editorStore.structure))
+    historyStore.saveCurrentForUndo(currentStructure)
+    
+    // 恢复重做状态
+    const nextState = historyStore.redo()
+    if (nextState) {
+      editorStore.applyStructure(nextState)
+    }
+  }
+
+  onMounted(() => {
+    window.addEventListener('keydown', handleKeyDown)
+  })
+
+  onUnmounted(() => {
+    window.removeEventListener('keydown', handleKeyDown)
+  })
+
+  function setup() {
+    window.addEventListener('keydown', handleKeyDown)
+  }
+
+  function cleanup() {
+    window.removeEventListener('keydown', handleKeyDown)
+  }
+  
+  // 注册回调函数
+  function registerCallbacks(callbacks: {
+    onLoadFile?: (pair: { imagePath: string; jsonPath: string }) => void
+    onApplyTemplate?: () => void
+  }) {
+    if (callbacks.onLoadFile) {
+      onLoadFileCallback = callbacks.onLoadFile
+    }
+    if (callbacks.onApplyTemplate) {
+      onApplyTemplateCallback = callbacks.onApplyTemplate
+    }
+  }
+
+  return {
+    performUndo,
+    performRedo,
+    setup,
+    cleanup,
+    registerCallbacks,
+    loadCurrentPair
+  }
+}

+ 25 - 0
table_line_generator/frontend/src/main.ts

@@ -0,0 +1,25 @@
+import { createApp } from 'vue'
+import { createPinia } from 'pinia'
+import ElementPlus from 'element-plus'
+import 'element-plus/dist/index.css'
+import * as ElementPlusIconsVue from '@element-plus/icons-vue'
+import VueKonva from 'vue-konva'
+
+import App from './App.vue'
+import router from './router'
+
+import './styles/main.scss'
+
+const app = createApp(App)
+
+// 注册所有图标
+for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
+  app.component(key, component)
+}
+
+app.use(createPinia())
+app.use(router)
+app.use(ElementPlus)
+app.use(VueKonva)
+
+app.mount('#app')

+ 14 - 0
table_line_generator/frontend/src/router/index.ts

@@ -0,0 +1,14 @@
+import { createRouter, createWebHistory } from 'vue-router'
+
+const router = createRouter({
+  history: createWebHistory(),
+  routes: [
+    {
+      path: '/',
+      name: 'editor',
+      component: () => import('@/views/EditorView.vue')
+    }
+  ]
+})
+
+export default router

+ 342 - 0
table_line_generator/frontend/src/stores/editorStore.ts

@@ -0,0 +1,342 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { TableStructure, ImageSize, SelectedLine } from '@/types'
+import { useHistoryStore } from './historyStore'
+
+/**
+ * 创建默认结构
+ */
+function createDefaultStructure(): TableStructure {
+  return {
+    horizontal_lines: [],
+    vertical_lines: [],
+    modified_h_lines: [],
+    modified_v_lines: []
+  }
+}
+
+export const useEditorStore = defineStore('editor', () => {
+  // 状态
+  const imageSrc = ref<string | null>(null)
+  const imageSize = ref<ImageSize>({ width: 0, height: 0 })
+  const scaleFactor = ref(1.0)
+  const structure = ref<TableStructure>(createDefaultStructure())
+  const ocrData = ref<Record<string, unknown> | null>(null)
+  const selectedLine = ref<SelectedLine | null>(null)
+  const zoom = ref(1.0)
+  const stagePos = ref({ x: 0, y: 0 })
+  const isLoading = ref(false)
+  const suggestedFilename = ref<string | null>(null)
+  
+  // 表格边界框状态 [x1, y1, x2, y2]
+  const tableBbox = ref<number[] | null>(null)
+  const isDefiningBbox = ref(false)  // 是否正在定义边界框
+  const skewAngle = ref(0)  // 倾斜角度(度)
+
+  // 计算属性
+  const hasData = computed(() => imageSrc.value !== null)
+  
+  const horizontalLineCount = computed(() => structure.value.horizontal_lines.length)
+  const verticalLineCount = computed(() => structure.value.vertical_lines.length)
+  
+  const rowCount = computed(() => Math.max(0, horizontalLineCount.value - 1))
+  const colCount = computed(() => Math.max(0, verticalLineCount.value - 1))
+  
+  // 有效的表格边界(优先使用用户定义的,否则使用结构中的,最后回退到图片边界)
+  const effectiveTableBbox = computed(() => {
+    if (tableBbox.value && tableBbox.value.length === 4) {
+      return tableBbox.value
+    }
+    if (structure.value.table_bbox && structure.value.table_bbox.length === 4) {
+      return structure.value.table_bbox
+    }
+    // 回退到图片边界
+    return [0, 0, imageSize.value.width, imageSize.value.height]
+  })
+  
+  // 是否有明确定义的表格边界
+  const hasDefinedBbox = computed(() => {
+    return (tableBbox.value && tableBbox.value.length === 4) ||
+           (structure.value.table_bbox && structure.value.table_bbox.length === 4)
+  })
+
+  // 获取历史存储
+  const historyStore = useHistoryStore()
+
+  // 方法
+  function loadData(
+    base64Image: string,
+    size: ImageSize,
+    tableStructure: TableStructure,
+    scale: number,
+    ocr: Record<string, unknown> | null,
+    filename?: string | null
+  ) {
+    imageSrc.value = base64Image
+    imageSize.value = size
+    structure.value = tableStructure
+    scaleFactor.value = scale
+    ocrData.value = ocr
+    suggestedFilename.value = filename || null
+    selectedLine.value = null
+    zoom.value = 1.0
+    stagePos.value = { x: 0, y: 0 }
+    
+    // 初始化表格边界框(从结构中获取)
+    tableBbox.value = tableStructure.table_bbox || null
+    
+    // 初始化倾斜角度
+    skewAngle.value = tableStructure.skew_angle || 0
+    isDefiningBbox.value = false
+    
+    // 清空历史
+    historyStore.clear()
+  }
+
+  function selectLine(line: SelectedLine | null) {
+    selectedLine.value = line
+  }
+
+  function clearSelection() {
+    selectedLine.value = null
+  }
+
+  /**
+   * 更新线条位置
+   */
+  function updateLinePosition(type: 'h' | 'v', index: number, newValue: number) {
+    // 保存历史
+    historyStore.pushState(JSON.parse(JSON.stringify(structure.value)))
+    
+    if (type === 'h') {
+      structure.value.horizontal_lines[index] = Math.round(newValue)
+      // 标记为已修改
+      if (!structure.value.modified_h_lines.includes(index)) {
+        structure.value.modified_h_lines.push(index)
+      }
+    } else {
+      structure.value.vertical_lines[index] = Math.round(newValue)
+      if (!structure.value.modified_v_lines.includes(index)) {
+        structure.value.modified_v_lines.push(index)
+      }
+    }
+    
+    // 保持排序
+    structure.value.horizontal_lines.sort((a, b) => a - b)
+    structure.value.vertical_lines.sort((a, b) => a - b)
+  }
+
+  /**
+   * 添加线条
+   */
+  function addLine(type: 'h' | 'v', value: number) {
+    // 保存历史
+    historyStore.pushState(JSON.parse(JSON.stringify(structure.value)))
+    
+    const roundedValue = Math.round(value)
+    
+    if (type === 'h') {
+      if (!structure.value.horizontal_lines.includes(roundedValue)) {
+        structure.value.horizontal_lines.push(roundedValue)
+        structure.value.horizontal_lines.sort((a, b) => a - b)
+        // 标记新添加的线为已修改
+        const newIndex = structure.value.horizontal_lines.indexOf(roundedValue)
+        if (!structure.value.modified_h_lines.includes(newIndex)) {
+          structure.value.modified_h_lines.push(newIndex)
+        }
+      }
+    } else {
+      if (!structure.value.vertical_lines.includes(roundedValue)) {
+        structure.value.vertical_lines.push(roundedValue)
+        structure.value.vertical_lines.sort((a, b) => a - b)
+        const newIndex = structure.value.vertical_lines.indexOf(roundedValue)
+        if (!structure.value.modified_v_lines.includes(newIndex)) {
+          structure.value.modified_v_lines.push(newIndex)
+        }
+      }
+    }
+  }
+
+  /**
+   * 删除选中的线条
+   */
+  function deleteSelectedLine() {
+    if (!selectedLine.value) return
+    
+    // 保存历史
+    historyStore.pushState(JSON.parse(JSON.stringify(structure.value)))
+    
+    const { type, index } = selectedLine.value
+    
+    if (type === 'h') {
+      structure.value.horizontal_lines.splice(index, 1)
+      // 更新修改记录中的索引
+      structure.value.modified_h_lines = structure.value.modified_h_lines
+        .filter(i => i !== index)
+        .map(i => i > index ? i - 1 : i)
+    } else {
+      structure.value.vertical_lines.splice(index, 1)
+      structure.value.modified_v_lines = structure.value.modified_v_lines
+        .filter(i => i !== index)
+        .map(i => i > index ? i - 1 : i)
+    }
+    
+    selectedLine.value = null
+  }
+
+  /**
+   * 微调选中线条
+   */
+  function nudgeSelectedLine(delta: number) {
+    if (!selectedLine.value) return
+    
+    const { type, index } = selectedLine.value
+    
+    if (type === 'h') {
+      const currentValue = structure.value.horizontal_lines[index]
+      updateLinePosition('h', index, currentValue + delta)
+    } else {
+      const currentValue = structure.value.vertical_lines[index]
+      updateLinePosition('v', index, currentValue + delta)
+    }
+  }
+
+  /**
+   * 设置缩放
+   */
+  function setZoom(newZoom: number) {
+    zoom.value = Math.max(0.25, Math.min(2.0, newZoom))
+  }
+
+  /**
+   * 设置画布位置
+   */
+  function setStagePos(x: number, y: number) {
+    stagePos.value = { x, y }
+  }
+
+  /**
+   * 设置加载状态
+   */
+  function setLoading(loading: boolean) {
+    isLoading.value = loading
+  }
+
+  /**
+   * 应用历史状态
+   */
+  function applyStructure(newStructure: TableStructure) {
+    structure.value = newStructure
+    selectedLine.value = null
+  }
+
+  /**
+   * 更新表格边界框
+   */
+  function updateTableBbox(bbox: number[]) {
+    tableBbox.value = bbox
+    // 同时更新structure中的table_bbox
+    structure.value.table_bbox = bbox
+  }
+  
+  /**
+   * 开始/结束定义边界框模式
+   */
+  function setDefiningBbox(defining: boolean) {
+    isDefiningBbox.value = defining
+  }
+  
+  /**
+   * 更新倾斜角度
+   */
+  function updateSkewAngle(angle: number) {
+    skewAngle.value = angle
+    // 同时更新structure
+    structure.value.skew_angle = angle
+  }
+
+  /**
+   * 恢复结构(用于撤销/重做)
+   */
+  function restoreStructure(newStructure: TableStructure) {
+    structure.value = JSON.parse(JSON.stringify(newStructure))
+    selectedLine.value = null
+  }
+
+  /**
+   * 更新结构(用于重新分析)
+   */
+  function updateStructure(newStructure: TableStructure) {
+    structure.value = {
+      ...newStructure,
+      modified_h_lines: [],
+      modified_v_lines: []
+    }
+    selectedLine.value = null
+  }
+
+  /**
+   * 重置编辑器
+   */
+  function reset() {
+    imageSrc.value = null
+    imageSize.value = { width: 0, height: 0 }
+    scaleFactor.value = 1.0
+    structure.value = createDefaultStructure()
+    ocrData.value = null
+    suggestedFilename.value = null
+    selectedLine.value = null
+    zoom.value = 1.0
+    stagePos.value = { x: 0, y: 0 }
+    isLoading.value = false
+    tableBbox.value = null
+    isDefiningBbox.value = false
+    skewAngle.value = 0
+    historyStore.clear()
+  }
+
+  return {
+    // 状态
+    imageSrc,
+    imageSize,
+    scaleFactor,
+    structure,
+    ocrData,
+    suggestedFilename,
+    selectedLine,
+    zoom,
+    stagePos,
+    isLoading,
+    tableBbox,
+    isDefiningBbox,
+    skewAngle,
+    
+    // 计算属性
+    hasData,
+    horizontalLineCount,
+    verticalLineCount,
+    rowCount,
+    colCount,
+    effectiveTableBbox,
+    hasDefinedBbox,
+    
+    // 方法
+    loadData,
+    selectLine,
+    clearSelection,
+    updateLinePosition,
+    addLine,
+    deleteSelectedLine,
+    nudgeSelectedLine,
+    setZoom,
+    setStagePos,
+    setLoading,
+    updateTableBbox,
+    setDefiningBbox,
+    updateSkewAngle,
+    applyStructure,
+    restoreStructure,
+    updateStructure,
+    reset
+  }
+})

+ 77 - 0
table_line_generator/frontend/src/stores/historyStore.ts

@@ -0,0 +1,77 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { TableStructure } from '@/types'
+
+const MAX_HISTORY = 30
+
+export const useHistoryStore = defineStore('history', () => {
+  const undoStack = ref<TableStructure[]>([])
+  const redoStack = ref<TableStructure[]>([])
+
+  const canUndo = computed(() => undoStack.value.length > 0)
+  const canRedo = computed(() => redoStack.value.length > 0)
+  const undoCount = computed(() => undoStack.value.length)
+  const redoCount = computed(() => redoStack.value.length)
+
+  function pushState(state: TableStructure) {
+    undoStack.value.push(state)
+    
+    // 限制历史记录数量
+    if (undoStack.value.length > MAX_HISTORY) {
+      undoStack.value.shift()
+    }
+    
+    // 清空重做栈
+    redoStack.value = []
+  }
+
+  function undo(): TableStructure | null {
+    if (undoStack.value.length === 0) return null
+    
+    const state = undoStack.value.pop()!
+    return state
+  }
+
+  function saveCurrentForRedo(current: TableStructure) {
+    redoStack.value.push(current)
+    
+    if (redoStack.value.length > MAX_HISTORY) {
+      redoStack.value.shift()
+    }
+  }
+
+  function redo(): TableStructure | null {
+    if (redoStack.value.length === 0) return null
+    
+    const state = redoStack.value.pop()!
+    return state
+  }
+
+  function saveCurrentForUndo(current: TableStructure) {
+    undoStack.value.push(current)
+    
+    if (undoStack.value.length > MAX_HISTORY) {
+      undoStack.value.shift()
+    }
+  }
+
+  function clear() {
+    undoStack.value = []
+    redoStack.value = []
+  }
+
+  return {
+    undoStack,
+    redoStack,
+    canUndo,
+    canRedo,
+    undoCount,
+    redoCount,
+    pushState,
+    undo,
+    redo,
+    saveCurrentForRedo,
+    saveCurrentForUndo,
+    clear
+  }
+})

+ 4 - 0
table_line_generator/frontend/src/stores/index.ts

@@ -0,0 +1,4 @@
+export { useEditorStore } from './editorStore'
+export { useHistoryStore } from './historyStore'
+export { useTemplateStore } from './templateStore'
+export type { TemplateInfo, FilePair, DirectoryItem } from './templateStore'

+ 278 - 0
table_line_generator/frontend/src/stores/templateStore.ts

@@ -0,0 +1,278 @@
+import { defineStore } from 'pinia'
+import { ref, computed } from 'vue'
+import type { 
+  TableStructure, 
+  TemplateInfo, 
+  FilePair, 
+  DirectoryItem, 
+  DataSourceConfig 
+} from '@/types'
+
+/**
+ * 模板管理 Store
+ */
+export const useTemplateStore = defineStore('template', () => {
+  // 模板列表
+  const templates = ref<TemplateInfo[]>([])
+  const isLoadingTemplates = ref(false)
+  
+  // 当前预览
+  const previewStructure = ref<TableStructure | null>(null)
+  const previewTemplateName = ref<string | null>(null)
+  const isPreviewMode = ref(false)
+  
+  // 批量模式
+  const batchMode = ref(false)
+  const selectedTemplateName = ref<string | null>(null)  // 选中的模板,用于批量应用
+  const autoApplyTemplate = ref(false)  // 是否自动应用模板
+  
+  // 目录浏览
+  const currentPath = ref<string>('')
+  const directoryItems = ref<DirectoryItem[]>([])  
+  const parentPath = ref<string | null>(null)  
+  
+  // 文件扫描
+  const filePairs = ref<FilePair[]>([])
+  const currentPairIndex = ref(0)
+  const scanConfig = ref({
+    imageDir: '',
+    jsonDir: '',
+    outputDir: ''
+  })
+  
+  // 数据源配置
+  const dataSources = ref<DataSourceConfig[]>([])
+  const activeDataSourceFilename = ref<string | null>(null)  // 使用 filename 作为唯一标识符
+  const isLoadingDataSources = ref(false)
+  
+  // 计算属性
+  const hasTemplates = computed(() => templates.value.length > 0)
+  const hasFilePairs = computed(() => filePairs.value.length > 0)
+  const currentFilePair = computed(() => 
+    filePairs.value[currentPairIndex.value] || null
+  )
+  const labeledCount = computed(() => 
+    filePairs.value.filter(p => p.has_structure).length
+  )
+  const unlabeledCount = computed(() => 
+    filePairs.value.filter(p => !p.has_structure).length
+  )
+  const selectedTemplate = computed(() =>
+    templates.value.find(t => t.name === selectedTemplateName.value) || null
+  )
+  const hasPrevPair = computed(() => currentPairIndex.value > 0)
+  const hasNextPair = computed(() => currentPairIndex.value < filePairs.value.length - 1)
+  
+  // 数据源相关计算属性
+  const hasDataSources = computed(() => dataSources.value.length > 0)
+  const activeDataSource = computed(() => 
+    dataSources.value.find(ds => ds.filename === activeDataSourceFilename.value) || null
+  )
+  // 为了向后兼容,保留 activeDataSourceName(使用 name 字段)
+  const activeDataSourceName = computed(() => activeDataSource.value?.name || null)
+  
+  // 方法
+  function setTemplates(list: TemplateInfo[]) {
+    templates.value = list
+  }
+  
+  function addTemplate(template: TemplateInfo) {
+    templates.value.unshift(template)
+  }
+  
+  function removeTemplate(name: string) {
+    const index = templates.value.findIndex(t => t.name === name)
+    if (index !== -1) {
+      templates.value.splice(index, 1)
+    }
+  }
+  
+  function setPreview(structure: TableStructure, templateName: string) {
+    previewStructure.value = structure
+    previewTemplateName.value = templateName
+    isPreviewMode.value = true
+  }
+  
+  function clearPreview() {
+    previewStructure.value = null
+    previewTemplateName.value = null
+    isPreviewMode.value = false
+  }
+  
+  function setDirectory(path: string, items: DirectoryItem[], parent: string | null) {
+    currentPath.value = path
+    directoryItems.value = items
+    parentPath.value = parent
+  }
+  
+  function setFilePairs(pairs: FilePair[]) {
+    filePairs.value = pairs
+    currentPairIndex.value = 0
+  }
+  
+  function setCurrentPairIndex(index: number) {
+    if (index >= 0 && index < filePairs.value.length) {
+      currentPairIndex.value = index
+    }
+  }
+  
+  function nextPair() {
+    if (currentPairIndex.value < filePairs.value.length - 1) {
+      currentPairIndex.value++
+    }
+  }
+  
+  function prevPair() {
+    if (currentPairIndex.value > 0) {
+      currentPairIndex.value--
+    }
+  }
+  
+  function updatePairStatus(index: number, hasStructure: boolean, structurePath: string | null) {
+    if (index >= 0 && index < filePairs.value.length) {
+      filePairs.value[index].has_structure = hasStructure
+      filePairs.value[index].structure_path = structurePath
+    }
+  }
+  
+  function setScanConfig(config: { imageDir: string; jsonDir: string; outputDir: string }) {
+    scanConfig.value = config
+  }
+  
+  function selectTemplate(name: string | null) {
+    selectedTemplateName.value = name
+  }
+  
+  function toggleBatchMode(enabled?: boolean) {
+    batchMode.value = enabled ?? !batchMode.value
+  }
+  
+  function setAutoApplyTemplate(enabled: boolean) {
+    autoApplyTemplate.value = enabled
+  }
+  
+  // 数据源管理方法
+  function setDataSources(sources: DataSourceConfig[]) {
+    dataSources.value = sources
+  }
+  
+  function selectDataSource(sourceFilename: string | null) {
+    activeDataSourceFilename.value = sourceFilename
+    
+    // 如果选择了数据源,自动应用其配置到 scanConfig
+    if (sourceFilename) {
+      const source = dataSources.value.find(ds => ds.filename === sourceFilename)
+      if (source) {
+        applyDataSourceConfig(source)
+      } else {
+        console.warn('selectDataSource: 未找到匹配的数据源', {
+          sourceFilename,
+          availableFilenames: dataSources.value.map(ds => ds.filename),
+          dataSourcesCount: dataSources.value.length
+        })
+      }
+    }
+  }
+  
+  function applyDataSourceConfig(source: DataSourceConfig) {
+    // 后端已经解析了模板变量,这里只需处理相对路径转绝对路径
+    let resolvedImageDir = source.image_dir
+    let resolvedJsonDir = source.json_dir
+    let resolvedOutputDir = source.output.directory
+    
+    // 如果是相对路径,与 base_dir 组合成绝对路径
+    if (!resolvedImageDir.startsWith('/')) {
+      resolvedImageDir = `${source.base_dir}/${resolvedImageDir}`.replace(/\/+/g, '/')
+    }
+    if (!resolvedJsonDir.startsWith('/')) {
+      resolvedJsonDir = `${source.base_dir}/${resolvedJsonDir}`.replace(/\/+/g, '/')
+    }
+    if (!resolvedOutputDir.startsWith('/')) {
+      resolvedOutputDir = `${source.base_dir}/${resolvedOutputDir}`.replace(/\/+/g, '/')
+    }
+    
+    scanConfig.value = {
+      imageDir: resolvedImageDir,
+      jsonDir: resolvedJsonDir,
+      outputDir: resolvedOutputDir
+    }
+    
+    // 如果数据源指定了默认模板,自动选择
+    if (source.batch?.template_name) {
+      selectedTemplateName.value = source.batch.template_name
+    }
+    
+    // 如果数据源指定了自动应用,设置标志
+    if (source.batch?.auto_apply !== undefined) {
+      autoApplyTemplate.value = source.batch.auto_apply
+    }
+  }
+  
+  function reset() {
+    templates.value = []
+    previewStructure.value = null
+    previewTemplateName.value = null
+    isPreviewMode.value = false
+    filePairs.value = []
+    currentPairIndex.value = 0
+    batchMode.value = false
+    selectedTemplateName.value = null
+    autoApplyTemplate.value = false
+    activeDataSourceFilename.value = null
+  }
+
+  return {
+    // 状态
+    templates,
+    isLoadingTemplates,
+    previewStructure,
+    previewTemplateName,
+    isPreviewMode,
+    batchMode,
+    selectedTemplateName,
+    autoApplyTemplate,
+    currentPath,
+    directoryItems,
+    parentPath,
+    filePairs,
+    currentPairIndex,
+    scanConfig,
+    dataSources,
+    activeDataSourceFilename,
+    activeDataSourceName,  // 计算属性,向后兼容
+    isLoadingDataSources,
+    
+    // 计算属性
+    hasTemplates,
+    hasFilePairs,
+    currentFilePair,
+    labeledCount,
+    unlabeledCount,
+    selectedTemplate,
+    hasPrevPair,
+    hasNextPair,
+    hasDataSources,
+    activeDataSource,
+    
+    // 方法
+    setTemplates,
+    addTemplate,
+    removeTemplate,
+    setPreview,
+    clearPreview,
+    setDirectory,
+    setFilePairs,
+    setCurrentPairIndex,
+    nextPair,
+    prevPair,
+    updatePairStatus,
+    setScanConfig,
+    selectTemplate,
+    toggleBatchMode,
+    setAutoApplyTemplate,
+    setDataSources,
+    selectDataSource,
+    applyDataSourceConfig,
+    reset
+  }
+})

+ 184 - 0
table_line_generator/frontend/src/styles/main.scss

@@ -0,0 +1,184 @@
+// 全局样式
+* {
+  box-sizing: border-box;
+}
+
+html, body {
+  margin: 0;
+  padding: 0;
+  height: 100%;
+  font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
+}
+
+#app {
+  height: 100%;
+}
+
+// 编辑器布局
+.editor-container {
+  display: flex;
+  height: 100vh;
+  overflow: hidden;
+}
+
+.canvas-container {
+  flex: 1;
+  overflow: hidden;
+  background: #f5f5f5;
+  position: relative;
+}
+
+.sidebar {
+  width: 320px;
+  border-left: 1px solid #e4e7ed;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+}
+
+// 工具面板
+.tool-panel {
+  padding: 16px;
+  border-bottom: 1px solid #e4e7ed;
+  
+  .panel-title {
+    font-size: 14px;
+    font-weight: 600;
+    margin-bottom: 12px;
+    color: #303133;
+  }
+  
+  .button-group {
+    display: flex;
+    gap: 8px;
+    margin-bottom: 12px;
+    flex-wrap: wrap;
+  }
+}
+
+// 结构信息面板
+.structure-panel {
+  flex: 1;
+  padding: 16px;
+  overflow-y: auto;
+  
+  .panel-title {
+    font-size: 14px;
+    font-weight: 600;
+    margin-bottom: 12px;
+    color: #303133;
+  }
+  
+  .info-row {
+    display: flex;
+    justify-content: space-between;
+    margin-bottom: 8px;
+    font-size: 13px;
+    
+    .label {
+      color: #909399;
+    }
+    
+    .value {
+      color: #303133;
+      font-weight: 500;
+    }
+  }
+  
+  .line-list {
+    margin-top: 12px;
+    
+    .line-item {
+      display: flex;
+      justify-content: space-between;
+      align-items: center;
+      padding: 6px 8px;
+      margin-bottom: 4px;
+      background: #f5f7fa;
+      border-radius: 4px;
+      font-size: 12px;
+      cursor: pointer;
+      transition: background 0.2s;
+      
+      &:hover {
+        background: #e6f7ff;
+      }
+      
+      &.selected {
+        background: #409eff;
+        color: #fff;
+      }
+      
+      &.modified {
+        background: #fef0f0;
+        
+        &.selected {
+          background: #f56c6c;
+        }
+      }
+    }
+  }
+}
+
+// 上传区域
+.upload-area {
+  padding: 40px;
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  height: 100%;
+  
+  .upload-icon {
+    font-size: 64px;
+    color: #c0c4cc;
+    margin-bottom: 16px;
+  }
+  
+  .upload-text {
+    font-size: 16px;
+    color: #606266;
+    margin-bottom: 8px;
+  }
+  
+  .upload-hint {
+    font-size: 13px;
+    color: #909399;
+  }
+}
+
+// 画布工具栏
+.canvas-toolbar {
+  position: absolute;
+  top: 12px;
+  left: 12px;
+  display: flex;
+  gap: 8px;
+  z-index: 10;
+  
+  .zoom-display {
+    background: rgba(0, 0, 0, 0.6);
+    color: #fff;
+    padding: 4px 12px;
+    border-radius: 4px;
+    font-size: 13px;
+  }
+}
+
+// 坐标输入对话框
+.coordinate-input {
+  .el-input-number {
+    width: 100%;
+  }
+}
+
+// 保存设置
+.save-settings {
+  padding: 16px;
+  border-top: 1px solid #e4e7ed;
+  background: #fafafa;
+  
+  .el-input {
+    margin-bottom: 12px;
+  }
+}

+ 134 - 0
table_line_generator/frontend/src/types/editor.ts

@@ -0,0 +1,134 @@
+/**
+ * 表格结构类型定义
+ */
+export interface TableStructure {
+  horizontal_lines: number[]
+  vertical_lines: number[]
+  table_bbox?: number[]
+  row_height?: number
+  col_widths?: number[]
+  total_rows?: number
+  total_cols?: number
+  mode?: string
+  modified_h_lines: number[]
+  modified_v_lines: number[]
+  image_rotation_angle?: number
+  skew_angle?: number
+  is_skew_corrected?: boolean
+}
+
+/**
+ * 图片尺寸
+ */
+export interface ImageSize {
+  width: number
+  height: number
+}
+
+/**
+ * 选中的线条
+ */
+export interface SelectedLine {
+  type: 'h' | 'v'  // h=横线, v=竖线
+  index: number
+}
+
+/**
+ * 上传响应
+ */
+export interface UploadResponse {
+  success: boolean
+  image_base64: string
+  structure: TableStructure
+  image_size: ImageSize
+  scale_factor: number
+  ocr_data?: Record<string, unknown>
+  suggested_filename?: string
+  message?: string
+}
+
+/**
+ * 分析参数
+ */
+export interface AnalyzeParams {
+  y_tolerance?: number
+  x_tolerance?: number
+  min_row_height?: number
+  method?: 'auto' | 'cluster' | 'mineru' | 'cell_index'
+}
+
+/**
+ * 分析选项
+ */
+export interface AnalyzeOptions {
+  method?: string
+  y_tolerance?: number
+  x_tolerance?: number
+  min_row_height?: number
+}
+
+/**
+ * 分析响应
+ */
+export interface AnalyzeResponse {
+  success: boolean
+  structure: TableStructure
+  message?: string
+}
+
+/**
+ * 保存请求
+ */
+export interface SaveRequest {
+  structure: TableStructure
+  image_base64?: string
+  output_dir: string
+  filename: string
+  image_filename?: string
+  overwrite_mode?: string
+  structure_suffix?: string
+  image_suffix?: string
+  line_width?: number
+  line_color?: number[]
+}
+
+/**
+ * 保存响应
+ */
+export interface SaveResponse {
+  success: boolean
+  structure_path?: string
+  image_path?: string
+  message?: string
+}
+
+/**
+ * 编辑器状态
+ */
+export interface EditorState {
+  // 图片相关
+  imageSrc: string | null
+  imageSize: ImageSize
+  scaleFactor: number
+  
+  // 结构相关
+  structure: TableStructure
+  ocrData: Record<string, unknown> | null
+  
+  // 交互状态
+  selectedLine: SelectedLine | null
+  zoom: number
+  stagePos: { x: number; y: number }
+  
+  // 加载状态
+  isLoading: boolean
+  hasData: boolean
+}
+
+/**
+ * 历史状态(用于撤销/重做)
+ */
+export interface HistoryState {
+  undoStack: TableStructure[]
+  redoStack: TableStructure[]
+}

+ 2 - 0
table_line_generator/frontend/src/types/index.ts

@@ -0,0 +1,2 @@
+export * from './editor'
+export * from './template'

+ 71 - 0
table_line_generator/frontend/src/types/template.ts

@@ -0,0 +1,71 @@
+/**
+ * 模板相关类型定义
+ */
+
+/**
+ * 模板信息
+ */
+export interface TemplateInfo {
+  name: string
+  description: string
+  created_at: string
+  stats: {
+    row_count: number
+    col_count: number
+  }
+  source_image_size: {
+    width: number
+    height: number
+  }
+  path?: string  // 后端可能返回模板文件路径
+}
+
+/**
+ * 文件对信息
+ */
+export interface FilePair {
+  index: number
+  image_path: string
+  image_name: string
+  json_path: string
+  json_name: string
+  has_structure: boolean
+  structure_path: string | null
+}
+
+/**
+ * 目录项
+ */
+export interface DirectoryItem {
+  name: string
+  path: string
+  is_dir: boolean
+  size: number | null
+}
+
+/**
+ * 数据源配置
+ */
+export interface DataSourceConfig {
+  filename: string  // 配置文件名(不含 .yaml 扩展名),作为唯一标识符
+  name: string  // 数据源名称(可能重名,仅用于显示)
+  description: string
+  json_format: 'mineru' | 'ppstructure'
+  base_dir: string
+  json_dir: string
+  image_dir: string
+  json_pattern?: string
+  image_pattern?: string
+  sort_key?: string
+  output: {
+    directory: string
+    structure_suffix: string
+    image_suffix: string
+  }
+  batch?: {
+    enabled: boolean
+    template_name?: string
+    auto_apply: boolean
+    parallel_workers: number
+  }
+}

+ 488 - 0
table_line_generator/frontend/src/views/EditorView.vue

@@ -0,0 +1,488 @@
+<template>
+  <div class="editor-view">
+    <!-- 未加载数据时显示上传界面 -->
+    <template v-if="!editorStore.hasData">
+      <div class="upload-container">
+        <div class="upload-section">
+          <FileUploader />
+        </div>
+        <div class="template-section">
+          <h3>模板管理</h3>
+          <TemplatePanel @load-file="handleLoadFile" />
+        </div>
+      </div>
+    </template>
+    
+    <!-- 已加载数据时显示编辑界面 -->
+    <template v-else>
+      <!-- 主内容区 -->
+      <div class="main-content">
+        <!-- 左侧画布 -->
+        <div class="canvas-area">
+          <TableCanvas />
+        </div>
+        
+        <!-- 右侧面板 -->
+        <div class="side-panel">
+          <el-tabs v-model="activeTab" type="border-card">
+            <el-tab-pane label="工具" name="tools">
+              <ToolPanel />
+              <StructurePanel />
+              <SavePanel />
+            </el-tab-pane>
+            <el-tab-pane label="模板" name="templates">
+              <TemplatePanel @load-file="handleLoadFile" />
+            </el-tab-pane>
+          </el-tabs>
+        </div>
+      </div>
+      
+      <!-- 顶部工具栏 -->
+      <div class="top-bar">
+        <el-button @click="handleReset" type="info" plain>
+          <el-icon><Refresh /></el-icon>
+          重新选择文件
+        </el-button>
+        <el-button @click="handleReanalyze" type="warning" plain :loading="isReanalyzing">
+          <el-icon><Aim /></el-icon>
+          重新分析
+        </el-button>
+      </div>
+      
+      <!-- 底部批量导航栏 -->
+      <div v-if="templateStore.hasFilePairs" class="batch-nav-bar">
+        <div class="nav-info">
+          <el-tag type="info" size="small">
+            {{ templateStore.currentPairIndex + 1 }} / {{ templateStore.filePairs.length }}
+          </el-tag>
+          <span class="file-name">{{ templateStore.currentFilePair?.image_name }}</span>
+          <el-tag 
+            :type="templateStore.currentFilePair?.has_structure ? 'success' : 'warning'" 
+            size="small"
+          >
+            {{ templateStore.currentFilePair?.has_structure ? '已标注' : '待处理' }}
+          </el-tag>
+        </div>
+        
+        <div class="nav-actions">
+          <!-- 模板选择 -->
+          <el-select 
+            v-model="templateStore.selectedTemplateName" 
+            placeholder="选择模板"
+            size="small"
+            clearable
+            style="width: 150px"
+          >
+            <el-option 
+              v-for="tpl in templateStore.templates" 
+              :key="tpl.name" 
+              :label="tpl.name" 
+              :value="tpl.name"
+            />
+          </el-select>
+          
+          <el-button-group>
+            <el-button 
+              size="small" 
+              @click="goToPrevFile" 
+              :disabled="!templateStore.hasPrevPair"
+              title="上一个 (Ctrl+←)"
+            >
+              <el-icon><ArrowLeft /></el-icon>
+            </el-button>
+            <el-button 
+              size="small" 
+              @click="goToNextFile" 
+              :disabled="!templateStore.hasNextPair"
+              title="下一个 (Ctrl+→)"
+            >
+              <el-icon><ArrowRight /></el-icon>
+            </el-button>
+          </el-button-group>
+          
+          <el-button 
+            type="primary" 
+            size="small"
+            @click="applySelectedTemplate"
+            :disabled="!templateStore.selectedTemplateName"
+            title="应用模板 (Ctrl+T)"
+          >
+            <el-icon><Check /></el-icon>
+            应用模板
+          </el-button>
+          
+          <el-button 
+            type="success" 
+            size="small"
+            @click="saveAndNext"
+            :disabled="!templateStore.hasNextPair"
+            title="保存并继续下一个"
+          >
+            <el-icon><Right /></el-icon>
+            保存并继续
+          </el-button>
+        </div>
+        
+        <div class="nav-shortcuts">
+          <span class="shortcut-hint">快捷键: Ctrl+← 上一个 | Ctrl+→ 下一个 | Ctrl+T 应用模板</span>
+        </div>
+      </div>
+    </template>
+    
+    <!-- 全局 loading -->
+    <el-loading 
+      v-model:full-screen="isLoading"
+      text="加载中..."
+    />
+  </div>
+</template>
+
+<script setup lang="ts">
+import { ref, computed, onMounted, onUnmounted } from 'vue'
+import { ElMessage, ElMessageBox } from 'element-plus'
+import { Refresh, Aim, ArrowLeft, ArrowRight, Check, Right } from '@element-plus/icons-vue'
+import { useEditorStore, useHistoryStore, useTemplateStore } from '@/stores'
+import { useKeyboardShortcuts } from '@/composables'
+import { TableCanvas, FileUploader, ToolPanel, StructurePanel, SavePanel, TemplatePanel } from '@/components'
+import { reanalyzeStructure, loadByPath, saveResult } from '@/api'
+import * as templateApi from '@/api/template'
+
+const editorStore = useEditorStore()
+const historyStore = useHistoryStore()
+const templateStore = useTemplateStore()
+const { setup: setupKeyboardShortcuts, cleanup: cleanupKeyboardShortcuts, registerCallbacks } = useKeyboardShortcuts()
+
+const isLoading = computed(() => editorStore.isLoading)
+const isReanalyzing = ref(false)
+const activeTab = ref('tools')
+
+onMounted(() => {
+  setupKeyboardShortcuts()
+  // 注册键盘快捷键回调
+  registerCallbacks({
+    onLoadFile: handleLoadFile,
+    onApplyTemplate: applySelectedTemplate
+  })
+})
+
+onUnmounted(() => {
+  cleanupKeyboardShortcuts()
+})
+
+// 从文件路径加载数据
+async function handleLoadFile(payload: { imagePath: string; jsonPath: string }) {
+  try {
+    editorStore.setLoading(true)
+    
+    // 获取后缀配置
+    const structureSuffix = templateStore.activeDataSource?.output?.structure_suffix?.replace('.json', '') || '_structure'
+    const imageSuffix = templateStore.activeDataSource?.output?.image_suffix?.replace('.png', '') || ''
+    
+    const response = await loadByPath(
+      payload.imagePath, 
+      payload.jsonPath,
+      templateStore.scanConfig.outputDir || './output',
+      structureSuffix,
+      imageSuffix
+    )
+    
+    if (response.success) {
+      editorStore.loadData(
+        response.image_base64,
+        response.image_size,
+        response.structure,
+        response.scale_factor,
+        response.ocr_data || null,
+        response.suggested_filename || undefined
+      )
+      historyStore.clear()
+      ElMessage.success('加载成功')
+    } else {
+      ElMessage.error(response.message || '加载失败')
+    }
+  } catch (error: any) {
+    console.error('加载文件失败:', error)
+    ElMessage.error(`加载失败: ${error.message}`)
+  } finally {
+    editorStore.setLoading(false)
+  }
+}
+
+async function handleReset() {
+  try {
+    await ElMessageBox.confirm(
+      '确定要重新选择文件吗?当前编辑内容将丢失。',
+      '提示',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }
+    )
+    
+    editorStore.reset()
+    historyStore.clear()
+  } catch {
+    // 用户取消
+  }
+}
+
+async function handleReanalyze() {
+  if (!editorStore.imageSrc || !editorStore.ocrData) {
+    ElMessage.warning('OCR 数据不可用,无法重新分析')
+    return
+  }
+  
+  try {
+    await ElMessageBox.confirm(
+      '确定要重新分析吗?当前编辑内容将被覆盖。',
+      '提示',
+      {
+        confirmButtonText: '确定',
+        cancelButtonText: '取消',
+        type: 'warning'
+      }
+    )
+    
+    isReanalyzing.value = true
+    
+    const response = await reanalyzeStructure({
+      ocr_data: editorStore.ocrData,
+      image_size: editorStore.imageSize,
+      options: {
+        method: 'cell_index'  // 使用默认分析方法
+      }
+    })
+    
+    if (response.success && response.structure) {
+      editorStore.updateStructure(response.structure)
+      historyStore.clear()  // 清空历史
+      ElMessage.success('重新分析完成')
+    } else {
+      ElMessage.error(response.message || '分析失败')
+    }
+  } catch (error) {
+    if (error !== 'cancel') {
+      console.error('重新分析失败:', error)
+      ElMessage.error('重新分析失败')
+    }
+  } finally {
+    isReanalyzing.value = false
+  }
+}
+
+// === 批量模式功能 ===
+
+// 跳转到上一个文件
+function goToPrevFile() {
+  if (templateStore.hasPrevPair) {
+    templateStore.prevPair()
+    const pair = templateStore.currentFilePair
+    if (pair) {
+      handleLoadFile({ imagePath: pair.image_path, jsonPath: pair.json_path })
+    }
+  }
+}
+
+// 跳转到下一个文件
+function goToNextFile() {
+  if (templateStore.hasNextPair) {
+    templateStore.nextPair()
+    const pair = templateStore.currentFilePair
+    if (pair) {
+      handleLoadFile({ imagePath: pair.image_path, jsonPath: pair.json_path })
+    }
+  }
+}
+
+// 应用选中的模板
+async function applySelectedTemplate() {
+  const templateName = templateStore.selectedTemplateName
+  if (!templateName || !editorStore.hasData) {
+    return
+  }
+  
+  try {
+    const structure = await templateApi.previewApplyTemplate(
+      templateName,
+      editorStore.imageSize,
+      editorStore.tableBbox,
+      'relative'
+    )
+    editorStore.updateStructure(structure)
+    ElMessage.success(`模板 "${templateName}" 已应用`)
+  } catch (error: any) {
+    ElMessage.error(`应用模板失败: ${error.message}`)
+  }
+}
+
+// 保存当前文件并继续下一个
+async function saveAndNext() {
+  if (!editorStore.hasData || !editorStore.imageSrc) {
+    ElMessage.warning('没有可保存的数据')
+    return
+  }
+  
+  try {
+    // 保存当前文件
+    const filename = editorStore.suggestedFilename || 'table'
+    // 从 base64 图片中提取数据
+    const imageBase64 = editorStore.imageSrc.includes(',') 
+      ? editorStore.imageSrc.split(',')[1] 
+      : editorStore.imageSrc
+    
+    const response = await saveResult({
+      filename,
+      structure: editorStore.structure,
+      image_base64: imageBase64,
+      output_dir: templateStore.scanConfig.outputDir || './output'
+    })
+    
+    if (response.success) {
+      ElMessage.success('保存成功')
+      
+      // 更新当前文件对的状态为已标注
+      templateStore.updatePairStatus(
+        templateStore.currentPairIndex,
+        true,
+        response.structure_path || null
+      )
+      
+      // 跳转到下一个文件
+      if (templateStore.hasNextPair) {
+        goToNextFile()
+        
+        // 如果设置了自动应用模板
+        if (templateStore.autoApplyTemplate && templateStore.selectedTemplateName) {
+          // 等待文件加载完成后应用模板
+          setTimeout(() => {
+            applySelectedTemplate()
+          }, 500)
+        }
+      } else {
+        ElMessage.info('已完成所有文件的标注')
+      }
+    } else {
+      ElMessage.error(response.message || '保存失败')
+    }
+  } catch (error: any) {
+    ElMessage.error(`保存失败: ${error.message}`)
+  }
+}
+</script>
+
+<style scoped>
+.editor-view {
+  width: 100%;
+  height: 100vh;
+  display: flex;
+  flex-direction: column;
+  position: relative;
+}
+
+.upload-container {
+  display: flex;
+  width: 100%;
+  height: 100%;
+}
+
+.upload-section {
+  flex: 1;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  border-right: 1px solid #e4e7ed;
+}
+
+.template-section {
+  width: 400px;
+  padding: 20px;
+  overflow-y: auto;
+  background: #fafafa;
+}
+
+.template-section h3 {
+  margin: 0 0 16px 0;
+  color: #303133;
+}
+
+.main-content {
+  flex: 1;
+  display: flex;
+  overflow: hidden;
+}
+
+.canvas-area {
+  flex: 1;
+  position: relative;
+  overflow: hidden;
+}
+
+.side-panel {
+  width: 320px;
+  border-left: 1px solid #e4e7ed;
+  display: flex;
+  flex-direction: column;
+  background: #fff;
+  overflow-y: auto;
+}
+
+.side-panel :deep(.el-tabs__content) {
+  padding: 0;
+}
+
+.top-bar {
+  position: absolute;
+  top: 12px;
+  right: 332px;
+  display: flex;
+  gap: 8px;
+  z-index: 100;
+}
+
+.batch-nav-bar {
+  position: fixed;
+  bottom: 0;
+  left: 0;
+  right: 320px;
+  background: #fff;
+  border-top: 1px solid #e4e7ed;
+  padding: 8px 16px;
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  z-index: 100;
+  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
+}
+
+.nav-info {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.nav-info .file-name {
+  flex: 1;
+  font-size: 13px;
+  color: #606266;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.nav-actions {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+}
+
+.nav-shortcuts {
+  border-top: 1px dashed #e4e7ed;
+  padding-top: 6px;
+}
+
+.shortcut-hint {
+  font-size: 11px;
+  color: #909399;
+}
+</style>

+ 1 - 0
table_line_generator/frontend/src/views/index.ts

@@ -0,0 +1 @@
+export { default as EditorView } from './EditorView.vue'

+ 25 - 0
table_line_generator/frontend/tsconfig.json

@@ -0,0 +1,25 @@
+{
+  "compilerOptions": {
+    "target": "ES2020",
+    "useDefineForClassFields": true,
+    "module": "ESNext",
+    "lib": ["ES2020", "DOM", "DOM.Iterable"],
+    "skipLibCheck": true,
+    "moduleResolution": "bundler",
+    "allowImportingTsExtensions": true,
+    "resolveJsonModule": true,
+    "isolatedModules": true,
+    "noEmit": true,
+    "jsx": "preserve",
+    "strict": true,
+    "noUnusedLocals": true,
+    "noUnusedParameters": true,
+    "noFallthroughCasesInSwitch": true,
+    "paths": {
+      "@/*": ["./src/*"]
+    },
+    "baseUrl": "."
+  },
+  "include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.vue"],
+  "references": [{ "path": "./tsconfig.node.json" }]
+}

+ 11 - 0
table_line_generator/frontend/tsconfig.node.json

@@ -0,0 +1,11 @@
+{
+  "compilerOptions": {
+    "composite": true,
+    "skipLibCheck": true,
+    "module": "ESNext",
+    "moduleResolution": "bundler",
+    "allowSyntheticDefaultImports": true,
+    "strict": true
+  },
+  "include": ["vite.config.ts"]
+}

+ 22 - 0
table_line_generator/frontend/vite.config.ts

@@ -0,0 +1,22 @@
+import { defineConfig } from 'vite'
+import vue from '@vitejs/plugin-vue'
+import { resolve } from 'path'
+
+// https://vitejs.dev/config/
+export default defineConfig({
+  plugins: [vue()],
+  resolve: {
+    alias: {
+      '@': resolve(__dirname, 'src')
+    }
+  },
+  server: {
+    port: 5173,
+    proxy: {
+      '/api': {
+        target: 'http://localhost:8000',
+        changeOrigin: true
+      }
+    }
+  }
+})