1146 lines
58 KiB
HTML
1146 lines
58 KiB
HTML
<!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' ? '< ' + 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 => ({'&':'&','<':'<','>':'>','"':'"'}[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>
|