feat(courseware): add protected PPTX viewer

This commit is contained in:
2026-09-03 18:06:03 +08:00
parent fd53bab639
commit 7495d40d4d
28 changed files with 1823 additions and 4117 deletions
+583
View File
@@ -0,0 +1,583 @@
<?php
$is_player = $selected_deck !== null;
$caption_colors = array('#e86a5c', '#2f9e99', '#f0a04b');
include('template/' . $OJ_TEMPLATE . '/header.php');
?>
<style>
body#MainBg-C.cw-catalog-page {
background: #f3eadc !important;
background-image:
repeating-linear-gradient(
to bottom,
rgba(120, 90, 50, 0.05) 0,
rgba(120, 90, 50, 0.05) 1px,
transparent 1px,
transparent 32px
) !important;
}
body#MainBg-C.cw-player-page {
background: #1b2430 !important;
}
#main.ui.main.container.cw-main {
width: 100% !important;
max-width: none !important;
margin: 0 !important;
padding: 0 24px 48px !important;
}
.cw-player-page #main.ui.main.container.cw-main {
padding: 0 !important;
}
.cw-player-page .footer {
display: none;
}
.cw-hero {
text-align: center;
padding: 28px 0 8px;
}
.cw-hero h1 {
margin: 0;
font-size: 3.4rem;
font-weight: 800;
color: #e86a5c;
letter-spacing: 0.04em;
line-height: 1.1;
}
.cw-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 28px;
max-width: 1180px;
margin: 28px auto 0;
}
.cw-card {
display: block;
background: #fff;
border-radius: 18px;
overflow: hidden;
text-decoration: none !important;
color: inherit;
box-shadow: 0 14px 32px rgba(80, 50, 20, 0.12);
transform: translateY(0);
transition: transform 0.18s ease, box-shadow 0.18s ease;
}
.cw-card:hover,
.cw-card:focus {
transform: translateY(-4px);
box-shadow: 0 20px 40px rgba(80, 50, 20, 0.16);
}
.cw-card-cover {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
aspect-ratio: 16 / 9;
background:
radial-gradient(circle at 20% 20%, rgba(255,255,255,0.35), transparent 42%),
linear-gradient(160deg, #f7d3c8, #f6efe4 55%, #d8efe9);
}
.cw-card-meta {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
padding: 14px 16px 16px;
color: #fff;
}
.cw-card-title {
font-size: 1.05rem;
font-weight: 700;
line-height: 1.35;
}
.cw-empty {
max-width: 640px;
margin: 40px auto 0;
padding: 28px 24px;
text-align: center;
background: rgba(255, 255, 255, 0.72);
border-radius: 18px;
color: #4b3b2a;
font-size: 1.05rem;
line-height: 1.7;
}
.cw-theater {
min-height: calc(100vh - 77px);
display: flex;
flex-direction: column;
padding: 18px 20px 24px;
color: #f4f1ea;
}
.cw-back {
align-self: flex-start;
color: #f4f1ea !important;
font-size: 1.05rem;
font-weight: 600;
text-decoration: underline;
text-underline-offset: 4px;
margin-bottom: 8px;
}
.cw-stage {
flex: 1;
display: grid;
grid-template-columns: 72px minmax(0, 1fr) 72px;
align-items: center;
gap: 12px;
min-height: 0;
}
.cw-nav-btn {
width: 64px;
height: 64px;
border: 0;
border-radius: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 2rem;
cursor: pointer;
color: #fff;
background: #5b6570;
box-shadow: 0 8px 20px rgba(0, 0, 0, 0.25);
}
.cw-nav-btn.next {
background: #e86a5c;
}
.cw-nav-btn.is-disabled,
.cw-nav-btn:disabled {
opacity: 0.28;
pointer-events: none;
}
.cw-frame {
position: relative;
width: 100%;
max-height: calc(100vh - 220px);
aspect-ratio: 16 / 9;
margin: 0 auto;
background: #fff;
border-radius: 18px;
overflow: hidden;
box-shadow: 0 24px 60px rgba(0, 0, 0, 0.35);
cursor: pointer;
}
.cw-frame canvas {
display: block;
width: 100%;
height: 100%;
}
.cw-status {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
text-align: center;
color: #3b3228;
font-size: 1.15rem;
font-weight: 600;
background: #fff8ee;
}
.cw-status.is-error {
color: #9b2c2c;
}
.cw-status[hidden] {
display: none;
}
.cw-pager {
text-align: center;
margin-top: 16px;
}
.cw-pager-num {
font-size: 1.8rem;
font-weight: 800;
letter-spacing: 0.04em;
}
.cw-pager-num em {
font-style: normal;
color: #e86a5c;
border-bottom: 4px solid #e86a5c;
padding: 0 2px;
}
.cw-dots {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
margin-top: 10px;
}
.cw-dots button {
width: 10px;
height: 10px;
padding: 0;
border: 0;
border-radius: 50%;
background: #7b8490;
cursor: pointer;
}
.cw-dots button.is-current {
background: #e86a5c;
}
.cw-dots button:disabled {
cursor: default;
}
.cw-mobile-nav {
display: none;
justify-content: space-between;
padding: 8px 8px 0;
}
@media (max-width: 991px) {
.cw-hero h1 {
font-size: 2.4rem;
}
.cw-stage {
grid-template-columns: 1fr;
}
.cw-frame {
max-height: none;
}
.cw-mobile-nav {
display: flex;
}
}
</style>
<script>
document.body.classList.add('<?php echo $is_player ? 'cw-player-page' : 'cw-catalog-page'; ?>');
var main = document.getElementById('main');
if (main) {
main.classList.add('cw-main');
}
</script>
<?php if ($is_player) { ?>
<div class="cw-theater">
<a class="cw-back" href="courseware.php"><?php echo htmlspecialchars($MSG_COURSEWARE_BACK, ENT_QUOTES, 'UTF-8'); ?></a>
<div class="cw-stage">
<button type="button" class="cw-nav-btn desktop-only" id="cw-prev" aria-label="<?php echo htmlspecialchars($MSG_COURSEWARE_PREV, ENT_QUOTES, 'UTF-8'); ?>" disabled aria-disabled="true"></button>
<div class="cw-frame" id="cw-frame">
<canvas id="cw-canvas"></canvas>
<div class="cw-status" id="cw-status" role="status" aria-live="polite"><?php echo htmlspecialchars($MSG_COURSEWARE_OPENING, ENT_QUOTES, 'UTF-8'); ?></div>
</div>
<button type="button" class="cw-nav-btn next desktop-only" id="cw-next" aria-label="<?php echo htmlspecialchars($MSG_COURSEWARE_NEXT, ENT_QUOTES, 'UTF-8'); ?>" disabled aria-disabled="true"></button>
</div>
<div class="cw-mobile-nav">
<button type="button" class="cw-nav-btn" id="cw-prev-m" aria-label="<?php echo htmlspecialchars($MSG_COURSEWARE_PREV, ENT_QUOTES, 'UTF-8'); ?>" disabled aria-disabled="true"></button>
<button type="button" class="cw-nav-btn next" id="cw-next-m" aria-label="<?php echo htmlspecialchars($MSG_COURSEWARE_NEXT, ENT_QUOTES, 'UTF-8'); ?>" disabled aria-disabled="true"></button>
</div>
<div class="cw-pager">
<div class="cw-pager-num" aria-live="polite"><em id="cw-page-now"><?php echo intval($current_page); ?></em> / <span id="cw-page-total"><?php echo htmlspecialchars($MSG_COURSEWARE_PAGE_UNKNOWN, ENT_QUOTES, 'UTF-8'); ?></span></div>
<div class="cw-dots" id="cw-dots" role="tablist"></div>
</div>
</div>
<script src="<?php echo $path_fix; ?>include/pptxviewjs/jszip.min.js"></script>
<script src="<?php echo $path_fix; ?>include/pptxviewjs/PptxViewJS.min.js"></script>
<script>
(function () {
var slug = <?php echo json_encode($selected_deck['slug'], JSON_UNESCAPED_UNICODE); ?>;
var page = <?php echo intval($current_page); ?>;
var desiredPage = page;
var total = 0;
var totalKnown = false;
var ready = false;
var failed = false;
var viewer = null;
var navQueue = Promise.resolve();
var navBusy = false;
var canvas = document.getElementById('cw-canvas');
var statusEl = document.getElementById('cw-status');
var buttons = {
prev: [document.getElementById('cw-prev'), document.getElementById('cw-prev-m')],
next: [document.getElementById('cw-next'), document.getElementById('cw-next-m')]
};
var msg = {
unknown: <?php echo json_encode($MSG_COURSEWARE_PAGE_UNKNOWN, JSON_UNESCAPED_UNICODE); ?>,
pageN: <?php echo json_encode($MSG_COURSEWARE_PAGE_N, JSON_UNESCAPED_UNICODE); ?>,
login: <?php echo json_encode($MSG_COURSEWARE_ERR_LOGIN, JSON_UNESCAPED_UNICODE); ?>,
missing: <?php echo json_encode($MSG_COURSEWARE_ERR_NOT_FOUND, JSON_UNESCAPED_UNICODE); ?>,
broken: <?php echo json_encode($MSG_COURSEWARE_ERR_BROKEN, JSON_UNESCAPED_UNICODE); ?>,
player: <?php echo json_encode($MSG_COURSEWARE_ERR_PLAYER, JSON_UNESCAPED_UNICODE); ?>
};
function setStatus(text, isError) {
if (!text) {
statusEl.hidden = true;
statusEl.removeAttribute('role');
statusEl.setAttribute('role', 'status');
statusEl.setAttribute('aria-live', 'polite');
return;
}
statusEl.hidden = false;
statusEl.textContent = text;
statusEl.classList.toggle('is-error', !!isError);
statusEl.setAttribute('role', isError ? 'alert' : 'status');
statusEl.setAttribute('aria-live', isError ? 'assertive' : 'polite');
}
function setBtnState(list, disabled) {
list.forEach(function (btn) {
if (!btn) {
return;
}
btn.disabled = !!disabled;
btn.setAttribute('aria-disabled', disabled ? 'true' : 'false');
btn.classList.toggle('is-disabled', !!disabled);
});
}
function clampPage(value) {
var next = parseInt(value, 10);
if (isNaN(next) || next < 1) {
next = 1;
}
if (totalKnown && total >= 1 && next > total) {
next = total;
}
return next;
}
function shownPage() {
if (viewer && ready && !failed) {
return viewer.getCurrentSlideIndex() + 1;
}
return page;
}
function updateChrome() {
if (viewer && ready) {
page = viewer.getCurrentSlideIndex() + 1;
total = viewer.getSlideCount() || total;
totalKnown = total >= 1;
}
document.getElementById('cw-page-now').textContent = String(page);
document.getElementById('cw-page-total').textContent = totalKnown ? String(total) : msg.unknown;
var disableNav = !ready || failed;
setBtnState(buttons.prev, disableNav || desiredPage <= 1);
setBtnState(buttons.next, disableNav || (totalKnown && desiredPage >= total));
var dots = document.getElementById('cw-dots');
if (totalKnown && dots.childNodes.length !== total) {
dots.innerHTML = '';
for (var i = 1; i <= total; i++) {
var dot = document.createElement('button');
dot.type = 'button';
dot.setAttribute('aria-label', msg.pageN.replace('%s', String(i)));
(function (index) {
dot.addEventListener('click', function (event) {
event.stopPropagation();
go(index);
});
})(i);
dots.appendChild(dot);
}
}
Array.prototype.forEach.call(dots.children, function (dot, idx) {
dot.classList.toggle('is-current', idx + 1 === page);
dot.disabled = !ready || failed;
dot.setAttribute('aria-disabled', (!ready || failed) ? 'true' : 'false');
});
if (window.history && window.history.replaceState) {
window.history.replaceState(null, '', 'courseware.php?deck=' + encodeURIComponent(slug) + '&page=' + page);
}
}
function go(nextPage) {
if (!viewer || !ready || failed) {
return;
}
desiredPage = clampPage(nextPage);
if (navBusy) {
return;
}
navBusy = true;
navQueue = navQueue.then(function pump() {
if (!viewer || !ready || failed) {
navBusy = false;
return;
}
var target = clampPage(desiredPage);
var current = shownPage();
if (target === current) {
page = current;
navBusy = false;
updateChrome();
return;
}
return viewer.goToSlide(target - 1).then(function () {
page = shownPage();
updateChrome();
if (clampPage(desiredPage) !== page) {
return pump();
}
navBusy = false;
});
}).catch(function () {
navBusy = false;
setStatus(msg.broken, true);
});
}
function sizeCanvas() {
var frame = document.getElementById('cw-frame');
var rect = frame.getBoundingClientRect();
var ratio = window.devicePixelRatio || 1;
canvas.width = Math.max(1, Math.floor(rect.width * ratio));
canvas.height = Math.max(1, Math.floor(rect.height * ratio));
}
function fail(kind) {
failed = true;
ready = false;
if (kind === 'login') {
setStatus(msg.login, true);
} else if (kind === 'missing') {
setStatus(msg.missing, true);
} else if (kind === 'player') {
setStatus(msg.player, true);
} else {
setStatus(msg.broken, true);
}
updateChrome();
}
try {
if (!window.Chart || !window.JSZip || !window.PptxViewJS || !window.PptxViewJS.PPTXViewer) {
fail('player');
return;
}
sizeCanvas();
viewer = new PptxViewJS.PPTXViewer({
canvas: canvas,
slideSizeMode: 'fit',
backgroundColor: '#ffffff'
});
viewer.on('slideChanged', updateChrome);
viewer.on('renderComplete', function () {
if (!failed) {
setStatus('');
updateChrome();
}
});
viewer.loadFromArrayBuffer = function (buffer) {
return this.loadFile(buffer);
};
fetch('courseware-file.php?deck=' + encodeURIComponent(slug), { credentials: 'same-origin' }).then(function (res) {
if (res.status === 401) {
throw { kind: 'login' };
}
if (res.status === 404 || !res.ok) {
throw { kind: 'missing' };
}
return res.arrayBuffer();
}).then(function (buf) {
return viewer.loadFromArrayBuffer(buf);
}).then(function () {
total = viewer.getSlideCount() || 0;
totalKnown = total >= 1;
if (!totalKnown) {
throw { kind: 'broken' };
}
ready = true;
page = clampPage(page);
desiredPage = page;
return viewer.goToSlide(page - 1);
}).then(function () {
setStatus('');
updateChrome();
}).catch(function (err) {
fail(err && err.kind ? err.kind : 'broken');
});
} catch (err) {
fail('player');
}
buttons.prev.forEach(function (btn) {
if (btn) btn.addEventListener('click', function () { go(desiredPage - 1); });
});
buttons.next.forEach(function (btn) {
if (btn) btn.addEventListener('click', function () { go(desiredPage + 1); });
});
document.addEventListener('keydown', function (event) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
go(desiredPage - 1);
}
if (event.key === 'ArrowRight' || event.key === ' ') {
event.preventDefault();
go(desiredPage + 1);
}
if (event.key === 'Escape') window.location.href = 'courseware.php';
});
document.getElementById('cw-frame').addEventListener('click', function (event) {
var rect = this.getBoundingClientRect();
var x = event.clientX - rect.left;
if (x < rect.width * 0.4) go(desiredPage - 1);
if (x > rect.width * 0.6) go(desiredPage + 1);
});
window.addEventListener('resize', function () {
sizeCanvas();
if (viewer && ready && !failed) viewer.render();
});
updateChrome();
})();
</script>
<?php } else { ?>
<div class="cw-hero">
<h1><?php echo htmlspecialchars($MSG_COURSEWARE, ENT_QUOTES, 'UTF-8'); ?></h1>
</div>
<?php if (empty($decks)) { ?>
<div class="cw-empty"><?php echo htmlspecialchars($MSG_COURSEWARE_EMPTY, ENT_QUOTES, 'UTF-8'); ?></div>
<?php } else { ?>
<div class="cw-grid">
<?php foreach ($decks as $index => $deck) {
$bar = $caption_colors[$index % count($caption_colors)];
?>
<a class="cw-card" href="courseware.php?deck=<?php echo rawurlencode($deck['slug']); ?>">
<div class="cw-card-cover" aria-hidden="true"></div>
<div class="cw-card-meta" style="background:<?php echo $bar; ?>">
<div class="cw-card-title"><?php echo htmlspecialchars($deck['title'], ENT_QUOTES, 'UTF-8'); ?></div>
</div>
</a>
<?php } ?>
</div>
<?php } ?>
<?php } ?>
<?php include('template/' . $OJ_TEMPLATE . '/footer.php'); ?>
-193
View File
@@ -1,193 +0,0 @@
<?php include('template/' . $OJ_TEMPLATE . '/header.php'); ?>
<script src="include/vditor-adapter.js"></script>
<style>
.docs-shell {
display: flex;
gap: 18px;
min-height: calc(100vh - 180px);
align-items: stretch;
}
.docs-sidebar,
.docs-content {
background: rgba(255, 255, 255, 0.88);
border-radius: 18px;
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.08);
backdrop-filter: blur(8px);
}
.docs-sidebar {
width: 300px;
padding: 18px 0;
flex-shrink: 0;
overflow: hidden;
}
.docs-sidebar-header {
padding: 0 20px 14px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.docs-sidebar-header h2 {
margin: 0;
font-size: 1.2rem;
}
.docs-sidebar-header p {
margin: 8px 0 0;
color: #6b7280;
font-size: 0.92rem;
}
.docs-nav {
padding: 12px;
max-height: calc(100vh - 260px);
overflow-y: auto;
}
.docs-nav a {
display: block;
padding: 12px 14px;
border-radius: 12px;
color: #1f2937;
transition: background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.docs-nav a:hover,
.docs-nav a:focus {
background: rgba(37, 99, 235, 0.1);
color: #1d4ed8;
transform: translateX(2px);
}
.docs-nav a.active {
background: linear-gradient(135deg, #1d4ed8, #2563eb);
color: #fff;
box-shadow: 0 12px 30px rgba(37, 99, 235, 0.24);
}
.docs-empty {
padding: 14px 20px;
color: #6b7280;
}
.docs-content {
flex: 1;
padding: 22px 28px 28px;
overflow: hidden;
}
.docs-content-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 18px;
padding-bottom: 14px;
border-bottom: 1px solid rgba(15, 23, 42, 0.08);
}
.docs-content-header.docs-content-header-compact {
justify-content: flex-end;
}
.docs-content-header h1 {
margin: 0;
font-size: 1.6rem;
line-height: 1.3;
}
.docs-content-header span {
color: #6b7280;
font-size: 0.92rem;
white-space: nowrap;
}
.docs-render {
min-height: 320px;
color: #111827;
overflow-wrap: anywhere;
}
@media (max-width: 991px) {
.docs-shell {
flex-direction: column;
min-height: auto;
}
.docs-sidebar {
width: 100%;
}
.docs-nav {
max-height: 260px;
}
.docs-content {
padding: 18px 18px 22px;
}
.docs-content-header {
flex-direction: column;
align-items: flex-start;
}
.docs-content-header span {
white-space: normal;
}
}
</style>
<div class="docs-shell">
<aside class="docs-sidebar">
<div class="docs-sidebar-header">
<h2>文档目录</h2>
</div>
<div class="docs-nav">
<?php if (empty($doc_entries)) { ?>
<div class="docs-empty">当前 doc 目录下还没有 Markdown 文档。</div>
<?php } else { ?>
<?php foreach ($doc_entries as $doc_entry) { ?>
<a
href="docs.php?file=<?php echo rawurlencode($doc_entry['file']); ?>"
class="<?php if ($doc_entry['file'] === $selected_file) echo 'active'; ?>"
>
<?php echo htmlspecialchars($doc_entry['title'], ENT_QUOTES, 'UTF-8'); ?>
</a>
<?php } ?>
<?php } ?>
</div>
</aside>
<section class="docs-content">
<div class="docs-content-header<?php if (!$show_selected_title) echo ' docs-content-header-compact'; ?>">
<?php if ($show_selected_title) { ?>
<h1><?php echo htmlspecialchars($selected_title, ENT_QUOTES, 'UTF-8'); ?></h1>
<?php } ?>
<span><?php echo empty($selected_file) ? '未选择文档' : htmlspecialchars($selected_file, ENT_QUOTES, 'UTF-8'); ?></span>
</div>
<?php if (!empty($selected_markdown)) { ?>
<div id="doc-render" class="docs-render md"><?php echo htmlspecialchars($selected_markdown, ENT_NOQUOTES, 'UTF-8'); ?></div>
<?php } else { ?>
<div class="ui info message">没有可显示的文档内容。</div>
<?php } ?>
</section>
</div>
<script>
$(document).ready(function () {
if (!$('#doc-render').length) {
return;
}
HustOJVditor.renderMarkdownBlocks('#doc-render', {
useTextContent: true
}).catch(function (error) {
console.error('Failed to render docs markdown.', error);
});
});
</script>
<?php include('template/' . $OJ_TEMPLATE . '/footer.php'); ?>
+2
View File
@@ -1,7 +1,9 @@
</div>
</div>
<script src="<?php echo $OJ_CDN_URL.$path_fix."template/$OJ_TEMPLATE"?>/css/semantic.min.js"></script>
<?php if (empty($OJ_COURSEWARE_PAGE)) { ?>
<script src="<?php echo $path_fix."template/$OJ_TEMPLATE"?>/css/Chart.min.js"></script>
<?php } ?>
<style>
.footer {
line-height: 1.4285em;
+5
View File
@@ -192,6 +192,9 @@ a.label:hover {
console.log('\n %c Theme By %c Baoshuo ( @renbaoshuo ) %c https://baoshuo.ren %c\n', 'color: #fadfa3; background: #000000; padding:5px 0;', 'background: #fadfa3; padding:5px 0;', 'background: #ffbf33; padding:5px 0;', '');
console.log('\n GitHub Homepage: https://github.com/zhblue/hustoj \n Document: https://zhblue.github.io/hustoj \n Bug report URL: https://github.com/zhblue/hustoj/issues \n \n%c ★ Please give us a star on GitHub! ★ %c \n', 'color: red;', '')
</script>
<?php if (!empty($OJ_COURSEWARE_PAGE)) { ?>
<script src="<?php echo $path_fix."template/$OJ_TEMPLATE"?>/css/Chart.min.js"></script>
<?php } ?>
</head>
<?php
@@ -216,6 +219,8 @@ a.label:hover {
<!-- 问题 -->
<a class="item <?php if ($url=="problemset.php") echo "active";?>"
href="<?php echo $path_fix?>problemset.php"><i class="list icon"></i><?php echo $MSG_PROBLEMS?> </a>
<a class="item <?php if ($url=="courseware.php") echo "active";?>"
href="<?php echo $path_fix?>courseware.php"><i class="clone icon"></i><?php echo $MSG_COURSEWARE; ?></a>
<!-- 竞赛/作业 -->
<a class="item <?php if ($url=="contest.php") echo "active";?>" href="<?php echo $path_fix?>contest.php<?php if(isset($_SESSION[$OJ_NAME."_user_id"])) echo "?my" ?>" ><i
class="trophy icon"></i> <?php echo $MSG_CONTEST?></a>