add view de

This commit is contained in:
2026-06-30 15:34:59 +07:00
parent 840ddcdce9
commit 595aebd8a5
10 changed files with 1325 additions and 10 deletions

View File

@@ -8,6 +8,7 @@
"name": "management",
"version": "0.0.0",
"dependencies": {
"pdfjs-dist": "^3.11.174",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},
@@ -349,6 +350,40 @@
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@mapbox/node-pre-gyp": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz",
"integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==",
"license": "BSD-3-Clause",
"optional": true,
"dependencies": {
"detect-libc": "^2.0.0",
"https-proxy-agent": "^5.0.0",
"make-dir": "^3.1.0",
"node-fetch": "^2.6.7",
"nopt": "^5.0.0",
"npmlog": "^5.0.1",
"rimraf": "^3.0.2",
"semver": "^7.3.5",
"tar": "^6.1.11"
},
"bin": {
"node-pre-gyp": "bin/node-pre-gyp"
}
},
"node_modules/@mapbox/node-pre-gyp/node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz",
@@ -1150,6 +1185,58 @@
}
}
},
"node_modules/abbrev": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz",
"integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==",
"license": "ISC",
"optional": true
},
"node_modules/agent-base": {
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
"integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "4"
},
"engines": {
"node": ">= 6.0.0"
}
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/aproba": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz",
"integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==",
"license": "ISC",
"optional": true
},
"node_modules/are-we-there-yet": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz",
"integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"delegates": "^1.0.0",
"readable-stream": "^3.6.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/babel-plugin-react-compiler": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz",
@@ -1160,6 +1247,13 @@
"@babel/types": "^7.26.0"
}
},
"node_modules/balanced-match": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==",
"license": "MIT",
"optional": true
},
"node_modules/baseline-browser-mapping": {
"version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -1173,6 +1267,17 @@
"node": ">=6.0.0"
}
},
"node_modules/brace-expansion": {
"version": "1.1.15",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.15.tgz",
"integrity": "sha512-EwOCDEex4quD37XhqM3omwtMoJjr//isUZz1JopUNWms+4Z2ViyM/k1YIRePpoVNnQhENnxtFjLaxNHrT7xIUg==",
"license": "MIT",
"optional": true,
"dependencies": {
"balanced-match": "^1.0.0",
"concat-map": "0.0.1"
}
},
"node_modules/browserslist": {
"version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
@@ -1228,6 +1333,56 @@
],
"license": "CC-BY-4.0"
},
"node_modules/canvas": {
"version": "2.11.2",
"resolved": "https://registry.npmjs.org/canvas/-/canvas-2.11.2.tgz",
"integrity": "sha512-ItanGBMrmRV7Py2Z+Xhs7cT+FNt5K0vPL4p9EZ/UX/Mu7hFbkxSjKF2KVtPwX7UYWp7dRKnrTvReflgrItJbdw==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@mapbox/node-pre-gyp": "^1.0.0",
"nan": "^2.17.0",
"simple-get": "^3.0.3"
},
"engines": {
"node": ">=6"
}
},
"node_modules/chownr": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz",
"integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=10"
}
},
"node_modules/color-support": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz",
"integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==",
"license": "ISC",
"optional": true,
"bin": {
"color-support": "bin.js"
}
},
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT",
"optional": true
},
"node_modules/console-control-strings": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz",
"integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==",
"license": "ISC",
"optional": true
},
"node_modules/convert-source-map": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1246,7 +1401,7 @@
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true,
"devOptional": true,
"license": "MIT",
"dependencies": {
"ms": "^2.1.3"
@@ -1260,11 +1415,31 @@
}
}
},
"node_modules/decompress-response": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-4.2.1.tgz",
"integrity": "sha512-jOSne2qbyE+/r8G1VU+G/82LBs2Fs4LAsTiLSHOCOMZQl2OKZ6i8i4IyHemTe+/yIXOtTcRQMzPcgyhoFlqPkw==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-response": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/delegates": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz",
"integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==",
"license": "MIT",
"optional": true
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true,
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@@ -1277,6 +1452,13 @@
"dev": true,
"license": "ISC"
},
"node_modules/emoji-regex": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT",
"optional": true
},
"node_modules/escalade": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
@@ -1305,6 +1487,46 @@
}
}
},
"node_modules/fs-minipass": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz",
"integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^3.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/fs-minipass/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/fs-minipass/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"optional": true
},
"node_modules/fs.realpath": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz",
"integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==",
"license": "ISC",
"optional": true
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
@@ -1320,6 +1542,28 @@
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/gauge": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz",
"integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"aproba": "^1.0.3 || ^2.0.0",
"color-support": "^1.1.2",
"console-control-strings": "^1.0.0",
"has-unicode": "^2.0.1",
"object-assign": "^4.1.1",
"signal-exit": "^3.0.0",
"string-width": "^4.2.3",
"strip-ansi": "^6.0.1",
"wide-align": "^1.1.2"
},
"engines": {
"node": ">=10"
}
},
"node_modules/gensync": {
"version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1330,6 +1574,78 @@
"node": ">=6.9.0"
}
},
"node_modules/glob": {
"version": "7.2.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz",
"integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==",
"deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"optional": true,
"dependencies": {
"fs.realpath": "^1.0.0",
"inflight": "^1.0.4",
"inherits": "2",
"minimatch": "^3.1.1",
"once": "^1.3.0",
"path-is-absolute": "^1.0.0"
},
"engines": {
"node": "*"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/has-unicode": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz",
"integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==",
"license": "ISC",
"optional": true
},
"node_modules/https-proxy-agent": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz",
"integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==",
"license": "MIT",
"optional": true,
"dependencies": {
"agent-base": "6",
"debug": "4"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/inflight": {
"version": "1.0.6",
"resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz",
"integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==",
"deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.",
"license": "ISC",
"optional": true,
"dependencies": {
"once": "^1.3.0",
"wrappy": "1"
}
},
"node_modules/inherits": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC",
"optional": true
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/js-tokens": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1646,13 +1962,119 @@
"yallist": "^3.0.2"
}
},
"node_modules/make-dir": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz",
"integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==",
"license": "MIT",
"optional": true,
"dependencies": {
"semver": "^6.0.0"
},
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/mimic-response": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-2.1.0.tgz",
"integrity": "sha512-wXqjST+SLt7R009ySCglWBCFpjUygmCIfD790/kVbiGmUgfYGuB14PiTd5DwVxSV4NcYHjzMkoj5LjQZwTQLEA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/minimatch": {
"version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
"integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==",
"license": "ISC",
"optional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
},
"engines": {
"node": "*"
}
},
"node_modules/minipass": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz",
"integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz",
"integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==",
"license": "MIT",
"optional": true,
"dependencies": {
"minipass": "^3.0.0",
"yallist": "^4.0.0"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/minizlib/node_modules/minipass": {
"version": "3.3.6",
"resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz",
"integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==",
"license": "ISC",
"optional": true,
"dependencies": {
"yallist": "^4.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/minizlib/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"optional": true
},
"node_modules/mkdirp": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz",
"integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==",
"license": "MIT",
"optional": true,
"bin": {
"mkdirp": "bin/cmd.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/ms": {
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true,
"devOptional": true,
"license": "MIT"
},
"node_modules/nan": {
"version": "2.28.0",
"resolved": "https://registry.npmjs.org/nan/-/nan-2.28.0.tgz",
"integrity": "sha512-fTsDz99OTq2sVePhGdp4qQhggZFtKr64ZNVyVajRKtMOkJxYekplBh577PiJB12v/D3s2E5cGtOI45LWp6rnLQ==",
"license": "MIT",
"optional": true
},
"node_modules/nanoid": {
"version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
@@ -1672,6 +2094,27 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/node-fetch": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz",
"integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==",
"license": "MIT",
"optional": true,
"dependencies": {
"whatwg-url": "^5.0.0"
},
"engines": {
"node": "4.x || >=6.0.0"
},
"peerDependencies": {
"encoding": "^0.1.0"
},
"peerDependenciesMeta": {
"encoding": {
"optional": true
}
}
},
"node_modules/node-releases": {
"version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
@@ -1682,6 +2125,56 @@
"node": ">=18"
}
},
"node_modules/nopt": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz",
"integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"abbrev": "1"
},
"bin": {
"nopt": "bin/nopt.js"
},
"engines": {
"node": ">=6"
}
},
"node_modules/npmlog": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz",
"integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==",
"deprecated": "This package is no longer supported.",
"license": "ISC",
"optional": true,
"dependencies": {
"are-we-there-yet": "^2.0.0",
"console-control-strings": "^1.1.0",
"gauge": "^3.0.0",
"set-blocking": "^2.0.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
"integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
"integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==",
"license": "ISC",
"optional": true,
"dependencies": {
"wrappy": "1"
}
},
"node_modules/oxlint": {
"version": "1.71.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz",
@@ -1731,6 +2224,39 @@
}
}
},
"node_modules/path-is-absolute": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz",
"integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/path2d-polyfill": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/path2d-polyfill/-/path2d-polyfill-2.0.1.tgz",
"integrity": "sha512-ad/3bsalbbWhmBo0D6FZ4RNMwsLsPpL6gnvhuSaU5Vm7b06Kr5ubSltQQ0T7YKsiJQO+g22zJ4dJKNTXIyOXtA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
},
"node_modules/pdfjs-dist": {
"version": "3.11.174",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-3.11.174.tgz",
"integrity": "sha512-TdTZPf1trZ8/UFu5Cx/GXB7GZM30LT+wWUNfsi6Bq8ePLnb+woNKtDymI2mxZYBpMbonNFqKmiz684DIfnd8dA==",
"license": "Apache-2.0",
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"canvas": "^2.11.2",
"path2d-polyfill": "^2.0.1"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1801,6 +2327,38 @@
"react": "^19.2.7"
}
},
"node_modules/readable-stream": {
"version": "3.6.2",
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
"integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
"license": "MIT",
"optional": true,
"dependencies": {
"inherits": "^2.0.3",
"string_decoder": "^1.1.1",
"util-deprecate": "^1.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/rimraf": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz",
"integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==",
"deprecated": "Rimraf versions prior to v4 are no longer supported",
"license": "ISC",
"optional": true,
"dependencies": {
"glob": "^7.1.3"
},
"bin": {
"rimraf": "bin.js"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rolldown": {
"version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
@@ -1835,6 +2393,27 @@
"@rolldown/binding-win32-x64-msvc": "1.1.3"
}
},
"node_modules/safe-buffer": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
"integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
"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",
"optional": true
},
"node_modules/scheduler": {
"version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1845,12 +2424,59 @@
"version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true,
"devOptional": true,
"license": "ISC",
"bin": {
"semver": "bin/semver.js"
}
},
"node_modules/set-blocking": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz",
"integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==",
"license": "ISC",
"optional": true
},
"node_modules/signal-exit": {
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC",
"optional": true
},
"node_modules/simple-concat": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
"integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
"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",
"optional": true
},
"node_modules/simple-get": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/simple-get/-/simple-get-3.1.1.tgz",
"integrity": "sha512-CQ5LTKGfCpvE1K0n2us+kuMPbk/q0EKl82s4aheV9oXjFEz6W/Y7oQFVJuU6QG77hRT4Ghb5RURteF5vnWjupA==",
"license": "MIT",
"optional": true,
"dependencies": {
"decompress-response": "^4.2.0",
"once": "^1.3.1",
"simple-concat": "^1.0.0"
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1861,6 +2487,70 @@
"node": ">=0.10.0"
}
},
"node_modules/string_decoder": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
"integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
"license": "MIT",
"optional": true,
"dependencies": {
"safe-buffer": "~5.2.0"
}
},
"node_modules/string-width": {
"version": "4.2.3",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
"strip-ansi": "^6.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/strip-ansi": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/tar": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me",
"license": "ISC",
"optional": true,
"dependencies": {
"chownr": "^2.0.0",
"fs-minipass": "^2.0.0",
"minipass": "^5.0.0",
"minizlib": "^2.1.1",
"mkdirp": "^1.0.3",
"yallist": "^4.0.0"
},
"engines": {
"node": ">=10"
}
},
"node_modules/tar/node_modules/yallist": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC",
"optional": true
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1878,6 +2568,13 @@
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tr46": {
"version": "0.0.3",
"resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz",
"integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==",
"license": "MIT",
"optional": true
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1938,6 +2635,13 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT",
"optional": true
},
"node_modules/vite": {
"version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz",
@@ -2016,6 +2720,41 @@
}
}
},
"node_modules/webidl-conversions": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz",
"integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==",
"license": "BSD-2-Clause",
"optional": true
},
"node_modules/whatwg-url": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz",
"integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==",
"license": "MIT",
"optional": true,
"dependencies": {
"tr46": "~0.0.3",
"webidl-conversions": "^3.0.0"
}
},
"node_modules/wide-align": {
"version": "1.1.5",
"resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz",
"integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==",
"license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^1.0.2 || 2 || 3 || 4"
}
},
"node_modules/wrappy": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==",
"license": "ISC",
"optional": true
},
"node_modules/yallist": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

