新增: 首页 + 资料导入 + PDF导出 + AI文档生成

This commit is contained in:
lxy
2026-08-17 09:34:12 +08:00
parent a5ebfd8b74
commit 352496c00a
13 changed files with 1698 additions and 12 deletions
+512
View File
@@ -9,10 +9,13 @@
"version": "1.0.0", "version": "1.0.0",
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"mammoth": "^1.12.0",
"pdfjs-dist": "^5.6.205",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2.11.4",
"@types/pdfjs-dist": "^2.10.377",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.0.5", "vite": "^6.0.5",
@@ -513,6 +516,256 @@
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@napi-rs/canvas": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.100.tgz",
"integrity": "sha512-xglYA6q3XO5P3BNJYxVZ1IV7DLVjp1Py6nwag88YntrS+3vKHyYcMqXVS4ZztJmwz2uGvz1FWhI/4LgbR5uQDA==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.100",
"@napi-rs/canvas-darwin-arm64": "0.1.100",
"@napi-rs/canvas-darwin-x64": "0.1.100",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.100",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.100",
"@napi-rs/canvas-linux-arm64-musl": "0.1.100",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-gnu": "0.1.100",
"@napi-rs/canvas-linux-x64-musl": "0.1.100",
"@napi-rs/canvas-win32-arm64-msvc": "0.1.100",
"@napi-rs/canvas-win32-x64-msvc": "0.1.100"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.100.tgz",
"integrity": "sha512-hjhCKhntPv9+t4ckHymdx0phYNcVW+GKQR6Lzw2zE+pOVjOplSmtx9nNNknTjbEDLcuLZqA1y8ufKg1XfgftzQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.100.tgz",
"integrity": "sha512-2PcswRaC7Ly645DGt88///zuFDhJxJYdKAs1uU3mfk1atYkXufgcgLfBpk6Tm12nCQBaNt1wpybuPZ4qOhTo8A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.100.tgz",
"integrity": "sha512-ePNZtj7pNIva/siZMg+HmbeozkIjqUIYdoymH8HaA3qK7LfzFN4WMBM8G6HQ9ZC+H3+Dnn5pqtiXpgLykaPOhw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.100.tgz",
"integrity": "sha512-d5cDB48oWFGU8/XPhUOFAlySgb/VAu7D+s8fi55K1Pcfg8aPplHWqMgibhVLU8ky7Pyg/fuiVLz4Nf3JrSTuUA==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.100.tgz",
"integrity": "sha512-rDxgxRu69RvDlX/bh9o22DxLsGr8EqsNgotL9+RwQE1S0b0cqeatqsw6aW45mukm0B42DIAaAacKaYQ8cqS1nw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.100.tgz",
"integrity": "sha512-K3mDW66N+xT2/V439u1alFANiBUjdEx2gLiNYnCmUsva5jZMxWTjafBYwTzYK+EMFMHrUoabuU+T1BIP5CgbYQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.100.tgz",
"integrity": "sha512-mooqUBTIsccZpnoQC4NgrC1v6C1vof39etLNMnBwCY+p0gajWJvAHLGQ6g/gGyS5YrpDW+GefSN4+Cvcr08UWw==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.100.tgz",
"integrity": "sha512-1eCvkDCazm7FFhsT7DfGOdSaHgZVK3bt/dSBl5EWHOWmnz+I7j8tPseJqqD81NF+MH21jKUK4wQSDjN0mdhnTg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.100.tgz",
"integrity": "sha512-20arT6lnI19S68qNlii73TSEDbECNgzMz2EpldC1V3mZFuRkeujXkcebRk0LRJe9SEUAooYiLokfMViY8IX7yA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-0.1.100.tgz",
"integrity": "sha512-DZFFT1wIAg37LJw37yhMRFfjATd3vTQzjZ1Yki8u2vhO6Hi5VE6BVaGQ1aaDu7xb4iMErz+9EOwjpS7xcxFeBw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.100",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.100.tgz",
"integrity": "sha512-MyT1j3mHC2+Lu4pBi9mKyMJhtP6U7k7EldY7sj/uS5gJA65gTXt8MefJQXLJo5d/vZbuWmfxzkEUNc/urV3pHA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@rollup/rollup-android-arm-eabi": { "node_modules/@rollup/rollup-android-arm-eabi": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", "resolved": "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz",
@@ -1097,6 +1350,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/pdfjs-dist": {
"version": "2.10.377",
"resolved": "https://registry.npmmirror.com/@types/pdfjs-dist/-/pdfjs-dist-2.10.377.tgz",
"integrity": "sha512-RNBWpazVTkC5aiUIIo6d7ojlL0hJSoeQP1QCvTsVLW6qkNEpTALiI0DNGCYAGDfhbL0r17c/zNPQ15lk948UXg==",
"dev": true,
"license": "MIT",
"dependencies": {
"pdfjs-dist": "*"
}
},
"node_modules/@vitejs/plugin-vue": { "node_modules/@vitejs/plugin-vue": {
"version": "5.2.4", "version": "5.2.4",
"resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz", "resolved": "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.4.tgz",
@@ -1276,6 +1539,15 @@
"integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==", "integrity": "sha512-l1rrBtBfTnmxvtsvdQDXltUUy8S1Y+ZaqdfUzmAnJkTd8Z8rv5v/ytW+TKiqEOWyHPoqtPlNFSs0lhRmYVSHVA==",
"license": "MIT" "license": "MIT"
}, },
"node_modules/@xmldom/xmldom": {
"version": "0.8.13",
"resolved": "https://registry.npmmirror.com/@xmldom/xmldom/-/xmldom-0.8.13.tgz",
"integrity": "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/alien-signals": { "node_modules/alien-signals": {
"version": "1.0.13", "version": "1.0.13",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz", "resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-1.0.13.tgz",
@@ -1283,6 +1555,15 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmmirror.com/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/balanced-match": { "node_modules/balanced-match": {
"version": "1.0.2", "version": "1.0.2",
"resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmmirror.com/balanced-match/-/balanced-match-1.0.2.tgz",
@@ -1290,6 +1571,32 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmmirror.com/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
],
"license": "MIT"
},
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmmirror.com/bluebird/-/bluebird-3.4.7.tgz",
"integrity": "sha512-iD3898SR7sWVRHbiQv+sHUtHnMvC1o3nW5rAcqnq3uOn07DSAppZYUkIGslDz6gXC7HfunPe7YVBgoEJASPcHA==",
"license": "MIT"
},
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz", "resolved": "https://registry.npmmirror.com/brace-expansion/-/brace-expansion-2.1.2.tgz",
@@ -1300,6 +1607,12 @@
"balanced-match": "^1.0.0" "balanced-match": "^1.0.0"
} }
}, },
"node_modules/core-util-is": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/core-util-is/-/core-util-is-1.0.3.tgz",
"integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
"license": "MIT"
},
"node_modules/csstype": { "node_modules/csstype": {
"version": "3.2.3", "version": "3.2.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz", "resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.2.3.tgz",
@@ -1313,6 +1626,21 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/dingbat-to-unicode": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/dingbat-to-unicode/-/dingbat-to-unicode-1.0.1.tgz",
"integrity": "sha512-98l0sW87ZT58pU4i61wa2OHwxbiYSbuxsCBozaVnYX2iCnr3bLM3fIes1/ej7h1YdOKuKt/MLs706TVnALA65w==",
"license": "BSD-2-Clause"
},
"node_modules/duck": {
"version": "0.1.12",
"resolved": "https://registry.npmmirror.com/duck/-/duck-0.1.12.tgz",
"integrity": "sha512-wkctla1O6VfP89gQ+J/yDesM0S7B7XLXjKGzXxMDVFg7uEn706niAtyYovKbyq1oT9YwDcly721/iUWoc8MVRg==",
"license": "BSD",
"dependencies": {
"underscore": "^1.13.1"
}
},
"node_modules/entities": { "node_modules/entities": {
"version": "7.0.1", "version": "7.0.1",
"resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz", "resolved": "https://registry.npmmirror.com/entities/-/entities-7.0.1.tgz",
@@ -1416,6 +1744,56 @@
"he": "bin/he" "he": "bin/he"
} }
}, },
"node_modules/immediate": {
"version": "3.0.6",
"resolved": "https://registry.npmmirror.com/immediate/-/immediate-3.0.6.tgz",
"integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
"license": "MIT"
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmmirror.com/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/isarray/-/isarray-1.0.0.tgz",
"integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
"license": "MIT"
},
"node_modules/jszip": {
"version": "3.10.1",
"resolved": "https://registry.npmmirror.com/jszip/-/jszip-3.10.1.tgz",
"integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
"license": "(MIT OR GPL-3.0-or-later)",
"dependencies": {
"lie": "~3.3.0",
"pako": "~1.0.2",
"readable-stream": "~2.3.6",
"setimmediate": "^1.0.5"
}
},
"node_modules/lie": {
"version": "3.3.0",
"resolved": "https://registry.npmmirror.com/lie/-/lie-3.3.0.tgz",
"integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
"license": "MIT",
"dependencies": {
"immediate": "~3.0.5"
}
},
"node_modules/lop": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/lop/-/lop-0.4.2.tgz",
"integrity": "sha512-RefILVDQ4DKoRZsJ4Pj22TxE3omDO47yFpkIBoDKzkqPRISs5U1cnAdg/5583YPkWPaLIYHOKRMQSvjFsO26cw==",
"license": "BSD-2-Clause",
"dependencies": {
"duck": "^0.1.12",
"option": "~0.2.1",
"underscore": "^1.13.1"
}
},
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.21", "version": "0.30.21",
"resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz", "resolved": "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.21.tgz",
@@ -1425,6 +1803,30 @@
"@jridgewell/sourcemap-codec": "^1.5.5" "@jridgewell/sourcemap-codec": "^1.5.5"
} }
}, },
"node_modules/mammoth": {
"version": "1.12.0",
"resolved": "https://registry.npmmirror.com/mammoth/-/mammoth-1.12.0.tgz",
"integrity": "sha512-cwnK1RIcRdDMi2HRx2EXGYlxqIEh0Oo3bLhorgnsVJi2UkbX1+jKxuBNR9PC5+JaX7EkmJxFPmo6mjLpqShI2w==",
"license": "BSD-2-Clause",
"dependencies": {
"@xmldom/xmldom": "^0.8.6",
"argparse": "~1.0.3",
"base64-js": "^1.5.1",
"bluebird": "~3.4.0",
"dingbat-to-unicode": "^1.0.1",
"jszip": "^3.7.1",
"lop": "^0.4.2",
"path-is-absolute": "^1.0.0",
"underscore": "^1.13.1",
"xmlbuilder": "^10.0.0"
},
"bin": {
"mammoth": "bin/mammoth"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "9.0.9", "version": "9.0.9",
"resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz", "resolved": "https://registry.npmmirror.com/minimatch/-/minimatch-9.0.9.tgz",
@@ -1466,6 +1868,25 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
} }
}, },
"node_modules/node-readable-to-web-readable-stream": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/node-readable-to-web-readable-stream/-/node-readable-to-web-readable-stream-0.4.2.tgz",
"integrity": "sha512-/cMZNI34v//jUTrI+UIo4ieHAB5EZRY/+7OmXZgBxaWBMcW2tGdceIw06RFxWxrKZ5Jp3sI2i5TsRo+CBhtVLQ==",
"license": "MIT",
"optional": true
},
"node_modules/option": {
"version": "0.2.4",
"resolved": "https://registry.npmmirror.com/option/-/option-0.2.4.tgz",
"integrity": "sha512-pkEqbDyl8ou5cpq+VsnQbe/WlEy5qS7xPzMS1U55OCG9KPvwFD46zDbxQIj3egJSFc3D+XhYOPUzz49zQAVy7A==",
"license": "BSD-2-Clause"
},
"node_modules/pako": {
"version": "1.0.11",
"resolved": "https://registry.npmmirror.com/pako/-/pako-1.0.11.tgz",
"integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
"license": "(MIT AND Zlib)"
},
"node_modules/path-browserify": { "node_modules/path-browserify": {
"version": "1.0.1", "version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz", "resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
@@ -1473,6 +1894,28 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/pdfjs-dist": {
"version": "5.6.205",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.6.205.tgz",
"integrity": "sha512-tlUj+2IDa7G1SbvBNN74UHRLJybZDWYom+k6p5KIZl7huBvsA4APi6mKL+zCxd3tLjN5hOOEE9Tv7VdzO88pfg==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.19.0 || >=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.96",
"node-readable-to-web-readable-stream": "^0.4.2"
}
},
"node_modules/picocolors": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -1520,6 +1963,27 @@
"node": "^10 || ^12 || >=14" "node": "^10 || ^12 || >=14"
} }
}, },
"node_modules/process-nextick-args": {
"version": "2.0.1",
"resolved": "https://registry.npmmirror.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
"node_modules/readable-stream": {
"version": "2.3.8",
"resolved": "https://registry.npmmirror.com/readable-stream/-/readable-stream-2.3.8.tgz",
"integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
"license": "MIT",
"dependencies": {
"core-util-is": "~1.0.0",
"inherits": "~2.0.3",
"isarray": "~1.0.0",
"process-nextick-args": "~2.0.0",
"safe-buffer": "~5.1.1",
"string_decoder": "~1.1.1",
"util-deprecate": "~1.0.1"
}
},
"node_modules/rollup": { "node_modules/rollup": {
"version": "4.62.2", "version": "4.62.2",
"resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmmirror.com/rollup/-/rollup-4.62.2.tgz",
@@ -1565,6 +2029,18 @@
"fsevents": "~2.3.2" "fsevents": "~2.3.2"
} }
}, },
"node_modules/safe-buffer": {
"version": "5.1.2",
"resolved": "https://registry.npmmirror.com/safe-buffer/-/safe-buffer-5.1.2.tgz",
"integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
"license": "MIT"
},
"node_modules/setimmediate": {
"version": "1.0.5",
"resolved": "https://registry.npmmirror.com/setimmediate/-/setimmediate-1.0.5.tgz",
"integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
"license": "MIT"
},
"node_modules/source-map-js": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1574,6 +2050,21 @@
"node": ">=0.10.0" "node": ">=0.10.0"
} }
}, },
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmmirror.com/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/string_decoder": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/string_decoder/-/string_decoder-1.1.1.tgz",
"integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
"license": "MIT",
"dependencies": {
"safe-buffer": "~5.1.0"
}
},
"node_modules/tinyglobby": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1605,6 +2096,18 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/underscore": {
"version": "1.13.8",
"resolved": "https://registry.npmmirror.com/underscore/-/underscore-1.13.8.tgz",
"integrity": "sha512-DXtD3ZtEQzc7M8m4cXotyHR+FAS18C64asBYY5vqZexfYryNNnDc02W4hKg3rdQuqOYas1jkseX0+nZXjTXnvQ==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmmirror.com/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": { "node_modules/vite": {
"version": "6.4.3", "version": "6.4.3",
"resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz", "resolved": "https://registry.npmmirror.com/vite/-/vite-6.4.3.tgz",
@@ -1724,6 +2227,15 @@
"peerDependencies": { "peerDependencies": {
"typescript": ">=5.0.0" "typescript": ">=5.0.0"
} }
},
"node_modules/xmlbuilder": {
"version": "10.1.1",
"resolved": "https://registry.npmmirror.com/xmlbuilder/-/xmlbuilder-10.1.1.tgz",
"integrity": "sha512-OyzrcFLL/nb6fMGHbiRDuPup9ljBycsdCypwuyg5AAHvyWzGfChJpCXMG88AGTIMFhGZ9RccFN1e6lhg3hkwKg==",
"license": "MIT",
"engines": {
"node": ">=4.0"
}
} }
} }
} }
+5 -1
View File
@@ -5,7 +5,8 @@
"private": true, "private": true,
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite --mode web", "dev": "vite",
"dev:web": "vite --mode web",
"build": "vue-tsc -b && vite build", "build": "vue-tsc -b && vite build",
"preview": "vite preview", "preview": "vite preview",
"tauri": "tauri", "tauri": "tauri",
@@ -14,10 +15,13 @@
}, },
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.11.1", "@tauri-apps/api": "^2.11.1",
"mammoth": "^1.12.0",
"pdfjs-dist": "^5.6.205",
"vue": "^3.5.13" "vue": "^3.5.13"
}, },
"devDependencies": { "devDependencies": {
"@tauri-apps/cli": "^2.11.4", "@tauri-apps/cli": "^2.11.4",
"@types/pdfjs-dist": "^2.10.377",
"@vitejs/plugin-vue": "^5.2.1", "@vitejs/plugin-vue": "^5.2.1",
"typescript": "~5.6.3", "typescript": "~5.6.3",
"vite": "^6.0.5", "vite": "^6.0.5",
+2 -2
View File
@@ -60,7 +60,7 @@ pub fn exec_op(state: State<AppState>, op_json: serde_json::Value) -> Result<OpR
.map_err(|e| format!("Op 解析失败: {}", e))?; .map_err(|e| format!("Op 解析失败: {}", e))?;
let mut deck = state.deck.lock().map_err(|e| e.to_string())?; let mut deck = state.deck.lock().map_err(|e| e.to_string())?;
let backward_op = invert_op(&deck, &op); let _backward_op = invert_op(&deck, &op);
*deck = apply_op(&deck, &op); *deck = apply_op(&deck, &op);
let mut hist = state.history.lock().map_err(|e| e.to_string())?; let mut hist = state.history.lock().map_err(|e| e.to_string())?;
@@ -154,7 +154,7 @@ pub fn ping() -> String {
/// 保存 deck 到文件 /// 保存 deck 到文件
#[tauri::command] #[tauri::command]
pub fn save_file(content: String) -> Result<String, String> { pub fn save_file(_content: String) -> Result<String, String> {
Ok("ok".to_string()) Ok("ok".to_string())
} }
+3
View File
@@ -1,3 +1,6 @@
// 本文件的 struct/fn 多数是 TS 端的 Rust 镜像供序列化参考,非死代码
#![allow(dead_code)]
mod ai; mod ai;
mod color; mod color;
mod commands; mod commands;
+1 -1
View File
@@ -337,7 +337,7 @@ pub fn invert_op(input_deck: &Deck, op: &crate::model::Op) -> crate::model::Op {
"del_element" => { "del_element" => {
let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize); let slide_idx = op.payload.get("slideIdx").or_else(|| op.payload.get("slide_idx")).and_then(|v| v.as_f64()).map(|f| f as usize);
let element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string(); let _element_id = op.payload.get("elementId").or_else(|| op.payload.get("element_id")).and_then(|v| v.as_str()).unwrap_or("").to_string();
if let Some(deleted) = op.payload.get("deletedElement").or_else(|| op.payload.get("deleted_element")) { if let Some(deleted) = op.payload.get("deletedElement").or_else(|| op.payload.get("deleted_element")) {
Op { Op {
op_type: "add_element".into(), op_type: "add_element".into(),
+60 -8
View File
@@ -2,7 +2,7 @@
App.vue 根组件编辑/演示模式切换工具栏三栏布局弹窗 App.vue 根组件编辑/演示模式切换工具栏三栏布局弹窗
===================================================================== --> ===================================================================== -->
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue' import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
import { store } from './core/store' import { store } from './core/store'
import Toolbar from './components/editor/Toolbar.vue' import Toolbar from './components/editor/Toolbar.vue'
import ThumbBar from './components/editor/ThumbBar.vue' import ThumbBar from './components/editor/ThumbBar.vue'
@@ -12,10 +12,13 @@ import AiPanel from './components/ai/AiPanel.vue'
import SettingsModal from './components/modals/SettingsModal.vue' import SettingsModal from './components/modals/SettingsModal.vue'
import LibraryModal from './components/modals/LibraryModal.vue' import LibraryModal from './components/modals/LibraryModal.vue'
import TemplateModal from './components/modals/TemplateModal.vue' import TemplateModal from './components/modals/TemplateModal.vue'
import ImportModal from './components/modals/ImportModal.vue'
import PrintModal from './components/modals/PrintModal.vue'
import PresentMode from './components/present/PresentMode.vue' import PresentMode from './components/present/PresentMode.vue'
import HomePage from './components/HomePage.vue'
/* ---------- 模式 ---------- */ /* ---------- 模式 ---------- */
const mode = ref<'editor' | 'present'>('editor') const mode = ref<'home' | 'editor' | 'present'>('home')
const presentVisible = ref(false) const presentVisible = ref(false)
const presentStartIndex = ref(0) const presentStartIndex = ref(0)
@@ -29,6 +32,9 @@ function switchTab(name: 'props' | 'ai') {
const settingsVisible = ref(false) const settingsVisible = ref(false)
const libraryVisible = ref(false) const libraryVisible = ref(false)
const templateVisible = ref(false) const templateVisible = ref(false)
const importVisible = ref(false)
const printVisible = ref(false)
const deckLoaded = ref(false) // 文库/导入是否加载了新 deck,防止误切
/* ---------- toast ---------- */ /* ---------- toast ---------- */
const toastText = ref('') const toastText = ref('')
@@ -96,6 +102,7 @@ function onImportJson() {
try { try {
store.importJSON(reader.result as string) store.importJSON(reader.result as string)
toast('已导入') toast('已导入')
if (mode.value === 'home') mode.value = 'editor'
} catch (e: any) { } catch (e: any) {
toast('导入失败:' + (e?.message || String(e))) toast('导入失败:' + (e?.message || String(e)))
} }
@@ -105,13 +112,27 @@ function onImportJson() {
input.click() input.click()
} }
function onNewBlank() {
store.newBlankDeck()
deckLoaded.value = true
mode.value = 'editor'
}
function onImportMaterials() { importVisible.value = true }
function onImportClose() {
importVisible.value = false
if (mode.value === 'home') {
const slides = store.getSlides()
// 不是仅有 1 页空白页时视为已导入内容
if (slides.length > 1 || slides[0]?.elements?.length > 0) {
mode.value = 'editor'
}
}
}
function onExportPdf() { function onExportPdf() {
// 用浏览器打印 + @media print CSS 实现 PDF 导出 printVisible.value = true
// 演示模式的 slide-layer 已有 print 样式
document.body.classList.add('printing')
window.print()
setTimeout(() => document.body.classList.remove('printing'), 500)
toast('在打印对话框中选择「另存为 PDF」')
} }
/* ---------- 全局快捷键(编辑模式) ---------- */ /* ---------- 全局快捷键(编辑模式) ---------- */
@@ -146,12 +167,32 @@ function onKey(e: KeyboardEvent) {
onMounted(() => { onMounted(() => {
document.addEventListener('keydown', onKey) document.addEventListener('keydown', onKey)
}) })
/* 文库关闭时若在首页且从文库加载了 deck 则切入编辑 */
watch(libraryVisible, (now, prev) => {
if (prev && !now && deckLoaded.value) {
deckLoaded.value = false
if (mode.value === 'home') mode.value = 'editor'
}
})
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('keydown', onKey) document.removeEventListener('keydown', onKey)
}) })
</script> </script>
<template> <template>
<!-- ===================== 首页 ===================== -->
<HomePage
v-show="mode === 'home'"
@new-blank="onNewBlank"
@open-library="libraryVisible = true"
@import-materials="importVisible = true"
@open-deck="deckLoaded = true; mode = 'editor'"
@ai-create="onNewBlank(); switchTab('ai')"
@toast="toast"
@open-settings="settingsVisible = true"
/>
<!-- ===================== 编辑模式 ===================== --> <!-- ===================== 编辑模式 ===================== -->
<div v-show="mode === 'editor'" class="app-editor"> <div v-show="mode === 'editor'" class="app-editor">
<Toolbar <Toolbar
@@ -163,6 +204,7 @@ onUnmounted(() => {
@save="onSave" @save="onSave"
@open-templates="templateVisible = true" @open-templates="templateVisible = true"
@export-json="onExportJson" @export-json="onExportJson"
@import-materials="onImportMaterials"
@import-json="onImportJson" @import-json="onImportJson"
@export-pdf="onExportPdf" @export-pdf="onExportPdf"
/> />
@@ -218,6 +260,16 @@ onUnmounted(() => {
@close="templateVisible = false" @close="templateVisible = false"
@toast="toast" @toast="toast"
/> />
<ImportModal
:visible="importVisible"
@close="onImportClose"
@toast="toast"
@open-settings="settingsVisible = true"
/>
<PrintModal
v-if="printVisible"
@close="printVisible = false"
/>
<!-- 轻提示 --> <!-- 轻提示 -->
<div class="toast" :class="{ show: toastShow }">{{ toastText }}</div> <div class="toast" :class="{ show: toastShow }">{{ toastText }}</div>
+266
View File
@@ -0,0 +1,266 @@
<!-- =====================================================================
HomePage.vue 首页品牌展示快捷入口最近文库
===================================================================== -->
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { store } from '../core/store'
import type { LibItem } from '../core/types'
const emit = defineEmits<{
(e: 'new-blank'): void
(e: 'open-library'): void
(e: 'import-materials'): void
(e: 'open-settings'): void
(e: 'toast', msg: string): void
(e: 'open-deck'): void
(e: 'ai-create'): void
}>()
const libVersion = ref(0)
const library = computed<LibItem[]>(() => {
void libVersion.value
return store.getLibrary().slice(0, 6) // 6
})
function bump() { libVersion.value++ }
onMounted(bump)
function loadItem(id: string) {
if (store.loadFromLibrary(id)) {
emit('toast', '已加载:' + (library.value.find(x => x.id === id)?.name || ''))
emit('open-deck')
}
}
function formatTime(ts: number): string {
if (!ts) return ''
const diff = Date.now() - ts
if (diff < 60000) return '刚刚'
if (diff < 3600000) return Math.floor(diff / 60000) + ' 分钟前'
if (diff < 86400000) return Math.floor(diff / 3600000) + ' 小时前'
const d = new Date(ts)
return d.getMonth() + 1 + '/' + d.getDate()
}
function firstSlideTitle(item: LibItem): string {
const el = item?.deck?.slides?.[0]?.elements?.[0]
return el?.type === 'title' ? (el.content || '无标题') : '无标题'
}
</script>
<template>
<div class="home">
<!-- 背景装饰 -->
<div class="home-bg">
<div class="home-glow top-right"></div>
<div class="home-glow bottom-left"></div>
</div>
<div class="home-inner">
<!-- 品牌区 -->
<header class="home-brand">
<span class="home-logo"></span>
<h1 class="home-title">u-ppt</h1>
<p class="home-desc">轻量在线演示工具 · 支持 AI 创作与本地资料导入</p>
</header>
<!-- 快捷入口 -->
<div class="home-actions">
<button class="action-card" @click="emit('new-blank')">
<span class="action-icon"></span>
<span class="action-label">新建空白</span>
<span class="action-hint">从空白页开始创作</span>
</button>
<button class="action-card" @click="emit('open-library')">
<span class="action-icon">📁</span>
<span class="action-label">从文库打开</span>
<span class="action-hint">继续已有的演示</span>
</button>
<button class="action-card" @click="emit('import-materials')">
<span class="action-icon">📂</span>
<span class="action-label">导入资料</span>
<span class="action-hint">图片/PDF/DOCX AI 生成</span>
</button>
<button class="action-card accent" @click="emit('ai-create')">
<span class="action-icon">🤖</span>
<span class="action-label">AI 创作</span>
<span class="action-hint">输入主题AI 生成全套演示</span>
</button>
</div>
<!-- 最近文库 -->
<div v-if="library.length > 0" class="home-recent">
<h2 class="section-title">最近文档</h2>
<div class="recent-list">
<button
v-for="item in library"
:key="item.id"
class="recent-item"
@click="loadItem(item.id)"
:title="item.name"
>
<span class="recent-icon">📄</span>
<span class="recent-info">
<span class="recent-name">{{ item.name }}</span>
<span class="recent-meta">
{{ item.deck.slides.length }} · {{ formatTime(item.updatedAt || item.createdAt) }}
</span>
</span>
<span class="recent-preview">{{ firstSlideTitle(item) }}</span>
</button>
</div>
</div>
<!-- 底部 -->
<footer class="home-footer">
<button class="btn ghost" @click="emit('open-settings')"> 设置</button>
</footer>
</div>
</div>
</template>
<style scoped>
.home {
height: 100vh;
display: flex;
align-items: center;
justify-content: center;
position: relative;
overflow: hidden;
background: var(--ui-bg, #f1f5f9);
}
/* 背景光晕 */
.home-bg { position: absolute; inset: 0; pointer-events: none; }
.home-glow {
position: absolute;
width: 480px; height: 480px;
border-radius: 50%;
filter: blur(120px);
opacity: .08;
}
.home-glow.top-right { top: -120px; right: -80px; background: var(--ui-primary, #4f46e5); }
.home-glow.bottom-left { bottom: -160px; left: -80px; background: var(--ui-primary, #4f46e5); }
.home-inner {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
gap: 40px;
padding: 40px 24px;
max-width: 680px;
width: 100%;
}
/* 品牌 */
.home-brand { text-align: center; }
.home-logo {
font-size: 56px; line-height: 1;
color: var(--ui-primary, #4f46e5);
display: block; margin-bottom: 8px;
}
.home-title {
font-size: 32px; font-weight: 700;
letter-spacing: -.02em;
color: var(--ui-text, #1e293b);
}
.home-desc {
margin-top: 6px;
font-size: 15px;
color: var(--ui-muted, #64748b);
}
/* 快捷入口网格 */
.home-actions {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 12px;
width: 100%;
}
.action-card {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
padding: 20px;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 12px;
background: var(--ui-panel, #fff);
cursor: pointer;
transition: all .15s;
text-align: left;
}
.action-card:hover {
border-color: var(--ui-primary, #4f46e5);
box-shadow: var(--shadow-md, 0 4px 12px rgba(15,23,42,.08));
transform: translateY(-2px);
}
.action-card:active { transform: translateY(0); }
.action-icon { font-size: 28px; line-height: 1; margin-bottom: 4px; }
.action-label { font-size: 16px; font-weight: 600; color: var(--ui-text, #1e293b); }
.action-hint { font-size: 13px; color: var(--ui-muted, #64748b); }
/* 最近文档 */
.home-recent { width: 100%; }
.section-title {
font-size: 14px; font-weight: 600;
color: var(--ui-muted, #64748b);
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: .05em;
}
.recent-list {
display: flex;
flex-direction: column;
border: 1px solid var(--ui-border, #e2e8f0);
border-radius: 10px;
overflow: hidden;
background: var(--ui-panel, #fff);
}
.recent-item {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
border: none;
border-bottom: 1px solid var(--ui-border, #f1f5f9);
background: transparent;
cursor: pointer;
transition: background .1s;
text-align: left;
width: 100%;
}
.recent-item:last-child { border-bottom: none; }
.recent-item:hover { background: var(--ui-hover, #f1f5f9); }
.recent-icon { font-size: 20px; flex-shrink: 0; }
.recent-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.recent-name {
font-size: 14px; font-weight: 500;
color: var(--ui-text, #1e293b);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recent-meta {
font-size: 12px;
color: var(--ui-muted, #64748b);
}
.recent-preview {
font-size: 13px;
color: var(--ui-muted, #64748b);
max-width: 160px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* 底部 */
.home-footer { display: flex; gap: 8px; }
</style>
+2
View File
@@ -15,6 +15,7 @@ const emit = defineEmits<{
(e: 'open-templates'): void (e: 'open-templates'): void
(e: 'export-json'): void (e: 'export-json'): void
(e: 'import-json'): void (e: 'import-json'): void
(e: 'import-materials'): void
(e: 'export-pdf'): void (e: 'export-pdf'): void
}>() }>()
@@ -74,6 +75,7 @@ defineProps<{ disabledActions?: string[] }>()
<button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button> <button class="btn" data-action="library" title="我的演示文库" @click="action('library')">📁 文库</button>
<button class="btn" data-action="save" title="保存到文库" :disabled="disabledActions?.includes('save')" @click="action('save')">💾 保存</button> <button class="btn" data-action="save" title="保存到文库" :disabled="disabledActions?.includes('save')" @click="action('save')">💾 保存</button>
<button class="btn" data-action="export-json" title="导出 JSON" @click="emit('export-json')">📤 导出</button> <button class="btn" data-action="export-json" title="导出 JSON" @click="emit('export-json')">📤 导出</button>
<button class="btn" data-action="import-materials" title="导入本地资料(图片/文档/目录)" @click="emit('import-materials')">📂 资料</button>
<button class="btn" data-action="import-json" title="导入 JSON" @click="emit('import-json')">📥 导入</button> <button class="btn" data-action="import-json" title="导入 JSON" @click="emit('import-json')">📥 导入</button>
<button class="btn" data-action="export-pdf" title="打印/导出 PDF" @click="emit('export-pdf')">🖨 PDF</button> <button class="btn" data-action="export-pdf" title="打印/导出 PDF" @click="emit('export-pdf')">🖨 PDF</button>
<button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button> <button class="btn" data-action="open-ai" title="打开 AI 助手" @click="action('open-ai')">🤖 AI</button>
+373
View File
@@ -0,0 +1,373 @@
<!-- =====================================================================
ImportModal.vue 本地资料导入对话框
支持图片直接插入文档PDF/DOCX/MD/TXT LLM 分析生成幻灯片
===================================================================== -->
<script setup lang="ts">
import { ref, computed } from 'vue'
import { store } from '../../core/store'
import {
scanFiles, readEntries,
aiAnalyzeDocument,
fileToImageElement, imagesToSlideElements,
type FileEntry, type ImportReport,
describeReport
} from '../../core/importer'
const props = withDefaults(defineProps<{ visible: boolean }>(), { visible: false })
const emit = defineEmits<{
(e: 'close'): void
(e: 'toast', msg: string): void
(e: 'open-settings'): void
}>()
/* ---------- 状态 ---------- */
const activeTab = ref<'files' | 'dir'>('files')
const entries = ref<FileEntry[]>([])
const loading = ref(false)
const loaded = ref(false)
const analyzing = ref(false) // AI
const analyzeDone = ref(false) // AI
const analyzeError = ref('') // AI
const showImages = ref(true)
const showDocs = ref(true)
/* ---------- 过滤后的文件 ---------- */
const filteredEntries = computed(() => {
return entries.value.filter(e => {
if (e.kind === 'image' && !showImages.value) return false
if (e.kind === 'document' && !showDocs.value) return false
return true
})
})
const imageEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'image'))
const docEntries = computed(() => filteredEntries.value.filter(e => e.kind === 'document'))
const unsupportedCount = computed(() => entries.value.filter(e => e.kind === 'unsupported').length)
/** 是否有文档需要 AI 分析 */
const hasDocs = computed(() => docEntries.value.length > 0)
/** AI 是否已配置 */
const aiReady = computed(() => !!store.getCfg().key)
/* ---------- 打开文件选择器 ---------- */
let fileInput: HTMLInputElement | null = null
let dirInput: HTMLInputElement | null = null
function openFilePicker() {
if (!fileInput) {
fileInput = document.createElement('input')
fileInput.type = 'file'
fileInput.multiple = true
fileInput.accept = '.png,.jpg,.jpeg,.gif,.webp,.svg,.md,.markdown,.txt,.text,.pdf,.docx,.doc'
fileInput.onchange = () => handleFiles(fileInput!.files)
}
fileInput.value = ''
fileInput.click()
}
function openDirPicker() {
if (!dirInput) {
dirInput = document.createElement('input')
dirInput.type = 'file'
;(dirInput as any).webkitdirectory = true
dirInput.onchange = () => handleFiles(dirInput!.files)
}
dirInput.value = ''
dirInput.click()
}
async function handleFiles(fl: FileList | null) {
if (!fl || fl.length === 0) return
loading.value = true
loaded.value = false
analyzeDone.value = false
analyzeError.value = ''
try {
const scanned = await scanFiles(fl)
entries.value = scanned
//
if (scanned.some(e => e.kind === 'image' || e.kind === 'document')) {
await readEntries(scanned)
}
loaded.value = true
} catch (e: any) {
emit('toast', '读取失败: ' + (e?.message || String(e)))
} finally {
loading.value = false
}
}
/* ---------- AI 分析文档 ---------- */
async function runAiAnalysis() {
const docs = docEntries.value.filter(d => d.data && !d.slides)
if (docs.length === 0) return
if (!aiReady.value) {
analyzeError.value = '请先在 ⚙ 设置中配置 AI API Key'
return
}
analyzing.value = true
analyzeError.value = ''
let successCount = 0
for (const doc of docs) {
analyzeError.value = `正在 AI 分析(${successCount + 1}/${docs.length}):${doc.name}`
try {
await aiAnalyzeDocument(doc)
successCount++
} catch (e: any) {
const msg = e?.message || String(e)
doc.error = msg
analyzeError.value = `${doc.name}」分析失败: ${msg}`
console.warn(`AI 分析失败 ${doc.name}:`, e)
}
}
analyzing.value = false
analyzeDone.value = successCount > 0
if (successCount > 0 && !analyzeError.value) {
emit('toast', `AI 完成 ${successCount} 个文档分析,共生成 ${docs.reduce((s, d) => s + (d.slides?.length || 0), 0)} 页幻灯片`)
}
}
/* ---------- 执行导入 ---------- */
function doImport() {
const images = imageEntries.value.filter(e => e.data)
const docs = docEntries.value.filter(e => e.slides && e.slides.length > 0)
if (images.length === 0 && docs.length === 0) {
// AI
if (docEntries.value.filter(d => d.data && !d.slides).length > 0) {
void runAiAnalysis() // fire-and-forget UI
return
}
emit('toast', '没有可导入的内容')
return
}
let insertedImages = 0
let insertedSlides = 0
// 1.
if (images.length > 0) {
if (images.length === 1) {
const el = fileToImageElement(images[0].file, images[0].data!)
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
insertedImages = 1
} else {
const els = imagesToSlideElements(images.map(f => ({ file: f.file, dataUrl: f.data! })))
for (const el of els) {
store.addElement('image', { content: el.content, x: el.x, y: el.y, w: el.w, h: el.h, style: el.style })
insertedImages++
}
}
}
// 2. AI deck
if (docs.length > 0) {
for (const d of docs) {
if (d.slides) {
for (const slide of d.slides) {
store.appendSlide(slide)
insertedSlides++
}
}
}
}
const report: ImportReport = { images: insertedImages, slides: insertedSlides, files: entries.value.length }
emit('toast', describeReport(report))
emit('close')
}
/* ---------- 清除 ---------- */
function clearFiles() {
entries.value = []
loaded.value = false
analyzeDone.value = false
analyzeError.value = ''
}
/* ---------- 格式友好文件大小 ---------- */
function fmtSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1024 / 1024).toFixed(1) + ' MB'
}
/** 截取文本预览前一段 */
function textPreview(data: string, maxLen = 80): string {
return data.replace(/\n/g, ' ').slice(0, maxLen) + (data.length > maxLen ? '…' : '')
}
</script>
<template>
<div v-if="props.visible" class="modal-mask" @click.self="emit('close')">
<div class="modal import-modal">
<header class="modal-header">
<h2>📂 导入本地资料</h2>
<button class="close" @click="emit('close')"></button>
</header>
<!-- ======== 选择区域 ======== -->
<div v-if="!loaded" class="import-select">
<div class="import-tabs">
<button class="tab" :class="{ active: activeTab === 'files' }" @click="activeTab = 'files'">📄 选择文件</button>
<button class="tab" :class="{ active: activeTab === 'dir' }" @click="activeTab = 'dir'">📁 读取目录</button>
</div>
<div v-if="activeTab === 'files'" class="dropzone" @click="openFilePicker">
<div class="dropzone-icon">📄</div>
<div class="dropzone-text">
<strong>点击选择文件</strong>
<span class="hint">图片直接插入 · 文档PDF/DOCX/MD/TXT AI 分析生成幻灯片</span>
</div>
</div>
<div v-else class="dropzone" @click="openDirPicker">
<div class="dropzone-icon">📁</div>
<div class="dropzone-text">
<strong>点击选择目录</strong>
<span class="hint">读取目录下所有支持的图片和文档AI 自动分析生成</span>
</div>
</div>
<div v-if="loading" class="loading">
<span class="spin"></span> 正在读取文件...
</div>
</div>
<!-- ======== 预览区 ======== -->
<div v-else class="import-preview">
<div class="preview-actions">
<span class="file-count">
已选 {{ filteredEntries.length }} 个文件
<template v-if="unsupportedCount > 0">{{ unsupportedCount }} 个不支持的类型已忽略</template>
</span>
<button class="btn-sm" @click="clearFiles">重新选择</button>
</div>
<!-- 过滤 -->
<div class="filter-bar">
<label class="chk"><input type="checkbox" v-model="showImages" /> 🖼 图片 ({{ imageEntries.length }})</label>
<label class="chk"><input type="checkbox" v-model="showDocs" /> 📄 文档 ({{ docEntries.length }})</label>
</div>
<!-- AI 分析进度 -->
<div v-if="analyzing" class="ai-progress">
<span class="spin"></span> AI 正在分析文档内容理解语义并生成幻灯片
</div>
<!-- AI 分析错误 -->
<div v-if="analyzeError && !analyzing" class="ai-error">
{{ analyzeError }}
<template v-if="!aiReady">
<button class="btn-sm" @click="emit('open-settings')">去配置</button>
</template>
</div>
<!-- 文件列表 -->
<div class="file-list">
<div v-for="(entry, i) in filteredEntries" :key="i" class="file-item" :class="entry.kind">
<span class="file-icon">{{ entry.kind === 'image' ? '🖼' : '📄' }}</span>
<span class="file-name" :title="entry.name">{{ entry.name }}</span>
<span class="file-size">{{ fmtSize(entry.file.size) }}</span>
<span class="file-status">
<!-- 图片 -->
<template v-if="entry.kind === 'image' && entry.data"> 图片就绪</template>
<!-- 文档AI 分析结果 -->
<template v-else-if="entry.kind === 'document' && entry.slides"> AI {{ entry.slides.length }} </template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzeDone"> 待分析</template>
<template v-else-if="entry.kind === 'document' && entry.data && analyzing"> AI 分析中</template>
<template v-else-if="entry.kind === 'document' && entry.data">📄 {{ textPreview(entry.data) }}</template>
<template v-else-if="entry.error"> {{ entry.error }}</template>
<template v-else-if="entry.kind === 'unsupported'"> 跳过</template>
<template v-else> 读取中</template>
</span>
</div>
</div>
<!-- 文档幻灯片预览 -->
<div v-if="docEntries.filter(e => e.slides).length > 0" class="preview-slides">
<span class="preview-title">🤖 AI 解析结果</span>
<div class="preview-scroll">
<div v-for="(entry, i) in docEntries.filter(e => e.slides)" :key="'p' + i" class="preview-item">
<strong>{{ entry.name }}</strong>
{{ entry.slides?.length }} 页幻灯片
<span class="preview-titles">
{{ entry.slides?.map(s => {
const t = s.elements.find(e => e.type === 'title')
return t ? t.content.slice(0, 20) : '无标题'
}).join(' · ') }}
</span>
</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="import-actions">
<button class="btn primary" @click="doImport" :disabled="filteredEntries.length === 0 || analyzing">
{{ hasDocs && !analyzeDone ? '🤖 AI 分析文档并导入' : '✅ 导入以上内容' }}
</button>
<button class="btn" @click="emit('close')">取消</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.import-modal { max-width: 660px; max-height: 85vh; display: flex; flex-direction: column; }
.import-select { padding: 8px 0; }
.import-tabs { display: flex; gap: 4px; margin-bottom: 16px; }
.import-tabs .tab {
flex: 1; padding: 8px 16px; border: 1px solid var(--border, #e2e8f0);
background: var(--bg, #fff); border-radius: 8px; cursor: pointer;
font-size: 14px; transition: all .15s;
}
.import-tabs .tab.active { background: var(--primary, #4f46e5); color: #fff; border-color: var(--primary, #4f46e5); }
.dropzone {
border: 2px dashed var(--border, #cbd5e1); border-radius: 12px; padding: 36px 24px;
display: flex; align-items: center; gap: 20px; cursor: pointer; transition: all .2s;
background: var(--panel, #f8fafc);
}
.dropzone:hover { border-color: var(--primary, #4f46e5); background: color-mix(in srgb, var(--primary, #4f46e5) 5%, var(--panel, #f8fafc)); }
.dropzone-icon { font-size: 44px; }
.dropzone-text { display: flex; flex-direction: column; gap: 4px; }
.dropzone-text strong { font-size: 16px; }
.hint { font-size: 13px; color: var(--muted, #64748b); }
.import-preview { display: flex; flex-direction: column; gap: 8px; overflow: hidden; }
.preview-actions { display: flex; justify-content: space-between; align-items: center; }
.file-count { font-size: 14px; color: var(--muted, #64748b); }
.btn-sm { padding: 4px 12px; font-size: 13px; border: 1px solid var(--border, #e2e8f0); border-radius: 6px; background: var(--bg, #fff); cursor: pointer; }
.filter-bar { display: flex; gap: 16px; padding: 4px 0; }
.filter-bar .chk { display: flex; align-items: center; gap: 6px; font-size: 14px; cursor: pointer; }
.ai-progress { padding: 6px 12px; background: rgba(79, 70, 229, 0.06); border-radius: 8px; font-size: 13px; color: var(--primary, #4f46e5); }
.ai-error { padding: 6px 12px; background: rgba(239, 68, 68, 0.06); border-radius: 8px; font-size: 13px; color: #dc2626; display: flex; align-items: center; gap: 8px; }
.file-list { max-height: 200px; overflow-y: auto; border: 1px solid var(--border, #e2e8f0); border-radius: 8px; display: flex; flex-direction: column; }
.file-item { display: flex; align-items: center; gap: 8px; padding: 5px 10px; font-size: 13px; border-bottom: 1px solid var(--border, #f1f5f9); }
.file-item:last-child { border-bottom: none; }
.file-item.document { background: rgba(5, 150, 105, 0.04); }
.file-item.unsupported { opacity: 0.5; }
.file-icon { font-size: 16px; width: 24px; text-align: center; }
.file-name { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { color: var(--muted, #64748b); min-width: 56px; text-align: right; }
.file-status { min-width: 80px; text-align: right; font-size: 12px; }
.preview-slides { font-size: 13px; }
.preview-title { font-weight: 600; display: block; margin-bottom: 4px; }
.preview-scroll { max-height: 100px; overflow-y: auto; }
.preview-item { padding: 3px 0; }
.preview-titles { display: block; font-size: 12px; color: var(--muted, #64748b); margin-top: 1px; }
.import-actions { display: flex; gap: 8px; justify-content: flex-end; padding-top: 8px; border-top: 1px solid var(--border, #e2e8f0); }
.loading { text-align: center; padding: 24px; color: var(--muted, #64748b); font-size: 14px; }
.spin { display: inline-block; animation: spin 1s linear infinite; }
@keyframes spin { to { transform: rotate(360deg); } }
</style>
+185
View File
@@ -0,0 +1,185 @@
<!-- =====================================================================
PrintModal.vue PDF 导出全量幻灯片打印预览
渲染所有 slide隐藏编辑 UI纯展示
===================================================================== -->
<script setup lang="ts">
import type { Slide } from '../../core/types'
import { store, resolveBg, resolveColor } from '../../core/store'
const emit = defineEmits<{ (e: 'close'): void }>()
const deck = store.getDeck()
const slides = deck.slides
function onPrint() { window.print() }
</script>
<template>
<div class="print-modal" @print="onPrint">
<!-- 操作栏打印时不显示 -->
<div class="print-bar no-print">
<button class="btn primary" @click="onPrint">🖨 打印 / 另存为 PDF</button>
<span class="muted">{{ slides.length }} </span>
<span class="sep"></span>
<button class="btn ghost" @click="emit('close')"> 关闭</button>
</div>
<!-- 所有幻灯片逐页渲染 -->
<div
v-for="(slide, i) in slides"
:key="slide.id"
class="print-page"
:style="{ background: resolveBg(slide.background) }"
>
<!-- 页码每页第一个标题元素作为内容提示 -->
<div class="print-number">{{ i + 1 }}</div>
<!-- 元素渲染 -->
<div
v-for="el in slide.elements"
:key="el.id"
class="el"
:data-type="el.type"
:style="{
left: el.x + '%',
top: el.y + '%',
width: el.w + '%',
height: el.h + '%',
fontSize: el.style?.fontSize ? el.style.fontSize + 'px' : '',
color: resolveColor(el.style?.color, false),
textAlign: el.style?.align || undefined,
fontWeight: el.style?.bold ? 'bold' : undefined,
fontStyle: el.style?.italic ? 'italic' : undefined,
}"
>
<!-- 标题/正文/引用 -->
<template v-if="['title', 'text', 'quote'].includes(el.type)">
<div class="el-text">{{ el.content }}</div>
</template>
<!-- 列表 -->
<template v-else-if="el.type === 'list'">
<div class="el-list">
<div v-for="(line, li) in el.content.split('\n')" :key="li" class="li">{{ line }}</div>
</div>
</template>
<!-- 数据 -->
<template v-else-if="el.type === 'stat'">
<div class="el-stat">
<div class="num" :style="{ fontSize: el.style?.fontSize ? el.style.fontSize + 'px' : '', color: resolveColor(el.style?.color, false) }">{{ el.content }}</div>
<div class="label" v-if="el.style?.label">{{ el.style.label }}</div>
</div>
</template>
<!-- 图片 -->
<img v-else-if="el.type === 'image'" class="el-image" :src="el.content" draggable="false" />
<!-- 形状 -->
<template v-else-if="el.type === 'shape'">
<div
class="el-shape"
:style="{
borderRadius: el.style?.shapeType === 'circle' ? '50%' : (el.style?.radius || 0) + 'px',
background: resolveBg(el.style?.fill || 'accent'),
opacity: el.style?.opacity ?? 1,
}"
></div>
</template>
<!-- 卡片 -->
<template v-else-if="el.type === 'card'">
<div class="el-card-bar" :style="{ background: resolveBg(el.style?.accent || 'primary') }"></div>
<div class="el-card">
<div class="card-icon" v-if="el.style?.icon">{{ el.style.icon }}</div>
<div class="card-title">{{ el.content.split('\n')[0] }}</div>
<div class="card-body">{{ el.content.split('\n').slice(1).join('\n') }}</div>
</div>
</template>
<!-- 图表 -> 简化为文本描述 -->
<template v-else-if="el.type === 'chart'">
<div class="el-chart">
<div class="chart-text">[{{ el.style?.chartType || 'bar' }} 图表]</div>
<div class="chart-text muted">{{ el.content.slice(0, 60) }}</div>
</div>
</template>
<!-- 其他table/code/formula-> 按文本显示 -->
<template v-else>
<div class="el-text">{{ el.content }}</div>
</template>
</div>
</div>
</div>
</template>
<style scoped>
/* 屏幕上的容器 */
.print-modal {
position: fixed; inset: 0; z-index: 900;
background: #e2e8f0;
overflow-y: auto;
}
.print-bar {
position: sticky; top: 0; z-index: 10;
display: flex; align-items: center; gap: 12px;
padding: 12px 24px;
background: #fff;
border-bottom: 1px solid #e2e8f0;
}
.print-page {
width: 210mm; min-height: 297mm;
margin: 12mm auto;
padding: 20mm 25mm;
background: #fff;
box-shadow: 0 2px 16px rgba(0,0,0,0.1);
position: relative;
page-break-after: always;
overflow: hidden;
}
.print-number {
position: absolute; bottom: 8mm; right: 10mm;
font-size: 11px; color: #94a3b8;
}
/* 打印样式 */
@media print {
.no-print { display: none !important; }
.print-modal { position: static; background: none; overflow: visible; }
.print-page {
margin: 0; padding: 0;
box-shadow: none;
width: 100%; height: 100vh;
min-height: auto;
page-break-after: always;
/* 确保背景色打印 */
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}
/* 元素样式 */
.el { position: absolute; overflow: hidden; }
.el-text {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
height: 100%; width: 100%;
}
.el-list { white-space: pre-wrap; line-height: 1.6; }
.el-list .li::before { content: '• '; }
.el-stat { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; }
.el-stat .num { font-weight: 700; line-height: 1.2; }
.el-stat .label { font-size: 14px; color: #64748b; margin-top: 4px; text-align: center; }
.el-image { width: 100%; height: 100%; object-fit: contain; }
.el-shape { width: 100%; height: 100%; }
.el-card-bar { position: absolute; top: 0; left: 0; right: 0; height: 6px; border-radius: 4px 4px 0 0; }
.el-card { padding: 12px; }
.card-title { font-size: 18px; font-weight: 600; margin-bottom: 6px; }
.card-body { font-size: 13px; color: #475569; white-space: pre-wrap; line-height: 1.5; }
.el-chart { display: flex; flex-direction: column; justify-content: center; align-items: center; height: 100%; gap: 6px; }
.chart-text { font-size: 14px; color: #1e293b; text-align: center; }
.muted { color: #94a3b8; }
.sep { flex: 1; }
</style>
+29
View File
@@ -383,6 +383,35 @@ export async function generate(opts: { topic: string; count?: number; signal?: A
return { action: 'create_all', slides } return { action: 'create_all', slides }
} }
/** 文档内容 → AI 分析后生成幻灯片 */
export async function generateFromDocument(opts: {
text: string
filename?: string
signal?: AbortSignal
}): Promise<{ action: 'create_all'; slides: Slide[] }> {
const SYS_DOC =
SYS_BASE +
'\n任务:分析下方提供的文档内容,将其提炼为 5-10 页专业演示稿(含封面 1 页 + 内容 3-7 页 + 结尾 1 页)。\n' +
'要求:\n' +
'- 提取文档中的核心观点、关键数据、重要结论,不要遗漏重要信息\n' +
'- 每页只有一个主题,标题具体、有信息量,如「Q3 营收增长 23%」而非「业绩回顾」\n' +
'- 正文有实质内容,包含具体数字、案例、来源;避免空泛口号\n' +
'- 合理运用 card(对比/分组)、stat(突出数据)、chart(趋势/分布)等元素\n' +
'- 每个主要元素都加 style.anim 入场动画\n' +
'- 封面用 g-primary 渐变,结尾可用 g-primary 或 g-deep,内容页用 bg/panel 浅底\n' +
'严格输出 JSON{"action":"create_all","slides":[...]}'
const messages: Message[] = [
{ role: 'system', content: SYS_DOC },
{ role: 'user', content: '文档' + (opts.filename ? '' + opts.filename + '' : '') + '内容如下:\n\n' + opts.text }
]
const r = await streamChat(messages, { jsonMode: true, signal: opts.signal })
if (!r.json) throw new Error('AI 输出无法解析为 JSON,请重试。')
const slides = normSlides(r.json.slides || r.json)
if (!slides.length) throw new Error('AI 未生成有效幻灯片,请重试或检查文档内容。')
return { action: 'create_all', slides }
}
/** 润色单页 */ /** 润色单页 */
export async function polish(opts: { slide: Slide; instruction?: string; signal?: AbortSignal }): Promise<{ action: 'update_page'; slide: Slide; note: string }> { export async function polish(opts: { slide: Slide; instruction?: string; signal?: AbortSignal }): Promise<{ action: 'update_page'; slide: Slide; note: string }> {
const instruction = opts.instruction || '让这页内容更有吸引力、表达更精炼' const instruction = opts.instruction || '让这页内容更有吸引力、表达更精炼'
+257
View File
@@ -0,0 +1,257 @@
/* =====================================================================
* importer.ts /
*
*
* - data URL image
* - PDF/DOCX/MD/TXT LLM
* - LLM PPT
* ===================================================================== */
import type { Slide, SlideElement } from './types'
import { createElement } from './sample'
/** 允许的图片扩展名 */
const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.bmp', '.ico'])
/** 允许的文档扩展名(由 LLM 解析) */
const DOC_EXTS = new Set(['.md', '.markdown', '.txt', '.text', '.pdf', '.docx', '.doc'])
/** 文件分类结果 */
export interface FileEntry {
file: File
name: string
ext: string
kind: 'image' | 'document' | 'unsupported'
/** 图片的 data URL,或文档的原始文本 */
data?: string
/** AI 解析出的幻灯片(文档读取 + LLM 分析后填充) */
slides?: Slide[]
/** 错误信息 */
error?: string
}
/** 导入统计 */
export interface ImportReport {
images: number
slides: number
files: number
}
export function getFileKind(file: File): FileEntry['kind'] {
const name = file.name.toLowerCase()
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot) : ''
if (IMAGE_EXTS.has(ext)) return 'image'
if (DOC_EXTS.has(ext)) return 'document'
return 'unsupported'
}
/** 读取文件为 ArrayBuffer */
function readAsArrayBuffer(file: File): Promise<ArrayBuffer> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as ArrayBuffer)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsArrayBuffer(file)
})
}
/** 读取图片为 Data URL */
function readAsDataURL(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsDataURL(file)
})
}
/** 读取文本文件 */
function readAsText(file: File): Promise<string> {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onload = () => resolve(reader.result as string)
reader.onerror = () => reject(new Error(`读取失败: ${file.name}`))
reader.readAsText(file, 'utf-8')
})
}
/* ============================================================
* PDF
* ============================================================ */
export async function extractPdfText(file: File): Promise<string> {
const pdfjsLib: any = await import('pdfjs-dist')
pdfjsLib.GlobalWorkerOptions.workerSrc =
`https://cdnjs.cloudflare.com/ajax/libs/pdf.js/${pdfjsLib.version}/pdf.worker.min.mjs`
const buffer = await readAsArrayBuffer(file)
const pdf = await pdfjsLib.getDocument({ data: buffer }).promise
const pages: string[] = []
for (let i = 1; i <= pdf.numPages; i++) {
const page = await pdf.getPage(i)
const content = await page.getTextContent()
const lines = groupPdfTextItems(
content.items as Array<{ str: string; transform: number[] }>
)
pages.push(lines.join('\n'))
}
return pages.map((p, i) => `[p${i + 1}]\n${p}`).join('\n\n')
}
// 注意:阈值 5 基于坐标差值,多栏排版可能合并错乱,交由 LLM 理解时再纠正
function groupPdfTextItems(
items: Array<{ str: string; transform: number[] }>
): string[] {
const lines: string[] = []
let currentLine = ''
let lastY = 0
for (const item of items) {
const text = item.str
if (!text && text.length === 0) continue
const y = item.transform[5]
if (currentLine && Math.abs(y - lastY) > 5) {
lines.push(currentLine.trimEnd())
currentLine = text
} else {
currentLine +=
(currentLine && !currentLine.endsWith(' ') && !text.startsWith(' ') ? ' ' : '') + text
}
lastY = y
}
if (currentLine.trim()) lines.push(currentLine.trimEnd())
return lines
}
/* ============================================================
* DOCX Markdown
* ============================================================ */
export async function extractDocxText(file: File): Promise<string> {
const mammoth: any = await import('mammoth')
const buffer = await readAsArrayBuffer(file)
const result = await mammoth.convertToMarkdown({ arrayBuffer: buffer })
return result.value
}
/* ============================================================
*
* ============================================================ */
async function readTextFile(file: File): Promise<string> {
return readAsText(file)
}
/* ============================================================
* SlideElementimage
* ============================================================ */
export function fileToImageElement(file: File, dataUrl: string): SlideElement {
const isWide = file.type === 'image/svg+xml'
return createElement('image', {
content: dataUrl,
x: 15, y: 12, w: isWide ? 60 : 40, h: isWide ? 40 : 50,
style: { fit: 'contain', anim: 'fade' }
})
}
export function imagesToSlideElements(files: Array<{ file: File; dataUrl: string }>): SlideElement[] {
if (files.length === 1) {
return [fileToImageElement(files[0].file, files[0].dataUrl)]
}
const cols = Math.ceil(Math.sqrt(files.length))
const rows = Math.ceil(files.length / cols)
const cellW = Math.floor(80 / cols)
const cellH = Math.floor(60 / rows)
return files.map((f, i) => {
const col = i % cols
const row = Math.floor(i / cols)
return createElement('image', {
content: f.dataUrl,
x: 8 + col * cellW, y: 8 + row * cellH,
w: cellW - 4, h: cellH - 4,
style: { fit: 'cover', anim: 'fade' }
})
})
}
/* ============================================================
*
* ============================================================ */
export async function scanFiles(fileList: FileList): Promise<FileEntry[]> {
const entries: FileEntry[] = []
for (let i = 0; i < fileList.length; i++) {
const file = fileList[i]
const name = file.name
const dot = name.lastIndexOf('.')
const ext = dot >= 0 ? name.slice(dot).toLowerCase() : ''
const kind = getFileKind(file)
entries.push({ file, name, ext, kind })
}
return entries
}
/** 读取文件内容(图片 → data URL,文档 → 原始文本),原位修改 entries */
export async function readEntries(entries: FileEntry[]): Promise<void> {
for (const entry of entries) {
try {
if (entry.kind === 'image') {
entry.data = await readAsDataURL(entry.file)
} else if (entry.kind === 'document') {
if (entry.ext === '.pdf') {
entry.data = await extractPdfText(entry.file)
} else if (entry.ext === '.docx' || entry.ext === '.doc') {
entry.data = await extractDocxText(entry.file)
} else {
// .md / .markdown / .txt / .text
const text = await readTextFile(entry.file)
entry.data = text
}
}
} catch (e: any) {
entry.error = e?.message || String(e)
console.warn(`读取失败 ${entry.name}:`, e)
}
}
}
/** 使用 AI 将一个文档的文本内容解析为幻灯片(异步,调用 LLM) */
export async function aiAnalyzeDocument(
entry: FileEntry,
signal?: AbortSignal
): Promise<Slide[]> {
if (!entry.data || entry.kind !== 'document') {
throw new Error('没有可分析的文本内容')
}
const { generateFromDocument } = await import('./ai')
// 文档文本太长时截断(按 token 估算,保留关键部分:开头 + 结尾)
let text = entry.data
const MAX_CHARS = 12000
if (text.length > MAX_CHARS) {
const keepFront = Math.floor(MAX_CHARS * 0.5)
const keepTail = MAX_CHARS - keepFront
text = text.slice(0, keepFront) + '\n\n...(中间省略 ' + (text.length - MAX_CHARS) + ' 字符)...\n\n' + text.slice(-keepTail)
}
const result = await generateFromDocument({
text,
filename: entry.name,
signal
})
entry.slides = result.slides
return result.slides
}
/** 生成导入统计描述 */
export function describeReport(r: ImportReport): string {
const parts: string[] = []
if (r.slides > 0) parts.push(`AI 生成 ${r.slides} 页幻灯片`)
if (r.images > 0) parts.push(`插入 ${r.images} 张图片`)
if (r.files > 0) parts.push(`共处理 ${r.files} 个文件`)
return parts.join('') || '未导入任何内容'
}
+3
View File
@@ -3,6 +3,9 @@ import vue from '@vitejs/plugin-vue'
export default defineConfig(({ mode }) => ({ export default defineConfig(({ mode }) => ({
plugins: [vue()], plugins: [vue()],
build: {
chunkSizeWarningLimit: 800,
},
// npm run devmode=web)→ 浏览器模式,端口 8080 // npm run devmode=web)→ 浏览器模式,端口 8080
// npm run tauri:dev → 默认 mode,端口 5173 // npm run tauri:dev → 默认 mode,端口 5173
server: mode === 'web' server: mode === 'web'