This commit is contained in:
star7th
2025-07-31 01:44:28 +08:00
parent 3cb25118ab
commit 48bdb5905d
30 changed files with 3192 additions and 33 deletions
+16
View File
@@ -6,6 +6,22 @@ const nextConfig = {
// 在构建过程中忽略ESLint错误
ignoreDuringBuilds: true,
},
webpack: (config, { isServer }) => {
// 处理PDF.js在SSR中的问题
if (isServer) {
config.externals.push({
'pdfjs-dist': 'commonjs pdfjs-dist',
});
}
// 处理canvas模块
config.resolve.fallback = {
...config.resolve.fallback,
canvas: false,
};
return config;
},
/* config options here */
};
+358 -2
View File
@@ -14,18 +14,23 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "^2.0.7",
"@uiw/react-json-view": "^2.0.0-alpha.30",
"compressorjs": "^1.2.1",
"cron-parser": "^5.1.1",
"cronstrue": "^2.59.0",
"crypto-js": "^4.2.0",
"fabric": "^6.6.4",
"file-saver": "^2.0.5",
"iconv-lite": "^0.6.3",
"marked": "^15.0.7",
"nanoid": "^5.1.5",
"next": "15.2.3",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.54",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-pdf": "^10.0.1",
"react-qrcode-logo": "^3.0.0",
"turndown": "^7.2.0",
"vanilla-jsoneditor": "^3.1.1"
@@ -1009,6 +1014,191 @@
"integrity": "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw==",
"license": "BSD-2-Clause"
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas/-/canvas-0.1.74.tgz",
"integrity": "sha512-pOIyzuS+5Bz1vAhD7tdhaw5/936mMJZUn4aVajojUdjYOGSWmfpDYSgt0nQLZPZVN5GLgWgutqXPOi7Jsm3k+Q==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.74",
"@napi-rs/canvas-darwin-arm64": "0.1.74",
"@napi-rs/canvas-darwin-x64": "0.1.74",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.74",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.74",
"@napi-rs/canvas-linux-arm64-musl": "0.1.74",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.74",
"@napi-rs/canvas-linux-x64-gnu": "0.1.74",
"@napi-rs/canvas-linux-x64-musl": "0.1.74",
"@napi-rs/canvas-win32-x64-msvc": "0.1.74"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.74.tgz",
"integrity": "sha512-aq5ode+9Z/ZR0H485dI2jdRdttg/hl9Ob+iPCt0nj+QFiirpxDrbUHKeTZWQWEtkWyC7vI5R2dMTbDINBfl9eg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.74.tgz",
"integrity": "sha512-eO5Miz+ef1dEQyUMWDdcbAb1Wr7yMyxD9/CL9d4frQxO4pTTaCiMBUWup8XDPLr/g7XkSkGCZLP47xiXiyXSpQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.74.tgz",
"integrity": "sha512-0EkO0IFkps7C3JpKC7lbM3IL+QDUYeUKagHLDbUry4PeQTghxp6JcgccpmU32ZbpFZgPnm7o0tTJO0J1d8S2rA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.74.tgz",
"integrity": "sha512-qAVJEN2JqGayEI1kSpJy1Xr6ZmCFV9QhRyV35yWsS7e9X1jm+T4DAlCxI4PlKIlqVSzYMYhKrxchST20XBSzHg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.74.tgz",
"integrity": "sha512-lOnop22qy6MYxI94GGunMMjo6D80I//2W/6pqKUfwXaDQtOfvHsTcVVzDu5cFXUTNrb9ZRfMCeol5YEd+9FJvg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.74.tgz",
"integrity": "sha512-tfFqLHGtSEabBigOnPUfZviSTGmW2xHv5tYZYPBWmgGiTkoNJ7lEWFUxHjwvV5HXGqLs8ok/O7g1enSpxO6lmQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.74.tgz",
"integrity": "sha512-j6H9dHTMtr1y3tu/zGm1ythYIL9vTl4EEv9f6CMx0n3Zn2M+OruUUwh9ylCj4afzSNEK9T8cr6zMnmTPzkpBvQ==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.74.tgz",
"integrity": "sha512-73DIV4E7Y9CpIJuUXVl9H6+MEQXyRy4VJQoUGA1tOlcKQiStxqhq6UErL4decI28NxjyQXBhtYZKj5q8AJEuOg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.74.tgz",
"integrity": "sha512-FgDMEFdGIJT3I2xejflRJ82/ZgDphyirS43RgtoLaIXI6zihLiZcQ7rczpqeWgAwlJNjR0He2EustsKe1SkUOg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.74",
"resolved": "https://registry.npmmirror.com/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.74.tgz",
"integrity": "sha512-x6bhwlhn0wU7dfiP46mt5Bi6PowSUH4CJ4PTzGj58LRQ1HVasEIJgoMx7MLC48F738eJpzbfg3WR/D8+e9CeTA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "0.2.7",
"resolved": "https://registry.npmmirror.com/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.7.tgz",
@@ -1214,6 +1404,24 @@
"node": ">=12.4.0"
}
},
"node_modules/@pdf-lib/standard-fonts": {
"version": "1.0.0",
"resolved": "https://registry.npmmirror.com/@pdf-lib/standard-fonts/-/standard-fonts-1.0.0.tgz",
"integrity": "sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.6"
}
},
"node_modules/@pdf-lib/upng": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/@pdf-lib/upng/-/upng-1.0.1.tgz",
"integrity": "sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==",
"license": "MIT",
"dependencies": {
"pako": "^1.0.10"
}
},
"node_modules/@pkgjs/parseargs": {
"version": "0.11.0",
"resolved": "https://registry.npmmirror.com/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -1313,6 +1521,12 @@
"integrity": "sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==",
"license": "MIT"
},
"node_modules/@types/file-saver": {
"version": "2.0.7",
"resolved": "https://registry.npmmirror.com/@types/file-saver/-/file-saver-2.0.7.tgz",
"integrity": "sha512-dNKVfHd/jk0SkR/exKGj2ggkB45MAkzvWCaqLUUgkyjITkGNzH8H+yUwr+BLJUBjZOe9w8X3wgmXhZDRg1ED6A==",
"license": "MIT"
},
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmmirror.com/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -1348,7 +1562,7 @@
"version": "19.0.12",
"resolved": "https://registry.npmmirror.com/@types/react/-/react-19.0.12.tgz",
"integrity": "sha512-V6Ar115dBDrjbtXSrS+/Oruobc+qVbbUxDFC1RSbRqLt5SYvxxyIDrSC85RWml54g+jfNeEMZhEj7wW07ONQhA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.0.2"
@@ -2704,7 +2918,7 @@
"version": "3.1.3",
"resolved": "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/damerau-levenshtein": {
@@ -2881,6 +3095,15 @@
"license": "MIT",
"optional": true
},
"node_modules/dequal": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz",
"integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==",
"license": "MIT",
"engines": {
"node": ">=6"
}
},
"node_modules/detect-libc": {
"version": "2.0.3",
"resolved": "https://registry.npmmirror.com/detect-libc/-/detect-libc-2.0.3.tgz",
@@ -3766,6 +3989,12 @@
"node": ">=16.0.0"
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmmirror.com/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmmirror.com/fill-range/-/fill-range-7.1.1.tgz",
@@ -5258,6 +5487,15 @@
"@jridgewell/sourcemap-codec": "^1.5.0"
}
},
"node_modules/make-cancellable-promise": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/make-cancellable-promise/-/make-cancellable-promise-2.0.0.tgz",
"integrity": "sha512-3SEQqTpV9oqVsIWqAcmDuaNeo7yBO3tqPtqGRcKkEo0lrzD3wqbKG9mkxO65KoOgXqj+zH2phJ2LiAsdzlogSw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-cancellable-promise?sponsor=1"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmmirror.com/make-dir/-/make-dir-3.1.0.tgz",
@@ -5284,6 +5522,15 @@
"semver": "bin/semver.js"
}
},
"node_modules/make-event-props": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/make-event-props/-/make-event-props-2.0.0.tgz",
"integrity": "sha512-G/hncXrl4Qt7mauJEXSg3AcdYzmpkIITTNl5I+rH9sog5Yw0kK6vseJjCaPfOXqOqQuPUP89Rkhfz5kPS8ijtw==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/make-event-props?sponsor=1"
}
},
"node_modules/marked": {
"version": "15.0.7",
"resolved": "https://registry.npmmirror.com/marked/-/marked-15.0.7.tgz",
@@ -5312,6 +5559,23 @@
"integrity": "sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==",
"license": "MIT"
},
"node_modules/merge-refs": {
"version": "2.0.0",
"resolved": "https://registry.npmmirror.com/merge-refs/-/merge-refs-2.0.0.tgz",
"integrity": "sha512-3+B21mYK2IqUWnd2EivABLT7ueDhb0b8/dGK8LoFQPrU61YITeCMn14F7y7qZafWNZhUEKb24cJdiT5Wxs3prg==",
"license": "MIT",
"funding": {
"url": "https://github.com/wojtekmaj/merge-refs?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/merge2": {
"version": "1.4.1",
"resolved": "https://registry.npmmirror.com/merge2/-/merge2-1.4.1.tgz",
@@ -5929,6 +6193,12 @@
"dev": true,
"license": "BlueOak-1.0.0"
},
"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/parent-module": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/parent-module/-/parent-module-1.0.1.tgz",
@@ -6009,6 +6279,36 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/pdf-lib": {
"version": "1.17.1",
"resolved": "https://registry.npmmirror.com/pdf-lib/-/pdf-lib-1.17.1.tgz",
"integrity": "sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==",
"license": "MIT",
"dependencies": {
"@pdf-lib/standard-fonts": "^1.0.0",
"@pdf-lib/upng": "^1.0.1",
"pako": "^1.0.11",
"tslib": "^1.11.1"
}
},
"node_modules/pdf-lib/node_modules/tslib": {
"version": "1.14.1",
"resolved": "https://registry.npmmirror.com/tslib/-/tslib-1.14.1.tgz",
"integrity": "sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==",
"license": "0BSD"
},
"node_modules/pdfjs-dist": {
"version": "5.4.54",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.4.54.tgz",
"integrity": "sha512-TBAiTfQw89gU/Z4LW98Vahzd2/LoCFprVGvGbTgFt+QCB1F+woyOPmNNVgLa6djX9Z9GGTnj7qE1UzpOVJiINw==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.74"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz",
@@ -6332,6 +6632,47 @@
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==",
"license": "MIT"
},
"node_modules/react-pdf": {
"version": "10.0.1",
"resolved": "https://registry.npmmirror.com/react-pdf/-/react-pdf-10.0.1.tgz",
"integrity": "sha512-dblLZ5BqubeNFZwTzHkRi7Rexev2+Xb9z7sEvNYT+IOxmih1KgrByeqD4Hm0RnwR9nTWludXbiHQMe9d47DK2w==",
"license": "MIT",
"dependencies": {
"clsx": "^2.0.0",
"dequal": "^2.0.3",
"make-cancellable-promise": "^2.0.0",
"make-event-props": "^2.0.0",
"merge-refs": "^2.0.0",
"pdfjs-dist": "5.3.31",
"tiny-invariant": "^1.0.0",
"warning": "^4.0.0"
},
"funding": {
"url": "https://github.com/wojtekmaj/react-pdf?sponsor=1"
},
"peerDependencies": {
"@types/react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0",
"react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/react-pdf/node_modules/pdfjs-dist": {
"version": "5.3.31",
"resolved": "https://registry.npmmirror.com/pdfjs-dist/-/pdfjs-dist-5.3.31.tgz",
"integrity": "sha512-EhPdIjNX0fcdwYQO+e3BAAJPXt+XI29TZWC7COhIXs/K0JHcUt1Gdz1ITpebTwVMFiLsukdUZ3u0oTO7jij+VA==",
"license": "Apache-2.0",
"engines": {
"node": ">=20.16.0 || >=22.3.0"
},
"optionalDependencies": {
"@napi-rs/canvas": "^0.1.67"
}
},
"node_modules/react-qrcode-logo": {
"version": "3.0.0",
"resolved": "https://registry.npmmirror.com/react-qrcode-logo/-/react-qrcode-logo-3.0.0.tgz",
@@ -7477,6 +7818,12 @@
"node": ">=0.8"
}
},
"node_modules/tiny-invariant": {
"version": "1.3.3",
"resolved": "https://registry.npmmirror.com/tiny-invariant/-/tiny-invariant-1.3.3.tgz",
"integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.12",
"resolved": "https://registry.npmmirror.com/tinyglobby/-/tinyglobby-0.2.12.tgz",
@@ -7896,6 +8243,15 @@
"node": ">=14"
}
},
"node_modules/warning": {
"version": "4.0.3",
"resolved": "https://registry.npmmirror.com/warning/-/warning-4.0.3.tgz",
"integrity": "sha512-rpJyN222KWIvHJ/F53XSZv0Zl/accqHR8et1kpaMTD/fLCRxtV8iX8czMzY7sVZupTI3zcUTg8eycS2kNF9l6w==",
"license": "MIT",
"dependencies": {
"loose-envify": "^1.0.0"
}
},
"node_modules/webidl-conversions": {
"version": "7.0.0",
"resolved": "https://registry.npmmirror.com/webidl-conversions/-/webidl-conversions-7.0.0.tgz",
+5
View File
@@ -15,18 +15,23 @@
"@fortawesome/free-solid-svg-icons": "^6.7.2",
"@fortawesome/react-fontawesome": "^0.2.2",
"@types/crypto-js": "^4.2.2",
"@types/file-saver": "^2.0.7",
"@uiw/react-json-view": "^2.0.0-alpha.30",
"compressorjs": "^1.2.1",
"cron-parser": "^5.1.1",
"cronstrue": "^2.59.0",
"crypto-js": "^4.2.0",
"fabric": "^6.6.4",
"file-saver": "^2.0.5",
"iconv-lite": "^0.6.3",
"marked": "^15.0.7",
"nanoid": "^5.1.5",
"next": "15.2.3",
"pdf-lib": "^1.17.1",
"pdfjs-dist": "^5.4.54",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-pdf": "^10.0.1",
"react-qrcode-logo": "^3.0.0",
"turndown": "^7.2.0",
"vanilla-jsoneditor": "^3.1.1"
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -681,7 +681,7 @@ export default function Home() {
{/* 主内容区域 */}
<main className="flex-1 container mx-auto">
{/* 分类导航 - 独立导航栏 */}
<div className="flex flex-wrap nav-bar gap-2 sm:gap-4 mb-6 sm:mb-8 mt-6">
<div className="flex flex-wrap nav-bar gap-2 sm:gap-3 mb-6 sm:mb-8 mt-6">
{allCategories.map((category, index) => (
<button
key={index}
@@ -0,0 +1,404 @@
'use client';
import React, { useState, useRef } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faUpload,
faSpinner,
faDownload,
faCopy,
faCheck,
faTrash,
faFileCode
} from '@fortawesome/free-solid-svg-icons';
import ToolHeader from '@/components/ToolHeader';
import BackToTop from '@/components/BackToTop';
import tools from '@/config/tools';
import { apiClient } from '@/lib/api-client';
import { useLanguage } from '@/context/LanguageContext';
// 转换结果接口
interface ConversionResult {
markdown_content: string;
conversion_time_seconds: number;
original_filename?: string;
}
// CSS样式
const styles = {
container: "min-h-screen flex flex-col max-w-[1440px] mx-auto p-4 md:p-6",
card: "card p-6",
actionBar: "flex flex-wrap items-center justify-between gap-3 p-4 bg-block rounded-lg border border-purple-glow mb-6",
uploadSection: "flex flex-col items-center p-6 bg-block rounded-lg border-2 border-dashed border-purple-glow/50 cursor-pointer hover:border-purple-glow transition-colors mb-6",
uploadIcon: "text-purple text-3xl mb-4",
uploadText: "text-center text-secondary mb-2",
supportedFormats: "text-center text-sm text-tertiary",
hiddenInput: "hidden",
loading: "flex flex-col items-center justify-center p-6 bg-block rounded-lg mb-6",
spinner: "animate-spin text-purple text-2xl mb-4",
loadingText: "text-secondary",
fileName: "text-sm text-secondary max-w-full truncate",
fileInfo: "flex items-center p-4 bg-block-strong rounded-lg border border-purple-glow/30 mb-4",
fileIcon: "text-purple mr-3 text-xl",
fileDetails: "flex-1",
fileActions: "flex gap-2",
resultContainer: "w-full",
resultHeader: "flex items-center justify-between mb-4",
resultTitle: "text-lg font-medium text-primary",
markdownOutput: "w-full h-[500px] p-4 bg-block-strong border border-purple-glow/30 rounded-lg text-primary font-mono text-sm resize-y overflow-auto focus:outline-none focus:border-purple focus:ring-1 focus:ring-purple",
buttonGroup: "flex flex-wrap gap-3",
actionButton: "btn-secondary flex items-center",
convertButton: "btn-primary flex items-center",
iconMargin: "mr-2",
infoBox: "p-4 bg-block rounded-lg border border-purple-glow/30 mb-6",
infoText: "text-tertiary text-sm",
errorContainer: "p-4 bg-red-500/10 border border-red-500/30 rounded-lg text-red-500 mb-6",
successContainer: "p-4 bg-green-500/10 border border-green-500/30 rounded-lg text-green-500 mb-6",
warningText: "text-yellow-500 text-sm mt-2",
};
// 支持的文件格式
const SUPPORTED_FILE_FORMATS = [
'.docx', '.pdf', '.pptx', '.xlsx', '.html', '.htm', '.rtf', '.txt', '.csv', '.json', '.xml', '.epub', '.md'
];
// 文件大小限制(50MB
const MAX_FILE_SIZE = 50 * 1024 * 1024;
export default function FileToMarkdownConverter() {
// 使用语言上下文
const { t } = useLanguage();
// 从工具配置中获取当前工具信息
const toolConfig = tools.find(tool => tool.code === 'file_to_markdown_converter');
// 状态管理
const [file, setFile] = useState<File | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [markdown, setMarkdown] = useState<string>('');
const [copied, setCopied] = useState(false);
const [isFileTooLarge, setIsFileTooLarge] = useState(false);
// 引用
const fileInputRef = useRef<HTMLInputElement>(null);
const markdownTextAreaRef = useRef<HTMLTextAreaElement>(null);
// 提交文件进行转换
const handleSubmit = async () => {
if (!file) {
setError(t('tools.file_to_markdown_converter.no_file_selected'));
return;
}
// 设置加载状态
setLoading(true);
setError(null);
setSuccess(null);
setMarkdown('');
try {
// 使用API客户端上传文件
const result = await apiClient.uploadFile<ConversionResult>('/api/markdown-convert', file);
// 更新状态
setMarkdown(result.markdown_content);
setSuccess(t('tools.file_to_markdown_converter.conversion_success').replace('{time}', result.conversion_time_seconds.toFixed(2)));
} catch (err) {
console.error(t('tools.file_to_markdown_converter.conversion_error'), err);
setError((err as Error).message || t('tools.file_to_markdown_converter.conversion_error'));
} finally {
setLoading(false);
}
};
// 处理文件选择
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const selectedFile = event.target.files?.[0];
if (!selectedFile) return;
// 检查文件格式
const fileExtension = selectedFile.name.toLowerCase().split('.').pop();
if (fileExtension === 'doc' || fileExtension === 'ppt' || fileExtension === 'xls') {
setError(t('tools.file_to_markdown_converter.old_office_format').replace('{format}', fileExtension));
setIsFileTooLarge(true); // 使用这个状态来禁用转换按钮
return;
}
// 检查文件大小
if (selectedFile.size > MAX_FILE_SIZE) {
setIsFileTooLarge(true);
setError(t('tools.file_to_markdown_converter.file_too_large').replace('{size}', (selectedFile.size / (1024 * 1024)).toFixed(2)));
return;
}
// 重置状态
setMarkdown('');
setError(null);
setSuccess(null);
setCopied(false);
setIsFileTooLarge(false);
// 设置选中的文件
setFile(selectedFile);
};
// 打开文件选择器
const handleSelectFile = () => {
fileInputRef.current?.click();
};
// 处理拖放文件
const handleDrop = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
const droppedFile = event.dataTransfer.files[0];
if (droppedFile) {
// 检查文件格式
const fileExtension = droppedFile.name.toLowerCase().split('.').pop();
if (fileExtension === 'doc' || fileExtension === 'ppt' || fileExtension === 'xls') {
setError(t('tools.file_to_markdown_converter.old_office_format').replace('{format}', fileExtension));
setIsFileTooLarge(true); // 使用这个状态来禁用转换按钮
return;
}
// 检查文件大小
if (droppedFile.size > MAX_FILE_SIZE) {
setIsFileTooLarge(true);
setError(t('tools.file_to_markdown_converter.file_too_large').replace('{size}', (droppedFile.size / (1024 * 1024)).toFixed(2)));
return;
}
// 重置状态
setMarkdown('');
setError(null);
setSuccess(null);
setCopied(false);
setIsFileTooLarge(false);
// 设置拖放的文件
setFile(droppedFile);
}
};
// 防止默认拖放行为
const handleDragOver = (event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
event.stopPropagation();
};
// 复制Markdown到剪贴板
const copyToClipboard = () => {
if (!markdown) return;
navigator.clipboard.writeText(markdown)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(err => {
console.error(t('tools.file_to_markdown_converter.copy_failed'), err);
setError(t('tools.file_to_markdown_converter.copy_failed'));
});
};
// 下载Markdown文件
const downloadMarkdown = () => {
if (!markdown) return;
// 创建Blob对象
const blob = new Blob([markdown], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
// 创建下载链接
const link = document.createElement('a');
link.href = url;
// 设置文件名,从原始文件名中提取基本名称
const originalName = file?.name || 'document';
const baseName = originalName.split('.').slice(0, -1).join('.') || originalName;
link.download = `${baseName}.md`;
// 触发下载
document.body.appendChild(link);
link.click();
// 清理
URL.revokeObjectURL(url);
document.body.removeChild(link);
};
// 清除选中的文件和结果
const clearAll = () => {
setFile(null);
setMarkdown('');
setError(null);
setSuccess(null);
setCopied(false);
setIsFileTooLarge(false);
// 重置文件输入
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
};
return (
<div className={styles.container}>
{/* 工具头部 */}
{toolConfig && (
<ToolHeader
title={toolConfig.title || ''}
description={toolConfig.description || ''}
icon={toolConfig.icon}
toolCode="file_to_markdown_converter"
/>
)}
{/* 提示信息 */}
<div className={styles.infoBox}>
<p className={styles.infoText}>
{t('tools.file_to_markdown_converter.description')}
<strong className="block mt-2">
{t('tools.file_to_markdown_converter.old_office_format').replace('{format}', 'doc/xls/ppt')}
</strong>
</p>
</div>
{/* 错误提示 */}
{error && (
<div className={styles.errorContainer}>
{error}
</div>
)}
{/* 成功提示 */}
{success && (
<div className={styles.successContainer}>
{success}
</div>
)}
{/* 始终显示上传区域 */}
<div
className={styles.uploadSection}
onClick={handleSelectFile}
onDrop={handleDrop}
onDragOver={handleDragOver}
>
<FontAwesomeIcon icon={faUpload} className={styles.uploadIcon} />
<p className={styles.uploadText}>
{file ? t('tools.file_to_markdown_converter.select_file') : t('tools.file_to_markdown_converter.drop_file_here')}
</p>
<p className={styles.supportedFormats}>
{t('tools.file_to_markdown_converter.supported_formats')}
</p>
<p className={styles.warningText}>{t('tools.file_to_markdown_converter.file_too_large').replace('{size}', '50')}</p>
<p className={styles.warningText}>{t('tools.file_to_markdown_converter.old_office_format').replace('{format}', 'doc/xls/ppt')}</p>
<input
type="file"
ref={fileInputRef}
className={styles.hiddenInput}
onChange={handleFileChange}
accept={SUPPORTED_FILE_FORMATS.join(',')}
/>
</div>
{/* 显示选中的文件信息 */}
{file && !isFileTooLarge && (
<div className={styles.fileInfo}>
<FontAwesomeIcon icon={faFileCode} className={styles.fileIcon} />
<div className={styles.fileDetails}>
<div className="font-medium">{file.name}</div>
<div className="text-sm text-secondary">{t('tools.file_to_markdown_converter.file_size')}: {(file.size / 1024).toFixed(1)} KB</div>
</div>
<div className={styles.fileActions}>
<button
className={styles.convertButton}
onClick={handleSubmit}
disabled={loading}
>
{loading ? (
<>
<FontAwesomeIcon icon={faSpinner} spin className={styles.iconMargin} />
{t('tools.file_to_markdown_converter.converting')}
</>
) : (
<>
<FontAwesomeIcon icon={faFileCode} className={styles.iconMargin} />
{t('tools.file_to_markdown_converter.convert')}
</>
)}
</button>
<button
className={styles.actionButton}
onClick={clearAll}
disabled={loading}
>
<FontAwesomeIcon icon={faTrash} className={styles.iconMargin} />
{t('tools.file_to_markdown_converter.clear')}
</button>
</div>
</div>
)}
{/* 加载状态 */}
{loading && (
<div className={styles.loading}>
<FontAwesomeIcon icon={faSpinner} spin className={styles.spinner} />
<p className={styles.loadingText}>{t('tools.file_to_markdown_converter.converting')}</p>
</div>
)}
{/* 转换结果 - 仅显示纯文本结果 */}
{markdown && (
<div className={styles.resultContainer}>
<div className={styles.resultHeader}>
<h3 className={styles.resultTitle}>{t('tools.file_to_markdown_converter.markdown_output')}</h3>
<div className={styles.buttonGroup}>
<button
className={styles.actionButton}
onClick={copyToClipboard}
>
<FontAwesomeIcon
icon={copied ? faCheck : faCopy}
className={styles.iconMargin}
/>
{copied ? t('tools.file_to_markdown_converter.copied') : t('tools.file_to_markdown_converter.copy')}
</button>
<button
className={styles.actionButton}
onClick={downloadMarkdown}
>
<FontAwesomeIcon icon={faDownload} className={styles.iconMargin} />
{t('tools.file_to_markdown_converter.download')}
</button>
<button
className={styles.actionButton}
onClick={clearAll}
>
<FontAwesomeIcon icon={faTrash} className={styles.iconMargin} />
{t('tools.file_to_markdown_converter.clear')}
</button>
</div>
</div>
<textarea
ref={markdownTextAreaRef}
className={styles.markdownOutput}
value={markdown}
onChange={(e) => setMarkdown(e.target.value)}
spellCheck={false}
/>
</div>
)}
{/* 回到顶部按钮 */}
<BackToTop />
</div>
);
}
+1 -1
View File
@@ -190,7 +190,7 @@ export default function JsonFormatter() {
try {
// 直接在原始输入字符串上移除转义斜杠
const processed = jsonInput.replace(/\\\//g, '/');
const processed = jsonInput.replace(/\\\/+/g, '/');
// 检查是否有变化
if (processed === jsonInput) {
+454
View File
@@ -0,0 +1,454 @@
'use client';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import ToolHeader from '@/components/ToolHeader';
import FileUpload from '@/components/FileUpload';
import ActionButton from '@/components/ActionButton';
import ProgressBar from '@/components/ProgressBar';
import { faCompress, faDownload } from '@fortawesome/free-solid-svg-icons';
interface CompressionResult {
url: string;
filename: string;
originalSize: number;
compressedSize: number;
compressionRatio: number;
}
interface CompressionSettings {
quality: 'high' | 'medium' | 'low';
imageCompression: boolean;
fontSubsetting: boolean;
metadataRemoval: boolean;
removeBookmarks: boolean;
removeAnnotations: boolean;
}
export default function PDFCompressorPage() {
const { t } = useLanguage();
const [files, setFiles] = useState<File[]>([]);
const [isCompressing, setIsCompressing] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<CompressionResult[]>([]);
const [error, setError] = useState<string | null>(null);
const [pdfjsLib, setPdfjsLib] = useState<typeof import('pdfjs-dist') | null>(null);
const [isClient, setIsClient] = useState(false);
const resultsRef = useRef<HTMLDivElement>(null);
const [settings, setSettings] = useState<CompressionSettings>({
quality: 'medium',
imageCompression: true,
fontSubsetting: true,
metadataRemoval: false,
removeBookmarks: false,
removeAnnotations: false,
});
// 确保只在客户端运行
useEffect(() => {
setIsClient(true);
}, []);
// 动态导入PDF.js
useEffect(() => {
if (!isClient) return;
const loadPDFJS = async () => {
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = '/lib/pdfjs-dist/pdf.worker.min.mjs';
setPdfjsLib(pdfjs);
} catch (error) {
console.error('Failed to load PDF.js:', error);
setError(t('tools.pdf_compressor.errors.library_failed'));
}
};
loadPDFJS();
}, [isClient]);
const handleFileSelect = useCallback((selectedFiles: File[]) => {
setFiles(selectedFiles);
setError(null);
}, []);
const handleFileError = useCallback((errorMessage: string) => {
setError(errorMessage);
}, []);
// 压缩PDF文件
const compressPDF = async (pdfFile: File): Promise<CompressionResult> => {
if (!pdfjsLib) {
throw new Error('PDF处理库未加载');
}
const arrayBuffer = await pdfFile.arrayBuffer();
const originalSize = pdfFile.size;
try {
// 使用PDF.js加载PDF
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
const numPages = pdf.numPages;
// 创建新的PDF文档
const { PDFDocument } = await import('pdf-lib');
const newPdfDoc = await PDFDocument.create();
// 根据质量设置确定压缩参数
const qualitySettings = {
high: { imageQuality: 0.8, imageScale: 1.0 },
medium: { imageQuality: 0.6, imageScale: 0.8 },
low: { imageQuality: 0.4, imageScale: 0.6 }
};
const currentQuality = qualitySettings[settings.quality];
// 处理每一页
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
setProgress((pageNum / numPages) * 80); // 80%用于页面处理
const page = await pdf.getPage(pageNum);
const viewport = page.getViewport({ scale: currentQuality.imageScale });
// 创建canvas渲染页面
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) continue;
canvas.height = viewport.height;
canvas.width = viewport.width;
const renderContext = {
canvasContext: context,
viewport: viewport,
canvas: canvas,
};
await page.render(renderContext).promise;
// 将canvas转换为图片
const imageBlob = await new Promise<Blob>((resolve) => {
canvas.toBlob((blob) => {
resolve(blob!);
}, 'image/jpeg', currentQuality.imageQuality);
});
// 将图片嵌入到新PDF中
const imageBytes = await imageBlob.arrayBuffer();
const image = await newPdfDoc.embedJpg(imageBytes);
const pageWidth = viewport.width;
const pageHeight = viewport.height;
const newPage = newPdfDoc.addPage([pageWidth, pageHeight]);
newPage.drawImage(image, {
x: 0,
y: 0,
width: pageWidth,
height: pageHeight,
});
}
// 生成压缩后的PDF
const compressedPdfBytes = await newPdfDoc.save({
useObjectStreams: true,
addDefaultPage: false,
objectsPerTick: 20,
updateFieldAppearances: false,
});
setProgress(90);
// 创建Blob和URL
const compressedBlob = new Blob([compressedPdfBytes], { type: 'application/pdf' });
const url = URL.createObjectURL(compressedBlob);
const filename = pdfFile.name.replace('.pdf', '_compressed.pdf');
const compressedSize = compressedBlob.size;
const compressionRatio = ((originalSize - compressedSize) / originalSize) * 100;
setProgress(100);
return {
url,
filename,
originalSize,
compressedSize,
compressionRatio,
};
} catch (error) {
console.error('PDF压缩错误:', error);
throw new Error('PDF压缩失败');
}
};
const startCompression = useCallback(async () => {
if (files.length === 0) {
setError(t('tools.pdf_compressor.errors.no_file'));
return;
}
if (!pdfjsLib) {
setError(t('tools.pdf_compressor.errors.library_failed'));
return;
}
// 滚动到页面顶部,确保用户能看到进度
window.scrollTo({ top: 0, behavior: 'smooth' });
setIsCompressing(true);
setProgress(0);
setError(null);
setResults([]);
// 开始压缩时滚动到进度条
setTimeout(() => {
const progressElement = document.querySelector('[data-progress]');
if (progressElement) {
progressElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}, 50);
try {
const compressionResults: CompressionResult[] = [];
// 只处理PDF文件
const pdfFiles = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length === 0) {
setError(t('tools.pdf_compressor.errors.invalid_format'));
return;
}
for (let i = 0; i < pdfFiles.length; i++) {
setProgress((i / pdfFiles.length) * 10); // 前10%用于文件准备
const result = await compressPDF(pdfFiles[i]);
compressionResults.push(result);
await new Promise(resolve => setTimeout(resolve, 100));
}
setResults(compressionResults);
// 压缩完成后滚动到结果区域
setTimeout(() => {
if (resultsRef.current) {
resultsRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}, 100);
} catch (err) {
console.error('压缩错误:', err);
setError(t('tools.pdf_compressor.errors.compression_failed'));
} finally {
setIsCompressing(false);
}
}, [files, settings, t, pdfjsLib]);
const downloadResult = useCallback((result: CompressionResult) => {
const link = document.createElement('a');
link.href = result.url;
link.download = result.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, []);
const clearAll = useCallback(() => {
// 清理之前的结果URL
results.forEach(result => URL.revokeObjectURL(result.url));
setFiles([]);
setResults([]);
setError(null);
setProgress(0);
}, [results]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 如果不在客户端,显示加载状态
if (!isClient) {
return (
<div className="min-h-screen bg-gray-900 text-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500 mx-auto mb-4"></div>
<p>...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<ToolHeader
toolCode="pdf_compressor"
title={t('tools.pdf_compressor.title')}
description={t('tools.pdf_compressor.description')}
icon={faCompress}
/>
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* 压缩设置 */}
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">{t('tools.pdf_compressor.compression_settings.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
{t('tools.pdf_compressor.compression_settings.quality')}
</label>
<select
value={settings.quality}
onChange={(e) => setSettings(prev => ({ ...prev, quality: e.target.value as 'high' | 'medium' | 'low' }))}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
>
<option value="high">{t('tools.pdf_compressor.compression_settings.quality_high')}</option>
<option value="medium">{t('tools.pdf_compressor.compression_settings.quality_medium')}</option>
<option value="low">{t('tools.pdf_compressor.compression_settings.quality_low')}</option>
</select>
</div>
<div className="space-y-3">
<div className="flex items-center">
<input
type="checkbox"
id="imageCompression"
checked={settings.imageCompression}
onChange={(e) => setSettings(prev => ({ ...prev, imageCompression: e.target.checked }))}
className="mr-2"
/>
<label htmlFor="imageCompression" className="text-sm">
{t('tools.pdf_compressor.compression_settings.image_compression')}
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="fontSubsetting"
checked={settings.fontSubsetting}
onChange={(e) => setSettings(prev => ({ ...prev, fontSubsetting: e.target.checked }))}
className="mr-2"
/>
<label htmlFor="fontSubsetting" className="text-sm">
{t('tools.pdf_compressor.compression_settings.font_subsetting')}
</label>
</div>
<div className="flex items-center">
<input
type="checkbox"
id="metadataRemoval"
checked={settings.metadataRemoval}
onChange={(e) => setSettings(prev => ({ ...prev, metadataRemoval: e.target.checked }))}
className="mr-2"
/>
<label htmlFor="metadataRemoval" className="text-sm">
{t('tools.pdf_compressor.compression_settings.metadata_removal')}
</label>
</div>
</div>
</div>
</div>
{/* 文件上传 */}
<div className="bg-gray-800 rounded-lg p-6">
<FileUpload
accept=".pdf"
maxSize={100 * 1024 * 1024}
multiple={true}
onFileSelect={handleFileSelect}
onError={handleFileError}
title={t('tools.pdf_compressor.upload_area.title')}
subtitle={t('tools.pdf_compressor.upload_area.subtitle')}
buttonText={t('tools.pdf_compressor.upload_area.button')}
/>
</div>
{/* 错误提示 */}
{error && (
<div className="bg-red-600 text-white p-4 rounded-lg">
{error}
</div>
)}
{/* 压缩进度 */}
{isCompressing && (
<div data-progress>
<ProgressBar
progress={progress}
status={t('tools.pdf_compressor.status.compressing')}
onCancel={() => setIsCompressing(false)}
/>
</div>
)}
{/* 操作按钮 */}
{files.length > 0 && !isCompressing && (
<div className="flex gap-4">
<ActionButton
onClick={startCompression}
loading={isCompressing}
disabled={files.length === 0 || !pdfjsLib}
>
{t('tools.pdf_compressor.actions.compress')}
</ActionButton>
<ActionButton
onClick={clearAll}
variant="secondary"
>
{t('tools.pdf_compressor.actions.clear')}
</ActionButton>
</div>
)}
{/* 压缩结果 */}
{results.length > 0 && (
<div ref={resultsRef} className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">{t('tools.pdf_compressor.results.title')}</h3>
<div className="space-y-3">
{results.map((result, index) => (
<div key={index} className="bg-gray-700 rounded-lg p-4">
<div className="flex items-center justify-between mb-3">
<div>
<p className="text-sm font-medium">{result.filename}</p>
<p className="text-xs text-gray-400">
{t('tools.pdf_compressor.results.original_size')}: {formatFileSize(result.originalSize)} |
{t('tools.pdf_compressor.results.compressed_size')}: {formatFileSize(result.compressedSize)}
</p>
</div>
<div className="text-right">
<p className={`text-sm font-bold ${
result.compressionRatio > 0 ? 'text-green-400' : 'text-red-400'
}`}>
{result.compressionRatio > 0 ? '-' : '+'}{Math.abs(result.compressionRatio).toFixed(1)}%
</p>
</div>
</div>
<div className="flex gap-2">
<ActionButton
onClick={() => downloadResult(result)}
variant="primary"
size="sm"
icon={faDownload}
>
{t('tools.pdf_compressor.actions.download')}
</ActionButton>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+423
View File
@@ -0,0 +1,423 @@
'use client';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import ToolHeader from '@/components/ToolHeader';
import FileUpload from '@/components/FileUpload';
import ActionButton from '@/components/ActionButton';
import ProgressBar from '@/components/ProgressBar';
import { faDownload, faExchangeAlt } from '@fortawesome/free-solid-svg-icons';
interface ConversionResult {
url: string;
filename: string;
size: number;
type: 'image' | 'text';
}
export default function PDFConverterPage() {
const { t } = useLanguage();
const [files, setFiles] = useState<File[]>([]);
const [conversionType, setConversionType] = useState<'pdf_to_image' | 'pdf_to_text'>('pdf_to_image');
const [imageFormat, setImageFormat] = useState<'png' | 'jpeg' | 'webp'>('png');
const [imageQuality, setImageQuality] = useState<'high' | 'medium' | 'low'>('high');
const [isConverting, setIsConverting] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<ConversionResult[]>([]);
const [error, setError] = useState<string | null>(null);
const [pdfjsLib, setPdfjsLib] = useState<typeof import('pdfjs-dist') | null>(null);
const [isClient, setIsClient] = useState(false);
const resultsRef = useRef<HTMLDivElement>(null);
// 确保只在客户端运行
useEffect(() => {
setIsClient(true);
}, []);
// 动态导入PDF.js
useEffect(() => {
if (!isClient) return;
const loadPDFJS = async () => {
try {
const pdfjs = await import('pdfjs-dist');
pdfjs.GlobalWorkerOptions.workerSrc = '/lib/pdfjs-dist/pdf.worker.min.mjs';
setPdfjsLib(pdfjs);
} catch (error) {
console.error('Failed to load PDF.js:', error);
setError('PDF处理库加载失败');
}
};
loadPDFJS();
}, [isClient]);
const handleFileSelect = useCallback((selectedFiles: File[]) => {
setFiles(selectedFiles);
setError(null);
}, []);
const handleFileError = useCallback((errorMessage: string) => {
setError(errorMessage);
}, []);
// PDF转图片功能
const convertPDFToImages = async (pdfFile: File): Promise<ConversionResult[]> => {
if (!pdfjsLib) {
throw new Error('PDF处理库未加载');
}
const arrayBuffer = await pdfFile.arrayBuffer();
const results: ConversionResult[] = [];
try {
// 使用本地PDF.js来加载和渲染PDF
const loadingTask = pdfjsLib.getDocument({ data: arrayBuffer });
const pdf = await loadingTask.promise;
const numPages = pdf.numPages;
for (let pageNum = 1; pageNum <= numPages; pageNum++) {
// 获取页面
const page = await pdf.getPage(pageNum);
// 设置渲染比例
const scale = 2.0; // 提高分辨率
const viewport = page.getViewport({ scale });
// 创建canvas
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
if (!context) continue;
canvas.height = viewport.height;
canvas.width = viewport.width;
// 渲染页面到canvas
const renderContext = {
canvasContext: context,
viewport: viewport,
canvas: canvas,
};
await page.render(renderContext).promise;
// 转换canvas为图片
const quality = imageQuality === 'high' ? 1.0 : imageQuality === 'medium' ? 0.7 : 0.5;
const mimeType = imageFormat === 'png' ? 'image/png' : imageFormat === 'jpeg' ? 'image/jpeg' : 'image/webp';
// 等待canvas转换为blob
const blob = await new Promise<Blob | null>((resolve) => {
canvas.toBlob((blob) => {
resolve(blob);
}, mimeType, quality);
});
if (blob) {
const url = URL.createObjectURL(blob);
const filename = `${pdfFile.name.replace('.pdf', '')}_page_${pageNum}.${imageFormat}`;
results.push({
url,
filename,
size: blob.size,
type: 'image'
});
}
}
return results;
} catch (error) {
console.error('PDF转换错误:', error);
throw new Error('PDF转换失败');
}
};
const startConversion = useCallback(async () => {
if (files.length === 0) {
setError(t('tools.pdf_converter.errors.no_file'));
return;
}
if (!pdfjsLib) {
setError('PDF处理库未加载,请刷新页面重试');
return;
}
// 滚动到页面顶部,确保用户能看到进度
window.scrollTo({ top: 0, behavior: 'smooth' });
setIsConverting(true);
setProgress(0);
setError(null);
setResults([]);
// 开始转换时滚动到进度条
setTimeout(() => {
const progressElement = document.querySelector('[data-progress]');
if (progressElement) {
progressElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}, 50);
try {
const conversionResults: ConversionResult[] = [];
if (conversionType === 'pdf_to_image') {
// 只处理PDF文件
const pdfFiles = files.filter(file => file.type === 'application/pdf' || file.name.toLowerCase().endsWith('.pdf'));
if (pdfFiles.length === 0) {
setError('请选择PDF文件进行转换');
return;
}
for (let i = 0; i < pdfFiles.length; i++) {
setProgress((i / pdfFiles.length) * 100);
const results = await convertPDFToImages(pdfFiles[i]);
conversionResults.push(...results);
await new Promise(resolve => setTimeout(resolve, 100));
}
} else if (conversionType === 'pdf_to_text') {
// PDF转文本功能 - 直接跳转到文件转Markdown工具
window.open('https://www.jisuxiang.com/tools/file_to_markdown_converter', '_blank');
return;
}
setProgress(100);
setResults(conversionResults);
// 转换完成后滚动到结果区域
setTimeout(() => {
if (resultsRef.current) {
resultsRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}, 100);
} catch (err) {
console.error('转换错误:', err);
setError(t('tools.pdf_converter.errors.conversion_failed'));
} finally {
setIsConverting(false);
}
}, [files, conversionType, imageFormat, imageQuality, t, pdfjsLib]);
const downloadResult = useCallback((result: ConversionResult) => {
const link = document.createElement('a');
link.href = result.url;
link.download = result.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, []);
const clearAll = useCallback(() => {
// 清理之前的结果URL
results.forEach(result => URL.revokeObjectURL(result.url));
setFiles([]);
setResults([]);
setError(null);
setProgress(0);
}, [results]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
// 如果不在客户端,显示加载状态
if (!isClient) {
return (
<div className="min-h-screen bg-gray-900 text-gray-100 flex items-center justify-center">
<div className="text-center">
<div className="animate-spin rounded-full h-12 w-12 border-b-2 border-purple-500 mx-auto mb-4"></div>
<p>...</p>
</div>
</div>
);
}
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<ToolHeader
toolCode="pdf_converter"
title={t('tools.pdf_converter.title')}
description={t('tools.pdf_converter.description')}
icon={faExchangeAlt}
/>
<div className="container mx-auto px-4 py-8">
<div className="max-w-4xl mx-auto space-y-6">
{/* 转换类型选择 */}
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-2 gap-3">
{(['pdf_to_image', 'pdf_to_text'] as const).map((type) => (
<button
key={type}
onClick={() => setConversionType(type)}
className={`p-3 rounded-lg border transition-colors ${
conversionType === type
? 'border-purple-500 bg-purple-600 text-white'
: 'border-gray-600 hover:border-purple-500'
}`}
>
{type === 'pdf_to_image' ? 'PDF转图片' : 'PDF转文本'}
</button>
))}
</div>
</div>
{/* PDF转文本提示 */}
{conversionType === 'pdf_to_text' && (
<div className="bg-blue-600 text-white p-4 rounded-lg">
<div className="flex items-center justify-between">
<div>
<p className="font-medium mb-2">使Markdown工具</p>
<p className="text-sm opacity-90">
PDF转文本功能建议使用我们的文件转Markdown工具
</p>
</div>
<a
href="https://www.jisuxiang.com/tools/file_to_markdown_converter"
target="_blank"
rel="noopener noreferrer"
className="px-4 py-2 bg-white bg-opacity-20 hover:bg-opacity-30 rounded-lg transition-colors flex items-center gap-2"
>
<i className="fas fa-external-link-alt"></i>
</a>
</div>
</div>
)}
{/* 转换设置 */}
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{conversionType === 'pdf_to_image' && (
<>
<div>
<label className="block text-sm font-medium mb-2">
</label>
<select
value={imageFormat}
onChange={(e) => setImageFormat(e.target.value as 'png' | 'jpeg' | 'webp')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
>
<option value="png">PNG</option>
<option value="jpeg">JPEG</option>
<option value="webp">WebP</option>
</select>
</div>
<div>
<label className="block text-sm font-medium mb-2">
</label>
<select
value={imageQuality}
onChange={(e) => setImageQuality(e.target.value as 'high' | 'medium' | 'low')}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
>
<option value="high"></option>
<option value="medium"></option>
<option value="low"></option>
</select>
</div>
</>
)}
</div>
</div>
{/* 文件上传 - 在PDF转文本时隐藏 */}
{conversionType !== 'pdf_to_text' && (
<div className="bg-gray-800 rounded-lg p-6">
<FileUpload
accept=".pdf"
maxSize={100 * 1024 * 1024}
multiple={true}
onFileSelect={handleFileSelect}
onError={handleFileError}
title="上传PDF文件"
subtitle="支持拖拽上传,最大100MB"
buttonText="选择文件"
/>
</div>
)}
{/* 错误提示 */}
{error && (
<div className="bg-red-600 text-white p-4 rounded-lg">
{error}
</div>
)}
{/* 转换进度 */}
{isConverting && (
<div data-progress>
<ProgressBar
progress={progress}
status="正在转换..."
onCancel={() => setIsConverting(false)}
/>
</div>
)}
{/* 操作按钮 */}
{files.length > 0 && !isConverting && conversionType !== 'pdf_to_text' && (
<div className="flex gap-4">
<ActionButton
onClick={startConversion}
loading={isConverting}
disabled={files.length === 0 || !pdfjsLib}
>
</ActionButton>
<ActionButton
onClick={clearAll}
variant="secondary"
>
</ActionButton>
</div>
)}
{/* 转换结果 */}
{results.length > 0 && (
<div ref={resultsRef} className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4"></h3>
<div className="space-y-3">
{results.map((result, index) => (
<div key={index} className="flex items-center justify-between bg-gray-700 rounded-lg p-3">
<div className="flex items-center gap-3">
<div>
<p className="text-sm font-medium">{result.filename}</p>
<p className="text-xs text-gray-400">{formatFileSize(result.size)}</p>
</div>
</div>
<div className="flex gap-2">
<ActionButton
onClick={() => downloadResult(result)}
variant="primary"
size="sm"
icon={faDownload}
>
</ActionButton>
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
</div>
);
}
+705
View File
@@ -0,0 +1,705 @@
'use client';
import React, { useState, useCallback, useEffect, useRef } from 'react';
import { useLanguage } from '@/context/LanguageContext';
import ToolHeader from '@/components/ToolHeader';
import FileUpload from '@/components/FileUpload';
import ActionButton from '@/components/ActionButton';
import ProgressBar from '@/components/ProgressBar';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import {
faDownload, faTrash, faGripVertical, faObjectGroup,
faScissors
} from '@fortawesome/free-solid-svg-icons';
interface PDFFile {
file: File;
url: string;
size: number;
pages?: number;
}
interface SplitRange {
start: number;
end: number;
id: string;
}
type OperationMode = 'merge' | 'split';
export default function PDFManagerPage() {
const { t } = useLanguage();
const [files, setFiles] = useState<PDFFile[]>([]);
const [operationMode, setOperationMode] = useState<OperationMode>('merge');
const [isProcessing, setIsProcessing] = useState(false);
const [progress, setProgress] = useState(0);
const [results, setResults] = useState<{ url: string; filename: string; size: number }[]>([]);
const [error, setError] = useState<string | null>(null);
const [pdfLib, setPdfLib] = useState<typeof import('pdf-lib') | null>(null);
const resultsRef = useRef<HTMLDivElement>(null);
const [isClient, setIsClient] = useState(false);
// 分割模式状态
const [splitMethod, setSplitMethod] = useState<'single_pages' | 'custom_ranges' | 'equal_parts'>('single_pages');
const [startPage, _setStartPage] = useState(1);
const [endPage, _setEndPage] = useState(1);
const [customRanges, setCustomRanges] = useState<SplitRange[]>([]);
const [customRangeInput, setCustomRangeInput] = useState('');
const [partsCount, setPartsCount] = useState(2);
// 合并模式状态
const [outputName, setOutputName] = useState('merged_document.pdf');
// 确保只在客户端运行
useEffect(() => {
setIsClient(true);
}, []);
// 动态导入pdf-lib
useEffect(() => {
if (!isClient) return;
const loadPdfLib = async () => {
try {
const pdfLibModule = await import('pdf-lib');
setPdfLib(pdfLibModule);
} catch (error) {
console.error('Failed to load pdf-lib:', error);
setError('PDF处理库加载失败');
}
};
loadPdfLib();
}, [isClient]);
const handleFileSelect = useCallback((selectedFiles: File[]) => {
const totalSize = selectedFiles.reduce((sum, file) => sum + file.size, 0);
if (totalSize > 500 * 1024 * 1024) {
setError(t('tools.pdf_manager.errors.file_too_large'));
return;
}
const pdfFiles: PDFFile[] = selectedFiles.map(file => ({
file,
url: URL.createObjectURL(file),
size: file.size
}));
setFiles(pdfFiles);
setError(null);
}, [t]);
const handleFileError = useCallback((errorMessage: string) => {
setError(errorMessage);
}, []);
const removeFile = useCallback((index: number) => {
const newFiles = files.filter((_, i) => i !== index);
setFiles(newFiles);
}, [files]);
const _moveFile = useCallback((fromIndex: number, toIndex: number) => {
const newFiles = [...files];
const [movedFile] = newFiles.splice(fromIndex, 1);
newFiles.splice(toIndex, 0, movedFile);
setFiles(newFiles);
}, [files]);
// 解析自定义范围输入
const parseCustomRanges = useCallback((input: string): SplitRange[] => {
const ranges: SplitRange[] = [];
const parts = input.split(',').map(p => p.trim());
for (const part of parts) {
if (part.includes('-')) {
const [start, end] = part.split('-').map(n => parseInt(n.trim()));
if (!isNaN(start) && !isNaN(end) && start <= end) {
ranges.push({
start,
end,
id: `${start}-${end}`
});
}
} else {
const page = parseInt(part);
if (!isNaN(page)) {
ranges.push({
start: page,
end: page,
id: `${page}`
});
}
}
}
return ranges;
}, []);
const addCustomRange = useCallback(() => {
if (!customRangeInput.trim()) return;
const newRanges = parseCustomRanges(customRangeInput);
setCustomRanges(prev => [...prev, ...newRanges]);
setCustomRangeInput('');
}, [customRangeInput, parseCustomRanges]);
const removeCustomRange = useCallback((id: string) => {
setCustomRanges(prev => prev.filter(range => range.id !== id));
}, []);
// 重置所有状态到初始值
const resetState = useCallback(() => {
setFiles([]);
setResults([]);
setError(null);
setProgress(0);
setCustomRanges([]);
setCustomRangeInput('');
setOutputName('merged_document.pdf');
setSplitMethod('single_pages');
setPartsCount(2);
}, []);
// 切换操作模式时重置状态
const handleOperationModeChange = useCallback((mode: OperationMode) => {
setOperationMode(mode);
resetState();
}, [resetState]);
const startOperation = useCallback(async () => {
if (!pdfLib) {
setError('PDF处理库未加载');
return;
}
if (files.length === 0) {
setError(t('tools.pdf_manager.errors.no_files'));
return;
}
// 滚动到页面顶部,确保用户能看到进度
window.scrollTo({ top: 0, behavior: 'smooth' });
setIsProcessing(true);
setProgress(0);
setError(null);
// 开始处理时滚动到进度条
setTimeout(() => {
const progressElement = document.querySelector('[data-progress]');
if (progressElement) {
progressElement.scrollIntoView({
behavior: 'smooth',
block: 'center'
});
}
}, 50);
try {
if (operationMode === 'merge') {
await performMerge();
} else if (operationMode === 'split') {
await performSplit();
}
// 操作完成后自动滚动到结果区域
setTimeout(() => {
if (resultsRef.current) {
resultsRef.current.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
}, 100);
} catch (err) {
console.error('PDF operation error:', err);
setError(err instanceof Error ? err.message : t('tools.pdf_manager.errors.operation_failed'));
} finally {
setIsProcessing(false);
}
}, [files, operationMode, splitMethod, startPage, endPage, customRanges, partsCount, outputName, t, pdfLib]);
const performMerge = useCallback(async () => {
if (files.length < 2) {
throw new Error(t('tools.pdf_manager.errors.need_at_least_two'));
}
// 创建新的PDF文档
const mergedPdf = await pdfLib!.PDFDocument.create();
setProgress(20);
// 合并所有PDF文件
for (let i = 0; i < files.length; i++) {
const file = files[i];
try {
const arrayBuffer = await file.file.arrayBuffer();
const pdf = await pdfLib!.PDFDocument.load(arrayBuffer);
// 复制所有页面到合并文档
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach((page) => mergedPdf.addPage(page));
setProgress(20 + (i + 1) * (60 / files.length));
} catch {
throw new Error(`处理文件失败: ${file.file.name}。请确保是有效的PDF文件。`);
}
}
setProgress(80);
// 生成合并后的PDF
const mergedPdfBytes = await mergedPdf.save();
setProgress(100);
// 创建Blob并生成下载链接
const mergedPdfBlob = new Blob([mergedPdfBytes], { type: 'application/pdf' });
const result = {
url: URL.createObjectURL(mergedPdfBlob),
filename: outputName,
size: mergedPdfBlob.size
};
setResults([result]);
}, [files, outputName, t, pdfLib]);
const performSplit = useCallback(async () => {
if (files.length === 0) {
throw new Error(t('tools.pdf_manager.errors.no_files'));
}
const file = files[0];
const arrayBuffer = await file.file.arrayBuffer();
const pdf = await pdfLib!.PDFDocument.load(arrayBuffer);
const pageCount = pdf.getPageCount();
setProgress(20);
const splitResults: { url: string; filename: string; size: number }[] = [];
if (splitMethod === 'single_pages') {
// 单页分割
for (let i = 0; i < pageCount; i++) {
const newPdf = await pdfLib!.PDFDocument.create();
const [page] = await newPdf.copyPages(pdf, [i]);
newPdf.addPage(page);
const pdfBytes = await newPdf.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
splitResults.push({
url: URL.createObjectURL(blob),
filename: `${file.file.name.replace('.pdf', '')}_page_${i + 1}.pdf`,
size: blob.size
});
setProgress(20 + (i + 1) * (60 / pageCount));
}
} else if (splitMethod === 'custom_ranges') {
// 自定义范围分割
const ranges = customRanges.length > 0 ? customRanges : [{ start: startPage, end: endPage, id: 'default' }];
for (let i = 0; i < ranges.length; i++) {
const range = ranges[i];
if (range.start < 1 || range.end > pageCount || range.start > range.end) {
throw new Error(t('tools.pdf_manager.errors.range_out_of_bounds'));
}
const newPdf = await pdfLib!.PDFDocument.create();
const pageIndices = Array.from({ length: range.end - range.start + 1 }, (_, j) => range.start - 1 + j);
const pages = await newPdf.copyPages(pdf, pageIndices);
pages.forEach(page => newPdf.addPage(page));
const pdfBytes = await newPdf.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
splitResults.push({
url: URL.createObjectURL(blob),
filename: `${file.file.name.replace('.pdf', '')}_${range.start}-${range.end}.pdf`,
size: blob.size
});
setProgress(20 + (i + 1) * (60 / ranges.length));
}
} else if (splitMethod === 'equal_parts') {
// 等分分割
if (partsCount < 2) {
throw new Error(t('tools.pdf_manager.errors.invalid_parts_count'));
}
const pagesPerPart = Math.ceil(pageCount / partsCount);
for (let i = 0; i < partsCount; i++) {
const startPage = i * pagesPerPart + 1;
const endPage = Math.min((i + 1) * pagesPerPart, pageCount);
if (startPage > pageCount) break;
const newPdf = await pdfLib!.PDFDocument.create();
const pageIndices = Array.from({ length: endPage - startPage + 1 }, (_, j) => startPage - 1 + j);
const pages = await newPdf.copyPages(pdf, pageIndices);
pages.forEach(page => newPdf.addPage(page));
const pdfBytes = await newPdf.save();
const blob = new Blob([pdfBytes], { type: 'application/pdf' });
splitResults.push({
url: URL.createObjectURL(blob),
filename: `${file.file.name.replace('.pdf', '')}_part_${i + 1}.pdf`,
size: blob.size
});
setProgress(20 + (i + 1) * (60 / partsCount));
}
}
setProgress(100);
setResults(splitResults);
}, [files, splitMethod, startPage, endPage, customRanges, partsCount, t, pdfLib]);
const downloadResult = useCallback((result: { url: string; filename: string; size: number }) => {
const link = document.createElement('a');
link.href = result.url;
link.download = result.filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}, []);
const downloadAllResults = useCallback(() => {
results.forEach(result => downloadResult(result));
}, [results, downloadResult]);
const clearAll = useCallback(() => {
files.forEach(file => URL.revokeObjectURL(file.url));
results.forEach(result => URL.revokeObjectURL(result.url));
setFiles([]);
setResults([]);
setError(null);
setProgress(0);
setCustomRanges([]);
setCustomRangeInput('');
}, [files, results]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
const totalSize = files.reduce((sum, file) => sum + file.size, 0);
return (
<div className="min-h-screen bg-gray-900 text-gray-100">
<ToolHeader
toolCode="pdf_manager"
title={t('tools.pdf_manager.title')}
description={t('tools.pdf_manager.description')}
icon={faObjectGroup}
/>
<div className="container mx-auto px-4 py-8">
<div className="max-w-6xl mx-auto space-y-6">
{/* 操作模式选择 */}
<div className="bg-gray-800 rounded-lg p-6">
<h3 className="text-lg font-semibold mb-4">{t('tools.pdf_manager.operation_mode.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<button
onClick={() => handleOperationModeChange('merge')}
className={`p-4 rounded-lg border-2 transition-all ${
operationMode === 'merge'
? 'border-indigo-500 bg-indigo-500/20 text-indigo-300'
: 'border-gray-600 bg-gray-700 hover:border-gray-500'
}`}
>
<FontAwesomeIcon icon={faObjectGroup} className="text-2xl mb-2" />
<div className="font-medium">{t('tools.pdf_manager.operation_mode.merge')}</div>
</button>
<button
onClick={() => handleOperationModeChange('split')}
className={`p-4 rounded-lg border-2 transition-all ${
operationMode === 'split'
? 'border-indigo-500 bg-indigo-500/20 text-indigo-300'
: 'border-gray-600 bg-gray-700 hover:border-gray-500'
}`}
>
<FontAwesomeIcon icon={faScissors} className="text-2xl mb-2" />
<div className="font-medium">{t('tools.pdf_manager.operation_mode.split')}</div>
</button>
</div>
</div>
{/* 文件上传 */}
<div className="bg-gray-800 rounded-lg p-6">
<FileUpload
accept=".pdf"
maxSize={operationMode === 'merge' ? 500 * 1024 * 1024 : 100 * 1024 * 1024}
multiple={operationMode === 'merge'}
onFileSelect={handleFileSelect}
onError={handleFileError}
title={t('tools.pdf_manager.upload_area.title')}
subtitle={t('tools.pdf_manager.upload_area.subtitle')}
buttonText={t('tools.pdf_manager.upload_area.button')}
/>
</div>
{/* 错误提示 */}
{error && (
<div className="bg-red-600 text-white p-4 rounded-lg">
{error}
</div>
)}
{/* 文件列表 */}
{files.length > 0 && (
<div className="bg-gray-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">{t('tools.pdf_manager.file_list.title')}</h3>
<div className="text-sm text-gray-400">
{t('tools.pdf_manager.file_list.total_size')}: {formatFileSize(totalSize)}
</div>
</div>
<div className="space-y-2">
{files.map((file, index) => (
<div
key={index}
className="flex items-center justify-between bg-gray-700 rounded-lg p-3"
>
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faGripVertical} className="text-gray-400 cursor-move" />
<div>
<p className="text-sm font-medium">{file.file.name}</p>
<p className="text-xs text-gray-400">{formatFileSize(file.size)}</p>
</div>
</div>
<div className="flex gap-2">
<button
onClick={() => removeFile(index)}
className="text-gray-400 hover:text-red-400 transition-colors"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
</div>
))}
</div>
<p className="text-xs text-gray-400 mt-2">
{t('tools.pdf_manager.file_list.drag_hint')}
</p>
</div>
)}
{/* 操作设置 */}
{files.length > 0 && (
<div className="bg-gray-800 rounded-lg p-6">
{operationMode === 'merge' && (
<div>
<h3 className="text-lg font-semibold mb-4">{t('tools.pdf_manager.merge_mode.title')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-2">
{t('tools.pdf_manager.merge_mode.output_name')}
</label>
<input
type="text"
value={outputName}
onChange={(e) => setOutputName(e.target.value)}
className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2"
placeholder="merged_document.pdf"
/>
</div>
</div>
</div>
)}
{operationMode === 'split' && (
<div className="space-y-6">
<h3 className="text-lg font-semibold">{t('tools.pdf_manager.split_mode.title')}</h3>
{/* 分割方式选择 */}
<div>
<label className="block text-sm font-medium mb-2">
{t('tools.pdf_manager.split_mode.split_method')}
</label>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
{(['single_pages', 'custom_ranges', 'equal_parts'] as const).map((method) => (
<button
key={method}
onClick={() => setSplitMethod(method)}
className={`p-3 rounded-lg border-2 transition-all ${
splitMethod === method
? 'border-indigo-500 bg-indigo-500/20 text-indigo-300'
: 'border-gray-600 bg-gray-700 hover:border-gray-500'
}`}
>
{t(`tools.pdf_manager.split_methods.${method}`)}
</button>
))}
</div>
</div>
{/* 单页分割设置 */}
{splitMethod === 'single_pages' && (
<div className="bg-gray-700 rounded-lg p-4">
<p className="text-sm text-gray-300">
{t('tools.pdf_manager.split_methods.single_pages')} - {t('tools.pdf_manager.split_methods.single_pages_description')}
</p>
</div>
)}
{/* 自定义范围设置 */}
{splitMethod === 'custom_ranges' && (
<div className="space-y-4">
<div className="bg-gray-700 rounded-lg p-4">
<label className="block text-sm font-medium mb-2">
{t('tools.pdf_manager.custom_ranges.title')}
</label>
<div className="flex gap-2">
<input
type="text"
value={customRangeInput}
onChange={(e) => setCustomRangeInput(e.target.value)}
className="flex-1 bg-gray-600 border border-gray-500 rounded-lg px-3 py-2"
placeholder={t('tools.pdf_manager.custom_ranges.placeholder')}
/>
<button
onClick={addCustomRange}
className="px-4 py-2 bg-indigo-600 hover:bg-indigo-700 rounded-lg transition-colors"
>
{t('tools.pdf_manager.custom_ranges.add_range')}
</button>
</div>
<p className="text-xs text-gray-400 mt-2">
{t('tools.pdf_manager.custom_ranges.range_format')}
</p>
</div>
{customRanges.length > 0 && (
<div className="space-y-2">
{customRanges.map((range) => (
<div key={range.id} className="flex items-center justify-between bg-gray-700 rounded-lg p-3">
<span className="text-sm">
{range.start === range.end
? t('tools.pdf_manager.custom_ranges.single_page').replace('{page}', range.start.toString())
: t('tools.pdf_manager.custom_ranges.page_range').replace('{start}', range.start.toString()).replace('{end}', range.end.toString())
}
</span>
<button
onClick={() => removeCustomRange(range.id)}
className="text-red-400 hover:text-red-300 transition-colors"
>
<FontAwesomeIcon icon={faTrash} />
</button>
</div>
))}
</div>
)}
</div>
)}
{/* 等分分割设置 */}
{splitMethod === 'equal_parts' && (
<div className="bg-gray-700 rounded-lg p-4">
<label className="block text-sm font-medium mb-2">
{t('tools.pdf_manager.equal_parts.parts_count')}
</label>
<input
type="number"
min="2"
value={partsCount}
onChange={(e) => setPartsCount(parseInt(e.target.value) || 2)}
className="w-full bg-gray-600 border border-gray-500 rounded-lg px-3 py-2"
/>
</div>
)}
</div>
)}
</div>
)}
{/* 处理进度 */}
{isProcessing && (
<div data-progress>
<ProgressBar
progress={progress}
status={t(`tools.pdf_manager.status.${operationMode === 'merge' ? 'merging' : 'splitting'}`)}
onCancel={() => setIsProcessing(false)}
/>
</div>
)}
{/* 操作按钮 */}
{files.length > 0 && !isProcessing && (
<div className="flex gap-4">
<ActionButton
onClick={startOperation}
loading={isProcessing}
disabled={files.length === 0}
>
{t(`tools.pdf_manager.actions.${operationMode === 'merge' ? 'merge' : 'split'}`)}
</ActionButton>
<ActionButton
onClick={clearAll}
variant="secondary"
>
{t('tools.pdf_manager.actions.clear')}
</ActionButton>
</div>
)}
{/* 操作结果 */}
{results.length > 0 && (
<div ref={resultsRef} className="bg-gray-800 rounded-lg p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">{t('tools.pdf_manager.results.title')}</h3>
<div className="text-sm text-gray-400">
{t('tools.pdf_manager.results.files_count')}: {results.length}
</div>
</div>
<div className="space-y-3">
{results.map((result, index) => (
<div key={index} className="flex items-center justify-between bg-gray-700 rounded-lg p-3">
<div>
<p className="text-sm font-medium">{result.filename}</p>
<p className="text-xs text-gray-400">{formatFileSize(result.size)}</p>
</div>
<ActionButton
onClick={() => downloadResult(result)}
variant="primary"
size="sm"
icon={faDownload}
>
{t('tools.pdf_manager.actions.download')}
</ActionButton>
</div>
))}
</div>
{results.length > 1 && (
<div className="mt-4 pt-4 border-t border-gray-600">
<ActionButton
onClick={downloadAllResults}
variant="primary"
icon={faDownload}
>
{t('tools.pdf_manager.results.download_all')}
</ActionButton>
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}
+24 -24
View File
@@ -187,13 +187,6 @@ export default function TimestampConverter() {
dateTimeToTimestamp(value);
};
// 使用常用时间戳
const handleUseCommonTimestamp = (ts: number) => {
const tsStr = ts.toString();
setTimestamp(tsStr);
timestampToDateTime(tsStr);
};
// 刷新计算,使用当前时间
const refreshWithCurrentTime = () => {
const now = new Date();
@@ -202,23 +195,11 @@ export default function TimestampConverter() {
timestampToDateTime(currentTimestamp.toString());
};
// 使用当前时间更新日期时间输入
const setCurrentDateTime = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const nowDateTime = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
setDateTime(nowDateTime);
setFormattedDateTime(`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`);
// 转换为时间戳
const timestamp = Math.floor(now.getTime() / 1000);
setTimestamp(timestamp.toString());
// 使用常用时间戳
const handleUseCommonTimestamp = (ts: number) => {
const tsStr = ts.toString();
setTimestamp(tsStr);
timestampToDateTime(tsStr);
};
// 复制时间戳到剪贴板
@@ -260,6 +241,25 @@ export default function TimestampConverter() {
setSwapped(!swapped);
};
// 使用当前时间更新日期时间输入
const setCurrentDateTime = () => {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const hours = String(now.getHours()).padStart(2, '0');
const minutes = String(now.getMinutes()).padStart(2, '0');
const seconds = String(now.getSeconds()).padStart(2, '0');
const nowDateTime = `${year}-${month}-${day}T${hours}:${minutes}:${seconds}`;
setDateTime(nowDateTime);
setFormattedDateTime(`${year}-${month}-${day} ${hours}:${minutes}:${seconds}`);
// 转换为时间戳
const timestamp = Math.floor(now.getTime() / 1000);
setTimestamp(timestamp.toString());
};
// 渲染时间戳区域
const renderTimestampSection = () => (
<div id="timestamp-section" className="flex flex-col gap-4 transition-all duration-300">
+59
View File
@@ -0,0 +1,59 @@
'use client';
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { IconDefinition } from '@fortawesome/fontawesome-svg-core';
interface ActionButtonProps {
onClick: () => void;
children: React.ReactNode;
variant?: 'primary' | 'secondary' | 'danger';
size?: 'sm' | 'md' | 'lg';
disabled?: boolean;
loading?: boolean;
icon?: IconDefinition;
className?: string;
}
export default function ActionButton({
onClick,
children,
variant = 'primary',
size = 'md',
disabled = false,
loading = false,
icon,
className = ''
}: ActionButtonProps) {
const baseClasses = 'inline-flex items-center justify-center font-medium rounded-lg transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2';
const variantClasses = {
primary: 'bg-purple-600 hover:bg-purple-700 text-white focus:ring-purple-500 disabled:bg-gray-600 disabled:cursor-not-allowed',
secondary: 'bg-gray-700 hover:bg-gray-600 text-white border border-gray-600 focus:ring-gray-500 disabled:bg-gray-800 disabled:cursor-not-allowed',
danger: 'bg-red-600 hover:bg-red-700 text-white focus:ring-red-500 disabled:bg-gray-600 disabled:cursor-not-allowed'
};
const sizeClasses = {
sm: 'px-3 py-1.5 text-sm',
md: 'px-4 py-2 text-sm',
lg: 'px-6 py-3 text-base'
};
const classes = `${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${className}`;
return (
<button
onClick={onClick}
disabled={disabled || loading}
className={classes}
>
{loading && (
<div className="animate-spin rounded-full h-4 w-4 border-b-2 border-white mr-2" />
)}
{icon && !loading && (
<FontAwesomeIcon icon={icon} className="mr-2" />
)}
{children}
</button>
);
}
+177
View File
@@ -0,0 +1,177 @@
'use client';
import React, { useCallback, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faCloudUploadAlt, faFile, faTimes } from '@fortawesome/free-solid-svg-icons';
interface FileUploadProps {
accept?: string;
maxSize?: number; // 以字节为单位
multiple?: boolean;
onFileSelect: (files: File[]) => void;
onError?: (error: string) => void;
title?: string;
subtitle?: string;
buttonText?: string;
className?: string;
}
export default function FileUpload({
accept = '*',
maxSize = 100 * 1024 * 1024, // 默认100MB
multiple = false,
onFileSelect,
onError,
title = '拖拽文件到此处或点击上传',
subtitle = '支持多种格式',
buttonText = '选择文件',
className = ''
}: FileUploadProps) {
const [isDragOver, setIsDragOver] = useState(false);
const [selectedFiles, setSelectedFiles] = useState<File[]>([]);
const validateFiles = useCallback((files: FileList): File[] => {
const validFiles: File[] = [];
const errors: string[] = [];
Array.from(files).forEach((file) => {
// 检查文件大小
if (file.size > maxSize) {
errors.push(`文件 ${file.name} 过大,最大支持 ${Math.round(maxSize / 1024 / 1024)}MB`);
return;
}
// 检查文件类型
if (accept !== '*' && !file.type.match(new RegExp(accept.replace(/\*/g, '.*')))) {
errors.push(`文件 ${file.name} 格式不支持`);
return;
}
validFiles.push(file);
});
if (errors.length > 0) {
onError?.(errors.join('\n'));
return [];
}
return validFiles;
}, [accept, maxSize, onError]);
const handleFileSelect = useCallback((event: React.ChangeEvent<HTMLInputElement>) => {
const files = event.target.files;
if (!files) return;
const validFiles = validateFiles(files);
if (validFiles.length > 0) {
setSelectedFiles(validFiles);
onFileSelect(validFiles);
}
}, [validateFiles, onFileSelect]);
const handleDrop = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
const files = event.dataTransfer.files;
if (!files) return;
const validFiles = validateFiles(files);
if (validFiles.length > 0) {
setSelectedFiles(validFiles);
onFileSelect(validFiles);
}
}, [validateFiles, onFileSelect]);
const handleDragOver = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(true);
}, []);
const handleDragLeave = useCallback((event: React.DragEvent<HTMLDivElement>) => {
event.preventDefault();
setIsDragOver(false);
}, []);
const removeFile = useCallback((index: number) => {
const newFiles = selectedFiles.filter((_, i) => i !== index);
setSelectedFiles(newFiles);
onFileSelect(newFiles);
}, [selectedFiles, onFileSelect]);
const formatFileSize = (bytes: number): string => {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
};
return (
<div className={`space-y-4 ${className}`}>
{/* 文件上传区域 */}
<div
className={`
border-2 border-dashed rounded-lg p-8 text-center transition-colors cursor-pointer
${isDragOver
? 'border-purple-400 bg-purple-50 dark:bg-purple-900/20'
: 'border-purple-500 hover:border-purple-400'
}
`}
onDrop={handleDrop}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onClick={() => document.getElementById('file-upload')?.click()}
>
<FontAwesomeIcon
icon={faCloudUploadAlt}
className="text-4xl text-purple-500 mb-4"
/>
<h3 className="text-xl font-semibold mb-2 text-gray-100">
{title}
</h3>
<p className="text-gray-400 mb-4">
{subtitle}
</p>
<button className="bg-purple-600 hover:bg-purple-700 text-white px-6 py-2 rounded-lg transition-colors">
{buttonText}
</button>
<input
id="file-upload"
type="file"
accept={accept}
multiple={multiple}
onChange={handleFileSelect}
className="hidden"
/>
</div>
{/* 已选择的文件列表 */}
{selectedFiles.length > 0 && (
<div className="space-y-2">
<h4 className="text-sm font-medium text-gray-300"></h4>
{selectedFiles.map((file, index) => (
<div
key={`${file.name}-${index}`}
className="flex items-center justify-between bg-gray-800 rounded-lg p-3"
>
<div className="flex items-center gap-3">
<FontAwesomeIcon icon={faFile} className="text-purple-500" />
<div>
<p className="text-sm font-medium text-gray-100">{file.name}</p>
<p className="text-xs text-gray-400">{formatFileSize(file.size)}</p>
</div>
</div>
<button
onClick={() => removeFile(index)}
className="text-gray-400 hover:text-red-400 transition-colors"
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
))}
</div>
)}
</div>
);
}
+49
View File
@@ -0,0 +1,49 @@
'use client';
import React from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faTimes } from '@fortawesome/free-solid-svg-icons';
interface ProgressBarProps {
progress: number; // 0-100
status: string;
onCancel?: () => void;
showCancel?: boolean;
className?: string;
}
export default function ProgressBar({
progress,
status,
onCancel,
showCancel = true,
className = ''
}: ProgressBarProps) {
return (
<div className={`bg-gray-800 rounded-lg p-4 ${className}`}>
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-gray-300">{status}</span>
<span className="text-sm text-gray-400">{Math.round(progress)}%</span>
</div>
<div className="w-full bg-gray-700 rounded-full h-2 mb-2">
<div
className="bg-purple-600 h-2 rounded-full transition-all duration-300 ease-out"
style={{ width: `${progress}%` }}
/>
</div>
{showCancel && onCancel && (
<div className="flex justify-end">
<button
onClick={onCancel}
className="text-gray-400 hover:text-red-400 transition-colors text-sm flex items-center gap-1"
>
<FontAwesomeIcon icon={faTimes} />
</button>
</div>
)}
</div>
);
}
+1
View File
@@ -12,6 +12,7 @@ const categories: Category[] = [
{ code: "text", active: false },
{ code: "image", active: false },
{ code: "frontend", active: false },
{ code: "pdf", active: false },
];
export default categories;
+1
View File
@@ -36,6 +36,7 @@ export const commonEn = {
text: 'Text Processing',
image: 'Image Tools',
frontend: 'Frontend Dev',
pdf: 'PDF Tools',
favorites: 'My Favorites'
}
};
+1
View File
@@ -36,6 +36,7 @@ export const commonZh = {
text: '文本处理',
image: '图像工具',
frontend: '前端开发',
pdf: 'PDF工具',
favorites: '我的收藏'
}
};
+5 -1
View File
@@ -32,7 +32,11 @@ export const en = {
image_watermark: tools.image_watermark.en,
image_to_ico: tools.image_to_ico.en,
cron_generator: tools.cron_generator.en,
icon_designer: tools.icon_designer.en
icon_designer: tools.icon_designer.en,
pdf_converter: tools.pdf_converter.en,
pdf_manager: tools.pdf_manager.en,
pdf_compressor: tools.pdf_compressor.en,
}
};
+10 -1
View File
@@ -27,7 +27,10 @@ import imageWatermark from './image_watermark';
import imageToIco from './image_to_ico';
import cronGenerator from './cron_generator';
import { iconDesigner } from './icon_designer';
import { pdfConverterI18n } from './pdf_converter';
import { pdfManager } from './pdf_manager';
import { pdfCompressor } from './pdf_compressor';
export const tools = {
json_formatter: jsonFormatter,
regex_tester: regexTester,
@@ -57,7 +60,13 @@ export const tools = {
image_watermark: imageWatermark,
image_to_ico: imageToIco,
cron_generator: cronGenerator,
icon_designer: iconDesigner
icon_designer: iconDesigner,
pdf_converter: pdfConverterI18n,
pdf_manager: pdfManager,
pdf_compressor: pdfCompressor,
};
export default tools;
@@ -0,0 +1,45 @@
export const pdfCompressorEn = {
title: 'PDF Compressor',
description: 'Efficiently compress PDF files to reduce file size while maintaining document quality',
compression_settings: {
title: 'Compression Settings',
quality: 'Compression Quality',
quality_high: 'High Quality (Larger File)',
quality_medium: 'Medium Quality (Recommended)',
quality_low: 'Low Quality (Smallest File)',
image_compression: 'Compress Images',
font_subsetting: 'Font Subsetting',
metadata_removal: 'Remove Metadata',
remove_bookmarks: 'Remove Bookmarks',
remove_annotations: 'Remove Annotations'
},
upload_area: {
title: 'Upload PDF Files',
subtitle: 'Drag and drop supported, max 100MB',
button: 'Choose Files'
},
actions: {
compress: 'Start Compression',
download: 'Download',
clear: 'Clear'
},
status: {
compressing: 'Compressing...',
completed: 'Compression Complete',
failed: 'Compression Failed',
ready: 'Ready'
},
results: {
title: 'Compression Results',
original_size: 'Original Size',
compressed_size: 'Compressed Size',
compression_ratio: 'Compression Ratio'
},
errors: {
no_file: 'Please select PDF files first',
file_too_large: 'File too large, please select files smaller than 100MB',
invalid_format: 'Unsupported file format, please select PDF files',
compression_failed: 'Compression failed, please check the file',
library_failed: 'PDF processing library failed to load'
}
};
@@ -0,0 +1,7 @@
import { pdfCompressorZh } from './zh';
import { pdfCompressorEn } from './en';
export const pdfCompressor = {
zh: pdfCompressorZh,
en: pdfCompressorEn,
};
@@ -0,0 +1,45 @@
export const pdfCompressorZh = {
title: 'PDF压缩器',
description: '高效压缩PDF文件,减小文件大小,保持文档质量',
compression_settings: {
title: '压缩设置',
quality: '压缩质量',
quality_high: '高质量 (文件较大)',
quality_medium: '中等质量 (推荐)',
quality_low: '低质量 (文件最小)',
image_compression: '压缩图片',
font_subsetting: '字体子集化',
metadata_removal: '移除元数据',
remove_bookmarks: '移除书签',
remove_annotations: '移除注释'
},
upload_area: {
title: '上传PDF文件',
subtitle: '支持拖拽上传,最大100MB',
button: '选择文件'
},
actions: {
compress: '开始压缩',
download: '下载',
clear: '清空'
},
status: {
compressing: '正在压缩...',
completed: '压缩完成',
failed: '压缩失败',
ready: '准备就绪'
},
results: {
title: '压缩结果',
original_size: '原始大小',
compressed_size: '压缩后',
compression_ratio: '压缩率'
},
errors: {
no_file: '请先选择PDF文件',
file_too_large: '文件过大,请选择小于100MB的文件',
invalid_format: '不支持的文件格式,请选择PDF文件',
compression_failed: '压缩失败,请检查文件',
library_failed: 'PDF处理库加载失败'
}
};
+50
View File
@@ -0,0 +1,50 @@
export const pdfConverterEn = {
title: 'PDF Converter',
description: 'PDF to image and text extraction tool with batch processing support',
upload_area: {
title: 'Drag PDF files here or click to upload',
subtitle: 'Supports PDF format, max 100MB',
button: 'Choose Files'
},
conversion_types: {
pdf_to_image: 'PDF to Image',
pdf_to_text: 'PDF to Text'
},
settings: {
image_format: 'Image Format',
image_quality: 'Image Quality',
page_range: 'Page Range',
output_format: 'Output Format',
compression: 'Compression Settings'
},
options: {
formats: {
png: 'PNG',
jpeg: 'JPEG',
webp: 'WebP'
},
quality: {
high: 'High Quality',
medium: 'Medium Quality',
low: 'Low Quality'
}
},
actions: {
convert: 'Start Conversion',
download: 'Download',
preview: 'Preview',
clear: 'Clear'
},
status: {
converting: 'Converting...',
completed: 'Conversion Completed',
failed: 'Conversion Failed',
ready: 'Ready'
},
errors: {
file_too_large: 'File too large, please select files smaller than 100MB',
invalid_format: 'Unsupported file format',
conversion_failed: 'Conversion failed, please check the file',
no_file: 'Please select files first'
}
};
@@ -0,0 +1,7 @@
import { pdfConverterZh } from './zh';
import { pdfConverterEn } from './en';
export const pdfConverterI18n = {
zh: pdfConverterZh,
en: pdfConverterEn,
};
+50
View File
@@ -0,0 +1,50 @@
export const pdfConverterZh = {
title: 'PDF转换器',
description: 'PDF转图片和文本提取工具,支持批量转换',
upload_area: {
title: '拖拽PDF文件到此处或点击上传',
subtitle: '支持PDF格式,最大100MB',
button: '选择文件'
},
conversion_types: {
pdf_to_image: 'PDF转图片',
pdf_to_text: 'PDF转文本'
},
settings: {
image_format: '图片格式',
image_quality: '图片质量',
page_range: '页面范围',
output_format: '输出格式',
compression: '压缩设置'
},
options: {
formats: {
png: 'PNG',
jpeg: 'JPEG',
webp: 'WebP'
},
quality: {
high: '高质量',
medium: '中等质量',
low: '低质量'
}
},
actions: {
convert: '开始转换',
download: '下载',
preview: '预览',
clear: '清空'
},
status: {
converting: '转换中...',
completed: '转换完成',
failed: '转换失败',
ready: '准备就绪'
},
errors: {
file_too_large: '文件过大,请选择小于100MB的文件',
invalid_format: '不支持的文件格式',
conversion_failed: '转换失败,请检查文件',
no_file: '请先选择文件'
}
};
+113
View File
@@ -0,0 +1,113 @@
export const pdfManagerEn = {
title: 'PDF Merge & Split',
description: 'Merge, split from PDF files ',
upload_area: {
title: 'Drag and drop PDF files here or click to upload',
subtitle: 'Supports multiple PDF files, single file up to 100MB, total size up to 500MB',
button: 'Choose Files'
},
operation_mode: {
title: 'Operation Mode',
merge: 'Merge Mode',
split: 'Split Mode',
extract: 'Page Extract'
},
merge_mode: {
title: 'Merge Settings',
file_order: 'File Order',
output_name: 'Output Filename',
compression: 'Compression'
},
split_mode: {
title: 'Split Settings',
split_method: 'Split Method',
page_range: 'Page Range',
custom_ranges: 'Custom Ranges',
equal_parts: 'Equal Parts'
},
split_methods: {
single_pages: 'Single Pages',
single_pages_description: 'Split each page into a separate PDF file',
custom_ranges: 'Custom Ranges',
equal_parts: 'Equal Parts',
by_range: 'By Range'
},
page_range: {
title: 'Page Range',
start_page: 'Start Page',
end_page: 'End Page',
total_pages: 'Total Pages',
preview: 'Preview'
},
custom_ranges: {
title: 'Custom Ranges',
add_range: 'Add Range',
remove_range: 'Remove Range',
range_format: 'Format: 1-5,7-10,15',
placeholder: 'Enter page ranges, e.g.: 1-5,7-10,15',
single_page: 'Page {page}',
page_range: 'Pages {start}-{end}'
},
equal_parts: {
title: 'Equal Parts',
parts_count: 'Number of Parts',
pages_per_part: 'Pages per Part'
},
extract_mode: {
title: 'Page Extract',
extract_pages: 'Extract Pages',
page_numbers: 'Page Numbers',
page_numbers_hint: 'Enter page numbers separated by commas, support ranges like: 1,3,5-8,10',
output_format: 'Output Format'
},
file_list: {
title: 'File List',
drag_hint: 'Drag files to reorder',
total_size: 'Total Size',
page_count: 'Total Pages',
remove_file: 'Remove File'
},
output_settings: {
title: 'Output Settings',
filename_prefix: 'Filename Prefix',
include_page_numbers: 'Include Page Numbers',
compression: 'Compression'
},
actions: {
merge: 'Start Merge',
split: 'Start Split',
extract: 'Start Extract',
preview: 'Preview',
download: 'Download',
download_all: 'Download All',
download_selected: 'Download Selected',
clear: 'Clear'
},
status: {
merging: 'Merging...',
splitting: 'Splitting...',
extracting: 'Extracting...',
completed: 'Operation completed',
failed: 'Operation failed',
ready: 'Ready'
},
results: {
title: 'Operation Results',
files_count: 'File Count',
total_size: 'Total Size',
download_all: 'Download All Files',
download_selected: 'Download Selected Files'
},
errors: {
file_too_large: 'Files too large, single file cannot exceed 100MB, total size cannot exceed 500MB',
invalid_format: 'Unsupported file format, please select PDF files',
operation_failed: 'Operation failed, please check the files',
no_files: 'Please select files first',
need_at_least_two: 'Merge mode requires at least two PDF files',
invalid_range: 'Invalid page range',
range_out_of_bounds: 'Page range exceeds file page count',
invalid_parts_count: 'Number of parts must be greater than 1',
no_ranges: 'Please add at least one split range',
invalid_page_numbers: 'Invalid page numbers'
}
};
@@ -0,0 +1,7 @@
import { pdfManagerZh } from './zh';
import { pdfManagerEn } from './en';
export const pdfManager = {
zh: pdfManagerZh,
en: pdfManagerEn
};
+113
View File
@@ -0,0 +1,113 @@
export const pdfManagerZh = {
title: 'PDF合并与切割',
description: 'PDF文件合并、切割',
upload_area: {
title: '拖拽PDF文件到此处或点击上传',
subtitle: '支持多个PDF文件,单个文件不超过100MB,总大小不超过500MB',
button: '选择文件'
},
operation_mode: {
title: '操作模式',
merge: '合并模式',
split: '分割模式',
extract: '页面提取'
},
merge_mode: {
title: '合并设置',
file_order: '文件顺序',
output_name: '输出文件名',
compression: '压缩设置'
},
split_mode: {
title: '分割设置',
split_method: '分割方式',
page_range: '页面范围',
custom_ranges: '自定义范围',
equal_parts: '等分分割'
},
split_methods: {
single_pages: '单页分割',
single_pages_description: '将每个页面分割为单独的PDF文件',
custom_ranges: '自定义范围',
equal_parts: '等分分割',
by_range: '按范围分割'
},
page_range: {
title: '页面范围',
start_page: '起始页',
end_page: '结束页',
total_pages: '总页数',
preview: '预览'
},
custom_ranges: {
title: '自定义范围',
add_range: '添加范围',
remove_range: '删除范围',
range_format: '格式:1-5,7-10,15',
placeholder: '输入页面范围,如:1-5,7-10,15',
single_page: '第 {page} 页',
page_range: '第 {start}-{end} 页'
},
equal_parts: {
title: '等分分割',
parts_count: '分割份数',
pages_per_part: '每份页数'
},
extract_mode: {
title: '页面提取',
extract_pages: '提取页面',
page_numbers: '页面编号',
page_numbers_hint: '输入页面编号,用逗号分隔,支持范围如:1,3,5-8,10',
output_format: '输出格式'
},
file_list: {
title: '文件列表',
drag_hint: '拖拽文件调整顺序',
total_size: '总大小',
page_count: '总页数',
remove_file: '删除文件'
},
output_settings: {
title: '输出设置',
filename_prefix: '文件名前缀',
include_page_numbers: '包含页码',
compression: '压缩设置'
},
actions: {
merge: '开始合并',
split: '开始分割',
extract: '开始提取',
preview: '预览',
download: '下载',
download_all: '下载全部',
download_selected: '下载选中',
clear: '清空'
},
status: {
merging: '合并中...',
splitting: '分割中...',
extracting: '提取中...',
completed: '操作完成',
failed: '操作失败',
ready: '准备就绪'
},
results: {
title: '操作结果',
files_count: '文件数量',
total_size: '总大小',
download_all: '下载全部文件',
download_selected: '下载选中文件'
},
errors: {
file_too_large: '文件过大,单个文件不能超过100MB,总大小不能超过500MB',
invalid_format: '不支持的文件格式,请选择PDF文件',
operation_failed: '操作失败,请检查文件',
no_files: '请先选择文件',
need_at_least_two: '合并模式至少需要选择两个PDF文件',
invalid_range: '无效的页面范围',
range_out_of_bounds: '页面范围超出文件页数',
invalid_parts_count: '分割份数必须大于1',
no_ranges: '请至少添加一个分割范围',
invalid_page_numbers: '无效的页面编号'
}
};
+6 -1
View File
@@ -32,7 +32,12 @@ export const zh = {
image_watermark: tools.image_watermark.zh,
image_to_ico: tools.image_to_ico.zh,
cron_generator: tools.cron_generator.zh,
icon_designer: tools.icon_designer.zh
icon_designer: tools.icon_designer.zh,
pdf_converter: tools.pdf_converter.zh,
pdf_manager: tools.pdf_manager.zh,
pdf_compressor: tools.pdf_compressor.zh,
}
};
+26 -2
View File
@@ -2,7 +2,7 @@ import {
faCode, faExchangeAlt, faClock, faGlobe, faLink, faLock,
faImage, faCogs, faFileCode, faKey, faFont,
faCalendarAlt, faPalette, faEdit, faRuler, faNetworkWired,
faEraser, faCalculator, faFileAlt, faCube
faEraser, faCalculator, faFileAlt, faCube,faObjectGroup, faCompress
} from '@fortawesome/free-solid-svg-icons';
import { Tool } from '@/types/tools';
@@ -177,7 +177,31 @@ const tools: Tool[] = [
icon: faCube,
category: ['common', 'image'],
keywords: ['图标设计', '图标生成', 'icon设计', '图标制作', 'app图标', 'logo设计', 'favicon制作', '图标工具', 'icon designer', 'icon generator', 'tubiao', 'tb', 'sheji', 'sj', 'zhizuo', 'zz']
}
},
// PDF工具
{
code: 'pdf_converter',
icon: faExchangeAlt,
category: ['pdf'],
keywords: ['pdf转换', 'pdf转图片', '图片转pdf', 'pdf转文本', 'pdf转换器', 'pdf converter', 'pdf转换工具', 'pdf', 'zhuanhuan', 'zh', 'zhuanhuanqi', 'zhq']
},
{
code: 'pdf_manager',
icon: faObjectGroup,
category: ['pdf'],
keywords: ['pdf合并', 'pdf分割', 'pdf合并器', 'pdf分割器', '合并pdf', '分割pdf', 'pdf合并工具', 'pdf分割工具', 'pdf merger', 'pdf splitter', 'pdf合并软件', 'pdf分割软件', 'pdf', 'hebing', 'fenge', 'hb', 'fg', 'hebingqi', 'fengeqi', 'hbq', 'fgq']
},
{
code: 'pdf_compressor',
icon: faCompress,
category: ['pdf'],
keywords: ['pdf压缩', 'pdf压缩器', '压缩pdf', 'pdf压缩工具', 'pdf compressor', 'pdf压缩软件', 'pdf', 'yasuo', 'ys', 'yasuoqi', 'ysq']
},
] as Tool[];
export default tools;