@@ -10,6 +10,7 @@
"preview": "vite preview"
},
"dependencies": {
"pdfjs-dist": "^3.11.174",
"react": "^19.2.7",
"react-dom": "^19.2.7"
},

View File

@@ -694,6 +694,13 @@ export interface ExamSubmission {
gitPublishUrl?: string;
}
function decodeBase64ToArrayBuffer(b64: string): ArrayBuffer {
const bin = atob(b64);
const out = new Uint8Array(bin.length);
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
return out.buffer;
}
export const apiExam = {
list: async (): Promise<{ data: ExamRoomItem[] }> => {
const res = await staffFetch('/exam-rooms');
@@ -798,6 +805,38 @@ export const apiExam = {
if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
return res.json();
},
fetchPaperPdfBytes: async (examId: number, paperId: number): Promise<ArrayBuffer> => {
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/view`);
if (!res.ok) await parseError(res, 'Không mở được đề PDF');
const json = await res.json() as { data: string };
if (!json.data) throw new Error('Server không trả dữ liệu PDF');
return decodeBase64ToArrayBuffer(json.data);
},
fetchPaperResourceBytes: async (examId: number, paperId: number, resourceId: number): Promise<ArrayBuffer> => {
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/resources/${resourceId}/view`);
if (!res.ok) await parseError(res, 'Không tải tài nguyên');
const json = await res.json() as { data: string };
if (!json.data) throw new Error('Server không trả dữ liệu file');
return decodeBase64ToArrayBuffer(json.data);
},
downloadPaperFile: async (examId: number, paperId: number, opts?: { resourceId?: number; fileName?: string }) => {
const params = new URLSearchParams();
if (opts?.resourceId) {
params.set('kind', 'resource');
params.set('fileId', String(opts.resourceId));
} else {
params.set('kind', 'pdf');
}
const res = await staffFetch(`/exam-rooms/${examId}/papers/${paperId}/download?${params}`);
if (!res.ok) await parseError(res, 'Tải file thất bại');
const buf = await res.arrayBuffer();
const url = URL.createObjectURL(new Blob([buf]));
const a = document.createElement('a');
a.href = url;
a.download = opts?.fileName || 'de-thi.pdf';
a.click();
URL.revokeObjectURL(url);
},
assignRandom: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
if (!res.ok) await parseError(res, 'Chia gói đề thất bại');

View File

@@ -0,0 +1,44 @@
import { useEffect, useRef, useState } from 'react';
import { renderExamPdfPages } from '../utils/renderExamPdf';
interface Props {
title: string;
bytes: Uint8Array | null;
loading?: boolean;
onClose: () => void;
}
export function ExamPaperPdfModal({ title, bytes, loading, onClose }: Props) {
const containerRef = useRef<HTMLDivElement>(null);
const [err, setErr] = useState('');
useEffect(() => {
if (!bytes || !containerRef.current) return;
let cancelled = false;
setErr('');
renderExamPdfPages(containerRef.current, bytes).catch((e: unknown) => {
if (!cancelled) {
setErr(e instanceof Error ? e.message : 'Không hiển thị được PDF');
}
});
return () => { cancelled = true; };
}, [bytes]);
return (
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={onClose}>
<div className="exam-pdf-viewer-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-pdf-viewer-header">
<h2 className="modal-title">{title}</h2>
<button type="button" className="modal-close-btn" onClick={onClose} aria-label="Đóng">×</button>
</div>
<div className="exam-pdf-viewer-scroll" ref={containerRef}>
{loading && <p className="exam-pdf-viewer-status">Đang tải đ...</p>}
{err && <p className="login-error" style={{ padding: '1rem' }}>{err}</p>}
{!loading && !bytes && !err && (
<p className="exam-pdf-viewer-status">Không dữ liệu PDF</p>
)}
</div>
</div>
</div>
);
}

View File

@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useState } from 'react';
import React, { useCallback, useEffect, useRef, useState } from 'react';
import {
apiExam,
staffFetch,
@@ -12,6 +12,7 @@ import { navigate, pushNav } from '../navigation';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { openStaffChat } from '../chatEvents';
import { AppPoolModal } from './AppPoolModal';
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
import { NavHistoryBar } from './NavHistoryBar';
import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal';
@@ -83,6 +84,10 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
const [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info');
const [papersModalOpen, setPapersModalOpen] = useState(false);
const [pdfViewer, setPdfViewer] = useState<{ title: string; bytes: Uint8Array | null } | null>(null);
const [pdfLoading, setPdfLoading] = useState(false);
const [inlineResource, setInlineResource] = useState<{ url: string; title: string; isImage: boolean } | null>(null);
const load = useCallback(async () => {
setLoading(true);
@@ -117,6 +122,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
useEffect(() => { load().catch(console.error); }, [load]);
const closePdfViewer = useCallback(() => {
setPdfViewer(null);
setPdfLoading(false);
}, []);
const closeInlineResource = useCallback(() => {
setInlineResource((prev) => {
if (prev?.url) URL.revokeObjectURL(prev.url);
return null;
});
}, []);
const inlineResourceRef = useRef(inlineResource);
inlineResourceRef.current = inlineResource;
useEffect(() => () => {
if (inlineResourceRef.current?.url) URL.revokeObjectURL(inlineResourceRef.current.url);
}, []);
const fetchOnline = useCallback(async () => {
try {
const res = await apiExam.fetchOnlineStudents(examId);
@@ -237,6 +261,64 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
URL.revokeObjectURL(url);
};
const viewPaperPdf = async (paperId: number, title?: string) => {
setErr('');
const paperTitle = title || papers.find((p) => p.id === paperId)?.title || 'Đề thi';
setPapersModalOpen(false);
setPdfLoading(true);
setPdfViewer({ title: paperTitle, bytes: null });
try {
const buf = await apiExam.fetchPaperPdfBytes(examId, paperId);
setPdfViewer({ title: paperTitle, bytes: new Uint8Array(buf) });
} catch (e: any) {
closePdfViewer();
setErr(e?.message || 'Không mở được đề PDF');
} finally {
setPdfLoading(false);
}
};
const viewPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
setErr('');
if (/\.pdf$/i.test(fileName)) {
setPapersModalOpen(false);
setPdfLoading(true);
setPdfViewer({ title: fileName, bytes: null });
try {
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
setPdfViewer({ title: fileName, bytes: new Uint8Array(buf) });
} catch (e: any) {
closePdfViewer();
setErr(e?.message || 'Không mở được PDF');
} finally {
setPdfLoading(false);
}
return;
}
closeInlineResource();
try {
const buf = await apiExam.fetchPaperResourceBytes(examId, paperId, resourceId);
const isImage = /\.(png|jpe?g|gif|webp)$/i.test(fileName);
const mime = /\.png$/i.test(fileName) ? 'image/png'
: /\.jpe?g$/i.test(fileName) ? 'image/jpeg'
: /\.gif$/i.test(fileName) ? 'image/gif'
: 'image/webp';
const url = URL.createObjectURL(new Blob([buf], { type: mime }));
setInlineResource({ url, title: fileName, isImage });
} catch (e: any) {
setErr(e?.message || 'Không mở được tài nguyên');
}
};
const downloadPaperResource = async (paperId: number, resourceId: number, fileName: string) => {
setErr('');
try {
await apiExam.downloadPaperFile(examId, paperId, { resourceId, fileName });
} catch (e: any) {
setErr(e?.message || 'Không tải được tài nguyên');
}
};
const downloadAllSubs = async () => {
setErr(''); setMsg('');
setBundlingSubs(true);
@@ -417,6 +499,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{canCancel && (
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ
</button>
)}
</div>
</div>
@@ -610,17 +697,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{papers.map((p) => (
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', gap: '0.5rem', alignItems: 'flex-start', flexWrap: 'wrap' }}>
<strong>{p.title}</strong>
<div style={{ display: 'flex', gap: '0.35rem', alignItems: 'center' }}>
<button type="button" className="btn btn-ghost btn-sm" onClick={() => viewPaperPdf(p.id)}>Xem PDF</button>
<span className="badge badge-muted">Gói đ</span>
</div>
</div>
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
<li>📄 main.pdf (đ chính)</li>
{(p.resources || []).length === 0 ? (
<li style={{ color: 'var(--text-muted)' }}>Chưa tài nguyên kèm</li>
) : (
(p.resources || []).map((r) => (
<li key={r.id}>📎 {r.fileName}</li>
<li key={r.id}>
📎 {r.fileName}{' '}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</li>
))
)}
</ul>
@@ -718,6 +813,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
+ Thêm sinh viên
</button>
)}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
</button>
)}
</div>
</div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} />
@@ -820,7 +920,19 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
{isOnline ? 'Online' : 'Offline'}
</td>
<td>{s.paperTitle || '—'}</td>
<td>
{s.paperTitle || '—'}
{s.assignedPaperId && (
<button
type="button"
className="link-btn"
style={{ marginLeft: '0.35rem' }}
onClick={() => viewPaperPdf(s.assignedPaperId!)}
>
Xem đ
</button>
)}
</td>
<td>{s.paperSentAt ? fmtTime(s.paperSentAt) : s.paperScheduledAt ? `Hẹn ${fmtTime(s.paperScheduledAt)}` : '—'}</td>
<td>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
@@ -853,6 +965,15 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="session-logs-panel">
{submissions.length > 0 && (
<div className="exam-submissions-toolbar">
{papers.length > 0 && (
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={() => setPapersModalOpen(true)}
>
📄 Xem đ phòng thi
</button>
)}
<button
type="button"
className="btn btn-primary btn-sm"
@@ -939,6 +1060,81 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div>
</div>
{pdfViewer && (
<ExamPaperPdfModal
title={pdfViewer.title}
bytes={pdfViewer.bytes}
loading={pdfLoading}
onClose={closePdfViewer}
/>
)}
{inlineResource && (
<div className="modal-overlay exam-pdf-viewer-overlay" onClick={closeInlineResource}>
<div className="exam-pdf-viewer-modal exam-inline-resource-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header exam-pdf-viewer-header">
<h2 className="modal-title">{inlineResource.title}</h2>
<button type="button" className="modal-close-btn" onClick={closeInlineResource} aria-label="Đóng">×</button>
</div>
<div className="exam-inline-resource-body">
{inlineResource.isImage ? (
<img src={inlineResource.url} alt={inlineResource.title} className="exam-inline-resource-image" />
) : (
<iframe src={inlineResource.url} title={inlineResource.title} className="exam-pdf-viewer-frame" />
)}
</div>
</div>
</div>
)}
{papersModalOpen && (
<div className="modal-overlay" onClick={() => setPapersModalOpen(false)}>
<div className="modal-container exam-papers-view-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<div>
<h2 className="modal-title">Đ thi {roomName}</h2>
<p className="exam-student-modal-desc">{papers.length} gói đ trong phòng thi</p>
</div>
<button type="button" className="modal-close-btn" onClick={() => setPapersModalOpen(false)} aria-label="Đóng">×</button>
</div>
<div className="exam-papers-view-body">
{papers.map((p) => (
<div key={p.id} className="exam-paper-view-card">
<div className="exam-paper-view-head">
<strong>{p.title}</strong>
<button type="button" className="btn btn-primary btn-sm" onClick={(e) => { e.preventDefault(); void viewPaperPdf(p.id); }}>
Xem PDF đ
</button>
</div>
<ul className="exam-paper-view-resources">
<li>📄 Đ chính (PDF)</li>
{(p.resources || []).length === 0 ? (
<li className="text-muted">Không tài nguyên kèm</li>
) : (
(p.resources || []).map((r) => (
<li key={r.id}>
📎 {r.fileName}
<span className="exam-paper-view-actions">
{/\.(pdf|png|jpe?g|gif|webp|txt)$/i.test(r.fileName) && (
<button type="button" className="link-btn" onClick={() => viewPaperResource(p.id, r.id, r.fileName)}>
Xem
</button>
)}
<button type="button" className="link-btn" onClick={() => downloadPaperResource(p.id, r.id, r.fileName)}>
Tải
</button>
</span>
</li>
))
)}
</ul>
</div>
))}
</div>
</div>
</div>
)}
{selectedStudent && (
<StudentDetailModal
student={selectedStudent}

View File

@@ -4122,6 +4122,131 @@ input:checked + .slider:before {
cursor: pointer;
}
.exam-papers-view-modal {
width: min(560px, 94vw);
max-height: 85vh;
display: flex;
flex-direction: column;
}
.exam-papers-view-body {
padding: 0 1.25rem 1.25rem;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.exam-paper-view-card {
border: 1px solid var(--border-light);
border-radius: var(--radius-md);
padding: 0.85rem 1rem;
background: var(--bg-subtle);
}
.exam-paper-view-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.exam-paper-view-resources {
margin: 0.65rem 0 0;
padding-left: 1.1rem;
font-size: 0.88rem;
color: var(--text-secondary);
}
.exam-paper-view-resources li {
margin-bottom: 0.35rem;
}
.exam-paper-view-actions {
margin-left: 0.5rem;
display: inline-flex;
gap: 0.5rem;
}
.exam-pdf-viewer-overlay {
z-index: 2000;
}
.exam-pdf-viewer-modal {
width: min(960px, 96vw);
height: min(90vh, 900px);
display: flex;
flex-direction: column;
background: var(--bg-card);
border-radius: var(--radius-lg);
overflow: hidden;
box-shadow: var(--shadow-lg);
}
.exam-pdf-viewer-header {
flex-shrink: 0;
padding: 0.85rem 1rem;
border-bottom: 1px solid var(--border-light);
}
.exam-pdf-viewer-scroll {
flex: 1;
overflow-y: auto;
overflow-x: hidden;
background: #525659;
padding: 1rem 0;
min-height: 0;
}
.exam-pdf-viewer-status {
text-align: center;
color: #fff;
padding: 2rem 1rem;
margin: 0;
}
.exam-pdf-page-wrap {
display: flex;
justify-content: center;
margin-bottom: 12px;
padding: 0 12px;
}
.exam-pdf-page {
display: block;
max-width: 100%;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.35);
background: #fff;
}
.exam-pdf-viewer-frame {
flex: 1;
width: 100%;
border: none;
background: #525659;
}
.exam-inline-resource-modal {
height: min(85vh, 800px);
}
.exam-inline-resource-body {
flex: 1;
overflow: auto;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-subtle);
min-height: 0;
}
.exam-inline-resource-image {
max-width: 100%;
max-height: 100%;
object-fit: contain;
}
.exam-send-row {
display: flex;
flex-wrap: wrap;

15
management/src/pdfjs.d.ts vendored Normal file
View File

@@ -0,0 +1,15 @@
declare module 'pdfjs-dist/build/pdf' {
export const GlobalWorkerOptions: { workerSrc: string };
export function getDocument(src: { data: Uint8Array }): { promise: Promise<{
numPages: number;
getPage: (n: number) => Promise<{
getViewport: (opts: { scale: number }) => { width: number; height: number };
render: (opts: { canvasContext: CanvasRenderingContext2D; viewport: unknown }) => { promise: Promise<void> };
}>;
}> };
}
declare module 'pdfjs-dist/build/pdf.worker.min.js?url' {
const src: string;
export default src;
}

View File

@@ -0,0 +1,33 @@
import * as pdfjsLib from 'pdfjs-dist/build/pdf';
import pdfjsWorker from 'pdfjs-dist/build/pdf.worker.min.js?url';
pdfjsLib.GlobalWorkerOptions.workerSrc = pdfjsWorker;
export async function renderExamPdfPages(container: HTMLElement, bytes: Uint8Array) {
container.innerHTML = '';
const pdf = await pdfjsLib.getDocument({ data: bytes }).promise;
const pad = 16;
const width = container.clientWidth || container.parentElement?.clientWidth || 900;
const maxWidth = Math.max(360, width - pad * 2);
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum += 1) {
const page = await pdf.getPage(pageNum);
const base = page.getViewport({ scale: 1 });
const scale = Math.min(1.6, maxWidth / base.width);
const viewport = page.getViewport({ scale });
const wrap = document.createElement('div');
wrap.className = 'exam-pdf-page-wrap';
const canvas = document.createElement('canvas');
canvas.className = 'exam-pdf-page';
canvas.width = viewport.width;
canvas.height = viewport.height;
wrap.appendChild(canvas);
container.appendChild(wrap);
await page.render({
canvasContext: canvas.getContext('2d')!,
viewport,
}).promise;
}
}

View File

@@ -2,6 +2,7 @@ package handlers
import (
"archive/zip"
"encoding/base64"
"fmt"
"io"
"math/rand"
@@ -992,9 +993,128 @@ func examViewMimeType(fileName string) string {
}
func sendExamFileInline(c *fiber.Ctx, filePath, fileName string) error {
data, err := os.ReadFile(filePath)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không đọc được file"})
}
c.Set("Content-Type", examViewMimeType(fileName))
c.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", fileName))
return c.SendFile(filePath)
return c.Send(data)
}
func encodeExamFileViewPayload(filePath, fileName, title string) (fiber.Map, error) {
data, err := os.ReadFile(filePath)
if err != nil {
return nil, err
}
if fileName == "" {
fileName = filepath.Base(filePath)
}
return fiber.Map{
"title": title,
"fileName": fileName,
"mime": examViewMimeType(fileName),
"data": base64.StdEncoding.EncodeToString(data),
}, nil
}
// GET /api/exam-rooms/:id/papers/:paperId/view — JSON base64, dùng cho xem trên web (không tải file)
func ViewStaffExamPaperHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
paperID, err := parseUintParam(c, "paperId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"})
}
var paper models.ExamPaper
if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"})
}
payload, err := encodeExamFileViewPayload(paper.PdfPath, "de-thi.pdf", paper.Title)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File PDF đề không tồn tại trên server"})
}
return c.JSON(payload)
}
}
// GET /api/exam-rooms/:id/papers/:paperId/resources/:resId/view
func ViewStaffExamPaperResourceHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
paperID, err := parseUintParam(c, "paperId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"})
}
resID, err := parseUintParam(c, "resId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid resId"})
}
var paper models.ExamPaper
if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"})
}
var res models.ExamPaperResource
if err := db.Where("id = ? AND exam_paper_id = ?", resID, paper.ID).First(&res).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại"})
}
payload, err := encodeExamFileViewPayload(res.FilePath, res.FileName, res.FileName)
if err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại trên server"})
}
return c.JSON(payload)
}
}
// GET /api/exam-rooms/:id/papers/:paperId/download?kind=pdf|resource&fileId=&view=1
func DownloadStaffExamPaperFileHandler(db *gorm.DB) fiber.Handler {
return func(c *fiber.Ctx) error {
roomID, err := parseUintParam(c, "id")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid id"})
}
paperID, err := parseUintParam(c, "paperId")
if err != nil {
return c.Status(400).JSON(fiber.Map{"error": "Invalid paperId"})
}
var paper models.ExamPaper
if err := db.Where("id = ? AND exam_room_id = ?", paperID, roomID).First(&paper).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "Không tìm thấy gói đề"})
}
inline := c.Query("view") == "1" || c.Query("inline") == "1"
kind := c.Query("kind", "pdf")
if kind == "pdf" {
if _, statErr := os.Stat(paper.PdfPath); statErr != nil {
return c.Status(404).JSON(fiber.Map{"error": "File PDF đề không tồn tại trên server"})
}
name := filepath.Base(paper.PdfPath)
if name == "" || name == "." {
name = "de-thi.pdf"
}
if inline {
return sendExamFileInline(c, paper.PdfPath, name)
}
return c.Download(paper.PdfPath, name)
}
fileID, _ := strconv.ParseUint(c.Query("fileId", "0"), 10, 64)
var res models.ExamPaperResource
if err := db.Where("id = ? AND exam_paper_id = ?", fileID, paper.ID).First(&res).Error; err != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại"})
}
if _, statErr := os.Stat(res.FilePath); statErr != nil {
return c.Status(404).JSON(fiber.Map{"error": "File tài nguyên không tồn tại trên server"})
}
if inline && examFileCanViewInline(res.FileName) {
return sendExamFileInline(c, res.FilePath, res.FileName)
}
return c.Download(res.FilePath, res.FileName)
}
}
// POST /api/student/exam/submit — multipart zip

View File

@@ -179,6 +179,9 @@ func main() {
staff.Post("/exam-rooms/:id/papers", handlers.UploadExamPaperHandler(gormDB))
staff.Post("/exam-rooms/:id/papers/:paperId/resources", handlers.UploadExamPaperResourceHandler(gormDB))
staff.Delete("/exam-rooms/:id/papers/:paperId", handlers.DeleteExamPaperHandler(gormDB))
staff.Get("/exam-rooms/:id/papers/:paperId/view", handlers.ViewStaffExamPaperHandler(gormDB))
staff.Get("/exam-rooms/:id/papers/:paperId/resources/:resId/view", handlers.ViewStaffExamPaperResourceHandler(gormDB))
staff.Get("/exam-rooms/:id/papers/:paperId/download", handlers.DownloadStaffExamPaperFileHandler(gormDB))
staff.Post("/exam-rooms/:id/publish", handlers.PublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))