diff --git a/plugins/link-relay/.gitignore b/plugins/link-relay/.gitignore new file mode 100644 index 000000000..05388a57f --- /dev/null +++ b/plugins/link-relay/.gitignore @@ -0,0 +1,32 @@ +# Dependencies +node_modules/ +*/node_modules/ + +# Build output (regenerated by build, but included in plugin package under src-ztools/dist) +dist/ +!src-ztools/dist/ + +# Logs +*.log +npm-debug.log* +pnpm-debug.log* + +# Editor +.vscode/ +.idea/ +*.swp +*.swo + +# OS +.DS_Store +Thumbs.db + +# Environment +.env +.env.local +.env.*.local + +# Temporary +*.tmp +*.bak +.cache/ diff --git a/plugins/link-relay/AGENTS.md b/plugins/link-relay/AGENTS.md new file mode 100644 index 000000000..987e851f8 --- /dev/null +++ b/plugins/link-relay/AGENTS.md @@ -0,0 +1,118 @@ +# 目录迁移助手 (link-relay) + +在 ZTools 中将任意目录(IDE 扩展/配置、缓存、用户数据等)从系统盘迁移到其他磁盘,通过 Junction/符号链接保持程序无感知运行。出厂 IDE 目录**预设**外置在 `preload/config/presets.json`(仅模板,可整组停用,无图标字段),自定义目录与预设使用同一套「源→目标+链接」模型,统一在单页表格中管理。**不做**多 IDE 插件共享。 + +## 技术栈 + +- **语言**: TypeScript 5.3+(strict,禁 `any`,仅 ` + + diff --git a/plugins/link-relay/package.json b/plugins/link-relay/package.json new file mode 100644 index 000000000..918d57ef3 --- /dev/null +++ b/plugins/link-relay/package.json @@ -0,0 +1,23 @@ +{ + "name": "link-relay", + "version": "1.0.0", + "description": "ZTools 插件:将任意目录(IDE 扩展/配置、缓存、用户数据等)从系统盘迁移到其他磁盘,通过 Junction/符号链接保持程序无感知运行;内置常见 IDE 目录预设,支持自定义目录迁移与链接重建。", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc && vite build" + }, + "dependencies": { + "pinia": "^4.0.3", + "uuid": "^14.0.2", + "vue": "^3.5.13" + }, + "devDependencies": { + "@vitejs/plugin-vue": "^5.2.1", + "@ztools-center/ztools-api-types": "^1.0.1", + "sass": "^1.103.1", + "typescript": "^5.3.0", + "vite": "^6.0.11", + "vue-tsc": "^2.2.12" + } +} diff --git a/plugins/link-relay/pnpm-lock.yaml b/plugins/link-relay/pnpm-lock.yaml new file mode 100644 index 000000000..24868e309 --- /dev/null +++ b/plugins/link-relay/pnpm-lock.yaml @@ -0,0 +1,1241 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + pinia: + specifier: ^4.0.3 + version: 4.0.3(@vue/devtools-api@8.2.1)(typescript@5.3.2)(vue@3.5.42(typescript@5.3.2)) + uuid: + specifier: ^14.0.2 + version: 14.0.2 + vue: + specifier: ^3.5.13 + version: 3.5.42(typescript@5.3.2) + devDependencies: + '@vitejs/plugin-vue': + specifier: ^5.2.1 + version: 5.2.1(vite@6.4.3(sass@1.103.1))(vue@3.5.42(typescript@5.3.2)) + '@ztools-center/ztools-api-types': + specifier: ^1.0.1 + version: 1.0.3 + sass: + specifier: ^1.103.1 + version: 1.103.1 + typescript: + specifier: ^5.3.0 + version: 5.3.2 + vite: + specifier: ^6.0.11 + version: 6.4.3(sass@1.103.1) + vue-tsc: + specifier: ^2.2.12 + version: 2.2.12(typescript@5.3.2) + +packages: + + '@babel/helper-string-parser@7.29.7': + resolution: {integrity: sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.29.7': + resolution: {integrity: sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.29.8': + resolution: {integrity: sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.29.8': + resolution: {integrity: sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==} + engines: {node: '>=6.9.0'} + + '@esbuild/aix-ppc64@0.25.0': + resolution: {integrity: sha512-O7vun9Sf8DFjH2UtqK8Ku3LkquL9SZL8OLY1T5NZkA34+wG3OQF7cl4Ql8vdNzM6fzBbYfLaiRLIOZ+2FOCgBQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.25.0': + resolution: {integrity: sha512-grvv8WncGjDSyUBjN9yHXNt+cq0snxXbDxy5pJtzMKGmmpPxeAmAhWxXI+01lU5rwZomDgD3kJwulEnhTRUd6g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.25.0': + resolution: {integrity: sha512-PTyWCYYiU0+1eJKmw21lWtC+d08JDZPQ5g+kFyxP0V+es6VPPSUhM6zk8iImp2jbV6GwjX4pap0JFbUQN65X1g==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.25.0': + resolution: {integrity: sha512-m/ix7SfKG5buCnxasr52+LI78SQ+wgdENi9CqyCXwjVR2X4Jkz+BpC3le3AoBPYTC9NHklwngVXvbJ9/Akhrfg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.25.0': + resolution: {integrity: sha512-mVwdUb5SRkPayVadIOI78K7aAnPamoeFR2bT5nszFUZ9P8UpK4ratOdYbZZXYSqPKMHfS1wdHCJk1P1EZpRdvw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.25.0': + resolution: {integrity: sha512-DgDaYsPWFTS4S3nWpFcMn/33ZZwAAeAFKNHNa1QN0rI4pUjgqf0f7ONmXf6d22tqTY+H9FNdgeaAa+YIFUn2Rg==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.25.0': + resolution: {integrity: sha512-VN4ocxy6dxefN1MepBx/iD1dH5K8qNtNe227I0mnTRjry8tj5MRk4zprLEdG8WPyAPb93/e4pSgi1SoHdgOa4w==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.25.0': + resolution: {integrity: sha512-mrSgt7lCh07FY+hDD1TxiTyIHyttn6vnjesnPoVDNmDfOmggTLXRv8Id5fNZey1gl/V2dyVK1VXXqVsQIiAk+A==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.25.0': + resolution: {integrity: sha512-9QAQjTWNDM/Vk2bgBl17yWuZxZNQIF0OUUuPZRKoDtqF2k4EtYbpyiG5/Dk7nqeK6kIJWPYldkOcBqjXjrUlmg==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.25.0': + resolution: {integrity: sha512-vkB3IYj2IDo3g9xX7HqhPYxVkNQe8qTK55fraQyTzTX/fxaDtXiEnavv9geOsonh2Fd2RMB+i5cbhu2zMNWJwg==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.25.0': + resolution: {integrity: sha512-43ET5bHbphBegyeqLb7I1eYn2P/JYGNmzzdidq/w0T8E2SsYL1U6un2NFROFRg1JZLTzdCoRomg8Rvf9M6W6Gg==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.25.0': + resolution: {integrity: sha512-fC95c/xyNFueMhClxJmeRIj2yrSMdDfmqJnyOY4ZqsALkDrrKJfIg5NTMSzVBr5YW1jf+l7/cndBfP3MSDpoHw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.25.0': + resolution: {integrity: sha512-nkAMFju7KDW73T1DdH7glcyIptm95a7Le8irTQNO/qtkoyypZAnjchQgooFUDQhNAy4iu08N79W4T4pMBwhPwQ==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.25.0': + resolution: {integrity: sha512-NhyOejdhRGS8Iwv+KKR2zTq2PpysF9XqY+Zk77vQHqNbo/PwZCzB5/h7VGuREZm1fixhs4Q/qWRSi5zmAiO4Fw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.25.0': + resolution: {integrity: sha512-5S/rbP5OY+GHLC5qXp1y/Mx//e92L1YDqkiBbO9TQOvuFXM+iDqUNG5XopAnXoRH3FjIUDkeGcY1cgNvnXp/kA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.25.0': + resolution: {integrity: sha512-XM2BFsEBz0Fw37V0zU4CXfcfuACMrppsMFKdYY2WuTS3yi8O1nFOhil/xhKTmE1nPmVyvQJjJivgDT+xh8pXJA==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.25.0': + resolution: {integrity: sha512-9yl91rHw/cpwMCNytUDxwj2XjFpxML0y9HAOH9pNVQDpQrBxHy01Dx+vaMu0N1CKa/RzBD2hB4u//nfc+Sd3Cw==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-arm64@0.25.0': + resolution: {integrity: sha512-RuG4PSMPFfrkH6UwCAqBzauBWTygTvb1nxWasEJooGSJ/NwRw7b2HOwyRTQIU97Hq37l3npXoZGYMy3b3xYvPw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [netbsd] + + '@esbuild/netbsd-x64@0.25.0': + resolution: {integrity: sha512-jl+qisSB5jk01N5f7sPCsBENCOlPiS/xptD5yxOx2oqQfyourJwIKLRA2yqWdifj3owQZCL2sn6o08dBzZGQzA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-arm64@0.25.0': + resolution: {integrity: sha512-21sUNbq2r84YE+SJDfaQRvdgznTD8Xc0oc3p3iW/a1EVWeNj/SdUCbm5U0itZPQYRuRTW20fPMWMpcrciH2EJw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + + '@esbuild/openbsd-x64@0.25.0': + resolution: {integrity: sha512-2gwwriSMPcCFRlPlKx3zLQhfN/2WjJ2NSlg5TKLQOJdV0mSxIcYNTMhk3H3ulL/cak+Xj0lY1Ym9ysDV1igceg==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.25.0': + resolution: {integrity: sha512-bxI7ThgLzPrPz484/S9jLlvUAHYMzy6I0XiU1ZMeAEOBcS0VePBFxh1JjTQt3Xiat5b6Oh4x7UC7IwKQKIJRIg==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.25.0': + resolution: {integrity: sha512-ZUAc2YK6JW89xTbXvftxdnYy3m4iHIkDtK3CLce8wg8M2L+YZhIvO1DKpxrd0Yr59AeNNkTiic9YLf6FTtXWMw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.25.0': + resolution: {integrity: sha512-eSNxISBu8XweVEWG31/JzjkIGbGIJN/TrRoiSVZwZ6pkC6VX4Im/WV2cz559/TXLcYbcrDN8JtKgd9DJVIo8GA==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.25.0': + resolution: {integrity: sha512-ZENoHJBxA20C2zFzh6AI4fT6RraMzjYw4xKWemRTRmRVtN9c5DcH9r/f2ihEkMjOW5eGgrwCslG/+Y/3bL+DHQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + + '@jridgewell/sourcemap-codec@1.5.5': + resolution: {integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + resolution: {integrity: sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==} + engines: {node: ^22.20 || ^24.12 || >=25} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-android-arm64@2.6.0': + resolution: {integrity: sha512-trgpLSCKRC/huFjXX/Smh+0sWe4+YtKfktIToiMl59ghz7z+qkH6kMvNnUbLyRs9N11t8l4svSCs1+5B3rOAhA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [android] + + '@parcel/watcher-darwin-arm64@2.6.0': + resolution: {integrity: sha512-Y3QV0gl7Q1zbfueunkWIERICbEojQFCgpyG7YqOGNFLsckXyI1xu9mAIUpKY9QBYzBtSkN8dBPwd3yiAO9ovMw==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [darwin] + + '@parcel/watcher-darwin-x64@2.6.0': + resolution: {integrity: sha512-Ohv6OpzhUfKYD7Beb8kDvG0jbIxORCYY1JRdZnaBtnjjkJxgD7ZVL0nw2sCYd0yTMKTvz3nnTnOF3cDifK+kvw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [darwin] + + '@parcel/watcher-freebsd-x64@2.6.0': + resolution: {integrity: sha512-5HmXvDgs8VK+74jF9y9/2FE3/OnlcKmc56tjmSrEuZjpSZOGL+fvAu+HKJBdPs9uwoP2hE6TlSUpXZ/C5jUFmQ==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [freebsd] + + '@parcel/watcher-linux-arm-glibc@2.6.0': + resolution: {integrity: sha512-Ps/hui3A+vMbjdqlqAowK2ZL8+BO8dBjxeWXj6npTBs3jx4wWmbPpaLuqwrQrSqIVMCnpWo238bJ1U37GhQOYg==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm-musl@2.6.0': + resolution: {integrity: sha512-9c6AUHgHoG+IY88MRIHupztQiQnrbqHYQjkM2btA+Bf/wQnQMuiD0Wfk1EVv3TlNT3x41uU71rn6E4xh/+zvkw==} + engines: {node: '>= 10.0.0'} + cpu: [arm] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + resolution: {integrity: sha512-yHRqS2owEXe6Hic9z6Mh1ECsCd+ODVOGvZDyciqRd21+v+o+DnXMOrw50DSpIG2sb8GPEaPPmfeCAWKPJdq46g==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-arm64-musl@2.6.0': + resolution: {integrity: sha512-WhB2e/V7rqdHHWZusBSPuy5Ei8S6lSz6FE5TKKQz5h3a0O+C+mhY7vxU9b/stqvMb8beLnPY82ZrFTLKs+SrKA==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@parcel/watcher-linux-x64-glibc@2.6.0': + resolution: {integrity: sha512-ulGE6x6Oz6iAwg75T8YQSoguBWasniIbX+QWpaYPcCnDOpdWX3k+4xbEYPZVLxOuoJI+svJJPD3sEj8G7lrQ3A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@parcel/watcher-linux-x64-musl@2.6.0': + resolution: {integrity: sha512-tkBYKt7YQrjIJWYDnto2YgO8MRkjlMTSNoRHzsXinBqbLdeOM3L32wPZJvIZxqaLMfSlS/4sUjH/6STVP/XDLw==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@parcel/watcher-win32-arm64@2.6.0': + resolution: {integrity: sha512-gIZAP23jaHjGWasY/TY6yL7NHFClf0Ga7FN+iINvk+KN94rhm94lYZhFsbYFNcA04/onvGD9kKmiJLJB2HbNwQ==} + engines: {node: '>= 10.0.0'} + cpu: [arm64] + os: [win32] + + '@parcel/watcher-win32-x64@2.6.0': + resolution: {integrity: sha512-cA+/pXV2YkfxlIcXOQ5fSWqAzzPyD78/x5qbK/I0vUkrlYHA8TIz+MXjAbGouguKVSI4bOmkTSJ1/poVSsgt+A==} + engines: {node: '>= 10.0.0'} + cpu: [x64] + os: [win32] + + '@parcel/watcher@2.6.0': + resolution: {integrity: sha512-7FNeNl8NCE7aINx7WXiKQrPYZWC/hvrTsmk6zmxbI7LTXE7hVek/n8AfVgpe2y82zl3w0HvCHN0bVKMBoJcC0w==} + engines: {node: '>= 10.0.0'} + + '@rollup/rollup-android-arm-eabi@4.63.0': + resolution: {integrity: sha512-70TeIFezKKy65LgAVyQh+w94/gjWhvPWaLaGGeMEgVrPkQhuj/M5bAYYZzIFUj9Y69oHyTm5Um/R6gcLh4A8JA==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.63.0': + resolution: {integrity: sha512-YC86tYIHK6M1IV+wbzO+Bxk8RCBr6ZyWYgWxUCzaZD8mc8rrFoIJDNzDrkHBYRc/wKdrsIXmm6/F7NzrAO+OrA==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.63.0': + resolution: {integrity: sha512-oI+ECtUcli0y0fi4xpW82GdPIXdTkI8G8DSjG2LRuw09fPAGykaWYH/hXxiKuTxiAjiPSTIIuYUqof5Z2hShWw==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.63.0': + resolution: {integrity: sha512-NwV+1s7TiKrMe4owHyKB/dTLD7ZJD0YEBEhIz+hvav1Cu1GReJjF+rsdNwjzENQeIAbE/CoNiaAc5Vz2h5DPAA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.63.0': + resolution: {integrity: sha512-tWtHBTu5gOPK4u4Urtk4qAHW3zZ9rQAmbssO8gp7ELvGTGI3aCiq6NqyTQ0PCIg7KbHJF2UkGDDs77YZGxfjCA==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.63.0': + resolution: {integrity: sha512-2qPoJiwTvtHQ27NnYvTnsgk8laXWYuVmNESG8WFZBcEPKLfZ3I27qBJarjVRQtwGeYyRfq5ZowHXih9lm2BItw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + resolution: {integrity: sha512-FQwsTRvLNuHoTdICABJQfbPUSEueISGmnpT06tXTMpfprf5NiKLSXKA0A+w45wJnCmZAnzgqBwbt6ARFuyOi5w==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + resolution: {integrity: sha512-BBVTXziw8mY1a4ZbWME9tZyfzqXCDPqaC7Z3heQ29p5dkvXzwL0NwelO8zLa8c3RBKvl3YTuSnBgsBhYBtwjIw==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + resolution: {integrity: sha512-w2Iyy9+RqKwx3d9qWMKsJg0FfRBsY0/pXNv0mCQ3ueRvJI6+QAScfD4nrMlzFLs2HNVW6Ew+mtZfDl9b7Ew5/Q==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.63.0': + resolution: {integrity: sha512-YK++KtrFRHYE0P6/RtYEAy9t8F37znP+K03RrIuLPYOL6SVlObRumf/0OE4V/h63xL9DwkWbNssZfmA9hawuDA==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + resolution: {integrity: sha512-aBfOG6fP7YkkPmTqPwufRJeFyz7WPpECv9XNbnsk9+vg7rxdih0lbtEel7jcRng4LZrrmU3FfitCFyEj4BWDWg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-loong64-musl@4.63.0': + resolution: {integrity: sha512-LGaHEOeHNAag9VuS1Crs5DFg4RrU9MPi2nVnNJk9DTePx/B6RRYKVmrIXt2h7YOJlwjaFJ6lwtFDliZxScTLrQ==} + cpu: [loong64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + resolution: {integrity: sha512-jClvk+J0FC3b7Udvegiw5/4hErbHtmsNsQgENnKXDWtNCJXsJYZH5WURvu7imDOO38xYml24eeh5x3A04ppwCw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + resolution: {integrity: sha512-0OJlaGK+8+B777Ql5okIpD7ua5Ro9+VB9Ve0OKa28OQJZ1RbuUBVNHK/e3pr4BROqsyPl1JrPO1ZxJseCNffcA==} + cpu: [ppc64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + resolution: {integrity: sha512-Ygsx+HoNH7afwi1bTIXbnTvVnsO+zurPLSYxybV1hHFVU72OWOCl6v05ql/z0hkpAPx+DK7Kn9Bi7MayCcjLTA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + resolution: {integrity: sha512-pDQxtMGb+OvG3fLwR2OkZlSd47hW+kWg4BYMG/++sR6RqorQccwPTDsxda5hPwiIeIErAnCF9ma3SAU06bdQtQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + resolution: {integrity: sha512-0BnUG9mS8I4SSHr3XsxVhuCMEiu+rX61xxZF5vujso4LaiAGFZFxvDjg6Xn6tLPNTUAfuCvQYas4LMQMVsKRSQ==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.63.0': + resolution: {integrity: sha512-Adu/VttB1dpPNW+FEacrZ+xVm9tFty84+RrFzsqlFaPxoJB+9XXyDGtp5dCOoBwGBIEVH0To7lExFXEx0BIF4A==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.63.0': + resolution: {integrity: sha512-NQ3bDvjUbFKmP23671xUlXtKmqVsUBd6M4PQCvbmNtOy06hnQIdKHy8oG/6S3R/S6He1JgPk6A5VT+prAJMYEw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-openbsd-x64@4.63.0': + resolution: {integrity: sha512-u2eDAl4+0aFvA13GxlGBtTI3SS3sdgwgtV0HyjZ0QaQVCgNE+jqNGey+GtxWiq+wxr/UycAx/OnfJzApCFamvA==} + cpu: [x64] + os: [openbsd] + + '@rollup/rollup-openharmony-arm64@4.63.0': + resolution: {integrity: sha512-XvRb5vfW3wAZQ+ZUG21AnHHDKtNcw99eigzEhjr//NZ3u7SoBaPP0seSc7FgP7p1epAEdAoZckMW9WY/+4w70w==} + cpu: [arm64] + os: [openharmony] + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + resolution: {integrity: sha512-iZPmniy4kNBf5yo2RezbkYNNK5HPbXE9+g+twnbqSng7dtLEJy1SKoxiE/ni4FDacjyuZpEeb9U054N4EoKHYw==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + resolution: {integrity: sha512-mFBBd+LF37fnE8JnYUOH+imj0aPFPK30vpar4ehJkgnLj9sZn8ZxiRENmLtgIwxK7TC8klF6N57fxdNBwQoqOA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-gnu@4.63.0': + resolution: {integrity: sha512-ujeqEY3B+zbGn3Z4Q03cUBG/LGWnBJncVT36WER31LcOsQk9+1dmINKKtvmmfChUvRbK1G0R8OhMWFgHgaZtAw==} + cpu: [x64] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.63.0': + resolution: {integrity: sha512-hncn90N4sOky0L2LKE5oESKLbxCPeVo4eLA2LSMoDzM+879ml4WSr+Rr4DWknNIVVvS1Hirkc9hx02W6YxS8rQ==} + cpu: [x64] + os: [win32] + + '@types/estree@1.0.9': + resolution: {integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==} + + '@vitejs/plugin-vue@5.2.1': + resolution: {integrity: sha512-cxh314tzaWwOLqVes2gnnCtvBDcM1UMdn+iFR+UjAn411dPT3tOmqrJjbMd7koZpMAmBM/GqeV4n9ge7JSiJJQ==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@volar/language-core@2.4.15': + resolution: {integrity: sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA==} + + '@volar/source-map@2.4.15': + resolution: {integrity: sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg==} + + '@volar/typescript@2.4.15': + resolution: {integrity: sha512-2aZ8i0cqPGjXb4BhkMsPYDkkuc2ZQ6yOpqwAuNwUoncELqoy5fRgOQtLR9gB0g902iS0NAkvpIzs27geVyVdPg==} + + '@vue/compiler-core@3.5.42': + resolution: {integrity: sha512-2Ye1ilMtKXxl8qZUrQ5j0CdgenFp/HFQmta6rfRyfEsTG69L6Wk+tWuNoHYHMx9E8tF2Slvdg1FuwDvAXdy1LQ==} + + '@vue/compiler-dom@3.5.42': + resolution: {integrity: sha512-qbhQZEFmycr+ni/qyuccS4sucNN7VAbDfbkvNxWOX2VfgFm90MNs3/UhRNKoPMEIVn0F8gdlYjLPvqxHwHeQOA==} + + '@vue/compiler-sfc@3.5.42': + resolution: {integrity: sha512-fkCAFB4okcAANGMThboWnScp/gzWjU0ZSkVnjTIiplmMDq2uq0tIB3j+xVu4rhv5rvOgBySCysudmbMd6xRRqw==} + + '@vue/compiler-ssr@3.5.42': + resolution: {integrity: sha512-xmLk3wLkbizPAiLyomjgFFosf2ys9b5Ghb+oh/k2tnvipNz8OFrQOiTcWCzyK7MpBp9KkyGtfvgfLUivbmuGYA==} + + '@vue/compiler-vue2@2.7.16': + resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} + + '@vue/devtools-api@8.2.1': + resolution: {integrity: sha512-6u4vXBlIBAC1wMplIZgpyPn7uh/s4Bf6F5bMzvLv+EdJ0aHs/+4B7Ygv864EStQSjRbsRzTko/kUG1A1IejQ3A==} + + '@vue/devtools-kit@8.2.1': + resolution: {integrity: sha512-FIGIuq3AWReEpbAHY/cRGeHDfI0qOb8OCQ3YjbEAX04uaxIDbGc9rhkbVcG7rnfHPXE3RsU5KrWOu9V/okd8AQ==} + + '@vue/devtools-shared@8.2.1': + resolution: {integrity: sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==} + + '@vue/language-core@2.2.12': + resolution: {integrity: sha512-IsGljWbKGU1MZpBPN+BvPAdr55YPkj2nB/TBNGNC32Vy2qLG25DYu/NBN2vNtZqdRbTRjaoYrahLrToim2NanA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + '@vue/reactivity@3.5.42': + resolution: {integrity: sha512-TzNNfKpb7hDxbQltwAut8VDQA5YP+BuRlxntHUuRjyKwlMvmAPbs3unhCvieijifY6vFfVBwsS7wG/C7uq+bEQ==} + + '@vue/runtime-core@3.5.42': + resolution: {integrity: sha512-9uACtuHs7vJGkm5Bp3xu4xRDLFTIYy5DgxpToVjqGIAhAEKwQfsaLvKINhM6nFVp6bZPRFGdDqd1g52MqKsotA==} + + '@vue/runtime-dom@3.5.42': + resolution: {integrity: sha512-rsCmhiWLaRxGltLwhlCWyYkFn7WAbKRh0q17eZ1A6Dq6eqc2ACQ61IIryxz0LrsvCzHSilLA9JHovVwM8CNE2g==} + + '@vue/server-renderer@3.5.42': + resolution: {integrity: sha512-2++5dUyYS4gvo7xQXSECUDhB7TS0aOl5SeVfC5qSq1Jgfhjvegw1zqhwTIR3imZ+QYPJQw9gfcFvXGAjGZ7ajQ==} + + '@vue/shared@3.5.42': + resolution: {integrity: sha512-2rPxex1jQf4jvl9MOHl6YaXCPcrNqz/FstMOEh3QWY+/OME9nQTvl9WYeCwhW7AFjaR0SnngZGlp/wkR6rkI6g==} + + '@ztools-center/ztools-api-types@1.0.3': + resolution: {integrity: sha512-dv1eOAIasAupqKaQL/gESk1i2+RebdM/1gvZhrvH2D/bo3enCUsAGJ8nrHnlCLBSOGB81eC/SU0IH8BNsUlmvA==} + + alien-signals@1.0.3: + resolution: {integrity: sha512-zQOh3wAYK5ujENxvBBR3CFGF/b6afaSzZ/c9yNhJ1ENrGHETvpUuKQsa93Qrclp0+PzTF93MaZ7scVp1uUozhA==} + + balanced-match@1.0.0: + resolution: {integrity: sha512-9Y0g0Q8rmSt+H33DfKv7FOc3v+iRI+o1lbzt8jGcIosYW37IIW/2XVYq5NPdmaD5NQ59Nk26Kl/vZbwW9Fr8vg==} + + birpc@2.6.1: + resolution: {integrity: sha512-LPnFhlDpdSH6FJhJyn4M0kFO7vtQ5iPw24FnG0y21q09xC7e8+1LeR31S1MAIrDAHp4m7aas4bEkTDTvMAtebQ==} + + brace-expansion@2.1.4: + resolution: {integrity: sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==} + + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + + csstype@3.2.3: + resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==} + + de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + + detect-libc@2.1.2: + resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==} + engines: {node: '>=8'} + + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + esbuild@0.25.0: + resolution: {integrity: sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==} + engines: {node: '>=18'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + fdir@6.5.0: + resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==} + engines: {node: '>=12.0.0'} + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + immutable@5.1.9: + resolution: {integrity: sha512-m8nVez3rwrgmWxtLMt1ZYXB2Lv7OKYn/disyxAlSDYAlKSlFoPPfIAmAM/M5xqL4m4C/wAPw7S2/CNaUii1Hxg==} + + is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + + is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + + magic-string@0.30.21: + resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==} + + minimatch@9.0.9: + resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} + engines: {node: '>=16 || 14 >=14.17'} + + muggle-string@0.4.1: + resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + + nanoid@3.3.18: + resolution: {integrity: sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + node-addon-api@7.1.1: + resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} + + nostics@1.2.0: + resolution: {integrity: sha512-FGqEfhQjrvo1lL8KFifdTQiNwwQHJxC1jtYE1Rc54qF/jxONUNL+kC9gS1krX8Q65PgrQ5fCqH/I4NhWBvdSqg==} + + path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + picomatch@4.0.7: + resolution: {integrity: sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==} + engines: {node: '>=12'} + + pinia@4.0.3: + resolution: {integrity: sha512-XMQqpvjgG7LMqVhhFUzKLT4KEbsYbfZZ0CZU9PgdFm1O2VKBmIkykL8+SfNhOaT7sR0wT6BEgKT0YNDYWgmVRA==} + peerDependencies: + '@vue/devtools-api': ^8.1.5 + typescript: '>=5.6.0' + vue: ^3.5.11 + peerDependenciesMeta: + typescript: + optional: true + + postcss@8.5.26: + resolution: {integrity: sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ==} + engines: {node: ^10 || ^12 || >=14} + + readdirp@5.1.1: + resolution: {integrity: sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA==} + engines: {node: '>= 20.19.0'} + + rollup@4.63.0: + resolution: {integrity: sha512-T5vnZ2y4QqC3/4P+w2+JO+Q/OVdnPsv4XcSYJYMEn0R9/jjl5AgLwO9LAZMzP2lN71O6pypn91rB7lDstUkfrQ==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + sass@1.103.1: + resolution: {integrity: sha512-9icZURbP51S6S0QGoyaeqk9uB06GNWxsFYWfH5RgpFgqK5FA8tJcM3AdVxrZEVJ7dz+L87nG95gBKf4VuaMHGw==} + engines: {node: '>=20.19.0'} + hasBin: true + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + tinyglobby@0.2.17: + resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} + engines: {node: '>=12.0.0'} + + typescript@5.3.2: + resolution: {integrity: sha512-6l+RyNy7oAHDfxC4FzSJcz9vnjTKxrLpDG5M2Vu4SHRVNg6xzqZp6LYSR9zjqQTu8DU/f5xwxUdADOkbrIX2gQ==} + engines: {node: '>=14.17'} + hasBin: true + + uuid@14.0.2: + resolution: {integrity: sha512-xZe/16rV4aa+HGSOCiY2YeLT1OybRLrrkL/Rqaq7p7GMVXjFh+6wN4oMYgjFmnSnhY8t6Xpdl2l9qmnHYuMHwQ==} + hasBin: true + + vite@6.4.3: + resolution: {integrity: sha512-NTKlcQjlAK7MlQoyb6LgaqHc8sso/pVyUJYWMws3jg21uTJw/LddqIFPcPqP6PzpgbIcZyKI85sFE4HBrQDA8A==} + engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0 + jiti: '>=1.21.0' + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + '@types/node': + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vscode-uri@3.2.0: + resolution: {integrity: sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==} + + vue-tsc@2.2.12: + resolution: {integrity: sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw==} + hasBin: true + peerDependencies: + typescript: '>=5.0.0' + + vue@3.5.42: + resolution: {integrity: sha512-4RyHQTbQvOPs3MfvUO1Sg0YRrKNnA0mAVtvpd12Tg1fKDN7OHBUl1IqSn8zGJjK9nI3NkNp8cgTpVrSZC5TTcA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + +snapshots: + + '@babel/helper-string-parser@7.29.7': {} + + '@babel/helper-validator-identifier@7.29.7': {} + + '@babel/parser@7.29.8': + dependencies: + '@babel/types': 7.29.8 + + '@babel/types@7.29.8': + dependencies: + '@babel/helper-string-parser': 7.29.7 + '@babel/helper-validator-identifier': 7.29.7 + + '@esbuild/aix-ppc64@0.25.0': + optional: true + + '@esbuild/android-arm64@0.25.0': + optional: true + + '@esbuild/android-arm@0.25.0': + optional: true + + '@esbuild/android-x64@0.25.0': + optional: true + + '@esbuild/darwin-arm64@0.25.0': + optional: true + + '@esbuild/darwin-x64@0.25.0': + optional: true + + '@esbuild/freebsd-arm64@0.25.0': + optional: true + + '@esbuild/freebsd-x64@0.25.0': + optional: true + + '@esbuild/linux-arm64@0.25.0': + optional: true + + '@esbuild/linux-arm@0.25.0': + optional: true + + '@esbuild/linux-ia32@0.25.0': + optional: true + + '@esbuild/linux-loong64@0.25.0': + optional: true + + '@esbuild/linux-mips64el@0.25.0': + optional: true + + '@esbuild/linux-ppc64@0.25.0': + optional: true + + '@esbuild/linux-riscv64@0.25.0': + optional: true + + '@esbuild/linux-s390x@0.25.0': + optional: true + + '@esbuild/linux-x64@0.25.0': + optional: true + + '@esbuild/netbsd-arm64@0.25.0': + optional: true + + '@esbuild/netbsd-x64@0.25.0': + optional: true + + '@esbuild/openbsd-arm64@0.25.0': + optional: true + + '@esbuild/openbsd-x64@0.25.0': + optional: true + + '@esbuild/sunos-x64@0.25.0': + optional: true + + '@esbuild/win32-arm64@0.25.0': + optional: true + + '@esbuild/win32-ia32@0.25.0': + optional: true + + '@esbuild/win32-x64@0.25.0': + optional: true + + '@jridgewell/sourcemap-codec@1.5.5': {} + + '@napi-rs/lzma-linux-x64-gnu@1.5.1': + optional: true + + '@parcel/watcher-android-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-arm64@2.6.0': + optional: true + + '@parcel/watcher-darwin-x64@2.6.0': + optional: true + + '@parcel/watcher-freebsd-x64@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-arm64-musl@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-glibc@2.6.0': + optional: true + + '@parcel/watcher-linux-x64-musl@2.6.0': + optional: true + + '@parcel/watcher-win32-arm64@2.6.0': + optional: true + + '@parcel/watcher-win32-x64@2.6.0': + optional: true + + '@parcel/watcher@2.6.0': + dependencies: + detect-libc: 2.1.2 + is-glob: 4.0.3 + node-addon-api: 7.1.1 + picomatch: 4.0.7 + optionalDependencies: + '@parcel/watcher-android-arm64': 2.6.0 + '@parcel/watcher-darwin-arm64': 2.6.0 + '@parcel/watcher-darwin-x64': 2.6.0 + '@parcel/watcher-freebsd-x64': 2.6.0 + '@parcel/watcher-linux-arm-glibc': 2.6.0 + '@parcel/watcher-linux-arm-musl': 2.6.0 + '@parcel/watcher-linux-arm64-glibc': 2.6.0 + '@parcel/watcher-linux-arm64-musl': 2.6.0 + '@parcel/watcher-linux-x64-glibc': 2.6.0 + '@parcel/watcher-linux-x64-musl': 2.6.0 + '@parcel/watcher-win32-arm64': 2.6.0 + '@parcel/watcher-win32-x64': 2.6.0 + optional: true + + '@rollup/rollup-android-arm-eabi@4.63.0': + optional: true + + '@rollup/rollup-android-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.63.0': + optional: true + + '@rollup/rollup-darwin-x64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.63.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-loong64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-ppc64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.63.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.63.0': + optional: true + + '@rollup/rollup-openbsd-x64@4.63.0': + optional: true + + '@rollup/rollup-openharmony-arm64@4.63.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-gnu@4.63.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.63.0': + optional: true + + '@types/estree@1.0.9': {} + + '@vitejs/plugin-vue@5.2.1(vite@6.4.3(sass@1.103.1))(vue@3.5.42(typescript@5.3.2))': + dependencies: + vite: 6.4.3(sass@1.103.1) + vue: 3.5.42(typescript@5.3.2) + + '@volar/language-core@2.4.15': + dependencies: + '@volar/source-map': 2.4.15 + + '@volar/source-map@2.4.15': {} + + '@volar/typescript@2.4.15': + dependencies: + '@volar/language-core': 2.4.15 + path-browserify: 1.0.1 + vscode-uri: 3.2.0 + + '@vue/compiler-core@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/shared': 3.5.42 + entities: 7.0.1 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.42': + dependencies: + '@vue/compiler-core': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-sfc@3.5.42': + dependencies: + '@babel/parser': 7.29.8 + '@vue/compiler-core': 3.5.42 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-ssr': 3.5.42 + '@vue/shared': 3.5.42 + estree-walker: 2.0.2 + magic-string: 0.30.21 + postcss: 8.5.26 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.42': + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/compiler-vue2@2.7.16': + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + + '@vue/devtools-api@8.2.1': + dependencies: + '@vue/devtools-kit': 8.2.1 + + '@vue/devtools-kit@8.2.1': + dependencies: + '@vue/devtools-shared': 8.2.1 + birpc: 2.6.1 + hookable: 5.5.3 + perfect-debounce: 2.1.0 + + '@vue/devtools-shared@8.2.1': {} + + '@vue/language-core@2.2.12(typescript@5.3.2)': + dependencies: + '@volar/language-core': 2.4.15 + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-vue2': 2.7.16 + '@vue/shared': 3.5.42 + alien-signals: 1.0.3 + minimatch: 9.0.9 + muggle-string: 0.4.1 + path-browserify: 1.0.1 + optionalDependencies: + typescript: 5.3.2 + + '@vue/reactivity@3.5.42': + dependencies: + '@vue/shared': 3.5.42 + + '@vue/runtime-core@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/runtime-dom@3.5.42': + dependencies: + '@vue/reactivity': 3.5.42 + '@vue/runtime-core': 3.5.42 + '@vue/shared': 3.5.42 + csstype: 3.2.3 + + '@vue/server-renderer@3.5.42': + dependencies: + '@vue/compiler-ssr': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/shared': 3.5.42 + + '@vue/shared@3.5.42': {} + + '@ztools-center/ztools-api-types@1.0.3': {} + + alien-signals@1.0.3: {} + + balanced-match@1.0.0: {} + + birpc@2.6.1: {} + + brace-expansion@2.1.4: + dependencies: + balanced-match: 1.0.0 + + chokidar@5.0.0: + dependencies: + readdirp: 5.1.1 + + csstype@3.2.3: {} + + de-indent@1.0.2: {} + + detect-libc@2.1.2: + optional: true + + entities@7.0.1: {} + + esbuild@0.25.0: + optionalDependencies: + '@esbuild/aix-ppc64': 0.25.0 + '@esbuild/android-arm': 0.25.0 + '@esbuild/android-arm64': 0.25.0 + '@esbuild/android-x64': 0.25.0 + '@esbuild/darwin-arm64': 0.25.0 + '@esbuild/darwin-x64': 0.25.0 + '@esbuild/freebsd-arm64': 0.25.0 + '@esbuild/freebsd-x64': 0.25.0 + '@esbuild/linux-arm': 0.25.0 + '@esbuild/linux-arm64': 0.25.0 + '@esbuild/linux-ia32': 0.25.0 + '@esbuild/linux-loong64': 0.25.0 + '@esbuild/linux-mips64el': 0.25.0 + '@esbuild/linux-ppc64': 0.25.0 + '@esbuild/linux-riscv64': 0.25.0 + '@esbuild/linux-s390x': 0.25.0 + '@esbuild/linux-x64': 0.25.0 + '@esbuild/netbsd-arm64': 0.25.0 + '@esbuild/netbsd-x64': 0.25.0 + '@esbuild/openbsd-arm64': 0.25.0 + '@esbuild/openbsd-x64': 0.25.0 + '@esbuild/sunos-x64': 0.25.0 + '@esbuild/win32-arm64': 0.25.0 + '@esbuild/win32-ia32': 0.25.0 + '@esbuild/win32-x64': 0.25.0 + + estree-walker@2.0.2: {} + + fdir@6.5.0(picomatch@4.0.7): + optionalDependencies: + picomatch: 4.0.7 + + fsevents@2.3.3: + optional: true + + he@1.2.0: {} + + hookable@5.5.3: {} + + immutable@5.1.9: {} + + is-extglob@2.1.1: + optional: true + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + optional: true + + magic-string@0.30.21: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.5 + + minimatch@9.0.9: + dependencies: + brace-expansion: 2.1.4 + + muggle-string@0.4.1: {} + + nanoid@3.3.18: {} + + node-addon-api@7.1.1: + optional: true + + nostics@1.2.0: {} + + path-browserify@1.0.1: {} + + perfect-debounce@2.1.0: {} + + picocolors@1.1.1: {} + + picomatch@4.0.7: {} + + pinia@4.0.3(@vue/devtools-api@8.2.1)(typescript@5.3.2)(vue@3.5.42(typescript@5.3.2)): + dependencies: + '@vue/devtools-api': 8.2.1 + nostics: 1.2.0 + vue: 3.5.42(typescript@5.3.2) + optionalDependencies: + typescript: 5.3.2 + + postcss@8.5.26: + dependencies: + nanoid: 3.3.18 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + readdirp@5.1.1: {} + + rollup@4.63.0: + dependencies: + '@types/estree': 1.0.9 + optionalDependencies: + '@napi-rs/lzma-linux-x64-gnu': 1.5.1 + '@rollup/rollup-android-arm-eabi': 4.63.0 + '@rollup/rollup-android-arm64': 4.63.0 + '@rollup/rollup-darwin-arm64': 4.63.0 + '@rollup/rollup-darwin-x64': 4.63.0 + '@rollup/rollup-freebsd-arm64': 4.63.0 + '@rollup/rollup-freebsd-x64': 4.63.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.63.0 + '@rollup/rollup-linux-arm-musleabihf': 4.63.0 + '@rollup/rollup-linux-arm64-gnu': 4.63.0 + '@rollup/rollup-linux-arm64-musl': 4.63.0 + '@rollup/rollup-linux-loong64-gnu': 4.63.0 + '@rollup/rollup-linux-loong64-musl': 4.63.0 + '@rollup/rollup-linux-ppc64-gnu': 4.63.0 + '@rollup/rollup-linux-ppc64-musl': 4.63.0 + '@rollup/rollup-linux-riscv64-gnu': 4.63.0 + '@rollup/rollup-linux-riscv64-musl': 4.63.0 + '@rollup/rollup-linux-s390x-gnu': 4.63.0 + '@rollup/rollup-linux-x64-gnu': 4.63.0 + '@rollup/rollup-linux-x64-musl': 4.63.0 + '@rollup/rollup-openbsd-x64': 4.63.0 + '@rollup/rollup-openharmony-arm64': 4.63.0 + '@rollup/rollup-win32-arm64-msvc': 4.63.0 + '@rollup/rollup-win32-ia32-msvc': 4.63.0 + '@rollup/rollup-win32-x64-gnu': 4.63.0 + '@rollup/rollup-win32-x64-msvc': 4.63.0 + fsevents: 2.3.3 + + sass@1.103.1: + dependencies: + chokidar: 5.0.0 + immutable: 5.1.9 + source-map-js: 1.2.1 + optionalDependencies: + '@parcel/watcher': 2.6.0 + + source-map-js@1.2.1: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + + typescript@5.3.2: {} + + uuid@14.0.2: {} + + vite@6.4.3(sass@1.103.1): + dependencies: + esbuild: 0.25.0 + fdir: 6.5.0(picomatch@4.0.7) + picomatch: 4.0.7 + postcss: 8.5.26 + rollup: 4.63.0 + tinyglobby: 0.2.17 + optionalDependencies: + fsevents: 2.3.3 + sass: 1.103.1 + + vscode-uri@3.2.0: {} + + vue-tsc@2.2.12(typescript@5.3.2): + dependencies: + '@volar/typescript': 2.4.15 + '@vue/language-core': 2.2.12(typescript@5.3.2) + typescript: 5.3.2 + + vue@3.5.42(typescript@5.3.2): + dependencies: + '@vue/compiler-dom': 3.5.42 + '@vue/compiler-sfc': 3.5.42 + '@vue/runtime-dom': 3.5.42 + '@vue/server-renderer': 3.5.42 + '@vue/shared': 3.5.42 + optionalDependencies: + typescript: 5.3.2 diff --git a/plugins/link-relay/pnpm-workspace.yaml b/plugins/link-relay/pnpm-workspace.yaml new file mode 100644 index 000000000..06c115425 --- /dev/null +++ b/plugins/link-relay/pnpm-workspace.yaml @@ -0,0 +1,3 @@ +allowBuilds: + '@parcel/watcher': false + esbuild: true diff --git a/plugins/link-relay/public/logo.png b/plugins/link-relay/public/logo.png new file mode 100644 index 000000000..f195f8779 Binary files /dev/null and b/plugins/link-relay/public/logo.png differ diff --git a/plugins/link-relay/public/plugin.json b/plugins/link-relay/public/plugin.json new file mode 100644 index 000000000..5e3a5e3df --- /dev/null +++ b/plugins/link-relay/public/plugin.json @@ -0,0 +1,39 @@ +{ + "$schema": "node_modules/@ztools-center/ztools-api-types/resource/ztools.schema.json", + "name": "link-relay", + "title": "目录迁移助手", + "description": "将任意目录(IDE 扩展/配置、缓存、用户数据等)从系统盘迁移到其他磁盘,通过 Junction/符号链接保持程序无感知运行。内置常见 IDE 目录预设,支持自定义任意目录、系统重装一键重建链接、冲突处理与失败自动回滚。", + "author": "2029364173@qq.com", + "license": "MIT", + "version": "1.0.0", + "main": "dist/index.html", + "preload": "preload/services.js", + "logo": "logo.png", + "development": { + "main": "http://localhost:5173" + }, + "features": [ + { + "code": "dashboard", + "explain": "目录迁移助手:把系统盘目录迁移到其他磁盘并建立 Junction 链接,内置 IDE 预设,支持自定义任意目录迁移与链接重建", + "icon": "logo.png", + "cmds": [ + "目录迁移", + "ide迁移", + "vscode迁移", + "c盘清理", + "junction", + "符号链接", + "目录搬家", + "扩展迁移", + "设置迁移", + "缓存迁移" + ] + } + ], + "platform": [ + "darwin", + "win32", + "linux" + ] +} diff --git a/plugins/link-relay/public/preload/core/link-manager.js b/plugins/link-relay/public/preload/core/link-manager.js new file mode 100644 index 000000000..b75ebba01 --- /dev/null +++ b/plugins/link-relay/public/preload/core/link-manager.js @@ -0,0 +1,217 @@ +/** + * 链接管理模块 —— 检测目录链接状态、创建/删除 Junction/Symlink。 + * + * 状态检测逻辑: + * - not-installed: 源和目标都不存在 + * - target-only: 源不存在,目标存在(重装后场景) + * - not-linked: 源是实体目录,目标不存在 + * - linked: 源是链接且指向目标 + * - broken: 源是链接但指向错误,或目标是链接 + * - conflict: 源和目标都是实体目录 + */ + +const fs = require('node:fs') +const path = require('node:path') + +/** 检测目录是否存在 */ +function dirExists(dirPath) { + try { + return fs.existsSync(dirPath) + } catch { + return false + } +} + +/** 检测路径是否为符号链接/Junction */ +function isLink(dirPath) { + try { + const stat = fs.lstatSync(dirPath) + return stat.isSymbolicLink() + } catch { + return false + } +} + +/** 获取链接的真实目标路径 */ +function getRealTarget(dirPath) { + try { + return fs.realpathSync(dirPath) + } catch { + return null + } +} + +/** 规范化路径用于比较(解析大小写、分隔符差异) */ +function normalizePath(p) { + if (!p) return '' + try { + return path.resolve(p).toLowerCase().replace(/[\\/]+$/, '') + } catch { + return p.toLowerCase().replace(/[\\/]+$/, '') + } +} + +/** + * 检测单个目录的链接状态。 + * @param {string} sourcePath 源路径 + * @param {string} targetPath 目标路径 + * @returns {{ path: string, target: string, status: string, size: number, realTarget?: string, sourceMtime?: string, targetMtime?: string }} + */ +function checkDirStatus(sourcePath, targetPath) { + const sourceExists = dirExists(sourcePath) + const targetExists = dirExists(targetPath) + + const result = { + path: sourcePath, + target: targetPath, + status: 'unknown', + size: 0, + } + + // 两侧都不存在 + if (!sourceExists && !targetExists) { + result.status = 'not-installed' + return result + } + + // 源不存在,目标存在 → 重装后场景 + if (!sourceExists && targetExists) { + result.status = 'target-only' + result.size = getDirSizeSync(targetPath) + result.targetMtime = getMtime(targetPath) + return result + } + + // 源存在 + const sourceIsLink = isLink(sourcePath) + + if (sourceIsLink) { + const realTarget = getRealTarget(sourcePath) + result.realTarget = realTarget + const expectedReal = targetExists ? normalizePath(targetPath) : null + + if (realTarget && expectedReal && normalizePath(realTarget) === expectedReal) { + result.status = 'linked' + result.size = 0 // 链接本身不占源盘空间 + } else { + result.status = 'broken' + result.size = getDirSizeSync(sourcePath) + } + return result + } + + // 源是实体目录 + if (targetExists) { + const targetIsLink = isLink(targetPath) + if (targetIsLink) { + result.status = 'broken' // 目标是链接,异常 + } else { + result.status = 'conflict' // 两侧都是实体目录 + } + result.size = getDirSizeSync(sourcePath) + result.sourceMtime = getMtime(sourcePath) + result.targetMtime = getMtime(targetPath) + return result + } + + // 源是实体目录,目标不存在 → 未迁移 + result.status = 'not-linked' + result.size = getDirSizeSync(sourcePath) + result.sourceMtime = getMtime(sourcePath) + return result +} + +/** 获取目录大小(递归,同步) */ +function getDirSizeSync(dirPath) { + try { + if (!fs.existsSync(dirPath)) return 0 + let total = 0 + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + // 跳过链接目录,避免循环 + if (isLink(fullPath)) continue + total += getDirSizeSync(fullPath) + } else { + try { + total += fs.statSync(fullPath).size + } catch { + // 忽略无法读取的文件 + } + } + } + return total + } catch { + return 0 + } +} + +/** 获取目录最后修改时间 */ +function getMtime(dirPath) { + try { + return fs.statSync(dirPath).mtime.toISOString() + } catch { + return undefined + } +} + +/** + * 创建 Junction(Windows)或 Symlink(macOS/Linux)。 + * Windows 使用 junction 类型,无需管理员权限。 + */ +function createLink(targetPath, sourcePath) { + const platform = process.platform + const linkType = platform === 'win32' ? 'junction' : 'dir' + + // 确保源路径的父目录存在 + const parentDir = path.dirname(sourcePath) + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }) + } + + fs.symlinkSync(targetPath, sourcePath, linkType) +} + +/** 删除链接(不删除链接指向的真实数据) */ +function removeLink(linkPath) { + if (!isLink(linkPath)) return false + try { + fs.unlinkSync(linkPath) + return true + } catch { + return false + } +} + +/** 验证链接是否正常工作(写入测试文件) */ +function verifyLink(sourcePath, targetPath) { + try { + const testFile = path.join(sourcePath, `.link-test-${Date.now()}.tmp`) + fs.writeFileSync(testFile, 'verify', { encoding: 'utf-8' }) + + // 检查目标端是否可见 + const targetTestFile = path.join(targetPath, path.basename(testFile)) + const visible = fs.existsSync(targetTestFile) + + // 清理测试文件 + try { fs.unlinkSync(testFile) } catch { /* ignore */ } + + return visible + } catch { + return false + } +} + +module.exports = { + dirExists, + isLink, + getRealTarget, + normalizePath, + checkDirStatus, + getDirSizeSync, + getMtime, + createLink, + removeLink, + verifyLink, +} diff --git a/plugins/link-relay/public/preload/core/migration-engine.js b/plugins/link-relay/public/preload/core/migration-engine.js new file mode 100644 index 000000000..87436b5f2 --- /dev/null +++ b/plugins/link-relay/public/preload/core/migration-engine.js @@ -0,0 +1,615 @@ +/** + * 迁移引擎 —— 核心迁移逻辑,支持 dry-run 预览、执行、回滚、进度推送。 + * + * 迁移流程: + * 前置检查 → 数据复制 → 备份原目录 → 创建链接 → 验证链接 → 清理备份 + * + * 任何步骤失败时自动回滚:删除链接 → 恢复备份目录名。 + */ + +const fs = require('node:fs') +const path = require('node:path') +const { execSync } = require('node:child_process') +const { EventEmitter } = require('node:events') +const { + createLink, + removeLink, + verifyLink, + isLink, + getDirSizeSync, +} = require('./link-manager') +const { isRunning } = require('./process-checker') + +const progressEmitter = new EventEmitter() +let abortRequested = false + +/** 格式化字节大小 */ +function formatSize(bytes) { + if (bytes === 0) return '0 B' + const units = ['B', 'KB', 'MB', 'GB', 'TB'] + const i = Math.floor(Math.log(bytes) / Math.log(1024)) + return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}` +} + +/** 生成时间戳 */ +function timestamp() { + const d = new Date() + const pad = (n) => String(n).padStart(2, '0') + return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}${pad(d.getSeconds())}` +} + +/** 发射进度事件 */ +function emit(type, data) { + progressEmitter.emit('progress', type, data) +} + +/** 订阅进度事件,返回取消订阅函数 */ +function onProgress(cb) { + const listener = (type, data) => { + try { cb(type, data) } catch { /* ignore listener errors */ } + } + progressEmitter.on('progress', listener) + return () => progressEmitter.off('progress', listener) +} + +/** 请求中止迁移 */ +function abortMigration() { + abortRequested = true +} + +/** 获取磁盘可用空间(字节) */ +function getDiskFreeSpace(dirPath) { + try { + // Windows: 尝试使用 wmic 或 fsutil + if (process.platform === 'win32') { + const drive = path.parse(dirPath).root || 'C:\\' + try { + const output = execSync(`fsutil volume diskfree ${drive}`, { + encoding: 'utf-8', + windowsHide: true, + timeout: 3000, + }) + const match = output.match(/总可用字节数[^\d]*(\d+)/) || output.match(/free bytes[^\d]*(\d+)/i) + if (match) return parseInt(match[1], 10) + } catch { /* fallback below */ } + } + // 简单估算:返回一个较大值表示空间充足(实际环境应使用 statfs) + return 100 * 1024 * 1024 * 1024 // 100GB 估算 + } catch { + return 0 + } +} + +/** + * Dry-run 预览:生成迁移操作计划,不修改文件系统。 + * @param {Array} items 可迁移项列表 + * @param {Object} options 迁移选项 + * @returns {Object} PreviewResult + */ +function dryRun(items, options = {}) { + const itemIds = options.itemIds + const scoped = itemIds && itemIds.length > 0 + ? items.filter((item) => itemIds.includes(item.id)) + : items.filter((item) => item.enabled) + + const operations = [] + const conflicts = [] + let totalSize = 0 + + for (const item of scoped) { + const { dir } = item + let action = 'skip' + let reason = '' + + switch (dir.status) { + case 'linked': + action = 'skip' + reason = '已正确链接,无需操作' + break + case 'not-linked': + action = 'migrate' + reason = '实体目录,需迁移并创建链接' + totalSize += dir.size + break + case 'target-only': + action = 'relink' + reason = '源不存在,目标有数据,直接创建链接(重装恢复)' + break + case 'broken': + action = 'repair' + reason = '链接断裂或指向错误,需修复重建' + break + case 'conflict': + action = 'migrate' + reason = `冲突:以${options.conflictStrategy === 'prefer-target' ? '目标盘' : '源盘'}数据为准,另一侧移入备份` + totalSize += dir.size + conflicts.push({ + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + source: dir.path, + target: dir.target, + }) + break + default: + action = 'skip' + reason = '状态未知,跳过' + } + + operations.push({ + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + action, + source: dir.path, + target: dir.target, + size: dir.size, + reason, + }) + } + + // 估算目标盘可用空间(取第一个操作的目标路径所在盘) + const firstTarget = operations.find((o) => o.action !== 'skip')?.target + const targetFree = firstTarget ? getDiskFreeSpace(firstTarget) : 0 + + return { + operations, + totalSize, + totalSizeFormatted: formatSize(totalSize), + targetFree, + targetFreeFormatted: formatSize(targetFree), + conflicts, + hasConflicts: conflicts.length > 0, + } +} + +/** + * 执行迁移。 + * @param {Array} items 可迁移项列表 + * @param {Object} options 迁移选项 + * @returns {Promise} 迁移结果列表 + */ +async function migrate(items, options = {}) { + abortRequested = false + const plan = dryRun(items, options) + const toExecute = plan.operations.filter((o) => o.action !== 'skip') + const results = [] + + emit('log', { level: 'info', message: `开始迁移,共 ${toExecute.length} 项待处理` }) + + for (const op of toExecute) { + // 每项之间让出事件循环,让前端能收到进度事件并更新 UI + await new Promise((resolve) => setImmediate(resolve)) + + if (abortRequested) { + emit('log', { level: 'warn', message: '用户请求中止,剩余任务跳过' }) + break + } + + const item = items.find((i) => i.id === op.itemId) + if (!item) { + results.push({ success: false, itemId: op.itemId, action: op.action, error: '未找到可迁移项' }) + continue + } + + try { + const result = await migrateOne(item, op, options) + results.push(result) + emit('item-done', { itemId: item.id, success: result.success }) + } catch (err) { + const result = { + success: false, + itemId: item.id, + action: op.action, + error: err.message || '迁移失败', + rolledBack: true, + } + results.push(result) + emit('item-done', { itemId: item.id, success: false }) + emit('log', { level: 'error', message: `[${item.groupDisplayName} - ${item.label}] 失败: ${err.message}` }) + } + } + + emit('done', { results }) + return results +} + +/** + * 迁移单个可迁移项。 + */ +async function migrateOne(item, op, options) { + const { dir } = item + const source = dir.path + const target = dir.target + const backupPath = `${source}.backup-${timestamp()}` + + emit('phase', { + phase: 'precheck', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: `前置检查:${item.groupDisplayName} - ${item.label}`, + }) + + // 1. 进程检测 + if (item.exeNames && item.exeNames.length > 0 && isRunning(item.exeNames)) { + throw new Error(`检测到相关进程正在运行,请先关闭 ${item.exeNames.join(', ')}`) + } + + // 2. 空间检查(仅 migrate 操作需要复制数据) + if (op.action === 'migrate' && dir.size > 0) { + const free = getDiskFreeSpace(target) + if (free > 0 && free < dir.size * 1.1) { + throw new Error(`目标盘空间不足,需要 ${formatSize(dir.size * 1.1)},可用 ${formatSize(free)}`) + } + } + + // 根据操作类型执行不同流程 + if (op.action === 'relink') { + return await relinkOne(item, op) + } + + if (op.action === 'repair') { + return await repairOne(item, op) + } + + // migrate 操作 + return await doMigrate(item, op, source, target, backupPath, options) +} + +/** + * 执行标准迁移流程(migrate)。 + */ +async function doMigrate(item, op, source, target, backupPath, options) { + // 3. 数据复制 + emit('phase', { + phase: 'copy', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: `复制数据到 ${target}…`, + }) + + // 冲突处理:如果目标已存在,先处理 + if (op.action === 'migrate' && fs.existsSync(target) && !isLink(target)) { + if (options.conflictStrategy === 'prefer-target') { + // 以目标为准:源数据移入备份,直接创建链接 + emit('log', { level: 'info', message: '冲突策略:以目标盘为准,源数据移入备份' }) + fs.renameSync(source, backupPath) + createLink(target, source) + const verified = verifyLink(source, target) + if (!verified) throw new Error('链接验证失败') + return { + success: true, + itemId: item.id, + action: 'migrate', + filesCopied: 0, + bytesCopied: 0, + backupPath: options.keepBackup ? backupPath : null, + } + } else { + // 以源为准(默认):目标旧数据移入备份 + const targetBackup = `${target}.backup-${timestamp()}` + emit('log', { level: 'info', message: `冲突策略:以源盘为准,目标旧数据移入 ${targetBackup}` }) + fs.renameSync(target, targetBackup) + } + } + + const copyResult = copyDirectory(source, target, item, options) + + // 4. 备份原目录(重命名) + emit('phase', { + phase: 'backup', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: '备份原目录…', + }) + + try { + fs.renameSync(source, backupPath) + } catch (err) { + throw new Error(`备份原目录失败: ${err.message}`) + } + + // 5. 创建链接 + emit('phase', { + phase: 'link', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: '创建 Junction 链接…', + }) + + try { + createLink(target, source) + } catch (err) { + // 回滚:恢复备份 + try { fs.renameSync(backupPath, source) } catch { /* ignore */ } + throw new Error(`创建链接失败,已回滚: ${err.message}`) + } + + // 6. 验证链接 + emit('phase', { + phase: 'verify', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: '验证链接…', + }) + + const verified = verifyLink(source, target) + if (!verified) { + // 回滚 + removeLink(source) + try { fs.renameSync(backupPath, source) } catch { /* ignore */ } + throw new Error('链接验证失败,已回滚') + } + + // 7. 清理备份 + if (!options.keepBackup) { + emit('phase', { + phase: 'cleanup', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: '清理备份…', + }) + try { + fs.rmSync(backupPath, { recursive: true, force: true }) + } catch { + emit('log', { level: 'warn', message: `备份清理失败,可手动删除: ${backupPath}` }) + } + } + + emit('log', { + level: 'ok', + message: `[${item.groupDisplayName} - ${item.label}] 迁移完成`, + }) + + return { + success: true, + itemId: item.id, + action: 'migrate', + filesCopied: copyResult.filesCopied, + bytesCopied: copyResult.bytesCopied, + backupPath: options.keepBackup ? backupPath : null, + } +} + +/** + * 重建链接(relink)—— 源不存在,目标有数据。 + */ +async function relinkOne(item, op) { + const { dir } = item + emit('phase', { + phase: 'link', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: '重建链接…', + }) + + if (!fs.existsSync(dir.target)) { + throw new Error('目标目录不存在,无法重建链接') + } + + createLink(dir.target, dir.path) + const verified = verifyLink(dir.path, dir.target) + if (!verified) { + removeLink(dir.path) + throw new Error('链接验证失败') + } + + emit('log', { level: 'ok', message: `[${item.groupDisplayName} - ${item.label}] 链接重建完成` }) + return { success: true, itemId: item.id, action: 'relink', filesCopied: 0, bytesCopied: 0 } +} + +/** + * 修复链接(repair)—— 链接断裂或指向错误。 + */ +async function repairOne(item, op) { + const { dir } = item + emit('phase', { + phase: 'repair', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + message: '修复链接…', + }) + + // 删除错误链接 + if (isLink(dir.path)) { + removeLink(dir.path) + } + + // 如果目标不存在但源是实体目录,执行标准迁移 + if (!fs.existsSync(dir.target) && fs.existsSync(dir.path) && !isLink(dir.path)) { + const backupPath = `${dir.path}.backup-${timestamp()}` + return doMigrate(item, op, dir.path, dir.target, backupPath, {}) + } + + // 目标存在,直接创建链接 + if (!fs.existsSync(dir.target)) { + throw new Error('目标目录不存在,无法修复') + } + + createLink(dir.target, dir.path) + const verified = verifyLink(dir.path, dir.target) + if (!verified) { + removeLink(dir.path) + throw new Error('链接验证失败') + } + + emit('log', { level: 'ok', message: `[${item.groupDisplayName} - ${item.label}] 链接修复完成` }) + return { success: true, itemId: item.id, action: 'repair', filesCopied: 0, bytesCopied: 0 } +} + +/** + * 复制目录(优先使用 robocopy,降级为 Node.js 递归复制)。 + * 返回 { filesCopied, bytesCopied } + */ +function copyDirectory(source, target, item, options) { + // 确保目标父目录存在 + const parentDir = path.dirname(target) + if (!fs.existsSync(parentDir)) { + fs.mkdirSync(parentDir, { recursive: true }) + } + + // Windows: 优先使用 robocopy + if (process.platform === 'win32') { + try { + const args = [ + `"${source}"`, + `"${target}"`, + '/E', // 复制子目录,包括空目录 + '/MT:8', // 8 线程多线程 + '/R:1', // 失败重试 1 次 + '/W:1', // 重试等待 1 秒 + '/NFL', // 不列出文件 + '/NDL', // 不列出目录 + '/NP', // 不显示进度 + ] + + // 排除缓存目录 + if (options.excludeCache && item.cachePatterns && item.cachePatterns.length > 0) { + for (const pattern of item.cachePatterns) { + args.push(`/XD "${pattern}"`) + } + } + + const cmd = `robocopy ${args.join(' ')}` + execSync(cmd, { encoding: 'utf-8', windowsHide: true, timeout: 600000 }) + + // robocopy 返回码 0-7 表示成功,8+ 表示错误 + // 但 execSync 在非零返回码时会抛异常,所以这里需要特殊处理 + // 实际上 robocopy 的返回码 1 表示成功复制了文件,也是非零 + // 所以我们用 spawnSync 来获取返回码 + } catch (err) { + // robocopy 返回码 1-7 是正常的(表示有文件被复制等) + // 只有 >=8 才是真正的错误 + if (err.status !== undefined && err.status >= 8) { + // 降级为 Node.js 复制 + return copyDirectoryNode(source, target, item, options) + } + // 返回码 < 8 视为成功 + } + + // 估算复制结果 + const filesCopied = countFiles(target) + const bytesCopied = getDirSizeSync(target) + emit('progress', { + filesCopied, + bytesCopied, + currentFile: '', + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + }) + return { filesCopied, bytesCopied } + } + + // macOS/Linux: 优先使用 rsync + if (process.platform !== 'win32') { + try { + const rsyncArgs = ['-a'] + if (options.excludeCache && item.cachePatterns) { + for (const pattern of item.cachePatterns) { + rsyncArgs.push(`--exclude=${pattern}`) + } + } + execSync(`rsync ${rsyncArgs.join(' ')} "${source}/" "${target}/"`, { + encoding: 'utf-8', + timeout: 600000, + }) + const filesCopied = countFiles(target) + const bytesCopied = getDirSizeSync(target) + return { filesCopied, bytesCopied } + } catch { + // 降级为 Node.js 复制 + } + } + + // 降级:Node.js 递归复制 + return copyDirectoryNode(source, target, item, options) +} + +/** Node.js 递归复制目录 */ +function copyDirectoryNode(source, target, item, options) { + let filesCopied = 0 + let bytesCopied = 0 + + function copyRecursive(src, dst) { + if (!fs.existsSync(dst)) { + fs.mkdirSync(dst, { recursive: true }) + } + + const entries = fs.readdirSync(src, { withFileTypes: true }) + for (const entry of entries) { + const srcPath = path.join(src, entry.name) + const dstPath = path.join(dst, entry.name) + + // 排除缓存目录 + if (entry.isDirectory() && options.excludeCache && item.cachePatterns?.includes(entry.name)) { + continue + } + + if (entry.isDirectory()) { + if (isLink(srcPath)) continue // 跳过链接目录 + copyRecursive(srcPath, dstPath) + } else { + try { + fs.copyFileSync(srcPath, dstPath) + filesCopied++ + bytesCopied += fs.statSync(srcPath).size + + // 每 50 个文件推送一次进度 + if (filesCopied % 50 === 0) { + emit('progress', { + filesCopied, + bytesCopied, + currentFile: srcPath, + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + }) + } + } catch { + // 忽略无法复制的文件 + } + } + } + } + + copyRecursive(source, target) + return { filesCopied, bytesCopied } +} + +/** 统计目录下文件数 */ +function countFiles(dirPath) { + try { + let count = 0 + const entries = fs.readdirSync(dirPath, { withFileTypes: true }) + for (const entry of entries) { + const fullPath = path.join(dirPath, entry.name) + if (entry.isDirectory()) { + if (isLink(fullPath)) continue + count += countFiles(fullPath) + } else { + count++ + } + } + return count + } catch { + return 0 + } +} + +module.exports = { + formatSize, + getDiskFreeSpace, + dryRun, + migrate, + abortMigration, + onProgress, +} diff --git a/plugins/link-relay/public/preload/core/path-util.js b/plugins/link-relay/public/preload/core/path-util.js new file mode 100644 index 000000000..bee801f9f --- /dev/null +++ b/plugins/link-relay/public/preload/core/path-util.js @@ -0,0 +1,46 @@ +/** + * 路径工具 —— 通用的环境变量 / 主目录 / 跨平台路径展开(无状态、纯函数)。 + * + * 新架构不再有预设库:分组与映射全部由前端维护并以绝对路径传入, + * 这里仅保留 expandEnv 供 window.services 暴露,以及内部复用的 resolve* 辅助。 + */ + +const path = require('node:path') +const os = require('node:os') + +/** 展开环境变量(Windows %VAR% / Unix ${VAR}、$VAR) */ +function expandEnv(p) { + if (!p) return p + let result = p + result = result.replace(/%([^%]+)%/g, (_, key) => process.env[key] || '') + result = result.replace(/\$\{([^}]+)\}/g, (_, key) => process.env[key] || '') + result = result.replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, key) => process.env[key] || '') + return result +} + +/** 解析 ~ 为用户主目录 */ +function resolveHome(p) { + if (!p) return p + if (p.startsWith('~')) return path.join(os.homedir(), p.slice(1)) + return p +} + +/** 展开环境变量 + ~ */ +function resolvePath(p) { + if (!p) return '' + return resolveHome(expandEnv(p)) +} + +/** 按当前平台取模板路径(字符串或 {win32,darwin,linux})并展开 */ +function resolvePlatformPath(template) { + if (!template) return '' + if (typeof template === 'string') return resolvePath(template) + return resolvePath(template[process.platform] || template.win32 || '') +} + +module.exports = { + expandEnv, + resolvePath, + resolveHome, + resolvePlatformPath, +} diff --git a/plugins/link-relay/public/preload/core/process-checker.js b/plugins/link-relay/public/preload/core/process-checker.js new file mode 100644 index 000000000..5fda4a1dd --- /dev/null +++ b/plugins/link-relay/public/preload/core/process-checker.js @@ -0,0 +1,40 @@ +/** + * 进程检测模块 —— 检测指定可执行文件是否正在运行。 + * 迁移前必须确认 IDE 进程已关闭,防止数据损坏。 + */ + +const { execSync } = require('node:child_process') + +/** + * 检测指定可执行文件是否正在运行。 + * @param {string[]} exeNames 可执行文件名列表 + * @returns {boolean} + */ +function isRunning(exeNames) { + if (!exeNames || exeNames.length === 0) return false + + try { + if (process.platform === 'win32') { + // Windows: 使用 tasklist 批量获取,然后在结果中查找 + const output = execSync('tasklist /FO CSV /NH', { + encoding: 'utf-8', + windowsHide: true, + timeout: 5000, + }) + const lowerOutput = output.toLowerCase() + return exeNames.some((name) => lowerOutput.includes(name.toLowerCase())) + } else { + // macOS/Linux: 使用 ps + const output = execSync('ps -A -o comm=', { + encoding: 'utf-8', + timeout: 5000, + }) + const lowerOutput = output.toLowerCase() + return exeNames.some((name) => lowerOutput.includes(name.toLowerCase())) + } + } catch { + return false + } +} + +module.exports = { isRunning } diff --git a/plugins/link-relay/public/preload/core/status-enum.js b/plugins/link-relay/public/preload/core/status-enum.js new file mode 100644 index 000000000..9b97025c0 --- /dev/null +++ b/plugins/link-relay/public/preload/core/status-enum.js @@ -0,0 +1,77 @@ +/** + * 链接状态枚举与状态语义(后端唯一事实源)。 + * 前端 src/utils/enums.ts 保持同构;新增状态时两处同步。 + */ + +/** 链接状态 */ +const LinkStatus = Object.freeze({ + LINKED: 'linked', // 已正确链接 + NOT_LINKED: 'not-linked', // 未迁移,源为实体目录 + BROKEN: 'broken', // 链接断裂或指向错误 + CONFLICT: 'conflict', // 源与目标都有实体目录 + TARGET_ONLY: 'target-only', // 源不存在、目标有数据(重装场景) + NOT_INSTALLED: 'not-installed', // 源与目标都不存在 + UNKNOWN: 'unknown', // 检测失败 +}) + +/** 行操作类型 */ +const RowActionType = Object.freeze({ + MIGRATE: 'migrate', + RELINK: 'relink', + REPAIR: 'repair', + NONE: 'none', +}) + +/** 状态严重度(数字越大越紧急,组聚合取最严重) */ +const STATUS_SEVERITY = Object.freeze({ + [LinkStatus.CONFLICT]: 5, + [LinkStatus.BROKEN]: 4, + [LinkStatus.NOT_LINKED]: 3, + [LinkStatus.TARGET_ONLY]: 2, + [LinkStatus.LINKED]: 1, + [LinkStatus.NOT_INSTALLED]: 0, + [LinkStatus.UNKNOWN]: 0, +}) + +/** 状态 → 行主操作 */ +const STATUS_ACTION = Object.freeze({ + [LinkStatus.NOT_LINKED]: { type: RowActionType.MIGRATE, label: '迁移', disabled: false }, + [LinkStatus.TARGET_ONLY]: { type: RowActionType.RELINK, label: '重建链接', disabled: false }, + [LinkStatus.BROKEN]: { type: RowActionType.REPAIR, label: '修复', disabled: false }, + [LinkStatus.CONFLICT]: { type: RowActionType.REPAIR, label: '修复', disabled: false }, + [LinkStatus.LINKED]: { type: RowActionType.NONE, label: '已迁移', disabled: true }, + [LinkStatus.NOT_INSTALLED]: { type: RowActionType.NONE, label: '—', disabled: true }, + [LinkStatus.UNKNOWN]: { type: RowActionType.NONE, label: '—', disabled: true }, +}) + +function rowAction(status) { + return STATUS_ACTION[status] || STATUS_ACTION[LinkStatus.UNKNOWN] +} + +function aggregateStatus(statuses) { + return statuses.reduce( + (acc, s) => ((STATUS_SEVERITY[s] ?? 0) > (STATUS_SEVERITY[acc] ?? 0) ? s : acc), + LinkStatus.UNKNOWN + ) +} + +/** 是否为待处理(需要迁移/重建) */ +function isPending(status) { + return status === LinkStatus.NOT_LINKED || status === LinkStatus.TARGET_ONLY +} + +/** 是否为异常(冲突/断裂) */ +function isAbnormal(status) { + return status === LinkStatus.CONFLICT || status === LinkStatus.BROKEN +} + +module.exports = { + LinkStatus, + RowActionType, + STATUS_SEVERITY, + STATUS_ACTION, + rowAction, + aggregateStatus, + isPending, + isAbnormal, +} diff --git a/plugins/link-relay/public/preload/package.json b/plugins/link-relay/public/preload/package.json new file mode 100644 index 000000000..5bbefffba --- /dev/null +++ b/plugins/link-relay/public/preload/package.json @@ -0,0 +1,3 @@ +{ + "type": "commonjs" +} diff --git a/plugins/link-relay/public/preload/services.js b/plugins/link-relay/public/preload/services.js new file mode 100644 index 000000000..61dbdbeea --- /dev/null +++ b/plugins/link-relay/public/preload/services.js @@ -0,0 +1,85 @@ +/** + * 目录迁移助手 —— preload 服务入口(Node 能力桥)。 + * + * 新架构职责边界: + * - 数据(分组 / 映射 / 日志)全部由前端三张 pinia 表持有并持久化,preload 不再保存任何配置, + * 因此这里没有 config-store / preset-library / entry-resolver,也没有仪表盘缓存; + * - 本文件只把前端传入的「迁移项」原样转交给执行层,是无状态的能力提供者: + * link-manager(状态检测)/ migration-engine(迁移执行)/ process-checker(进程检测)/ path-util(路径展开)。 + * + * 迁移项形态由前端 api/migrate.toEngineItem 组装: + * { id, enabled, groupDisplayName, label, exeNames, cachePatterns, + * dir: { path, target, status(kebab), size } } + */ + +const linkManager = require('./core/link-manager') +const processChecker = require('./core/process-checker') +const migrationEngine = require('./core/migration-engine') +const { expandEnv } = require('./core/path-util') + +// 让出一帧,避免在 IPC 同步回调里阻塞渲染 +function nextTick() { + return new Promise((resolve) => setImmediate(resolve)) +} + +window.services = { + // === 目录 / 链接 / 进程(无状态能力) === + + checkDirStatus(source, target) { + return linkManager.checkDirStatus(source, target) + }, + + expandEnv(p) { + return expandEnv(p) + }, + + getDiskFreeSpace(dirPath) { + return migrationEngine.getDiskFreeSpace(dirPath) + }, + + formatSize(bytes) { + return migrationEngine.formatSize(bytes) + }, + + isProcessRunning(exeNames) { + return processChecker.isAnyRunning(exeNames || []) + }, + + // === 迁移流程(items 由前端组装传入) === + + dryRun(items, options = {}) { + return migrationEngine.dryRun(items || [], options) + }, + + async migrate(items, options = {}) { + await nextTick() + return migrationEngine.migrate(items || [], options) + }, + + /** 单行重建链接:引擎依据 item.dir.status 自行判定为 relink */ + async relink(item) { + await nextTick() + if (!item) throw new Error('relink 缺少迁移项') + const results = await migrationEngine.migrate([item], { itemIds: [item.id] }) + return results[0] + }, + + /** 单行修复:broken 直接重建;conflict 使用传入或默认的冲突策略 */ + async repair(item, strategy) { + await nextTick() + if (!item) throw new Error('repair 缺少迁移项') + const results = await migrationEngine.migrate([item], { + itemIds: [item.id], + conflictStrategy: strategy || 'prefer-source', + }) + return results[0] + }, + + abortMigration() { + migrationEngine.abortMigration() + }, + + onProgress(cb) { + return migrationEngine.onProgress(cb) + }, +} diff --git a/plugins/link-relay/src/App.vue b/plugins/link-relay/src/App.vue new file mode 100644 index 000000000..0debc85f3 --- /dev/null +++ b/plugins/link-relay/src/App.vue @@ -0,0 +1,23 @@ + + + + + diff --git a/plugins/link-relay/src/api/db.ts b/plugins/link-relay/src/api/db.ts new file mode 100644 index 000000000..77508b57a --- /dev/null +++ b/plugins/link-relay/src/api/db.ts @@ -0,0 +1,142 @@ +/** + * 持久化底座 —— 封装宿主 ztools.db(每个插件独立文档库)。 + * + * 文档 _id 约定:'{collection}:{业务主键}',用 allDocs(prefix) 即按表取数,三张表天然隔离。 + * 全部方法异步:优先走 db.promises,避免同步 IO 阻塞 UI。 + * 无宿主(pnpm dev 跑在浏览器)时退化为内存 Map:纯内存、零磁盘 IO,不触碰任何真实目录。 + */ + +/** 宿主文档的元字段(业务侧不需要,取出后剥离) */ +interface DocMeta { + _id: string; + _rev?: string; +} + +/** 宿主文档:元字段 + 业务字段 */ +type Doc = DocMeta & Record; + +/** dev 内存兜底库,key = 完整文档 id */ +const memory = new Map>(); + +/** 取宿主文档库,无宿主返回 null */ +function hostDb() { + return typeof ztools !== 'undefined' && ztools.db ? ztools.db : null; +} + +/** 拼文档 id */ +export function docKey(collection: string, id: string): string { + return `${collection}:${id}`; +} + +/** 深拷贝,避免内存兜底库与 store state 共享引用 */ +function clone(value: T): T { + return JSON.parse(JSON.stringify(value)) as T; +} + +/** 剥离 _id/_rev,还原业务实体 */ +function toEntity(doc: Doc): T { + const entity: Record = { ...doc }; + delete entity._id; + delete entity._rev; + return entity as T; +} + +/** R:列出集合内全部实体 */ +export async function listCollection(collection: string): Promise { + const db = hostDb(); + const prefix = `${collection}:`; + if (!db) { + return [...memory.entries()].filter(([key]) => key.startsWith(prefix)).map(([, doc]) => clone(doc) as T); + } + const docs = (await db.promises.allDocs(prefix)) as Doc[]; + return docs.map((doc) => toEntity(doc)); +} + +/** R:按主键取单个实体 */ +export async function getDoc(collection: string, id: string): Promise { + const db = hostDb(); + const key = docKey(collection, id); + if (!db) { + const hit = memory.get(key); + return hit ? (clone(hit) as T) : null; + } + const doc = (await db.promises.get(key)) as Doc | null; + return doc ? toEntity(doc) : null; +} + +/** R:同步按主键取实体(store 水合用,换取首帧不出现空表) */ +export function getDocSync(collection: string, id: string): T | null { + const db = hostDb(); + const key = docKey(collection, id); + if (!db) { + const hit = memory.get(key); + return hit ? (clone(hit) as T) : null; + } + try { + const doc = db.get>(key) as Doc | null; + return doc ? toEntity(doc) : null; + } catch { + // 宿主库异常时降级为空数据,不阻塞 store 创建 + return null; + } +} + +/** C/U:写入实体(已存在则带 _rev 更新) */ +export async function putDoc(collection: string, id: string, entity: T): Promise { + const db = hostDb(); + const key = docKey(collection, id); + if (!db) { + memory.set(key, clone(entity) as Record); + return; + } + const existing = (await db.promises.get(key)) as Doc | null; + const payload = { ...(entity as Record) }; + await db.promises.put(existing?._rev ? { _id: key, _rev: existing._rev, ...payload } : { _id: key, ...payload }); +} + +/** C/U:同步写入(退出兜底用:进程被杀时异步写入可能来不及落地) */ +export function putDocSync(collection: string, id: string, entity: T): void { + const db = hostDb(); + const key = docKey(collection, id); + if (!db) { + memory.set(key, clone(entity) as Record); + return; + } + try { + const existing = db.get(key) as Doc | null; + const payload = { ...(entity as Record) }; + db.put(existing?._rev ? { _id: key, _rev: existing._rev, ...payload } : { _id: key, ...payload }); + } catch { + // 退出路径上不抛出,避免中断宿主流程 + } +} + +/** D:删除单个实体 */ +export async function removeDoc(collection: string, id: string): Promise { + const db = hostDb(); + const key = docKey(collection, id); + if (!db) { + memory.delete(key); + return; + } + await db.promises.remove(key); +} + +/** D:清空整个集合 */ +export async function clearCollection(collection: string): Promise { + const db = hostDb(); + const prefix = `${collection}:`; + if (!db) { + for (const key of [...memory.keys()]) { + if (key.startsWith(prefix)) memory.delete(key); + } + return; + } + const docs = (await db.promises.allDocs(prefix)) as Doc[]; + await Promise.all(docs.map((doc) => db.promises.remove(doc))); +} + +/** 当前是否落在宿主文档库(false 表示 dev 内存态,重启即丢) */ +export function isPersisted(): boolean { + return hostDb() !== null; +} diff --git a/plugins/link-relay/src/api/dev.ts b/plugins/link-relay/src/api/dev.ts new file mode 100644 index 000000000..06918c6ae --- /dev/null +++ b/plugins/link-relay/src/api/dev.ts @@ -0,0 +1,93 @@ +/** + * DEV 内存虚拟世界 —— 仅在「无宿主(pnpm dev 跑浏览器)」时使用。 + * + * 约束(硬要求): + * - 纯内存、零磁盘 IO,绝不读取/修改真实 IDE(.vscode / AppData 等)目录; + * - 界面与日志中出现的路径全部落在 RelocatorTest 虚拟根下; + * - 仅用于开发态跑通「扫描 → 预览 → 迁移 → 状态翻转」闭环,生产构建中这些数据不会被触达。 + */ +import type { MappingCreateDTO } from '../store/types/mapping'; +import type { NativeDirStatus, NativeLinkStatus } from './dir'; + +/** 虚拟测试根:源在 C 盘、目标在 D 盘,与真实用户目录完全隔离 */ +export const DEV_SOURCE_ROOT = 'C:\\RelocatorTest\\source'; +export const DEV_TARGET_ROOT = 'D:\\RelocatorTest\\target'; + +/** 一条样例记录(同时驱动:首帧种子数据 + 虚拟状态检测) */ +interface DevRecord { + /** 所属样例分组名(空串表示未分组) */ + group: string; + /** 行显示名 */ + name: string; + /** 目录叶子名(拼出虚拟源/目标路径) */ + leaf: string; + /** 初始链接状态(kebab,与 preload 同形) */ + status: NativeLinkStatus; + /** 源体积(字节),linked 为 0 */ + size: number; + /** 关联进程名(可空) */ + exeNames?: string[]; +} + +/** 固定样例:覆盖全部主要状态,便于开发态看到不同 UI 分支 */ +const RECORDS: DevRecord[] = [ + { group: '开发工具 A', name: '扩展目录', leaf: 'tool-a/home', status: 'not-linked', size: 361_000_000, exeNames: ['Code.exe'] }, + { group: '开发工具 A', name: '配置目录', leaf: 'tool-a/roaming', status: 'conflict', size: 198_000_000, exeNames: ['Code.exe'] }, + { group: '开发工具 B', name: '扩展目录', leaf: 'tool-b/home', status: 'linked', size: 0 }, + { group: '开发工具 B', name: '配置目录', leaf: 'tool-b/roaming', status: 'target-only', size: 74_000_000 }, + { group: '', name: 'npm 缓存', leaf: 'npm-cache', status: 'not-linked', size: 200_000_000 }, + { group: '', name: 'Maven 仓库', leaf: 'm2', status: 'broken', size: 150_000_000 }, +]; + +/** 迁移后被改写为 linked 的状态覆盖表,key = 源路径 */ +const overrides = new Map(); + +function sourceOf(leaf: string): string { + return `${DEV_SOURCE_ROOT}\\${leaf.replace(/\//g, '\\')}`; +} +function targetOf(leaf: string): string { + return `${DEV_TARGET_ROOT}\\${leaf.replace(/\//g, '\\')}`; +} + +function findBySource(source: string): DevRecord | undefined { + return RECORDS.find((r) => sourceOf(r.leaf) === source); +} + +/** 虚拟状态检测:按源路径查样例表,叠加迁移后的 linked 覆盖 */ +export function devDirStatus(source: string, target: string): NativeDirStatus { + const record = findBySource(source); + const status = overrides.get(source) ?? record?.status ?? 'not-linked'; + const size = status === 'linked' ? 0 : (record?.size ?? 12_000_000); + return { + path: source, + target, + status, + size, + ...(status === 'linked' ? { realTarget: target } : {}), + }; +} + +/** dev 模拟迁移成功后把该源标记为已链接 */ +export function devMarkLinked(source: string): void { + overrides.set(source, 'linked'); +} + +/** 样例分组名(去重、保序,空串代表的未分组不建组) */ +export function devSeedGroupNames(): string[] { + return [...new Set(RECORDS.map((r) => r.group).filter(Boolean))]; +} + +/** + * 样例映射行 DTO;groupNameToId 把样例分组名映射为建组后生成的 uuid,未分组传 ''。 + * 状态/体积不在 DTO 中,由首次 refresh 经 devDirStatus 检测后写回,模拟真实扫描。 + */ +export function devSeedMappings(groupNameToId: (name: string) => string): MappingCreateDTO[] { + return RECORDS.map((r) => ({ + name: r.name, + groupId: r.group ? groupNameToId(r.group) : '', + sourcePath: sourceOf(r.leaf), + targetPath: targetOf(r.leaf), + exeNames: r.exeNames ?? [], + cachePatterns: [], + })); +} diff --git a/plugins/link-relay/src/api/dir.ts b/plugins/link-relay/src/api/dir.ts new file mode 100644 index 000000000..eaedbbe5d --- /dev/null +++ b/plugins/link-relay/src/api/dir.ts @@ -0,0 +1,106 @@ +/** + * 目录 / 链接 / 进程能力 —— 需要 Node 侧(preload)支撑的能力集中在此。 + * + * 宿主内:读取 preload 注入的 window.services,并把返回字段与状态值归一化到 store 模型 + * (preload 用 kebab-case 状态,store 用 camelCase,映射在本文件收敛); + * dev 无宿主:全部退化为内存虚拟实现(api/dev),零磁盘 IO,不触碰任何真实 IDE 目录。 + * 目录选择走宿主对话框,不依赖 preload。 + */ +import type { LinkStatus } from '../store/types/mapping'; +import { devDirStatus } from './dev'; + +/** preload 侧(kebab-case)链接状态,与 preload/core/status-enum.js 同构 */ +export type NativeLinkStatus = + | 'linked' + | 'not-linked' + | 'broken' + | 'conflict' + | 'target-only' + | 'not-installed' + | 'unknown'; + +/** preload checkDirStatus 的原始返回 */ +export interface NativeDirStatus { + path: string; + target: string; + status: NativeLinkStatus; + size: number; + realTarget?: string; +} + +/** 目录链接状态检测结果(已归一化到 store 模型) */ +export interface DirStatus { + /** 链接状态 */ + status: LinkStatus; + /** 源目录体积(字节),已链接为 0 */ + sizeBytes: number; + /** 链接真实指向,非链接为空串 */ + realTarget: string; +} + +/** preload 的 kebab-case 状态 → store 的 camelCase 状态 */ +const STATUS_MAP: Record = { + linked: 'linked', + 'not-linked': 'notLinked', + broken: 'broken', + conflict: 'conflict', + 'target-only': 'targetOnly', + 'not-installed': 'notInstalled', + unknown: 'unknown', +}; + +/** dev 兜底的假可用空间(仅用于开发态不报错,不代表真实磁盘) */ +const DEV_FREE_SPACE = 100_000_000_000; + +/** 取 preload 注入的 Node 能力,无宿主返回 null */ +function native() { + return typeof window !== 'undefined' && window.services ? window.services : null; +} + +/** 展开路径中的 %ENV% 与 ~(无宿主原样返回) */ +export function expandPath(path: string): string { + if (!path) return ''; + return native()?.expandEnv(path) ?? path; +} + +/** 目标盘可用空间(字节) */ +export function freeSpace(path: string): number { + if (!path) return 0; + return native()?.getDiskFreeSpace(path) ?? DEV_FREE_SPACE; +} + +/** 检测「源 → 目标」的链接状态与体积 */ +export function checkLink(source: string, target: string): DirStatus { + const services = native(); + if (!services) { + const virtual = devDirStatus(source, target); + return { + status: STATUS_MAP[virtual.status] ?? 'unknown', + sizeBytes: virtual.size, + realTarget: virtual.realTarget ?? '', + }; + } + const info = services.checkDirStatus(source, target); + return { + status: STATUS_MAP[info.status] ?? 'unknown', + sizeBytes: info.size || 0, + realTarget: info.realTarget ?? '', + }; +} + +/** 关联进程是否运行中(迁移前置检查用,运行中应拒绝执行) */ +export function isProcessRunning(exeNames: string[]): boolean { + if (exeNames.length === 0) return false; + return native()?.isProcessRunning(exeNames) ?? false; +} + +/** 选择目录:宿主对话框,取消或无宿主返回空串 */ +export function pickDirectory(title = '选择目录', defaultPath = ''): string { + if (typeof ztools === 'undefined') return ''; + const picked = ztools.showOpenDialog({ + title, + defaultPath, + properties: ['openDirectory', 'createDirectory'], + }); + return picked?.[0] ?? ''; +} diff --git a/plugins/link-relay/src/api/env.ts b/plugins/link-relay/src/api/env.ts new file mode 100644 index 000000000..acd138cb0 --- /dev/null +++ b/plugins/link-relay/src/api/env.ts @@ -0,0 +1,80 @@ +/** + * 宿主环境与 UI 交互能力 —— 封装 ztools 基础 API(平台 / 主题 / 通知 / 窗口 / 系统路径 / Shell)。 + * 无宿主(pnpm dev 浏览器)时全部退化为安全默认值,保证开发态可直接跑通。 + */ + +/** 运行平台 */ +export type Platform = 'win32' | 'darwin' | 'linux' | 'unknown'; + +/** 可读取的系统路径名(宿主 getPath 支持的常用子集) */ +export type SystemPathName = 'home' | 'appData' | 'userData' | 'cache' | 'temp' | 'desktop' | 'documents' | 'downloads' | 'logs'; + +/** 取宿主对象,无宿主返回 null */ +function host() { + return typeof ztools !== 'undefined' ? ztools : null; +} + +/** 是否运行在 ZTools 宿主内 */ +export function isHost(): boolean { + return host() !== null; +} + +/** 是否开发模式(vite dev 或宿主 isDev) */ +export function isDev(): boolean { + return import.meta.env.DEV || (host()?.isDev() ?? false); +} + +/** 当前平台(决定 junction / symlink 等链接策略) */ +export function platform(): Platform { + const api = host(); + if (!api) return 'unknown'; + if (api.isWindows()) return 'win32'; + if (api.isMacOS()) return 'darwin'; + if (api.isLinux()) return 'linux'; + return 'unknown'; +} + +/** 是否深色主题 */ +export function isDark(): boolean { + return host()?.isDarkColors() ?? false; +} + +/** 宿主版本号 */ +export function appVersion(): string { + return host()?.getAppVersion() ?? ''; +} + +/** 读取系统路径(home / appData / userData / cache / temp / desktop…),无宿主返回空串 */ +export function systemPath(name: SystemPathName): string { + return host()?.getPath(name) ?? ''; +} + +/** 系统通知 */ +export function notify(message: string): void { + host()?.showNotification(message); +} + +/** 设置插件视图高度(首页高度自适应用) */ +export function setViewHeight(height: number): void { + host()?.setExpendHeight(height); +} + +/** 插件进入事件:首屏取数的触发点 */ +export function onEnter(callback: (code: string) => void): void { + host()?.onPluginEnter((action) => callback(action.code)); +} + +/** 插件退出事件(isKill=true 为杀进程):退出前 flush 待写快照的触发点 */ +export function onOut(callback: (isKill: boolean) => void): void { + host()?.onPluginOut(callback); +} + +/** 用系统默认程序打开目录或文件 */ +export function openPath(fullPath: string): void { + if (fullPath) host()?.shellOpenPath(fullPath); +} + +/** 在文件管理器中定位目录或文件 */ +export function showInFolder(fullPath: string): void { + if (fullPath) host()?.shellShowItemInFolder(fullPath); +} diff --git a/plugins/link-relay/src/api/index.ts b/plugins/link-relay/src/api/index.ts new file mode 100644 index 000000000..94b769b8b --- /dev/null +++ b/plugins/link-relay/src/api/index.ts @@ -0,0 +1,13 @@ +/** + * api 层出口 —— 按作用/功能分模块封装,命名空间导出避免同名冲突。 + * + * envApi 宿主环境与 UI 交互(平台 / 主题 / 通知 / 窗口高度 / 系统路径 / Shell / 插件进入) + * dbApi 持久化底座(宿主文档库 collection 读写,dev 内存兜底); + * 三张表的落库由 store/plugins/ztoolsPersist 插件以快照方式自动完成,不再各表一套 CRUD + * dirApi 目录 / 链接 / 进程能力(preload Node 桥接 + 宿主目录选择对话框) + * migrateApi 迁移引擎能力(dry-run / 迁移 / 重建 / 修复 / 进度,preload Node 桥接 + dev 模拟) + */ +export * as envApi from './env'; +export * as dbApi from './db'; +export * as dirApi from './dir'; +export * as migrateApi from './migrate'; diff --git a/plugins/link-relay/src/api/migrate.ts b/plugins/link-relay/src/api/migrate.ts new file mode 100644 index 000000000..d03c83c65 --- /dev/null +++ b/plugins/link-relay/src/api/migrate.ts @@ -0,0 +1,283 @@ +/** + * 迁移能力 —— 对接 preload 迁移引擎(window.services)的唯一出口。 + * + * - 契约类型(计划 / 结果 / 进度)集中在本文件,引擎只认 kebab-case 状态与 EngineItem; + * - toEngineItem 负责把 store 的 camelCase MappingVO 归一化为引擎入参(api 是唯一状态翻译边界); + * - dev 无宿主时走纯内存模拟(api/dev),推进虚拟状态、不触盘。 + */ +import type { LinkStatus, MappingVO } from '../store/types/mapping'; +import { devMarkLinked } from './dev'; +import { expandPath, type NativeLinkStatus } from './dir'; +import { formatBytes } from '../utils/format'; + +// ────────────── 契约类型 ────────────── + +/** 冲突策略(UI 仅提供两种) */ +export type ConflictStrategy = 'prefer-source' | 'prefer-target'; + +/** 引擎单步动作 */ +export type EngineAction = 'migrate' | 'skip' | 'relink' | 'repair'; + +/** 引擎可迁移项(preload migration-engine 的入参形态,状态为 kebab) */ +export interface EngineItem { + id: string; + enabled: boolean; + groupDisplayName: string; + label: string; + exeNames: string[]; + cachePatterns: string[]; + dir: { path: string; target: string; status: NativeLinkStatus; size: number }; +} + +/** 单步操作计划 */ +export interface OperationPlan { + itemId: string; + groupDisplayName: string; + dirLabel: string; + action: EngineAction; + source: string; + target: string; + size: number; + reason: string; +} + +/** dry-run 预览结果 */ +export interface PreviewResult { + operations: OperationPlan[]; + totalSize: number; + totalSizeFormatted: string; + targetFree: number; + targetFreeFormatted: string; + conflicts: Array<{ itemId: string; groupDisplayName: string; dirLabel: string; source: string; target: string }>; + hasConflicts: boolean; +} + +/** 迁移选项 */ +export interface MigrationOptions { + /** 限定迁移的行 id;不传则迁移全部启用且可迁移的行 */ + itemIds?: string[]; + excludeCache?: boolean; + keepBackup?: boolean; + conflictStrategy?: ConflictStrategy; +} + +/** 单步迁移结果 */ +export interface MigrationResult { + success: boolean; + itemId: string; + action: string; + error?: string; + filesCopied?: number; + bytesCopied?: number; + backupPath?: string | null; + rolledBack?: boolean; +} + +/** 进度事件(引擎以 (type, data) 两参推送) */ +export type ProgressEvent = + | { type: 'phase'; phase: string; itemId: string; groupDisplayName: string; dirLabel: string; message: string } + | { type: 'progress'; filesCopied: number; bytesCopied: number; currentFile: string; itemId: string; groupDisplayName: string; dirLabel: string } + | { type: 'log'; level: 'info' | 'ok' | 'warn' | 'error'; message: string } + | { type: 'item-done'; itemId: string; success: boolean } + | { type: 'done'; results: MigrationResult[] }; + +export type ProgressCallback = (type: ProgressEvent['type'], data: unknown) => void; + +/** preload 暴露的迁移类原生方法集合(供 env.d.ts 组合 ServicesApi) */ +export interface NativeMigrateApi { + dryRun(items: EngineItem[], options?: MigrationOptions): PreviewResult; + migrate(items: EngineItem[], options?: MigrationOptions): Promise; + relink(item: EngineItem): Promise; + repair(item: EngineItem, strategy?: ConflictStrategy): Promise; + abortMigration(): void; + onProgress(cb: ProgressCallback): () => void; +} + +// ────────────── store 行 → 引擎项(camel → kebab) ────────────── + +const TO_NATIVE: Record = { + linked: 'linked', + notLinked: 'not-linked', + broken: 'broken', + conflict: 'conflict', + targetOnly: 'target-only', + notInstalled: 'not-installed', + unknown: 'unknown', +}; + +/** 单行配置 + 组名 → 引擎项 */ +export function toEngineItem(row: MappingVO, groupName: string): EngineItem { + return { + id: row.id, + enabled: row.enabled, + groupDisplayName: groupName, + label: row.name, + exeNames: row.exeNames, + cachePatterns: row.cachePatterns, + dir: { + path: expandPath(row.sourcePath), + target: row.targetPath, + status: TO_NATIVE[row.status], + size: row.sizeBytes, + }, + }; +} + +// ────────────── 宿主 / dev 分流 ────────────── + +function native(): NativeMigrateApi | null { + return typeof window !== 'undefined' && window.services ? window.services : null; +} + +const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); + +// ── dev 内存模拟(与 preload migration-engine.dryRun 同形,零磁盘 IO) ── + +const devListeners = new Set(); +function devEmit(type: ProgressEvent['type'], data: unknown): void { + devListeners.forEach((fn) => { + try { + fn(type, data); + } catch { + /* 监听器异常不影响流程 */ + } + }); +} + +function devDryRun(items: EngineItem[], options: MigrationOptions = {}): PreviewResult { + const scoped = options.itemIds?.length + ? items.filter((i) => options.itemIds!.includes(i.id)) + : items.filter((i) => i.enabled); + + const operations: OperationPlan[] = []; + const conflicts: PreviewResult['conflicts'] = []; + let totalSize = 0; + + for (const item of scoped) { + const { dir } = item; + let action: EngineAction = 'skip'; + let reason = '状态未知,跳过'; + switch (dir.status) { + case 'linked': + reason = '已正确链接,无需操作'; + break; + case 'not-linked': + action = 'migrate'; + reason = '实体目录,需迁移并创建链接'; + totalSize += dir.size; + break; + case 'target-only': + action = 'relink'; + reason = '源不存在,目标有数据,直接创建链接'; + break; + case 'broken': + action = 'repair'; + reason = '链接断裂,需修复重建'; + break; + case 'conflict': + action = 'migrate'; + reason = `冲突:以${options.conflictStrategy === 'prefer-target' ? '目标盘' : '源盘'}数据为准,另一侧移入备份`; + totalSize += dir.size; + conflicts.push({ itemId: item.id, groupDisplayName: item.groupDisplayName, dirLabel: item.label, source: dir.path, target: dir.target }); + break; + default: + break; + } + operations.push({ + itemId: item.id, + groupDisplayName: item.groupDisplayName, + dirLabel: item.label, + action, + source: dir.path, + target: dir.target, + size: dir.size, + reason, + }); + } + + const targetFree = 100_000_000_000; + return { + operations, + totalSize, + totalSizeFormatted: formatBytes(totalSize), + targetFree, + targetFreeFormatted: '~100 GB', + conflicts, + hasConflicts: conflicts.length > 0, + }; +} + +async function devMigrate(items: EngineItem[], options: MigrationOptions = {}): Promise { + const plan = devDryRun(items, options); + const results: MigrationResult[] = []; + for (const op of plan.operations.filter((o) => o.action !== 'skip')) { + const item = items.find((i) => i.id === op.itemId); + if (!item) continue; + devEmit('phase', { phase: 'copy', itemId: op.itemId, groupDisplayName: op.groupDisplayName, dirLabel: op.dirLabel, message: `复制数据到 ${op.target}…` }); + await delay(180); + for (let i = 1; i <= 3; i++) { + devEmit('progress', { + filesCopied: i * 8, + bytesCopied: Math.floor((op.size * i) / 3), + currentFile: op.source, + itemId: op.itemId, + groupDisplayName: op.groupDisplayName, + dirLabel: op.dirLabel, + }); + await delay(90); + } + devMarkLinked(item.dir.path); + const result: MigrationResult = { success: true, itemId: op.itemId, action: op.action, filesCopied: 24, bytesCopied: op.size, backupPath: null }; + results.push(result); + devEmit('log', { level: 'ok', message: `[${op.groupDisplayName} - ${op.dirLabel}] 迁移完成` }); + devEmit('item-done', { itemId: op.itemId, success: true }); + } + devEmit('done', { results }); + return results; +} + +// ────────────── 对外 API ────────────── + +/** dry-run 预览(不触盘) */ +export function dryRun(items: EngineItem[], options?: MigrationOptions): PreviewResult { + return native()?.dryRun(items, options) ?? devDryRun(items, options); +} + +/** 执行迁移 */ +export async function migrate(items: EngineItem[], options?: MigrationOptions): Promise { + const host = native(); + return host ? host.migrate(items, options) : devMigrate(items, options); +} + +/** 单行重建链接 */ +export async function relink(item: EngineItem): Promise { + const host = native(); + if (host) return host.relink(item); + await delay(240); + devMarkLinked(item.dir.path); + devEmit('done', { results: [] }); + return { success: true, itemId: item.id, action: 'relink' }; +} + +/** 单行修复 */ +export async function repair(item: EngineItem, strategy?: ConflictStrategy): Promise { + const host = native(); + if (host) return host.repair(item, strategy); + await delay(240); + devMarkLinked(item.dir.path); + devEmit('done', { results: [] }); + return { success: true, itemId: item.id, action: 'repair' }; +} + +/** 请求中止 */ +export function abortMigration(): void { + native()?.abortMigration(); +} + +/** 订阅进度,返回取消订阅函数 */ +export function onProgress(cb: ProgressCallback): () => void { + const host = native(); + if (host) return host.onProgress(cb); + devListeners.add(cb); + return () => devListeners.delete(cb); +} diff --git a/plugins/link-relay/src/assets/config.json b/plugins/link-relay/src/assets/config.json new file mode 100644 index 000000000..a18512319 --- /dev/null +++ b/plugins/link-relay/src/assets/config.json @@ -0,0 +1,42 @@ +{ + "$comment": "出厂预设模板(只读)。结构与 store 三张表的 CreateDTO 对齐:groups[i] 对应 GroupCreateDTO(name),mappings[i] 对应 MappingCreateDTO(name/sourcePath/targetPath/exeNames/cachePatterns)。key/groupKey 仅用于首启实例化时把映射挂到新建分组(store 中分组 id 由 uuid 生成,故模板不写 id)。sourcePath 支持 %ENV%/~,实例化时由 api/dir.expandPath 展开为绝对路径;targetPath 为迁移目标绝对路径,可按需整体改盘符根目录。仅在宿主内、且 group/mapping 两表均为空的首次启动时实例化一次,之后以 store 持久化数据为准。", + "version": 1, + "groups": [ + { "key": "vscode", "name": "Visual Studio Code" }, + { "key": "qoder", "name": "Qoder IDE" } + ], + "mappings": [ + { + "groupKey": "vscode", + "name": "扩展目录", + "sourcePath": "%USERPROFILE%\\.vscode", + "targetPath": "D:\\DevTools\\AppData\\Visual Studio Code\\.vscode", + "exeNames": ["Code.exe", "Visual Studio Code.exe"], + "cachePatterns": ["Cache", "CachedData", "GPUCache", "Code Cache", "log"] + }, + { + "groupKey": "vscode", + "name": "配置目录", + "sourcePath": "%APPDATA%\\Code", + "targetPath": "D:\\DevTools\\AppData\\Visual Studio Code\\Code", + "exeNames": ["Code.exe", "Visual Studio Code.exe"], + "cachePatterns": ["Cache", "CachedData", "GPUCache", "Code Cache", "log"] + }, + { + "groupKey": "qoder", + "name": "扩展目录", + "sourcePath": "%USERPROFILE%\\.qoder", + "targetPath": "D:\\DevTools\\AppData\\Qoder IDE\\.qoder", + "exeNames": ["Qoder IDE.exe"], + "cachePatterns": ["Cache", "GPUCache", "Code Cache", "log"] + }, + { + "groupKey": "qoder", + "name": "配置目录", + "sourcePath": "%APPDATA%\\Qoder", + "targetPath": "D:\\DevTools\\AppData\\Qoder IDE\\Qoder", + "exeNames": ["Qoder IDE.exe"], + "cachePatterns": ["Cache", "GPUCache", "Code Cache", "log"] + } + ] +} diff --git a/plugins/link-relay/src/components/GroupDialog.vue b/plugins/link-relay/src/components/GroupDialog.vue new file mode 100644 index 000000000..a01175265 --- /dev/null +++ b/plugins/link-relay/src/components/GroupDialog.vue @@ -0,0 +1,126 @@ + + + + + diff --git a/plugins/link-relay/src/components/MappingDialog.vue b/plugins/link-relay/src/components/MappingDialog.vue new file mode 100644 index 000000000..cc2640b39 --- /dev/null +++ b/plugins/link-relay/src/components/MappingDialog.vue @@ -0,0 +1,158 @@ + + +