強化 NYCU E3 全站介面與操作體驗。
// ==UserScript==
// @name NYCU EM3 - E3 UI Plus
// @name:en NYCU EM3 - E3 Interface Optimization
// @name:zh-CN NYCU EM3 - E3 介面最佳化
// @name:zh-TW NYCU EM3 - E3 介面最佳化
// @name:zh NYCU EM3 - E3 介面最佳化
// @namespace http://tampermonkey.net/
// @version 1.2.6
// @description 強化 NYCU E3 全站介面與操作體驗。
// @description:en Improve NYCU E3 full-site UI/UX.
// @description:zh-CN 强化 NYCU E3 全站介面与操作体验。
// @description:zh-TW 強化 NYCU E3 全站介面與操作體驗。
// @description:zh 強化 NYCU E3 全站介面與操作體驗。
// @author Elvis Mao
// @match https://e3p.nycu.edu.tw/*
// @icon https://emtech.cc/static/icons/apple-touch-icon.png
// @license Apache-2.0
// @homepageURL https://github.com/Edit-Mr/SSS/tree/main
// @supportURL https://github.com/Edit-Mr/SSS/issues
// @run-at document-start
// @grant GM_addStyle
// ==/UserScript==
(() => {
"use strict";
// 嘗試載入 CSS。如果失敗的話代表可能是使用 dev.js 在跑,交給他就好。
try {
GM_addStyle("@import url('https://g.elvismao.com/nycu-em3/index.css');@import url('https://g.elvismao.com/nycu-em3/home.css');");
} catch (err) {
console.warn("[TM] Failed to load external CSS, maybe you're in dev mode.");
}
const path = location.pathname.replace(/\/+$/, "") || "/";
const isDashboard = location.origin === "https://e3p.nycu.edu.tw" && (path === "/my" || path === "/my/index.php");
const isEnglish = new URLSearchParams(location.search).get("lang") === "en" || (document.documentElement.lang || "").startsWith("en");
function boot() {
try {
console.log(`[TM] NYCU E3 UI Plus loaded. Dashboard: ${isDashboard}, English: ${isEnglish}`);
initTheme();
patchNavbar();
if (isDashboard) initDashboard();
} catch (err) {
console.error("[TM] Error in main execution:", err);
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", boot, { once: true });
} else {
boot();
}
// --- tool functions ---
function initTheme() {
const savedTheme = localStorage.getItem("em-theme");
if (savedTheme === "dark") {
document.body.classList.add("darkmode");
} else if (savedTheme === "light") {
document.body.classList.remove("darkmode");
} else {
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
if (prefersDark) {
document.body.classList.add("darkmode");
}
}
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
if (!localStorage.getItem("em-theme")) {
if (e.matches) {
document.body.classList.add("darkmode");
} else {
document.body.classList.remove("darkmode");
}
}
});
}
function toggleTheme() {
const isDark = document.body.classList.contains("darkmode");
if (isDark) {
document.body.classList.remove("darkmode");
localStorage.setItem("em-theme", "light");
} else {
document.body.classList.add("darkmode");
localStorage.setItem("em-theme", "dark");
}
updateThemeToggleIcon(!isDark);
}
function updateThemeToggleIcon(isDark) {
const toggleBtn = document.querySelector("#theme-toggle");
if (toggleBtn) {
toggleBtn.innerHTML = isDark ? "☀️" : "🌙";
toggleBtn.setAttribute("aria-label", isDark ? "切換到亮色模式" : "切換到暗色模式");
}
}
function patchNavbar() {
const navbar = document.querySelector("nav.navbar.fixed-top");
if (!navbar) return;
// 移除空的 primary-navigation 和 page_heading_menu
navbar.querySelector(".primary-navigation")?.remove();
navbar.querySelector("ul.navbar-nav.d-none.d-md-flex")?.remove();
navbar.querySelector(".navbar-toggler")?.remove();
document.querySelector("nav .navbar-brand").innerHTML = `
<img src="https://g.elvismao.com/nycu-em3/e3.svg" alt="E3 Logo" style="height:32px; margin-right:8px;">
<span style="font-weight:700;">NYCU EM3</span>
`;
// Add theme toggle button to navbar
const userNavigation = navbar.querySelector("#usernavigation");
if (userNavigation) {
const themeToggleContainer = document.createElement("div");
themeToggleContainer.className = "popover-region collapsed";
const isDark = document.body.classList.contains("darkmode");
themeToggleContainer.innerHTML = `
<button
id="theme-toggle"
class="nav-link popover-region-toggle position-relative icon-no-margin"
style="background:none; border:none; color:var(--em-text);"
aria-label="${isDark ? "切換到亮色模式" : "切換到暗色模式"}"
>
${isDark ? "☀️" : "🌙"}
</button>
`;
userNavigation.insertBefore(themeToggleContainer, userNavigation.firstChild);
const toggleBtn = themeToggleContainer.querySelector("#theme-toggle");
toggleBtn.addEventListener("click", toggleTheme);
}
// set favicon
const favicon = document.querySelector('link[rel="icon"]') || document.createElement("link");
favicon.rel = "icon";
favicon.href = "https://g.elvismao.com/nycu-em3/e3.svg";
document.head.appendChild(favicon);
// 升級 Gravatar 解析度(頭像 img)
const avatarImg = navbar.querySelector("#user-menu-toggle img.userpicture");
if (avatarImg?.src) {
avatarImg.src = avatarImg.src.replace("s=35", "s=200");
avatarImg.removeAttribute("width");
avatarImg.removeAttribute("height");
}
}
function initDashboard() {
const data = extractDashboardData();
const app = buildDashboardApp(data);
document.getElementById("page")?.remove();
document.getElementById("page-wrapper").appendChild(app);
}
function extractDashboardData() {
const siteLogo = document.querySelector(".navbar-brand img")?.src || document.querySelector(".drawerheader a img")?.src || "";
const avatarRaw = document.querySelector("#user-menu-toggle img")?.src || document.querySelector(".myprofileitem.picture img")?.src || "";
// Gravatar 預設 s=35 畫質很差,換成 200
const avatar = avatarRaw.replace("s=35", "s=200");
const notificationCount = cleanText(document.querySelector("#nav-notification-popover-container .count-container")?.textContent) || "";
const lang = cleanText([...document.querySelectorAll("#usernavigation a.nav-link")].map(el => el.textContent).find(t => /\bTW\b/i.test(t))) || "TW";
const profileName = cleanText(document.querySelector("#inst82730 .myprofileitem.fullname")?.textContent) || "已登入使用者";
const country = cleanText(document.querySelector("#inst82730 .myprofileitem.country")?.textContent).replace(/^國家:\s*/, "");
const englishName = cleanText(document.querySelectorAll("#inst82730 .myprofileitem.city")[0]?.textContent).replace(/^英文姓名:\s*/, "");
const email = cleanText(document.querySelectorAll("#inst82730 .myprofileitem.city")[1]?.textContent).replace(/^電子郵件信箱:\s*/, "");
const courses = parseCourses();
const announcements = parseAnnouncements();
const events = parseEvents();
// Collect unique terms in DOM order (newest first); courses without a term go under "其他"
const allTerms = [...new Set(courses.map(c => c.term).filter(Boolean))];
if (courses.some(c => !c.term)) allTerms.push("其他");
const currentTerm = allTerms[0] || "";
return {
siteLogo,
avatar,
notificationCount,
lang,
isEnglish,
profileName,
country,
englishName,
email,
currentTerm,
allTerms,
courses,
announcements,
events
};
}
function parseCourses() {
// 優先抓右側主課程清單;若只有一個學期,再補入左側 sidebar(可能包含舊學期)
const rightNodes = [...document.querySelectorAll("#layer2_right_current_course_stu a.course-link")];
const leftNodes = [...document.querySelectorAll("#layer2_right_current_course_left a.course-link")];
// 右側已有的 href set,用來去重
const rightHrefs = new Set(rightNodes.map(a => normalizeHref(a.getAttribute("href"))).filter(Boolean));
// 把左側有、但右側沒有的補進來(舊學期課程)
const extraNodes = leftNodes.filter(a => !rightHrefs.has(normalizeHref(a.getAttribute("href"))));
const nodes = [...rightNodes, ...extraNodes];
const seen = new Set();
const items = [];
for (const a of nodes) {
const href = normalizeHref(a.getAttribute("href"));
const raw = cleanText(a.textContent);
if (!href || !raw || seen.has(href)) continue;
seen.add(href);
const termMatch = raw.match(/【([^】]+)】/);
const term = termMatch ? termMatch[1] : "";
// 去掉【學期】前綴與課號後,剩下「中文名稱 英文名稱」
const body = raw
.replace(/^\s*【[^】]+】\s*/, "")
.replace(/^\d+\s*/, "")
.trim();
// 英文部分:從第一個「空白+大寫英文字母」開始到結尾
const enMatch = body.match(/\s+([A-Z].*)$/);
const titleEn = enMatch ? enMatch[1].trim() : "";
const titleZh = enMatch ? body.slice(0, enMatch.index).trim() : body;
const title = titleZh || raw;
items.push({ title, titleZh: title, titleEn, href, term });
}
return items;
}
function parseAnnouncements() {
const posts = [...document.querySelectorAll("#inst20 .post")];
const announcements = posts.map(post => {
const dateLine = cleanText(post.querySelector(".date")?.textContent);
const courseRaw = cleanText(post.querySelector(".date b")?.textContent);
const title = cleanText(post.querySelector(".name")?.textContent);
const info = cleanText(post.querySelector(".info")?.textContent);
const link = normalizeHref(post.querySelector(".info a")?.getAttribute("href"));
const rawTime = dateLine.replace(courseRaw, "").trim();
const formattedTime = formatRelativeDate(rawTime);
// 格式:1142.515501.離散數學 Discrete Mathematics → 離散數學
// 先去掉「學期代碼.課號.」前綴,再去掉英文名稱
const course =
courseRaw
.replace(/^\d+\.\d+\./, "") // 去掉 "1142.515501."
.replace(/\s+[A-Za-z][\s\S]*$/, "") // 去掉英文名稱
.trim() || courseRaw;
// 為了排序,保留原始時間用於解析
const timeMatch = rawTime.match(/(\d{1,2})月\s*(\d{1,2})日,(\d{1,2}):(\d{2})/);
let sortDate = new Date();
if (timeMatch) {
const [, month, day, hour, minute] = timeMatch;
const currentYear = new Date().getFullYear();
sortDate = new Date(currentYear, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
// 如果解析出來的日期比現在晚,則可能是去年
if (sortDate > new Date()) {
sortDate = new Date(currentYear - 1, parseInt(month) - 1, parseInt(day), parseInt(hour), parseInt(minute));
}
}
return {
course,
time: formattedTime,
title,
info,
href: link || "#",
sortDate
};
});
// 按日期排序,最新的在前面
return announcements.sort((a, b) => b.sortDate - a.sortDate);
}
function parseEvents() {
const items = [...document.querySelectorAll('#inst11984 [data-region="event-item"]')];
return items.slice(0, 4).map(item => {
const title = cleanText(item.querySelector("h6 a")?.textContent);
const href = normalizeHref(item.querySelector("h6 a")?.getAttribute("href"));
const time = cleanText(item.querySelector(".date")?.textContent);
return { title, href, time };
});
}
function buildDashboardApp(data) {
const app = document.createElement("div");
app.className = "em-app";
app.innerHTML = `
<main class="em-main">
<div class="em-col">
<section class="em-section">
<div class="em-section-head">
<h2 class="em-section-title">課程列表</h2>
${
data.allTerms.length > 1
? `<div class="em-term-dropdown" id="em-term-dropdown">
<button class="em-pill em-pill-btn" id="em-term-btn" aria-haspopup="true" aria-expanded="false">
${escapeHTML(data.currentTerm)} <span class="em-pill-caret">▾</span>
</button>
<ul class="em-term-menu" id="em-term-menu" role="listbox">
${data.allTerms
.map(t => {
const val = t === "其他" ? "" : t;
return `<li class="em-term-option${t === data.currentTerm ? " active" : ""}" role="option" data-term="${escapeAttr(val)}">${escapeHTML(t)}</li>`;
})
.join("")}
</ul>
</div>`
: data.currentTerm
? `<div class="em-pill">${escapeHTML(data.currentTerm)}</div>`
: ""
}
</div>
${
data.courses.length
? `<div class="em-course-grid" id="em-course-grid">
${data.courses
.map(
course => `
<a class="em-course-chip" href="${escapeAttr(course.href)}" data-term="${escapeAttr(course.term)}"${course.term !== data.currentTerm ? ' style="display:none"' : ""}>
${escapeHTML(data.isEnglish && course.titleEn ? course.titleEn : course.titleZh)}
</a>
`
)
.join("")}
</div>`
: `<div class="em-empty">沒有抓到課程資料</div>`
}
</section>
<section class="em-section">
<div class="em-section-head">
<h2 class="em-section-title">已登入使用者</h2>
</div>
<div class="em-card em-profile-card">
<div class="em-profile-pic">
${data.avatar ? `<img src="${escapeAttr(data.avatar)}" alt="profile">` : ""}
</div>
<div>
<div class="em-profile-name">${escapeHTML(data.profileName)}</div>
${data.country ? `<div class="em-profile-line"><b>國家:</b> ${escapeHTML(data.country)}</div>` : ""}
${data.englishName ? `<div class="em-profile-line"><b>英文姓名:</b> ${escapeHTML(data.englishName)}</div>` : ""}
${data.email ? `<div class="em-profile-line"><b>電子郵件信箱:</b> ${escapeHTML(data.email)}</div>` : ""}
</div>
</div>
</section>
</div>
<div class="em-col">
<section class="em-section">
<div class="em-section-head">
<h2 class="em-section-title">課程公告</h2>
</div>
<div class="em-card em-announcements">
${
data.announcements.length
? data.announcements
.map(
item => `
<a class="em-announcement" href="${escapeAttr(item.href)}">
<div class="em-announcement-meta">
<span class="em-announcement-course">${escapeHTML(item.course)}</span>
${item.time ? ` / ${escapeHTML(item.time)}` : ""}
</div>
<div class="em-announcement-title">${escapeHTML(item.title)}</div>
<div class="em-announcement-desc">${escapeHTML(item.info)}</div>
</a>
`
)
.join("")
: `<div class="em-empty">沒有抓到公告資料</div>`
}
</div>
</section>
<section class="em-section">
<div class="em-section-head">
<h2 class="em-section-title">未來事件</h2>
</div>
<div class="em-card em-events">
${
data.events.length
? data.events
.map(
event => `
<a class="em-event" href="${escapeAttr(event.href)}">
<div class="em-event-icon">🎓</div>
<div>
<div class="em-event-title">${escapeHTML(event.title)}</div>
<div class="em-event-time">${escapeHTML(event.time)}</div>
</div>
</a>
`
)
.join("")
: `<div class="em-empty">沒有抓到未來事件</div>`
}
</div>
</section>
</div>
</main>
`;
// Term dropdown interaction
const termBtn = app.querySelector("#em-term-btn");
const termMenu = app.querySelector("#em-term-menu");
const courseGrid = app.querySelector("#em-course-grid");
if (termBtn && termMenu && courseGrid) {
let activeTerm = data.currentTerm;
const applyTerm = term => {
activeTerm = term;
// Update pill label
const label = term === "" ? "其他" : term;
termBtn.childNodes[0].textContent = label + " ";
// Show/hide chips
courseGrid.querySelectorAll(".em-course-chip").forEach(chip => {
const t = chip.dataset.term;
chip.style.display = t === term ? "" : "none";
});
// Update active state in menu
termMenu.querySelectorAll(".em-term-option").forEach(opt => {
opt.classList.toggle("active", opt.dataset.term === term);
});
};
termBtn.addEventListener("click", e => {
e.stopPropagation();
const open = termMenu.classList.toggle("open");
termBtn.setAttribute("aria-expanded", String(open));
});
termMenu.addEventListener("click", e => {
const option = e.target.closest(".em-term-option");
if (!option) return;
applyTerm(option.dataset.term);
termMenu.classList.remove("open");
termBtn.setAttribute("aria-expanded", "false");
});
// Close on outside click
document.addEventListener("click", () => {
termMenu.classList.remove("open");
termBtn.setAttribute("aria-expanded", "false");
});
}
return app;
}
function normalizeHref(href) {
if (!href) return "";
try {
return new URL(href, location.origin).href;
} catch {
return href;
}
}
function cleanText(value) {
return (value || "")
.replace(/\u00a0/g, " ")
.replace(/\s+/g, " ")
.trim();
}
function escapeHTML(str) {
return String(str).replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">").replaceAll('"', """).replaceAll("'", "'");
}
function formatRelativeDate(dateStr) {
// 解析原始日期字串 "03月 16日,15:49"
const match = dateStr.match(/(\d{1,2})月\s*(\d{1,2})日,(\d{1,2}):(\d{2})/);
if (!match) return dateStr;
const [, month, day] = match;
const now = new Date();
const currentYear = now.getFullYear();
// 假設是當年的日期,如果解析出來的日期比現在晚,則可能是去年
let targetDate = new Date(currentYear, parseInt(month) - 1, parseInt(day), 0, 0);
if (targetDate > now) {
targetDate = new Date(currentYear - 1, parseInt(month) - 1, parseInt(day), 0, 0);
}
const timeDiff = now - targetDate;
const daysDiff = Math.floor(timeDiff / (1000 * 60 * 60 * 24));
const weekdays = ["星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"];
const weekday = weekdays[targetDate.getDay()];
// 如果是今天
if (daysDiff === 0) {
return `今天`;
}
// 如果是昨天
if (daysDiff === 1) {
return `昨天 ${weekday}`;
}
if (daysDiff < now.getDay()) {
return weekday;
}
if (daysDiff < now.getDay() + 7) {
return `${month}/${day} 上${weekday}`;
}
// 超過兩周,按週計算
const weeksDiff = Math.floor(daysDiff / 7) + 1;
if (weeksDiff < 8) {
return `${weeksDiff} 周前${weekday}`;
}
// 超過 8 周,顯示月/日
return `${month}/${day}`;
}
function escapeAttr(str) {
return escapeHTML(str);
}
})();