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", "name": "management",
"version": "0.0.0", "version": "0.0.0",
"dependencies": { "dependencies": {
"pdfjs-dist": "^3.11.174",
"react": "^19.2.7", "react": "^19.2.7",
"react-dom": "^19.2.7" "react-dom": "^19.2.7"
}, },
@@ -349,6 +350,40 @@
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@napi-rs/wasm-runtime": {
"version": "1.1.6", "version": "1.1.6",
"resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", "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": { "node_modules/babel-plugin-react-compiler": {
"version": "1.0.0", "version": "1.0.0",
"resolved": "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz", "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" "@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": { "node_modules/baseline-browser-mapping": {
"version": "2.10.40", "version": "2.10.40",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.40.tgz",
@@ -1173,6 +1267,17 @@
"node": ">=6.0.0" "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": { "node_modules/browserslist": {
"version": "4.28.4", "version": "4.28.4",
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.4.tgz",
@@ -1228,6 +1333,56 @@
], ],
"license": "CC-BY-4.0" "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": { "node_modules/convert-source-map": {
"version": "2.0.0", "version": "2.0.0",
"resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz",
@@ -1246,7 +1401,7 @@
"version": "4.4.3", "version": "4.4.3",
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"dev": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"ms": "^2.1.3" "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": { "node_modules/detect-libc": {
"version": "2.1.2", "version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"dev": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">=8" "node": ">=8"
@@ -1277,6 +1452,13 @@
"dev": true, "dev": true,
"license": "ISC" "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": { "node_modules/escalade": {
"version": "3.2.0", "version": "3.2.0",
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", "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": { "node_modules/fsevents": {
"version": "2.3.3", "version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", "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": "^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": { "node_modules/gensync": {
"version": "1.0.0-beta.2", "version": "1.0.0-beta.2",
"resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz",
@@ -1330,6 +1574,78 @@
"node": ">=6.9.0" "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": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -1646,13 +1962,119 @@
"yallist": "^3.0.2" "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": { "node_modules/ms": {
"version": "2.1.3", "version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true, "devOptional": true,
"license": "MIT" "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": { "node_modules/nanoid": {
"version": "3.3.15", "version": "3.3.15",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", "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": "^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": { "node_modules/node-releases": {
"version": "2.0.50", "version": "2.0.50",
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.50.tgz",
@@ -1682,6 +2125,56 @@
"node": ">=18" "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": { "node_modules/oxlint": {
"version": "1.71.0", "version": "1.71.0",
"resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", "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": { "node_modules/picocolors": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
@@ -1801,6 +2327,38 @@
"react": "^19.2.7" "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": { "node_modules/rolldown": {
"version": "1.1.3", "version": "1.1.3",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz",
@@ -1835,6 +2393,27 @@
"@rolldown/binding-win32-x64-msvc": "1.1.3" "@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": { "node_modules/scheduler": {
"version": "0.27.0", "version": "0.27.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz",
@@ -1845,12 +2424,59 @@
"version": "6.3.1", "version": "6.3.1",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz",
"integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==",
"dev": true, "devOptional": true,
"license": "ISC", "license": "ISC",
"bin": { "bin": {
"semver": "bin/semver.js" "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": { "node_modules/source-map-js": {
"version": "1.2.1", "version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
@@ -1861,6 +2487,70 @@
"node": ">=0.10.0" "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": { "node_modules/tinyglobby": {
"version": "0.2.17", "version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -1878,6 +2568,13 @@
"url": "https://github.com/sponsors/SuperchupuDev" "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": { "node_modules/tslib": {
"version": "2.8.1", "version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
@@ -1938,6 +2635,13 @@
"browserslist": ">= 4.21.0" "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": { "node_modules/vite": {
"version": "8.1.0", "version": "8.1.0",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", "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": { "node_modules/yallist": {
"version": "3.1.1", "version": "3.1.1",
"resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz",

View File

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

View File

@@ -694,6 +694,13 @@ export interface ExamSubmission {
gitPublishUrl?: string; 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 = { export const apiExam = {
list: async (): Promise<{ data: ExamRoomItem[] }> => { list: async (): Promise<{ data: ExamRoomItem[] }> => {
const res = await staffFetch('/exam-rooms'); 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'); if (!res.ok) await parseError(res, 'Xóa gói đề thất bại');
return res.json(); 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) => { assignRandom: async (id: number) => {
const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' }); const res = await staffFetch(`/exam-rooms/${id}/assign-random`, { method: 'POST' });
if (!res.ok) await parseError(res, 'Chia gói đề thất bại'); 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 { import {
apiExam, apiExam,
staffFetch, staffFetch,
@@ -12,6 +12,7 @@ import { navigate, pushNav } from '../navigation';
import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants'; import { BASE_APP_SUGGESTIONS, DEFAULT_EXAM_ALLOWED_APPS } from '../constants';
import { openStaffChat } from '../chatEvents'; import { openStaffChat } from '../chatEvents';
import { AppPoolModal } from './AppPoolModal'; import { AppPoolModal } from './AppPoolModal';
import { ExamPaperPdfModal } from './ExamPaperPdfModal';
import { NavHistoryBar } from './NavHistoryBar'; import { NavHistoryBar } from './NavHistoryBar';
import { StudentAvatar } from './StudentAvatar'; import { StudentAvatar } from './StudentAvatar';
import { StudentDetailModal } from './StudentDetailModal'; 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 [activeSubTab, setActiveSubTab] = useState<'roster' | 'detail' | 'submissions'>('roster');
const [configOpen, setConfigOpen] = useState(false); const [configOpen, setConfigOpen] = useState(false);
const [configTab, setConfigTab] = useState<'info' | 'apps' | 'papers'>('info'); 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 () => { const load = useCallback(async () => {
setLoading(true); setLoading(true);
@@ -117,6 +122,25 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
useEffect(() => { load().catch(console.error); }, [load]); 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 () => { const fetchOnline = useCallback(async () => {
try { try {
const res = await apiExam.fetchOnlineStudents(examId); const res = await apiExam.fetchOnlineStudents(examId);
@@ -237,6 +261,64 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
URL.revokeObjectURL(url); 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 () => { const downloadAllSubs = async () => {
setErr(''); setMsg(''); setErr(''); setMsg('');
setBundlingSubs(true); setBundlingSubs(true);
@@ -417,6 +499,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
{canCancel && ( {canCancel && (
<button type="button" className="btn btn-secondary btn-sm learning-btn-danger" onClick={handleCancel}>Hủy</button> <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>
</div> </div>
@@ -610,9 +697,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.65rem' }}>
{papers.map((p) => ( {papers.map((p) => (
<div key={p.id} style={{ border: '1px solid var(--border-light)', borderRadius: '8px', padding: '0.75rem', background: 'var(--bg-subtle)' }}> <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> <strong>{p.title}</strong>
<span className="badge badge-muted">Gói đ</span> <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> </div>
<ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}> <ul className="config-card-desc" style={{ margin: '0.5rem 0 0', paddingLeft: '1.1rem' }}>
<li>📄 main.pdf (đ chính)</li> <li>📄 main.pdf (đ chính)</li>
@@ -620,7 +710,12 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<li style={{ color: 'var(--text-muted)' }}>Chưa tài nguyên kèm</li> <li style={{ color: 'var(--text-muted)' }}>Chưa tài nguyên kèm</li>
) : ( ) : (
(p.resources || []).map((r) => ( (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> </ul>
@@ -718,6 +813,11 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
+ Thêm sinh viên + Thêm sinh viên
</button> </button>
)} )}
{papers.length > 0 && (
<button type="button" className="btn btn-secondary btn-sm" onClick={() => setPapersModalOpen(true)}>
📄 Xem đ ({papers.length})
</button>
)}
</div> </div>
</div> </div>
<div className="divider" style={{ opacity: 0.3, margin: '0.25rem 0' }} /> <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 }}> <td style={{ color: isOnline ? 'var(--success)' : 'var(--text-muted)', fontWeight: 700 }}>
{isOnline ? 'Online' : 'Offline'} {isOnline ? 'Online' : 'Offline'}
</td> </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.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>{s.submitted ? <span className="badge badge-success">Đã nộp</span> : '—'}</td>
<td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}> <td style={{ textAlign: 'right', whiteSpace: 'nowrap' }}>
@@ -853,6 +965,15 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
<div className="session-logs-panel"> <div className="session-logs-panel">
{submissions.length > 0 && ( {submissions.length > 0 && (
<div className="exam-submissions-toolbar"> <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 <button
type="button" type="button"
className="btn btn-primary btn-sm" className="btn btn-primary btn-sm"
@@ -939,6 +1060,81 @@ export const ExamRoomWorkspace: React.FC<Props> = ({ examId, onBack }) => {
</div> </div>
</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 && ( {selectedStudent && (
<StudentDetailModal <StudentDetailModal
student={selectedStudent} student={selectedStudent}

View File

@@ -4122,6 +4122,131 @@ input:checked + .slider:before {
cursor: pointer; 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 { .exam-send-row {
display: flex; display: flex;
flex-wrap: wrap; 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 ( import (
"archive/zip" "archive/zip"
"encoding/base64"
"fmt" "fmt"
"io" "io"
"math/rand" "math/rand"
@@ -992,9 +993,128 @@ func examViewMimeType(fileName string) string {
} }
func sendExamFileInline(c *fiber.Ctx, filePath, fileName string) error { 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-Type", examViewMimeType(fileName))
c.Set("Content-Disposition", fmt.Sprintf("inline; filename=%q", 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 // 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", handlers.UploadExamPaperHandler(gormDB))
staff.Post("/exam-rooms/:id/papers/:paperId/resources", handlers.UploadExamPaperResourceHandler(gormDB)) staff.Post("/exam-rooms/:id/papers/:paperId/resources", handlers.UploadExamPaperResourceHandler(gormDB))
staff.Delete("/exam-rooms/:id/papers/:paperId", handlers.DeleteExamPaperHandler(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/publish", handlers.PublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/unpublish", handlers.UnpublishExamRoomHandler(gormDB))
staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB)) staff.Post("/exam-rooms/:id/cancel", handlers.CancelExamRoomHandler(gormDB))