Files
wanghu 15762faeac
build / build (push) Successful in 5m20s
commit
2026-10-13 10:47:07 +08:00

1146 lines
58 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>课程表管理 · 大宁国际小学</title>
<meta name="description" content="电子课程表与照片课程表的在线编排与预览:按班级维护节次、时段与学科,一键生成可打印课表。">
<style>
/* ============================================================
设计令牌 —— 与 AcaSyllabu 模块视觉语言同源($color-primary #5979ea)
============================================================ */
:root{
--brand-50:#eef1fd; --brand-100:#dde4fb; --brand-200:#bdc9f6;
--brand-400:#7d93ee; --brand:#5979ea; --brand-600:#4560d4; --brand-800:#2f4191;
--ink-900:#22262e; --ink-700:#404042; --ink-600:#5a5f6a;
--ink-400:#8c8f99; --ink-300:#a8abaf; --ink-200:#d6d9df;
--line:#e9e9e9; --line-soft:#f0f1f3;
--bg-page:#f4f6fa; --bg-surface:#fff; --bg-subtle:#f7f8fb;
--ok:#3fa86c; --warn:#d99a2b; --err:#e05b45;
/* 时段色板:早自习 / 上午 / 课间操 / 午休 / 下午 / 晚自习 */
--er-bg:#eef4ff; --er-fg:#2a4b8d; --er-ac:#4c6fd0;
--am-bg:#fff9ec; --am-fg:#8a6105; --am-ac:#d99a2b;
--ex-bg:#eaf6ef; --ex-fg:#1f5c38; --ex-ac:#3f9e63;
--lu-bg:#fdf1ea; --lu-fg:#8a3d1b; --lu-ac:#d97a4b;
--pm-bg:#eef7fb; --pm-fg:#0b5c6e; --pm-ac:#3d9db5;
--ev-bg:#f3f0fe; --ev-fg:#4a3a94; --ev-ac:#7a63d8;
--no-bg:#f4f5f7; --no-fg:#6b7078; --no-ac:#9aa0aa;
--r-sm:6px; --r-md:8px; --r-lg:12px; --r-xl:16px;
--sp-1:4px; --sp-2:8px; --sp-3:12px; --sp-4:16px; --sp-5:20px; --sp-6:24px; --sp-8:32px; --sp-10:40px;
--sh-1:0 1px 2px rgba(34,38,46,.05);
--sh-2:0 4px 14px rgba(34,38,46,.07);
--sh-3:0 14px 40px rgba(34,38,46,.16);
--ease:cubic-bezier(.4,0,.2,1); --dur:200ms;
}
*,*::before,*::after{box-sizing:border-box;-webkit-tap-highlight-color:transparent}
html,body{height:100%}
/* 预留滚动条槽位:行数增减导致页面高度变化时,内容宽度不再左右跳动 */
html{scrollbar-gutter:stable}
body{
margin:0;min-width:1160px;font-size:14px;line-height:1.6;color:var(--ink-700);background:var(--bg-page);
font-family:"PingFang SC","Microsoft YaHei","Helvetica Neue",Inter,system-ui,sans-serif;
-webkit-font-smoothing:antialiased;
}
button,input,select{font-family:inherit;font-size:inherit;color:inherit}
button{cursor:pointer}
h1,h2,h3,p{margin:0}
:focus-visible{outline:2px solid var(--brand-600);outline-offset:2px;border-radius:var(--r-sm)}
.sr-only{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0}
.num{font-variant-numeric:tabular-nums}
/* ============================================================
顶部应用栏
============================================================ */
.appbar{
display:flex;align-items:center;gap:var(--sp-4);height:60px;padding:0 var(--sp-6);
color:#fff;background:linear-gradient(100deg,#5470e0 0%,#6b86f2 55%,#7f96f6 100%);
box-shadow:0 2px 14px rgba(84,112,224,.32);
}
.appbar .mark{display:grid;place-items:center;width:32px;height:32px;border-radius:9px;background:rgba(255,255,255,.18);border:1px solid rgba(255,255,255,.28)}
.appbar .divider{width:1px;height:22px;background:rgba(255,255,255,.28)}
.appbar .title{font-size:16px;font-weight:500;letter-spacing:.4px}
.appbar .spacer{flex:1}
.school{display:flex;align-items:center;gap:var(--sp-2);font-size:13px;opacity:.95}
.school .crest{display:grid;place-items:center;width:26px;height:26px;border-radius:50%;background:#fff;color:var(--brand-600);font-size:12px;font-weight:600}
/* ============================================================
页头
============================================================ */
.pagehead{display:flex;align-items:flex-end;justify-content:space-between;gap:var(--sp-6);flex-wrap:wrap;padding:var(--sp-6) var(--sp-6) var(--sp-5)}
.pagehead h1{font-size:22px;font-weight:600;color:var(--ink-900);letter-spacing:-.2px}
.pagehead .sub{margin-top:var(--sp-1);font-size:13px;color:var(--ink-400)}
.stats{display:flex;gap:var(--sp-2);flex-wrap:wrap}
.stat{
display:flex;flex-direction:column;gap:1px;min-width:92px;padding:var(--sp-2) var(--sp-4);
background:var(--bg-surface);border:1px solid var(--line);border-radius:var(--r-lg);box-shadow:var(--sh-1);
}
.stat b{font-size:18px;font-weight:600;color:var(--ink-900);line-height:1.3}
.stat span{font-size:12px;color:var(--ink-400)}
.stat.sample{background:var(--am-bg);border-color:var(--am-line);min-width:150px}
.stat.sample b{color:var(--am-fg)}
.stat.sample span{color:#9c7a2c}
/* ============================================================
主卡片 + 工具条
============================================================ */
.wrap{padding:0 var(--sp-6) var(--sp-10)}
/* overflow 保持 visible:嵌套滚动容器会让 thead / 首列的 sticky 失效,
且与页面滚动条并排产生视觉抖动,故统一交给页面级滚动 */
.card{background:var(--bg-surface);border:1px solid var(--line);border-radius:var(--r-xl);box-shadow:var(--sh-2)}
.card-tools{
display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap;
padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line-soft);
border-radius:var(--r-xl) var(--r-xl) 0 0;
background:linear-gradient(180deg,#fbfcfe,#f6f8fc);
}
.tabs{display:inline-flex;padding:3px;gap:2px;background:#e9edf6;border-radius:10px}
.tabs button{
display:flex;align-items:center;gap:6px;height:32px;padding:0 var(--sp-5);border:0;border-radius:8px;
background:transparent;font-size:13px;color:var(--ink-600);transition:all var(--dur) var(--ease);
}
.tabs button:hover{color:var(--brand-600)}
.tabs button[aria-selected="true"]{background:var(--bg-surface);color:var(--brand-600);font-weight:500;box-shadow:var(--sh-1)}
.grow{flex:1}
.field-inline{display:flex;align-items:center;gap:var(--sp-2)}
.field-inline label{font-size:13px;color:var(--ink-400);white-space:nowrap}
.select{
height:34px;padding:0 32px 0 var(--sp-3);border:1px solid var(--ink-200);border-radius:var(--r-md);
background:var(--bg-surface);appearance:none;cursor:pointer;transition:border-color var(--dur) var(--ease),box-shadow var(--dur) var(--ease);
background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' fill='none' stroke='%238c8f99' stroke-width='1.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 11px center;
}
.select:hover{border-color:var(--brand-400)}
.select:focus{border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-50);outline:none}
.btn{display:inline-flex;align-items:center;gap:6px;height:34px;padding:0 var(--sp-4);border-radius:var(--r-md);border:1px solid transparent;font-size:13px;transition:all var(--dur) var(--ease);white-space:nowrap}
.btn-primary{background:var(--brand);color:#fff;box-shadow:0 2px 8px rgba(89,121,234,.32)}
.btn-primary:hover{background:var(--brand-600);box-shadow:0 4px 12px rgba(89,121,234,.4)}
.btn-ghost{background:var(--bg-surface);border-color:var(--ink-200);color:var(--ink-700)}
.btn-ghost:hover{border-color:var(--brand-400);color:var(--brand-600);background:var(--brand-50)}
.btn-quiet{background:transparent;color:var(--ink-400);border-color:transparent}
.btn-quiet:hover{color:var(--err);background:#fdf1ef}
.btn:disabled{opacity:.45;cursor:not-allowed;box-shadow:none}
.saved{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--ink-400);white-space:nowrap}
.saved .dot{width:6px;height:6px;border-radius:50%;background:var(--ok)}
.saved.is-dirty .dot{background:var(--warn)}
/* ============================================================
电子课表编辑表
============================================================ */
.pane{display:none}
.pane.is-active{display:block}
.table-scroll{overflow:visible}
table.grid{width:100%;min-width:1080px;border-collapse:separate;border-spacing:0}
table.grid th,table.grid td{padding:var(--sp-2) var(--sp-3);text-align:center;vertical-align:middle;border-bottom:1px solid var(--line-soft)}
table.grid thead th{
position:sticky;top:0;z-index:5;height:44px;padding:0;
background:#f2f5fb;color:var(--ink-900);font-size:13px;font-weight:600;letter-spacing:.3px;
border-bottom:1px solid var(--line);box-shadow:inset 0 -1px 0 var(--line);
}
table.grid thead th small{display:block;font-size:11px;font-weight:400;color:var(--ink-400);letter-spacing:0}
table.grid tbody tr:hover td:not(.band){background-color:rgba(89,121,234,.035)}
table.grid td.col-slot,table.grid th.col-slot{position:sticky;left:0;z-index:4;background:var(--bg-surface);border-right:1px solid var(--line-soft);min-width:104px}
table.grid thead th.col-slot{z-index:6;background:#f2f5fb}
.slot-name{font-size:13px;font-weight:500;color:var(--ink-900)}
.slot-time{font-size:11px;color:var(--ink-400)}
/* 时段分组行 */
tr.period-row td{
position:sticky;left:0;padding:6px var(--sp-3);text-align:left;
font-size:12px;font-weight:600;letter-spacing:.6px;border-bottom:1px solid transparent;
}
tr.period-row .tag{display:inline-flex;align-items:center;gap:6px}
tr.period-row .tag i{width:8px;height:8px;border-radius:2px;display:inline-block}
tr.period-row .cnt{margin-left:var(--sp-2);font-weight:400;opacity:.72;letter-spacing:0}
tr.period-er td{background:var(--er-bg);color:var(--er-fg);border-left-color:var(--er-ac)}
tr.period-am td{background:var(--am-bg);color:var(--am-fg);border-left-color:var(--am-ac)}
tr.period-ex td{background:var(--ex-bg);color:var(--ex-fg);border-left-color:var(--ex-ac)}
tr.period-lu td{background:var(--lu-bg);color:var(--lu-fg);border-left-color:var(--lu-ac)}
tr.period-pm td{background:var(--pm-bg);color:var(--pm-fg);border-left-color:var(--pm-ac)}
tr.period-ev td{background:var(--ev-bg);color:var(--ev-fg);border-left-color:var(--ev-ac)}
tr.period-no td{background:var(--no-bg);color:var(--no-fg);border-left-color:var(--no-ac)}
tr.period-row td{border-left:3px solid transparent}
/* 学科选择单元 */
td.cell{padding:5px 4px}
.pick{position:relative;display:block}
.pick select{
width:100%;height:38px;padding:0 24px 0 10px;appearance:none;border-radius:var(--r-md);
border:1px solid var(--line);background:#fbfcfe;color:var(--ink-600);font-size:13px;cursor:pointer;
transition:border-color var(--dur) var(--ease),background var(--dur) var(--ease),color var(--dur) var(--ease),box-shadow var(--dur) var(--ease);
background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' fill='none' stroke='%23a8abaf' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
background-repeat:no-repeat;background-position:right 8px center;
}
.pick select:hover{border-color:var(--brand-200)}
.pick select:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-50)}
.pick select.is-empty{color:var(--ink-300)}
.pick.is-set select{
border-color:var(--c-line);background-color:var(--c-bg);color:var(--c-fg);font-weight:500;
background-image:url("data:image/svg+xml;charset=utf8,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='10' viewBox='0 0 12 12'%3E%3Cpath d='M3 4.5L6 7.5L9 4.5' fill='none' stroke='currentColor' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round' opacity='.55'/%3E%3C/svg%3E");
}
/* 周末两列弱化 */
td.cell.weekend .pick select:not(.is-empty){opacity:.94}
th.weekend{color:var(--brand-600)}
th.weekend small{color:var(--ink-300)}
/* 课间 / 午休:整行合并的空白分隔带 */
td.band{
text-align:center;font-size:12px;letter-spacing:3px;color:var(--c-fg);
background-color:var(--c-bg);
background-image:repeating-linear-gradient(135deg,rgba(255,255,255,.6) 0 7px,transparent 7px 14px);
border-top:1px solid var(--c-line);border-bottom:1px solid var(--c-line);
}
table.grid td.band{padding:12px var(--sp-3)}
tr.is-break td.col-slot .slot-name{color:var(--ink-400);font-weight:400}
/* 时间输入 */
td.col-time{min-width:112px}
.time-wrap{position:relative;display:flex;align-items:center;justify-content:center;gap:4px}
.time-wrap input{
width:96px;height:38px;padding:0 8px;text-align:center;border:1px solid var(--line);border-radius:var(--r-md);
background:#fbfcfe;font-variant-numeric:tabular-nums;transition:border-color var(--dur) var(--ease),box-shadow var(--dur) var(--ease);
}
.time-wrap input:hover{border-color:var(--brand-200)}
.time-wrap input:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-50);background:#fff}
.time-wrap input.invalid{border-color:var(--err);background:#fdf3f1}
td.col-op{width:56px}
.row-del{display:grid;place-items:center;width:30px;height:30px;border:1px solid transparent;border-radius:50%;background:transparent;color:var(--ink-300);transition:all var(--dur) var(--ease)}
.row-del:hover{color:var(--err);border-color:#f3cdc5;background:#fdf1ef}
.row-del:disabled{opacity:.3;cursor:not-allowed}
.row-del:disabled:hover{color:var(--ink-300);border-color:transparent;background:transparent}
.hintbar{display:flex;align-items:center;gap:var(--sp-2);flex-wrap:wrap;padding:var(--sp-3) var(--sp-5);border-top:1px solid var(--line-soft);background:var(--bg-subtle);border-radius:0 0 var(--r-xl) var(--r-xl);font-size:12px;color:var(--ink-400)}
.hintbar .hint-t{flex:0 0 auto}
.hintbar .rules{display:flex;align-items:center;gap:6px;flex-wrap:wrap;flex:1 1 auto;min-width:0}
.hintbar .rule{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border-radius:99px;background:var(--bg-surface);border:1px solid var(--line);white-space:nowrap}
.hintbar .rule i{width:7px;height:7px;border-radius:2px}
.hintbar .rule b{font-weight:500;color:var(--ink-600)}
.hintbar .rule .num{color:var(--ink-400)}
.hintbar .rule-note{font-size:11px;color:var(--ink-400);padding:3px 10px;border-radius:99px;border:1px dashed var(--ink-200);white-space:nowrap}
.btn-sm{height:28px;padding:0 12px;font-size:12px;flex:0 0 auto}
/* ============================================================
照片课表
============================================================ */
.photo-pane{padding:var(--sp-8) var(--sp-6) var(--sp-10);display:grid;place-items:center;gap:var(--sp-5)}
.dropzone{
position:relative;width:min(760px,100%);aspect-ratio:16/9;min-height:280px;
display:grid;place-items:center;border-radius:var(--r-xl);background:var(--bg-surface);
border:1.5px dashed var(--ink-200);cursor:pointer;overflow:hidden;
transition:border-color var(--dur) var(--ease),background var(--dur) var(--ease),box-shadow var(--dur) var(--ease);
}
.dropzone:hover{border-color:var(--brand-400);background:var(--brand-50)}
.dropzone.drag{border-color:var(--brand);background:var(--brand-50);box-shadow:inset 0 0 0 3px var(--brand-100)}
.dz-inner{display:grid;justify-items:center;gap:var(--sp-3);text-align:center;padding:var(--sp-6)}
.dz-icon{display:grid;place-items:center;width:64px;height:64px;border-radius:18px;background:linear-gradient(150deg,#eef1fd,#e2e8fb);color:var(--brand)}
.dz-title{font-size:16px;font-weight:500;color:var(--ink-900)}
.dz-sub{font-size:13px;color:var(--ink-400)}
.dz-formats{display:flex;gap:6px;justify-content:center;flex-wrap:wrap;margin-top:2px}
.dz-formats span{font-size:11px;color:var(--ink-400);padding:2px 8px;border:1px solid var(--line);border-radius:99px;background:var(--bg-subtle)}
.corner{position:absolute;width:22px;height:22px;border:2px solid var(--ink-200);opacity:.9}
.corner.tl{top:16px;left:16px;border-right:0;border-bottom:0;border-radius:6px 0 0 0}
.corner.tr{top:16px;right:16px;border-left:0;border-bottom:0;border-radius:0 6px 0 0}
.corner.bl{bottom:16px;left:16px;border-right:0;border-top:0;border-radius:0 0 0 6px}
.corner.br{bottom:16px;right:16px;border-left:0;border-top:0;border-radius:0 0 6px 0}
.photo-card{width:min(880px,100%);display:none;flex-direction:column;gap:var(--sp-3)}
.photo-card.is-shown{display:flex}
.photo-frame{
position:relative;border-radius:var(--r-lg);overflow:hidden;background:#0f1320;
border:1px solid var(--line);box-shadow:var(--sh-2);display:grid;place-items:center;
}
.photo-frame img{display:block;max-width:100%;max-height:56vh;object-fit:contain}
.photo-meta{display:flex;align-items:center;gap:var(--sp-3);flex-wrap:wrap;font-size:12px;color:var(--ink-400)}
.photo-meta .name{color:var(--ink-900);font-weight:500;font-size:13px}
.photo-meta .chip{padding:2px 8px;border-radius:99px;background:var(--bg-subtle);border:1px solid var(--line)}
.photo-actions{display:flex;gap:var(--sp-2);flex-wrap:wrap}
/* ============================================================
预览弹窗
============================================================ */
.overlay{
position:fixed;inset:0;z-index:80;display:none;place-items:center;padding:var(--sp-6);
background:rgba(28,33,48,.5);backdrop-filter:blur(3px);
}
.overlay.is-open{display:grid;animation:fade var(--dur) var(--ease)}
@keyframes fade{from{opacity:0}to{opacity:1}}
.modal{
width:min(1180px,100%);max-height:calc(100vh - 96px);display:flex;flex-direction:column;
background:var(--bg-surface);border-radius:var(--r-xl);box-shadow:var(--sh-3);overflow:hidden;
animation:pop 240ms var(--ease);
}
@keyframes pop{from{transform:translateY(10px) scale(.99);opacity:.6}to{transform:none;opacity:1}}
.modal-head{display:flex;align-items:center;gap:var(--sp-3);padding:var(--sp-4) var(--sp-5);border-bottom:1px solid var(--line)}
.modal-head h2{font-size:16px;font-weight:600;color:var(--ink-900)}
.modal-head .x{margin-left:auto;display:grid;place-items:center;width:32px;height:32px;border:1px solid var(--line);border-radius:var(--r-md);background:var(--bg-surface);color:var(--ink-600)}
.modal-head .x:hover{border-color:var(--brand-400);color:var(--brand-600);background:var(--brand-50)}
.modal-body{overflow:auto;padding:var(--sp-6);background:linear-gradient(180deg,#fff,#fbfcfe)}
.modal-foot{display:flex;justify-content:flex-end;gap:var(--sp-2);padding:var(--sp-4) var(--sp-5);border-top:1px solid var(--line);background:var(--bg-subtle)}
.modal-sm{width:min(640px,100%)}
.set-lead{font-size:13px;color:var(--ink-600);margin-bottom:var(--sp-4)}
.set-lead b{color:var(--ink-900);font-weight:600}
.set-table{width:100%;border-collapse:collapse}
.set-table td{padding:7px 8px;border-bottom:1px solid var(--line-soft);vertical-align:middle}
.set-table tr:last-child td{border-bottom:0}
.set-table .side{width:92px}
.set-table .side.r{text-align:right}
.set-table .mid{text-align:center;width:130px}
.set-table input{width:112px;height:34px;padding:0 8px;text-align:center;border:1px solid var(--ink-200);border-radius:var(--r-md);background:var(--bg-surface);font-variant-numeric:tabular-nums;transition:border-color var(--dur) var(--ease),box-shadow var(--dur) var(--ease)}
.set-table input:hover{border-color:var(--brand-400)}
.set-table input:focus{outline:none;border-color:var(--brand);box-shadow:0 0 0 3px var(--brand-50)}
.set-table tr.bad input{border-color:var(--err);background:#fdf3f1}
.set-table .desc{font-size:12px;color:var(--ink-400);text-align:left}
.ptag{display:inline-flex;align-items:center;gap:5px;padding:3px 9px;border-radius:99px;font-size:12px;font-weight:500;background:var(--c-bg);color:var(--c-fg);white-space:nowrap}
.ptag i{width:6px;height:6px;border-radius:50%;background:var(--c-ac)}
.set-note{margin-top:var(--sp-4);font-size:12px;color:var(--ink-400)}
.set-note.bad{color:var(--err)}
.sheet{width:100%;border-collapse:separate;border-spacing:5px;background:transparent}
.sheet caption{caption-side:top;text-align:left;padding:0 2px var(--sp-4)}
.sheet caption .t{font-size:19px;font-weight:600;color:var(--ink-900);letter-spacing:.5px}
.sheet caption .s{margin-left:var(--sp-3);font-size:12px;color:var(--ink-400)}
.sheet th{font-size:13px;font-weight:600;color:var(--ink-900);padding:10px 6px;background:#eef1f8;border-radius:var(--r-md)}
.sheet th.h-empty{background:linear-gradient(135deg,#eef1f8 0 50%,#fdf3d9 50% 100%);position:relative}
.sheet th.h-empty .a{position:absolute;left:8px;bottom:6px;color:var(--ink-900)}
.sheet th.h-empty .b{position:absolute;right:8px;top:6px;color:var(--am-fg)}
.sheet th.h-time{background:#fdf3d9;color:var(--am-fg)}
.sheet th.is-weekend{background:#e7ecfa;color:var(--brand-800)}
.sheet td{padding:10px 6px;text-align:center;font-size:13px;background:#fbfcfe;border-radius:var(--r-md);color:var(--ink-600)}
.sheet td.rail{background:var(--c-bg);color:var(--c-fg);font-weight:600;font-size:14px;letter-spacing:2px}
.sheet td.slot{background:#eef4f1;color:var(--ink-900);font-weight:500}
.sheet td.time{background:#f5f6f8;color:var(--ink-600);font-variant-numeric:tabular-nums;font-size:12.5px;border-radius:0 var(--r-md) var(--r-md) 0}
.sheet td.subject{background:var(--c-bg);color:var(--c-fg);font-weight:500;border:1px solid var(--c-line)}
.sheet td.blank{background:repeating-linear-gradient(135deg,#fbfcfe 0 6px,#f5f6f9 6px 12px);color:var(--ink-200)}
.sheet td.band{
background-color:var(--c-bg);
background-image:repeating-linear-gradient(135deg,rgba(255,255,255,.6) 0 7px,transparent 7px 14px);
border-top:1px solid var(--c-line);border-bottom:1px solid var(--c-line);
min-width:120px;height:34px;padding:9px 6px;border-radius:0;
}
.sheet tr.is-break td.slot{background:transparent;color:var(--ink-200)}
.sheet tr + tr td.rail{border-radius:var(--r-md) 0 0 var(--r-md)}
.photo-view{display:grid;place-items:center;background:#0f1320;border-radius:var(--r-lg);padding:var(--sp-4)}
.photo-view img{max-width:100%;max-height:calc(100vh - 260px);object-fit:contain;border-radius:var(--r-sm)}
.empty{display:grid;justify-items:center;gap:var(--sp-3);padding:var(--sp-10) var(--sp-6);color:var(--ink-400);text-align:center}
.empty .ico{display:grid;place-items:center;width:56px;height:56px;border-radius:16px;background:var(--brand-50);color:var(--brand-400)}
/* Toast */
.toast-host{position:fixed;left:50%;bottom:32px;transform:translateX(-50%);z-index:90;display:flex;flex-direction:column;gap:8px;align-items:center;pointer-events:none}
.toast{
display:flex;align-items:center;gap:8px;padding:9px 16px;border-radius:99px;font-size:13px;color:#fff;
background:rgba(34,38,46,.92);box-shadow:var(--sh-3);animation:toast 220ms var(--ease);
}
.toast.ok{background:rgba(46,120,80,.94)}
.toast.warn{background:rgba(180,120,20,.95)}
@keyframes toast{from{transform:translateY(8px);opacity:0}to{transform:none;opacity:1}}
@media (max-width:900px){
.pagehead{align-items:flex-start;flex-direction:column;gap:var(--sp-3)}
.card-tools{gap:var(--sp-2)}
.tabs button{padding:0 var(--sp-4)}
}
/* 打印:仅输出课表本体 */
@media print{
@page{size:A4 landscape;margin:12mm}
body{background:#fff;min-width:0}
.appbar,.pagehead,.card-tools,.hintbar,.modal-head,.modal-foot,.toast-host,.photo-actions,.photo-meta{display:none!important}
.overlay:not(.is-open){display:none!important}
.overlay.is-open{position:static;display:block;padding:0;background:none;backdrop-filter:none}
.modal{width:100%;max-height:none;box-shadow:none;border-radius:0}
.modal-body{overflow:visible;padding:0;background:#fff}
.sheet th,.sheet td{border-radius:0;-webkit-print-color-adjust:exact;print-color-adjust:exact}
}
</style>
</head>
<body>
<header class="appbar">
<div class="mark" aria-hidden="true">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="7" height="7" rx="1.5"/><rect x="14" y="4" width="7" height="7" rx="1.5"/><rect x="3" y="15" width="7" height="5" rx="1.5"/><rect x="14" y="15" width="7" height="5" rx="1.5"/></svg>
</div>
<div class="divider" aria-hidden="true"></div>
<div class="title">课程表</div>
<div class="spacer"></div>
<div class="school"><span class="crest">宁</span><span>大宁国际小学</span></div>
</header>
<div class="pagehead">
<div>
<h1>课程表编排</h1>
<p class="sub">按班级维护节次时段与每日学科,支持电子课表录入与照片课表上传。</p>
</div>
<div class="stats" id="stats"></div>
</div>
<main class="wrap">
<section class="card">
<div class="card-tools">
<div class="tabs" role="tablist" aria-label="课表形式">
<button role="tab" id="tab-electronic" aria-controls="pane-electronic" aria-selected="true" data-pane="electronic">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M4 6h16M4 12h16M4 18h10"/></svg>电子版
</button>
<button role="tab" id="tab-photo" aria-controls="pane-photo" aria-selected="false" data-pane="photo">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 8h3l1.5-2h7L17 8h3v11H4z"/><circle cx="12" cy="13" r="3"/></svg>照片版
</button>
</div>
<div class="field-inline" style="margin-left:auto">
<label for="classSel">班级</label>
<select class="select" id="classSel"></select>
</div>
<div id="tools-electronic" style="display:flex;gap:8px;align-items:center">
<button class="btn btn-ghost" id="btnAdd">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>新增节次
</button>
<button class="btn btn-ghost" id="btnReset">恢复默认节次</button>
<button class="btn btn-primary" id="btnPreview">预览课表</button>
</div>
<div id="tools-photo" style="display:none;gap:8px;align-items:center">
<button class="btn btn-ghost" id="btnPick">选择图片</button>
<button class="btn btn-primary" id="btnPhotoPreview">预览照片</button>
</div>
</div>
<!-- 电子版 -->
<div class="pane is-active" id="pane-electronic" role="tabpanel" aria-labelledby="tab-electronic">
<div class="table-scroll">
<table class="grid">
<caption class="sr-only">按节次编排周一至周五的学科与起止时间</caption>
<thead id="gridHead"></thead>
<tbody id="gridBody"></tbody>
</table>
</div>
<div class="hintbar">
<span class="hint-t">时段判定:</span>
<div class="rules" id="periodLegend"></div>
<span class="rule-note">课间 / 午休为空白行,不占用节次</span>
<button class="btn btn-ghost btn-sm" id="btnPeriods">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><circle cx="12" cy="12" r="3.2"/><path d="M12 3v2.4M12 18.6V21M4.2 7.5l2 1.2M17.8 15.3l2 1.2M4.2 16.5l2-1.2M17.8 8.7l2-1.2"/></svg>时段设置
</button>
<span style="margin-left:auto" id="dirtyFlag"></span>
</div>
</div>
<!-- 照片版 -->
<div class="pane" id="pane-photo" role="tabpanel" aria-labelledby="tab-photo" hidden>
<div class="photo-pane">
<div class="dropzone" id="dropzone" role="button" tabindex="0" aria-label="上传课程表照片,可拖拽文件到此处">
<span class="corner tl"></span><span class="corner tr"></span><span class="corner bl"></span><span class="corner br"></span>
<div class="dz-inner">
<div class="dz-icon">
<svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8h3.5L8 5.5h8L17.5 8H21v11H3z"/><circle cx="12" cy="13.5" r="3.5"/></svg>
</div>
<div class="dz-title">+ 上传课程表照片</div>
<div class="dz-sub">点击选择,或将图片拖拽到此处</div>
<div class="dz-formats"><span>JPG</span><span>PNG</span><span>WEBP</span><span>单张 ≤ 8 MB</span></div>
</div>
</div>
<div class="photo-card" id="photoCard">
<div class="photo-frame"><img id="photoImg" alt="课程表照片预览"></div>
<div class="photo-meta">
<span class="name" id="photoName"></span>
<span class="chip num" id="photoSize"></span>
<span class="chip" id="photoTime"></span>
</div>
<div class="photo-actions">
<button class="btn btn-ghost" id="btnReplace">替换图片</button>
<button class="btn btn-quiet" id="btnRemove">移除</button>
</div>
</div>
</div>
</div>
<input type="file" id="fileInput" accept="image/*" class="sr-only" tabindex="-1">
</section>
</main>
<!-- 预览弹窗 -->
<div class="overlay" id="overlay">
<div class="modal" role="dialog" aria-modal="true" aria-labelledby="modalTitle">
<div class="modal-head">
<h2 id="modalTitle">课程表预览</h2>
<button class="x" id="btnClose" aria-label="关闭预览">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="modal-body" id="modalBody"></div>
<div class="modal-foot">
<button class="btn btn-ghost" id="btnPrint">打印</button>
<button class="btn btn-primary" id="btnClose2">关闭</button>
</div>
</div>
</div>
<!-- 时段设置弹窗 -->
<div class="overlay" id="setOverlay">
<div class="modal modal-sm" role="dialog" aria-modal="true" aria-labelledby="setTitle">
<div class="modal-head">
<h2 id="setTitle">时段设置</h2>
<button class="x" id="btnSetClose" aria-label="关闭时段设置">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button>
</div>
<div class="modal-body">
<p class="set-lead">时段按节次的<b>开始时间</b>自动归类;<b>课间</b>与<b>午休</b>作为空白分隔行呈现,不占用节次。调整相邻时段的分界时间后,编辑区分组与课表预览同步更新。</p>
<table class="set-table"><tbody id="setRows"></tbody></table>
<p class="set-note" id="setNote"></p>
</div>
<div class="modal-foot">
<button class="btn btn-ghost" id="btnSetReset">恢复默认边界</button>
<button class="btn btn-primary" id="btnSetSave">保存设置</button>
</div>
</div>
</div>
<div class="toast-host" id="toastHost" aria-live="polite" aria-atomic="true"></div>
<script>
'use strict';
/* ============================================================
1. 基础数据
============================================================ */
const DAYS = ['星期一','星期二','星期三','星期四','星期五','星期六','星期日'];
const CN_NUM = ['一','二','三','四','五','六','七','八','九','十','十一','十二','十三','十四','十五','十六'];
/* 学科色板:底 / 边 / 字,均按 WCAG AA 正文对比度取色 */
const PALETTE = {
blue: ['#e6f1fb','#b5d4f4','#0c447c'],
teal: ['#e1f5ee','#9ad6bd','#085041'],
purple:['#eeedfe','#c4c0f4','#3c3489'],
amber: ['#faeeda','#f0cf9c','#633806'],
coral: ['#faece7','#f2c0ac','#712b13'],
green: ['#eaf3de','#c3dda2','#27500a'],
pink: ['#fbeaf0','#f2bccd','#72243e'],
cyan: ['#e4f4f8','#a9d8e4','#0b4c5c'],
indigo:['#e8ecfb','#bcc7f0','#2a3a7a'],
olive: ['#f1f0dc','#dcd9a8','#414413'],
plum: ['#f6e9f4','#e0bcd9','#6b2a5f'],
slate: ['#eef0f3','#c8cfd8','#31434f'],
rose: ['#fdecef','#f4c2cc','#8a2438'],
moss: ['#e7f1e8','#b3d3b7','#1f4a26']
};
const SUBJECTS = [
['语文','blue'],['数学','amber'],['英语','purple'],['道德与法治','teal'],
['科学','green'],['体育','coral'],['音乐','pink'],['美术','rose'],
['书法','plum'],['信息技术','cyan'],['劳动','olive'],['综合实践','moss'],
['心理健康','indigo'],['班会','slate'],['校本阅读','teal'],['作业辅导','blue'],
['早读','indigo']
];
const SUBJECT_MAP = new Map(SUBJECTS.map(([n,c])=>[n,c]));
/* 默认十行:八个节次 + 课间/午休两条空白分隔行 */
const DEFAULT_SLOTS = [
['07:40','08:10'],
['08:20','09:00'],['09:10','09:40'],
['09:50','10:20'],
['10:30','11:10'],['11:20','11:55'],
['12:30','14:00'],
['14:10','14:50'],['15:00','15:40'],
['18:30','19:10']
].map(([start,end])=>({ start, end, subjects: Array(7).fill('') }));
/* 示例课表:仅用于展示排布效果,任一单元格被编辑后即标记为正式数据 */
const SAMPLE_WEEK = [
['早读','早读','语文','早读','语文','—','—'],
['语文','数学','英语','语文','数学','科学','班会'],
['数学','语文','数学','英语','语文','美术','劳动'],
['—','—','—','—','—','—','—'],
['英语','体育','语文','数学','英语','音乐','道德与法治'],
['体育','英语','道德与法治','体育','语文','书法','综合实践'],
['—','—','—','—','—','—','—'],
['科学','美术','语文','科学','数学','—','—'],
['美术','音乐','数学','道德与法治','英语','—','—'],
['作业辅导','作业辅导','作业辅导','作业辅导','作业辅导','—','—']
];
function sampleSlots(){
return DEFAULT_SLOTS.map((s,i)=>({ start:s.start, end:s.end, subjects: SAMPLE_WEEK[i].map(x=>x==='—'?'':x) }));
}
const GRADES = ['一','二','三','四','五'];
const CLASSES = GRADES.flatMap(g => Array.from({length:8}, (_,i)=>`${g}${i+1}班`));
const SAMPLE_CLASS = '四7班'; // 预置示例课表的班级
/* 时段边界(可设置):决定六类时段的划分 */
const DEFAULT_PERIOD_TIMES = { erEnd:'08:00', exStart:'09:40', exEnd:'10:20', luStart:'11:30', pmStart:'14:00', evStart:'18:00' };
const PERIOD_TIME_LABELS = [
['erEnd','er','am'], ['exStart','am','ex'], ['exEnd','ex','am'],
['luStart','am','lu'], ['pmStart','lu','pm'], ['evStart','pm','ev']
];
const PERIODS = {
er:{ label:'早自习', cls:'period-er', color:'#4c6fd0', bg:'#eef4ff' },
am:{ label:'上午', cls:'period-am', color:'#d99a2b', bg:'#fff9ec' },
ex:{ label:'课间', cls:'period-ex', color:'#3f9e63', bg:'#eaf6ef' },
lu:{ label:'午休', cls:'period-lu', color:'#d97a4b', bg:'#fdf1ea' },
pm:{ label:'下午', cls:'period-pm', color:'#3d9db5', bg:'#eef7fb' },
ev:{ label:'晚自习', cls:'period-ev', color:'#7a63d8', bg:'#f3f0fe' },
none:{label:'未定时段', cls:'period-no', color:'#9aa0aa', bg:'#f4f5f7' }
};
function toMinutes(t){
const m = /^(\d{1,2}):(\d{2})$/.exec(t || '');
if(!m) return null;
const h = +m[1], mm = +m[2];
if(h > 23 || mm > 59) return null;
return h*60 + mm;
}
/* 时段判定:以开始时间落在哪个区间为准;边界非法时回退默认值 */
function periodTimes(){
const out = {};
for(const k in DEFAULT_PERIOD_TIMES){
const v = state.periods && toMinutes(state.periods[k]) !== null ? state.periods[k] : DEFAULT_PERIOD_TIMES[k];
out[k] = toMinutes(v);
}
return out;
}
function periodOf(start){
const m = toMinutes(start);
if(m === null) return null;
const P = periodTimes();
if(m < P.erEnd) return 'er';
if(m < P.exStart) return 'am';
if(m < P.exEnd) return 'ex';
if(m < P.luStart) return 'am';
if(m < P.pmStart) return 'lu';
if(m < P.evStart) return 'pm';
return 'ev';
}
/* 课间与午休不排课:编辑区与预览中作为空白分隔行呈现 */
const BREAK_PERIODS = new Set(['ex','lu']);
const isBreak = slot => BREAK_PERIODS.has(periodOf(slot.start));
/* 节次序号只按排课行累加,课间/午休空白行不占用节次 */
function slotNumbers(rows){
let n = 0;
return rows.map(r => isBreak(r) ? null : ++n);
}
const slotLabel = n => n ? `第${CN_NUM[n-1] || n}节` : '';
function rowLabel(rows, ri){
return slotLabel(slotNumbers(rows)[ri]) || PERIODS[periodOf(rows[ri].start) || 'none'].label;
}
/* 供图例与设置面板展示的连续区间;上午被课间切成前后两段 */
function periodRanges(){
const D = DEFAULT_PERIOD_TIMES, P = state.periods || D;
return [
['er','00:00',P.erEnd], ['am',P.erEnd,P.exStart], ['ex',P.exStart,P.exEnd],
['am',P.exEnd,P.luStart], ['lu',P.luStart,P.pmStart], ['pm',P.pmStart,P.evStart],
['ev',P.evStart,'24:00']
];
}
/* ============================================================
2. 状态与持久化(浏览器本地存储,按班级隔离)
============================================================ */
const STORE_KEY = 'acasyllabu.timetable.v1';
const state = {
activeClass: SAMPLE_CLASS,
tab: 'electronic',
periods: { ...DEFAULT_PERIOD_TIMES },
byClass: {}
};
function ensure(cls){
if(!state.byClass[cls]) state.byClass[cls] = cls === SAMPLE_CLASS
? { slots: sampleSlots(), photo: null, sample: true }
: { slots: clone(DEFAULT_SLOTS), photo: null, sample: false };
const d = state.byClass[cls];
if(!Array.isArray(d.slots) || !d.slots.length) d.slots = clone(DEFAULT_SLOTS);
d.slots.forEach(s => {
s.subjects = Array.isArray(s.subjects) ? s.subjects.slice(0,7) : [];
while(s.subjects.length < 7) s.subjects.push('');
});
return d;
}
const clone = v => JSON.parse(JSON.stringify(v));
/* 边界必须存在且严格递增,否则整组回退默认值,避免出现空时段 */
function validPeriods(p){
if(!p) return false;
let prev = -1;
for(const k of Object.keys(DEFAULT_PERIOD_TIMES)){
const m = toMinutes(p[k]);
if(m === null || m <= prev) return false;
prev = m;
}
return true;
}
function load(){
try{
const raw = localStorage.getItem(STORE_KEY);
if(!raw) return;
const saved = JSON.parse(raw);
if(saved && saved.byClass) state.byClass = saved.byClass;
if(saved && typeof saved.activeClass === 'string' && CLASSES.includes(saved.activeClass)) state.activeClass = saved.activeClass;
if(saved && (saved.tab === 'photo')) state.tab = saved.tab;
if(saved && validPeriods(saved.periods)) state.periods = { ...DEFAULT_PERIOD_TIMES, ...saved.periods };
}catch(e){ /* 存储不可用时按空状态运行 */ }
}
let saveTimer = null;
function persist(){
clearTimeout(saveTimer);
setDirty(true);
saveTimer = setTimeout(()=>{
try{
localStorage.setItem(STORE_KEY, JSON.stringify({ activeClass: state.activeClass, tab: state.tab, periods: state.periods, byClass: state.byClass }));
setDirty(false);
}catch(e){
toast('浏览器存储空间不足,课表未能保存','warn');
}
}, 400);
}
function setDirty(on){
const el = document.getElementById('dirtyFlag');
el.innerHTML = on
? '<span class="saved is-dirty"><span class="dot"></span>正在保存…</span>'
: '<span class="saved"><span class="dot"></span>已自动保存</span>';
}
/* ============================================================
3. DOM 引用
============================================================ */
const $ = id => document.getElementById(id);
const els = {
classSel: $('classSel'), head: $('gridHead'), body: $('gridBody'),
paneE: $('pane-electronic'), paneP: $('pane-photo'),
toolsE: $('tools-electronic'), toolsP: $('tools-photo'),
dropzone: $('dropzone'), fileInput: $('fileInput'),
photoCard: $('photoCard'), photoImg: $('photoImg'), photoName: $('photoName'),
photoSize: $('photoSize'), photoTime: $('photoTime'),
overlay: $('overlay'), modalBody: $('modalBody'), modalTitle: $('modalTitle'),
legend: $('periodLegend'), setOverlay: $('setOverlay'), setRows: $('setRows'), setNote: $('setNote'),
stats: $('stats'), toastHost: $('toastHost')
};
/* ============================================================
4. 渲染:班级 / 统计 / 编辑表
============================================================ */
function renderClasses(){
els.classSel.innerHTML = GRADES.map(g =>
`<optgroup label="${g}年级">${CLASSES.filter(c=>c.startsWith(g)).map(c=>`<option ${c===state.activeClass?'selected':''}>${c}</option>`).join('')}</optgroup>`
).join('');
}
function renderStats(){
const d = ensure(state.activeClass);
const filled = d.slots.filter(s=>!isBreak(s)).reduce((n,s)=> n + s.subjects.filter(Boolean).length, 0);
const periods = new Set(d.slots.filter(s=>!isBreak(s)).map(s=>periodOf(s.start)).filter(Boolean));
const photo = d.photo ? '已上传' : '未上传';
els.stats.innerHTML = [
['当前班级', state.activeClass],
['节次', slotNumbers(d.slots).filter(Boolean).length + ' 节'],
['已排课程', filled + ' 门'],
['覆盖时段', periods.size ? (periods.size > 3 ? periods.size + ' 类时段' : [...periods].map(p=>PERIODS[p].label).join(' · ')) : '待设定'],
['照片课表', photo]
].map(([k,v])=>`<div class="stat"><b>${esc(v)}</b><span>${k}</span></div>`).join('')
+ (d.sample ? `<div class="stat sample"><b>示例</b><span>${SAMPLE_CLASS}预置课表,编辑后转为正式数据</span></div>` : '');
}
/* 图例:由当前时段边界推导,上午被课间操分为前后两段,故出现两次 */
function renderLegend(){
els.legend.innerHTML = periodRanges().map(([k,from,to])=>{
const p = PERIODS[k];
const span = k === 'er' ? '&lt; ' + to : k === 'ev' ? '≥ ' + from : from + '–' + to;
return `<span class="rule"><i style="background:${p.color}"></i><b>${p.label}</b><span class="num">${span}</span></span>`;
}).join('');
}
function renderHead(){
els.head.innerHTML = `<tr>
<th class="col-slot">节次</th>
${DAYS.map((d,i)=>`<th class="${i>=5?'weekend':''}">${d}<small>${i>=5?'周末':'第'+(i+1)+'天'}</small></th>`).join('')}
<th class="col-time">开始时间</th><th class="col-time">结束时间</th><th class="col-op">操作</th>
</tr>`;
}
function subjectOptions(cur){
const opts = [`<option value="" ${cur?'':'selected'}>请选择</option>`]
.concat(SUBJECTS.map(([n])=>`<option value="${n}" ${n===cur?'selected':''}>${n}</option>`));
if(cur && !SUBJECT_MAP.has(cur)) opts.splice(1,0,`<option value="${esc(cur)}" selected>${esc(cur)}</option>`);
return opts.join('');
}
function renderGrid(){
const d = ensure(state.activeClass);
const rows = d.slots;
if(!rows.length){
els.body.innerHTML = `<tr><td colspan="11"><div class="empty">
<div class="ico"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18M8 2v4M16 2v4"/></svg></div>
<div><b style="color:var(--ink-700)">还没有节次</b><br>点击「新增节次」开始编排本班课程表。</div>
</div></td></tr>`;
return;
}
let html = '';
/* 按相邻同段分组:节次序号由排课行决定,课间/午休行只作分隔带、不插入分组行 */
const nums = slotNumbers(rows);
const runs = [];
rows.forEach((r, ri) => {
const p = periodOf(r.start);
const last = runs[runs.length-1];
if(last && last.p === p) last.n++;
else runs.push({ p, n:1, at:ri });
});
const runAt = new Map(runs.map(run => [run.at, run]));
rows.forEach((s, ri) => {
const brk = isBreak(s);
const meta = PERIODS[periodOf(s.start) || 'none'];
const run = runAt.get(ri);
if(run && !BREAK_PERIODS.has(run.p)){
html += `<tr class="period-row ${meta.cls}"><td colspan="11"><span class="tag"><i style="background:${meta.color}"></i>${meta.label}<span class="cnt">${run.n} 节</span></span></td></tr>`;
}
const invalid = s.start && s.end && toMinutes(s.end) <= toMinutes(s.start);
const label = slotLabel(nums[ri]);
html += `<tr data-row="${ri}" class="${brk?'is-break':''}">
<td class="col-slot"><div class="slot-name">${label}</div><div class="slot-time num">${s.start||'--:--'} – ${s.end||'--:--'}</div></td>
${brk
? `<td class="band" colspan="7" style="--c-bg:${meta.bg};--c-fg:${meta.color};--c-line:${meta.color}40">${meta.label} · 不排课</td>`
: s.subjects.map((sub,ci)=>{
const hue = SUBJECT_MAP.get(sub);
const [bg,line,fg] = hue ? PALETTE[hue] : [];
const style = hue ? `style="--c-bg:${bg};--c-line:${line};--c-fg:${fg}"` : '';
return `<td class="cell ${ci>=5?'weekend':''}"><span class="pick ${sub?'is-set':''}" ${style}>
<select data-row="${ri}" data-col="${ci}" aria-label="${DAYS[ci]} ${label} 学科">${subjectOptions(sub)}</select>
</span></td>`;
}).join('')}
<td class="col-time"><div class="time-wrap">
<input type="time" data-row="${ri}" data-field="start" value="${s.start||''}" class="${invalid?'invalid':''}" aria-label="开始时间">
</div></td>
<td class="col-time"><div class="time-wrap">
<input type="time" data-row="${ri}" data-field="end" value="${s.end||''}" class="${invalid?'invalid':''}" aria-label="结束时间">
</div></td>
<td class="col-op"><button class="row-del" data-del="${ri}" aria-label="删除${rowLabel(rows, ri)}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"/></svg>
</button></td>
</tr>`;
});
els.body.innerHTML = html;
}
/* ============================================================
5. 渲染:照片课表
============================================================ */
function renderPhoto(){
const d = ensure(state.activeClass);
const p = d.photo;
if(p){
els.dropzone.style.display = 'none';
els.photoCard.classList.add('is-shown');
els.photoImg.src = p.dataUrl;
els.photoImg.alt = `${state.activeClass} 课程表照片`;
els.photoName.textContent = p.name;
els.photoSize.textContent = formatSize(p.size);
els.photoTime.textContent = '上传于 ' + p.at;
}else{
els.dropzone.style.display = '';
els.photoCard.classList.remove('is-shown');
els.photoImg.removeAttribute('src');
}
}
function formatSize(b){
if(!b) return '—';
return b > 1024*1024 ? (b/1024/1024).toFixed(1) + ' MB' : Math.max(1, Math.round(b/1024)) + ' KB';
}
function handleFile(file){
if(!file) return;
if(!/^image\//.test(file.type)){ toast('仅支持图片格式(JPG / PNG / WEBP)','warn'); return; }
if(file.size > 8*1024*1024){ toast('图片超过 8 MB,请压缩后再上传','warn'); return; }
const reader = new FileReader();
reader.onload = () => {
const d = ensure(state.activeClass);
d.photo = { dataUrl: reader.result, name: file.name, size: file.size, at: stamp() };
persist(); renderPhoto(); renderStats(); toast('照片课表已上传','ok');
};
reader.onerror = () => toast('图片读取失败,请重试','warn');
reader.readAsDataURL(file);
}
function stamp(){
const t = new Date(), z = n => String(n).padStart(2,'0');
return `${t.getFullYear()}-${z(t.getMonth()+1)}-${z(t.getDate())} ${z(t.getHours())}:${z(t.getMinutes())}`;
}
/* ============================================================
6. 预览
============================================================ */
function buildPreviewTable(){
const d = ensure(state.activeClass);
const rows = d.slots.slice().sort((a,b)=>(toMinutes(a.start) ?? 9999) - (toMinutes(b.start) ?? 9999));
if(!rows.length){
return `<div class="empty"><div class="ico">
<svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><rect x="3" y="4" width="18" height="17" rx="2"/><path d="M3 9h18"/></svg>
</div><div>请先在「电子版」中编排节次与学科。</div></div>`;
}
const groups = [];
rows.forEach(r => {
const p = periodOf(r.start) || 'none';
const last = groups[groups.length-1];
if(last && last.key === p) last.items.push(r);
else groups.push({ key:p, items:[r] });
});
const idx = new Map(rows.map((r,i)=>[r,i]));
const nums = slotNumbers(rows);
let body = '';
groups.forEach(g => {
const meta = PERIODS[g.key];
const brk = BREAK_PERIODS.has(g.key);
g.items.forEach((r, i) => {
const rail = i === 0
? `<td class="rail" rowspan="${g.items.length}" style="--c-bg:${meta.bg};--c-fg:${meta.color}">${meta.label}</td>` : '';
const cells = brk
? `<td class="band" colspan="7" style="--c-bg:${meta.bg};--c-line:${meta.color}33"></td>`
: r.subjects.map((sub,ci)=>{
if(!sub) return `<td class="blank">—</td>`;
const hue = SUBJECT_MAP.get(sub);
const [bg,line,fg] = hue ? PALETTE[hue] : ['#fbfcfe','var(--line)','var(--ink-600)'];
return `<td class="subject" style="--c-bg:${bg};--c-line:${line};--c-fg:${fg}">${esc(sub)}</td>`;
}).join('');
body += `<tr class="${brk?'is-break':''}">${rail}<td class="slot">${slotLabel(nums[idx.get(r)])}</td>${cells}<td class="time">${r.start||'--:--'} – ${r.end||'--:--'}</td></tr>`;
});
});
return `<table class="sheet">
<caption><span class="t">${esc(state.activeClass)} 课程表</span><span class="s">大宁国际小学 · 生成时间 ${stamp()}</span></caption>
<thead><tr>
<th class="h-empty" style="min-width:52px"><span class="a">时段</span><span class="b">星期</span></th>
<th style="min-width:70px">节次</th>
${DAYS.map((x,i)=>`<th class="${i>=5?'is-weekend':''}">${x}</th>`).join('')}
<th class="h-time" style="min-width:118px">时间</th>
</tr></thead>
<tbody>${body}</tbody>
</table>`;
}
function openPreview(){
if(state.tab === 'photo'){
const p = ensure(state.activeClass).photo;
els.modalTitle.textContent = '课程表照片预览';
els.modalBody.innerHTML = p
? `<div class="photo-view"><img src="${p.dataUrl}" alt="${esc(state.activeClass)} 课程表照片"></div>
<p style="margin-top:12px;font-size:12px;color:var(--ink-400);text-align:center">${esc(state.activeClass)} · ${esc(p.name)} · ${formatSize(p.size)}</p>`
: `<div class="empty"><div class="ico"><svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"><path d="M4 8h3.5L9 5.5h8L18.5 8H21v11H4z"/><circle cx="13" cy="13.5" r="3.5"/></svg></div><div>尚未上传课程表照片。</div></div>`;
}else{
els.modalTitle.textContent = '课程表预览';
els.modalBody.innerHTML = buildPreviewTable();
}
els.overlay.classList.add('is-open');
$('btnClose').focus();
}
function closePreview(){
els.overlay.classList.remove('is-open');
els.modalBody.innerHTML = '';
}
/* ============================================================
6b. 时段设置
============================================================ */
let periodDraft = null;
const ptag = k => `<span class="ptag" style="--c-bg:${PERIODS[k].bg};--c-fg:${PERIODS[k].color};--c-ac:${PERIODS[k].color}">${PERIODS[k].label}</span>`;
function openSettings(){
periodDraft = { ...state.periods };
els.setRows.innerHTML = PERIOD_TIME_LABELS.map(([key,l,r])=>`<tr data-key="${key}">
<td class="side r">${ptag(l)}</td>
<td class="mid"><input type="time" data-key="${key}" value="${periodDraft[key]||''}" aria-label="${PERIODS[l].label} 与 ${PERIODS[r].label} 的分界时间"></td>
<td class="side">${ptag(r)}</td>
<td class="desc">${PERIODS[l].label}止于该时间,${PERIODS[r].label}由此开始</td>
</tr>`).join('');
markPeriodDraft();
els.setOverlay.classList.add('is-open');
const first = els.setRows.querySelector('input');
if(first) first.focus();
}
function closeSettings(){ els.setOverlay.classList.remove('is-open'); periodDraft = null; }
/* 与相邻边界不满足严格递增的输入框逐行标红 */
function markPeriodDraft(){
const keys = PERIOD_TIME_LABELS.map(x=>x[0]);
const vals = keys.map(k=>toMinutes(periodDraft[k]));
[...els.setRows.children].forEach((tr,i)=>{
const v = vals[i];
const bad = v === null
|| (i > 0 && vals[i-1] !== null && v <= vals[i-1])
|| (i < vals.length-1 && vals[i+1] !== null && v >= vals[i+1]);
tr.classList.toggle('bad', bad);
});
const ok = validPeriods(periodDraft);
$('btnSetSave').disabled = !ok;
els.setNote.className = 'set-note' + (ok ? '' : ' bad');
els.setNote.textContent = ok
? '保存后全部节次按新边界重新归类;未填写开始时间的节次显示为「未定时段」。'
: '分界时间不能为空,且需自上而下严格递增,请修正标红行。';
return ok;
}
function saveSettings(){
if(!markPeriodDraft()) return;
state.periods = { ...periodDraft };
persist(); renderLegend(); renderGrid(); renderStats();
closeSettings();
toast('时段边界已更新','ok');
}
/* ============================================================
7. 交互绑定
============================================================ */
function switchTab(tab){
state.tab = tab;
document.querySelectorAll('[role="tab"]').forEach(b => b.setAttribute('aria-selected', String(b.dataset.pane === tab)));
const isE = tab === 'electronic';
els.paneE.classList.toggle('is-active', isE); els.paneE.hidden = !isE;
els.paneP.classList.toggle('is-active', !isE); els.paneP.hidden = isE;
els.toolsE.style.display = isE ? 'flex' : 'none';
els.toolsP.style.display = isE ? 'none' : 'flex';
persist();
}
function bind(){
els.classSel.addEventListener('change', e => {
state.activeClass = e.target.value;
persist(); renderGrid(); renderPhoto(); renderStats();
toast(`已切换到 ${state.activeClass}`);
});
document.querySelectorAll('[role="tab"]').forEach(b => b.addEventListener('click', () => switchTab(b.dataset.pane)));
/* 学科选择 */
els.body.addEventListener('change', e => {
const t = e.target;
if(t.matches('select[data-row]')){
const d = ensure(state.activeClass);
d.slots[+t.dataset.row].subjects[+t.dataset.col] = t.value;
d.sample = false;
persist(); renderStats();
const cell = t.closest('.pick');
const hue = SUBJECT_MAP.get(t.value);
if(hue){
const [bg,line,fg] = PALETTE[hue];
cell.classList.add('is-set');
cell.style.setProperty('--c-bg',bg); cell.style.setProperty('--c-line',line); cell.style.setProperty('--c-fg',fg);
t.classList.remove('is-empty');
}else{
cell.classList.remove('is-set'); cell.removeAttribute('style');
t.classList.add('is-empty');
}
return;
}
if(t.matches('input[type="time"]')){
const d = ensure(state.activeClass);
const row = d.slots[+t.dataset.row];
const at = { row: t.dataset.row, field: t.dataset.field };
row[t.dataset.field] = t.value;
d.sample = false;
persist(); renderGrid(); renderStats();
/* 时段分组随时间变化,需重建表格;重建后把焦点交还给原输入框 */
const next = els.body.querySelector(`input[data-row="${at.row}"][data-field="${at.field}"]`);
if(next) next.focus();
}
});
els.body.addEventListener('click', e => {
const btn = e.target.closest('[data-del]');
if(!btn) return;
const d = ensure(state.activeClass);
const ri = +btn.dataset.del;
const name = rowLabel(d.slots, ri);
if(d.slots[ri].subjects.some(Boolean) && !confirm(`删除「${name}」将同时清除该节次的课程安排,确定继续?`)) return;
d.slots.splice(ri,1);
d.sample = false;
persist(); renderGrid(); renderStats(); toast(`已删除 ${name}`);
});
$('btnAdd').addEventListener('click', () => {
const d = ensure(state.activeClass);
const last = d.slots[d.slots.length-1];
const start = last && toMinutes(last.end) !== null ? fromMinutes(toMinutes(last.end)+10) : '';
const end = start && toMinutes(start) !== null ? fromMinutes(toMinutes(start)+40) : '';
d.slots.push({ start, end, subjects: Array(7).fill('') });
d.sample = false;
persist(); renderGrid(); renderStats();
els.body.lastElementChild.scrollIntoView({ block:'end', behavior:'smooth' });
toast('已新增节次');
});
$('btnReset').addEventListener('click', () => {
if(!confirm('恢复为默认十个节次?本班已录入的学科与时间将被清空。')) return;
const d = ensure(state.activeClass);
d.slots = clone(DEFAULT_SLOTS); d.sample = false;
persist(); renderGrid(); renderStats(); toast('已恢复默认节次','ok');
});
$('btnPreview').addEventListener('click', openPreview);
$('btnPhotoPreview').addEventListener('click', openPreview);
/* 上传 */
$('btnPick').addEventListener('click', () => els.fileInput.click());
els.dropzone.addEventListener('click', () => els.fileInput.click());
els.dropzone.addEventListener('keydown', e => {
if(e.key === 'Enter' || e.key === ' '){ e.preventDefault(); els.fileInput.click(); }
});
els.fileInput.addEventListener('change', e => { handleFile(e.target.files[0]); e.target.value = ''; });
['dragenter','dragover'].forEach(ev => els.dropzone.addEventListener(ev, e => { e.preventDefault(); els.dropzone.classList.add('drag'); }));
['dragleave','drop'].forEach(ev => els.dropzone.addEventListener(ev, e => { e.preventDefault(); els.dropzone.classList.remove('drag'); }));
els.dropzone.addEventListener('drop', e => handleFile(e.dataTransfer.files[0]));
$('btnReplace').addEventListener('click', () => els.fileInput.click());
$('btnRemove').addEventListener('click', () => {
if(!confirm('移除本班课程表照片?')) return;
ensure(state.activeClass).photo = null;
persist(); renderPhoto(); renderStats(); toast('已移除照片');
});
/* 弹窗 */
$('btnClose').addEventListener('click', closePreview);
$('btnClose2').addEventListener('click', closePreview);
$('btnPrint').addEventListener('click', () => window.print());
els.overlay.addEventListener('mousedown', e => { if(e.target === els.overlay) closePreview(); });
/* 时段设置 */
$('btnPeriods').addEventListener('click', openSettings);
$('btnSetClose').addEventListener('click', closeSettings);
$('btnSetSave').addEventListener('click', saveSettings);
$('btnSetReset').addEventListener('click', () => {
periodDraft = { ...DEFAULT_PERIOD_TIMES };
[...els.setRows.children].forEach(tr => { tr.querySelector('input').value = periodDraft[tr.dataset.key]; });
markPeriodDraft();
});
els.setRows.addEventListener('change', e => {
if(!e.target.matches('input[data-key]')) return;
periodDraft[e.target.dataset.key] = e.target.value;
markPeriodDraft();
});
els.setOverlay.addEventListener('mousedown', e => { if(e.target === els.setOverlay) closeSettings(); });
document.addEventListener('keydown', e => {
if(e.key !== 'Escape') return;
if(els.setOverlay.classList.contains('is-open')) closeSettings();
else if(els.overlay.classList.contains('is-open')) closePreview();
});
}
function fromMinutes(m){
const z = n => String(n).padStart(2,'0');
return `${z(Math.floor(m/60)%24)}:${z(m%60)}`;
}
function esc(s){ return String(s).replace(/[&<>"]/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c])); }
let toastTimer = null;
function toast(msg, kind){
clearTimeout(toastTimer);
els.toastHost.innerHTML = `<div class="toast ${kind||''}">${esc(msg)}</div>`;
toastTimer = setTimeout(() => { els.toastHost.innerHTML = ''; }, 2400);
}
/* ============================================================
8. 启动
============================================================ */
load();
renderClasses();
renderHead();
renderLegend();
renderGrid();
renderPhoto();
renderStats();
setDirty(false);
switchTab(state.tab);
bind();
</script>
</body>
</html>