<div id="studentWorkPage">
<!-- =========================================================
ADMIN CHẤM BÀI / STUDENT WORK v18.5.1F
CORE:
student-work.js @ v18.5.1
KIẾN TRÚC:
---------------------------------------------------------
- Chấm điểm / nhận xét
- Xóa bài
- Quà GVCN
- Linh thú
- Phân trang
- Ưu tiên bài
- Đồng bộ
- ADMIN
- Reward Core Canonical Assets
- Tài sản / Linh Thạch dùng StudentRewardSystem chung
MODULE MÔ TẢ:
---------------------------------------------------------
1. Lấy Mô tả bài tập trực tiếp từ:
NopBaiLuyenTap
Form Responses 1
gid = 1837470623
2. Cấu trúc:
A = Dấu thời gian
B = Họ và tên
C = Mã học viên
D = Tổ
E = Khóa
F = Tải bài tập lên
G = Mô tả bài tập
3. Ghép mô tả với bài theo:
- Google Drive File ID
- URL bài
- Mã học viên + thời gian dự phòng
4. Hiển thị mô tả ngay dưới tên học viên
5. Tìm kiếm theo:
- Họ và tên
- Mã học viên
- Mô tả bài tập
6. Icon bút:
String.fromCodePoint(0x270E)
========================================================= -->
<!-- =========================================================
CSS v18.5.1
========================================================= -->
<link
rel="stylesheet"
href="https://cdn.jsdelivr.net/gh/vnthudao-source/ocd4-assets@v18.5.1/student-work.css"
/>
<!-- =========================================================
CSS MODULE MÔ TẢ
========================================================= -->
<style>
/* =========================================================
MÔ TẢ BÀI TẬP
========================================================= */
.tp18-work-description{
display:flex;
align-items:flex-start;
gap:6px;
width:fit-content;
max-width:100%;
box-sizing:border-box;
margin:5px 0 9px;
padding:5px 9px;
border:1px solid #eadfd3;
border-radius:8px;
background:#f8f3ed;
color:#765344;
font-size:12px;
font-weight:600;
line-height:1.45;
overflow-wrap:anywhere;
}
/* =========================================================
ICON
========================================================= */
.tp18-work-description-icon{
flex:0 0 auto;
display:inline-flex;
align-items:center;
justify-content:center;
min-width:14px;
color:#a16e45;
font-size:13px;
line-height:1.35;
}
/* =========================================================
TEXT
========================================================= */
.tp18-work-description-text{
min-width:0;
color:#765344;
white-space:normal;
overflow-wrap:anywhere;
}
/* =========================================================
MOBILE
========================================================= */
@media (max-width:600px){
.tp18-work-description{
gap:4px;
margin-top:4px;
margin-bottom:7px;
padding:4px 7px;
border-radius:7px;
font-size:11px;
line-height:1.4;
}
.tp18-work-description-icon{
min-width:12px;
font-size:12px;
}
}
</style>
<!-- =========================================================
GIAO DIỆN CHÍNH
========================================================= -->
<div
class="tp18-wrap locked"
id="tp18-wrap"
>
<!-- =====================================================
HEADER
====================================================== -->
<div class="tp18-header">
<h2 id="tp18-title">
Tác phẩm học viên
</h2>
<p id="tp18-subtitle">
Tác phẩm · Điểm số · Nhận xét · Linh Thú
</p>
</div>
<!-- =====================================================
ADMIN NOTE
====================================================== -->
<div
class="tp18-admin-note"
id="tp18-admin-note"
></div>
<!-- =====================================================
STATUS
====================================================== -->
<div
class="tp18-status"
id="tp18-status"
></div>
<!-- =====================================================
TOAST
====================================================== -->
<div
class="tp18-toast"
id="tp18-toast"
></div>
<!-- =====================================================
NỘP BÀI
====================================================== -->
<div
class="tp18-submit"
id="tp18-submit"
>
<a
href="https://docs.google.com/forms/d/e/1FAIpQLScfsWRNbv1h3XdJq1K_llPnkmmWOll480qRB5e8aWgvJj5eUw/viewform"
target="_blank"
rel="noopener noreferrer"
>
📝 Nộp bài tập
</a>
</div>
<!-- =====================================================
TÌM KIẾM
====================================================== -->
<div
class="tp18-admin-search"
id="tp18-admin-search"
>
<div class="tp18-search-box">
<div
class="tp18-search-icon"
id="tp18-search-icon"
></div>
<input
autocomplete="off"
class="tp18-search-input"
id="tp18-search-input"
placeholder="Tìm theo tên, mã học viên hoặc mô tả bài tập..."
spellcheck="false"
type="search"
/>
<button
class="tp18-search-clear"
id="tp18-search-clear"
type="button"
>
Xóa
</button>
</div>
<div
class="tp18-search-info"
id="tp18-search-info"
></div>
</div>
<!-- =====================================================
STUDENT BAR
====================================================== -->
<div
class="tp18-student-bar"
id="tp18-student-bar"
>
<strong
id="tp18-student-title"
></strong>
<button
class="tp18-grade"
id="tp18-back"
type="button"
>
Quay lại
</button>
</div>
<!-- =====================================================
PHÂN TRANG TRÊN
====================================================== -->
<div
class="tp18-pages"
id="tp18-pages-top"
></div>
<!-- =====================================================
LOADING
====================================================== -->
<div
class="tp18-loading"
id="tp18-loading"
>
Đang tải dữ liệu...
</div>
<!-- =====================================================
ERROR
====================================================== -->
<div
class="tp18-error"
id="tp18-error"
></div>
<!-- =====================================================
EMPTY
====================================================== -->
<div
class="tp18-empty"
id="tp18-empty"
>
Chưa có tác phẩm.
</div>
<!-- =====================================================
GRID
====================================================== -->
<div
class="tp18-grid"
id="tp18-grid"
></div>
<!-- =====================================================
PHÂN TRANG DƯỚI
====================================================== -->
<div
class="tp18-pages"
id="tp18-pages-bottom"
></div>
</div>
<!-- =========================================================
IFRAME FORM
========================================================= -->
<iframe
id="tp18-gift-frame"
name="tp18-gift-frame"
style="display:none;width:0;height:0;border:0;"
></iframe>
<!-- =========================================================
STUDENT WORK v18.5.1F LOADER
========================================================= -->
<script>
(function(){
"use strict";
/* =========================================================
CHỐNG KHỞI ĐỘNG LẶP
========================================================= */
if(window.TP18_V1851F_STARTED){
return;
}
window.TP18_V1851F_STARTED = true;
/* =========================================================
CONFIG
========================================================= */
const NOP_BAI_ID =
"1GJoTRsbq0kZfZrDdh0uCC667PwS3Bgkje2fHQnwnCKs";
/*
FORM RESPONSES 1
Tab chứa:
- Dấu thời gian
- Họ và tên
- Mã học viên
- Tổ
- Khóa
- Tải bài tập lên
- Mô tả bài tập
*/
const FORM_RESPONSES_GID =
"1837470623";
const DESCRIPTION_URL =
"https://docs.google.com/spreadsheets/d/"
+
NOP_BAI_ID
+
"/export?format=csv&gid="
+
FORM_RESPONSES_GID;
/* =========================================================
CORE v18.5.1
Đây là bản Core mới:
- Reward Core Canonical Assets
- Không tự replay tài sản tại trang Admin
- Dùng StudentRewardSystem chung
========================================================= */
const CORE_URL =
"https://cdn.jsdelivr.net/gh/"
+
"vnthudao-source/ocd4-assets"
+
"@a6ad656fe020956371cfc96609e6708e2736e949/"
+
"student-work.js";
/* =========================================================
STATE
========================================================= */
const descriptionIndex = {
byDriveId:
new Map(),
byUrl:
new Map(),
byStudentTime:
new Map()
};
/* =========================================================
CLEAN
========================================================= */
function clean(value){
return String(
value == null
?
""
:
value
)
.replace(
/^\uFEFF/,
""
)
.trim();
}
/* =========================================================
NORMALIZE
========================================================= */
function normalizeLocal(value){
return clean(value)
.toLowerCase()
.normalize("NFD")
.replace(
/[\u0300-\u036f]/g,
""
)
.replace(
/đ/g,
"d"
)
.replace(
/\s+/g,
" "
)
.trim();
}
/* =========================================================
CSV PARSER
========================================================= */
function parseCSV(text){
const rows = [];
let row = [];
let value = "";
let quoted = false;
text =
String(text || "")
.replace(
/^\uFEFF/,
""
);
for(
let i = 0;
i < text.length;
i++
){
const char =
text[i];
const next =
text[i + 1];
/* =================================================
DOUBLE QUOTE TRONG CELL
================================================= */
if(
char === '"' &&
quoted &&
next === '"'
){
value += '"';
i++;
continue;
}
/* =================================================
MỞ / ĐÓNG QUOTE
================================================= */
if(
char === '"'
){
quoted =
!quoted;
continue;
}
/* =================================================
NGĂN CỘT
================================================= */
if(
char === "," &&
!quoted
){
row.push(
value
);
value = "";
continue;
}
/* =================================================
NGĂN DÒNG
================================================= */
if(
(
char === "\n" ||
char === "\r"
)
&&
!quoted
){
if(
char === "\r" &&
next === "\n"
){
i++;
}
row.push(
value
);
value = "";
if(
row.some(
function(cell){
return(
clean(cell) !==
""
);
}
)
){
rows.push(
row
);
}
row = [];
continue;
}
value += char;
}
/* =====================================================
DÒNG CUỐI
====================================================== */
if(
value ||
row.length
){
row.push(
value
);
if(
row.some(
function(cell){
return(
clean(cell) !==
""
);
}
)
){
rows.push(
row
);
}
}
return rows;
}
/* =========================================================
URL ĐẦU TIÊN TRONG CELL
========================================================= */
function firstUrl(value){
const text =
clean(value);
if(!text){
return "";
}
const match =
text.match(
/https?:\/\/[^\s,]+/i
);
return(
match
?
match[0]
:
text
);
}
/* =========================================================
GOOGLE DRIVE FILE ID
========================================================= */
function getDriveId(value){
const url =
firstUrl(
value
);
if(!url){
return "";
}
const patterns = [
/\/file\/d\/([^/?&#]+)/i,
/\/d\/([^/?&#]+)/i,
/[?&]id=([^&#]+)/i
];
for(
let i = 0;
i < patterns.length;
i++
){
const match =
url.match(
patterns[i]
);
if(
match &&
match[1]
){
return clean(
match[1]
);
}
}
return "";
}
/* =========================================================
NORMALIZE URL
========================================================= */
function normalizeUrl(value){
return firstUrl(
value
)
.replace(
/&/gi,
"&"
)
.replace(
/\/+$/,
""
)
.trim();
}
/* =========================================================
NORMALIZE TIME
========================================================= */
function normalizeTime(value){
return clean(value)
.replace(
/\s+/g,
" "
);
}
/* =========================================================
STUDENT + TIME KEY
========================================================= */
function studentTimeKey(
code,
time
){
return(
normalizeLocal(code)
+
"||"
+
normalizeTime(time)
);
}
/* =========================================================
TÌM CỘT HEADER
========================================================= */
function findHeaderIndex(
header,
keyword,
fallback
){
const normalizedKeyword =
normalizeLocal(
keyword
);
for(
let i = 0;
i < header.length;
i++
){
if(
normalizeLocal(
header[i]
)
.includes(
normalizedKeyword
)
){
return i;
}
}
return fallback;
}
/* =========================================================
BUILD DESCRIPTION INDEX
========================================================= */
function buildDescriptionIndex(csvText){
const rows =
parseCSV(
csvText
);
if(
!rows ||
rows.length < 2
){
throw new Error(
"Form Responses 1 không có dữ liệu."
);
}
const header =
rows[0];
/*
A = 0 : Dấu thời gian
C = 2 : Mã học viên
F = 5 : Tải bài tập lên
G = 6 : Mô tả bài tập
Tìm theo header trước,
fallback theo vị trí cột.
*/
const timeColumn =
findHeaderIndex(
header,
"Dấu thời gian",
0
);
const codeColumn =
findHeaderIndex(
header,
"Mã học viên",
2
);
const workColumn =
findHeaderIndex(
header,
"Tải bài tập lên",
5
);
const descriptionColumn =
findHeaderIndex(
header,
"Mô tả bài tập",
6
);
if(
descriptionColumn < 0
){
throw new Error(
"Không tìm thấy cột Mô tả bài tập."
);
}
descriptionIndex
.byDriveId
.clear();
descriptionIndex
.byUrl
.clear();
descriptionIndex
.byStudentTime
.clear();
/* =====================================================
DUYỆT TỪ DÒNG 2
====================================================== */
for(
let r = 1;
r < rows.length;
r++
){
const row =
rows[r];
const submittedAt =
clean(
row[timeColumn]
);
const studentCode =
clean(
row[codeColumn]
);
const workUrl =
clean(
row[workColumn]
);
const description =
clean(
row[descriptionColumn]
);
/*
Không có mô tả
-> không tạo label.
*/
if(!description){
continue;
}
/* =================================================
1. DRIVE FILE ID
================================================= */
const driveId =
getDriveId(
workUrl
);
if(driveId){
descriptionIndex
.byDriveId
.set(
driveId,
description
);
}
/* =================================================
2. URL
================================================= */
const normalizedWorkUrl =
normalizeUrl(
workUrl
);
if(normalizedWorkUrl){
descriptionIndex
.byUrl
.set(
normalizedWorkUrl,
description
);
}
/* =================================================
3. STUDENT + TIME
================================================= */
if(
studentCode &&
submittedAt
){
descriptionIndex
.byStudentTime
.set(
studentTimeKey(
studentCode,
submittedAt
),
description
);
}
}
}
/* =========================================================
LẤY MÔ TẢ CỦA MỘT BÀI
========================================================= */
function getDescription(
workUrl,
studentCode,
submittedAt
){
/* =====================================================
ƯU TIÊN 1:
DRIVE FILE ID
====================================================== */
const driveId =
getDriveId(
workUrl
);
if(
driveId &&
descriptionIndex
.byDriveId
.has(
driveId
)
){
return(
descriptionIndex
.byDriveId
.get(
driveId
)
||
""
);
}
/* =====================================================
ƯU TIÊN 2:
URL
====================================================== */
const normalizedWork =
normalizeUrl(
workUrl
);
if(
normalizedWork &&
descriptionIndex
.byUrl
.has(
normalizedWork
)
){
return(
descriptionIndex
.byUrl
.get(
normalizedWork
)
||
""
);
}
/* =====================================================
ƯU TIÊN 3:
MÃ HỌC VIÊN + THỜI GIAN
====================================================== */
if(
studentCode &&
submittedAt
){
const key =
studentTimeKey(
studentCode,
submittedAt
);
if(
descriptionIndex
.byStudentTime
.has(
key
)
){
return(
descriptionIndex
.byStudentTime
.get(
key
)
||
""
);
}
}
return "";
}
/* =========================================================
BRIDGE MÔ TẢ CHO STUDENT-WORK CORE
========================================================= */
window.TP18_WORK_DESCRIPTION = {
get:
function(
workUrl,
studentCode,
submittedAt
){
return getDescription(
workUrl,
studentCode,
submittedAt
);
},
count:
function(){
return(
descriptionIndex
.byDriveId
.size
);
}
};
/* =========================================================
FETCH
========================================================= */
async function fetchText(
url,
noCache
){
let finalUrl =
url;
if(noCache){
finalUrl +=
(
url.includes("?")
?
"&"
:
"?"
)
+
"_="
+
Date.now();
}
const response =
await fetch(
finalUrl,
{
cache:
noCache
?
"no-store"
:
"default"
}
);
if(!response.ok){
throw new Error(
"HTTP "
+
response.status
+
": "
+
url
);
}
return await response.text();
}
/* =========================================================
REPLACE BẮT BUỘC
========================================================= */
function replaceRequired(
source,
regex,
replacement,
name
){
regex.lastIndex =
0;
if(
!regex.test(
source
)
){
throw new Error(
"Không tìm thấy vị trí nâng cấp: "
+
name
);
}
regex.lastIndex =
0;
return source.replace(
regex,
replacement
);
}
/* =========================================================
PATCH CORE v18.5.1
THÊM MODULE MÔ TẢ BÀI TẬP
LƯU Ý:
Không patch Reward Core.
Không patch Linh Thạch.
Không patch tài sản.
Không patch quyền sở hữu Linh Thú.
Những phần đó do student-work.js v18.5.1
+ StudentRewardSystem xử lý.
========================================================= */
function patchCore(source){
let code =
String(
source || ""
);
/* =====================================================
PATCH 1
THÊM DESCRIPTION VÀO ITEM
====================================================== */
code =
replaceRequired(
code,
/item\.__workUrl\s*=\s*originalUrl\(\s*item\.__rawWork\s*\);/,
[
"item.__workUrl =",
"originalUrl(",
" item.__rawWork",
");",
"",
"item.__exerciseDescription =",
"(",
" window.TP18_WORK_DESCRIPTION",
" &&",
" typeof window.TP18_WORK_DESCRIPTION.get === \"function\"",
")",
"?",
"window.TP18_WORK_DESCRIPTION.get(",
" item.__rawWork,",
" item.__code,",
" item.__timestampText",
")",
":",
"\"\";",
"",
"item.__exerciseDescriptionNorm =",
"normalize(",
" item.__exerciseDescription",
");"
]
.join(
"\n"
),
"Mô tả bài tập"
);
/* =====================================================
PATCH 2
SEARCH:
TÊN + MÃ + MÔ TẢ
====================================================== */
code =
replaceRequired(
code,
/item\.__nameNorm\s*\.includes\(\s*query\s*\)\s*\|\|\s*item\.__codeNorm\s*\.includes\(\s*query\s*\)/,
[
"item.__nameNorm",
".includes(query)",
"",
"||",
"",
"item.__codeNorm",
".includes(query)",
"",
"||",
"",
"(",
" item.__exerciseDescriptionNorm",
" ||",
" \"\"",
")",
".includes(query)"
]
.join(
"\n"
),
"Bộ lọc mô tả"
);
/* =====================================================
PATCH 3
HIỂN THỊ DESCRIPTION SAU TÊN
Không dùng HTML entity cho icon.
====================================================== */
code =
replaceRequired(
code,
/(<div class="tp18-name">\s*\$\{escapeHTML\(\s*name\s*\|\|\s*"Học viên"\s*\)\}\s*<\/div>)(\s*<div class="tp18-meta">)/,
function(
full,
nameBlock,
metaBlock
){
return(
nameBlock
+
"\n\n"
+
" ${\n"
+
" item.__exerciseDescription\n"
+
" ?\n"
+
" `\n"
+
" <div class=\"tp18-work-description\">\n"
+
" <span class=\"tp18-work-description-icon\">${escapeHTML(String.fromCodePoint(0x270E))}</span>\n"
+
" <span class=\"tp18-work-description-text\">${escapeHTML(item.__exerciseDescription)}</span>\n"
+
" </div>\n"
+
" `\n"
+
" :\n"
+
" \"\"\n"
+
" }\n\n"
+
metaBlock
);
},
"Hiển thị mô tả"
);
/* =====================================================
PATCH 4
SUBTITLE KẾT QUẢ SEARCH
====================================================== */
code =
code.replace(
/Kết quả theo tên hoặc mã học viên/g,
"Kết quả theo tên, mã học viên hoặc mô tả bài tập"
);
return code;
}
/* =========================================================
CHẠY CORE
========================================================= */
function executeCore(
source
){
const script =
document.createElement(
"script"
);
script.type =
"text/javascript";
script.text =
source
+
"\n//# sourceURL=student-work-v18.5.1F.js";
document.body.appendChild(
script
);
}
/* =========================================================
FALLBACK
Nếu module Mô tả bài tập gặp lỗi,
vẫn tải trực tiếp student-work.js v18.5.1.
QUAN TRỌNG:
Fallback KHÔNG quay về v18.4D.
Như vậy trang không bao giờ quay lại
công thức tài sản cũ.
========================================================= */
async function loadOriginalCoreOnly(){
const core =
await fetchText(
CORE_URL,
false
);
executeCore(
core
);
}
/* =========================================================
START
========================================================= */
async function start(){
try{
/*
TẢI SONG SONG:
1. CSV mô tả
2. student-work.js v18.5.1
*/
const results =
await Promise.all([
fetchText(
DESCRIPTION_URL,
true
),
fetchText(
CORE_URL,
false
)
]);
const descriptionCSV =
results[0];
const originalCore =
results[1];
/* =================================================
INDEX MÔ TẢ
================================================= */
buildDescriptionIndex(
descriptionCSV
);
/* =================================================
PATCH MODULE MÔ TẢ
================================================= */
const upgradedCore =
patchCore(
originalCore
);
/* =================================================
EXECUTE
================================================= */
executeCore(
upgradedCore
);
console.log(
"STUDENT WORK v18.5.1F đã khởi động."
);
console.log(
"Core:",
"student-work.js @ v18.5.1"
);
console.log(
"Số bài có mô tả:",
window.TP18_WORK_DESCRIPTION.count()
);
}catch(error){
console.error(
"STUDENT WORK v18.5.1F:",
error
);
/*
Module mô tả lỗi:
vẫn giữ trang Admin bằng Core v18.5.1.
Tuyệt đối không fallback v18.4D.
*/
try{
await loadOriginalCoreOnly();
console.warn(
"Module mô tả gặp lỗi. Đã chạy trực tiếp Core v18.5.1."
);
}catch(fallbackError){
console.error(
"Không thể tải Core v18.5.1:",
fallbackError
);
const loading =
document.getElementById(
"tp18-loading"
);
const errorBox =
document.getElementById(
"tp18-error"
);
if(loading){
loading.style.display =
"none";
}
if(errorBox){
errorBox.style.display =
"block";
errorBox.textContent =
"Không thể tải trang chấm bài.";
}
}
}
}
/* =========================================================
DOM READY
========================================================= */
if(
document.readyState ===
"loading"
){
document.addEventListener(
"DOMContentLoaded",
start,
{
once:true
}
);
}else{
start();
}
})();
</script>
</div>
Nhận xét
Đăng nhận xét