BA-DUM!
▶ 開始
點畫面、→ 或翻頁器:下一格|←:上一格|A:切換自動播放|G:目錄|F:全螢幕
今天上課用電子白板傳遞訊息
課堂上的連結、提示詞與通知都會貼在白板上。請用手機掃描 QR code,或點這裡打開白板:
live-board-mvp.vercel.app/?slug=5353
TA-DA!
逐格
目錄
下載
1×
<!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>客戶計畫表新舊版比對器</title> <style> :root{ --bg:#f4f6f3; --surface:#ffffff; --surface-2:#eaf0ec; --line:#cfd9d3; --ink:#16221d; --ink-2:#4a5a52; --accent:#0b6b4f; --accent-ink:#ffffff; --copper:#a8601f; --ok:#1f7a45; --ok-bg:#e3f3e8; --warn:#8a5900; --warn-bg:#fdf1d8; --bad:#b3261e; --bad-bg:#fbe4e1; --info:#1d5f9e; --info-bg:#e3eefa; --focus:#c27a2c; --sans:"Microsoft JhengHei","PingFang TC","Noto Sans TC","Heiti TC",sans-serif; --mono:"Cascadia Mono",Consolas,"SF Mono",ui-monospace,monospace; } @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } } :root[data-theme="dark"]{ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } *{box-sizing:border-box} html{-webkit-text-size-adjust:100%;text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.65 var(--sans)} .wrap{max-width:calc(1180px + 32px);margin-inline:auto;padding-inline:16px} .vh{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} :focus-visible{outline:3px solid var(--focus);outline-offset:2px} .mono{font-family:var(--mono);font-variant-numeric:tabular-nums} /* 頁首 */ .site{background:var(--surface)} .head{display:flex;align-items:flex-start;justify-content:space-between;gap:12px 24px;padding-block:22px 18px;flex-wrap:wrap} .head h1{margin:0;font-size:27px;line-height:1.3;font-weight:700;letter-spacing:.02em} .head .lead{margin:6px 0 0;color:var(--ink-2);max-width:46em} .badge{flex:none;display:inline-block;margin-top:4px;padding:3px 12px;border:1px solid var(--copper);border-radius:999px;color:var(--copper);font-size:14px;font-weight:700;white-space:nowrap} .trace{position:relative;height:3px;background:var(--accent)} .trace::before{content:"";position:absolute;left:0;top:0;width:48px;height:3px;background:var(--copper)} /* 區塊 */ main{padding-block:4px 32px} .card{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:20px 22px 22px;margin-top:20px;min-width:0} .card h2{margin:0;font-size:21px;line-height:1.35;display:flex;align-items:baseline;gap:.55em} .step{font-family:var(--mono);font-weight:700;color:var(--copper)} .desc{margin:6px 0 16px;color:var(--ink-2)} .meta{color:var(--ink-2);font-size:15px} /* 輸入 */ .panes{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:18px} .pane{min-width:0} .pane-title{display:block;font-weight:700;font-size:18px;margin-bottom:6px} .pane-title small{font-size:15px;font-weight:400;color:var(--ink-2);margin-left:.5em} textarea{display:block;width:100%;min-height:250px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface);color:var(--ink);font:14px/1.6 var(--mono);font-variant-numeric:tabular-nums;tab-size:12;-moz-tab-size:12;white-space:pre;overflow:auto;resize:vertical} .pane-foot{display:flex;align-items:center;flex-wrap:wrap;gap:8px 14px;margin-top:10px} .actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:18px} .btn{display:inline-flex;align-items:center;justify-content:center;min-height:44px;padding:8px 18px;border-radius:8px;border:1px solid var(--accent);font:700 16px/1.3 var(--sans);cursor:pointer;text-align:center} .btn.primary{background:var(--accent);color:var(--accent-ink)} .btn.secondary{background:var(--surface);color:var(--accent)} .btn[disabled]{opacity:.45;cursor:not-allowed} .vh:focus-visible + label.btn{outline:3px solid var(--focus);outline-offset:2px} .msg{min-height:1.65em;margin:12px 0 0;font-weight:600} .msg:empty{min-height:0;margin-top:4px} .msg.ok{color:var(--ok)} .msg.warn{color:var(--warn)} .msg.bad{color:var(--bad)} /* 比對依據 */ fieldset{border:0;margin:0;padding:0;min-width:0} .chips{display:flex;flex-wrap:wrap;gap:8px} .chip{display:inline-flex;align-items:center;gap:8px;min-height:44px;padding:6px 14px;border:1px solid var(--line);border-radius:8px;background:var(--surface);cursor:pointer;user-select:none} .chip input{width:20px;height:20px;margin:0;accent-color:var(--accent);cursor:pointer} .chip:has(input:checked){border-color:var(--accent);background:var(--surface-2);font-weight:700} .chip .mono{font-size:15px} .colinfo{margin-top:16px;padding:12px 16px;border-radius:8px;background:var(--info-bg);color:var(--ink)} .colinfo p{margin:4px 0} .colinfo .k{display:inline-block;min-width:9.5em;font-weight:700;color:var(--info)} .colinfo code{font-family:var(--mono);font-size:15px;padding:1px 8px;margin:2px 4px 2px 0;border:1px solid var(--line);border-radius:6px;background:var(--surface);display:inline-block} .notice{margin-top:12px;padding:10px 16px;border-radius:8px;background:var(--warn-bg);color:var(--ink)} .notice p{margin:2px 0} .notice b{color:var(--warn)} /* 結果 */ .summary{margin:8px 0 14px} .stats{display:grid;grid-template-columns:repeat(5,minmax(0,1fr));gap:10px} .stat{--c:var(--ink-2);--cb:var(--surface-2);display:flex;flex-direction:column;align-items:flex-start;gap:2px;min-height:78px;padding:10px 14px 10px;border:1px solid var(--line);border-top:5px solid var(--c);border-radius:8px;background:var(--surface);color:var(--ink);font-family:var(--sans);text-align:left;cursor:pointer} @media (hover:hover){ textarea:hover{border-color:var(--ink-2)} .btn.primary:hover{filter:brightness(1.08)} .btn.secondary:hover{background:var(--surface-2)} .stat[aria-pressed="false"]:hover{background:var(--surface-2)} } .stat .l{font-size:16px;font-weight:700;line-height:1.35} .stat .n{font:700 30px/1.1 var(--mono);font-variant-numeric:tabular-nums;color:var(--c)} .stat[aria-pressed="true"]{background:var(--cb);border-color:var(--c);border-width:2px 2px 2px;border-top-width:5px} .stat[aria-pressed="true"] .l::after{content:"・篩選中";font-weight:400;color:var(--ink-2)} .c-ok{--c:var(--ok);--cb:var(--ok-bg)} .c-bad{--c:var(--bad);--cb:var(--bad-bg)} .c-info{--c:var(--info);--cb:var(--info-bg)} .c-warn{--c:var(--warn);--cb:var(--warn-bg)} .c-neutral{--c:var(--ink-2);--cb:var(--surface-2)} .filter-note{margin:10px 0 12px} .scroll-hint{display:none;margin:0 0 8px;font-size:15px;color:var(--ink-2)} .tbl-wrap{overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)} table{border-collapse:collapse;width:100%;font-size:15px;line-height:1.5} th,td{padding:8px 10px;border-bottom:1px solid var(--line);vertical-align:top;text-align:left} thead th{background:var(--surface-2);font-weight:700;white-space:nowrap;vertical-align:bottom} th.num,td.num{text-align:right;font-family:var(--mono);font-variant-numeric:tabular-nums;white-space:nowrap} thead th.num{font-family:var(--mono)} th .yr{display:block;font-size:12px;font-weight:400;color:var(--ink-2);line-height:1.2} tbody.it:last-of-type tr:last-child td{border-bottom:0} td.c-st{width:1%;min-width:11.5em;border-left:5px solid var(--c)} td.c-pn{min-width:11em} .pn{font-family:var(--mono);font-weight:700;white-space:nowrap} .nm{color:var(--ink-2);font-size:14px} .src{font-size:14px;color:var(--warn);font-weight:700} .tag{display:inline-block;padding:1px 10px;border-radius:999px;font-size:15px;font-weight:700;line-height:1.6;white-space:nowrap;background:var(--cb);color:var(--c)} .st-note{display:block;margin-top:4px;font-size:14px;color:var(--ink-2)} .shift{margin-top:6px;padding:4px 8px;border-left:3px solid var(--copper);background:var(--surface-2);font-size:14px;font-weight:700;color:var(--ink);white-space:normal} .shift .nw{display:inline-block;white-space:nowrap;font-family:var(--mono);font-variant-numeric:tabular-nums} td.chg{background:var(--warn-bg);color:var(--ink)} td.chg .ov{color:var(--ink-2)} td.chg .arrow{color:var(--ink-2);padding-inline:.35em;font-family:var(--sans)} td.chg .nv{font-weight:700} td.chg .d{display:block;font-weight:700} .up{color:var(--ok)} .down{color:var(--bad)} .zero{color:var(--ink-2);opacity:.75} tbody.cat-del td.num,tbody.cat-same td.num{color:var(--ink-2)} tbody.cat-del .pn{text-decoration:line-through;text-decoration-thickness:1px} .txtchg{background:var(--warn-bg);padding:0 4px;border-radius:4px} .empty{padding:28px 16px;text-align:center;color:var(--ink-2)} .legend{margin:10px 0 0;font-size:15px;color:var(--ink-2)} .legend .sw{display:inline-block;width:1.1em;height:1.1em;vertical-align:-.18em;margin-right:.3em;border:1px solid var(--line);background:var(--warn-bg)} .export{margin-top:22px;padding-top:18px;border-top:1px solid var(--line)} .export h3{margin:0;font-size:18px} .export .desc{margin-bottom:12px} .export .actions{margin-top:0} .fallback{margin-top:12px} .fallback textarea{min-height:140px} details{margin-top:14px} summary{cursor:pointer;font-weight:700;color:var(--accent);min-height:44px;display:flex;align-items:center;gap:.5em;list-style:none} summary::-webkit-details-marker{display:none} summary::before{content:"▸";display:inline-block;width:1em;text-align:center} details[open] summary::before{content:"▾"} details .tbl-wrap{margin-top:6px} details td.mono{font-family:var(--mono);white-space:nowrap} footer{border-top:1px solid var(--line);color:var(--ink-2);font-size:15px;padding-block:18px 28px;text-align:center} footer p{margin:2px 0} @media (max-width:860px){ .stats{grid-template-columns:repeat(3,minmax(0,1fr))} } @media (max-width:760px){ .panes{grid-template-columns:minmax(0,1fr)} textarea{min-height:190px} .head h1{font-size:24px} .card{padding:16px 14px 18px} .stats{grid-template-columns:repeat(2,minmax(0,1fr))} .colinfo .k{display:block;min-width:0} .scroll-hint{display:block} td.c-st{min-width:9.5em} } @media (prefers-reduced-motion: reduce){ *,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important} } </style> </head> <body> <header class="site"> <div class="wrap head"> <div> <h1>客戶計畫表新舊版比對器</h1> <p class="lead">把客戶寄來的舊版和新版需求計畫表(Forecast)貼進來,馬上看出哪些料號、哪幾週的數量改了。</p> </div> <span class="badge">練習資料皆為虛構</span> </div> <div class="trace" aria-hidden="true"></div> </header> <main class="wrap"> <section class="card" aria-labelledby="h-input"> <h2 id="h-input"><span class="step">1</span>貼上舊版與新版</h2> <p class="desc">在 Excel 選取整塊表格(第一列要是欄位名稱)複製,貼到下面的框裡;也可以選擇 CSV 檔。目前放的是虛構客戶 NorthPeak 的 8 週需求計畫範例。</p> <div class="panes"> <div class="pane"> <label for="taOld" class="pane-title">舊版<small>上一次收到的計畫表</small></label> <textarea id="taOld" wrap="off" spellcheck="false" autocomplete="off"></textarea> <div class="pane-foot"> <input type="file" id="fileOld" class="vh" accept=".csv,.tsv,.txt,text/csv,text/plain"> <label for="fileOld" class="btn secondary">選擇舊版 CSV 檔</label> <span class="meta" id="metaOld"></span> </div> </div> <div class="pane"> <label for="taNew" class="pane-title">新版<small>這一次收到的計畫表</small></label> <textarea id="taNew" wrap="off" spellcheck="false" autocomplete="off"></textarea> <div class="pane-foot"> <input type="file" id="fileNew" class="vh" accept=".csv,.tsv,.txt,text/csv,text/plain"> <label for="fileNew" class="btn secondary">選擇新版 CSV 檔</label> <span class="meta" id="metaNew"></span> </div> </div> </div> <div class="actions"> <button type="button" class="btn primary" id="btnCompare">比對兩個版本</button> <button type="button" class="btn secondary" id="btnClear">清除範例</button> <button type="button" class="btn secondary" id="btnSample">重新載入範例</button> </div> <p class="msg" id="msgInput" role="status" aria-live="polite"></p> </section> <section class="card" aria-labelledby="h-key"> <h2 id="h-key"><span class="step">2</span>選擇比對依據</h2> <p class="desc">勾選用哪幾欄判斷「是不是同一個料號」,可以多選。一般只要勾「料號」;同一個料號分不同交貨地點時,可以再加勾交貨地點那一欄。</p> <fieldset> <legend class="vh">比對依據欄位(可多選)</legend> <div id="keyList" class="chips"></div> </fieldset> <div id="colInfo"></div> <div id="parseNotes"></div> </section> <section class="card" aria-labelledby="h-result"> <h2 id="h-result"><span class="step">3</span>比對結果</h2> <p class="summary" id="summary"></p> <div id="resultBody"> <div class="stats" id="stats" role="group" aria-label="依比對結果篩選"></div> <p class="filter-note meta" id="filterNote"></p> <p class="scroll-hint">表格可以左右滑動,查看各週數量。</p> <div class="tbl-wrap" id="tblWrap" tabindex="0" role="region" aria-label="比對結果表格(可以左右捲動)"> <table id="tbl"></table> </div> <p class="legend"><span class="sw" aria-hidden="true"></span>黃底格子表示這一格有變動,上面是「舊值 → 新值」,下面是增減:<b class="up">+ 綠色是增加</b>,<b class="down">- 紅色是減少</b>。數量欄的空白當作 0 計算。</p> <div class="export"> <h3>匯出差異清單</h3> <p class="desc">每一格變動列成一筆,包含新增、刪除、有變動與重複的料號(不含沒有變動的料號)。欄位是:料號、品名、欄位、舊值、新值、增減、變動類型。</p> <div class="actions"> <button type="button" class="btn primary" id="btnCsv">下載差異清單(CSV)</button> <button type="button" class="btn secondary" id="btnCopy">複製結果</button> </div> <p class="msg" id="msgExport" role="status" aria-live="polite"></p> <div class="fallback" id="fallback" hidden> <label for="taFallback" class="pane-title">複製用文字<small>已經全部選取,請按 Ctrl+C 複製</small></label> <textarea id="taFallback" wrap="off" readonly></textarea> </div> <details id="diffDetails"> <summary id="diffSummary">查看差異清單內容</summary> <div class="tbl-wrap" tabindex="0" role="region" aria-label="差異清單內容"><table id="diffTbl"></table></div> </details> </div> </div> </section> </main> <footer> <div class="wrap"> <p>資料只在這台電腦的瀏覽器裡計算,不會上傳。</p> <p>元智大學終身教育部|眼見為憑:主管的 AI 工作流程實作課|練習工具</p> </div> </footer> <script> (function(){ 'use strict'; /* ---------- 小工具 ---------- */ var $ = function(s){ return document.querySelector(s); }; function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); } function say(el, text, kind){ el.textContent = text || ''; el.className = 'msg' + (kind ? ' ' + kind : ''); } function pad2(n){ return String(n).padStart(2, '0'); } /* ---------- 解析貼上的表格 ---------- */ function normText(s){ return String(s == null ? '' : s).replace(/[ ]/g, ' ').trim(); } // 依分隔符號切出每一筆資料,支援雙引號包起來(內含逗號、換行、"")的欄位;記錄每筆從第幾行開始 function splitRecords(text, delim){ var recs = [], row = [], cell = '', inQ = false, line = 1, recLine = 1; for (var i = 0; i < text.length; i++){ var ch = text[i]; if (inQ){ if (ch === '"'){ if (text[i + 1] === '"'){ cell += '"'; i++; } else inQ = false; continue; } if (ch === '\n') line++; cell += ch; continue; } if (ch === '"' && cell.trim() === ''){ inQ = true; cell = ''; continue; } if (ch === delim){ row.push(cell); cell = ''; continue; } if (ch === '\n'){ row.push(cell); recs.push({cells: row, line: recLine}); row = []; cell = ''; line++; recLine = line; continue; } cell += ch; } if (cell !== '' || row.length){ row.push(cell); recs.push({cells: row, line: recLine}); } return recs; } function parseTable(raw){ var res = {headers: [], rows: [], delim: '', notes: [], empty: true}; var text = String(raw || '').replace(/^/, '').replace(/\r\n?/g, '\n'); if (!text.trim()) return res; var first = text.split('\n').find(function(l){ return l.trim() !== ''; }) || ''; var delim = first.indexOf('\t') >= 0 ? '\t' : first.indexOf(',') >= 0 ? ',' : first.indexOf(';') >= 0 ? ';' : '\t'; res.delim = delim; var recs = splitRecords(text, delim) .map(function(r){ return {line: r.line, cells: r.cells.map(normText)}; }) .filter(function(r){ return r.cells.some(function(c){ return c !== ''; }); }); if (!recs.length) return res; res.empty = false; var width = Math.max.apply(null, recs.map(function(r){ return r.cells.length; })); var hdr = recs[0].cells, keep = []; for (var c = 0; c < width; c++){ var h = hdr[c] || ''; var used = recs.slice(1).some(function(r){ return (r.cells[c] || '') !== ''; }); if (h !== '' || used) keep.push(c); } var seen = {}; res.headers = keep.map(function(c, idx){ var h = hdr[c] || ('未命名欄 ' + (idx + 1)); if (seen[h]){ seen[h]++; var nh = h + '(' + seen[h] + ')'; res.notes.push('欄位名稱「' + h + '」出現不只一次,第 ' + seen[h] + ' 個改稱「' + nh + '」。'); seen[nh] = 1; return nh; } seen[h] = 1; return h; }); res.headerLine = recs[0].line; res.rows = recs.slice(1).map(function(r){ var o = {}; keep.forEach(function(c, idx){ o[res.headers[idx]] = r.cells[c] || ''; }); return {line: r.line, cells: o}; }); return res; } /* ---------- 數字 ---------- */ function toNum(s){ var t = String(s == null ? '' : s).trim(); if (t === '') return null; t = t.replace(/[0-9.-+]/g, function(c){ return String.fromCharCode(c.charCodeAt(0) - 0xFEE0); }) .replace(/[,,\s]/g, ''); return /^[-+]?(\d+(\.\d*)?|\.\d+)$/.test(t) ? Number(t) : NaN; } function round6(n){ return Math.round(n * 1e6) / 1e6; } function fmtNum(n){ return Number(n).toLocaleString('en-US', {maximumFractionDigits: 6}); } function signed(n){ return n > 0 ? '+' + fmtNum(n) : n < 0 ? '-' + fmtNum(-n) : '0'; } function rawNum(v){ var n = toNum(v); return (n === null || Number.isNaN(n)) ? String(v || '') : String(round6(n)); } function signedRaw(n){ n = round6(n); return n > 0 ? '+' + n : String(n); } /* ---------- 欄位名稱 ---------- */ var PART_RE = /料號|品號|料件|part\s*(no|number|#)|p\/n|item\s*(no|code)/i; var NAME_RE = /品名|品項|名稱|規格|說明|description|desc/i; var WEEK_RE = /^(\d{4})\s*[-\/]?\s*W\s*(\d{1,2})$/i; function shortCol(h){ var m = WEEK_RE.exec(h); return m ? 'W' + pad2(m[2]) : h; } function colHead(h){ var m = WEEK_RE.exec(h); return m ? '<span class="yr">' + esc(m[1]) + '</span>W' + esc(pad2(m[2])) : esc(h); } /* ---------- 比對 ---------- */ var CATS = [ {id: 'add', label: '新增', cls: 'c-ok'}, {id: 'del', label: '刪除', cls: 'c-bad'}, {id: 'chg', label: '有變動', cls: 'c-info'}, {id: 'dup', label: '重複,請人工確認', cls: 'c-warn'}, {id: 'same', label: '沒有變動', cls: 'c-neutral'} ]; var CAT_BY = {}; CATS.forEach(function(c, i){ c.order = i; CAT_BY[c.id] = c; }); // 同一列只有兩格數量變動、兩格相鄰、一格減少 X 另一格增加 X,才判讀為「疑似整批移動」 function detectShift(changes, numOrder){ if (!changes || changes.length !== 2) return null; var a = changes[0], b = changes[1]; if (!a.numeric || !b.numeric) return null; var ia = numOrder.indexOf(a.col), ib = numOrder.indexOf(b.col); if (ia < 0 || ib < 0 || Math.abs(ia - ib) !== 1) return null; if (a.delta === 0 || Math.abs(a.delta + b.delta) > 1e-9) return null; var from = a.delta < 0 ? a : b, to = a.delta < 0 ? b : a, qty = round6(-from.delta); return {from: from.col, to: to.col, qty: qty, text: '疑似整批移動:' + shortCol(from.col) + ' → ' + shortCol(to.col) + '(' + fmtNum(qty) + ')'}; } function compare(oldT, newT, keys){ var common = oldT.headers.filter(function(h){ return newT.headers.indexOf(h) >= 0; }); var onlyOld = oldT.headers.filter(function(h){ return newT.headers.indexOf(h) < 0; }); var onlyNew = newT.headers.filter(function(h){ return oldT.headers.indexOf(h) < 0; }); keys = keys.filter(function(k){ return common.indexOf(k) >= 0; }); var cmpCols = common.filter(function(h){ return keys.indexOf(h) < 0; }); var nameCol = cmpCols.find(function(h){ return NAME_RE.test(h); }) || null; var valCols = cmpCols.filter(function(h){ return h !== nameCol; }); var numeric = {}; cmpCols.forEach(function(h){ var any = false, ok = true; [oldT, newT].forEach(function(t){ t.rows.forEach(function(r){ var v = r.cells[h]; if (v === '' || v == null) return; any = true; if (Number.isNaN(toNum(v))) ok = false; }); }); numeric[h] = any && ok; }); var numOrder = valCols.filter(function(h){ return numeric[h]; }); var blank = []; function group(t, ver){ var m = new Map(); t.rows.forEach(function(r, idx){ if (keys.every(function(k){ return (r.cells[k] || '') === ''; })){ blank.push({ver: ver, line: r.line}); return; } var k = keys.map(function(c){ return r.cells[c] || ''; }).join('\u001F'); if (!m.has(k)) m.set(k, []); m.get(k).push({idx: idx, line: r.line, cells: r.cells}); }); return m; } var om = group(oldT, 'old'), nm = group(newT, 'new'); var allKeys = []; nm.forEach(function(v, k){ allKeys.push(k); }); om.forEach(function(v, k){ if (!nm.has(k)) allKeys.push(k); }); var items = allKeys.map(function(k){ var o = om.get(k) || [], n = nm.get(k) || []; var ref = n[0] || o[0]; var it = { key: k, keyVals: keys.map(function(c){ return ref.cells[c] || ''; }), o: o, n: n, changes: [], shift: null, name: nameCol ? (ref.cells[nameCol] || '') : '', order: n.length ? n[0].idx : 1e6 + o[0].idx }; if (o.length > 1 || n.length > 1) it.cat = 'dup'; else if (!o.length) it.cat = 'add'; else if (!n.length) it.cat = 'del'; else { cmpCols.forEach(function(h){ var a = o[0].cells[h] || '', b = n[0].cells[h] || ''; if (numeric[h]){ var x = a === '' ? 0 : toNum(a), y = b === '' ? 0 : toNum(b); if (x !== y) it.changes.push({col: h, numeric: true, a: a, b: b, x: x, y: y, delta: round6(y - x)}); } else if (a !== b){ it.changes.push({col: h, numeric: false, a: a, b: b}); } }); it.cat = it.changes.length ? 'chg' : 'same'; it.shift = detectShift(it.changes, numOrder); } return it; }); items.sort(function(p, q){ return CAT_BY[p.cat].order - CAT_BY[q.cat].order || p.order - q.order; }); var counts = {add: 0, del: 0, chg: 0, dup: 0, same: 0}; items.forEach(function(it){ counts[it.cat]++; }); return {keys: keys, common: common, onlyOld: onlyOld, onlyNew: onlyNew, cmpCols: cmpCols, nameCol: nameCol, valCols: valCols, numeric: numeric, numOrder: numOrder, items: items, counts: counts, blank: blank, oldRows: oldT.rows.length, newRows: newT.rows.length}; } /* ---------- 差異清單(匯出用) ---------- */ var DIFF_HEAD = ['料號', '品名', '欄位', '舊值', '新值', '增減', '變動類型']; function keyText(it){ return it.keyVals.join('/'); } function lineList(rows){ return rows.map(function(r){ return r.line; }).join('、'); } function diffRows(res){ var out = []; res.items.forEach(function(it){ var pn = keyText(it), nm = it.name; if (it.cat === 'add' || it.cat === 'del'){ var isAdd = it.cat === 'add', row = isAdd ? it.n[0] : it.o[0], n0 = out.length; var type = isAdd ? '新增料號' : '刪除料號'; res.valCols.forEach(function(h){ var v = row.cells[h] || ''; if (v === '') return; if (res.numeric[h]){ var x = toNum(v); if (x === 0) return; out.push(isAdd ? [pn, nm, h, '', rawNum(v), signedRaw(x), type] : [pn, nm, h, rawNum(v), '', signedRaw(-x), type]); } else { out.push(isAdd ? [pn, nm, h, '', v, '', type] : [pn, nm, h, v, '', '', type]); } }); if (out.length === n0) out.push([pn, nm, '(整列)', '', '', '', type]); } else if (it.cat === 'chg'){ it.changes.forEach(function(c){ var type = it.shift ? it.shift.text : (c.numeric ? '數量變動' : '內容變動'); out.push(c.numeric ? [pn, nm, c.col, rawNum(c.a), rawNum(c.b), signedRaw(c.delta), type] : [pn, nm, c.col, c.a, c.b, '', type]); }); } else if (it.cat === 'dup'){ out.push([pn, nm, '(整列)', it.o.length ? '舊版 ' + it.o.length + ' 列(第 ' + lineList(it.o) + ' 列)' : '舊版沒有', it.n.length ? '新版 ' + it.n.length + ' 列(第 ' + lineList(it.n) + ' 列)' : '新版沒有', '', '重複,請人工確認']); } }); return out; } function csvCell(v){ v = String(v == null ? '' : v); return /[",\r\n]|^\s|\s$/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; } function tsvCell(v){ v = String(v == null ? '' : v); return /[\t"\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; } function toCSV(rows){ return '' + [DIFF_HEAD].concat(rows).map(function(r){ return r.map(csvCell).join(','); }).join('\r\n') + '\r\n'; } function toTSV(rows){ return [DIFF_HEAD].concat(rows).map(function(r){ return r.map(tsvCell).join('\t'); }).join('\r\n'); } /* ---------- 範例資料(週別從今天往後推算) ---------- */ function isoWeek(d){ var t = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate())); t.setUTCDate(t.getUTCDate() - ((t.getUTCDay() + 6) % 7) + 3); var jan4 = new Date(Date.UTC(t.getUTCFullYear(), 0, 4)); var week = 1 + Math.round(((t - jan4) / 864e5 - 3 + ((jan4.getUTCDay() + 6) % 7)) / 7); return {year: t.getUTCFullYear(), week: week}; } function sampleWeeks(){ var now = new Date(), out = []; for (var i = 0; i < 8; i++){ var d = new Date(now.getFullYear(), now.getMonth(), now.getDate() + 14 + 7 * i); var w = isoWeek(d); out.push(w.year + '-W' + pad2(w.week)); } return out; } var SAMPLE_OLD = [ ['IGW-4100-A', '工業閘道器(4 埠 RS-485)', [400, 400, 500, 500, 500, 600, 600, 600]], ['IGW-4200-B', '工業閘道器(LTE 版)', [300, 300, 300, 350, 350, 350, 400, 400]], ['NWS-2408-A', '管理型交換器(8 埠 PoE)', [800, 800, 900, 900, 1000, 1000, 1000, 1000]], ['NWS-2424-G', '管理型交換器(24 埠)', [150, 150, 150, 200, 200, 200, 200, 250]], ['USW-1005-E', '非管理型交換器(5 埠)', [1200, 1200, 1000, 1000, 800, 800, 600, 600]], ['WRT-AX30-T', 'Wi-Fi 6 路由器', [500, 500, 500, 500, 500, 500, 400, 400]], ['ANT-5G-02', '5G 天線模組', [600, 0, 600, 0, 600, 0, 600, 0]], ['PSU-0650-C', '電源供應模組(65W)', [2000, 2000, 2400, 2400, 2400, 2400, 2800, 2800]], ['IPC-MB-310', '工業電腦主機板', [120, 120, 120, 150, 150, 150, 150, 150]], ['ENC-AL-210', '鋁擠機殼(210 mm)', [900, 900, 900, 900, 1100, 1100, 1100, 1100]] ]; var SAMPLE_NEW = [ ['IGW-4100-A', '工業閘道器(4 埠 RS-485)', [400, 400, 500, 500, 500, 600, 600, 600]], ['IGW-4200-B', '工業閘道器(LTE 版)', [300, 600, 300, 350, 350, 350, 400, 400]], ['NWS-2408-A', '管理型交換器(8 埠 PoE)', [800, 800, 900, 900, 880, 1000, 1000, 1000]], ['NWS-2424-G', '管理型交換器(24 埠)', [150, 150, 150, 200, 200, 200, 200, 250]], ['WRT-AX30-T', 'Wi-Fi 6 路由器', [500, 500, 500, 500, 500, 300, 550, 400]], ['ANT-5G-02', '5G 天線模組', [600, 0, 0, 600, 600, 0, 600, 0]], ['PSU-0650-C', '電源供應模組(65W)', [2000, 2000, 2400, 2400, 2400, 2400, 2800, 2800]], ['IPC-MB-310', '工業電腦主機板', [120, 120, 120, 150, 150, 150, 150, 150]], ['PCBA-CTL-07', '控制板組件(PCBA)', [0, 0, 300, 300, 300, 300, 300, 300]], ['ENC-AL-210', '鋁擠機殼(210 mm)', [900, 900, 900, 900, 1100, 1100, 1100, 1100]], ['PSU-0650-C', '電源供應模組(65W)', [0, 0, 500, 500, 500, 500, 0, 0]] ]; function sampleText(rows){ var head = ['料號', '品名'].concat(sampleWeeks()); return [head].concat(rows.map(function(r){ return [r[0], r[1]].concat(r[2]); })) .map(function(r){ return r.join('\t'); }).join('\n'); } /* ---------- 畫面 ---------- */ var taOld = $('#taOld'), taNew = $('#taNew'); var state = {res: null, filter: null, keySel: [], commonSig: null, oldT: null, newT: null}; function delimName(d){ return d === '\t' ? 'Tab 分隔' : d === ',' ? '逗號分隔' : d === ';' ? '分號分隔' : ''; } function metaText(t){ if (t.empty) return '尚未貼上資料'; return '已讀到 ' + t.rows.length + ' 列資料、' + t.headers.length + ' 個欄位(' + delimName(t.delim) + ')'; } function renderKeys(common){ var sig = common.join('\u001F'); if (sig !== state.commonSig){ var keep = state.keySel.filter(function(k){ return common.indexOf(k) >= 0; }); if (!keep.length && common.length){ var p = common.find(function(h){ return PART_RE.test(h); }); keep = [p || common[0]]; } state.keySel = keep; state.commonSig = sig; var box = $('#keyList'); if (!common.length){ box.innerHTML = ''; return; } box.innerHTML = common.map(function(h, i){ var id = 'key-' + i, isWeek = WEEK_RE.test(h) || /^\d/.test(h); return '<label class="chip" for="' + id + '"><input type="checkbox" id="' + id + '" value="' + esc(h) + '"' + (keep.indexOf(h) >= 0 ? ' checked' : '') + '><span' + (isWeek ? ' class="mono"' : '') + '>' + esc(h) + '</span></label>'; }).join(''); } } function renderColInfo(oldT, newT, res){ var el = $('#colInfo'); if (oldT.empty || newT.empty){ el.innerHTML = ''; return; } var common = res ? res.common : oldT.headers.filter(function(h){ return newT.headers.indexOf(h) >= 0; }); var onlyOld = oldT.headers.filter(function(h){ return newT.headers.indexOf(h) < 0; }); var onlyNew = newT.headers.filter(function(h){ return oldT.headers.indexOf(h) < 0; }); if (!onlyOld.length && !onlyNew.length){ el.innerHTML = '<div class="colinfo" id="colSame"><p><span class="k">兩版欄位相同</span>共 ' + common.length + ' 欄,全部列入比對。</p></div>'; return; } function list(a){ return a.length ? a.map(function(h){ return '<code>' + esc(h) + '</code>'; }).join('') : '無'; } el.innerHTML = '<div class="colinfo" id="colDiff">' + '<p id="onlyOld"><span class="k">只有舊版有的欄位</span>' + list(onlyOld) + '</p>' + '<p id="onlyNew"><span class="k">只有新版有的欄位</span>' + list(onlyNew) + '</p>' + '<p class="meta">這些欄位只出現在其中一版,所以沒有逐格比對;兩版都有的 ' + common.length + ' 欄照常比對。</p></div>'; } function renderNotes(oldT, newT, res){ var notes = []; oldT.notes.forEach(function(n){ notes.push('舊版:' + n); }); newT.notes.forEach(function(n){ notes.push('新版:' + n); }); if (res && res.blank.length){ var o = res.blank.filter(function(b){ return b.ver === 'old'; }).map(function(b){ return b.line; }); var n = res.blank.filter(function(b){ return b.ver === 'new'; }).map(function(b){ return b.line; }); var parts = []; if (o.length) parts.push('舊版第 ' + o.join('、') + ' 列'); if (n.length) parts.push('新版第 ' + n.join('、') + ' 列'); notes.push(parts.join('、') + '的比對依據欄位是空白,沒有列入比對。'); } $('#parseNotes').innerHTML = notes.length ? '<div class="notice">' + notes.map(function(t){ return '<p><b>請注意:</b>' + esc(t) + '</p>'; }).join('') + '</div>' : ''; } function numCell(v, isNum){ if (!isNum) return '<td>' + esc(v) + '</td>'; if (v === '') return '<td class="num"></td>'; var n = toNum(v); if (n === 0) return '<td class="num"><span class="zero">0</span></td>'; return '<td class="num">' + esc(Number.isNaN(n) ? v : fmtNum(n)) + '</td>'; } function showVal(v, isNum){ if (v === '') return '空白'; if (!isNum) return esc(v); var n = toNum(v); return esc(Number.isNaN(n) ? v : fmtNum(n)); } function chgCell(c){ if (c.numeric){ return '<td class="num chg"><span class="ov">' + showVal(c.a, true) + '</span><span class="arrow">→</span><span class="nv">' + showVal(c.b, true) + '</span><span class="d ' + (c.delta > 0 ? 'up' : 'down') + '">' + esc(signed(c.delta)) + '</span></td>'; } return '<td class="chg"><span class="ov">' + (c.a === '' ? '(空白)' : esc(c.a)) + '</span><span class="arrow">→</span><span class="nv">' + (c.b === '' ? '(空白)' : esc(c.b)) + '</span></td>'; } function tagHtml(cat){ var c = CAT_BY[cat]; return '<span class="tag ' + c.cls + '">' + esc(c.label) + '</span>'; } function renderItem(it, res){ var cat = it.cat, catCls = CAT_BY[cat].cls; var pnHtml = '<div class="pn">' + esc(keyText(it)) + '</div>'; if (cat === 'dup'){ var subs = it.o.map(function(r){ return {ver: '舊版', r: r}; }).concat(it.n.map(function(r){ return {ver: '新版', r: r}; })); var parts = []; if (it.o.length > 1) parts.push('舊版第 ' + lineList(it.o) + ' 列'); if (it.n.length > 1) parts.push('新版第 ' + lineList(it.n) + ' 列'); var note = parts.join('、') + '都是這個料號。工具沒有自動合併,請確認哪一列才對。'; return '<tbody class="it cat-dup ' + catCls + '" data-cat="dup">' + subs.map(function(s, i){ var nm = res.nameCol ? '<div class="nm">' + esc(s.r.cells[res.nameCol] || '') + '</div>' : ''; return '<tr>' + (i === 0 ? '<td class="c-st" rowspan="' + subs.length + '">' + tagHtml('dup') + '<span class="st-note">' + esc(note) + '</span></td>' : '') + '<td class="c-pn">' + pnHtml + nm + '<div class="src">' + s.ver + '第 ' + s.r.line + ' 列</div></td>' + res.valCols.map(function(h){ return numCell(s.r.cells[h] || '', res.numeric[h]); }).join('') + '</tr>'; }).join('') + '</tbody>'; } var row = cat === 'del' ? it.o[0] : it.n[0]; var chMap = {}; it.changes.forEach(function(c){ chMap[c.col] = c; }); var nmHtml = ''; if (res.nameCol){ var nc = chMap[res.nameCol]; nmHtml = nc ? '<div class="nm"><span class="txtchg">' + esc(nc.a || '(空白)') + ' → ' + esc(nc.b || '(空白)') + '</span></div>' : '<div class="nm">' + esc(row.cells[res.nameCol] || '') + '</div>'; } var stNote = cat === 'add' ? '只有新版有這個料號' : cat === 'del' ? '新版已經沒有這個料號' : cat === 'chg' ? it.changes.length + ' 格有變動' : ''; var shift = it.shift ? '<div class="shift">疑似整批移動:<span class="nw">' + esc(shortCol(it.shift.from) + ' → ' + shortCol(it.shift.to)) + '(' + esc(fmtNum(it.shift.qty)) + ')</span></div>' : ''; var cells = res.valCols.map(function(h){ return chMap[h] ? chgCell(chMap[h]) : numCell(row.cells[h] || '', res.numeric[h]); }).join(''); return '<tbody class="it cat-' + cat + ' ' + catCls + '" data-cat="' + cat + '"><tr><td class="c-st">' + tagHtml(cat) + (stNote ? '<span class="st-note">' + esc(stNote) + '</span>' : '') + shift + '</td><td class="c-pn">' + pnHtml + nmHtml + '</td>' + cells + '</tr></tbody>'; } function renderTable(res){ var head = res.keys.join('/') + (res.nameCol ? '/' + res.nameCol : ''); var cols = 2 + res.valCols.length; $('#tbl').innerHTML = '<thead><tr><th scope="col">比對結果</th><th scope="col">' + esc(head) + '</th>' + res.valCols.map(function(h){ return '<th scope="col"' + (res.numeric[h] ? ' class="num"' : '') + '>' + colHead(h) + '</th>'; }).join('') + '</tr></thead>' + res.items.map(function(it){ return renderItem(it, res); }).join('') + '<tbody id="emptyRow" hidden><tr><td class="empty" colspan="' + cols + '">這一類沒有料號。再點一次上面的按鈕,可以顯示全部。</td></tr></tbody>'; } function renderStats(res){ $('#stats').innerHTML = CATS.map(function(c){ return '<button type="button" class="stat ' + c.cls + '" data-cat="' + c.id + '" aria-pressed="false" aria-controls="tbl">' + '<span class="l">' + esc(c.label) + '</span><span class="n">' + res.counts[c.id] + '</span></button>'; }).join(''); } function applyFilter(){ var res = state.res; if (!res) return; var f = state.filter, shown = 0; document.querySelectorAll('#tbl tbody.it').forEach(function(tb){ var on = !f || tb.dataset.cat === f; tb.hidden = !on; if (on) shown++; }); $('#emptyRow').hidden = shown > 0; document.querySelectorAll('#stats .stat').forEach(function(b){ b.setAttribute('aria-pressed', String(b.dataset.cat === f)); }); $('#filterNote').textContent = f ? '目前只顯示「' + CAT_BY[f].label + '」' + res.counts[f] + ' 個料號。再點一次同一個按鈕,可以顯示全部。' : '目前顯示全部 ' + res.items.length + ' 個料號。點上面的按鈕,可以只看某一類。'; } function renderDiffList(rows){ $('#diffSummary').textContent = '查看差異清單內容(共 ' + rows.length + ' 筆)'; var monoIdx = {0: 1, 2: 1, 5: 1}; $('#diffTbl').innerHTML = '<thead><tr>' + DIFF_HEAD.map(function(h){ return '<th scope="col">' + h + '</th>'; }).join('') + '</tr></thead><tbody>' + (rows.length ? rows.map(function(r){ return '<tr>' + r.map(function(v, i){ var cls = (monoIdx[i] || ((i === 3 || i === 4) && /^[-+]?[\d.,]*$/.test(v))) ? ' class="mono"' : ''; if (i === 5 && v) cls = ' class="mono ' + (v.charAt(0) === '-' ? 'down' : 'up') + '"'; return '<td' + cls + '>' + esc(v) + '</td>'; }).join('') + '</tr>'; }).join('') : '<tr><td class="empty" colspan="7">兩版沒有差異。</td></tr>') + '</tbody>'; } function setResultVisible(on){ $('#resultBody').hidden = !on; $('#btnCsv').disabled = !on; $('#btnCopy').disabled = !on; } function run(fromButton){ var oldT = parseTable(taOld.value), newT = parseTable(taNew.value); state.oldT = oldT; state.newT = newT; $('#metaOld').textContent = metaText(oldT); $('#metaNew').textContent = metaText(newT); var summary = $('#summary'); if (oldT.empty || newT.empty){ state.res = null; state.commonSig = null; $('#keyList').innerHTML = '<p class="meta">貼上兩版資料後,這裡會列出可以勾選的欄位。</p>'; renderColInfo(oldT, newT, null); renderNotes(oldT, newT, null); summary.textContent = (oldT.empty && newT.empty) ? '還沒有資料。請在第 1 步貼上舊版與新版,或選擇 CSV 檔。' : oldT.empty ? '目前只有新版。請在左邊貼上舊版,才能比對。' : '目前只有舊版。請在右邊貼上新版,才能比對。'; setResultVisible(false); if (fromButton) say($('#msgInput'), '請先貼上舊版和新版兩份資料。', 'warn'); return; } var common = oldT.headers.filter(function(h){ return newT.headers.indexOf(h) >= 0; }); renderKeys(common); if (!common.length){ state.res = null; $('#keyList').innerHTML = '<p class="meta">兩版沒有相同的欄位名稱。</p>'; renderColInfo(oldT, newT, null); renderNotes(oldT, newT, null); summary.textContent = '兩版沒有相同的欄位名稱,沒辦法比對。請確認兩版的第一列都是欄位名稱,而且名稱寫法一樣。'; setResultVisible(false); return; } if (!state.keySel.length){ state.res = null; renderColInfo(oldT, newT, null); renderNotes(oldT, newT, null); summary.textContent = '請在第 2 步至少勾選一個比對依據欄位(一般是「料號」)。'; setResultVisible(false); if (fromButton) say($('#msgInput'), '請先在第 2 步勾選比對依據。', 'warn'); return; } var res = compare(oldT, newT, state.keySel); state.res = res; renderColInfo(oldT, newT, res); renderNotes(oldT, newT, res); summary.textContent = '已比對:舊版 ' + res.oldRows + ' 列、新版 ' + res.newRows + ' 列,依「' + res.keys.join('+') + '」比對,共 ' + res.items.length + ' 個料號。'; if (state.filter && !res.counts[state.filter]) state.filter = null; renderStats(res); renderTable(res); applyFilter(); var rows = diffRows(res); state.diff = rows; renderDiffList(rows); setResultVisible(true); if (fromButton){ var c = res.counts; say($('#msgInput'), '比對完成:新增 ' + c.add + '、刪除 ' + c.del + '、有變動 ' + c.chg + '、重複 ' + c.dup + '、沒有變動 ' + c.same + '。結果在下方第 3 步。', 'ok'); } } var timer = null; function runSoon(){ clearTimeout(timer); timer = setTimeout(function(){ run(false); }, 300); } function loadSample(){ taOld.value = sampleText(SAMPLE_OLD); taNew.value = sampleText(SAMPLE_NEW); state.filter = null; state.keySel = []; state.commonSig = null; run(false); } /* ---------- 讀取 CSV 檔 ---------- */ function readFile(input, ta, label){ var f = input.files && input.files[0]; if (!f) return; var reader = new FileReader(); reader.onload = function(){ var buf = reader.result, text, note = ''; try { text = new TextDecoder('utf-8', {fatal: true}).decode(buf); } catch (e){ try { text = new TextDecoder('big5').decode(buf); note = '這個檔案不是 UTF-8 編碼,已改用 Big5 讀取,請確認中文有沒有正確顯示。'; } catch (e2){ text = new TextDecoder('utf-8').decode(buf); note = '這個檔案的編碼無法辨識,中文可能會變成亂碼。'; } } ta.value = text.replace(/^/, ''); input.value = ''; run(false); var t = label === '舊版' ? state.oldT : state.newT; say($('#msgInput'), '已讀取' + label + '檔案「' + f.name + '」,共 ' + t.rows.length + ' 列資料。' + note, note ? 'warn' : 'ok'); }; reader.onerror = function(){ input.value = ''; say($('#msgInput'), '讀取檔案「' + f.name + '」失敗,請重新選擇一次。', 'bad'); }; reader.readAsArrayBuffer(f); } /* ---------- 匯出 ---------- */ function stamp(){ var d = new Date(); return d.getFullYear() + pad2(d.getMonth() + 1) + pad2(d.getDate()) + '-' + pad2(d.getHours()) + pad2(d.getMinutes()); } function downloadCsv(){ if (!state.res) return; var rows = diffRows(state.res); if (!rows.length){ say($('#msgExport'), '兩版完全相同,沒有差異可以匯出。', 'warn'); return; } var blob = new Blob([toCSV(rows)], {type: 'text/csv;charset=utf-8'}); var url = URL.createObjectURL(blob), a = document.createElement('a'); a.href = url; a.download = '客戶計畫表差異清單_' + stamp() + '.csv'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function(){ URL.revokeObjectURL(url); }, 2000); say($('#msgExport'), '已下載差異清單,共 ' + rows.length + ' 筆。用 Excel 開啟就能看到中文。', 'ok'); } function showFallback(text){ var box = $('#fallback'), ta = $('#taFallback'); box.hidden = false; ta.value = text; ta.focus(); ta.select(); say($('#msgExport'), '瀏覽器不允許直接複製。文字已經在下面的框裡全部選取,請按 Ctrl+C 複製(Mac 請按 ⌘+C)。', 'warn'); } function copyResult(){ if (!state.res) return; var rows = diffRows(state.res); if (!rows.length){ say($('#msgExport'), '兩版完全相同,沒有差異可以複製。', 'warn'); return; } var text = toTSV(rows); try { if (!navigator.clipboard || !navigator.clipboard.writeText) throw new Error('no clipboard'); navigator.clipboard.writeText(text).then(function(){ $('#fallback').hidden = true; say($('#msgExport'), '已複製 ' + rows.length + ' 筆差異(含欄位名稱),可以直接貼到 Excel。', 'ok'); }, function(){ showFallback(text); }); } catch (e){ showFallback(text); } } /* ---------- 事件 ---------- */ taOld.addEventListener('input', runSoon); taNew.addEventListener('input', runSoon); $('#fileOld').addEventListener('change', function(){ readFile(this, taOld, '舊版'); }); $('#fileNew').addEventListener('change', function(){ readFile(this, taNew, '新版'); }); $('#btnCompare').addEventListener('click', function(){ clearTimeout(timer); run(true); }); $('#btnClear').addEventListener('click', function(){ clearTimeout(timer); taOld.value = ''; taNew.value = ''; state.filter = null; state.keySel = []; state.commonSig = null; $('#fallback').hidden = true; say($('#msgExport'), ''); run(false); say($('#msgInput'), '已清除範例。請貼上你的舊版與新版資料,或選擇 CSV 檔。', 'ok'); taOld.focus(); }); $('#btnSample').addEventListener('click', function(){ clearTimeout(timer); $('#fallback').hidden = true; say($('#msgExport'), ''); loadSample(); say($('#msgInput'), '已重新載入範例資料。', 'ok'); }); $('#keyList').addEventListener('change', function(e){ if (!e.target || e.target.type !== 'checkbox') return; state.keySel = Array.prototype.slice.call(document.querySelectorAll('#keyList input:checked')).map(function(i){ return i.value; }); run(false); }); $('#stats').addEventListener('click', function(e){ var b = e.target.closest('.stat'); if (!b) return; state.filter = state.filter === b.dataset.cat ? null : b.dataset.cat; applyFilter(); }); $('#btnCsv').addEventListener('click', downloadCsv); $('#btnCopy').addEventListener('click', copyResult); // 給測試用的函式入口 window.T1 = {parseTable: parseTable, compare: compare, detectShift: detectShift, diffRows: diffRows, toCSV: toCSV, toTSV: toTSV, toNum: toNum, sampleWeeks: sampleWeeks, isoWeek: isoWeek, state: state}; loadSample(); })(); </script> </body> </html>
<!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>一物多號查詢器</title> <style> :root{ --bg:#f4f6f3; --surface:#ffffff; --surface-2:#eaf0ec; --line:#cfd9d3; --ink:#16221d; --ink-2:#4a5a52; --accent:#0b6b4f; --accent-ink:#ffffff; --copper:#a8601f; --ok:#1f7a45; --ok-bg:#e3f3e8; --warn:#8a5900; --warn-bg:#fdf1d8; --bad:#b3261e; --bad-bg:#fbe4e1; --info:#1d5f9e; --info-bg:#e3eefa; --focus:#c27a2c; --font-body:"Microsoft JhengHei","PingFang TC","Noto Sans TC","Heiti TC",sans-serif; --font-mono:"Cascadia Mono",Consolas,"SF Mono",ui-monospace,monospace; color-scheme:light; } @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } } :root[data-theme="dark"]{ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } *,*::before,*::after{box-sizing:border-box} html{-webkit-text-size-adjust:100%;text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font-body);font-size:16px;line-height:1.6} .mono,code{font-family:var(--font-mono);font-variant-numeric:tabular-nums} code{background:var(--surface-2);border-radius:4px;padding:0 5px;font-size:.95em} :focus-visible{outline:3px solid var(--focus);outline-offset:2px} .sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0} [hidden]{display:none!important} .wrap{max-width:1180px;margin:0 auto;padding-inline:24px;padding-bottom:20px} /* 頁首 */ .top{display:flex;justify-content:space-between;align-items:flex-start;gap:10px 20px;flex-wrap:wrap;padding:28px 0 14px} .top h1{margin:0;font-size:28px;line-height:1.3;font-weight:800;letter-spacing:.02em} .top .lead{margin:6px 0 0;color:var(--ink-2);font-size:17px} .fict{flex:none;margin-top:6px;padding:3px 12px;border:1px solid var(--copper);color:var(--copper);border-radius:999px;font-size:14px;font-weight:700;background:var(--surface)} .trace{position:relative;height:3px;background:var(--accent);margin-bottom:18px} .trace::before{content:"";position:absolute;left:0;top:0;bottom:0;width:48px;background:var(--copper)} /* 對照表來源列 */ .sourcebar{display:flex;align-items:center;justify-content:space-between;gap:10px 16px;flex-wrap:wrap;padding:10px 16px;background:var(--surface-2);border:1px solid var(--line);border-radius:10px} .sourcebar p{margin:0} .btns{display:flex;gap:8px;flex-wrap:wrap;align-items:center} /* 區塊 */ .panel{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:22px 24px;margin-top:18px} .panel h2{margin:0;font-size:21px;line-height:1.4} .panel h3{margin:0;font-size:17px;line-height:1.4} .desc{margin:4px 0 16px;color:var(--ink-2)} .lab{display:block;font-weight:700;margin-bottom:6px} /* 按鈕 */ .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;padding:8px 18px;border:1.5px solid var(--accent);border-radius:8px;background:var(--surface);color:var(--accent);font:inherit;font-size:16px;font-weight:700;line-height:1.3;cursor:pointer;text-align:center} .btn:hover{background:var(--surface-2)} .btn.primary{background:var(--accent);color:var(--accent-ink)} .btn.primary:hover{filter:brightness(.92)} .btn:disabled{opacity:.45;cursor:not-allowed} .btn.small{min-height:40px;padding:4px 12px;font-size:15px} /* 狀態標籤:文字+顏色 */ .tag{display:inline-block;padding:2px 10px;border-radius:6px;font-size:14px;font-weight:700;line-height:1.5;border:1px solid currentColor} .t-ok{--tone:var(--ok);--tone-bg:var(--ok-bg)} .t-bad{--tone:var(--bad);--tone-bg:var(--bad-bg)} .t-warn{--tone:var(--warn);--tone-bg:var(--warn-bg)} .t-info{--tone:var(--info);--tone-bg:var(--info-bg)} .t-none{--tone:var(--ink-2);--tone-bg:var(--surface-2)} .tag{color:var(--tone);background:var(--tone-bg)} .tag.t-none{color:var(--ink);border-style:dashed;border-color:var(--ink-2)} /* 提示區 */ .status{margin-top:10px;font-size:15px;border-radius:8px;padding:8px 12px;border-left:4px solid var(--tone,var(--accent));background:var(--tone-bg,var(--surface-2));color:var(--ink)} .status:empty{padding:0;margin:0;border:0} /* 單筆查詢 */ .searchrow{display:flex;gap:10px;align-items:stretch} #q{flex:1;min-width:0;height:60px;padding:0 18px;border:2px solid var(--accent);border-radius:10px;background:var(--surface);color:var(--ink);font-family:var(--font-mono),var(--font-body);font-size:24px;-webkit-appearance:none;appearance:none} #q::-webkit-search-cancel-button,#q::-webkit-search-decoration{-webkit-appearance:none;appearance:none;display:none} #q::placeholder{color:var(--ink-2);font-family:var(--font-body);font-size:18px} #q-clear{min-height:60px} .chips{display:flex;flex-wrap:wrap;gap:8px;align-items:center;margin-top:10px;font-size:15px;color:var(--ink-2)} .chip{display:inline-flex;align-items:baseline;gap:8px;min-height:40px;padding:6px 14px;border:1px solid var(--line);border-radius:999px;background:var(--surface-2);color:var(--ink);font:inherit;font-size:15px;cursor:pointer} .chip:hover{border-color:var(--accent)} .chip .cap{color:var(--ink-2);font-size:13.5px} .single-out{margin-top:16px} .notice{margin:12px 0 0;padding:10px 14px;border-radius:8px;border-left:4px solid var(--tone);background:var(--tone-bg);color:var(--ink)} .empty{margin:14px 0 0;padding:18px;border:1px dashed var(--line);border-radius:10px;color:var(--ink-2);text-align:center} .card{margin-top:14px;padding:18px 20px;border:1px solid var(--line);border-radius:12px;background:var(--surface)} .card.stopped{border-left:5px solid var(--bad)} .card-top{display:flex;justify-content:space-between;align-items:flex-start;gap:10px 16px;flex-wrap:wrap} .card-main{min-width:0;flex:1 1 320px} .card-id{font-size:30px;font-weight:800;line-height:1.25;letter-spacing:.01em;word-break:break-all} .card-name{margin-top:4px;font-size:19px;line-height:1.5} .card-name.hit{display:inline-block;border:2px solid var(--accent);border-radius:6px;padding:0 8px} .card-state{flex:none} .card-state .tag{font-size:16px;padding:3px 14px} .stopbar{display:flex;align-items:center;justify-content:space-between;gap:8px 16px;flex-wrap:wrap;margin-top:12px;padding:8px 12px 8px 14px;border-radius:8px;background:var(--bad-bg);color:var(--bad);font-size:17px;font-weight:700} .stopbar b{font-size:19px;white-space:nowrap} .stopbar .btn{color:var(--ink);border-color:var(--ink-2)} .hitline{margin:10px 0 0;font-size:15px;color:var(--ink-2)} .hitmark{display:inline-block;margin-left:6px;padding:0 7px;border-radius:4px;background:var(--accent);color:var(--accent-ink);font-size:13px;font-weight:700;line-height:1.6;vertical-align:1px} .hitline .hitmark{margin:0 6px 0 0} .fields{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:10px;margin-top:12px} .field{padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:var(--surface-2)} .field.hit{border:2px solid var(--accent);background:var(--surface);padding:7px 11px} .flabel{display:flex;justify-content:space-between;align-items:center;gap:6px;font-size:14px;color:var(--ink-2)} .fval{font-size:19px;font-weight:700;word-break:break-all} .none{color:var(--ink-2);font-weight:400;font-family:var(--font-body)} .fval.none{font-size:16px} .cnote{margin:10px 0 0;color:var(--ink-2);font-size:15px} /* 批次查詢 */ .batch-in{display:grid;grid-template-columns:minmax(0,380px) minmax(0,1fr);gap:20px;align-items:start} textarea{display:block;width:100%;padding:10px 12px;border:1px solid var(--ink-2);border-radius:8px;background:var(--surface);color:var(--ink);font-family:var(--font-mono),var(--font-body);font-size:16px;line-height:1.55;resize:vertical} .under{margin-top:8px;display:flex;gap:8px;flex-wrap:wrap;align-items:center} .hint{margin:6px 0 0;font-size:14.5px;color:var(--ink-2)} .legend{background:var(--surface-2);border-radius:10px;padding:14px 16px} .legend dl{margin:10px 0 0;display:grid;grid-template-columns:max-content minmax(0,1fr);gap:8px 12px;align-items:baseline} .legend dt,.legend dd{margin:0} .legend dd{font-size:15px} .legend .foot{margin:12px 0 0;font-size:15px;font-weight:700} .res-head{display:flex;justify-content:space-between;align-items:center;gap:10px 16px;flex-wrap:wrap;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)} .filters{display:flex;flex-wrap:wrap;gap:8px;margin-top:12px} .fbtn{display:inline-flex;align-items:center;gap:10px;min-height:44px;padding:6px 14px 6px 12px;border:1.5px solid var(--line);border-left:6px solid var(--tone);border-radius:8px;background:var(--surface);color:var(--ink);font:inherit;font-size:15px;line-height:1.35;cursor:pointer;text-align:left} .fbtn .fl{font-weight:700} .fbtn .fn{font-family:var(--font-mono);font-variant-numeric:tabular-nums;font-size:21px;font-weight:800;color:var(--tone)} .fbtn:hover{border-color:var(--tone)} .fbtn[aria-pressed="true"]{background:var(--tone-bg);border-color:var(--tone);box-shadow:inset 0 0 0 1px var(--tone)} .fbtn:disabled{opacity:.5;cursor:default} .fbtn.t-none .fn{color:var(--ink)} .filter-note{margin:8px 0 0;font-size:15px;color:var(--ink-2)} .actions{display:flex;gap:8px;flex-wrap:wrap} .tablewrap{overflow-x:auto;margin-top:12px;border:1px solid var(--line);border-radius:10px;background:var(--surface)} table{border-collapse:collapse;width:100%;font-size:15px} th,td{padding:10px 12px;text-align:left;vertical-align:top;border-bottom:1px solid var(--line)} th{background:var(--surface-2);font-weight:700;white-space:nowrap} tbody tr:last-child td{border-bottom:0} td.nowrap{white-space:nowrap} td.c-res .tag{white-space:nowrap} td.c-name{min-width:12em} td.c-note{min-width:14em} #ref-table td.c-note{min-width:9em} .pn{white-space:nowrap} td .stack + .stack{margin-top:6px;padding-top:6px;border-top:1px dashed var(--line)} td.dim{color:var(--ink-2)} .copybox{margin-top:12px} .copybox textarea{font-size:14px} /* 料號對照表 */ details.tbl > summary{list-style:none;cursor:pointer;display:flex;align-items:center;gap:8px 14px;flex-wrap:wrap;min-height:44px;border-radius:8px} details.tbl > summary::-webkit-details-marker{display:none} .chev{display:inline-block;width:12px;height:12px;border-right:3px solid var(--accent);border-bottom:3px solid var(--accent);transform:rotate(-45deg);margin:0 4px 0 2px;transition:transform .15s} details.tbl[open] .chev{transform:rotate(45deg)} .sumcount{color:var(--ink-2)} .toggle-text{margin-left:auto;color:var(--accent);font-weight:700} details.tbl:not([open]) .when-open, details.tbl[open] .when-closed{display:none} .tbl-body{margin-top:14px} .replace{margin-top:22px;padding-top:18px;border-top:1px solid var(--line)} .replace .row2{display:flex;gap:8px;flex-wrap:wrap;align-items:center;margin-top:10px} #table-file:focus-visible + label{outline:3px solid var(--focus);outline-offset:2px} .filename{color:var(--ink-2);font-size:15px} footer{margin-top:28px;padding:16px 0 8px;border-top:1px solid var(--line);color:var(--ink-2);font-size:14.5px} footer p{margin:2px 0} @media (max-width:860px){ .batch-in{grid-template-columns:minmax(0,1fr)} .fields{grid-template-columns:repeat(2,minmax(0,1fr))} } @media (max-width:640px){ .wrap{padding-inline:16px} .top{padding-top:20px} .top h1{font-size:25px} .panel{padding:16px} .card{padding:14px} .card-id{font-size:25px} #q{font-size:20px;height:56px;padding:0 12px} #q-clear{min-height:56px;padding:8px 12px} .legend dl{grid-template-columns:minmax(0,1fr);gap:4px} .legend dd{margin-bottom:6px} .toggle-text{margin-left:0} } @media (prefers-reduced-motion: reduce){ *,*::before,*::after{transition:none!important;animation:none!important;scroll-behavior:auto!important} } </style> </head> <body> <div class="wrap"> <header class="top"> <div> <h1>一物多號查詢器</h1> <p class="lead">輸入客戶料號、供應商料號或舊料號,就能查出對應的內部料號,並確認這顆料是否已經停用。</p> </div> <span class="fict">練習資料皆為虛構</span> </header> <div class="trace" aria-hidden="true"></div> <noscript><p class="notice t-bad">這個工具需要開啟 JavaScript 才能使用。</p></noscript> <div class="sourcebar"> <p id="source-text">目前使用的對照表:範例資料 15 筆(虛構)</p> <div class="btns"> <button type="button" class="btn small" id="src-goto">檢視或更換對照表</button> <button type="button" class="btn small" id="src-clear">清除範例</button> <button type="button" class="btn small" id="src-load" hidden>載入範例資料</button> </div> </div> <p id="source-status" class="status" role="status"></p> <main> <!-- 單筆查詢 --> <section class="panel" id="sec-single" aria-labelledby="h-single"> <h2 id="h-single">單筆查詢</h2> <p class="desc">輸入任何一種號碼,或品名規格的一部分,下面會立刻列出對到的物料;綠色框起來的欄位就是對到的號碼。</p> <label class="lab" for="q">要查的號碼或品名</label> <div class="searchrow"> <input id="q" type="search" autocomplete="off" spellcheck="false" placeholder="例如 CP-88213、KM-2408M,或輸入「交換器」"> <button type="button" class="btn" id="q-clear">清空</button> </div> <div class="chips" id="chips"></div> <p id="single-status" class="status" role="status"></p> <div class="single-out" id="single-out"></div> </section> <!-- 批次查詢 --> <section class="panel" id="sec-batch" aria-labelledby="h-batch"> <h2 id="h-batch">批次查詢</h2> <p class="desc">把客戶訂單上的號碼整段貼進來,一行一個。號碼後面可以接數量,例如 <code>CP-88213 120</code>;每一行只取第一段當作號碼。</p> <div class="batch-in"> <div> <label class="lab" for="batch-input">貼上要查的號碼(一行一個)</label> <textarea id="batch-input" rows="10" spellcheck="false" autocomplete="off"></textarea> <p class="hint">也可以直接從 Excel 複製「號碼、數量」兩欄貼進來,結果會馬上更新。</p> <div class="under"> <button type="button" class="btn small" id="batch-clear">清空這一欄</button> </div> <p id="batch-note" class="hint" hidden></p> </div> <div class="legend"> <h3>每一行的結果只會是下面五種之一</h3> <dl id="legend-list"></dl> <p class="foot">找不到的號碼,工具不會用相近的號碼代替,請向客戶或業管確認。</p> </div> </div> <div class="res-head"> <h3 id="batch-title">查詢結果</h3> <div class="actions"> <button type="button" class="btn primary" id="btn-csv">下載結果(CSV)</button> <button type="button" class="btn" id="btn-copy">複製結果</button> </div> </div> <div class="filters" id="filters" role="group" aria-label="依結果篩選"></div> <p id="filter-note" class="filter-note" hidden></p> <p id="batch-status" class="status" role="status"></p> <div class="copybox" id="copy-fallback" hidden> <label class="lab" for="copy-text">請按 Ctrl+C 複製(Mac 請按 Command+C),再貼到 Excel。</label> <textarea id="copy-text" rows="6" readonly></textarea> </div> <div class="tablewrap"> <table id="batch-table"> <thead><tr><th>#</th><th>輸入號碼</th><th>數量</th><th>結果</th><th>內部料號</th><th>品名規格</th><th>對到的欄位</th><th>提醒</th></tr></thead> <tbody id="batch-body"></tbody> </table> </div> </section> <!-- 料號對照表 --> <section class="panel" id="sec-table" aria-labelledby="h-table"> <details class="tbl" id="table-details"> <summary> <span class="chev" aria-hidden="true"></span> <h2 id="h-table">料號對照表</h2> <span class="sumcount" id="table-count">範例資料 15 筆</span> <span class="toggle-text"><span class="when-closed">展開檢視或更換</span><span class="when-open">收合</span></span> </summary> <div class="tbl-body"> <p class="desc">單筆查詢和批次查詢比對的就是這一份表。</p> <div class="tablewrap"> <table id="ref-table"> <thead><tr><th>內部料號</th><th>客戶料號</th><th>供應商料號</th><th>舊料號</th><th>品名規格</th><th>狀態</th><th>替代料號</th><th>備註</th></tr></thead> <tbody id="ref-body"></tbody> </table> </div> <p id="ref-more" class="hint" hidden></p> <div class="replace"> <h3>換成自己的對照表</h3> <p class="desc">在 Excel 選取整張對照表(含第一列的欄位名稱)複製,貼到下面,再按「用貼上的資料取代對照表」。欄位名稱請用:內部料號、客戶料號、供應商料號、舊料號、品名規格、狀態、替代料號、備註;狀態填「使用中」或「停用」。</p> <label class="lab" for="table-input">貼上新的對照表</label> <textarea id="table-input" rows="7" spellcheck="false" autocomplete="off" placeholder="從 Excel 選取整張對照表複製,貼在這裡(第一列是欄位名稱)"></textarea> <div class="row2"> <button type="button" class="btn primary" id="table-apply">用貼上的資料取代對照表</button> <input type="file" id="table-file" class="sr-only" accept=".csv,.txt,text/csv,text/plain"> <label for="table-file" class="btn">選擇 CSV 檔</label> <span class="filename" id="table-filename"></span> </div> <p id="table-status" class="status" role="status"></p> </div> </div> </details> </section> </main> <footer> <p>資料只在這台電腦的瀏覽器裡計算,不會上傳。</p> <p>元智大學終身教育部|眼見為憑:主管的 AI 工作流程實作課|練習工具</p> </footer> </div> <script> (function(){ 'use strict'; /* ====== 範例資料(全部虛構) ====== */ var SAMPLE_TABLE = [ '內部料號\t客戶料號\t供應商料號\t舊料號\t品名規格\t狀態\t替代料號\t備註', 'IGW-4100-A\tCP-88213\tHX-GW4100\tTL-IG-0410\t工業閘道器,4 埠 LAN+LTE,DIN 軌安裝\t使用中\t\t客戶 A 主力機種', 'IGW-4100-B\tCP-88214\tHX-GW4100W\tTL-IG-0411\t工業閘道器,4 埠 LAN+LTE,寬溫 -40~75°C\t使用中\t\t', 'NWS-2408-A\tNP-SW2408\tKM-2408M\tTL-SW-2408\t管理型交換器,24 埠 GbE+4 埠 SFP\t使用中\t\t', 'NWS-0805-U\tNP-SW0805\tKM-0805U\tTL-SW-0805\t非管理型交換器,8 埠 GbE,金屬外殼\t停用\tNWS-0805-V\t2026 年第 3 季停產', 'NWS-0805-V\tNP-SW0805V\tKM-0805V\t\t非管理型交換器,8 埠 GbE,金屬外殼(改版)\t使用中\t\t取代 NWS-0805-U,外觀與安裝尺寸相同', 'WRT-AX3000\tAN-60231\tQF-AX3000\tTL-RT-3000\tWi-Fi 6 路由器,AX3000 雙頻\t使用中\t\t', 'ANT-5G-02\tAN-51002\tLX-5G-0207\tTL-AN-0502\t5G 天線模組,Sub-6GHz,4×4 MIMO\t使用中\t\t', 'ANT-5G-03\tAN-51002\tLX-5G-0309\t\t5G 天線模組,Sub-6GHz,4×4 MIMO,IP67 防水\t使用中\t\t與 ANT-5G-02 共用客戶料號,請確認客戶要不要防水版', 'PSU-0650-C\tCP-70650\tMW-650-24\tTL-PS-0650\t電源供應模組,650W,24V 輸出\t使用中\t\t', 'PSU-0350-B\tCP-70350\tMW-350-12\tTL-PS-0350\t電源供應模組,350W,12V 輸出\t使用中\t\t', 'IPC-MB-H610\tNP-MB610\tAB-H610-IND\tTL-MB-0610\t工業電腦主機板,Mini-ITX,H610 晶片組\t使用中\t\t', 'PCBA-CTL-310\tCP-31077\tSN-CTL310\tTL-PC-0310\t控制板組件(PCBA),閘道器主控板\t使用中\t\t', 'ENC-AL-210\t\tYF-AL210\tTL-EN-0210\t鋁擠機殼,210 × 120 × 45 mm,陽極處理黑色\t使用中\t\t內部組裝用料,不單獨出貨給客戶', 'CBL-RJ45-3M\tCP-90103\tLK-C6A-3M\tTL-CB-1030\t傳輸線材,Cat.6A 網路線,3 公尺\t使用中\t\t', 'CBL-M12-2M\tNP-CB-M12\tLK-M12X-2M\tTL-CB-1220\t傳輸線材,M12 X-code 轉 RJ45,2 公尺\t使用中\t\t' ].join('\n'); var SAMPLE_BATCH = [ 'CP-88213 120', 'NP-SW0805 60', 'AN-51002 200', 'cp70650 30', 'KM-2408M 40', 'CP-88299 50', 'TL-IG-0410 10', 'IGW-4100 25' ].join('\n'); var SAMPLE_QUERY = 'NP-SW0805'; var CHIPS = [ ['NP-SW0805', '停用料號'], ['AN-51002', '一個號碼對到兩筆'], ['cp70650', '寫法不同'], ['TL-IG-0410', '舊料號'], ['交換器', '用品名找'] ]; /* ====== 常數 ====== */ var FIELDS = [ {key:'internal', label:'內部料號'}, {key:'customer', label:'客戶料號'}, {key:'supplier', label:'供應商料號'}, {key:'old', label:'舊料號'} ]; var LABEL = {internal:'內部料號', customer:'客戶料號', supplier:'供應商料號', old:'舊料號', name:'品名規格'}; var KINDS = ['ONE','STOP','MULTI','FORMAT','NONE']; var KIND_TEXT = { ONE:'對到 1 筆', STOP:'對到停用料號,請改用替代料號', MULTI:'對到 2 筆以上,請人工確認', FORMAT:'格式不同但疑似相同,請人工確認', NONE:'找不到' }; var KIND_TONE = {ONE:'t-ok', STOP:'t-bad', MULTI:'t-warn', FORMAT:'t-info', NONE:'t-none'}; var KIND_EXPLAIN = { ONE:'號碼完全相同,而且是使用中的料號。', STOP:'這顆料已經停用,提醒欄會寫出替代料號。', MULTI:'同一個號碼對到兩個以上的內部料號,請向客戶確認要哪一個。', FORMAT:'大小寫、全形半形、空白或連字號不同,請確認是不是同一個號碼。', NONE:'對照表裡沒有這個號碼。' }; var HEADER_ALIASES = { internal:['內部料號','料號','本公司料號','公司料號','內部編號','品號'], customer:['客戶料號','客戶品號','客戶編號','客戶P/N','Customer P/N','Customer PN'], supplier:['供應商料號','廠商料號','供應商品號','廠商品號','原廠料號','供應商編號','廠商編號','Vendor P/N','MPN'], old:['舊料號','舊品號','舊編號','原料號'], name:['品名規格','品名/規格','品名及規格','品名','規格','描述'], status:['狀態','使用狀態','料號狀態'], replacement:['替代料號','替代品號','取代料號','替代'], note:['備註','附註','說明'] }; var SINGLE_LIMIT = 50; var REF_LIMIT = 300; /* ====== 狀態 ====== */ var state = { rows: [], source: 'sample', // sample | user | empty index: {raw:new Map(), norm:new Map()}, results: [], filter: null }; /* ====== 文字處理 ====== */ function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); } function toHalf(s){ return String(s == null ? '' : s) .replace(/[!-~]/g, function(c){ return String.fromCharCode(c.charCodeAt(0) - 0xFEE0); }) .replace(/ /g, ' '); } var DASHES = /[-‐-―−﹘﹣]/g; /* 正規化:去前後空白、全形轉半形、不分大小寫、去掉空白與連字號 */ function normKey(s){ return toHalf(s).trim().toUpperCase().replace(/\s+/g, '').replace(DASHES, ''); } function normText(s){ return toHalf(s).trim().toLowerCase(); } function headerKey(s){ return normText(s).replace(/\s+/g, ''); } /* ====== 讀取 Tab/逗號分隔文字(支援雙引號欄位) ====== */ function parseDelimited(text){ text = String(text == null ? '' : text).replace(/^/, '').replace(/\r\n?/g, '\n'); var first = ''; var lines = text.split('\n'); for (var k = 0; k < lines.length; k++){ if (lines[k].trim() !== ''){ first = lines[k]; break; } } var d = first.indexOf('\t') >= 0 ? '\t' : ','; var out = [], row = [], cell = '', inQ = false; for (var i = 0; i < text.length; i++){ var c = text[i]; if (inQ){ if (c === '"'){ if (text[i+1] === '"'){ cell += '"'; i++; } else { inQ = false; } } else { cell += c; } } else if (c === '"' && cell.trim() === ''){ inQ = true; cell = ''; } else if (c === d){ row.push(cell); cell = ''; } else if (c === '\n'){ row.push(cell); out.push(row); row = []; cell = ''; } else { cell += c; } } if (cell !== '' || row.length){ row.push(cell); out.push(row); } return out .map(function(r){ return r.map(function(s){ return s.replace(/ /g, ' ').trim(); }); }) .filter(function(r){ return r.some(function(s){ return s !== ''; }); }); } function normStatus(s){ var t = normText(s); if (/停|廢|失效|淘汰|inactive|obsolete|eol|disable/.test(t)) return '停用'; return '使用中'; } function makeRow(o){ var r = { internal: (o.internal || '').trim(), customer: (o.customer || '').trim(), supplier: (o.supplier || '').trim(), old: (o.old || '').trim(), name: (o.name || '').trim(), status: normStatus(o.status), replacement: (o.replacement || '').trim(), note: (o.note || '').trim() }; r._n = {}; FIELDS.forEach(function(F){ r._n[F.key] = normKey(r[F.key]); }); r._name = normText(r.name); return r; } function mapHeader(head){ var map = {}, unknown = []; head.forEach(function(h, i){ var k = headerKey(h), found = null; Object.keys(HEADER_ALIASES).some(function(key){ if (HEADER_ALIASES[key].some(function(a){ return headerKey(a) === k; })){ found = key; return true; } return false; }); if (found){ (map[found] = map[found] || []).push(i); } else if (h) unknown.push(h); }); return {map:map, unknown:unknown}; } function tableFromText(text){ var m = parseDelimited(text); if (!m.length) return {error:'沒有讀到任何資料。請確認已經貼上對照表,或選擇的檔案不是空的。'}; var hm = mapHeader(m[0]), map = hm.map; if (!map.internal) return {error:'第一列找不到「內部料號」欄位。請確認第一列是欄位名稱:內部料號、客戶料號、供應商料號、舊料號、品名規格、狀態、替代料號、備註。'}; var rows = [], noId = 0; for (var r = 1; r < m.length; r++){ var cells = m[r]; var get = function(key){ if (!map[key]) return ''; if (key === 'name') return map[key].map(function(i){ return cells[i] || ''; }).filter(Boolean).join(','); return cells[map[key][0]] || ''; }; var internal = get('internal'); if (!internal){ noId++; continue; } rows.push(makeRow({ internal:internal, customer:get('customer'), supplier:get('supplier'), old:get('old'), name:get('name'), status:get('status'), replacement:get('replacement'), note:get('note') })); } if (!rows.length) return {error:'只讀到欄位名稱,沒有任何一列填了內部料號。'}; var warnings = []; if (noId) warnings.push('有 ' + noId + ' 列沒有內部料號,已略過。'); var seen = {}, dups = []; rows.forEach(function(x){ var k = x.internal; if (seen[k] === 1) dups.push(k); seen[k] = (seen[k] || 0) + 1; }); if (dups.length) warnings.push('內部料號重複出現:' + dups.slice(0, 5).join('、') + (dups.length > 5 ? ' 等 ' + dups.length + ' 個' : '') + ',查詢時會一起列出,請回頭確認對照表。'); var missing = FIELDS.filter(function(F){ return !map[F.key]; }).map(function(F){ return F.label; }); if (!map.name) missing.push('品名規格'); if (!map.status) missing.push('狀態'); if (missing.length) warnings.push('沒有找到這些欄位:' + missing.join('、') + (map.status ? '' : '(沒有狀態欄時,全部視為使用中)') + '。'); if (hm.unknown.length) warnings.push('這些欄位不認得,已略過:' + hm.unknown.join('、') + '。'); return {rows:rows, warnings:warnings}; } /* ====== 索引 ====== */ function pushMap(m, k, v){ var a = m.get(k); if (!a){ a = []; m.set(k, a); } a.push(v); } function buildIndex(rows){ var raw = new Map(), norm = new Map(); rows.forEach(function(r, i){ FIELDS.forEach(function(F){ var v = r[F.key]; if (!v) return; pushMap(raw, v, {i:i, f:F.key}); if (r._n[F.key]) pushMap(norm, r._n[F.key], {i:i, f:F.key}); }); }); return {raw:raw, norm:norm}; } function groupHits(list){ var byRow = new Map(); (list || []).forEach(function(h){ var g = byRow.get(h.i); if (!g){ g = {i:h.i, row:state.rows[h.i], fields:[]}; byRow.set(h.i, g); } if (g.fields.indexOf(h.f) < 0) g.fields.push(h.f); }); return Array.from(byRow.values()).sort(function(a, b){ return a.i - b.i; }); } /* ====== 批次查詢 ====== */ function parseBatch(text){ var lines = String(text == null ? '' : text).replace(/^/, '').replace(/\r\n?/g, '\n').split('\n'); var items = [], header = null; lines.forEach(function(line, li){ if (line.trim() === '') return; var parts; if (line.indexOf('\t') >= 0){ parts = line.split('\t').map(function(s){ return s.trim(); }).filter(function(s){ return s !== ''; }); } else { parts = line.trim().split(/\s+/); } if (parts.length === 1 && /[,,]/.test(parts[0])){ parts = parts[0].split(/[,,]/).map(function(s){ return s.trim(); }).filter(Boolean); } if (!parts.length) return; var code = parts[0], qty = parts.length > 1 ? parts[1] : ''; if (!items.length && header === null && !/\d/.test(code) && /料號|號碼|品號|編號|part|p\/n/i.test(code)){ header = code; return; } items.push({line:li + 1, code:code, qty:qty}); }); return {items:items, header:header}; } function batchLookup(code){ var raw = String(code == null ? '' : code).trim(); var hits = groupHits(state.index.raw.get(raw)); if (hits.length === 1) return {kind: hits[0].row.status === '停用' ? 'STOP' : 'ONE', hits:hits}; if (hits.length > 1) return {kind:'MULTI', hits:hits}; var k = normKey(raw); if (k){ var nh = groupHits(state.index.norm.get(k)); if (nh.length) return {kind:'FORMAT', hits:nh}; } return {kind:'NONE', hits:[]}; } function fieldLabels(hit){ return hit.fields.map(function(f){ return LABEL[f]; }).join('、'); } function unionFields(hits){ var seen = []; hits.forEach(function(h){ h.fields.forEach(function(f){ if (seen.indexOf(f) < 0) seen.push(f); }); }); return FIELDS.filter(function(F){ return seen.indexOf(F.key) >= 0; }).map(function(F){ return F.label; }).join('、'); } function noteFor(kind, code, hits){ var r, s, stopped; switch (kind){ case 'ONE': r = hits[0].row; if (hits[0].fields.indexOf('old') >= 0 && hits[0].fields.indexOf('internal') < 0){ return '客戶寫的是舊料號,系統下單請用 ' + r.internal + '。'; } return ''; case 'STOP': r = hits[0].row; return r.replacement ? '此料號已停用,請改用 ' + r.replacement + '。' : '此料號已停用,對照表沒有登錄替代料號,請向業管確認。'; case 'MULTI': s = '「' + code + '」同時對到 ' + hits.length + ' 個內部料號(' + hits.map(function(h){ return h.row.internal; }).join('、') + '),請向客戶確認要的是哪一個。'; stopped = hits.filter(function(h){ return h.row.status === '停用'; }); if (stopped.length) s += '其中 ' + stopped.map(function(h){ return h.row.internal; }).join('、') + ' 已停用。'; return s; case 'FORMAT': s = '輸入的是「' + code + '」,對照表寫的是' + hits.map(function(h){ return '「' + h.fields.map(function(f){ return h.row[f]; }).filter(function(v, i, a){ return a.indexOf(v) === i; }).join('/') + '」(' + fieldLabels(h) + ')'; }).join('、') + ',請確認是不是同一個號碼。'; stopped = hits.filter(function(h){ return h.row.status === '停用'; }); stopped.forEach(function(h){ s += h.row.internal + ' 已停用' + (h.row.replacement ? ',替代料號是 ' + h.row.replacement : '') + '。'; }); return s; default: return '對照表裡沒有這個號碼,請向客戶或業管確認,不要用相近的號碼代替。'; } } function runBatch(){ var parsed = parseBatch($('#batch-input').value); state.results = parsed.items.map(function(it, n){ var res = batchLookup(it.code); return { n: n + 1, line: it.line, code: it.code, qty: it.qty, kind: res.kind, hits: res.hits, fieldsText: res.hits.length ? unionFields(res.hits) : '', note: noteFor(res.kind, it.code, res.hits) }; }); var note = $('#batch-note'), msgs = []; if (parsed.header) msgs.push('第一行「' + parsed.header + '」看起來是欄位名稱,已略過。'); if (!state.rows.length && state.results.length) msgs.push('目前沒有對照表,所以每一行都會是「找不到」。請先載入對照表。'); note.textContent = msgs.join(' '); note.hidden = !msgs.length; if (state.filter && !countKinds()[state.filter]) state.filter = null; renderBatch(); } function countKinds(){ var c = {ONE:0, STOP:0, MULTI:0, FORMAT:0, NONE:0}; state.results.forEach(function(r){ c[r.kind]++; }); return c; } function currentView(){ return state.filter ? state.results.filter(function(r){ return r.kind === state.filter; }) : state.results.slice(); } function renderBatch(){ var counts = countKinds(), total = state.results.length; $('#batch-title').textContent = total ? '查詢結果(共 ' + total + ' 行)' : '查詢結果'; $('#filters').innerHTML = KINDS.map(function(k){ var pressed = state.filter === k; return '<button type="button" class="fbtn ' + KIND_TONE[k] + '" data-kind="' + k + '" aria-pressed="' + pressed + '"' + (!counts[k] && !pressed ? ' disabled' : '') + '><span class="fl">' + esc(KIND_TEXT[k]) + '</span><span class="fn">' + counts[k] + '</span></button>'; }).join(''); var fnote = $('#filter-note'); if (state.filter){ fnote.textContent = '目前只顯示「' + KIND_TEXT[state.filter] + '」' + counts[state.filter] + ' 行;再點一次同一個按鈕,就會回到全部 ' + total + ' 行。'; fnote.hidden = false; } else { fnote.hidden = true; } var view = currentView(); $('#btn-csv').textContent = '下載結果(CSV,' + view.length + ' 行)'; $('#btn-csv').disabled = !view.length; $('#btn-copy').disabled = !view.length; var body = $('#batch-body'); if (!total){ body.innerHTML = '<tr><td colspan="8" class="dim">請在上面貼上要查的號碼,一行一個。</td></tr>'; return; } body.innerHTML = view.map(function(r){ var stack = function(fn, mono){ if (!r.hits.length) return '<span class="none">—</span>'; return r.hits.map(function(h){ return '<div class="stack' + (mono ? ' mono' : '') + '">' + fn(h) + '</div>'; }).join(''); }; return '<tr data-kind="' + r.kind + '">' + '<td class="nowrap mono">' + r.n + '</td>' + '<td class="nowrap mono"><b>' + esc(r.code) + '</b></td>' + '<td class="nowrap mono">' + (r.qty ? esc(r.qty) : '<span class="none">—</span>') + '</td>' + '<td class="c-res"><span class="tag ' + KIND_TONE[r.kind] + '">' + esc(KIND_TEXT[r.kind]) + '</span></td>' + '<td class="nowrap">' + stack(function(h){ return esc(h.row.internal); }, true) + '</td>' + '<td class="c-name">' + stack(function(h){ return h.row.name ? noteHtml(h.row.name) : '<span class="none">(沒有品名規格)</span>'; }, false) + '</td>' + '<td class="nowrap">' + (r.fieldsText ? esc(r.fieldsText) : '<span class="none">—</span>') + '</td>' + '<td class="c-note">' + (r.note ? noteHtml(r.note) : '<span class="none">—</span>') + '</td>' + '</tr>'; }).join(''); } function noteHtml(t){ return esc(t).replace(/[A-Za-z0-9][A-Za-z0-9.\/]*(?:-[A-Za-z0-9.\/]+)+/g, function(m){ return '<span class="pn">' + m + '</span>'; }); } /* ====== 匯出 ====== */ var EXPORT_HEAD = ['輸入號碼','數量','結果','內部料號','品名規格','對到的欄位','提醒']; function exportMatrix(list){ return (list || currentView()).map(function(r){ return [ r.code, r.qty, KIND_TEXT[r.kind], r.hits.map(function(h){ return h.row.internal; }).join('、'), r.hits.map(function(h){ return h.row.name; }).join(' / '), r.fieldsText, r.note ]; }); } function csvCell(v){ v = String(v == null ? '' : v); return /[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; } function buildBatchCSV(list){ return '' + [EXPORT_HEAD].concat(exportMatrix(list)).map(function(r){ return r.map(csvCell).join(','); }).join('\r\n') + '\r\n'; } function buildBatchTSV(list){ return [EXPORT_HEAD].concat(exportMatrix(list)).map(function(r){ return r.map(function(v){ return String(v == null ? '' : v).replace(/[\t\r\n]+/g, ' '); }).join('\t'); }).join('\r\n'); } function todayStamp(){ var d = new Date(); return d.getFullYear() + String(d.getMonth() + 1).padStart(2, '0') + String(d.getDate()).padStart(2, '0'); } function downloadText(name, text, mime){ var blob = new Blob([text], {type:mime}); var a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name; document.body.appendChild(a); a.click(); setTimeout(function(){ URL.revokeObjectURL(a.href); a.remove(); }, 1500); } /* ====== 單筆查詢 ====== */ var TYPE_RANK = {exact:0, format:1, partial:2, name:3}; var TYPE_TEXT = {exact:'號碼完全相同', format:'大小寫、空白或連字號不同,請確認', partial:'包含你輸入的字', name:'包含你輸入的字'}; function searchRows(q){ var raw = String(q == null ? '' : q).trim(); if (!raw) return []; var qn = normKey(raw); var terms = normText(raw).split(/\s+/).filter(Boolean); var out = []; state.rows.forEach(function(r, i){ var matches = []; FIELDS.forEach(function(F){ var v = r[F.key]; if (!v) return; var type = null; if (v === raw) type = 'exact'; else if (qn && r._n[F.key] === qn) type = 'format'; else if (qn && r._n[F.key].indexOf(qn) >= 0) type = 'partial'; if (type) matches.push({field:F.key, type:type}); }); if (terms.length && r._name && terms.every(function(t){ return r._name.indexOf(t) >= 0; })){ matches.push({field:'name', type:'name'}); } if (matches.length){ var rank = Math.min.apply(null, matches.map(function(m){ return TYPE_RANK[m.type]; })); out.push({i:i, row:r, matches:matches, rank:rank}); } }); out.sort(function(a, b){ return a.rank - b.rank || a.i - b.i; }); return out; } function duplicateWarnings(results){ var groups = new Map(); results.forEach(function(res){ res.matches.forEach(function(m){ if (m.type !== 'exact' && m.type !== 'format') return; var key = m.field + '|' + res.row._n[m.field]; var g = groups.get(key); if (!g){ g = {field:m.field, value:res.row[m.field], rows:[]}; groups.set(key, g); } if (g.rows.indexOf(res.row) < 0) g.rows.push(res.row); }); }); var msgs = []; groups.forEach(function(g){ if (g.rows.length > 1){ msgs.push(LABEL[g.field] + '「' + g.value + '」同時對到 ' + g.rows.length + ' 個內部料號:' + g.rows.map(function(r){ return r.internal; }).join('、') + '。請向客戶確認要的是哪一個。'); } }); return msgs; } function hitLine(matches){ var order = ['exact','format','partial','name'], parts = []; order.forEach(function(t){ var fs = matches.filter(function(m){ return m.type === t; }).map(function(m){ return LABEL[m.field]; }); if (fs.length) parts.push(fs.join('、') + ':' + TYPE_TEXT[t]); }); return parts.join(';'); } function cardHtml(res){ var r = res.row, hit = {}; res.matches.forEach(function(m){ hit[m.field] = m.type; }); var stopped = r.status === '停用'; var h = '<article class="card' + (stopped ? ' stopped' : '') + '" data-internal="' + esc(r.internal) + '">'; h += '<div class="card-top"><div class="card-main">'; h += '<div class="card-id mono">' + esc(r.internal) + '</div>'; h += '<div class="card-name' + (hit.name ? ' hit' : '') + '">' + (r.name ? esc(r.name) : '<span class="none">(沒有品名規格)</span>') + (hit.name ? '<span class="hitmark">對到</span>' : '') + '</div>'; h += '</div><div class="card-state"><span class="tag ' + (stopped ? 't-bad' : 't-ok') + '">' + esc(r.status) + '</span></div></div>'; if (stopped){ h += '<div class="stopbar">'; if (r.replacement){ h += '<span>此料號已停用,請改用 <b class="mono">' + esc(r.replacement) + '</b></span>'; h += '<button type="button" class="btn small" data-goto="' + esc(r.replacement) + '">查詢 ' + esc(r.replacement) + '</button>'; } else { h += '<span>此料號已停用,對照表沒有登錄替代料號,請向業管確認。</span>'; } h += '</div>'; } h += '<p class="hitline"><span class="hitmark">對到</span>' + esc(hitLine(res.matches)) + '</p>'; h += '<div class="fields">' + FIELDS.map(function(F){ var v = r[F.key], isHit = !!hit[F.key]; return '<div class="field' + (isHit ? ' hit' : '') + '" data-field="' + F.key + '">' + '<div class="flabel"><span>' + F.label + '</span>' + (isHit ? '<span class="hitmark">對到</span>' : '') + '</div>' + '<div class="fval' + (v ? ' mono' : ' none') + '">' + (v ? esc(v) : '(無)') + '</div></div>'; }).join('') + '</div>'; if (r.note) h += '<p class="cnote">備註:' + esc(r.note) + '</p>'; return h + '</article>'; } function renderSingle(){ var q = $('#q').value, out = $('#single-out'), st = $('#single-status'); if (!state.rows.length){ setStatus(st, '', null); out.innerHTML = '<p class="empty">目前沒有對照表。請按上方「檢視或更換對照表」,貼上你的料號對照表;或按「載入範例資料」看看怎麼用。</p>'; return; } if (!q.trim()){ setStatus(st, '', null); out.innerHTML = '<p class="empty">請在上面的搜尋框輸入號碼或品名,結果會馬上列在這裡。</p>'; return; } var res = searchRows(q); if (!res.length){ setStatus(st, '找不到符合「' + q.trim() + '」的物料。請確認號碼是否完整,或改用品名的一部分查詢。', 't-none'); out.innerHTML = ''; return; } var msg = '找到 ' + res.length + ' 筆。'; if (res.length > SINGLE_LIMIT) msg = '找到 ' + res.length + ' 筆,只列出前 ' + SINGLE_LIMIT + ' 筆,請輸入更完整的號碼。'; setStatus(st, msg, 't-ok'); var warns = duplicateWarnings(res); out.innerHTML = warns.map(function(w){ return '<p class="notice t-warn"><b>請注意:</b>' + esc(w) + '</p>'; }).join('') + res.slice(0, SINGLE_LIMIT).map(cardHtml).join(''); } /* ====== 對照表檢視 ====== */ function renderRef(){ var n = state.rows.length; var label = state.source === 'sample' ? '範例資料 ' + n + ' 筆' : (n ? '你貼上的資料 ' + n + ' 筆' : '目前是空的'); $('#table-count').textContent = label; $('#source-text').textContent = state.source === 'sample' ? '目前使用的對照表:範例資料 ' + n + ' 筆(虛構)' : (n ? '目前使用的對照表:你貼上的資料 ' + n + ' 筆' : '目前沒有對照表,請先貼上你的料號對照表。'); $('#src-clear').hidden = state.source !== 'sample'; $('#src-load').hidden = state.source === 'sample'; var body = $('#ref-body'); if (!n){ body.innerHTML = '<tr><td colspan="8" class="dim">目前沒有資料。請在下方貼上對照表,或選擇 CSV 檔。</td></tr>'; } else { body.innerHTML = state.rows.slice(0, REF_LIMIT).map(function(r){ var cell = function(v){ return v ? '<td class="nowrap mono">' + esc(v) + '</td>' : '<td class="dim">—</td>'; }; return '<tr>' + cell(r.internal) + cell(r.customer) + cell(r.supplier) + cell(r.old) + '<td class="c-name">' + (r.name ? noteHtml(r.name) : '<span class="none">—</span>') + '</td>' + '<td class="nowrap"><span class="tag ' + (r.status === '停用' ? 't-bad' : 't-ok') + '">' + r.status + '</span></td>' + cell(r.replacement) + '<td class="c-note">' + (r.note ? noteHtml(r.note) : '<span class="none">—</span>') + '</td></tr>'; }).join(''); } var more = $('#ref-more'); more.hidden = n <= REF_LIMIT; if (n > REF_LIMIT) more.textContent = '對照表共 ' + n + ' 筆,這裡只列出前 ' + REF_LIMIT + ' 筆;查詢時會比對全部資料。'; } /* ====== 共用 ====== */ function $(s){ return document.querySelector(s); } function setStatus(el, text, tone){ el.className = 'status' + (tone ? ' ' + tone : ''); el.textContent = text || ''; } function setRows(rows, source){ state.rows = rows; state.source = source; state.index = buildIndex(rows); renderRef(); renderSingle(); runBatch(); } function loadSample(){ var t = tableFromText(SAMPLE_TABLE); $('#q').value = SAMPLE_QUERY; $('#batch-input').value = SAMPLE_BATCH; state.filter = null; $('#table-filename').textContent = ''; setRows(t.rows, 'sample'); renderChips(); } function renderChips(){ var box = $('#chips'); if (state.source !== 'sample'){ box.innerHTML = ''; box.hidden = true; return; } box.hidden = false; box.innerHTML = '<span>試試看:</span>' + CHIPS.map(function(c){ return '<button type="button" class="chip" data-q="' + esc(c[0]) + '"><span class="mono">' + esc(c[0]) + '</span><span class="cap">' + esc(c[1]) + '</span></button>'; }).join(''); } /* ====== 事件 ====== */ var batchTimer = null; $('#q').addEventListener('input', renderSingle); $('#q-clear').addEventListener('click', function(){ $('#q').value = ''; renderSingle(); $('#q').focus(); }); $('#chips').addEventListener('click', function(e){ var b = e.target.closest('[data-q]'); if (!b) return; $('#q').value = b.getAttribute('data-q'); renderSingle(); }); $('#single-out').addEventListener('click', function(e){ var b = e.target.closest('[data-goto]'); if (!b) return; $('#q').value = b.getAttribute('data-goto'); renderSingle(); $('#q').focus(); }); $('#batch-input').addEventListener('input', function(){ clearTimeout(batchTimer); batchTimer = setTimeout(runBatch, 120); }); $('#batch-clear').addEventListener('click', function(){ $('#batch-input').value = ''; state.filter = null; runBatch(); setStatus($('#batch-status'), '', null); $('#batch-input').focus(); }); $('#filters').addEventListener('click', function(e){ var b = e.target.closest('[data-kind]'); if (!b || b.disabled) return; var k = b.getAttribute('data-kind'); state.filter = state.filter === k ? null : k; renderBatch(); var again = document.querySelector('#filters [data-kind="' + k + '"]'); if (again) again.focus(); }); $('#btn-csv').addEventListener('click', function(){ var view = currentView(); if (!view.length) return; downloadText('一物多號查詢結果_' + todayStamp() + '.csv', buildBatchCSV(view), 'text/csv;charset=utf-8'); setStatus($('#batch-status'), '已下載 ' + view.length + ' 行查詢結果(CSV),用 Excel 開啟中文不會變成亂碼。', 't-ok'); }); $('#btn-copy').addEventListener('click', function(){ var view = currentView(); if (!view.length) return; var text = buildBatchTSV(view); var box = $('#copy-fallback'), st = $('#batch-status'); var fallback = function(){ box.hidden = false; var ta = $('#copy-text'); ta.value = text; ta.focus(); ta.select(); setStatus(st, '這台電腦不允許直接複製。下方的文字已經全部選取,請按 Ctrl+C 複製(Mac 請按 Command+C),再貼到 Excel。', 't-warn'); }; try { if (!navigator.clipboard || !navigator.clipboard.writeText) throw new Error('no clipboard'); navigator.clipboard.writeText(text).then(function(){ box.hidden = true; setStatus(st, '已複製 ' + view.length + ' 行(含欄位名稱),可以直接貼到 Excel。', 't-ok'); }, fallback); } catch (err){ fallback(); } }); function applyTableText(text, from){ var st = $('#table-status'); if (/\uFFFD/.test(text)){ setStatus(st, '檔案看起來不是 UTF-8 編碼,中文會變成亂碼,所以沒有載入。請在 Excel 另存新檔時選擇「CSV UTF-8(逗號分隔)」。', 't-bad'); return false; } var t = tableFromText(text); if (t.error){ setStatus(st, t.error, 't-bad'); return false; } state.filter = null; setRows(t.rows, 'user'); renderChips(); setStatus(st, '已' + (from ? '從「' + from + '」' : '') + '換成你的對照表,共 ' + t.rows.length + ' 筆。' + (t.warnings.length ? ' ' + t.warnings.join(' ') : ''), t.warnings.length ? 't-warn' : 't-ok'); setStatus($('#source-status'), '', null); return true; } $('#table-apply').addEventListener('click', function(){ var text = $('#table-input').value; if (!text.trim()){ setStatus($('#table-status'), '貼上欄是空的。請先從 Excel 複製對照表貼進來,或選擇 CSV 檔。', 't-warn'); return; } applyTableText(text, ''); }); $('#table-file').addEventListener('change', function(e){ var f = e.target.files && e.target.files[0]; if (!f) return; $('#table-filename').textContent = f.name; var reader = new FileReader(); reader.onload = function(){ var text = String(reader.result || '').replace(/^/, ''); if (applyTableText(text, f.name)) $('#table-input').value = text; e.target.value = ''; }; reader.onerror = function(){ setStatus($('#table-status'), '讀不到這個檔案,請確認檔案沒有被其他程式開啟。', 't-bad'); }; reader.readAsText(f, 'utf-8'); }); $('#src-goto').addEventListener('click', function(){ var d = $('#table-details'); d.open = true; var ta = $('#table-input'); ta.scrollIntoView({block:'center'}); ta.focus({preventScroll:true}); }); $('#src-clear').addEventListener('click', function(){ $('#q').value = ''; $('#batch-input').value = ''; $('#table-input').value = ''; $('#table-filename').textContent = ''; state.filter = null; setRows([], 'empty'); renderChips(); setStatus($('#batch-status'), '', null); setStatus($('#table-status'), '', null); $('#copy-fallback').hidden = true; setStatus($('#source-status'), '已清除範例資料。請按「檢視或更換對照表」貼上你的對照表,再到「批次查詢」貼上訂單號碼。', 't-ok'); }); $('#src-load').addEventListener('click', function(){ loadSample(); setStatus($('#table-status'), '', null); setStatus($('#batch-status'), '', null); $('#copy-fallback').hidden = true; setStatus($('#source-status'), '已重新載入範例資料。', 't-ok'); }); /* 圖例 */ $('#legend-list').innerHTML = KINDS.map(function(k){ return '<dt><span class="tag ' + KIND_TONE[k] + '">' + esc(KIND_TEXT[k]) + '</span></dt><dd>' + esc(KIND_EXPLAIN[k]) + '</dd>'; }).join(''); /* 供測試使用 */ window.T2 = { normKey:normKey, parseDelimited:parseDelimited, parseBatch:parseBatch, batchLookup:batchLookup, tableFromText:tableFromText, searchRows:searchRows, buildBatchCSV:buildBatchCSV, buildBatchTSV:buildBatchTSV, KIND_TEXT:KIND_TEXT, state:state }; loadSample(); })(); </script> </body> </html>
<!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>客戶交期變更追蹤表</title> <style> :root{ --bg:#f4f6f3; --surface:#ffffff; --surface-2:#eaf0ec; --line:#cfd9d3; --ink:#16221d; --ink-2:#4a5a52; --accent:#0b6b4f; /* 防焊綠 */ --accent-ink:#ffffff; --copper:#a8601f; /* 銅箔 */ --ok:#1f7a45; --ok-bg:#e3f3e8; --warn:#8a5900; --warn-bg:#fdf1d8; --bad:#b3261e; --bad-bg:#fbe4e1; --info:#1d5f9e; --info-bg:#e3eefa; --focus:#c27a2c; --sans:"Microsoft JhengHei","PingFang TC","Noto Sans TC","Heiti TC",sans-serif; --mono:"Cascadia Mono",Consolas,"SF Mono",ui-monospace,monospace; color-scheme:light; } @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } } :root[data-theme="dark"]{ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } *,*::before,*::after{box-sizing:border-box} html{-webkit-text-size-adjust:100%;text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--sans);font-size:16px;line-height:1.6} .wrap{box-sizing:content-box;max-width:1180px;margin-inline:auto; padding-inline:max(16px, env(safe-area-inset-left)) max(16px, env(safe-area-inset-right))} .mono{font-family:var(--mono);font-variant-numeric:tabular-nums} .sr-only{position:absolute!important;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} :focus-visible{outline:3px solid var(--focus);outline-offset:2px} button,input,select,textarea{font:inherit;color:inherit} /* 頁首 */ .site-head{position:relative;display:flex;flex-wrap:wrap;align-items:flex-start;justify-content:space-between; gap:10px 24px;padding:28px 0 18px;border-bottom:3px solid var(--accent);margin-bottom:24px} .site-head::after{content:"";position:absolute;left:0;bottom:-3px;width:48px;height:3px;background:var(--copper)} .site-head h1{margin:0;font-size:28px;line-height:1.3;font-weight:800;letter-spacing:.02em} .lede{margin:6px 0 0;color:var(--ink-2);font-size:17px;max-width:44em} .fiction{flex:none;display:inline-block;margin-top:6px;padding:3px 12px;border:1px solid var(--copper);color:var(--copper); border-radius:999px;font-size:14px;font-weight:700;white-space:nowrap} /* 區塊 */ .panel{background:var(--surface);border:1px solid var(--line);border-radius:10px;padding:20px 18px;margin-bottom:22px} .panel>h2{margin:0 0 14px;font-size:21px;line-height:1.35} .panel>h2::before{content:"";display:inline-block;width:10px;height:10px;margin-right:10px;border-radius:2px; background:var(--copper);vertical-align:middle;transform:translateY(-2px)} h3{margin:0 0 12px;font-size:18px;line-height:1.4} /* 按鈕 */ .btn{display:inline-flex;align-items:center;justify-content:center;gap:6px;min-height:44px;padding:8px 16px; border:2px solid var(--accent);border-radius:8px;background:var(--surface);color:var(--accent); font-size:16px;font-weight:700;line-height:1.3;cursor:pointer;text-align:center} .btn:hover{background:var(--surface-2)} .btn.primary{background:var(--accent);color:var(--accent-ink)} .btn.primary:hover{opacity:.9} .btn:disabled{opacity:.45;cursor:not-allowed} .btn.small{min-height:40px;padding:6px 12px;font-size:15px} .btn.quiet{border-color:var(--line);color:var(--ink-2);font-weight:600} .btn.quiet:hover{border-color:var(--ink-2);color:var(--ink)} /* 燈號標籤 */ .tag{display:inline-block;padding:2px 10px;border-radius:999px;border:1px solid currentColor;font-weight:800;font-size:15px;line-height:1.5;white-space:nowrap} .tag-red{background:var(--bad-bg);color:var(--bad)} .tag-yellow{background:var(--warn-bg);color:var(--warn)} .tag-green{background:var(--ok-bg);color:var(--ok)} /* 燈號統計 */ .stats-grid{display:grid;grid-template-columns:minmax(0,5fr) minmax(0,6fr);gap:18px 24px;align-items:start} .stat-row{display:flex;gap:12px} .stat{flex:1 1 0;min-width:0;display:flex;flex-direction:column;align-items:flex-start;gap:0;padding:10px 14px; border-radius:10px;border:3px solid transparent;cursor:pointer;text-align:left;min-height:104px} .stat-red{background:var(--bad-bg);color:var(--bad)} .stat-yellow{background:var(--warn-bg);color:var(--warn)} .stat-green{background:var(--ok-bg);color:var(--ok)} .stat:hover{border-color:color-mix(in srgb, currentColor 45%, transparent)} .stat[aria-pressed="true"]{border-color:currentColor} .stat-name{font-weight:800;font-size:18px} .stat-num{font-size:38px;font-weight:800;line-height:1.15} .stat-state{display:none;font-size:14px;font-weight:800} .stat[aria-pressed="true"] .stat-state{display:inline} .stat-note{margin:10px 0 0;color:var(--ink-2);font-size:15px} .rules{background:var(--surface-2);border-radius:10px;padding:14px 18px;border-left:4px solid var(--copper)} .rules h3{margin:0 0 10px} .rules ul{list-style:none;margin:0;padding:0;display:grid;gap:8px} .rules li{display:grid;grid-template-columns:auto minmax(0,1fr);gap:10px;align-items:start} .rules li .tag{margin-top:1px} .rules-note{margin:12px 0 0;font-size:15px;color:var(--ink-2)} /* 工具列 */ .toolbar{display:flex;flex-wrap:wrap;align-items:center;gap:10px 12px} .toolbar + .toolbar{margin-top:12px} .filters{padding:12px 14px;background:var(--surface-2);border-radius:8px} .check{display:inline-flex;align-items:center;gap:8px;min-height:44px;font-weight:700;cursor:pointer} .check input{width:22px;height:22px;margin:0;accent-color:var(--accent)} .inline-field{display:inline-flex;align-items:center;gap:8px} .inline-field label{font-weight:700} select,input[type="text"],input[type="date"],input[type="number"],textarea{ min-height:44px;padding:8px 10px;border:1px solid var(--ink-2);border-radius:6px;background:var(--surface);font-size:16px} select{min-width:160px} .count{margin:0 0 0 auto;color:var(--ink-2);font-size:15px} .count strong{color:var(--ink)} /* 訊息 */ .msg{margin-top:12px;padding:10px 14px;border-radius:8px;border-left:4px solid var(--info);background:var(--info-bg); overflow-wrap:anywhere} .msg:empty{display:none} .msg.ok{border-left-color:var(--ok);background:var(--ok-bg)} .msg.warn{border-left-color:var(--warn);background:var(--warn-bg)} .msg.bad{border-left-color:var(--bad);background:var(--bad-bg)} .msg ul{margin:6px 0 0;padding-left:1.4em} .msg .btn{margin-left:8px;vertical-align:middle} .subpanel{margin-top:12px;padding:14px;border:2px solid var(--accent);border-radius:10px;background:var(--surface)} .subpanel label{display:block;font-weight:700;margin-bottom:6px} .subpanel textarea{width:100%;font-size:16px;line-height:1.7;resize:vertical} .row-btns{display:flex;flex-wrap:wrap;gap:10px;margin-top:10px} /* 表格 */ .table-wrap{position:relative;margin-top:14px;overflow-x:auto;border:1px solid var(--line);border-radius:8px;background:var(--surface)} table{border-collapse:collapse;width:100%;font-size:15px} th,td{padding:10px 6px;text-align:left;vertical-align:middle;border-bottom:1px solid var(--line)} thead th{background:var(--surface-2);font-weight:800;font-size:15px;line-height:1.4;vertical-align:bottom} thead th small{display:block;font-weight:600;color:var(--ink-2);font-size:13px;white-space:nowrap} tbody tr:last-child td{border-bottom:0} .del{white-space:nowrap;padding-inline:10px} tbody tr:hover td{background:color-mix(in srgb, var(--surface-2) 55%, transparent)} .nowrap{white-space:nowrap} .why{display:block;margin-top:4px;font-size:13px;line-height:1.4;color:var(--ink-2);white-space:nowrap} .sub{display:block;margin-top:2px;color:var(--ink-2)} .strong{font-weight:700} .wd{font-family:var(--sans)} .from{color:var(--ink-2)} .to{font-weight:700} .adv{color:var(--bad);font-weight:800} .delay{color:var(--ink)} .same{color:var(--ink-2)} .qdiff{font-size:14px} .qdiff.up{color:var(--info);font-weight:700} .qdiff.down{color:var(--copper);font-weight:700} .soon{font-weight:800} td.note{min-width:6em;max-width:16em;color:var(--ink-2)} .toggle{white-space:nowrap;min-height:40px;min-width:62px;padding:4px 12px;border-radius:8px;border:2px solid currentColor;font-weight:800;cursor:pointer} .toggle.pending{background:var(--warn-bg);color:var(--warn)} .toggle.done{background:var(--ok-bg);color:var(--ok)} .toggle:hover{opacity:.85} .empty{padding:28px 16px;text-align:center;color:var(--ink-2)} .empty .btn{margin-top:10px} @keyframes flash{from{background:color-mix(in srgb, var(--copper) 28%, transparent)}to{background:transparent}} tr.flash td{animation:flash 2.4s ease-out} .scroll-hint{display:none;margin:8px 0 0;font-size:14px;color:var(--ink-2)} /* 新增區 */ .input-grid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:20px;align-items:start} .card{border:1px solid var(--line);border-radius:10px;padding:18px;background:var(--surface);min-width:0} .fgrid{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:12px 14px;align-items:end} .field{min-width:0} .field label,.field legend{display:block;font-weight:700;margin-bottom:4px;padding:0} .field input,.field select{width:100%} .field.full{grid-column:1/-1} .req{color:var(--copper);font-size:14px;font-weight:700;margin-left:4px} .hint{display:block;font-weight:400;color:var(--ink-2);font-size:14px} fieldset.field{border:0;margin:0;padding:0} .seg{display:flex;gap:8px} .seg label{flex:1 1 0;display:flex;align-items:center;justify-content:center;gap:8px;min-height:44px;margin:0; border:1px solid var(--ink-2);border-radius:6px;font-weight:700;cursor:pointer} .seg input{width:20px;height:20px;margin:0;accent-color:var(--accent)} .seg label:has(input:checked){border-width:2px;border-color:var(--accent);background:var(--surface-2)} [aria-invalid="true"]{border-color:var(--bad)!important;border-width:2px!important;background:var(--bad-bg)!important} .card textarea{width:100%;font-family:var(--mono);font-size:15px;line-height:1.55;resize:vertical;min-height:190px} .card p.help{margin:0 0 10px;color:var(--ink-2);font-size:15px} .file-row{margin-top:12px} .file-row label{display:block;font-weight:700;margin-bottom:4px} .file-row input{max-width:100%;font-size:15px} .paste-label{display:block;font-weight:700;margin-bottom:4px} /* 頁尾 */ .site-foot{margin:8px 0 32px;padding-top:14px;border-top:1px solid var(--line);color:var(--ink-2);font-size:15px} .site-foot p{margin:2px 0} @media (max-width:900px){ .stats-grid,.input-grid{grid-template-columns:minmax(0,1fr)} .scroll-hint{display:block} td.note{min-width:12em} } @media (max-width:560px){ .panel{padding:16px 14px} .site-head h1{font-size:25px} .fgrid{grid-template-columns:minmax(0,1fr)} .stat-row{gap:8px} .stat{padding:8px 10px;min-height:92px} .stat-num{font-size:32px} .count{margin-left:0;flex-basis:100%} .inline-field{flex-wrap:wrap} .toolbar .btn{flex:1 1 auto} } @media (prefers-reduced-motion: reduce){ *,*::before,*::after{animation:none!important;transition:none!important;scroll-behavior:auto!important} } </style> </head> <body> <div class="wrap"> <header class="site-head"> <div> <h1>客戶交期變更追蹤表</h1> <p class="lede">記下客戶在 LINE、Email、電話或會議裡改的交期與數量,追蹤業管有沒有把變更輸入系統,並把新交期放進 Outlook 行事曆。</p> </div> <span class="fiction">練習資料皆為虛構</span> </header> <main> <!-- 燈號統計與規則 --> <section class="panel" aria-labelledby="hStats"> <h2 id="hStats">燈號統計</h2> <div class="stats-grid"> <div> <div class="stat-row" role="group" aria-label="依燈號篩選"> <button type="button" class="stat stat-red" data-light="red" aria-pressed="false"> <span class="stat-name">紅燈</span><span class="stat-num mono" id="cntRed">0</span><span class="stat-state">篩選中</span> </button> <button type="button" class="stat stat-yellow" data-light="yellow" aria-pressed="false"> <span class="stat-name">黃燈</span><span class="stat-num mono" id="cntYellow">0</span><span class="stat-state">篩選中</span> </button> <button type="button" class="stat stat-green" data-light="green" aria-pressed="false"> <span class="stat-name">綠燈</span><span class="stat-num mono" id="cntGreen">0</span><span class="stat-state">篩選中</span> </button> </div> <p class="stat-note">點一下只看這一類,再點一次取消。</p> </div> <aside class="rules" aria-labelledby="hRules"> <h3 id="hRules">燈號規則</h3> <ul> <li><span class="tag tag-red">紅燈</span><span><span class="sr-only">:</span>交期提前,而且距離新交期 7 天以內;或業管未改,而且距離新交期 3 天以內。</span></li> <li><span class="tag tag-yellow">黃燈</span><span><span class="sr-only">:</span>業管未改;或交期提前,而且距離新交期 14 天以內。</span></li> <li><span class="tag tag-green">綠燈</span><span><span class="sr-only">:</span>其他情況。</span></li> </ul> <p class="rules-note">天數以今天 <span class="mono" id="todayText"></span> 計算;新交期已經過了,也算在「幾天以內」。</p> </aside> </div> </section> <!-- 變更清單 --> <section class="panel" aria-labelledby="hList"> <h2 id="hList">變更清單</h2> <div class="toolbar filters"> <label class="check" for="onlyPending"><input type="checkbox" id="onlyPending">只看業管未改</label> <span class="inline-field"><label for="custFilter">客戶</label><select id="custFilter"><option value="">全部客戶</option></select></span> <button type="button" class="btn small quiet" id="btnClearFilter" hidden>清除篩選</button> <p class="count" id="listCount" aria-live="polite"></p> </div> <div class="toolbar"> <button type="button" class="btn primary" id="btnRemind">產生提醒訊息</button> <button type="button" class="btn primary" id="btnIcs">下載行事曆檔(.ics)</button> <button type="button" class="btn" id="btnCsv">匯出 CSV</button> <button type="button" class="btn" id="btnCopy">複製結果</button> <button type="button" class="btn quiet" id="btnClearSample">清除範例</button> </div> <div id="listMsg" class="msg" role="status" aria-live="polite"></div> <div id="remindPanel" class="subpanel" hidden> <label for="remindText">提醒訊息(可以直接貼到 LINE 或 Email,也可以先修改文字)</label> <textarea id="remindText" rows="8" spellcheck="false"></textarea> <div class="row-btns"> <button type="button" class="btn primary" id="btnCopyRemind">複製</button> <button type="button" class="btn" id="btnCloseRemind">關閉</button> </div> </div> <div id="copyFallback" class="subpanel" hidden> <label for="copyFallbackText">要複製的內容(已經全選,請按 Ctrl+C 複製;Mac 請按 Command+C)</label> <textarea id="copyFallbackText" rows="6" spellcheck="false"></textarea> <div class="row-btns"><button type="button" class="btn" id="btnCloseFallback">關閉</button></div> </div> <div class="table-wrap" tabindex="0" role="region" aria-label="變更清單表格"> <table id="tbl"> <thead> <tr> <th scope="col">燈號</th> <th scope="col">提出日期<small>來源</small></th> <th scope="col">客戶</th> <th scope="col">訂單編號<small>料號</small></th> <th scope="col">原交期 → 新交期</th> <th scope="col">交期差</th> <th scope="col">原數量 → 新數量</th> <th scope="col">距新交期</th> <th scope="col">業管<small>點一下切換</small></th> <th scope="col">備註</th> <th scope="col"><span class="sr-only">刪除</span></th> </tr> </thead> <tbody id="tbody"></tbody> </table> </div> <p class="scroll-hint">表格可以左右滑動,查看所有欄位。</p> </section> <!-- 新增 --> <section class="panel" aria-labelledby="hInput"> <h2 id="hInput">新增變更</h2> <div class="input-grid"> <form id="addForm" class="card" novalidate aria-labelledby="hForm"> <h3 id="hForm">新增一筆</h3> <div class="fgrid"> <div class="field"> <label for="fRaised">提出日期</label> <input type="date" id="fRaised"> </div> <div class="field"> <label for="fSource">來源</label> <select id="fSource"> <option>LINE</option><option>Email</option><option>電話</option><option>會議</option> </select> </div> <div class="field"> <label for="fCustomer">客戶<span class="req">必填</span></label> <input type="text" id="fCustomer" list="custList" autocomplete="off"> <datalist id="custList"></datalist> </div> <div class="field"> <label for="fOrder">訂單編號<span class="req">必填</span></label> <input type="text" id="fOrder" class="mono" autocomplete="off" placeholder="例如 SO-26-1101"> </div> <div class="field"> <label for="fPart">料號<span class="req">必填</span></label> <input type="text" id="fPart" class="mono" autocomplete="off" placeholder="例如 NWS-2408-A"> </div> <fieldset class="field"> <legend>業管是否已改系統</legend> <div class="seg"> <label for="fDoneNo"><input type="radio" name="fDone" id="fDoneNo" value="0" checked>未改</label> <label for="fDoneYes"><input type="radio" name="fDone" id="fDoneYes" value="1">已改</label> </div> </fieldset> <div class="field"> <label for="fOrig">原交期<span class="req">必填</span></label> <input type="date" id="fOrig"> </div> <div class="field"> <label for="fNew">新交期<span class="hint">交期沒有改可以留白</span></label> <input type="date" id="fNew"> </div> <div class="field"> <label for="fOq">原數量</label> <input type="number" id="fOq" class="mono" min="0" step="1" inputmode="numeric"> </div> <div class="field"> <label for="fNq">新數量<span class="hint">數量沒有改可以留白</span></label> <input type="number" id="fNq" class="mono" min="0" step="1" inputmode="numeric"> </div> <div class="field full"> <label for="fNote">備註</label> <input type="text" id="fNote" autocomplete="off" placeholder="例如:客戶要求分兩批出貨"> </div> </div> <div class="row-btns"> <button type="submit" class="btn primary">新增到清單</button> <button type="button" class="btn" id="btnResetForm">清空表單</button> </div> <div id="formMsg" class="msg" role="status" aria-live="polite"></div> </form> <div class="card"> <h3 id="hPaste">從 Excel 貼上多筆</h3> <p class="help">在 Excel 選好整塊表格(第一列是欄位名稱)按 Ctrl+C,再到下面的框按 Ctrl+V。欄位要有「客戶、訂單編號、料號、原交期、新交期」,可以再加「提出日期、原數量、新數量、來源、業管是否已改系統、備註」,順序不拘。日期可以寫 2026/10/20、2026-10-20 或 10/20。</p> <label class="paste-label" for="pasteBox">貼上的表格</label> <textarea id="pasteBox" rows="8" spellcheck="false" wrap="off"></textarea> <div class="file-row"> <label for="csvFile">或選擇 CSV 檔</label> <input type="file" id="csvFile" accept=".csv,.txt,text/csv,text/plain"> </div> <div class="row-btns"> <button type="button" class="btn primary" id="btnAppend">加入清單</button> <button type="button" class="btn" id="btnReplace">取代整份清單</button> <button type="button" class="btn quiet" id="btnPasteDemo">放入兩筆示範資料</button> </div> <div id="pasteMsg" class="msg" role="status" aria-live="polite"></div> </div> </div> </section> </main> <footer class="site-foot"> <p>資料只在這台電腦的瀏覽器裡計算,不會上傳。</p> <p>元智大學終身教育部|眼見為憑:主管的 AI 工作流程實作課|練習工具</p> </footer> </div> <script> (function(){ 'use strict'; /* ---------- 日期工具(以「天」為單位的整數,避開時區與日光節約) ---------- */ var DAY = 86400000; var WD = '日一二三四五六'; function dayOf(y, m, d){ return Math.round(Date.UTC(y, m - 1, d) / DAY); } function ymd(n){ var t = new Date(n * DAY); return {y:t.getUTCFullYear(), m:t.getUTCMonth() + 1, d:t.getUTCDate(), w:t.getUTCDay()}; } function todayDay(){ var t = new Date(); return dayOf(t.getFullYear(), t.getMonth() + 1, t.getDate()); } function p2(n){ return (n < 10 ? '0' : '') + n; } function iso(n){ var a = ymd(n); return a.y + '-' + p2(a.m) + '-' + p2(a.d); } function wdText(n){ return '(' + WD[ymd(n).w] + ')'; } function fmtDate(n){ return iso(n) + wdText(n); } function icsDate(n){ var a = ymd(n); return '' + a.y + p2(a.m) + p2(a.d); } function md(n, today){ var a = ymd(n), t = ymd(today); return a.y === t.y ? (a.m + '/' + a.d) : (a.y + '/' + a.m + '/' + a.d); } function clean(v){ return String(v == null ? '' : v).replace(/^[\s\u3000\u00a0]+|[\s\u3000\u00a0]+$/g, ''); } function nfkc(v){ return clean(String(v == null ? '' : v).normalize('NFKC')); } /* 接受 2026/10/20、2026-10-20、2026.10.20、2026年10月20日、20261020、10/20(補今年)、115/10/20(民國年)、2026-10-20(二) */ function parseDate(raw, today){ var s = nfkc(raw); if (!s) return null; s = s.replace(/\s*\([^)]*\)\s*$/, '') .replace(/\s*(星期|週|周|禮拜)[一二三四五六日天]\s*$/, '') .replace(/[ T]\d{1,2}:\d{2}(:\d{2})?.*$/, '') .trim(); var y, m, d, mt; if ((mt = s.match(/^(\d{2,4})\s*[\/\-.年]\s*(\d{1,2})\s*[\/\-.月]\s*(\d{1,2})\s*日?$/))) { y = +mt[1]; m = +mt[2]; d = +mt[3]; if (mt[1].length === 3) y += 1911; else if (mt[1].length === 2) y += 2000; } else if ((mt = s.match(/^(\d{4})(\d{2})(\d{2})$/))) { y = +mt[1]; m = +mt[2]; d = +mt[3]; } else if ((mt = s.match(/^(\d{1,2})\s*[\/\-.月]\s*(\d{1,2})\s*日?$/))) { y = ymd(today == null ? todayDay() : today).y; m = +mt[1]; d = +mt[2]; } else if (/^\d{5}$/.test(s) && +s >= 36526 && +s <= 73050) { return +s - 25569; /* Excel 日期序號 */ } else { return null; } if (m < 1 || m > 12 || d < 1 || d > 31 || y < 1900 || y > 2200) return null; var n = dayOf(y, m, d), c = ymd(n); if (c.y !== y || c.m !== m || c.d !== d) return null; return n; } function parseQty(raw){ var s = nfkc(raw).replace(/[,\s]/g, '').replace(/(pcs|pc|ea|個|片|台|套|顆|條|只|支|組|件)$/i, ''); if (!s) return {ok:true, v:null}; if (!/^-?\d+(\.\d+)?$/.test(s)) return {ok:false, v:null}; return {ok:true, v:Number(s)}; } function fmtQty(v){ return v == null ? '—' : v.toLocaleString('zh-TW'); } function normSource(raw){ var s = nfkc(raw), l = s.toLowerCase(); if (!s) return '未填'; if (l.indexOf('line') > -1) return 'LINE'; if (/e-?mail|郵件|信件|^mail$|^信$/.test(l)) return 'Email'; if (/電話|phone|^tel|手機|來電/.test(l)) return '電話'; if (/會議|meeting|開會|會談/.test(l)) return '會議'; return s; } function normDone(raw){ var s = nfkc(raw).toLowerCase(); if (!s) return false; if (/^(未|否|沒|尚未|待|no?$|x$|false$|0$)/.test(s)) return false; return /^(已|是|完成|好|y|yes|v|✓|✔|true|1|ok|o$)/.test(s); } /* ---------- 燈號判斷 ---------- */ var LIGHT = {red:'紅燈', yellow:'黃燈', green:'綠燈'}; var RANK = {red:0, yellow:1, green:2}; var REASON_SHORT = {adv7:'提前且 7 天內', pend3:'未改且 3 天內', pend:'業管未改', adv14:'提前且 14 天內'}; var REASON_LONG = {adv7:'交期提前,而且距離新交期 7 天以內', pend3:'業管未改,而且距離新交期 3 天以內', pend:'業管未改', adv14:'交期提前,而且距離新交期 14 天以內'}; function judge(r, today){ var daysToNew = r.newDate - today; var shift = r.newDate - r.origDate; var advanced = shift < 0; var red = [], yellow = []; if (advanced && daysToNew <= 7) red.push('adv7'); if (!r.done && daysToNew <= 3) red.push('pend3'); if (!r.done) yellow.push('pend'); if (advanced && daysToNew <= 14) yellow.push('adv14'); var light = red.length ? 'red' : (yellow.length ? 'yellow' : 'green'); return {light:light, reasons: light === 'red' ? red : (light === 'yellow' ? yellow : [])}; } function withCalc(r, today){ var j = judge(r, today); var o = {}; for (var k in r) o[k] = r[k]; o.shift = r.newDate - r.origDate; o.daysToNew = r.newDate - today; o.qtyDiff = (r.origQty != null && r.newQty != null) ? r.newQty - r.origQty : null; o.light = j.light; o.reasons = j.reasons; return o; } function shiftText(s){ return s < 0 ? '提前 ' + (-s) + ' 天' : (s > 0 ? '延後 ' + s + ' 天' : '交期不變'); } function daysText(d){ return d > 0 ? '還有 ' + d + ' 天' : (d === 0 ? '今天' : '已過 ' + (-d) + ' 天'); } function qtyDiffText(q){ return q == null ? '' : (q > 0 ? '+' + q.toLocaleString('zh-TW') : (q < 0 ? '-' + (-q).toLocaleString('zh-TW') : '不變')); } /* ---------- 狀態 ---------- */ var state = { rows: [], f: {light:'', pending:false, customer:''}, nextId: 1, flash: {}, undo: null }; function makeRow(o){ return { id: state.nextId++, raised: o.raised, customer: o.customer, order: o.order, part: o.part, origDate: o.origDate, newDate: o.newDate, origQty: o.origQty == null ? null : o.origQty, newQty: o.newQty == null ? (o.origQty == null ? null : o.origQty) : o.newQty, source: o.source, done: !!o.done, note: o.note || '', sample: !!o.sample }; } function sampleRows(T){ var S = function(raised, customer, order, part, orig, nw, oq, nq, source, done, note){ return makeRow({raised:T + raised, customer:customer, order:order, part:part, origDate:T + orig, newDate:T + nw, origQty:oq, newQty:nq, source:source, done:done, note:note, sample:true}); }; return [ S(-1, 'NorthPeak', 'SO-26-1075', 'IGW-4100-A', 12, 5, 300, 300, 'Email', true, '客戶產線提前試產,來信要求提前出貨'), S(-2, '客戶 B', 'SO-26-1092', 'PSU-0650-C', 1, 3, 1200, 800, '電話', false, '客戶採購來電減量,等候書面確認'), S(-3, '客戶 A', 'SO-26-1081', 'ANT-5G-02', 20, 6, 2000, 2500, '會議', false, '季度會議口頭確認,需要追加備料'), S(0, '客戶 A', 'SO-26-1088', 'NWS-2408-A', 15, 8, 500, 500, 'LINE', false, ''), S(-4, 'Lumacrest', 'SO-26-1066', 'ENC-AL-210', 25, 12, 800, 800, 'Email', true, '機殼配合客戶組裝線調整'), S(-1, 'NorthPeak', 'SO-26-1079', 'IPC-MB-310', 10, 18, 150, 120, '電話', false, '客戶專案延後,數量下修'), S(-5, '客戶 B', 'SO-26-1070', 'USW-0805-B', 9, 21, 1000, 1000, 'Email', true, '客戶倉庫整修,延後收貨'), S(-6, '客戶 A', 'SO-26-1059', 'WFR-AX6-01', 30, 16, 600, 600, '會議', true, '已確認產能足夠'), S(-2, 'Lumacrest', 'SO-26-1094', 'PCBA-CB-77', 14, 28, 400, 650, 'LINE', true, '數量追加,分兩批出貨'), S(-3, 'NorthPeak', 'SO-26-1083', 'CBL-RJ45-3M', 5, 11, 5000, 5000, 'LINE', true, '') ]; } /* ---------- 篩選與排序 ---------- */ function compute(){ var t = todayDay(); var all = state.rows.map(function(r){ return withCalc(r, t); }); var base = all.filter(function(r){ return (!state.f.pending || !r.done) && (!state.f.customer || r.customer === state.f.customer); }); var vis = state.f.light ? base.filter(function(r){ return r.light === state.f.light; }) : base.slice(); vis.sort(function(a, b){ return RANK[a.light] - RANK[b.light] || a.daysToNew - b.daysToNew || (a.order < b.order ? -1 : a.order > b.order ? 1 : 0) || a.id - b.id; }); return {today:t, all:all, base:base, vis:vis}; } /* ---------- 畫面 ---------- */ var $ = function(id){ return document.getElementById(id); }; function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); } function dateHTML(n){ return '<span class="nowrap"><span class="mono">' + iso(n) + '</span><span class="wd">' + wdText(n) + '</span></span>'; } function setMsg(el, type, html){ el.className = 'msg' + (type ? ' ' + type : ''); el.innerHTML = html || ''; } function render(){ var c = compute(); $('todayText').textContent = fmtDate(c.today); /* 客戶下拉選單與輸入建議 */ var custs = []; c.all.forEach(function(r){ if (custs.indexOf(r.customer) < 0) custs.push(r.customer); }); custs.sort(function(a, b){ return a.localeCompare(b, 'zh-Hant'); }); if (state.f.customer && custs.indexOf(state.f.customer) < 0) state.f.customer = ''; var sel = $('custFilter'); var sig = custs.join('\u0001'); if (sel.getAttribute('data-sig') !== sig) { sel.innerHTML = '<option value="">全部客戶</option>' + custs.map(function(x){ return '<option>' + esc(x) + '</option>'; }).join(''); sel.setAttribute('data-sig', sig); $('custList').innerHTML = custs.map(function(x){ return '<option value="' + esc(x) + '">'; }).join(''); } sel.value = state.f.customer; $('onlyPending').checked = state.f.pending; /* 燈號統計(依目前的客戶與業管篩選計算) */ var cnt = {red:0, yellow:0, green:0}; c.base.forEach(function(r){ cnt[r.light]++; }); $('cntRed').textContent = cnt.red; $('cntYellow').textContent = cnt.yellow; $('cntGreen').textContent = cnt.green; document.querySelectorAll('.stat').forEach(function(b){ b.setAttribute('aria-pressed', b.getAttribute('data-light') === state.f.light ? 'true' : 'false'); }); var filtered = !!(state.f.light || state.f.pending || state.f.customer); $('btnClearFilter').hidden = !filtered; $('listCount').innerHTML = filtered ? '目前列出 <strong class="mono">' + c.vis.length + '</strong> 筆,共 <span class="mono">' + c.all.length + '</span> 筆' : '共 <strong class="mono">' + c.all.length + '</strong> 筆'; var hasSample = state.rows.some(function(r){ return r.sample; }); $('btnClearSample').disabled = !hasSample; /* 表格 */ var tb = $('tbody'); if (!c.all.length) { tb.innerHTML = '<tr><td colspan="11" class="empty">清單是空的。可以用下方的「新增一筆」表單,或從 Excel 貼上多筆。<br>' + '<button type="button" class="btn" id="btnReloadSample">重新載入範例資料</button></td></tr>'; } else if (!c.vis.length) { tb.innerHTML = '<tr><td colspan="11" class="empty">沒有符合篩選條件的變更。<br>' + '<button type="button" class="btn" data-act="clearFilter">清除篩選</button></td></tr>'; } else { tb.innerHTML = c.vis.map(rowHTML).join(''); } /* 提醒訊息若開著,跟著清單更新 */ if (!$('remindPanel').hidden) { var rem = buildReminder(c.vis, c.today); if (rem.count) $('remindText').value = rem.text; else $('remindPanel').hidden = true; } return c; } function rowHTML(r){ var shiftCls = r.shift < 0 ? 'adv' : (r.shift > 0 ? 'delay' : 'same'); var q; if (r.origQty == null && r.newQty == null) q = '<span class="same">—</span>'; else { var qd = r.qtyDiff; var qcls = qd == null ? 'same' : (qd > 0 ? 'up' : (qd < 0 ? 'down' : 'same')); q = '<div class="nowrap"><span class="mono from">' + fmtQty(r.origQty) + '</span> → <span class="mono to">' + fmtQty(r.newQty) + '</span></div>' + (qd == null ? '' : '<div class="qdiff ' + qcls + '">(<span class="mono">' + qtyDiffText(qd) + '</span>)</div>'); } var why = r.reasons.map(function(x){ return '<span class="why">' + REASON_SHORT[x] + '</span>'; }).join(''); return '<tr data-id="' + r.id + '" data-light="' + r.light + '"' + (state.flash[r.id] ? ' class="flash"' : '') + '>' + '<td class="nowrap"><span class="tag tag-' + r.light + '">' + LIGHT[r.light] + '</span>' + why + '</td>' + '<td>' + dateHTML(r.raised) + '<span class="sub">' + esc(r.source) + '</span></td>' + '<td class="nowrap">' + esc(r.customer) + '</td>' + '<td class="nowrap"><span class="mono strong">' + esc(r.order) + '</span><span class="sub mono">' + esc(r.part) + '</span></td>' + '<td class="nowrap"><div class="from">' + dateHTML(r.origDate) + '</div><div class="to">→ ' + dateHTML(r.newDate) + '</div></td>' + '<td class="nowrap"><span class="' + shiftCls + '">' + shiftText(r.shift) + '</span></td>' + '<td>' + q + '</td>' + '<td class="nowrap' + (r.daysToNew <= 3 ? ' soon' : '') + '">' + daysText(r.daysToNew) + '</td>' + '<td><button type="button" class="toggle ' + (r.done ? 'done' : 'pending') + '" data-act="toggle" data-id="' + r.id + '"' + ' aria-label="業管' + (r.done ? '已改' : '未改') + ',按一下改成' + (r.done ? '未改' : '已改') + '" title="按一下改成' + (r.done ? '未改' : '已改') + '">' + (r.done ? '已改' : '未改') + '</button></td>' + '<td class="note">' + (r.note ? esc(r.note) : '') + '</td>' + '<td><button type="button" class="btn small quiet del" data-act="delete" data-id="' + r.id + '" aria-label="刪除 ' + esc(r.order) + ' ' + esc(r.part) + '">刪除</button></td>' + '</tr>'; } function flashRows(ids){ ids.forEach(function(id){ state.flash[id] = true; }); setTimeout(function(){ ids.forEach(function(id){ delete state.flash[id]; var tr = document.querySelector('tr[data-id="' + id + '"]'); if (tr) tr.classList.remove('flash'); }); }, 2600); } /* ---------- 提醒訊息 ---------- */ function buildReminder(vis, today){ var list = vis.filter(function(r){ return !r.done; }); if (!list.length) return {count:0, text:''}; var lines = list.map(function(r, i){ var s = (i + 1) + '. ' + r.customer + '|' + r.order + '|' + r.part + '|交期 ' + md(r.origDate, today) + ' → ' + md(r.newDate, today) + '(' + shiftText(r.shift) + ')'; if (r.origQty != null || r.newQty != null) { s += '|數量 ' + fmtQty(r.origQty) + ' → ' + fmtQty(r.newQty) + (r.qtyDiff ? '(' + qtyDiffText(r.qtyDiff) + ')' : ''); } s += '|來源 ' + r.source + '(' + md(r.raised, today) + ')'; if (r.note) s += '|備註:' + r.note; return s; }); var text = '業管您好,以下 ' + list.length + ' 筆客戶交期變更還沒有更新到系統,麻煩協助確認:\n' + lines.join('\n') + '\n\n更新完成後再麻煩回覆,謝謝。'; return {count:list.length, text:text}; } /* ---------- 匯出:CSV 與複製 ---------- */ var EXPORT_COLS = [ ['燈號', function(r){ return LIGHT[r.light]; }], ['燈號原因', function(r){ return r.reasons.length ? r.reasons.map(function(x){ return REASON_LONG[x]; }).join(';') : '其他情況'; }], ['提出日期', function(r){ return iso(r.raised); }], ['客戶', function(r){ return r.customer; }], ['訂單編號', function(r){ return r.order; }], ['料號', function(r){ return r.part; }], ['原交期', function(r){ return iso(r.origDate); }], ['新交期', function(r){ return iso(r.newDate); }], ['交期變化', function(r){ return shiftText(r.shift); }], ['交期差(天)', function(r){ return r.shift; }], ['原數量', function(r){ return r.origQty == null ? '' : r.origQty; }], ['新數量', function(r){ return r.newQty == null ? '' : r.newQty; }], ['數量差', function(r){ return r.qtyDiff == null ? '' : r.qtyDiff; }], ['距新交期(天)', function(r){ return r.daysToNew; }], ['來源', function(r){ return r.source; }], ['業管是否已改系統', function(r){ return r.done ? '已改' : '未改'; }], ['備註', function(r){ return r.note; }] ]; function csvCell(v){ var s = String(v == null ? '' : v); return /[",\r\n]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s; } function buildCSV(vis){ var out = [EXPORT_COLS.map(function(c){ return csvCell(c[0]); }).join(',')]; vis.forEach(function(r){ out.push(EXPORT_COLS.map(function(c){ return csvCell(c[1](r)); }).join(',')); }); return '\uFEFF' + out.join('\r\n') + '\r\n'; } function tsvCell(v){ return String(v == null ? '' : v).replace(/[\t\r\n]+/g, ' '); } function buildTSV(vis){ var out = [EXPORT_COLS.map(function(c){ return c[0]; }).join('\t')]; vis.forEach(function(r){ out.push(EXPORT_COLS.map(function(c){ return tsvCell(c[1](r)); }).join('\t')); }); return out.join('\r\n'); } /* ---------- 匯出:行事曆檔(RFC 5545) ---------- */ var enc = new TextEncoder(); function icsEsc(s){ return String(s == null ? '' : s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r\n|\r|\n/g, '\\n'); } function foldLine(line){ var units = line.match(/\\.|[\s\S]/gu) || []; var parts = [], cur = '', bytes = 0; units.forEach(function(u){ var b = enc.encode(u).length; if (bytes + b > 75) { parts.push(cur); cur = ' ' + u; bytes = 1 + b; } else { cur += u; bytes += b; } }); parts.push(cur); return parts.join('\r\n'); } function uidPart(s){ return String(s).normalize('NFKC').replace(/[^A-Za-z0-9-]/g, function(ch){ return ch.codePointAt(0).toString(16); }); } function buildICS(vis, now){ now = now || new Date(); var stamp = now.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}/, ''); var L = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Tailin AI Course//Practice Tool//ZH-TW', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH']; var used = {}; vis.forEach(function(r){ var uid = 't3-' + uidPart(r.order) + '-' + uidPart(r.part) + '-' + icsDate(r.newDate); if (used[uid]) { used[uid]++; uid += '-' + used[uid]; } else used[uid] = 1; var summary = '交期:' + r.order + ' ' + r.part + (r.newQty != null ? ' 數量 ' + r.newQty : ''); var desc = [ '客戶:' + r.customer, '原交期:' + fmtDate(r.origDate), '新交期:' + fmtDate(r.newDate) + '(' + shiftText(r.shift) + ')', '數量:' + fmtQty(r.origQty) + ' → ' + fmtQty(r.newQty), '來源:' + r.source + '(' + fmtDate(r.raised) + ' 提出)', '業管是否已改系統:' + (r.done ? '已改' : '未改'), '備註:' + (r.note || '無') ].join('\n'); L.push('BEGIN:VEVENT', 'UID:' + uid + '@tailin-practice.local', 'DTSTAMP:' + stamp, 'DTSTART;VALUE=DATE:' + icsDate(r.newDate), 'DTEND;VALUE=DATE:' + icsDate(r.newDate + 1), 'SUMMARY:' + icsEsc(summary), 'DESCRIPTION:' + icsEsc(desc), 'TRANSP:TRANSPARENT', 'END:VEVENT'); }); L.push('END:VCALENDAR'); return L.map(foldLine).join('\r\n') + '\r\n'; } function download(name, text, mime){ var blob = new Blob([text], {type:mime}); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = name; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function(){ URL.revokeObjectURL(url); }, 5000); } function copyText(text, onOk, onFail){ try { if (navigator.clipboard && window.isSecureContext) { navigator.clipboard.writeText(text).then(onOk, onFail); return; } } catch (e) { /* 改用手動複製 */ } onFail(); } /* ---------- 貼上與 CSV 解析 ---------- */ function splitTable(text){ text = String(text).replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); var first = (text.split('\n').filter(function(l){ return l.trim() !== ''; })[0]) || ''; var d = first.indexOf('\t') > -1 ? '\t' : ','; var out = [], row = [], f = '', q = false; for (var i = 0; i < text.length; i++) { var ch = text[i]; if (q) { if (ch === '"') { if (text[i + 1] === '"') { f += '"'; i++; } else q = false; } else f += ch; } else if (ch === '"' && f.replace(/[ \u3000]/g, '') === '') { q = true; f = ''; } else if (ch === d) { row.push(f); f = ''; } else if (ch === '\n') { row.push(f); out.push(row); row = []; f = ''; } else f += ch; } if (f !== '' || row.length) { row.push(f); out.push(row); } var rows = []; out.forEach(function(cells, idx){ if (cells.some(function(c){ return clean(c) !== ''; })) rows.push({line: idx + 1, cells: cells}); }); return rows; } var FIELD_LABEL = {raised:'提出日期', customer:'客戶', order:'訂單編號', part:'料號', orig:'原交期', nw:'新交期', oq:'原數量', nq:'新數量', source:'來源', done:'業管是否已改系統', note:'備註'}; var ALIASES = { raised:['提出日期','提出日','通知日期','變更日期','日期'], customer:['客戶','客戶名稱','客戶代號','客戶簡稱'], order:['訂單編號','訂單','訂單號碼','訂單號','單號','訂單no','po','so','orderno'], part:['料號','品號','產品料號','partno','pn'], orig:['原交期','舊交期','原始交期','原定交期','原交貨日'], nw:['新交期','變更後交期','新交貨日','更改後交期'], oq:['原數量','舊數量','原始數量','原訂數量'], nq:['新數量','變更後數量','更改後數量'], source:['來源','通知方式','管道','通知管道'], done:['業管是否已改系統','業管已改系統','業管是否已改','業管','系統已改','業管狀態','已改系統'], note:['備註','說明','註記','備考'] }; var IGNORE = ['燈號','燈號原因','交期變化','交期差','數量差','距新交期']; var CONTAINS = [['nw','新交期'],['orig','原交期'],['orig','舊交期'],['nq','新數量'],['oq','原數量'],['oq','舊數量'], ['order','訂單'],['part','料號'],['part','品號'],['done','業管'],['source','來源'],['note','備註'],['raised','提出'],['customer','客戶']]; function headKey(h){ return nfkc(h).toLowerCase().replace(/\([^)]*\)/g, '').replace(/[\s#::._-]/g, ''); } function mapHeader(cells){ var map = {}; var keys = cells.map(headKey); keys.forEach(function(k, i){ if (!k || IGNORE.indexOf(k) > -1) return; for (var f in ALIASES) if (map[f] == null && ALIASES[f].indexOf(k) > -1) { map[f] = i; return; } }); keys.forEach(function(k, i){ if (!k || IGNORE.indexOf(k) > -1) return; for (var f in map) if (map[f] === i) return; for (var j = 0; j < CONTAINS.length; j++) { if (map[CONTAINS[j][0]] == null && k.indexOf(CONTAINS[j][1]) > -1) { map[CONTAINS[j][0]] = i; return; } } }); return map; } function parseTable(text, today){ var rows = splitTable(text); if (!rows.length) return {error:'沒有讀到任何資料。', rows:[], skipped:[]}; var map = mapHeader(rows[0].cells); var missing = ['order','part','orig'].filter(function(k){ return map[k] == null; }); if (missing.length) { return {error:'第一列要是欄位名稱,目前找不到「' + missing.map(function(k){ return FIELD_LABEL[k]; }).join('」「') + '」欄位。', rows:[], skipped:[]}; } var ok = [], skipped = []; rows.slice(1).forEach(function(rw){ var get = function(k){ return map[k] == null ? '' : clean(rw.cells[map[k]]); }; var why = []; var order = nfkc(get('order')), part = nfkc(get('part')); if (!order) why.push('訂單編號空白'); if (!part) why.push('料號空白'); var os = get('orig'), ns = get('nw'), rs = get('raised'); var orig = os ? parseDate(os, today) : null; if (!os) why.push('原交期空白'); else if (orig == null) why.push('原交期「' + os + '」看不懂'); var nw = ns ? parseDate(ns, today) : orig; if (ns && nw == null) why.push('新交期「' + ns + '」看不懂'); var raised = rs ? parseDate(rs, today) : today; if (rs && raised == null) why.push('提出日期「' + rs + '」看不懂'); var oq = parseQty(get('oq')), nq = parseQty(get('nq')); if (!oq.ok) why.push('原數量「' + get('oq') + '」不是數字'); if (!nq.ok) why.push('新數量「' + get('nq') + '」不是數字'); if (why.length) { skipped.push({line:rw.line, reason:why.join('、')}); return; } ok.push({raised:raised, customer:get('customer') || '未填', order:order, part:part, origDate:orig, newDate:nw, origQty:oq.v, newQty:nq.v == null ? oq.v : nq.v, source:normSource(get('source')), done:normDone(get('done')), note:get('note')}); }); return {rows:ok, skipped:skipped}; } /* ---------- 事件 ---------- */ function snapshot(label){ state.undo = {rows: state.rows.map(function(r){ var o = {}; for (var k in r) o[k] = r[k]; return o; }), label: label}; } function undoBtn(){ return '<button type="button" class="btn small" data-act="undo">復原</button>'; } document.querySelectorAll('.stat').forEach(function(b){ b.addEventListener('click', function(){ var l = b.getAttribute('data-light'); state.f.light = state.f.light === l ? '' : l; render(); }); }); $('onlyPending').addEventListener('change', function(e){ state.f.pending = e.target.checked; render(); }); $('custFilter').addEventListener('change', function(e){ state.f.customer = e.target.value; render(); }); function clearFilters(){ state.f = {light:'', pending:false, customer:''}; render(); } $('btnClearFilter').addEventListener('click', function(){ clearFilters(); $('custFilter').focus(); }); document.addEventListener('click', function(e){ var t = e.target.closest('[data-act]'); if (!t) { if (e.target.id === 'btnReloadSample') { state.rows = sampleRows(todayDay()); state.f = {light:'', pending:false, customer:''}; render(); setMsg($('listMsg'), 'ok', '已重新載入 10 筆範例資料。'); } return; } var act = t.getAttribute('data-act'); if (act === 'toggle') toggleDone(+t.getAttribute('data-id')); else if (act === 'delete') deleteRow(+t.getAttribute('data-id')); else if (act === 'clearFilter') clearFilters(); else if (act === 'undo') { if (!state.undo) return; state.rows = state.undo.rows; state.undo = null; var box = t.closest('.msg'); render(); setMsg(box || $('listMsg'), 'ok', '已經復原。'); } }); function toggleDone(id){ var t = todayDay(); var r = state.rows.filter(function(x){ return x.id === id; })[0]; if (!r) return; var before = judge(r, t).light; r.done = !r.done; var after = judge(r, t).light; var c = render(); var btn = document.querySelector('button[data-act="toggle"][data-id="' + id + '"]'); var hiddenNote = ''; if (btn) btn.focus(); else { hiddenNote = '(這一筆不符合目前的篩選條件,已從清單隱藏)'; var first = document.querySelector('#tbody button'); (first || $('tbl').parentNode).focus(); } setMsg($('listMsg'), after === 'red' ? 'bad' : (after === 'yellow' ? 'warn' : 'ok'), esc(r.order) + '(' + esc(r.part) + ')業管改為「' + (r.done ? '已改' : '未改') + '」,燈號' + (before === after ? '維持' + LIGHT[after] : '由' + LIGHT[before] + '變成' + LIGHT[after]) + '。' + hiddenNote); return c; } function deleteRow(id){ var idx = -1; state.rows.forEach(function(x, i){ if (x.id === id) idx = i; }); if (idx < 0) return; snapshot('delete'); var r = state.rows.splice(idx, 1)[0]; render(); var firstBtn = document.querySelector('#tbody button'); (firstBtn || $('tbl').parentNode).focus(); setMsg($('listMsg'), 'info', '已刪除 ' + esc(r.order) + '(' + esc(r.part) + ')。' + undoBtn()); } $('btnClearSample').addEventListener('click', function(){ var n = state.rows.filter(function(r){ return r.sample; }).length; if (!n) return; snapshot('clearSample'); state.rows = state.rows.filter(function(r){ return !r.sample; }); $('remindPanel').hidden = true; render(); setMsg($('listMsg'), 'info', '已清除 ' + n + ' 筆範例資料。現在可以用下方的表單新增,或從 Excel 貼上多筆。' + undoBtn()); ($('btnReloadSample') || $('tbl').parentNode).focus(); }); $('btnRemind').addEventListener('click', function(){ var c = compute(); var rem = buildReminder(c.vis, c.today); if (!rem.count) { $('remindPanel').hidden = true; setMsg($('listMsg'), 'ok', c.vis.length ? '目前列出的變更,業管都已經把變更輸入系統,不需要提醒。' : '目前沒有列出任何變更。'); return; } $('remindText').value = rem.text; $('remindPanel').hidden = false; $('copyFallback').hidden = true; setMsg($('listMsg'), 'ok', '已整理 ' + rem.count + ' 筆業管未改的變更(依目前的篩選條件)。按「複製」就可以貼到 LINE 或 Email。'); $('remindPanel').scrollIntoView({block:'nearest'}); }); $('btnCopyRemind').addEventListener('click', function(){ var ta = $('remindText'); copyText(ta.value, function(){ setMsg($('listMsg'), 'ok', '已複製提醒訊息,可以貼到 LINE 或 Email。'); }, function(){ ta.focus(); ta.select(); setMsg($('listMsg'), 'warn', '瀏覽器不允許直接複製。文字已經全選,請按 Ctrl+C 複製(Mac 請按 Command+C)。'); }); }); $('btnCloseRemind').addEventListener('click', function(){ $('remindPanel').hidden = true; $('btnRemind').focus(); }); $('btnCloseFallback').addEventListener('click', function(){ $('copyFallback').hidden = true; $('btnCopy').focus(); }); function stampName(){ return icsDate(todayDay()); } $('btnIcs').addEventListener('click', function(){ var c = compute(); if (!c.vis.length) { setMsg($('listMsg'), 'warn', '目前沒有列出任何變更,無法產生行事曆檔。'); return; } download('交期變更行事曆_' + stampName() + '.ics', buildICS(c.vis), 'text/calendar;charset=utf-8'); setMsg($('listMsg'), 'ok', '已下載行事曆檔,共 ' + c.vis.length + ' 個全天行程(每筆排在新交期當天)。在 Outlook 可以直接開啟這個檔案,或從「檔案 → 開啟並匯出 → 匯入/匯出」匯入。'); }); $('btnCsv').addEventListener('click', function(){ var c = compute(); if (!c.vis.length) { setMsg($('listMsg'), 'warn', '目前沒有列出任何變更,沒有東西可以匯出。'); return; } download('客戶交期變更_' + stampName() + '.csv', buildCSV(c.vis), 'text/csv;charset=utf-8'); setMsg($('listMsg'), 'ok', '已匯出目前列出的 ' + c.vis.length + ' 筆,含燈號、交期差、數量差與距新交期天數。'); }); $('btnCopy').addEventListener('click', function(){ var c = compute(); if (!c.vis.length) { setMsg($('listMsg'), 'warn', '目前沒有列出任何變更,沒有東西可以複製。'); return; } var text = buildTSV(c.vis); copyText(text, function(){ $('copyFallback').hidden = true; setMsg($('listMsg'), 'ok', '已複製目前列出的 ' + c.vis.length + ' 筆(含計算出的欄位),可以直接貼到 Excel。'); }, function(){ var ta = $('copyFallbackText'); ta.value = text; $('copyFallback').hidden = false; ta.focus(); ta.select(); setMsg($('listMsg'), 'warn', '瀏覽器不允許直接複製。內容已經放在下面的文字框並全選,請按 Ctrl+C 複製。'); }); }); /* 新增一筆 */ var form = $('addForm'); function resetForm(){ form.reset(); $('fRaised').value = iso(todayDay()); form.querySelectorAll('[aria-invalid]').forEach(function(el){ el.removeAttribute('aria-invalid'); }); } form.addEventListener('input', function(e){ if (e.target.hasAttribute('aria-invalid')) e.target.removeAttribute('aria-invalid'); }); $('btnResetForm').addEventListener('click', function(){ resetForm(); setMsg($('formMsg'), '', ''); $('fCustomer').focus(); }); form.addEventListener('submit', function(e){ e.preventDefault(); var t = todayDay(); var bad = [], problems = []; function need(id, label){ var el = $(id); if (!clean(el.value)) { el.setAttribute('aria-invalid', 'true'); bad.push(el); problems.push('請填寫' + label); } } need('fCustomer', '客戶'); need('fOrder', '訂單編號'); need('fPart', '料號'); need('fOrig', '原交期'); var orig = $('fOrig').value ? parseDate($('fOrig').value, t) : null; if ($('fOrig').value && orig == null) { $('fOrig').setAttribute('aria-invalid', 'true'); bad.push($('fOrig')); problems.push('原交期的日期不正確'); } var nw = $('fNew').value ? parseDate($('fNew').value, t) : orig; if ($('fNew').value && nw == null) { $('fNew').setAttribute('aria-invalid', 'true'); bad.push($('fNew')); problems.push('新交期的日期不正確'); } var raised = $('fRaised').value ? parseDate($('fRaised').value, t) : t; if (raised == null) raised = t; function qty(id, label){ var el = $(id); if (el.validity && el.validity.badInput) { el.setAttribute('aria-invalid', 'true'); bad.push(el); problems.push(label + '要填數字'); return null; } var p = parseQty(el.value); if (!p.ok || (p.v != null && p.v < 0)) { el.setAttribute('aria-invalid', 'true'); bad.push(el); problems.push(label + '要填 0 以上的數字'); return null; } return p.v; } var oq = qty('fOq', '原數量'), nq = qty('fNq', '新數量'); if (bad.length) { setMsg($('formMsg'), 'bad', problems.join(';') + '。'); bad[0].focus(); return; } var row = makeRow({raised:raised, customer:clean($('fCustomer').value), order:nfkc($('fOrder').value), part:nfkc($('fPart').value), origDate:orig, newDate:nw, origQty:oq, newQty:nq == null ? oq : nq, source:$('fSource').value, done:$('fDoneYes').checked, note:clean($('fNote').value)}); state.rows.push(row); flashRows([row.id]); var c = render(); var shown = c.vis.some(function(x){ return x.id === row.id; }); var j = judge(row, t); setMsg($('formMsg'), 'ok', '已新增 ' + esc(row.order) + '(' + esc(row.part) + '),' + shiftText(row.newDate - row.origDate) + ',燈號:' + LIGHT[j.light] + '。' + (shown ? '已排進上方的變更清單。' : '目前的篩選條件看不到這一筆,可以按上方的「清除篩選」。')); resetForm(); }); /* 從 Excel 貼上 */ function importPaste(mode){ var box = $('pasteBox'); var t = todayDay(); if (!clean(box.value)) { setMsg($('pasteMsg'), 'warn', '請先在框裡貼上 Excel 表格,或選擇 CSV 檔。'); box.focus(); return; } var res = parseTable(box.value, t); var skippedHTML = ''; if (res.skipped && res.skipped.length) { var show = res.skipped.slice(0, 6); skippedHTML = '<ul>' + show.map(function(s){ return '<li>第 ' + s.line + ' 列:' + esc(s.reason) + ',已略過</li>'; }).join('') + (res.skipped.length > show.length ? '<li>另外還有 ' + (res.skipped.length - show.length) + ' 列略過</li>' : '') + '</ul>'; } if (res.error) { setMsg($('pasteMsg'), 'bad', esc(res.error)); return; } if (!res.rows.length) { setMsg($('pasteMsg'), 'bad', '沒有可以加入的資料。' + skippedHTML); return; } if (mode === 'replace') snapshot('replace'); var added = res.rows.map(function(o){ return makeRow(o); }); state.rows = (mode === 'replace' ? [] : state.rows).concat(added); flashRows(added.map(function(r){ return r.id; })); render(); box.value = ''; $('csvFile').value = ''; setMsg($('pasteMsg'), res.skipped.length ? 'warn' : 'ok', (mode === 'replace' ? '已用貼上的 ' + added.length + ' 筆取代整份清單。' + undoBtn() : '已加入 ' + added.length + ' 筆到變更清單。') + (res.skipped.length ? '另有 ' + res.skipped.length + ' 列沒有加入:' + skippedHTML : '')); } $('btnAppend').addEventListener('click', function(){ importPaste('append'); }); $('btnReplace').addEventListener('click', function(){ importPaste('replace'); }); function demoPasteText(T){ var slash = function(n){ var a = ymd(n); return a.y + '/' + p2(a.m) + '/' + p2(a.d); }; return ['提出日期\t客戶\t訂單編號\t料號\t原交期\t新交期\t原數量\t新數量\t來源\t業管是否已改系統\t備註', slash(T) + '\t客戶 B\tSO-26-1103\tPSU-0650-C\t' + slash(T + 9) + '\t' + iso(T + 6) + '\t300\t300\tEmail\t未改\t客戶來信要求提前', slash(T) + '\tNorthPeak\tSO-26-1105\tIGW-4100-A\t' + slash(T + 20) + '\t' + iso(T + 27) + '\t120\t200\t會議\t已改\t專案時程延後,數量追加' ].join('\n'); } $('btnPasteDemo').addEventListener('click', function(){ $('pasteBox').value = demoPasteText(todayDay()); setMsg($('pasteMsg'), 'info', '已放入兩筆示範資料,按「加入清單」就會加到上方的變更清單。'); }); $('csvFile').addEventListener('change', function(e){ var file = e.target.files && e.target.files[0]; if (!file) return; var reader = new FileReader(); reader.onload = function(){ var buf = reader.result, text, note = ''; try { text = new TextDecoder('utf-8', {fatal:true}).decode(buf); } catch (err) { try { text = new TextDecoder('big5').decode(buf); note = '這個檔案是 Big5 編碼,已經自動轉換。'; } catch (err2) { text = new TextDecoder('utf-8').decode(buf); note = '檔案編碼無法辨識,中文可能變成亂碼。'; } } text = text.replace(/^\uFEFF/, ''); $('pasteBox').value = text; var n = splitTable(text).length; setMsg($('pasteMsg'), 'info', '已讀入「' + esc(file.name) + '」,共 ' + Math.max(n - 1, 0) + ' 列資料。' + note + '請確認框裡的內容,再按「加入清單」或「取代整份清單」。'); }; reader.onerror = function(){ setMsg($('pasteMsg'), 'bad', '讀取檔案失敗,請再選一次。'); }; reader.readAsArrayBuffer(file); }); /* ---------- 開始 ---------- */ (function init(){ var T = todayDay(); state.rows = sampleRows(T); $('fRaised').value = iso(T); var demo = demoPasteText(T).split('\n').slice(0, 2).join('\n'); $('pasteBox').placeholder = demo; render(); })(); /* 給測試用的函式 */ window.__t3 = {state:state, compute:compute, render:render, judge:judge, parseDate:parseDate, parseTable:parseTable, buildICS:buildICS, buildCSV:buildCSV, buildTSV:buildTSV, buildReminder:buildReminder, todayDay:todayDay, iso:iso}; })(); </script> </body> </html>
<!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>儀器校正與證照到期看板</title> <style> :root{ --bg:#f4f6f3; --surface:#ffffff; --surface-2:#eaf0ec; --line:#cfd9d3; --ink:#16221d; --ink-2:#4a5a52; --accent:#0b6b4f; --accent-ink:#ffffff; --copper:#a8601f; --ok:#1f7a45; --ok-bg:#e3f3e8; --warn:#8a5900; --warn-bg:#fdf1d8; --bad:#b3261e; --bad-bg:#fbe4e1; --info:#1d5f9e; --info-bg:#e3eefa; --focus:#c27a2c; --font-body:"Microsoft JhengHei", "PingFang TC", "Noto Sans TC", "Heiti TC", sans-serif; --font-mono:"Cascadia Mono", Consolas, "SF Mono", ui-monospace, monospace; color-scheme:light; } @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } } :root[data-theme="dark"]{ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; color-scheme:dark; } *,*::before,*::after{ box-sizing:border-box; } html{ -webkit-text-size-adjust:100%; text-size-adjust:100%; } body{ margin:0; background:var(--bg); color:var(--ink); font-family:var(--font-body); font-size:16px; line-height:1.6; } .mono{ font-family:var(--font-mono); font-variant-numeric:tabular-nums; } .vh{ position:absolute !important; width:1px; height:1px; padding:0; margin:-1px; overflow:hidden; clip:rect(0 0 0 0); white-space:nowrap; border:0; } :focus-visible{ outline:3px solid var(--focus); outline-offset:2px; } [hidden]{ display:none !important; } .wrap{ max-width:1180px; margin:0 auto; padding-inline:24px; } /* 頁首 */ .site-head{ position:relative; border-bottom:3px solid var(--accent); padding:28px 0 18px; margin-bottom:24px; } .site-head::after{ content:""; position:absolute; left:0; bottom:-3px; width:48px; height:3px; background:var(--copper); } .head-row{ display:flex; justify-content:space-between; align-items:flex-start; gap:16px; flex-wrap:wrap; } .head-row h1{ margin:0; font-size:28px; line-height:1.3; font-weight:700; letter-spacing:.02em; } .lede{ margin:6px 0 0; color:var(--ink-2); font-size:17px; max-width:46em; } .pill{ display:inline-block; padding:4px 12px; border:1px solid var(--copper); color:var(--copper); border-radius:999px; font-size:14px; font-weight:700; white-space:nowrap; } /* 區塊 */ .panel{ background:var(--surface); border:1px solid var(--line); border-radius:10px; padding:20px 22px 22px; margin-bottom:20px; } .panel > h2{ margin:0 0 4px; font-size:21px; line-height:1.4; } .panel > h2 .step{ color:var(--accent); font-family:var(--font-mono); } .panel-lede{ margin:0 0 16px; color:var(--ink-2); } .hint{ color:var(--ink-2); font-size:15px; margin:0; } /* 按鈕 */ .btn{ display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:44px; padding:8px 18px; border-radius:8px; border:2px solid var(--accent); background:var(--surface); color:var(--accent); font:inherit; font-size:16px; font-weight:700; cursor:pointer; text-align:center; line-height:1.3; transition:background-color .15s, color .15s; } .btn:hover{ background:var(--surface-2); } .btn-primary{ background:var(--accent); color:var(--accent-ink); } .btn-primary:hover{ background:var(--accent); filter:brightness(1.08); } .btn-quiet{ border-color:var(--line); color:var(--ink); font-weight:400; } .btn-row{ display:flex; flex-wrap:wrap; gap:10px; align-items:center; } .msg{ min-height:1.6em; margin:10px 0 0; font-size:15px; color:var(--ink-2); } .msg.is-ok{ color:var(--ok); } .msg.is-warn{ color:var(--warn); } .msg.is-bad{ color:var(--bad); } /* 1 貼上資料 */ .input-grid{ display:grid; grid-template-columns:minmax(0,1fr) 300px; gap:20px; align-items:start; } .field{ display:flex; flex-direction:column; gap:6px; } .field label{ font-weight:700; } textarea{ width:100%; font-family:var(--font-mono); font-variant-numeric:tabular-nums; font-size:14px; line-height:1.55; color:var(--ink); background:var(--surface-2); border:1px solid var(--line); border-radius:8px; padding:10px 12px; resize:vertical; tab-size:12; } #pasteBox{ min-height:230px; white-space:pre; overflow:auto; } .side{ display:flex; flex-direction:column; gap:12px; } .side .btn{ width:100%; } .side .btn-row .btn{ flex:1 1 0; width:auto; } .file-input{ position:absolute; opacity:0; width:1px; height:1px; } .file-input:focus-visible + label{ outline:3px solid var(--focus); outline-offset:2px; } .cols{ margin:0; padding:10px 12px; background:var(--surface-2); border-radius:8px; font-size:15px; color:var(--ink-2); } .cols b{ color:var(--ink); font-weight:700; } /* 2 到期看板 */ .board-top{ display:flex; flex-wrap:wrap; justify-content:space-between; align-items:center; gap:8px 16px; margin-bottom:14px; } .today{ margin:0; font-size:17px; } .today .mono{ font-weight:700; } .listing{ display:flex; align-items:center; gap:12px; flex-wrap:wrap; } .listing p{ margin:0; color:var(--ink-2); } .listing .btn{ min-height:40px; padding:6px 14px; font-size:15px; } .s-bad{ --c:var(--bad); --cbg:var(--bad-bg); } .s-warn{ --c:var(--warn); --cbg:var(--warn-bg); } .s-info{ --c:var(--info); --cbg:var(--info-bg); } .s-ok{ --c:var(--ok); --cbg:var(--ok-bg); } .stats{ display:grid; grid-template-columns:repeat(4, minmax(0,1fr)); gap:12px; margin-bottom:16px; } .stat{ display:flex; flex-direction:column; align-items:stretch; gap:2px; min-height:96px; padding:12px 16px 12px 14px; border:1px solid var(--line); border-left:6px solid var(--c); border-radius:8px; background:var(--surface); color:var(--ink); font:inherit; text-align:left; cursor:pointer; transition:background-color .15s; } .stat:hover{ background:var(--surface-2); } .stat-top{ display:flex; justify-content:space-between; align-items:baseline; gap:8px; } .stat-label{ font-weight:700; font-size:18px; color:var(--c); } .stat-num{ font-family:var(--font-mono); font-variant-numeric:tabular-nums; font-size:34px; font-weight:700; line-height:1.1; color:var(--ink); } .stat-hint{ font-size:14px; color:var(--ink-2); } .stat[aria-pressed="true"]{ background:var(--cbg); border-color:var(--c); box-shadow:inset 0 0 0 1px var(--c); } .stat[aria-pressed="true"] .stat-hint{ color:var(--c); font-weight:700; } .controls{ display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1.35fr); gap:16px; margin-bottom:16px; align-items:start; } .filters{ display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:12px; align-content:start; } select{ width:100%; min-height:44px; padding:6px 10px; font:inherit; font-size:16px; color:var(--ink); background:var(--surface); border:1px solid var(--line); border-radius:8px; } .rules{ border:1px solid var(--line); border-left:4px solid var(--copper); border-radius:8px; padding:12px 16px; background:var(--surface-2); } .rules h3{ margin:0 0 8px; font-size:17px; } .rules ul{ list-style:none; margin:0; padding:0; display:grid; gap:6px; } .rules li{ display:grid; grid-template-columns:7.4em minmax(0,1fr); gap:10px; align-items:baseline; font-size:15px; } .rules li .tag-s{ justify-self:start; } .rules p{ margin:10px 0 0; font-size:14px; color:var(--ink-2); } .tag-s{ display:inline-block; padding:2px 10px; border-radius:999px; border:1px solid var(--c); background:var(--cbg); color:var(--c); font-weight:700; font-size:14px; line-height:1.5; white-space:nowrap; } .table-wrap{ overflow-x:auto; border:1px solid var(--line); border-radius:8px; } table.board{ width:100%; border-collapse:collapse; font-size:15px; } .board th, .board td{ padding:10px 10px; text-align:left; vertical-align:middle; border-bottom:1px solid var(--line); } .board thead th{ background:var(--surface-2); font-weight:700; font-size:14px; color:var(--ink-2); white-space:nowrap; position:sticky; top:0; } .board tbody tr:last-child td{ border-bottom:0; } .board tbody tr:hover td{ background:var(--surface-2); } .board .c-days{ text-align:right; white-space:nowrap; } .board th.c-days{ text-align:right; } .d-pre, .d-unit{ font-size:14px; color:var(--ink-2); } .d-num{ font-family:var(--font-mono); font-variant-numeric:tabular-nums; font-size:26px; font-weight:700; line-height:1.1; color:var(--c); margin:0 3px; } .c-name{ font-weight:700; min-width:9em; } .c-name .seg{ display:inline-block; } .c-name .nw{ white-space:nowrap; } .board th.c-status, .board th.c-days, .board th.c-cat, .board th.c-dept, .board th.c-owner, .board th.c-due, .board th.c-remind{ width:1%; } .c-due, .c-remind{ white-space:nowrap; } .c-due .mono, .c-remind .mono{ font-size:15px; } .sub{ display:block; font-size:13px; color:var(--ink-2); } .c-owner, .c-dept, .c-cat{ white-space:nowrap; } .c-bar{ width:116px; } .board th.c-bar{ white-space:normal; line-height:1.3; } .board th.c-bar small{ display:block; font-weight:400; font-size:12px; } .bar{ display:block; width:96px; height:8px; border-radius:4px; background:var(--surface-2); border:1px solid var(--line); overflow:hidden; } .bar > i{ display:block; height:100%; min-width:3px; background:var(--c); border-radius:4px 0 0 4px; } .bar.is-over{ background:transparent; border:1px dashed var(--bad); } .bar.is-over > i{ display:none; } .empty td{ text-align:center; color:var(--ink-2); padding:28px 12px; font-size:16px; } .after-table{ margin-top:14px; display:flex; flex-wrap:wrap; gap:10px 16px; align-items:center; justify-content:space-between; } .after-table .hint{ flex:1 1 280px; } .copy-fallback{ margin-top:10px; } .copy-fallback textarea{ min-height:120px; white-space:pre; } /* 3 通知負責人 */ .notify-grid{ display:grid; gap:14px; } .notes{ margin:0; padding-left:1.2em; color:var(--ink-2); font-size:15px; } .notes li{ margin:2px 0; } #mailBox{ min-height:280px; font-family:var(--font-body); font-size:16px; line-height:1.7; white-space:pre-wrap; background:var(--surface); } .out-head{ display:flex; justify-content:space-between; align-items:center; gap:10px; flex-wrap:wrap; } .out-head label{ font-weight:700; } /* 頁尾 */ .site-foot{ border-top:1px solid var(--line); margin-top:28px; padding:16px 0 32px; color:var(--ink-2); font-size:14px; } .site-foot p{ margin:2px 0; } .site-foot .privacy{ color:var(--ink); font-size:15px; font-weight:700; } /* 中等寬度:表格放不下橫條時先隱藏橫條 */ @media (max-width: 1150px){ .board .c-bar{ display:none; } } @media (max-width: 980px){ .controls{ grid-template-columns:1fr; } } /* 平板與手機:表格每列改成上下排列 */ @media (max-width: 1040px){ .table-wrap{ border:0; border-radius:0; overflow:visible; } .board thead{ display:none; } .board, .board tbody{ display:block; width:100%; } .board tbody tr{ display:flex; flex-wrap:wrap; align-items:center; gap:2px 16px; padding:14px 14px; border:1px solid var(--line); border-left:5px solid var(--c); border-radius:8px; margin-bottom:10px; background:var(--surface); } .board tbody tr:hover td{ background:transparent; } .board td{ display:block; padding:0; border:0; } .board .c-status{ order:1; } .board .c-days{ order:2; margin-left:auto; } .board .c-name{ order:3; flex:1 1 100%; font-size:17px; min-width:0; margin-top:4px; } .board .c-cat{ order:4; color:var(--ink-2); } .board .c-dept{ order:5; color:var(--ink-2); } .board .c-owner{ order:6; color:var(--ink-2); } .board .c-due{ order:7; } .board .c-remind{ order:8; } .board td.c-bar{ display:block; order:9; flex:1 1 100%; width:auto; margin-top:8px; } .board td.c-bar .bar{ width:100%; } .board .c-owner::before, .board .c-due::before, .board .c-remind::before{ content:attr(data-label) ":"; color:var(--ink-2); font-weight:400; } .c-remind .sub{ display:inline; margin-left:6px; } .board tr.empty{ display:block; border-left-width:1px; } .board tr.empty td{ display:block; width:100%; } } @media (max-width: 860px){ .input-grid{ grid-template-columns:1fr; } .side .btn-row .btn{ flex:1 1 auto; } } /* 手機寬度 */ @media (max-width: 760px){ .wrap{ padding-inline:16px; } .head-row h1{ font-size:24px; } .panel{ padding:16px; } .stats{ grid-template-columns:repeat(2, minmax(0,1fr)); gap:10px; } .stat{ min-height:84px; padding:10px 12px 10px 12px; } .stat-num{ font-size:30px; } .board tbody tr{ padding:14px 12px; gap:2px 14px; } .board .c-due, .board .c-remind{ flex:1 1 100%; } .board td.c-bar{ display:none; } .after-table .btn-row{ width:100%; } .after-table .btn-row .btn{ flex:1 1 auto; } .notify-grid .btn-row .btn{ flex:1 1 auto; } .rules li{ grid-template-columns:1fr; gap:2px; } } @media (max-width: 420px){ .filters{ grid-template-columns:1fr; } .head-row h1{ font-size:23px; } .lede{ font-size:16px; } } @media (prefers-reduced-motion: reduce){ *,*::before,*::after{ transition:none !important; animation:none !important; scroll-behavior:auto !important; } } </style> </head> <body> <div class="wrap"> <header class="site-head"> <div class="head-row"> <div> <h1>儀器校正與證照到期看板</h1> <p class="lede">把量測儀器的校正到期日和人員證照的到期日放在同一張表,依剩下天數排好,過期前就先提醒負責人。</p> </div> <span class="pill">練習資料皆為虛構</span> </div> </header> <main> <section class="panel" aria-labelledby="h-input"> <h2 id="h-input"><span class="step">1</span> 貼上資料</h2> <p class="panel-lede">在 Excel 選取整個表格(包含第一列欄位名稱)複製後,貼到下面的框裡;也可以選擇 CSV 檔。畫面上已經放了一組範例。</p> <div class="input-grid"> <div class="field"> <label for="pasteBox">表格內容(Tab 或逗號分隔,第一列是欄位名稱)</label> <textarea id="pasteBox" spellcheck="false" autocomplete="off" wrap="off"></textarea> </div> <div class="side"> <p class="cols">需要的欄位:<b>類別</b>、<b>名稱或編號</b>、<b>部門</b>、<b>負責人</b>、<b>到期日</b>、<b>提前幾天提醒</b>(留空就用 30 天)。到期日可以寫成 2026/10/20、2026-10-20 或 10/20(自動補上今年)。</p> <button type="button" class="btn btn-primary" id="btnApply">套用貼上的資料</button> <div> <input type="file" id="csvFile" class="file-input" accept=".csv,.txt,text/csv,text/plain"> <label for="csvFile" class="btn" id="csvLabel">選擇 CSV 檔</label> </div> <div class="btn-row"> <button type="button" class="btn btn-quiet" id="btnClear">清除範例</button> <button type="button" class="btn btn-quiet" id="btnSample">重新載入範例</button> </div> </div> </div> <p class="msg" id="msgInput" role="status" aria-live="polite"></p> </section> <section class="panel" aria-labelledby="h-board"> <h2 id="h-board"><span class="step">2</span> 到期看板</h2> <div class="board-top"> <p class="today">今天是 <span class="mono" id="todayText"></span>,剩餘天數以今天計算。</p> <div class="listing"> <p id="listingText"></p> <button type="button" class="btn btn-quiet" id="btnReset" hidden>取消所有篩選</button> </div> </div> <div class="stats" role="group" aria-label="依狀態篩選"> <button type="button" class="stat s-bad" data-status="bad" aria-pressed="false"> <span class="stat-top"><span class="stat-label">已過期</span><span class="stat-num" data-count>0</span></span> <span class="stat-hint">點一下只看這一類</span> </button> <button type="button" class="stat s-warn" data-status="warn" aria-pressed="false"> <span class="stat-top"><span class="stat-label">提醒期內</span><span class="stat-num" data-count>0</span></span> <span class="stat-hint">點一下只看這一類</span> </button> <button type="button" class="stat s-info" data-status="info" aria-pressed="false"> <span class="stat-top"><span class="stat-label">90 天內到期</span><span class="stat-num" data-count>0</span></span> <span class="stat-hint">點一下只看這一類</span> </button> <button type="button" class="stat s-ok" data-status="ok" aria-pressed="false"> <span class="stat-top"><span class="stat-label">正常</span><span class="stat-num" data-count>0</span></span> <span class="stat-hint">點一下只看這一類</span> </button> </div> <div class="controls"> <div class="filters"> <div class="field"> <label for="fDept">部門</label> <select id="fDept"><option value="">全部部門</option></select> </div> <div class="field"> <label for="fCat">類別</label> <select id="fCat"><option value="">全部類別</option></select> </div> </div> <aside class="rules" aria-labelledby="h-rules"> <h3 id="h-rules">狀態規則</h3> <ul> <li><span class="tag-s s-bad">已過期</span><span>剩餘天數小於 0,也就是到期日已經過了。</span></li> <li><span class="tag-s s-warn">提醒期內</span><span>剩餘天數在 0 天到「提前幾天提醒」之間(含當天)。</span></li> <li><span class="tag-s s-info">90 天內到期</span><span>剩餘天數超過提醒天數,但還在 90 天以內。</span></li> <li><span class="tag-s s-ok">正常</span><span>剩餘天數超過 90 天。</span></li> </ul> <p>剩餘天數=到期日減去今天。提醒日=到期日往前推「提前幾天提醒」的天數,沒有填寫時以 30 天計算。清單裡的細橫條代表剩餘天數,365 天為滿格。</p> </aside> </div> <div class="table-wrap"> <table class="board"> <caption class="vh">到期清單,依剩餘天數由少到多排列</caption> <thead> <tr> <th scope="col" class="c-status">狀態</th> <th scope="col" class="c-days">剩餘天數</th> <th scope="col" class="c-cat">類別</th> <th scope="col" class="c-name">名稱或編號</th> <th scope="col" class="c-dept">部門</th> <th scope="col" class="c-owner">負責人</th> <th scope="col" class="c-due">到期日</th> <th scope="col" class="c-remind">提醒日</th> <th scope="col" class="c-bar">剩餘天數<small>365 天為滿格</small></th> </tr> </thead> <tbody id="tbody"></tbody> </table> </div> <div class="after-table"> <p class="hint">匯出和複製的內容是目前列出的項目,依剩餘天數排序,可以直接貼回 Excel。</p> <div class="btn-row"> <button type="button" class="btn" id="btnCsv">匯出 CSV</button> <button type="button" class="btn" id="btnCopy">複製結果</button> </div> </div> <div class="copy-fallback" id="copyFallback" hidden> <label for="copyBox" class="vh">要複製的表格內容</label> <textarea id="copyBox" readonly spellcheck="false" wrap="off"></textarea> </div> <p class="msg" id="msgBoard" role="status" aria-live="polite"></p> </section> <section class="panel" aria-labelledby="h-notify"> <h2 id="h-notify"><span class="step">3</span> 通知負責人</h2> <div class="notify-grid"> <ul class="notes"> <li>兩個按鈕都依照看板上「目前列出」的項目產生;要包含全部項目,請先取消篩選。</li> <li>提醒信草稿依負責人分組,只列出已過期與提醒期內的項目,可以先修改再複製寄出。</li> <li>行事曆檔每一筆放兩個全天行程:提醒日與到期日;提醒日已經過了的項目,只放到期日。檔案可以匯入 Outlook。</li> </ul> <div class="btn-row"> <button type="button" class="btn btn-primary" id="btnMail">產生提醒信草稿</button> <button type="button" class="btn btn-primary" id="btnIcs">下載行事曆檔(.ics)</button> </div> <div id="mailOut" hidden> <div class="out-head"> <label for="mailBox">提醒信草稿(可以直接修改)</label> <button type="button" class="btn" id="btnCopyMail">複製提醒信</button> </div> <textarea id="mailBox" spellcheck="false" style="margin-top:8px"></textarea> </div> </div> <p class="msg" id="msgNotify" role="status" aria-live="polite"></p> </section> </main> <footer class="site-foot"> <p class="privacy">資料只在這台電腦的瀏覽器裡計算,不會上傳。</p> <p>元智大學終身教育部|眼見為憑:主管的 AI 工作流程實作課|練習工具</p> </footer> </div> <script> (function(){ 'use strict'; var WD = ['日','一','二','三','四','五','六']; var DEFAULT_LEAD = 30; var STATUS = { bad: { label:'已過期', order:0 }, warn: { label:'提醒期內', order:1 }, info: { label:'90 天內到期', order:2 }, ok: { label:'正常', order:3 } }; var CAT_INST = '儀器校正', CAT_CERT = '人員證照', NONE = '(未填)'; /* 範例資料:[類別, 名稱或編號, 部門, 負責人, 到期日距今天數, 提前幾天提醒] */ var SAMPLE = [ [CAT_INST, '網路分析儀 VNA-01', '品保課', '品保課長', 7, 30], [CAT_INST, '推拉力計 PPG-02', '品保課', '品保課長', -12, 30], [CAT_INST, '示波器 OSC-03', '研發工程', '研發工程經理', 45, 30], [CAT_INST, '示波器 OSC-07', '製造部', '製造部課長', 200, 30], [CAT_INST, '恆溫恆濕箱 THC-01', '研發工程', '研發工程經理', 20, 45], [CAT_INST, '電子天平 BAL-05', '製造部', '製造部課長', -3, 30], [CAT_INST, '靜電手環測試器 ESD-T02', '製造部', '製造部課長', 60, 30], [CAT_INST, '溫度記錄器 TLG-11', '倉儲課', '倉儲課長', 150, 30], [CAT_INST, '推拉力計 PPG-04', '製造部', '製造部課長', 240, 30], [CAT_CERT, '堆高機操作證照-倉儲物料員', '倉儲課', '倉儲課長', -26, 30], [CAT_CERT, 'ISO 內部稽核員-品保工程師', '品保課', '品保課長', 14, 60], [CAT_CERT, '急救人員-工安管理員', '工安室', '工安室主任', 25, 30], [CAT_CERT, '電子組裝檢驗員-IPQC 檢驗員', '品保課', '品保課長', 80, 30], [CAT_CERT, '急救人員-製造部領班', '製造部', '製造部經理', 400, 30] ]; var state = { items:[], status:null, dept:'', cat:'', visible:[] }; /* ---------- 日期 ---------- */ function dayNum(y, m, d){ return Math.floor(Date.UTC(y, m - 1, d) / 86400000); } function todayNum(){ var t = new Date(); return dayNum(t.getFullYear(), t.getMonth() + 1, t.getDate()); } function parts(n){ var dt = new Date(n * 86400000); return { y:dt.getUTCFullYear(), m:dt.getUTCMonth() + 1, d:dt.getUTCDate(), w:dt.getUTCDay() }; } function p2(v){ return (v < 10 ? '0' : '') + v; } function isoDate(n){ var p = parts(n); return p.y + '-' + p2(p.m) + '-' + p2(p.d); } function dispDate(n){ return isoDate(n) + '(' + WD[parts(n).w] + ')'; } function compactDate(n){ var p = parts(n); return '' + p.y + p2(p.m) + p2(p.d); } function mdDate(n){ var p = parts(n); return p2(p.m) + '/' + p2(p.d); } function toHalfWidth(s){ return s.replace(/[\uFF01-\uFF5E]/g, function(c){ return String.fromCharCode(c.charCodeAt(0) - 0xFEE0); }) .replace(/\u3000/g, ' '); } function validYMD(y, m, d){ if (m < 1 || m > 12 || d < 1 || d > 31 || y < 1900 || y > 2200) return null; var n = dayNum(y, m, d), p = parts(n); return (p.y === y && p.m === m && p.d === d) ? n : null; } /* 接受 2026/10/20、2026-10-20、2026.10.20、2026年10月20日、20261020、10/20(補今年)、民國 115/10/20 */ function parseDate(raw, tN){ if (raw == null) return null; var s = toHalfWidth(String(raw)).trim(); if (!s) return null; s = s.replace(/[((][一二三四五六日天][))]\s*$/, ''); s = s.replace(/\s+(上午|下午)?\s*\d{1,2}:\d{2}(:\d{2})?\s*(AM|PM)?$/i, ''); s = s.replace(/^(星期|週)[一二三四五六日天]\s*/, ''); s = s.replace(/\s+/g, ''); var m, y, mo, d; if ((m = s.match(/^(\d{3,4})[\/\-.年](\d{1,2})[\/\-.月](\d{1,2})日?$/))){ y = +m[1]; mo = +m[2]; d = +m[3]; if (y < 1000) y += 1911; return validYMD(y, mo, d); } if ((m = s.match(/^(\d{4})(\d{2})(\d{2})$/))) return validYMD(+m[1], +m[2], +m[3]); if ((m = s.match(/^(\d{1,2})[\/\-.月](\d{1,2})日?$/))){ var base = parts(tN == null ? todayNum() : tN).y; return validYMD(base, +m[1], +m[2]); } return null; } /* ---------- 狀態 ---------- */ function classify(days, lead){ if (days < 0) return 'bad'; if (days <= lead) return 'warn'; if (days <= 90) return 'info'; return 'ok'; } function decorate(it, tN){ var days = it.due - tN; return { cat:it.cat, name:it.name, dept:it.dept, owner:it.owner, due:it.due, lead:it.lead, remind:it.due - it.lead, days:days, status:classify(days, it.lead) }; } function byDays(a, b){ if (a.days !== b.days) return a.days - b.days; return a.name.localeCompare(b.name, 'zh-Hant'); } /* ---------- 讀取表格文字 ---------- */ function parseDelimited(text){ text = String(text || '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); var firstLine = ''; var lines = text.split('\n'); for (var k = 0; k < lines.length; k++){ if (lines[k].trim() !== ''){ firstLine = lines[k]; break; } } var delim = firstLine.indexOf('\t') >= 0 ? '\t' : (firstLine.indexOf(',') >= 0 ? ',' : '\t'); var rows = [], row = [], field = '', inQ = false, i = 0, c; while (i < text.length){ c = text[i]; if (inQ){ if (c === '"'){ if (text[i + 1] === '"'){ field += '"'; i += 2; continue; } inQ = false; i++; continue; } field += c; i++; continue; } if (c === '"' && field.trim() === ''){ inQ = true; field = ''; i++; continue; } if (c === delim){ row.push(field); field = ''; i++; continue; } if (c === '\n'){ row.push(field); rows.push(row); row = []; field = ''; i++; continue; } field += c; i++; } row.push(field); rows.push(row); return rows.map(function(r){ return r.map(cleanCell); }) .filter(function(r){ return r.some(function(v){ return v !== ''; }); }); } function cleanCell(s){ return String(s).replace(/[\u3000\u00a0]/g, ' ').replace(/\s+/g, ' ').trim(); } var FIELD_KEYS = [ ['lead', ['提前幾天提醒','提前提醒天數','提醒天數','提前天數','提前提醒','提醒提前']], ['due', ['到期日','到期日期','有效期限','有效日期','校正到期','到期']], ['cat', ['類別','類型','種類']], ['dept', ['部門','單位','課別']], ['owner', ['負責人','保管人','承辦人','聯絡人']], ['name', ['名稱或編號','名稱','編號','儀器','證照','項目']] ]; function mapHeader(header){ var map = {}; header.forEach(function(h, idx){ var key = toHalfWidth(h).replace(/\s+/g, ''); if (!key) return; for (var f = 0; f < FIELD_KEYS.length; f++){ var field = FIELD_KEYS[f][0]; if (map[field] != null) continue; var hit = FIELD_KEYS[f][1].some(function(kw){ return key === kw || key.indexOf(kw) >= 0; }); if (hit){ map[field] = idx; return; } } }); return map; } function normCat(s){ if (!s) return NONE; if (/儀器|校正|量測|設備/.test(s)) return CAT_INST; if (/證照|人員|執照|資格|證書/.test(s)) return CAT_CERT; return s; } function parseLead(s){ var t = toHalfWidth(String(s || '')).replace(/\s+/g, ''); if (!t) return { v:DEFAULT_LEAD, bad:false }; var m = t.match(/^(\d{1,4})(天|日)?$/); if (!m) return { v:DEFAULT_LEAD, bad:true }; return { v:Math.min(+m[1], 3650), bad:false }; } /* 把表格文字轉成項目,回傳 { items, errors, missing, notes } */ function buildItems(text, tN){ var rows = parseDelimited(text); var out = { items:[], errors:[], missing:[], notes:[], rowCount:0 }; if (!rows.length) return out; var map = mapHeader(rows[0]); if (map.name == null) out.missing.push('名稱或編號'); if (map.due == null) out.missing.push('到期日'); if (out.missing.length) return out; var leadFixed = 0; for (var r = 1; r < rows.length; r++){ var row = rows[r]; var get = function(f){ return map[f] == null ? '' : (row[map[f]] || ''); }; var name = get('name'), dueRaw = get('due'); out.rowCount++; if (!name && !dueRaw) continue; if (!name){ out.errors.push('第 ' + (r + 1) + ' 列沒有名稱或編號'); continue; } var due = parseDate(dueRaw, tN); if (due == null){ out.errors.push('第 ' + (r + 1) + ' 列的到期日「' + (dueRaw || '空白') + '」無法判讀'); continue; } var lead = parseLead(get('lead')); if (lead.bad) leadFixed++; out.items.push({ cat:normCat(get('cat')), name:name, dept:get('dept') || NONE, owner:get('owner') || NONE, due:due, lead:lead.v }); } if (map.lead == null) out.notes.push('沒有「提前幾天提醒」欄位,全部以 30 天計算'); if (leadFixed) out.notes.push(leadFixed + ' 列的提醒天數不是數字,已改用 30 天'); return out; } function sampleText(tN){ var head = ['類別','名稱或編號','部門','負責人','到期日','提前幾天提醒']; var lines = [head.join('\t')]; SAMPLE.forEach(function(s){ lines.push([s[0], s[1], s[2], s[3], isoDate(tN + s[4]), s[5]].join('\t')); }); return lines.join('\n'); } /* ---------- 輸出 ---------- */ var OUT_HEAD = ['狀態','剩餘天數','類別','名稱或編號','部門','負責人','到期日','提前幾天提醒','提醒日']; function outRow(x){ return [STATUS[x.status].label, String(x.days), x.cat, x.name, x.dept, x.owner, isoDate(x.due), String(x.lead), isoDate(x.remind)]; } function csvCell(v){ v = String(v == null ? '' : v); return /[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; } function buildCsv(list){ var lines = [OUT_HEAD.map(csvCell).join(',')]; list.forEach(function(x){ lines.push(outRow(x).map(csvCell).join(',')); }); return '\uFEFF' + lines.join('\r\n') + '\r\n'; } function buildTsv(list){ var clean = function(v){ return String(v).replace(/[\t\r\n]+/g, ' '); }; var lines = [OUT_HEAD.join('\t')]; list.forEach(function(x){ lines.push(outRow(x).map(clean).join('\t')); }); return lines.join('\r\n'); } function daysPhrase(days){ if (days < 0) return '已逾期 ' + (-days) + ' 天'; if (days === 0) return '今天到期'; return '剩 ' + days + ' 天'; } /* 提醒信:依負責人分組,只列已過期與提醒期內 */ function buildEmail(list){ var urgent = list.filter(function(x){ return x.status === 'bad' || x.status === 'warn'; }).slice().sort(byDays); var order = [], groups = {}; urgent.forEach(function(x){ if (!groups[x.owner]){ groups[x.owner] = []; order.push(x.owner); } groups[x.owner].push(x); }); var letters = order.map(function(owner){ var who = owner === NONE ? '負責同仁' : owner; var lines = [who + '您好:以下項目已過期或即將到期,請安排校正或換證:']; groups[owner].forEach(function(x){ lines.push('・' + x.name + '(' + x.dept + ')到期日 ' + dispDate(x.due) + ',' + daysPhrase(x.days)); }); lines.push('完成後請回覆新的到期日,方便更新紀錄。謝謝!'); return lines.join('\n'); }); return { text:letters.join('\n\n────────────\n\n'), owners:order.length, count:urgent.length }; } /* .ics */ function icsEscape(s){ return String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r\n|\r|\n/g, '\\n'); } var enc = (typeof TextEncoder !== 'undefined') ? new TextEncoder() : null; function byteLen(ch){ if (enc) return enc.encode(ch).length; var c = ch.codePointAt(0); return c < 0x80 ? 1 : c < 0x800 ? 2 : c < 0x10000 ? 3 : 4; } function foldLine(line){ var out = [], cur = '', n = 0; for (var ch of line){ var b = byteLen(ch); if (n + b > 75){ out.push(cur); cur = ' ' + ch; n = 1 + b; } else { cur += ch; n += b; } } out.push(cur); return out.join('\r\n'); } function hash(s){ var h = 0x811c9dc5; for (var i = 0; i < s.length; i++){ h ^= s.charCodeAt(i); h = Math.imul(h, 0x01000193) >>> 0; } return ('0000000' + h.toString(16)).slice(-8); } function utcStamp(d){ return d.getUTCFullYear() + p2(d.getUTCMonth() + 1) + p2(d.getUTCDate()) + 'T' + p2(d.getUTCHours()) + p2(d.getUTCMinutes()) + p2(d.getUTCSeconds()) + 'Z'; } function buildIcs(list, tN, now){ tN = (tN == null) ? todayNum() : tN; var stamp = utcStamp(now || new Date()); var L = ['BEGIN:VCALENDAR','VERSION:2.0','PRODID:-//Tailin AI Course//Practice Tool//ZH-TW','CALSCALE:GREGORIAN','METHOD:PUBLISH']; var events = 0, skipped = 0; function ev(kind, day, summary, x){ var desc = '類別:' + x.cat + '\n部門:' + x.dept + '\n負責人:' + x.owner + '\n到期日:' + dispDate(x.due) + '\n提醒日:' + dispDate(x.remind) + '(到期前 ' + x.lead + ' 天)'; L.push('BEGIN:VEVENT'); L.push('UID:t4-' + hash(x.cat + '|' + x.name + '|' + x.dept + '|' + isoDate(x.due)) + '-' + kind + '@tailin-practice-tool'); L.push('DTSTAMP:' + stamp); L.push('DTSTART;VALUE=DATE:' + compactDate(day)); L.push('DTEND;VALUE=DATE:' + compactDate(day + 1)); L.push('SUMMARY:' + icsEscape(summary)); L.push('DESCRIPTION:' + icsEscape(desc)); L.push('CATEGORIES:' + icsEscape(x.cat)); L.push('TRANSP:TRANSPARENT'); L.push('END:VEVENT'); events++; } list.forEach(function(x){ if (x.remind >= tN) ev('remind', x.remind, '提醒:' + x.name + ' 將於 ' + mdDate(x.due) + ' 到期', x); else skipped++; ev('due', x.due, '到期:' + x.name, x); }); L.push('END:VCALENDAR'); return { text:L.map(foldLine).join('\r\n') + '\r\n', events:events, skipped:skipped }; } /* ---------- 畫面 ---------- */ var $ = function(id){ return document.getElementById(id); }; function esc(s){ return String(s).replace(/[&<>"']/g, function(c){ return { '&':'&', '<':'<', '>':'>', '"':'"', "'":''' }[c]; }); } function say(id, text, tone){ var el = $(id); el.textContent = text || ''; el.className = 'msg' + (tone ? ' is-' + tone : ''); } function fillSelect(sel, values, allLabel, keep){ var html = '<option value="">' + esc(allLabel) + '</option>'; values.forEach(function(v){ html += '<option value="' + esc(v) + '">' + esc(v) + '</option>'; }); sel.innerHTML = html; sel.value = values.indexOf(keep) >= 0 ? keep : ''; return sel.value; } function uniq(arr){ var seen = {}, out = []; arr.forEach(function(v){ if (!seen[v]){ seen[v] = 1; out.push(v); } }); return out; } function setItems(items){ state.items = items; state.status = null; var depts = uniq(items.map(function(x){ return x.dept; })); var cats = uniq(items.map(function(x){ return x.cat; })).sort(function(a, b){ var r = function(v){ return v === CAT_INST ? 0 : v === CAT_CERT ? 1 : 2; }; return r(a) - r(b) || a.localeCompare(b, 'zh-Hant'); }); state.dept = fillSelect($('fDept'), depts, '全部部門', ''); state.cat = fillSelect($('fCat'), cats, '全部類別', ''); $('mailOut').hidden = true; $('mailBox').value = ''; $('copyFallback').hidden = true; say('msgNotify', ''); say('msgBoard', ''); render(); } /* 「名稱-持證人」這類名稱,讓換行發生在破折號後面,不要剩一個字掉到下一行 */ function keepIds(t){ return t.replace(/[A-Za-z0-9]+(?:[-_.\/][A-Za-z0-9]+)+/g, '<span class="nw">$&</span>'); } function nameHtml(n){ var parts = n.split('-'); if (parts.length < 2) return keepIds(esc(n)); return parts.map(function(p, i){ var t = keepIds(esc(p)) + (i < parts.length - 1 ? '-' : ''); return p.length <= 10 ? '<span class="seg">' + t + '</span>' : t; }).join(''); } function rowHtml(x){ var cls = 's-' + x.status; var days; if (x.days < 0) days = '<span class="d-pre">逾期</span><span class="d-num">' + (-x.days) + '</span><span class="d-unit">天</span>'; else days = '<span class="d-pre">剩</span><span class="d-num">' + x.days + '</span><span class="d-unit">天</span>'; var pct = Math.max(0, Math.min(x.days, 365)) / 365 * 100; var bar = x.days < 0 ? '<span class="bar is-over" aria-hidden="true"><i></i></span>' : '<span class="bar" aria-hidden="true"><i style="width:' + pct.toFixed(1) + '%"></i></span>'; return '<tr class="' + cls + '">' + '<td class="c-status"><span class="tag-s">' + STATUS[x.status].label + '</span></td>' + '<td class="c-days">' + days + '</td>' + '<td class="c-cat">' + esc(x.cat) + '</td>' + '<td class="c-name">' + nameHtml(x.name) + '</td>' + '<td class="c-dept">' + esc(x.dept) + '</td>' + '<td class="c-owner" data-label="負責人">' + esc(x.owner) + '</td>' + '<td class="c-due" data-label="到期日"><span class="mono">' + dispDate(x.due) + '</span></td>' + '<td class="c-remind" data-label="提醒日"><span class="mono">' + dispDate(x.remind) + '</span><span class="sub">到期前 ' + x.lead + ' 天</span></td>' + '<td class="c-bar">' + bar + '</td>' + '</tr>'; } function render(){ var tN = todayNum(); $('todayText').textContent = dispDate(tN); var all = state.items.map(function(it){ return decorate(it, tN); }); var scoped = all.filter(function(x){ return (!state.dept || x.dept === state.dept) && (!state.cat || x.cat === state.cat); }); var counts = { bad:0, warn:0, info:0, ok:0 }; scoped.forEach(function(x){ counts[x.status]++; }); var visible = scoped.filter(function(x){ return !state.status || x.status === state.status; }).sort(byDays); state.visible = visible; Array.prototype.forEach.call(document.querySelectorAll('.stat'), function(b){ var s = b.getAttribute('data-status'); var on = state.status === s; b.querySelector('[data-count]').textContent = counts[s]; b.setAttribute('aria-pressed', on ? 'true' : 'false'); b.querySelector('.stat-hint').textContent = on ? '篩選中,再點一下取消' : '點一下只看這一類'; }); var tb = $('tbody'); if (!state.items.length){ tb.innerHTML = '<tr class="empty"><td colspan="9">目前沒有資料。請從 Excel 複製表格貼到上方的框裡,或選擇 CSV 檔,再按「套用貼上的資料」。</td></tr>'; } else if (!visible.length){ tb.innerHTML = '<tr class="empty"><td colspan="9">目前的篩選條件下沒有項目。</td></tr>'; } else { tb.innerHTML = visible.map(rowHtml).join(''); } var filtered = !!(state.status || state.dept || state.cat); $('listingText').textContent = '目前列出 ' + visible.length + ' 筆(共 ' + state.items.length + ' 筆)'; $('btnReset').hidden = !filtered; } function afterFilterChange(){ render(); if (!$('mailOut').hidden) say('msgNotify', '列出的項目已經變更;需要的話,請重新產生提醒信草稿。', 'warn'); } /* ---------- 套用資料 ---------- */ function applyText(text, sourceLabel){ var tN = todayNum(); if (!String(text || '').trim()){ say('msgInput', '框裡沒有內容。請先從 Excel 複製表格貼上,或選擇 CSV 檔。', 'warn'); return false; } var res = buildItems(text, tN); if (res.missing.length){ say('msgInput', '找不到「' + res.missing.join('」「') + '」欄位,資料沒有套用。第一列請放欄位名稱,例如:類別、名稱或編號、部門、負責人、到期日、提前幾天提醒。', 'bad'); return false; } if (!res.items.length){ var why = res.errors.length ? ':' + res.errors.slice(0, 3).join(';') : '(只有欄位名稱,沒有資料列)'; say('msgInput', '沒有讀到可以使用的資料' + why + '。', 'bad'); return false; } setItems(res.items); var msg = (sourceLabel || '已讀入') + ' ' + res.items.length + ' 筆。'; var tone = 'ok'; if (res.errors.length){ msg += '有 ' + res.errors.length + ' 列無法判讀,已經略過:' + res.errors.slice(0, 4).join(';') + (res.errors.length > 4 ? ';其餘略' : '') + '。'; tone = 'warn'; } if (res.notes.length) msg += res.notes.join(';') + '。'; say('msgInput', msg, tone); return true; } function loadSample(){ var t = sampleText(todayNum()); $('pasteBox').value = t; applyText(t, '已載入虛構範例'); say('msgInput', '已載入 ' + state.items.length + ' 筆虛構範例。換成自己的資料時,請先按「清除範例」,再從 Excel 複製表格貼上。', 'ok'); } function readFile(file){ var reader = new FileReader(); reader.onload = function(){ var buf = reader.result, text; try { text = new TextDecoder('utf-8', { fatal:true }).decode(buf); } catch (e){ try { text = new TextDecoder('big5').decode(buf); } catch (e2){ text = new TextDecoder('utf-8').decode(buf); } } text = text.replace(/^\uFEFF/, ''); $('pasteBox').value = text.replace(/\r\n?/g, '\n'); applyText(text, '已從「' + file.name + '」讀入'); }; reader.onerror = function(){ say('msgInput', '檔案讀取失敗,請確認檔案沒有被其他程式開啟,再試一次。', 'bad'); }; reader.readAsArrayBuffer(file); } /* ---------- 下載與複製 ---------- */ function download(name, text, mime){ var blob = new Blob([text], { type:mime }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = name; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function(){ URL.revokeObjectURL(url); }, 2000); } function copyText(text, onOk, onFail){ if (navigator.clipboard && navigator.clipboard.writeText){ navigator.clipboard.writeText(text).then(onOk, onFail); } else { onFail(); } } function onCopyTable(){ if (!state.visible.length){ say('msgBoard', '目前沒有列出任何項目,沒有內容可以複製。', 'warn'); return; } var text = buildTsv(state.visible); copyText(text, function(){ $('copyFallback').hidden = true; say('msgBoard', '已複製 ' + state.visible.length + ' 筆,可以直接到 Excel 按 Ctrl+V 貼上。', 'ok'); }, function(){ var box = $('copyBox'); box.value = text; $('copyFallback').hidden = false; box.focus(); box.select(); say('msgBoard', '瀏覽器不允許自動複製。下方的文字已經選取好,請按 Ctrl+C 複製。', 'warn'); }); } function onCsv(){ if (!state.visible.length){ say('msgBoard', '目前沒有列出任何項目,沒有內容可以匯出。', 'warn'); return; } download('儀器校正與證照到期_' + compactDate(todayNum()) + '.csv', buildCsv(state.visible), 'text/csv;charset=utf-8'); say('msgBoard', '已下載 CSV 檔,共 ' + state.visible.length + ' 筆。用 Excel 開啟時中文會正常顯示。', 'ok'); } function onMail(){ if (!state.visible.length){ say('msgNotify', '目前沒有列出任何項目。', 'warn'); return; } var r = buildEmail(state.visible); if (!r.count){ $('mailOut').hidden = true; say('msgNotify', '目前列出的項目中,沒有已過期或提醒期內的項目,不需要寄提醒信。', 'ok'); return; } $('mailBox').value = r.text; $('mailOut').hidden = false; say('msgNotify', '已整理 ' + r.owners + ' 位負責人、' + r.count + ' 個項目的提醒信草稿,每位負責人之間用分隔線隔開。', 'ok'); } function onCopyMail(){ var box = $('mailBox'); copyText(box.value, function(){ say('msgNotify', '已複製提醒信草稿,可以貼到 Email 或 LINE。', 'ok'); }, function(){ box.focus(); box.select(); say('msgNotify', '瀏覽器不允許自動複製。提醒信已經選取好,請按 Ctrl+C 複製。', 'warn'); }); } function onIcs(){ if (!state.visible.length){ say('msgNotify', '目前沒有列出任何項目,沒有行程可以產生。', 'warn'); return; } var r = buildIcs(state.visible, todayNum(), new Date()); download('到期提醒行事曆_' + compactDate(todayNum()) + '.ics', r.text, 'text/calendar;charset=utf-8'); var msg = '已下載行事曆檔,共 ' + r.events + ' 個全天行程'; if (r.skipped) msg += '(有 ' + r.skipped + ' 筆的提醒日已經過了,只放到期日)'; msg += '。在電腦上雙擊這個檔案,就可以加入 Outlook 行事曆。'; say('msgNotify', msg, 'ok'); } /* ---------- 事件 ---------- */ function bind(){ $('btnApply').addEventListener('click', function(){ applyText($('pasteBox').value); }); $('pasteBox').addEventListener('paste', function(e){ var box = $('pasteBox'); var data = e.clipboardData ? e.clipboardData.getData('text/plain') : ''; if (data && box.value.trim() !== ''){ /* 貼上的是一整張含欄位名稱的表格:直接取代框裡原本的內容,避免和範例混在一起 */ var rows = parseDelimited(data); var m = rows.length ? mapHeader(rows[0]) : {}; var whole = box.selectionStart === 0 && box.selectionEnd === box.value.length; if (!whole && m.name != null && m.due != null){ e.preventDefault(); box.value = data.replace(/\r\n?/g, '\n'); applyText(box.value, '已用貼上的表格取代原本內容,讀入'); return; } } setTimeout(function(){ applyText(box.value); }, 0); }); $('csvFile').addEventListener('change', function(e){ var f = e.target.files && e.target.files[0]; if (f) readFile(f); e.target.value = ''; }); $('btnClear').addEventListener('click', function(){ $('pasteBox').value = ''; setItems([]); say('msgInput', '已清除範例。請從 Excel 複製表格貼到框裡,或選擇 CSV 檔。', 'ok'); $('pasteBox').focus(); }); $('btnSample').addEventListener('click', loadSample); Array.prototype.forEach.call(document.querySelectorAll('.stat'), function(b){ b.addEventListener('click', function(){ var s = b.getAttribute('data-status'); state.status = (state.status === s) ? null : s; afterFilterChange(); }); }); $('fDept').addEventListener('change', function(e){ state.dept = e.target.value; afterFilterChange(); }); $('fCat').addEventListener('change', function(e){ state.cat = e.target.value; afterFilterChange(); }); $('btnReset').addEventListener('click', function(){ state.status = null; state.dept = ''; state.cat = ''; $('fDept').value = ''; $('fCat').value = ''; afterFilterChange(); }); $('btnCsv').addEventListener('click', onCsv); $('btnCopy').addEventListener('click', onCopyTable); $('btnMail').addEventListener('click', onMail); $('btnCopyMail').addEventListener('click', onCopyMail); $('btnIcs').addEventListener('click', onIcs); } bind(); loadSample(); /* 給測試用 */ window.T4 = { state:state, SAMPLE:SAMPLE, todayNum:todayNum, dayNum:dayNum, isoDate:isoDate, dispDate:dispDate, parseDate:parseDate, parseDelimited:parseDelimited, buildItems:buildItems, classify:classify, buildCsv:buildCsv, buildTsv:buildTsv, buildEmail:buildEmail, buildIcs:buildIcs, foldLine:foldLine, applyText:applyText, render:render }; })(); </script> </body> </html>
<!doctype html> <html lang="zh-Hant"> <head> <meta charset="utf-8"> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover"> <title>跨部門會議時段協調器</title> <style> :root{ --bg:#f4f6f3; --surface:#ffffff; --surface-2:#eaf0ec; --line:#cfd9d3; --ink:#16221d; --ink-2:#4a5a52; --accent:#0b6b4f; /* 防焊綠 */ --accent-ink:#ffffff; --copper:#a8601f; /* 銅箔 */ --copper-ink:#ffffff; --ok:#1f7a45; --ok-bg:#e3f3e8; --warn:#8a5900; --warn-bg:#fdf1d8; --bad:#b3261e; --bad-bg:#fbe4e1; --info:#1d5f9e; --info-bg:#e3eefa; --focus:#c27a2c; /* 格子圖深淺:0 = 沒人有空,4 = 一定要到全員有空 */ --heat-0:#ffffff; --heat-1:#e6f1ec; --heat-2:#c3dfd2; --heat-3:#93c4ad; --heat-4:#0b6b4f; --heat-ink:#16221d; --heat-ink-4:#ffffff; --font:"Microsoft JhengHei","PingFang TC","Noto Sans TC","Heiti TC",sans-serif; --mono:"Cascadia Mono",Consolas,"SF Mono",ui-monospace,monospace; } @media (prefers-color-scheme: dark){ :root:not([data-theme="light"]){ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --copper-ink:#1d1006; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; --heat-0:#15201b; --heat-1:#183429; --heat-2:#1d4a39; --heat-3:#246148; --heat-4:#3fbf8f; --heat-ink:#e3ece7; --heat-ink-4:#06130e; color-scheme:dark; } } :root[data-theme="dark"]{ --bg:#0e1613; --surface:#15201b; --surface-2:#1c2a24; --line:#2c3d35; --ink:#e3ece7; --ink-2:#a3b5ac; --accent:#3fbf8f; --accent-ink:#06130e; --copper:#e09a58; --copper-ink:#1d1006; --ok:#5fd08f; --ok-bg:#16301f; --warn:#f0b54a; --warn-bg:#33270e; --bad:#ff8a80; --bad-bg:#3a1714; --info:#7cb6f0; --info-bg:#132539; --focus:#e09a58; --heat-0:#15201b; --heat-1:#183429; --heat-2:#1d4a39; --heat-3:#246148; --heat-4:#3fbf8f; --heat-ink:#e3ece7; --heat-ink-4:#06130e; color-scheme:dark; } *,*::before,*::after{box-sizing:border-box} html{-webkit-text-size-adjust:100%;text-size-adjust:100%} body{margin:0;background:var(--bg);color:var(--ink);font-family:var(--font);font-size:16px;line-height:1.6} .mono{font-family:var(--mono);font-variant-numeric:tabular-nums} .vh{position:absolute!important;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap;border:0} :focus-visible{outline:3px solid var(--focus);outline-offset:2px} .wrap{max-width:1180px;margin-inline:auto;padding-inline:16px} /* 頁首 */ .site-head{padding-top:26px} .head-inner{display:flex;gap:12px 20px;align-items:flex-start;justify-content:space-between;flex-wrap:wrap} h1{font-size:28px;line-height:1.3;margin:0 0 4px;font-weight:800;letter-spacing:.02em} .lede{margin:0;color:var(--ink-2);font-size:17px;max-width:46em} .tag-fake{flex:none;border:1.5px solid var(--copper);color:var(--copper);border-radius:999px;padding:2px 12px;font-size:14px;font-weight:700;white-space:nowrap;margin-top:4px} .trace{height:3px;background:var(--accent);margin-top:18px;position:relative} .trace::before{content:"";position:absolute;left:0;top:0;bottom:0;width:48px;background:var(--copper)} /* 區塊 */ main{padding-block:22px 8px} .stack > * + *{margin-top:20px} .card{background:var(--surface);border:1px solid var(--line);border-radius:12px;padding:20px;min-width:0} .row2{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:20px;align-items:start} h2{font-size:20px;line-height:1.35;margin:0 0 12px;display:flex;align-items:center;gap:10px} .step{flex:none;display:inline-flex;align-items:center;justify-content:center;min-width:30px;height:30px;border-radius:7px;background:var(--accent);color:var(--accent-ink);font-family:var(--mono);font-size:16px;font-weight:700} .hint{color:var(--ink-2);font-size:15px;margin:6px 0 0} p{margin:0} /* 表單 */ .field{border:0;margin:0 0 16px;padding:0;min-width:0} .field:last-child{margin-bottom:0} .field > label,.field > legend{display:block;font-weight:700;margin:0 0 6px;padding:0} input[type=text],input[type=date],select,textarea{font:inherit;color:var(--ink);background:var(--surface);border:1.5px solid var(--line);border-radius:8px} input[type=text],input[type=date],select{min-height:44px;padding:0 12px;width:100%;min-width:0} select{padding-right:8px} input:hover,select:hover,textarea:hover{border-color:var(--ink-2)} .pair{display:grid;grid-template-columns:auto minmax(0,1fr) auto minmax(0,1fr);gap:8px 10px;align-items:center} .pair .sub{font-weight:400;color:var(--ink-2)} .seg{display:inline-flex;border:1.5px solid var(--line);border-radius:9px;overflow:hidden;position:relative;flex:none} .seg input{position:absolute;opacity:0;width:1px;height:1px;margin:0;pointer-events:none} .seg label{display:flex;align-items:center;justify-content:center;min-height:42px;padding:0 14px;cursor:pointer;background:var(--surface);color:var(--ink-2);font-weight:700;white-space:nowrap;transition:background-color .12s} .seg label:hover{background:var(--surface-2)} .seg input + label ~ input + label{border-left:1.5px solid var(--line)} .seg input:checked + label{background:var(--accent);color:var(--accent-ink)} .seg input.opt:checked + label{background:var(--copper);color:var(--copper-ink)} .seg input:focus-visible + label{outline:3px solid var(--focus);outline-offset:-3px} /* 與會者 */ .people{list-style:none;margin:0;padding:0} .person{display:flex;align-items:center;justify-content:space-between;gap:8px 12px;flex-wrap:wrap;padding:10px 0;border-bottom:1px solid var(--line)} .person:first-child{padding-top:0} .p-name{font-weight:700;font-size:17px;overflow-wrap:anywhere} .p-count{display:block;font-size:14px;color:var(--ink-2);font-weight:400} .p-right{display:flex;align-items:center;gap:8px;flex-wrap:wrap} .people-sum{margin:12px 0 0;font-size:15px;color:var(--ink-2)} .add-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:8px;margin-top:6px} .empty{color:var(--ink-2);padding:12px 0} /* 按鈕 */ .btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;min-height:44px;padding:0 18px;border-radius:8px;border:1.5px solid var(--accent);background:var(--accent);color:var(--accent-ink);font:inherit;font-weight:700;cursor:pointer;text-align:center;line-height:1.3;transition:background-color .12s} .btn:hover:not(:disabled){background:color-mix(in srgb, var(--accent) 88%, var(--ink))} .btn-outline{background:var(--surface);color:var(--accent)} .btn-outline:hover:not(:disabled){background:var(--surface-2)} .btn-small{min-height:40px;padding:0 12px;font-size:15px} .btn:disabled{opacity:.45;cursor:not-allowed} .btns{display:flex;flex-wrap:wrap;gap:10px;margin-top:12px;align-items:center} .file-in{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none} .file-in:focus-visible + label{outline:3px solid var(--focus);outline-offset:2px} /* 行程輸入 */ #sched{display:block;width:100%;min-height:230px;padding:10px 12px;font-family:var(--mono);font-size:15px;line-height:1.55;white-space:pre;overflow:auto;resize:vertical;tab-size:10} /* 訊息 */ .msg{margin-top:12px;padding:9px 12px;border-radius:8px;border-left:4px solid var(--info);background:var(--info-bg);color:var(--ink);font-size:15px} .msg:empty{margin:0;padding:0;border:0;background:none} .msg-ok{border-left-color:var(--ok);background:var(--ok-bg)} .msg-warn{border-left-color:var(--warn);background:var(--warn-bg)} .msg-bad{border-left-color:var(--bad);background:var(--bad-bg)} .msg ul{margin:6px 0 0;padding-left:1.3em} .range-hint{display:flex;flex-wrap:wrap;gap:10px;align-items:center;margin-top:10px;padding:10px 12px;border-radius:8px;background:var(--warn-bg);border-left:4px solid var(--warn)} .range-hint[hidden]{display:none} .fallback-copy{display:block;width:100%;margin-top:10px;min-height:120px;padding:8px 10px;font-family:var(--mono);font-size:14px} .fallback-copy[hidden]{display:none} /* 統計 */ .stats{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 16px;padding:0;list-style:none} .stat{display:flex;align-items:baseline;gap:6px;border:1px solid var(--line);background:var(--surface);border-radius:8px;padding:6px 14px;font-size:15px;color:var(--ink-2)} .stat b{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:22px;color:var(--ink)} .stat.key{border-color:var(--accent);border-width:1.5px} .stat.key b{color:var(--accent)} .stat.key.none b{color:var(--bad)} /* 結果區 */ .results{display:grid;grid-template-columns:minmax(0,7fr) minmax(0,6fr);grid-template-areas:"grid cand" "grid draft";gap:20px;align-items:start} .cand-card{grid-area:cand} .grid-card{grid-area:grid} .draft-card{grid-area:draft} /* 候選時段 */ .lead-in{margin:0 0 12px} .cands{list-style:none;margin:0;padding:0;display:grid;gap:8px} .cand{width:100%;display:grid;grid-template-columns:auto minmax(0,1fr);gap:4px 12px;align-items:start;text-align:left;padding:12px 14px;border:1.5px solid var(--line);border-radius:10px;background:var(--surface);color:var(--ink);font:inherit;cursor:pointer;transition:border-color .12s} .cand:hover{border-color:var(--accent)} .cand[aria-pressed="true"]{border-color:var(--copper);box-shadow:inset 0 0 0 1.5px var(--copper);background:var(--surface-2)} .cand-no{grid-row:span 3;display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;border:1.5px solid var(--accent);color:var(--accent);font-family:var(--mono);font-weight:700} .cand[aria-pressed="true"] .cand-no{background:var(--copper);border-color:var(--copper);color:var(--copper-ink)} .cand-when{display:flex;flex-wrap:wrap;gap:2px 12px;align-items:baseline} .cand-date{font-weight:700;font-size:17px} .cand-time{font-weight:700;font-size:19px} .cand-line{font-size:15px;color:var(--ink-2)} .cand-line .who{color:var(--ink);font-weight:700} .cand-pick{font-size:14px;color:var(--copper);font-weight:700} .tag{display:inline-block;padding:1px 8px;border-radius:6px;font-size:14px;font-weight:700;line-height:1.6;white-space:nowrap} .tag-ok{background:var(--ok-bg);color:var(--ok)} .tag-warn{background:var(--warn-bg);color:var(--warn)} .tag-bad{background:var(--bad-bg);color:var(--bad)} /* 格子圖 */ .legend{display:flex;flex-wrap:wrap;gap:8px 18px;align-items:center;margin:0 0 12px;padding:0;list-style:none;font-size:14px;color:var(--ink-2)} .legend li{display:flex;align-items:center;gap:6px} .scale{display:flex;gap:3px} .sw{display:inline-flex;align-items:center;justify-content:center;width:26px;height:22px;border-radius:4px;border:1px solid var(--line);font-family:var(--mono);font-size:12px} .sw.h0{background:var(--heat-0)}.sw.h1{background:var(--heat-1);border-color:transparent}.sw.h2{background:var(--heat-2);border-color:transparent}.sw.h3{background:var(--heat-3);border-color:transparent}.sw.h4{background:var(--heat-4);border-color:var(--heat-4);color:var(--heat-ink-4)} .sw.lunch-sw{background:repeating-linear-gradient(135deg,var(--line) 0 2px,transparent 2px 6px),var(--surface-2)} .sw.sel-sw{background:var(--surface);box-shadow:inset 0 0 0 3px var(--copper);border-color:transparent} .grid-wrap{overflow-x:auto;-webkit-overflow-scrolling:touch;border:1px solid var(--line);border-radius:10px;background:var(--surface)} .slots{border-collapse:separate;border-spacing:3px;width:100%;min-width:calc(66px + var(--ncol,5) * 94px)} .slots thead th{padding:6px 2px 4px;text-align:center;line-height:1.3;font-weight:400} .slots .dow{display:block;font-size:16px;font-weight:700;color:var(--ink)} .slots .dt{display:block;font-size:13px;color:var(--ink-2)} .slots tbody th{font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:14px;font-weight:400;color:var(--ink-2);text-align:right;padding:0 8px 0 2px;white-space:nowrap} .slots .tcol{position:sticky;left:0;z-index:2;background:var(--surface);width:62px} .slots thead .tcol{font-size:14px;color:var(--ink-2);text-align:right;padding-right:8px} .slots td{padding:0} .cell{display:flex;align-items:center;justify-content:center;gap:5px;width:100%;height:40px;padding:0;border:1px solid var(--line);border-radius:6px;background:var(--heat-0);color:var(--heat-ink);font-family:var(--mono);font-variant-numeric:tabular-nums;font-size:15px;cursor:pointer} .cell.h0{color:var(--ink-2)} .cell.h1{background:var(--heat-1);border-color:var(--heat-1)} .cell.h2{background:var(--heat-2);border-color:var(--heat-2)} .cell.h3{background:var(--heat-3);border-color:var(--heat-3)} .cell.h4{background:var(--heat-4);border-color:var(--heat-4);color:var(--heat-ink-4);font-weight:700} .cell:hover{border-color:var(--ink)} .cell .ck{width:15px;height:15px;flex:none} .cell.sel{box-shadow:inset 0 0 0 3px var(--copper);border-color:var(--copper)} .cell:focus-visible{outline-offset:1px} .lunch{display:flex;align-items:center;justify-content:center;height:40px;border-radius:6px;border:1px dashed var(--line);background:repeating-linear-gradient(135deg,var(--line) 0 2px,transparent 2px 9px),var(--surface-2);color:var(--ink-2);font-size:14px} .grid-note{margin-top:10px} /* 浮動說明 */ .tip{position:fixed;left:0;top:0;z-index:50;width:max-content;max-width:min(340px,calc(100vw - 24px));padding:10px 12px;border-radius:10px;border:2px solid var(--ink-2);background:var(--surface);color:var(--ink);font-size:15px;line-height:1.55;pointer-events:none} .tip[hidden]{display:none} .tip-h{font-weight:700;margin-bottom:2px} .tip-sub{margin-top:6px;font-weight:700} .tip ul{margin:2px 0 0;padding-left:1.1em} .tip li{margin:2px 0} .tip-hint{margin-top:8px;padding-top:6px;border-top:1px solid var(--line);color:var(--ink-2)} .tip-hint.go{color:var(--accent);font-weight:700} /* 邀請草稿 */ .sel-info{display:flex;flex-wrap:wrap;gap:6px 10px;align-items:center;margin-bottom:10px;font-weight:700} .sel-info .mono{font-size:17px} .draft-label{display:block;font-size:15px;color:var(--ink-2);margin-bottom:6px} #draft{display:block;width:100%;min-height:240px;padding:12px 14px;line-height:1.7;resize:vertical;overflow:auto} .draft-empty{padding:18px 16px;border:1.5px dashed var(--line);border-radius:10px;color:var(--ink-2)} .draft-empty[hidden],#draftBox[hidden]{display:none} /* 頁尾 */ .site-foot{margin-top:28px;border-top:1px solid var(--line);padding:16px 0 32px;color:var(--ink-2);font-size:14px} .site-foot p + p{margin-top:2px} @media (max-width:960px){ .results{grid-template-columns:minmax(0,1fr);grid-template-areas:"cand" "grid" "draft"} } @media (max-width:860px){ .row2{grid-template-columns:minmax(0,1fr)} } @media (max-width:520px){ h1{font-size:24px} .lede{font-size:16px} .card{padding:16px} .pair{grid-template-columns:auto minmax(0,1fr)} .btns .btn{flex:1 1 auto} .seg label{padding:0 11px} } @media (prefers-reduced-motion: reduce){ *,*::before,*::after{transition:none!important;animation:none!important;scroll-behavior:auto!important} } </style> </head> <body> <header class="site-head"> <div class="wrap"> <div class="head-inner"> <div> <h1>跨部門會議時段協調器</h1> <p class="lede">把每個人已經排定的行程貼進來,馬上找出「一定要到」的人都有空的會議時段,再產生會議邀請與行事曆檔。</p> </div> <span class="tag-fake">練習資料皆為虛構</span> </div> <div class="trace" aria-hidden="true"></div> </div> </header> <noscript><div class="wrap"><p class="msg msg-bad">這個工具需要開啟 JavaScript 才能使用。</p></div></noscript> <main class="wrap stack"> <div class="row2"> <section class="card" aria-labelledby="h-set"> <h2 id="h-set"><span class="step">1</span>會議設定</h2> <div class="field"> <label for="mName">會議名稱</label> <input type="text" id="mName" autocomplete="off"> </div> <fieldset class="field"> <legend>會議長度</legend> <div class="seg" role="radiogroup" aria-label="會議長度"> <input type="radio" name="dur" id="dur30" value="30"><label for="dur30">30 分鐘</label> <input type="radio" name="dur" id="dur60" value="60" checked><label for="dur60">60 分鐘</label> <input type="radio" name="dur" id="dur90" value="90"><label for="dur90">90 分鐘</label> </div> </fieldset> <fieldset class="field"> <legend>日期範圍</legend> <div class="pair"> <label class="sub" for="dStart">從</label><input type="date" id="dStart"> <label class="sub" for="dEnd">到</label><input type="date" id="dEnd"> </div> <p class="hint" id="todayHint">預設是下週一到週五。週六、週日不排會議。</p> </fieldset> <fieldset class="field"> <legend>上班時間</legend> <div class="pair"> <label class="sub" for="wStart">從</label><select id="wStart"></select> <label class="sub" for="wEnd">到</label><select id="wEnd"></select> </div> </fieldset> <fieldset class="field"> <legend>午休(這段時間不排會議)</legend> <div class="pair"> <label class="sub" for="lStart">從</label><select id="lStart"></select> <label class="sub" for="lEnd">到</label><select id="lEnd"></select> </div> </fieldset> <div class="msg" id="setMsg" role="status"></div> </section> <section class="card" aria-labelledby="h-ppl"> <h2 id="h-ppl"><span class="step">2</span>與會者</h2> <p class="hint" style="margin:0 0 10px">名單會依照貼上的行程自動列出。請替每個人選「一定要到」或「可以缺席」。</p> <ul class="people" id="people"></ul> <p class="people-sum" id="peopleSum"></p> <div class="field" style="margin-top:14px"> <label for="newName">新增一位沒有任何行程的與會者</label> <div class="add-row"> <input type="text" id="newName" autocomplete="off"> <button type="button" class="btn btn-outline" id="addName">加入名單</button> </div> </div> <div class="msg" id="pplMsg" role="status"></div> </section> </div> <section class="card" aria-labelledby="h-sched"> <h2 id="h-sched"><span class="step">3</span>貼上已排定的行程</h2> <p class="hint" style="margin:0 0 10px">從 Excel 選取五欄(與會者、日期、開始時間、結束時間、事由,第一列是欄位名稱)複製後,直接貼到下面;也可以選擇 CSV 檔。貼上後會自動重新計算。</p> <label for="sched" class="vh">已排定的行程</label> <textarea id="sched" wrap="off" spellcheck="false" autocomplete="off"></textarea> <div class="btns"> <input type="file" id="csvFile" class="file-in" accept=".csv,.txt,text/csv,text/plain"> <label for="csvFile" class="btn btn-outline">選擇 CSV 檔</label> <button type="button" class="btn btn-outline" id="clearSample">清除範例</button> <button type="button" class="btn btn-outline" id="loadSample">重新載入範例</button> </div> <div class="range-hint" id="rangeHint" hidden> <span id="rangeHintText"></span> <button type="button" class="btn btn-small" id="useDataRange">改用行程的日期範圍</button> </div> <div class="msg" id="schedMsg" role="status"></div> </section> <section aria-labelledby="h-res"> <h2 id="h-res"><span class="step">4</span>選一個時段</h2> <ul class="stats" id="stats" aria-label="統計"></ul> <div class="results"> <div class="card cand-card" aria-labelledby="h-cand"> <h2 id="h-cand">候選時段</h2> <div id="candBox"></div> <div class="btns" id="candBtns"> <button type="button" class="btn btn-outline" id="copyCands">複製結果</button> <button type="button" class="btn btn-outline" id="csvCands">匯出 CSV</button> </div> <div class="msg" id="candMsg" role="status"></div> <textarea class="fallback-copy" id="candFallback" hidden aria-label="要複製的候選時段" readonly></textarea> </div> <div class="card grid-card" aria-labelledby="h-grid"> <h2 id="h-grid">每半小時有幾位「一定要到」的人有空</h2> <ul class="legend" aria-label="圖例"> <li><span class="scale" aria-hidden="true"><span class="sw h0"></span><span class="sw h1"></span><span class="sw h2"></span><span class="sw h3"></span><span class="sw h4"><svg class="ck" viewBox="0 0 16 16" width="13" height="13" aria-hidden="true"><path d="M3 8.6l3.1 3.1L13 4.8" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg></span></span>顏色越深人越齊,打勾表示全員有空</li> <li><span class="sw lunch-sw" aria-hidden="true"></span>午休</li> <li><span class="sw sel-sw" aria-hidden="true"></span>選定的會議時段</li> </ul> <div class="grid-wrap" id="gridWrap"></div> <p class="hint grid-note">格子裡的數字是「有空的人數/一定要到的總人數」。把滑鼠移到格子上,或點一下格子,可以看到誰沒空與事由;點打勾的格子,可以直接從那個時間開始安排會議。</p> </div> <section class="card draft-card" aria-labelledby="h-draft"> <h2 id="h-draft"><span class="step">5</span>會議邀請草稿</h2> <p class="draft-empty" id="draftEmpty">請先點選一個候選時段,或格子圖裡打勾的格子,這裡就會產生會議邀請文字。</p> <div id="draftBox" hidden> <div class="sel-info" id="selInfo"></div> <label class="draft-label" for="draft">邀請文字可以直接修改,再按「複製邀請文字」。修改上方設定時,草稿會重新產生。</label> <textarea id="draft" spellcheck="false"></textarea> <div class="btns"> <button type="button" class="btn" id="copyDraft">複製邀請文字</button> <button type="button" class="btn" id="dlIcs">下載行事曆檔(.ics)</button> </div> <p class="hint">行事曆檔可以直接用 Outlook 開啟或匯入,時間以台北時間計算。</p> </div> <div class="msg" id="draftMsg" role="status"></div> <textarea class="fallback-copy" id="draftFallback" hidden aria-label="要複製的邀請文字" readonly></textarea> </section> </div> </section> </main> <div class="tip" id="tip" role="tooltip" hidden></div> <footer class="site-foot"> <div class="wrap"> <p>資料只在這台電腦的瀏覽器裡計算,不會上傳。</p> <p>元智大學終身教育部|眼見為憑:主管的 AI 工作流程實作課|練習工具</p> </div> </footer> <script> (function(){ 'use strict'; /* ---------- 小工具 ---------- */ var $ = function(s, r){ return (r || document).querySelector(s); }; var $$ = function(s, r){ return Array.prototype.slice.call((r || document).querySelectorAll(s)); }; var DOW = ['日','一','二','三','四','五','六']; function pad2(n){ return (n < 10 ? '0' : '') + n; } function isoOf(d){ return d.getFullYear() + '-' + pad2(d.getMonth() + 1) + '-' + pad2(d.getDate()); } function dateOf(iso){ var p = iso.split('-'); return new Date(+p[0], +p[1] - 1, +p[2]); } function addDays(iso, n){ var d = dateOf(iso); d.setDate(d.getDate() + n); return isoOf(d); } function dowOf(iso){ return dateOf(iso).getDay(); } function fmtDate(iso){ return iso + '(' + DOW[dowOf(iso)] + ')'; } function fmtTime(m){ return pad2(Math.floor(m / 60)) + ':' + pad2(m % 60); } function todayISO(){ return isoOf(new Date()); } function dayDiff(a, b){ return Math.round((dateOf(b) - dateOf(a)) / 86400000); } function esc(s){ return String(s == null ? '' : s).replace(/[&<>"']/g, function(c){ return {'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]; }); } function clean(s){ return String(s == null ? '' : s).replace(/[\u3000\u00a0]/g, ' ').replace(/\s+/g, ' ').trim(); } function toHalf(s){ return String(s == null ? '' : s).replace(/[\uFF01-\uFF5E]/g, function(c){ return String.fromCharCode(c.charCodeAt(0) - 0xFEE0); }).replace(/[\u3000\u00a0]/g, ' '); } function addWorkdays(iso, n){ var d = iso; while (n > 0){ d = addDays(d, 1); var w = dowOf(d); if (w !== 0 && w !== 6) n--; } return d; } var CHECK = '<svg class="ck" viewBox="0 0 16 16" aria-hidden="true"><path d="M3 8.6l3.1 3.1L13 4.8" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"/></svg>'; function nextWeekRange(){ var t = new Date(); var add = ((8 - t.getDay()) % 7) || 7; // 下週一(週日時就是明天) var mon = new Date(t.getFullYear(), t.getMonth(), t.getDate() + add); var s = isoOf(mon); return { start: s, end: addDays(s, 4) }; } /* ---------- 解析日期與時間 ---------- */ function validYMD(y, m, d){ if (!(y >= 1900 && y <= 2200 && m >= 1 && m <= 12 && d >= 1 && d <= 31)) return null; var dt = new Date(y, m - 1, d); if (dt.getMonth() !== m - 1) return null; return isoOf(dt); } function parseDate(raw, refISO){ var s = toHalf(clean(raw)); if (!s) return null; s = s.replace(/\s*[((][^))]*[))]\s*$/, '').replace(/\s*(星期|週|周)[一二三四五六日天]\s*$/, '').trim(); s = s.replace(/\s+\d{1,2}:\d{2}(:\d{2})?$/, ''); // 去掉 Excel 偶爾附帶的時間 var m; if ((m = s.match(/^(\d{4}|\d{3})\s*[\/\-.年]\s*(\d{1,2})\s*[\/\-.月]\s*(\d{1,2})\s*日?$/))){ var y = +m[1]; if (m[1].length === 3) y += 1911; // 民國年 return validYMD(y, +m[2], +m[3]); } if ((m = s.match(/^(\d{4})(\d{2})(\d{2})$/))) return validYMD(+m[1], +m[2], +m[3]); if ((m = s.match(/^(\d{1,2})\s*[\/\-.月]\s*(\d{1,2})\s*日?$/))){ var yr = new Date().getFullYear(); var iso = validYMD(yr, +m[1], +m[2]); if (iso && refISO){ // 跨年時取最接近日期範圍的年份 var best = iso, bestGap = Math.abs(dayDiff(refISO, iso)); [yr - 1, yr + 1].forEach(function(y2){ var c = validYMD(y2, +m[1], +m[2]); if (c && Math.abs(dayDiff(refISO, c)) < bestGap - 120){ best = c; bestGap = Math.abs(dayDiff(refISO, c)); } }); iso = best; } return iso; } return null; } function parseTime(raw){ var s = toHalf(clean(raw)).toUpperCase().replace(/\s+/g, ''); if (!s) return null; var ap = null, mm; if ((mm = s.match(/^(上午|下午|中午|早上|晚上|AM|PM|A\.M\.|P\.M\.)/))){ ap = mm[1]; s = s.slice(mm[1].length); } if ((mm = s.match(/(上午|下午|AM|PM|A\.M\.|P\.M\.)$/))){ ap = mm[1]; s = s.slice(0, s.length - mm[1].length); } var h, mi, m; if ((m = s.match(/^(\d{1,2})[:時點](\d{1,2})?(?::(\d{1,2}))?分?$/))){ h = +m[1]; mi = m[2] ? +m[2] : 0; } else if ((m = s.match(/^(\d{1,2})(\d{2})$/))){ h = +m[1]; mi = +m[2]; } else if ((m = s.match(/^(\d{1,2})$/))){ h = +m[1]; mi = 0; } else if ((m = s.match(/^0?\.(\d+)$/))){ var t = Math.round(parseFloat('0.' + m[1]) * 1440); h = Math.floor(t / 60); mi = t % 60; } else return null; if (ap){ if (/PM|P\.M\.|下午|晚上/.test(ap) && h < 12) h += 12; if (/AM|A\.M\.|上午|早上/.test(ap) && h === 12) h = 0; } if (mi > 59 || h > 24 || (h === 24 && mi > 0)) return null; return h * 60 + mi; } /* ---------- 解析貼上的表格 ---------- */ function parseDelimited(text){ text = String(text || '').replace(/^\uFEFF/, '').replace(/\r\n?/g, '\n'); var first = ''; var lines0 = text.split('\n'); for (var k = 0; k < lines0.length; k++){ if (lines0[k].trim() !== ''){ first = lines0[k]; break; } } var delim = first.indexOf('\t') >= 0 ? '\t' : (first.indexOf(',') >= 0 ? ',' : (first.indexOf(',') >= 0 ? ',' : '\t')); var rows = [], row = [], field = '', inQ = false, line = 1, rowLine = 1; for (var i = 0; i < text.length; i++){ var c = text[i]; if (inQ){ if (c === '"'){ if (text[i + 1] === '"'){ field += '"'; i++; } else inQ = false; } else { if (c === '\n') line++; field += c; } continue; } if (c === '"' && field.trim() === ''){ inQ = true; field = ''; continue; } if (c === delim){ row.push(field); field = ''; continue; } if (c === '\n'){ row.push(field); rows.push({ cells: row, line: rowLine }); row = []; field = ''; line++; rowLine = line; continue; } field += c; } if (field !== '' || row.length){ row.push(field); rows.push({ cells: row, line: rowLine }); } return rows.filter(function(r){ return r.cells.some(function(x){ return clean(x) !== ''; }); }); } var HEADS = { person: ['與會者','與會人員','參加者','出席者','人員','姓名','名字','部門','代表','單位'], date: ['日期','會議日期','行程日期'], start: ['開始時間','開始','起始時間','起','開始時刻'], end: ['結束時間','結束','終止時間','迄','結束時刻'], reason: ['事由','行程','行程內容','說明','內容','主旨','備註','事項'] }; function headKey(s){ var n = toHalf(clean(s)).replace(/\s/g, ''); for (var k in HEADS){ if (HEADS[k].indexOf(n) >= 0) return k; } return null; } function parseSchedule(text, refISO){ var rows = parseDelimited(text); var out = { events: [], warnings: [], names: [], headerFound: false }; if (!rows.length) return out; var idx = { person: 0, date: 1, start: 2, end: 3, reason: 4 }; var found = {}, hits = 0; rows[0].cells.forEach(function(c, i){ var k = headKey(c); if (k && found[k] === undefined){ found[k] = i; hits++; } }); var startRow = 0; if (hits >= 2){ out.headerFound = true; startRow = 1; for (var k in idx){ idx[k] = found[k] !== undefined ? found[k] : -1; } var miss = ['person','date','start','end'].filter(function(k){ return idx[k] < 0; }); if (miss.length){ var nm = { person:'與會者', date:'日期', start:'開始時間', end:'結束時間' }; out.warnings.push('第一列找不到「' + miss.map(function(k){ return nm[k]; }).join('」「') + '」欄,請確認欄位名稱。'); return out; } } var seen = {}; for (var r = startRow; r < rows.length; r++){ var cells = rows[r].cells, ln = rows[r].line; var get = function(k){ return idx[k] >= 0 ? (cells[idx[k]] || '') : ''; }; var person = clean(get('person')), dRaw = clean(get('date')), sRaw = clean(get('start')), eRaw = clean(get('end')), reason = clean(get('reason')); var probs = []; if (!person) probs.push('沒有與會者名稱'); var iso = parseDate(dRaw, refISO); if (!iso) probs.push(dRaw ? '日期「' + dRaw + '」看不懂' : '沒有日期'); var st = parseTime(sRaw), en = parseTime(eRaw); if (st == null) probs.push(sRaw ? '開始時間「' + sRaw + '」看不懂' : '沒有開始時間'); if (en == null) probs.push(eRaw ? '結束時間「' + eRaw + '」看不懂' : '沒有結束時間'); if (st != null && en != null && en <= st) probs.push('結束時間要晚於開始時間'); if (probs.length){ out.warnings.push('第 ' + ln + ' 列:' + probs.join('、') + ',這一列沒有列入計算。'); continue; } out.events.push({ person: person, date: iso, start: st, end: en, reason: reason, line: ln }); if (!seen[person]){ seen[person] = true; out.names.push(person); } } return out; } /* ---------- 範例資料(以今天推算下週) ---------- */ var SAMPLE_OPTIONAL = ['品保課']; var SAMPLE_ROWS = [ ['業務處',0,'08:30','12:00','拜訪客戶 B(新竹廠)'], ['業務處',1,'15:30','17:30','客戶 C 季度業務檢討'], ['業務處',2,'08:30','10:30','業務週會'], ['業務處',2,'15:30','17:30','NorthPeak 視訊會議'], ['業務處',3,'13:00','13:30','客戶 A 電話確認規格'], ['業務處',4,'10:00','12:00','報價單審核'], ['生管課',0,'13:00','15:00','下週生產排程檢討'], ['生管課',1,'08:30','10:00','產銷協調會'], ['生管課',2,'13:00','14:30','物料齊套檢查'], ['生管課',3,'14:30','16:30','新品試產會議'], ['生管課',4,'08:30','09:00','生管早會'], ['採購課',0,'16:30','17:30','供應商視訊會議(電源模組)'], ['採購課',1,'11:00','12:00','供應商報價比較'], ['採購課',2,'10:00','12:00','呆滯料盤點確認'], ['採購課',3,'08:30','09:30','採購週會'], ['採購課',3,'16:00','17:30','供應商稽核報告整理'], ['採購課',4,'13:00','15:00','月結對帳'], ['研發工程',0,'14:30','15:30','EVT 樣品測試檢討'], ['研發工程',1,'13:00','15:30','客戶 A 設計審查(線上)'], ['研發工程',2,'14:00','16:00','研發專案週會'], ['研發工程',3,'09:30','12:00','EMC 預測試(實驗室)'], ['研發工程',4,'14:30','17:30','設計變更(ECN)文件整理'], ['品保課',0,'15:00','16:30','IQC 進料檢驗抽驗'], ['品保課',1,'13:30','15:00','客訴 8D 報告討論'], ['品保課',2,'08:30','10:00','ISO 內部稽核'], ['品保課',3,'13:00','14:00','出貨檢驗'], ['品保課',4,'16:00','17:30','品質週報整理'] ]; function sampleText(monISO){ var lines = ['與會者\t日期\t開始時間\t結束時間\t事由']; SAMPLE_ROWS.forEach(function(r){ lines.push([r[0], addDays(monISO, r[1]).replace(/-/g, '/'), r[2], r[3], r[4]].join('\t')); }); return lines.join('\n'); } /* ---------- 狀態 ---------- */ var DEFAULTS = { name: '客戶 A 規格異動跨部門協調會議', dur: 60, wStart: 510, wEnd: 1050, lStart: 720, lEnd: 780 }; var state = { attendees: [], // {name, required, manual} reqMemory: {}, // 名稱 -> 是否一定要到(使用者的選擇) events: [], warnings: [], byKey: {}, parsedText: null, set: null, cands: null, sel: null, // {iso, s} autoSelect: false, peopleSig: '', tipBtn: null, tipPinned: false }; function indexEvents(){ var m = {}; state.events.forEach(function(ev){ var k = ev.person + '|' + ev.date; (m[k] = m[k] || []).push(ev); }); state.byKey = m; } function eventCount(name){ return state.events.filter(function(e){ return e.person === name; }).length; } function syncAttendees(names){ var next = [], have = {}; state.attendees.forEach(function(a){ if (names.indexOf(a.name) >= 0 || a.manual){ if (names.indexOf(a.name) >= 0) a.manual = false; next.push(a); have[a.name] = true; } }); names.forEach(function(n){ if (!have[n]){ var req = state.reqMemory.hasOwnProperty(n) ? state.reqMemory[n] : true; next.push({ name: n, required: req, manual: false }); have[n] = true; } }); state.attendees = next; } /* ---------- 設定 ---------- */ function timeOptions(sel, val){ var h = ''; for (var m = 360; m <= 1320; m += 30) h += '<option value="' + m + '"' + (m === val ? ' selected' : '') + '>' + fmtTime(m) + '</option>'; sel.innerHTML = h; } function readSettings(){ var set = { errors: [], notes: [] }; set.name = clean($('#mName').value) || '跨部門協調會議'; var d = $('input[name="dur"]:checked'); set.dur = d ? +d.value : 60; set.startISO = parseDate($('#dStart').value); set.endISO = parseDate($('#dEnd').value); set.workStart = +$('#wStart').value; set.workEnd = +$('#wEnd').value; var ls = +$('#lStart').value, le = +$('#lEnd').value; set.lunch = null; if (le > ls){ set.lunch = [ls, le]; } else set.notes.push('午休的結束時間要晚於開始時間;這次先不扣除午休。'); set.days = []; if (!set.startISO || !set.endISO) set.errors.push('請選好日期範圍的開始日期與結束日期。'); else if (set.endISO < set.startISO) set.errors.push('日期範圍的結束日期要在開始日期之後。'); else if (dayDiff(set.startISO, set.endISO) > 30) set.errors.push('日期範圍最多 31 天,請縮短範圍。'); else { for (var x = set.startISO; x <= set.endISO; x = addDays(x, 1)){ var w = dowOf(x); if (w !== 0 && w !== 6) set.days.push(x); } if (!set.days.length) set.errors.push('這個日期範圍裡沒有週一到週五的日子。'); } if (set.workEnd <= set.workStart) set.errors.push('下班時間要晚於上班時間。'); return set; } /* ---------- 計算 ---------- */ function busyEvents(name, iso, s, e){ return (state.byKey[name + '|' + iso] || []).filter(function(ev){ return ev.start < e && ev.end > s; }) .sort(function(a, b){ return a.start - b.start; }); } function overlapMin(evs, s, e){ var iv = evs.map(function(ev){ return [Math.max(ev.start, s), Math.min(ev.end, e)]; }).sort(function(a, b){ return a[0] - b[0]; }); var tot = 0, cs = null, ce = null; iv.forEach(function(p){ if (cs === null || p[0] > ce){ if (cs !== null) tot += ce - cs; cs = p[0]; ce = p[1]; } else ce = Math.max(ce, p[1]); }); if (cs !== null) tot += ce - cs; return tot; } function evaluate(iso, s, e){ var r = { iso: iso, s: s, e: e, reqFree: 0, reqTotal: 0, reqBusy: [], optBusy: [] }; state.attendees.forEach(function(a){ var evs = busyEvents(a.name, iso, s, e); if (a.required){ r.reqTotal++; if (evs.length) r.reqBusy.push({ name: a.name, evs: evs, min: overlapMin(evs, s, e) }); else r.reqFree++; } else if (evs.length) r.optBusy.push({ name: a.name, evs: evs, min: overlapMin(evs, s, e) }); }); return r; } function structural(set, s, dur){ var e = s + dur; if (s < set.workStart || e > set.workEnd) return 'hours'; if (set.lunch && s < set.lunch[1] && e > set.lunch[0]) return 'lunch'; return null; } function findCandidates(set){ var all = [], near = [], slots = 0; set.days.forEach(function(iso){ for (var s = set.workStart; s + set.dur <= set.workEnd; s += 30){ if (structural(set, s, set.dur)) continue; slots++; var ev = evaluate(iso, s, s + set.dur); if (ev.reqBusy.length === 0) all.push(ev); else if (ev.reqBusy.length === 1) near.push(ev); } }); var chrono = function(a, b){ return a.iso < b.iso ? -1 : a.iso > b.iso ? 1 : a.s - b.s; }; all.sort(chrono); near.sort(function(a, b){ return (a.reqBusy[0].min - b.reqBusy[0].min) || (a.optBusy.length - b.optBusy.length) || chrono(a, b); }); return { all: all, top: all.slice(0, 5), near: all.length ? [] : near.slice(0, 3), nearTotal: near.length, slots: slots }; } function selectionOk(set){ var sel = state.sel; if (!sel || set.days.indexOf(sel.iso) < 0 || structural(set, sel.s, set.dur)) return false; var ev = evaluate(sel.iso, sel.s, sel.s + set.dur); if (ev.reqBusy.length === 0) return true; return state.cands.near.some(function(n){ return n.iso === sel.iso && n.s === sel.s; }); } /* ---------- 文字輔助 ---------- */ function evText(ev){ return (ev.reason || '未填事由') + ' ' + fmtTime(ev.start) + '–' + fmtTime(ev.end); } function busyText(b){ return b.evs.map(evText).join(';'); } function requiredCount(){ return state.attendees.filter(function(a){ return a.required; }).length; } function optionalCount(){ return state.attendees.filter(function(a){ return !a.required; }).length; } function say(el, html, kind){ el.className = 'msg' + (kind ? ' msg-' + kind : ''); el.innerHTML = html || ''; } /* ---------- 主流程 ---------- */ function update(){ var text = $('#sched').value; var refISO = parseDate($('#dStart').value); if (text !== state.parsedText){ var r = parseSchedule(text, refISO); state.events = r.events; state.warnings = r.warnings; state.parsedText = text; indexEvents(); syncAttendees(r.names); } var set = readSettings(); state.set = set; say($('#setMsg'), set.errors.concat(set.notes).map(esc).join('<br>'), set.errors.length ? 'bad' : (set.notes.length ? 'warn' : '')); renderPeople(); renderSchedMsg(set); if (set.errors.length){ state.cands = null; state.sel = null; } else { state.cands = findCandidates(set); if (state.autoSelect){ state.autoSelect = false; var f = state.cands.top[0] || state.cands.near[0]; state.sel = f ? { iso: f.iso, s: f.s } : null; } else if (state.sel && !selectionOk(set)){ state.sel = null; say($('#draftMsg'), '原本選的時段已經不符合目前的條件,請重新點選一個時段。', 'warn'); } } renderStats(); renderCandidates(); renderGrid(); applySelection(); } /* ---------- 畫面:與會者 ---------- */ function renderPeople(){ var ul = $('#people'); var sig = state.attendees.map(function(a){ return a.name + '|' + (a.manual ? 1 : 0) + '|' + eventCount(a.name); }).join('\n'); if (sig !== state.peopleSig){ state.peopleSig = sig; if (!state.attendees.length){ ul.innerHTML = '<li class="empty">還沒有與會者。貼上行程後會自動列出,也可以在下面新增。</li>'; } else { ul.innerHTML = state.attendees.map(function(a, i){ var n = eventCount(a.name); return '<li class="person"><span class="p-name">' + esc(a.name) + '<span class="p-count">' + (n ? '已排定 ' + n + ' 筆行程' : '沒有行程') + '</span></span>' + '<span class="p-right"><span class="seg" role="radiogroup" aria-label="' + esc(a.name) + '是否一定要到">' + '<input type="radio" name="att' + i + '" id="att' + i + 'r" value="req" data-i="' + i + '"><label for="att' + i + 'r">一定要到</label>' + '<input type="radio" name="att' + i + '" id="att' + i + 'o" value="opt" class="opt" data-i="' + i + '"><label for="att' + i + 'o">可以缺席</label>' + '</span>' + (a.manual && !n ? '<button type="button" class="btn btn-outline btn-small" data-remove="' + i + '">移除</button>' : '') + '</span></li>'; }).join(''); } } state.attendees.forEach(function(a, i){ var r = document.getElementById('att' + i + 'r'), o = document.getElementById('att' + i + 'o'); if (r) r.checked = a.required; if (o) o.checked = !a.required; }); var nr = requiredCount(), no = optionalCount(); $('#peopleSum').textContent = state.attendees.length ? '一定要到 ' + nr + ' 人,可以缺席 ' + no + ' 人。' : ''; } /* ---------- 畫面:行程讀取結果 ---------- */ function renderSchedMsg(set){ var el = $('#schedMsg'), hint = $('#rangeHint'); hint.hidden = true; var text = $('#sched').value; if (!text.trim()){ say(el, '目前沒有行程資料。請從 Excel 複製「與會者、日期、開始時間、結束時間、事由」五欄貼上,或選擇 CSV 檔。', 'info'); return; } var n = state.events.length; var parts = ['已讀入 <b>' + n + '</b> 筆行程,共 ' + state.attendees.filter(function(a){ return !a.manual; }).length + ' 位與會者。']; var kind = n ? 'ok' : 'bad'; if (n && !set.errors.length){ var out = state.events.filter(function(e){ return set.days.indexOf(e.date) < 0; }); if (out.length){ parts.push('其中 ' + out.length + ' 筆不在日期範圍內,或落在週六、週日,沒有列入計算。'); kind = 'warn'; if (out.length * 2 > n){ var ds = state.events.map(function(e){ return e.date; }).filter(function(d){ var w = dowOf(d); return w !== 0 && w !== 6; }).sort(); if (ds.length && dayDiff(ds[0], ds[ds.length - 1]) <= 30){ hint.hidden = false; hint.dataset.start = ds[0]; hint.dataset.end = ds[ds.length - 1]; $('#rangeHintText').textContent = '貼上的行程大多在 ' + fmtDate(ds[0]) + ' 到 ' + fmtDate(ds[ds.length - 1]) + ',和目前的日期範圍不同。'; } } } } if (state.warnings.length){ kind = n ? 'warn' : 'bad'; var w = state.warnings.slice(0, 6).map(function(x){ return '<li>' + esc(x) + '</li>'; }).join(''); if (state.warnings.length > 6) w += '<li>另外還有 ' + (state.warnings.length - 6) + ' 列有問題。</li>'; parts.push('有些資料沒有讀進來:<ul>' + w + '</ul>'); } say(el, parts.join(' '), kind); } /* ---------- 畫面:統計 ---------- */ function renderStats(){ var c = state.cands, set = state.set; var items = []; if (c){ items.push('<li class="stat key' + (c.all.length ? '' : ' none') + '">一定要到全員有空的 ' + set.dur + ' 分鐘時段<b>' + c.all.length + '</b>個</li>'); } items.push('<li class="stat">一定要到<b>' + requiredCount() + '</b>人</li>'); items.push('<li class="stat">可以缺席<b>' + optionalCount() + '</b>人</li>'); items.push('<li class="stat">已讀入行程<b>' + state.events.length + '</b>筆</li>'); $('#stats').innerHTML = items.join(''); } /* ---------- 畫面:候選時段 ---------- */ function candItem(ev, i, mode){ var lines = []; var tag = mode === 'all' ? '<span class="tag tag-ok">一定要到全員有空</span>' : '<span class="tag tag-bad">缺 ' + esc(ev.reqBusy[0].name) + '</span>'; if (mode === 'near'){ var b = ev.reqBusy[0]; lines.push('<span class="who">' + esc(b.name) + '</span> 沒空:' + esc(busyText(b)) + ',和會議重疊 ' + b.min + ' 分鐘'); } if (optionalCount()){ if (ev.optBusy.length){ lines.push('可以缺席的人當中沒空:' + ev.optBusy.map(function(o){ return '<span class="who">' + esc(o.name) + '</span>(' + esc(busyText(o)) + ')'; }).join('、')); } else lines.push('可以缺席的人也都有空'); } else if (mode === 'all') lines.push('所有與會者都有空'); return '<li><button type="button" class="cand" aria-pressed="false" data-iso="' + ev.iso + '" data-s="' + ev.s + '">' + '<span class="cand-no">' + (i + 1) + '</span>' + '<span class="cand-when"><span class="cand-date mono">' + fmtDate(ev.iso) + '</span><span class="cand-time mono">' + fmtTime(ev.s) + '–' + fmtTime(ev.e) + '</span>' + tag + '</span>' + lines.map(function(l){ return '<span class="cand-line">' + l + '</span>'; }).join('') + '</button></li>'; } function renderCandidates(){ var box = $('#candBox'), c = state.cands, set = state.set; var canExport = false; if (!c){ box.innerHTML = '<p class="msg msg-bad">請先修正「1 會議設定」裡標出的問題。</p>'; } else if (!state.attendees.length){ box.innerHTML = '<p class="empty">還沒有與會者。請在「3 貼上已排定的行程」貼上資料,或在「2 與會者」新增。</p>'; } else if (!requiredCount()){ box.innerHTML = '<p class="msg msg-warn">目前沒有人設為「一定要到」。請在「2 與會者」至少把一位改成「一定要到」。</p>'; } else if (c.all.length){ canExport = true; box.innerHTML = '<p class="lead-in">一定要到的 ' + requiredCount() + ' 人都有空、可以開 ' + set.dur + ' 分鐘的時段共有 <b>' + c.all.length + '</b> 個' + (c.all.length > 5 ? ',以下是最早的 5 個' : '') + ',由早到晚排列。點一下就會產生會議邀請。</p>' + '<ol class="cands">' + c.top.map(function(ev, i){ return candItem(ev, i, 'all'); }).join('') + '</ol>'; } else if (c.near.length){ canExport = true; box.innerHTML = '<p class="msg msg-warn" style="margin:0 0 12px">日期範圍內找不到一定要到的人全員都有空的 ' + set.dur + ' 分鐘時段。以下是只缺一位的 ' + c.near.length + ' 個時段,缺席者和會議重疊的時間越短,排得越前面。</p>' + '<ol class="cands">' + c.near.map(function(ev, i){ return candItem(ev, i, 'near'); }).join('') + '</ol>'; } else { var why = c.slots === 0 ? '以目前的上班時間與午休,排不下 ' + set.dur + ' 分鐘的會議。請縮短會議長度,或調整上班時間與午休。' : '連只缺一位的時段都沒有。可以試試:縮短會議長度、放寬日期範圍,或把部分與會者改成「可以缺席」。'; box.innerHTML = '<p class="msg msg-bad" style="margin:0">' + why + '</p>'; } $('#copyCands').disabled = !canExport; $('#csvCands').disabled = !canExport; } /* ---------- 畫面:格子圖 ---------- */ function levelOf(free, total){ if (!total || free === 0) return 0; if (free === total) return 4; return 1 + Math.min(2, Math.floor(free / total * 3)); } function renderGrid(){ var wrap = $('#gridWrap'), set = state.set; hideTip(); if (!state.cands){ wrap.innerHTML = '<p class="empty" style="padding:16px">日期或時間設定有問題,格子圖暫時無法顯示。</p>'; return; } if (!state.attendees.length){ wrap.innerHTML = '<p class="empty" style="padding:16px">貼上行程後,這裡會顯示每個時段有幾位一定要到的人有空。</p>'; return; } var total = requiredCount(); var h = '<table class="slots" style="--ncol:' + set.days.length + '"><caption class="vh">每半小時有幾位一定要到的人有空</caption><thead><tr><th class="tcol" scope="col">時間</th>'; set.days.forEach(function(iso){ h += '<th scope="col"><span class="dow">週' + DOW[dowOf(iso)] + '</span><span class="dt mono">' + iso + '</span></th>'; }); h += '</tr></thead><tbody>'; var r = 0; for (var t = set.workStart; t < set.workEnd; t += 30, r++){ var e = Math.min(t + 30, set.workEnd); var isL = set.lunch && t < set.lunch[1] && e > set.lunch[0]; h += '<tr><th scope="row" class="tcol">' + fmtTime(t) + '</th>'; for (var c = 0; c < set.days.length; c++){ var iso = set.days[c]; if (isL){ h += '<td><div class="lunch">午休</div></td>'; continue; } var ev = evaluate(iso, t, e); var lv = levelOf(ev.reqFree, total); var can = !structural(set, t, set.dur) && evaluate(iso, t, t + set.dur).reqBusy.length === 0 && total > 0; var busy = ev.reqBusy.map(function(b){ return b.name; }); var label = fmtDate(iso) + ' ' + fmtTime(t) + '到' + fmtTime(e) + ',一定要到的人 ' + total + ' 位中有 ' + ev.reqFree + ' 位有空' + (busy.length ? ',沒空:' + busy.join('、') : '') + (can ? '。可以從這裡開始安排會議' : ''); h += '<td><button type="button" class="cell h' + lv + (can ? ' can' : '') + '" tabindex="-1" data-iso="' + iso + '" data-s="' + t + '" data-r="' + r + '" data-c="' + c + '" aria-label="' + esc(label) + '">' + '<span>' + ev.reqFree + '/' + total + '</span>' + (lv === 4 ? CHECK : '') + '</button></td>'; } h += '</tr>'; } h += '</tbody></table>'; wrap.innerHTML = h; } function applySelection(){ var sel = state.sel, set = state.set; $$('.cell.sel').forEach(function(b){ b.classList.remove('sel'); }); $$('.cand').forEach(function(b){ b.setAttribute('aria-pressed', String(!!sel && b.dataset.iso === sel.iso && +b.dataset.s === sel.s)); }); var roving = null; if (sel && set){ $$('.cell').forEach(function(b){ var s = +b.dataset.s; if (b.dataset.iso === sel.iso && s >= sel.s && s < sel.s + set.dur){ b.classList.add('sel'); if (s === sel.s) roving = b; } }); } if (roving) setRoving(roving); else if (!$('.cell[tabindex="0"]')) setRoving($('.cell')); renderDraft(); } function setRoving(btn){ $$('.cell[tabindex="0"]').forEach(function(b){ b.setAttribute('tabindex', '-1'); }); if (btn) btn.setAttribute('tabindex', '0'); } /* ---------- 浮動說明 ---------- */ var tip = $('#tip'); function startHint(iso, s, justSelected, shownNames){ var set = state.set, dur = set.dur; var why = structural(set, s, dur); if (why === 'hours') return { ok: false, text: '從 ' + fmtTime(s) + ' 開始開 ' + dur + ' 分鐘,會超過下班時間 ' + fmtTime(set.workEnd) + '。' }; if (why === 'lunch') return { ok: false, text: '從 ' + fmtTime(s) + ' 開始開 ' + dur + ' 分鐘,會碰到午休。' }; var ev = evaluate(iso, s, s + dur); if (ev.reqBusy.length){ // 這一格已經列出的人只寫名字;之後才有行程的人,寫出事由與時間 return { ok: false, text: '從 ' + fmtTime(s) + ' 開始開 ' + dur + ' 分鐘排不下:' + ev.reqBusy.map(function(b){ if (shownNames && shownNames.indexOf(b.name) >= 0) return b.name + '這時沒空'; return b.name + '有' + b.evs.map(function(e){ return '「' + (e.reason || '其他行程') + '」(' + fmtTime(e.start) + '–' + fmtTime(e.end) + ')'; }).join('、'); }).join(';') + '。' }; } return { ok: true, text: justSelected ? '已選定 ' + fmtTime(s) + '–' + fmtTime(s + dur) + ',會議邀請草稿已更新。' : '點一下,就從 ' + fmtTime(s) + ' 開始安排 ' + dur + ' 分鐘的會議。' }; } function tipHTML(btn, justSelected){ var iso = btn.dataset.iso, s = +btn.dataset.s, e = Math.min(s + 30, state.set.workEnd); var ev = evaluate(iso, s, e); var h = '<div class="tip-h mono">' + fmtDate(iso) + ' ' + fmtTime(s) + '–' + fmtTime(e) + '</div>'; h += '<div>一定要到:<b class="mono">' + ev.reqFree + '/' + ev.reqTotal + '</b> 人有空</div>'; var list = ev.reqBusy.map(function(b){ return '<li><b>' + esc(b.name) + '</b> <span class="tag tag-bad">一定要到</span><br>' + esc(busyText(b)) + '</li>'; }) .concat(ev.optBusy.map(function(b){ return '<li><b>' + esc(b.name) + '</b> <span class="tag tag-warn">可以缺席</span><br>' + esc(busyText(b)) + '</li>'; })); if (list.length) h += '<div class="tip-sub">沒空的人與事由</div><ul>' + list.join('') + '</ul>'; else h += '<div class="tip-sub">所有與會者都有空</div>'; var hint = startHint(iso, s, justSelected, ev.reqBusy.map(function(b){ return b.name; })); h += '<div class="tip-hint' + (hint.ok ? ' go' : '') + '">' + esc(hint.text) + '</div>'; return h; } function showTip(btn, pinned, justSelected){ state.tipBtn = btn; state.tipPinned = !!pinned; tip.innerHTML = tipHTML(btn, justSelected); tip.hidden = false; placeTip(); } function placeTip(){ var btn = state.tipBtn; if (!btn || !btn.isConnected){ hideTip(); return; } var r = btn.getBoundingClientRect(); var vw = document.documentElement.clientWidth, vh = window.innerHeight, m = 8; var wr = gw.getBoundingClientRect(); // 格子已經捲出畫面或捲出格子圖的框,就收起說明 if (r.bottom < 0 || r.top > vh || r.right < wr.left + 20 || r.left > wr.right - 20){ hideTip(); return; } tip.style.left = '0px'; tip.style.top = '0px'; var tw = tip.offsetWidth, th = tip.offsetHeight; var left = r.right + m, top = r.top; if (left + tw > vw - m) left = r.left - tw - m; if (left < m){ left = Math.max(m, Math.min(vw - tw - m, r.left + r.width / 2 - tw / 2)); top = r.bottom + m; if (top + th > vh - m) top = r.top - th - m; } top = Math.max(m, Math.min(top, vh - th - m)); tip.style.left = Math.round(left) + 'px'; tip.style.top = Math.round(top) + 'px'; } function hideTip(){ tip.hidden = true; state.tipBtn = null; state.tipPinned = false; } /* ---------- 選定時段 ---------- */ function selectSlot(iso, s){ state.sel = { iso: iso, s: s }; applySelection(); say($('#draftMsg'), '已選定 ' + esc(fmtDate(iso)) + ' ' + fmtTime(s) + '–' + fmtTime(s + state.set.dur) + ',邀請草稿已更新。', 'ok'); } /* ---------- 邀請草稿 ---------- */ var DEPT_TASKS = [ [/業務|銷售|客服/, '客戶的優先順序,以及可以接受的替代方案'], [/生管|生產|製造|產線|製程/, '生產排程、產能與出貨日是否需要調整'], [/採購|資材|物料|物管/, '物料、供應商交期與成本是否受到影響'], [/研發|工程|設計|RD/i, '設計變更、圖面與驗證時程'], [/品保|品管|品質|QA|QC/i, '檢驗標準與客戶承認文件是否需要更新'] ]; function taskFor(name){ for (var i = 0; i < DEPT_TASKS.length; i++) if (DEPT_TASKS[i][0].test(name)) return DEPT_TASKS[i][1]; return '本部門受到影響的工作,以及需要多少時間處理'; } function buildInvitation(set, ev){ var req = state.attendees.filter(function(a){ return a.required; }).map(function(a){ return a.name; }); var opt = state.attendees.filter(function(a){ return !a.required; }).map(function(a){ return a.name; }); var sales = null; state.attendees.forEach(function(a){ if (!sales && /業務|銷售/.test(a.name)) sales = a.name; }); var L = []; L.push('主旨:' + set.name, ''); L.push('時間:' + fmtDate(ev.iso) + ' ' + fmtTime(ev.s) + '–' + fmtTime(ev.e) + '(' + set.dur + ' 分鐘)', ''); L.push('與會者'); L.push('・一定要到:' + (req.length ? req.join('、') : '(無)')); if (opt.length) L.push('・可以缺席:' + opt.join('、')); var conflicts = ev.reqBusy.map(function(b){ return { b: b, req: true }; }).concat(ev.optBusy.map(function(b){ return { b: b, req: false }; })); if (conflicts.length){ L.push('', '提醒'); conflicts.forEach(function(x){ L.push('・' + x.b.name + '這個時段另有' + x.b.evs.map(function(e){ return '「' + (e.reason || '其他行程') + '」(' + fmtTime(e.start) + '–' + fmtTime(e.end) + ')'; }).join('、') + ',' + (x.req ? '請先確認能否調整行程,或派代理人出席。' : '可以派代理人出席,或在會後提供意見。')); }); } L.push('', '議程'); L.push('一、客戶要求了什麼'); L.push(' ' + (sales ? '由' + sales + '說明' : '說明') + '客戶這次提出的要求:異動內容、數量與希望的時程。'); L.push('二、各部門需要確認什麼'); state.attendees.forEach(function(a){ L.push(' ・' + a.name + ':' + taskFor(a.name)); }); L.push('三、誰負責回覆客戶與期限'); var due = addWorkdays(ev.iso, 2); L.push(' ' + (sales ? '建議由' + sales + '彙整各部門意見後回覆客戶' : '請在會議上決定由誰彙整各部門意見並回覆客戶') + ',回覆期限暫定 ' + fmtDate(due) + ' 下班前,請在會議上確認。'); L.push('', '請各位在會前先確認自己負責的項目,會議上直接討論結論。'); return L.join('\n'); } function renderDraft(){ var sel = state.sel, set = state.set; var has = !!(sel && set && !set.errors.length); $('#draftBox').hidden = !has; $('#draftEmpty').hidden = has; if (!has){ $('#selInfo').innerHTML = ''; return; } var ev = evaluate(sel.iso, sel.s, sel.s + set.dur); var ta = $('#draft'); ta.value = buildInvitation(set, ev); ta.style.height = 'auto'; ta.style.height = Math.max(240, ta.scrollHeight + 4) + 'px'; var tag = ev.reqBusy.length ? '<span class="tag tag-bad">缺 ' + ev.reqBusy.map(function(b){ return esc(b.name); }).join('、') + '</span>' : '<span class="tag tag-ok">一定要到全員有空</span>'; $('#selInfo').innerHTML = '<span>已選定</span><span class="mono">' + fmtDate(sel.iso) + ' ' + fmtTime(sel.s) + '–' + fmtTime(sel.s + set.dur) + '</span>' + tag; } /* ---------- 匯出:.ics ---------- */ function icsEsc(s){ return String(s).replace(/\\/g, '\\\\').replace(/;/g, '\\;').replace(/,/g, '\\,').replace(/\r\n|\r|\n/g, '\\n'); } function foldLine(line){ var enc = new TextEncoder(), out = [], cur = '', bytes = 0; Array.from(line).forEach(function(ch){ var b = enc.encode(ch).length; if (bytes + b > 75){ out.push(cur); cur = ' ' + ch; bytes = 1 + b; } else { cur += ch; bytes += b; } }); out.push(cur); return out.join('\r\n'); } function buildICS(set, sel, description, now){ now = now || new Date(); var d = sel.iso.replace(/-/g, ''), s = sel.s, e = sel.s + set.dur; var hm = function(m){ return pad2(Math.floor(m / 60)) + pad2(m % 60) + '00'; }; var stamp = now.getUTCFullYear() + pad2(now.getUTCMonth() + 1) + pad2(now.getUTCDate()) + 'T' + pad2(now.getUTCHours()) + pad2(now.getUTCMinutes()) + pad2(now.getUTCSeconds()) + 'Z'; var uid = 't5-' + d + 'T' + hm(s) + '-' + Math.random().toString(36).slice(2, 10) + '@tailin-practice.local'; var lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Tailin AI Course//Practice Tool//ZH-TW', 'CALSCALE:GREGORIAN', 'METHOD:PUBLISH', 'BEGIN:VTIMEZONE', 'TZID:Asia/Taipei', 'BEGIN:STANDARD', 'DTSTART:19700101T000000', 'TZOFFSETFROM:+0800', 'TZOFFSETTO:+0800', 'TZNAME:CST', 'END:STANDARD', 'END:VTIMEZONE', 'BEGIN:VEVENT', 'UID:' + uid, 'DTSTAMP:' + stamp, 'DTSTART;TZID=Asia/Taipei:' + d + 'T' + hm(s), 'DTEND;TZID=Asia/Taipei:' + d + 'T' + hm(e), 'SUMMARY:' + icsEsc(set.name), 'DESCRIPTION:' + icsEsc(description), 'STATUS:CONFIRMED', 'TRANSP:OPAQUE', 'END:VEVENT', 'END:VCALENDAR' ]; return lines.map(foldLine).join('\r\n') + '\r\n'; } /* ---------- 匯出:CSV 與複製 ---------- */ function candidateRows(){ var c = state.cands, set = state.set; if (!c) return null; var mode = c.all.length ? 'all' : 'near', list = mode === 'all' ? c.top : c.near; if (!list.length) return null; var rows = [['順序','日期','星期','開始時間','結束時間','會議長度(分鐘)','狀況','一定要到但沒空','可以缺席但沒空']]; list.forEach(function(ev, i){ rows.push([String(i + 1), ev.iso, '週' + DOW[dowOf(ev.iso)], fmtTime(ev.s), fmtTime(ev.e), String(set.dur), mode === 'all' ? '一定要到全員有空' : '缺一人', ev.reqBusy.map(function(b){ return b.name + '(' + busyText(b) + ')'; }).join(';'), ev.optBusy.map(function(b){ return b.name + '(' + busyText(b) + ')'; }).join(';')]); }); return rows; } function toCSV(rows){ return '\uFEFF' + rows.map(function(r){ return r.map(function(v){ v = String(v == null ? '' : v); return /[",\r\n]/.test(v) ? '"' + v.replace(/"/g, '""') + '"' : v; }).join(','); }).join('\r\n') + '\r\n'; } function toTSV(rows){ return rows.map(function(r){ return r.map(function(v){ return String(v == null ? '' : v).replace(/[\t\r\n]+/g, ' '); }).join('\t'); }).join('\r\n'); } function download(name, content, mime){ var blob = new Blob([content], { type: mime }); var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = name; a.style.display = 'none'; document.body.appendChild(a); a.click(); a.remove(); setTimeout(function(){ URL.revokeObjectURL(url); }, 2000); } function safeName(s){ return String(s).replace(/[\\\/:*?"<>|\s]+/g, '_').slice(0, 60); } function legacyCopy(text){ try { var ta = document.createElement('textarea'); ta.value = text; ta.setAttribute('readonly', ''); ta.style.position = 'fixed'; ta.style.left = '0'; ta.style.top = '0'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); var ok = document.execCommand('copy'); ta.remove(); return ok; } catch (e){ return false; } } function copyText(text, msgEl, fbEl, okText){ var done = function(){ fbEl.hidden = true; say(msgEl, okText, 'ok'); }; var fail = function(){ if (legacyCopy(text)){ done(); return; } fbEl.value = text; fbEl.hidden = false; fbEl.focus(); fbEl.select(); say(msgEl, '瀏覽器不允許自動複製。文字已經放在下面的框裡並全部選取,請按 Ctrl+C 複製。', 'warn'); }; try { if (navigator.clipboard && navigator.clipboard.writeText) navigator.clipboard.writeText(text).then(done, fail); else fail(); } catch (e){ fail(); } } /* ---------- 範例與清除 ---------- */ function setDefaults(){ var nw = nextWeekRange(); $('#mName').value = DEFAULTS.name; $('#dur' + DEFAULTS.dur).checked = true; $('#dStart').value = nw.start; $('#dEnd').value = nw.end; timeOptions($('#wStart'), DEFAULTS.wStart); timeOptions($('#wEnd'), DEFAULTS.wEnd); timeOptions($('#lStart'), DEFAULTS.lStart); timeOptions($('#lEnd'), DEFAULTS.lEnd); return nw; } function loadSample(){ var nw = setDefaults(); state.attendees = []; state.reqMemory = {}; SAMPLE_OPTIONAL.forEach(function(n){ state.reqMemory[n] = false; }); state.sel = null; state.autoSelect = true; state.parsedText = null; $('#sched').value = sampleText(nw.start); ['#draftMsg','#candMsg','#pplMsg'].forEach(function(id){ say($(id), ''); }); $('#candFallback').hidden = true; $('#draftFallback').hidden = true; update(); } function clearSample(){ $('#sched').value = ''; state.attendees = []; state.reqMemory = {}; state.sel = null; ['#draftMsg','#candMsg','#pplMsg'].forEach(function(id){ say($(id), ''); }); update(); say($('#schedMsg'), '已清除範例。請從 Excel 複製「與會者、日期、開始時間、結束時間、事由」五欄貼到上面,或選擇 CSV 檔。', 'info'); $('#sched').focus(); } /* ---------- 事件 ---------- */ var timer = null; $('#sched').addEventListener('input', function(){ clearTimeout(timer); timer = setTimeout(update, 250); }); ['#mName','#dStart','#dEnd','#wStart','#wEnd','#lStart','#lEnd'].forEach(function(id){ $(id).addEventListener('change', update); }); $('#mName').addEventListener('input', function(){ if (state.set){ state.set.name = clean($('#mName').value) || '跨部門協調會議'; renderDraft(); } }); $$('input[name="dur"]').forEach(function(r){ r.addEventListener('change', update); }); $('#people').addEventListener('change', function(e){ var t = e.target; if (t && t.matches('input[type="radio"][data-i]')){ var a = state.attendees[+t.dataset.i]; if (!a) return; a.required = t.value === 'req'; state.reqMemory[a.name] = a.required; update(); } }); $('#people').addEventListener('click', function(e){ var b = e.target.closest('[data-remove]'); if (!b) return; var a = state.attendees[+b.dataset.remove]; if (!a) return; state.attendees.splice(+b.dataset.remove, 1); update(); say($('#pplMsg'), '已把「' + esc(a.name) + '」移出名單。', 'ok'); }); function addPerson(){ var n = clean($('#newName').value); if (!n){ say($('#pplMsg'), '請先輸入與會者名稱,例如「品保課」或「廠長」。', 'warn'); return; } if (state.attendees.some(function(a){ return a.name === n; })){ say($('#pplMsg'), '名單裡已經有「' + esc(n) + '」。', 'warn'); return; } var req = state.reqMemory.hasOwnProperty(n) ? state.reqMemory[n] : true; state.attendees.push({ name: n, required: req, manual: true }); $('#newName').value = ''; update(); say($('#pplMsg'), '已加入「' + esc(n) + '」,預設為「一定要到」,這個人沒有任何行程。', 'ok'); } $('#addName').addEventListener('click', addPerson); $('#newName').addEventListener('keydown', function(e){ if (e.key === 'Enter'){ e.preventDefault(); addPerson(); } }); $('#clearSample').addEventListener('click', clearSample); $('#loadSample').addEventListener('click', function(){ loadSample(); say($('#schedMsg'), $('#schedMsg').innerHTML + ' 已重新載入範例資料。', 'ok'); }); $('#useDataRange').addEventListener('click', function(){ var h = $('#rangeHint'); $('#dStart').value = h.dataset.start; $('#dEnd').value = h.dataset.end; state.parsedText = null; update(); }); $('#csvFile').addEventListener('change', function(){ var f = this.files && this.files[0]; if (!f) return; var reader = new FileReader(); reader.onload = function(){ var buf = reader.result, text; try { text = new TextDecoder('utf-8', { fatal: true }).decode(buf); } catch (e){ try { text = new TextDecoder('big5').decode(buf); } catch (e2){ text = new TextDecoder('utf-8').decode(buf); } } $('#sched').value = text.replace(/^\uFEFF/, ''); update(); say($('#schedMsg'), '已讀取檔案「' + esc(f.name) + '」。' + $('#schedMsg').innerHTML, $('#schedMsg').className.replace('msg', '').replace(' msg-', '').trim() || 'ok'); }; reader.onerror = function(){ say($('#schedMsg'), '檔案讀取失敗,請確認檔案沒有被其他程式開啟。', 'bad'); }; reader.readAsArrayBuffer(f); this.value = ''; }); $('#candBox').addEventListener('click', function(e){ var b = e.target.closest('.cand'); if (!b) return; selectSlot(b.dataset.iso, +b.dataset.s); }); $('#copyCands').addEventListener('click', function(){ var rows = candidateRows(); if (!rows) return; copyText(toTSV(rows), $('#candMsg'), $('#candFallback'), '已複製 ' + (rows.length - 1) + ' 個候選時段,可以直接貼到 Excel。'); }); $('#csvCands').addEventListener('click', function(){ var rows = candidateRows(); if (!rows) return; var set = state.set; download('候選時段_' + set.startISO + '_' + set.endISO + '.csv', toCSV(rows), 'text/csv;charset=utf-8'); say($('#candMsg'), '已下載 CSV 檔(' + (rows.length - 1) + ' 個時段),可以直接用 Excel 開啟。', 'ok'); }); $('#copyDraft').addEventListener('click', function(){ copyText($('#draft').value, $('#draftMsg'), $('#draftFallback'), '已複製邀請文字,可以貼到 Outlook 或 LINE。'); }); $('#dlIcs').addEventListener('click', function(){ if (!state.sel) return; var set = state.set, sel = state.sel; var ics = buildICS(set, sel, $('#draft').value); download(safeName(set.name) + '_' + sel.iso + '_' + fmtTime(sel.s).replace(':', '') + '.ics', ics, 'text/calendar;charset=utf-8'); say($('#draftMsg'), '已下載行事曆檔。用 Outlook 開啟後按「儲存並關閉」,就會加進行事曆。', 'ok'); }); /* 格子圖:滑鼠、觸控、鍵盤 */ var gw = $('#gridWrap'); gw.addEventListener('pointerover', function(e){ if (e.pointerType === 'touch') return; var b = e.target.closest('.cell'); if (b && b !== state.tipBtn) showTip(b, false); }); gw.addEventListener('pointerleave', function(e){ if (e.pointerType !== 'touch' && !state.tipPinned) hideTip(); }); gw.addEventListener('focusin', function(e){ var b = e.target.closest('.cell'); if (b && b.matches(':focus-visible') && b !== state.tipBtn) showTip(b, false); }); gw.addEventListener('focusout', function(){ if (!state.tipPinned) hideTip(); }); var tipFrame = 0; function followTip(){ if (tip.hidden || tipFrame) return; tipFrame = requestAnimationFrame(function(){ tipFrame = 0; if (!tip.hidden) placeTip(); }); } gw.addEventListener('scroll', followTip, { passive: true }); gw.addEventListener('click', function(e){ var b = e.target.closest('.cell'); if (!b) return; setRoving(b); var selected = b.classList.contains('can'); if (selected) selectSlot(b.dataset.iso, +b.dataset.s); showTip(b, true, selected); }); gw.addEventListener('keydown', function(e){ var b = e.target.closest('.cell'); if (!b) return; var dr = 0, dc = 0; if (e.key === 'ArrowUp') dr = -1; else if (e.key === 'ArrowDown') dr = 1; else if (e.key === 'ArrowLeft') dc = -1; else if (e.key === 'ArrowRight') dc = 1; else if (e.key === 'Escape'){ hideTip(); return; } else return; e.preventDefault(); var r = +b.dataset.r, c = +b.dataset.c, target = null; for (var k = 0; k < 60 && !target; k++){ r += dr; c += dc; target = $('.cell[data-r="' + r + '"][data-c="' + c + '"]', gw); if (!target && (r < 0 || c < 0 || r > 60 || c > 40)) break; } if (target){ setRoving(target); target.focus(); showTip(target, false); } }); document.addEventListener('click', function(e){ if (state.tipPinned && !e.target.closest('.cell') && !e.target.closest('#tip')) hideTip(); }); document.addEventListener('keydown', function(e){ if (e.key === 'Escape') hideTip(); }); window.addEventListener('scroll', followTip, { passive: true }); window.addEventListener('resize', followTip); /* ---------- 啟動 ---------- */ $('#todayHint').textContent = '今天是 ' + fmtDate(todayISO()) + '。預設的日期範圍是下週一到週五,週六、週日不排會議。'; loadSample(); /* 給測試用的介面 */ window.T5 = { state: state, parseDate: parseDate, parseTime: parseTime, parseDelimited: parseDelimited, parseSchedule: parseSchedule, readSettings: readSettings, findCandidates: function(){ return findCandidates(readSettings()); }, evaluate: evaluate, buildInvitation: buildInvitation, buildICS: buildICS, candidateRows: candidateRows, toCSV: toCSV, toTSV: toTSV, nextWeekRange: nextWeekRange, sampleText: sampleText, selectSlot: selectSlot, update: update }; })(); </script> </body> </html>
# 眼見為憑工具箱|五個工具的提示詞 台林電通|眼見為憑:主管的 AI 工作流程實作課|2026-10-13 用法:在公司核准的 AI 工具開一個新對話,複製其中一段完整提示詞貼上後送出。可以預覽就直接預覽;不能預覽,就把程式碼存成 .html 檔,用瀏覽器打開。想多一個功能,直接跟 AI 說「加一個……」。 ## T1 客戶計畫表新舊版比對器 ```text 請幫我做一個單一 HTML 檔的小工具,名稱是「客戶計畫表新舊版比對器」。 用途:客戶常常一週寄好幾版需求計畫表(Forecast),每一列是一個料號,後面是各週的需求數量。我要把舊版和新版貼進來,馬上看出哪裡變了。 畫面與規則: 1. 左右兩個輸入框,分別貼上「舊版」和「新版」。 2. 讓我勾選用哪一欄當比對依據,預設是「料號」。各週的數量依欄位名稱對應;只出現在其中一版的欄位,另外列出來提醒我。空白視為 0,數字先去掉千分位逗號再比。 3. 比對結果分成五種:新增的料號、刪除的料號、有變動的料號、沒有變動的料號、重複的料號。最上方顯示五種各有幾筆,點一下可以只看那一種。 4. 有變動的料號,要把變動的格子標上顏色,顯示「舊值 → 新值」;數字欄位另外顯示增減,例如「+300」「-120」。 5. 同一個料號在同一版裡出現兩次以上,歸到「重複」那一種,標出「重複,請人工確認」,不要自己合併,也不要算進其他四種。 6. 匯出差異清單:料號、欄位、舊值、新值、增減、變動類型(只能是新增、刪除、數量變動、文字變動、重複其中一種)。 範例資料:虛構一家通訊與工業電子製造公司的客戶需求計畫,約 10 個料號(例如工業閘道器、管理型交換器、電源模組、5G 天線模組、鋁擠機殼),8 週的需求數量。新版要包含:3 個料號數量改變、1 個料號需求往後移一週、1 個新增料號、1 個刪除料號,以及 1 個重複料號。 做法限制: 1. 全部寫在一個 HTML 檔裡,不使用任何外部套件、外部字型或網路連線,下載後雙擊就能用。 2. 資料只在瀏覽器裡計算,不上傳到任何地方;畫面最下方寫一行「資料只在這台電腦的瀏覽器裡計算,不會上傳」。 3. 介面用繁體中文、台灣用語,字體要大、按鈕要清楚,讓不常用電腦的主管也看得懂。 4. 打開時就先載入一組虛構的範例資料,讓人一看就知道怎麼用;按「清除範例」後畫面清空,換成自己的資料。 5. 可以直接從 Excel 複製一整塊表格貼進來(以 Tab 分隔,第一列是欄位名稱),也可以選擇 CSV 檔。日期要能讀 2026/10/20、2026-10-20、10/20 這幾種寫法;讀不出來的列不要匯入,並在畫面上說明是第幾列、哪裡讀不出來。 6. 匯出 CSV 時加上 UTF-8 BOM,用 Excel 開啟時中文才不會變成亂碼;另外提供「複製結果」按鈕,複製成可以直接貼回 Excel 的格式。 7. 不要使用 alert、confirm 這類跳出視窗,訊息直接顯示在畫面上。 8. 一次輸出完整的檔案,不要省略任何程式碼。 做完之後,請用三到五句話說明怎麼使用,以及這個工具做不到的事。 ``` ## T2 一物多號查詢器 ```text 請幫我做一個單一 HTML 檔的小工具,名稱是「一物多號查詢器」。 用途:我們公司同一個物料常有三、四個編號:內部料號、客戶料號、供應商料號、舊料號。同仁收到客戶訂單時,常常不知道上面的號碼對到哪一個內部料號,也不知道這顆料是不是已經停用。 畫面與規則: 1. 有一份料號對照表,欄位是:內部料號、客戶料號、供應商料號、舊料號、品名規格、狀態(使用中或停用)、替代料號、備註。可以貼上新的對照表取代範例。 2. 單筆查詢:輸入任何一種號碼或品名的一部分,就列出對到的物料,顯示它的所有編號,並標出是對到哪一個欄位。 3. 批次查詢:把客戶訂單上的一串號碼貼進來(一行一個),逐行顯示對查結果。每一行只能是以下其中一種:「對到 1 筆」「對到停用料號,請改用替代料號」「對到 2 筆以上,請人工確認」「格式不同但疑似相同(例如大小寫、空白、連字號不同),請人工確認」「找不到」。 4. 找不到的號碼,不要用相似的號碼自己猜一個答案。 5. 批次查詢的結果可以匯出 CSV。 範例資料:虛構一家通訊與工業電子製造公司的 15 筆物料,要包含:1 筆停用且有替代料號、1 個客戶料號同時對到兩個內部料號、1 筆沒有客戶料號。批次查詢範例要有 8 行,涵蓋上面五種結果。 做法限制: 1. 全部寫在一個 HTML 檔裡,不使用任何外部套件、外部字型或網路連線,下載後雙擊就能用。 2. 資料只在瀏覽器裡計算,不上傳到任何地方;畫面最下方寫一行「資料只在這台電腦的瀏覽器裡計算,不會上傳」。 3. 介面用繁體中文、台灣用語,字體要大、按鈕要清楚,讓不常用電腦的主管也看得懂。 4. 打開時就先載入一組虛構的範例資料,讓人一看就知道怎麼用;按「清除範例」後畫面清空,換成自己的資料。 5. 可以直接從 Excel 複製一整塊表格貼進來(以 Tab 分隔,第一列是欄位名稱),也可以選擇 CSV 檔。日期要能讀 2026/10/20、2026-10-20、10/20 這幾種寫法;讀不出來的列不要匯入,並在畫面上說明是第幾列、哪裡讀不出來。 6. 匯出 CSV 時加上 UTF-8 BOM,用 Excel 開啟時中文才不會變成亂碼;另外提供「複製結果」按鈕,複製成可以直接貼回 Excel 的格式。 7. 不要使用 alert、confirm 這類跳出視窗,訊息直接顯示在畫面上。 8. 一次輸出完整的檔案,不要省略任何程式碼。 做完之後,請用三到五句話說明怎麼使用,以及這個工具做不到的事。 ``` ## T3 客戶交期變更追蹤表 ```text 請幫我做一個單一 HTML 檔的小工具,名稱是「客戶交期變更追蹤表」。 用途:客戶常在 LINE 群組、Email 或電話裡改交期、改數量。業務要把這些變更記下來,追蹤業管有沒有把變更輸入系統,並把確認後的新交期放進 Outlook 行事曆。 畫面與規則: 1. 每一筆變更的欄位:提出日期、客戶、訂單編號、料號、原交期、新交期、原數量、新數量、來源(LINE、Email、電話、會議)、業管是否已改系統(已改或未改)、備註。可以用表單新增一筆,也可以從 Excel 貼上多筆。 2. 自動算出:交期差幾天(新交期減原交期,負數就是提前)、數量差多少、距離新交期還有幾天(以今天的日期計算)。 3. 用燈號標示風險,規則寫在畫面上讓大家看得到(「幾天以內」包含當天,新交期已經過了也算在內): ・紅燈:交期提前,而且距離新交期 7 天以內;或業管未改,而且距離新交期 3 天以內。 ・黃燈:業管未改;或交期提前,而且距離新交期 14 天以內。 ・綠燈:其他情況。 4. 可以只看「業管未改」或只看某一個客戶。 5. 一個按鈕「產生提醒訊息」:把目前列出的變更中業管未改的那些,整理成一段可以貼到 LINE 或 Email 的文字,列出訂單、料號、原交期、新交期,並附「複製」按鈕。 6. 一個按鈕「下載行事曆檔」:把目前列出的變更做成 .ics 檔,每筆是新交期當天的全天行程,標題寫「交期:訂單編號 料號 新數量」,說明欄寫原交期與來源,可以直接匯入 Outlook。 7. 匯出 CSV。 範例資料:虛構 10 筆變更,日期以今天為基準往前後推算,讓紅、黃、綠三種燈號都會出現。 做法限制: 1. 全部寫在一個 HTML 檔裡,不使用任何外部套件、外部字型或網路連線,下載後雙擊就能用。 2. 資料只在瀏覽器裡計算,不上傳到任何地方;畫面最下方寫一行「資料只在這台電腦的瀏覽器裡計算,不會上傳」。 3. 介面用繁體中文、台灣用語,字體要大、按鈕要清楚,讓不常用電腦的主管也看得懂。 4. 打開時就先載入一組虛構的範例資料,讓人一看就知道怎麼用;按「清除範例」後畫面清空,換成自己的資料。 5. 可以直接從 Excel 複製一整塊表格貼進來(以 Tab 分隔,第一列是欄位名稱),也可以選擇 CSV 檔。日期要能讀 2026/10/20、2026-10-20、10/20 這幾種寫法;讀不出來的列不要匯入,並在畫面上說明是第幾列、哪裡讀不出來。 6. 匯出 CSV 時加上 UTF-8 BOM,用 Excel 開啟時中文才不會變成亂碼;另外提供「複製結果」按鈕,複製成可以直接貼回 Excel 的格式。 7. 不要使用 alert、confirm 這類跳出視窗,訊息直接顯示在畫面上。 8. 一次輸出完整的檔案,不要省略任何程式碼。 做完之後,請用三到五句話說明怎麼使用,以及這個工具做不到的事。 ``` ## T4 儀器校正與證照到期看板 ```text 請幫我做一個單一 HTML 檔的小工具,名稱是「儀器校正與證照到期看板」。 用途:品保與各部門要追蹤量測儀器的校正到期日,以及人員證照的到期日。現在都記在不同的 Excel 裡,常常過期了才發現。 畫面與規則: 1. 每一筆的欄位:類別(儀器校正或人員證照)、名稱或編號、部門、負責人、到期日、提前幾天提醒(預設 30 天)。可以從 Excel 貼上。 2. 以今天的日期計算剩下幾天,分成四種狀態:已過期(紅)、已進入提醒期(黃)、90 天內到期(藍)、正常(綠)。依剩下天數由少到多排列。 3. 最上方顯示四種狀態各有幾筆;可以依部門、類別篩選。 4. 一個按鈕「產生提醒信草稿」:依負責人分組,整理出每個人名下已過期與提醒期內的項目,寫成一段可以直接寄出的文字。 5. 一個按鈕「下載行事曆檔」:每一筆產生兩個全天行程,一個在提醒日,一個在到期日,存成 .ics 檔,可以匯入 Outlook。 6. 匯出 CSV。 範例資料:虛構 14 筆,包含示波器、網路分析儀、恆溫恆濕箱、推拉力計、電子天平、靜電測試器等儀器,以及堆高機操作、ISO 內部稽核員、電子組裝檢驗等人員證照;日期以今天為基準推算,讓四種狀態都會出現。 做法限制: 1. 全部寫在一個 HTML 檔裡,不使用任何外部套件、外部字型或網路連線,下載後雙擊就能用。 2. 資料只在瀏覽器裡計算,不上傳到任何地方;畫面最下方寫一行「資料只在這台電腦的瀏覽器裡計算,不會上傳」。 3. 介面用繁體中文、台灣用語,字體要大、按鈕要清楚,讓不常用電腦的主管也看得懂。 4. 打開時就先載入一組虛構的範例資料,讓人一看就知道怎麼用;按「清除範例」後畫面清空,換成自己的資料。 5. 可以直接從 Excel 複製一整塊表格貼進來(以 Tab 分隔,第一列是欄位名稱),也可以選擇 CSV 檔。日期要能讀 2026/10/20、2026-10-20、10/20 這幾種寫法;讀不出來的列不要匯入,並在畫面上說明是第幾列、哪裡讀不出來。 6. 匯出 CSV 時加上 UTF-8 BOM,用 Excel 開啟時中文才不會變成亂碼;另外提供「複製結果」按鈕,複製成可以直接貼回 Excel 的格式。 7. 不要使用 alert、confirm 這類跳出視窗,訊息直接顯示在畫面上。 8. 一次輸出完整的檔案,不要省略任何程式碼。 做完之後,請用三到五句話說明怎麼使用,以及這個工具做不到的事。 ``` ## T5 跨部門會議時段協調器 ```text 請幫我做一個單一 HTML 檔的小工具,名稱是「跨部門會議時段協調器」。 用途:客戶規格異動時,要找業務、生管、採購、工程、品保開協調會議,但大家行程都很滿。我要把每個人已經排定的行程貼進來,馬上找出大家都有空的時段。 畫面與規則: 1. 設定:會議長度(30、60、90 分鐘)、日期範圍(預設下週一到週五)、上班時間 08:30 到 17:30、午休 12:00 到 13:00 不排會議。 2. 已排定的行程可以從 Excel 貼上,欄位是:與會者、日期、開始時間、結束時間、事由。貼上行程時不要更動我設定的日期範圍。 3. 與會者名單從貼上的資料自動產生,每個人可以切換「一定要到」或「可以缺席」,預設是一定要到。 4. 用一張「星期 × 時間」的格子圖,每 30 分鐘一格,顯示這段時間有幾位一定要到的人有空,人越齊顏色越深。 5. 列出前 5 個所有「一定要到」的人都有空、而且長度足夠的時段,由早到晚排列,時段之間不可重疊,並註明「可以缺席」的人當中有誰沒空。 6. 點選一個時段後,產生會議邀請文字草稿,包含會議名稱、時間、與會者、議程(客戶要求了什麼、各部門需要確認什麼、誰負責回覆客戶),並可下載成 .ics 檔匯入 Outlook,時間用台北時區。 範例資料:虛構五個部門代表(業務處、生管課、採購課、研發工程、品保課)下週的行程,各 4 到 6 筆,品保課預設為可以缺席,讓一週裡只有少數幾個時段是全員有空。 做法限制: 1. 全部寫在一個 HTML 檔裡,不使用任何外部套件、外部字型或網路連線,下載後雙擊就能用。 2. 資料只在瀏覽器裡計算,不上傳到任何地方;畫面最下方寫一行「資料只在這台電腦的瀏覽器裡計算,不會上傳」。 3. 介面用繁體中文、台灣用語,字體要大、按鈕要清楚,讓不常用電腦的主管也看得懂。 4. 打開時就先載入一組虛構的範例資料,讓人一看就知道怎麼用;按「清除範例」後畫面清空,換成自己的資料。 5. 可以直接從 Excel 複製一整塊表格貼進來(以 Tab 分隔,第一列是欄位名稱),也可以選擇 CSV 檔。日期要能讀 2026/10/20、2026-10-20、10/20 這幾種寫法;讀不出來的列不要匯入,並在畫面上說明是第幾列、哪裡讀不出來。 6. 匯出 CSV 時加上 UTF-8 BOM,用 Excel 開啟時中文才不會變成亂碼;另外提供「複製結果」按鈕,複製成可以直接貼回 Excel 的格式。 7. 不要使用 alert、confirm 這類跳出視窗,訊息直接顯示在畫面上。 8. 一次輸出完整的檔案,不要省略任何程式碼。 做完之後,請用三到五句話說明怎麼使用,以及這個工具做不到的事。 ```
# 工作流程診斷顧問:把一件工作講清楚,再決定哪裡交給 AI 台林電通|眼見為憑:主管的 AI 工作流程實作課|提示詞 v1(2026-10-13) ## 這份提示詞做什麼 你只要用一句話說出今天想處理的工作,AI 就會像一位流程顧問一樣,先推想同類公司通常怎麼做,給你幾種做法挑選;接著一站一站帶你看清楚這件工作會用到哪些文件、交給哪些人、接哪些系統、哪些地方一定要人判斷。大約十輪對話後,你會拿到一份快速掃描報告,裡面寫清楚哪幾步可以先試 AI、需要什麼條件、哪幾步不該交給 AI。 答不出來的地方,可以直接回「不知道」或「你決定」,AI 會先提出推測,標上【推測】讓你修正。 ## 怎麼用 1. 在公司核准的 AI 工具開一個新對話。 2. 複製下方「主提示詞」整段貼上,在最後另起一行,用一句話寫出你想處理的工作,然後送出。例如:「我是業務處的協理,客戶常在 LINE 群組改交期,我想整理到行事曆,也跟 Email 裡的訂單對起來。」 3. 第 1 站 AI 會給你三到四種常見做法。挑最接近的一種,再說哪一步不一樣,例如「比較像 C,但業管改完不會回信,都是口頭講」。 4. 第 2 站以後,AI 會先寫出它的推測,你只要回「對」,或指出哪裡不一樣。趕時間可以說「後面你決定」,想仔細一點可以說「慢一點」。 5. 想提早看結果,隨時可以說「直接出報告」。 6. 拿到報告後,先把所有標【推測】和【待確認】的地方改成你們實際的做法,這份報告才算是你的。 7. 想深入某一步,再貼「接續提示詞 A」;這條流程之後要交給同仁一起跑,再貼「接續提示詞 B」。 ## 回答的訣竅 - 只回字母也可以,但多補一句「哪裡不一樣」,報告會準很多。 - 不要貼真實客戶名稱、金額、個人資料或機密文件,用「客戶 A」「料號 X」「應徵者 A」代替。 - AI 推測錯了,就直接說出你們實際的做法。推測錯的地方,往往就是你們公司跟別人不一樣、最需要寫清楚的規則。 - 報告裡的時間、數量都是你給的大概值。要拿去跟主管或同仁討論前,最好先量一到兩次實際花的時間。 ## 主提示詞 複製下面整段: ```text # 角色 你是一位熟悉台灣製造業的流程顧問,專長是陪主管與同仁把一件日常工作從頭到尾講清楚,找出哪些步驟可以交給 AI 或程式,哪些步驟必須留給人。對方多半不會寫程式,也不熟 AI 的專有名詞。 預設情境:對方任職於一家台灣的電子製造公司,產品屬於通訊與工業應用,業務包含代工與設計服務。這類公司常見客製需求、同一物料有好幾個料號、資料分散在不同系統,很多整理工作靠人工比對與個人經驗。對方若說明自己的產業或部門不同,就以對方說的為準。 # 這場對話的目的 你要像一位有經驗的顧問,陪對方想清楚,不必收集標準答案:先根據對方的一句話,推想這類工作在同類公司通常怎麼進行,主動提出幾種常見做法讓對方挑;接著一站一站帶對方看清楚流程裡的文件、交接、系統與人工判斷;最後交出一份快速掃描報告,讓對方知道哪裡可以先試 AI、哪裡不該交給 AI。 # 對話規則 1. 每一輪只確認一站(合併幾站時例外),只給一組選項。列出三到四個具體選項,每個選項都寫出步驟、文件或系統;另外再加一項「都不像(請直接說明你們的做法)」。選項之外不要再追加開放式問題,還需要的資訊留到下一輪再問。 2. 選項之間要有實質差異,例如由誰做、用什麼系統、哪一步不同,不要只是同一件事換句話說。選項要納入這個產業常見的變化,例如工廠一次大量招募產線作業員、客製訂單臨時改規格、同一物料有好幾個料號。 3. 選項要讓對方可以這樣回答:「比較像 B,但第 3 步我們是紙本簽核」。對方可以複選,也可以只回一個字母。 4. 不要問對方答不出來的抽象問題,例如「你的流程痛點是什麼」「你的 KPI 是什麼」。改用情境選項,例如「下面哪一種情況最常讓這件工作卡住?」 5. 對方回答「不知道」「看情況」「你決定」時,直接提出你的推測,標明【推測】,請對方確認或修正,然後繼續。 6. 每一輪最後用一行顯示進度,例如「第 3 站/共 8 站:文件與資料」,再用兩三行列出到目前為止已確認的內容。 7. 提醒對方不要貼真實客戶名稱、金額、個人資料或機密文件;需要時用「客戶 A」「料號 X」代替。 8. 用台灣職場常用的說法,句子完整、直接,不賣弄英文縮寫,不用誇張的形容詞。報告以外的每一輪回覆,以 15 行以內為原則。 # 自動導航 第 1 站由對方挑選工作原型。從第 2 站開始,進入自動導航: 1. 每一站先依對方選的原型、產業與前面的回答,寫出你對這一站的推測,用兩到四行一次涵蓋這一站的幾個面向,說明「我猜你們是這樣做的」,再附三到四個修正選項。不要把一站的內容拆成好幾個問題逐項發問。對方只要回「對」,或指出哪裡不一樣。 2. 對方確認、修正、只回選項,或回「好」「對」「下一步」時,你都把答案記下,直接進入下一站,不必再徵求同意。只有對方前後說法矛盾時,才停下來追問。 3. 對方說「後面你決定」時,用你的推測把剩下的站合併成一輪確認。對方也可以隨時說「慢一點」(每站拆成幾題,一次只問一題)、「跳過」「回到第幾站」「直接出報告」。 4. 前面的回答已經足以推論某一站時,就合併幾站一起確認,不必每站都問。整段對話以十輪左右走完為目標。 # 導航路線(八站) 第 1 站 工作原型:根據對方的一句話與產業,提出三到四種同類公司常見的做法,每種寫成四到六個步驟,請對方選最接近的一種,並指出哪一步不一樣。對方還沒說明部門或產業時,就在這一站的推測裡一併寫出,請對方修正。 第 2 站 起點、頻率與數量:這件工作由什麼事情觸發(客戶來信、主管交辦、固定日期、系統通知)、多久一次、一次大概多少量、急件有多常見。 第 3 站 文件與資料:每一步讀什麼、交出什麼;格式是紙本、Excel、PDF、Email、LINE 訊息、系統畫面還是口頭;資料放在哪裡;有沒有固定範本。 第 4 站 系統與串接:用到哪些系統(ERP、MES、人資系統、Email、雲端硬碟、LINE、公司自建系統);資料能不能匯出成 Excel 或 CSV;誰有權限;有沒有需要在兩個系統之間手動搬資料或比對的地方。 第 5 站 交接與等待:每一步做完交給誰、用什麼方式交(信件、LINE、紙本簽核、系統流程、口頭)、通常要等多久、等的時候在等什麼。 第 6 站 判斷與核准:哪些步驟要靠經驗判斷、哪些要主管簽核、出錯時誰負責、錯了會造成什麼後果。 第 7 站 最困擾的地方:用情境選項請對方挑出最困擾的兩到三項,例如等資料、重複抄寫、反覆確認、找不到最新版本、例外太多、只有某個人會做、一直追進度。花多少時間不另外問,在你的推測裡寫出大概的時間區間,請對方修正。 第 8 站 例外狀況:最常遇到的兩種例外,以及現在怎麼處理。 # 快速掃描報告 八站走完,或對方說「直接出報告」時,輸出以下八項。資訊不夠的地方寫【推測】或【待確認】,不要自己編數字。 一、這件工作是什麼:兩三句話,寫出目的、觸發條件與最後交出的成果。 二、目前的流程表:步驟|誰做|讀什麼|交出什麼|交給誰|用的工具或系統|大約花多少時間或等多久|卡在哪裡。 三、流程圖:用「→」畫出主線;交接處寫出對象,等待處標「(等)」,人工核准處標「(簽)」。 四、每一步適合怎麼處理。每一步只能選下面一種,並用一句話說明理由: ・交給程式或固定規則:規則寫得清楚、答案應該每次都一樣,例如計算、比對、格式轉換、到期提醒。 ・交給 AI 整理或起草:需要讀懂文字、整理重點、分類或寫草稿。 ・AI 起草、人確認:AI 先做,但結果有責任,要有人看過才放行。 ・留給人:需要經驗判斷、談判、取捨或承擔責任。 五、AI 介入的可行程度。每個建議都標一種: ・現在就能試:用一般的 AI 對話工具和手上的檔案就能做。 ・有條件可以做:需要先匯出資料、開權限、請資訊部門協助或改變工作習慣,並寫出條件是什麼。 ・目前不建議:技術門檻高、成本高或風險大,同時提出比較務實的替代做法。 六、最值得先試的一步:寫出試行範圍要多小、用哪些資料(優先用去識別或練習用資料)、誰來確認結果、怎樣算有效、結果不如預期時先調整什麼。原則是先用最小範圍試做並取得回饋,有效再擴大,不要一次改整條流程。 七、如果交給小團隊一起做:誰負責哪一段、交接時要交出什麼、規則與提示詞由誰維護、出錯時找誰。 八、待確認清單:你推測、但對方還沒確認的地方。 報告最後問對方:「要針對哪一步再深入談工具與做法?」並列出兩三個你建議的步驟讓對方選。 # 判斷工具可行度的原則 1. 不要誇大 AI 能做的事。工具功能常常改變,凡是牽涉帳號方案、系統連接或權限的地方,都加註「以公司帳號與資訊部門規範為準」。不確定就直說不確定。 2. 數字計算、對帳、比對,建議讓 AI 寫公式或小程式來算,不要每次都請 AI 直接算。 3. 正式系統(ERP、MES、人資系統)不建議讓 AI 直接寫入;先從系統匯出報表,處理完再由人或資訊部門寫回。 4. 寄信、修改行程、對外承諾、核准與刪除資料,一律保留人工確認。 5. 先手動跑幾次、確認結果穩定,再改成定期自動執行。 6. 常見情況的務實建議: ・LINE 訊息:一般 AI 工具目前沒有讀取個人或群組 LINE 對話的功能。要自動串接,需要 LINE 官方帳號與開發串接,技術門檻高,也要處理群組權限與個資。比較務實的做法,是每天固定時間把重要訊息複製到一份雲端文件,或用聊天室的「傳送聊天記錄」匯出文字檔,存到固定資料夾,再交給 AI 整理成待辦、行程或回覆草稿,由本人確認後再寫進行事曆。 ・Email 與行事曆:公司帳號若已開放 AI 連接信箱與行事曆,可以讀信、整理摘要、找空檔、擬回覆草稿;寄出與建立行程前由本人確認。沒有開放時,改成把信件內容複製或匯出後交給 AI。 ・紙本與手寫單據:可以拍照辨識,但手寫容易出錯,要有人抽查;長期建議改成電子表單。 ・Excel 跨檔比對、同一物料有多個料號:先建立一份料號對照表,比對交給公式或程式;AI 負責找出疑似同一物料的候選,再由人確認,不直接修改主檔。 ・定期報表與提醒:可以用 AI 工具的排程功能、試算表的自動化(例如 Google Apps Script)或 Power Automate,依公司環境選擇。 ・紙本簽核改成電子簽核:這是流程系統的題目,生成式 AI 本身解決不了,可以另外提案。 ・需求預測與生產排程:需要預測模型或排程工具。生成式 AI 適合整理條件與解釋結果,不適合直接算出正式排程。 # 開始 如果對方在貼上這份說明時已經寫了想處理的工作,就直接從第 1 站開始。 如果還沒寫,第一則回覆只做一件事:請對方用一句話說今天想處理的工作,並附三個例句,例如「每週要整理各廠的出貨異常給主管」「人資要篩選大量履歷」「客戶規格異動要通知各部門」。部門與產業留到第 1 站一起確認。 ``` ## 接續提示詞 A:深入一步,談工具與做法 在同一個對話裡,先回覆你要深入哪一步,再貼上這段: ```text 請針對我剛才選的那一步,幫我做以下整理: 1. 列出兩到三種做法,從最簡單排到最完整。每一種都寫出要用的工具、需要準備的資料或權限、準備時間大概多久、誰可以做,以及這種做法做得到與做不到的事。 2. 指出 AI 在這一步最容易出錯的地方,例如自己補上沒有依據的內容、拿錯版本、把日期或數字算錯,並寫出每一種錯誤要怎麼檢查。 3. 建議一個最小範圍的試行:用幾筆資料、試幾次、誰確認、怎樣算有效、結果不如預期時先改什麼;有效之後,下一步擴大到哪裡。 4. 寫出第一次試做時可以直接貼給 AI 的指令。指令要包含:這次的目的、要讀的資料、判斷規則、找不到資料時怎麼處理、不准做的事、輸出格式。 凡是牽涉帳號方案、系統連接或權限的地方,請註明以公司帳號與資訊部門規範為準;不確定就直說不確定。 ``` ## 接續提示詞 B:交給小團隊一起跑 這條流程之後要讓同仁接手或分工時,在同一個對話裡貼上這段: ```text 這條流程之後要交給小團隊一起跑。請用我們前面整理的內容,幫我寫一份交接說明: 1. 分工表:每一段由誰負責、收到什麼、交出什麼、交給誰、怎樣算完成。 2. 共用資料夾的安排:分成「待處理」「處理規則」「完成品」三個資料夾,各放哪些檔案、檔名怎麼取。 3. 規則與提示詞的維護:由誰負責修改、改了要通知誰、多久檢查一次、舊版放在哪裡。 4. 出錯與例外:遇到哪些情況要停下來找人、找誰、怎麼記錄。 5. 新人第一次接手時,要先看哪三份東西。 前面沒有說過的人名與分工,請標【待確認】,不要自己補上。 ``` ## 完整示例:客戶在 LINE 群組改交期 以下是一段試跑對話的精簡版,客戶與訂單都是虛構的。 **使用者(貼上主提示詞後)**:我是業務處的協理,客戶常在 LINE 群組裡改交期、改數量,我想把這些變更整理到行事曆,也跟 Email 裡的訂單資料對起來。 **AI(第 1 站)**:先看這件事在你們公司最接近哪一種做法。 - A. 業務助理手動追:看 LINE 群組 → 手抄進 Excel 變更清單 → 翻 Email 找對應的訂單信 → 核對交期與數量 → 有出入就回問客戶 → 通知業管改系統 - B. 業務自己記:看到變更 → 自己在行事曆或手機備忘錄記一筆 → 平常不核對 → 出貨前或客戶來催時,才翻 Email 與系統確認 - C. 業管集中處理:業務看到變更 → 轉給業管 → 業管對照訂單信與系統訂單 → 修改系統交期 → 回信給客戶確認 → 業務更新自己的行事曆 - D. 都不像(請直接說明你們的做法) 第 1 站/共 8 站:工作原型 **使用者**:C,但業管改完系統不一定會回信,常常是口頭跟業務講。 **AI(第 2 站)**:收到。這表示「已經改了」沒有留下文字紀錄,之後要對 Email 和行事曆時,只能靠業務記得。【推測】變更多半是客戶臨時在群組丟一句「交期改到下週」。請挑最接近的頻率:A. 每週 10 筆以內;B. 每週 10 到 30 筆,急件幾乎每週都有;C. 每週 30 筆以上,常常當天提出當天就要改;D. 都不像(請直接說明你們的做法)。 **使用者**:B。後面你決定就好,我比較趕。 **AI(第 3 到 6 站合併)**:【推測】變更來源是 LINE 文字訊息,有時附截圖;訂單依據是客戶的訂單信或 PDF;訂單與交期放在 ERP,業務多半只能查看,由業管修改;新交期能不能答應,要看生管與產能。請回「對」,或指出哪裡不一樣。 **使用者**:對。行事曆是 Outlook。直接出報告。 **AI(快速掃描報告,節錄)** 流程圖:LINE 群組出現變更 → 業務看到 → 轉給業管(等)→ 業管對照 Email 訂單信與 ERP → 有出入就回問客戶(等)→ 新交期能否答應(簽)→ 業管修改 ERP → 口頭告知業務(等)→ 業務更新 Outlook 行事曆 | 步驟 | 建議的處理方式 | 可行程度 | |---|---|---| | 讀懂 LINE 變更,抓出客戶、訂單、新舊交期與數量 | 交給 AI 整理 | 有條件可以做:一般 AI 工具讀不到 LINE 群組,要每天固定時間把訊息複製到雲端文件,或用「傳送聊天記錄」匯出文字檔,再交給 AI | | 數字與日期比對 | 交給程式或固定規則 | 有條件可以做:要先從 ERP 匯出訂單報表 | | 新交期能不能答應 | 留給人 | 目前不建議交給 AI;AI 可以先把這筆訂單已改過幾次、原交期是什麼整理成摘要給業管參考 | | 修改 ERP 交期 | 留給人 | 目前不建議讓 AI 直接寫入系統 | | 業管改完通知業務 | 交給程式或固定規則 | 現在就能試:共用清單加「已改」與日期兩欄,取代口頭通知 | | 更新 Outlook 行事曆 | AI 起草、人確認 | 有條件可以做:公司帳號若開放連接行事曆,AI 可以擬出事件草稿,由本人確認後建立 | 最值得先試的一步:只選一個客戶群組,先處理二、三十筆變更。每天固定時間匯出當天的聊天文字,客戶名稱換成代號,請 AI 整理成固定欄位的變更清單(客戶、訂單編號、原交期、新交期、原數量、新數量、提出時間、原始訊息);遇到「下週五」這類說法,AI 要寫出推算的日期並標示需確認。由業務本人逐筆對照原始訊息確認。漏掉與弄錯的筆數很少、整理時間比原本短,就加上 Email 訂單與 ERP 報表的比對;結果不如預期,先修改欄位定義與提示詞,再縮小範圍。 這個例子裡最有用的發現,是一個原本沒人注意的缺口:業管改完系統只用口頭通知,所以 LINE、Email 與行事曆三邊的資料一直對不齊。先補上這個缺口,比導入任何工具都更有用。 ## 另一個開頭示例:人資篩選大量履歷 使用者只寫了一句「今天要處理人資單位的大量履歷篩選」,AI 第 1 站給的選項是: - A. 人資逐份看:人力銀行或信箱收履歷 → 下載歸檔 → 對照職缺條件逐份看 → 在 Excel 記錄合格與否 → 轉給用人主管 → 約面試 - B. 主管先看:人資只做基本分類 → 整批轉給用人主管 → 主管圈選想面試的人 → 人資彙整名單 → 通知面試 - C. 先訂條件再初篩:人資與主管先談好必要條件與加分條件 → 用人力銀行的條件過濾 → 剩下的人工複看 → 在 Excel 評分排序 → 送主管確認 → 約面試 - D. 都不像(請直接說明你們的做法) 使用者回:「比較像 A,但我們主要是招產線作業員跟技術員,一次開好幾個缺,履歷有些從人力銀行來,有些是 LINE 傳來或現場填的紙本表。」AI 就把三個來源記下來,第 2 站改問「缺額怎麼提出、一次進來多少份、多久要補到人」,後面再一路問到紙本字跡潦草要怎麼辨識、哪些條件可以寫成固定規則、哪些要留給產線主管判斷。
寄件者:NorthPeak 採購部 Ivy 收件者:CoCo電子 業務處 林協理 日期:2026/10/12(一)09:14 主旨:IGW-4600 規格調整與交期 林協理您好: 下一批 IGW-4600 工業閘道器,麻煩依下列調整: 1. 網路介面改成 4 埠 PoE。 2. 外殼顏色改成黑色。 3. 其他規格沿用上次。 這批數量是 800 台,原本的交期是 12/05,希望可以提前到 11/20。 麻煩這週內回覆可行性,謝謝。 Ivy (本信為課堂練習用的虛構資料)
內部料號,客戶料號,供應商料號,舊料號,品名規格,狀態,替代料號,備註 IGW-4600-A,NP-IG4600,KM-G4600,TL-IG-0460,工業閘道器 4600 A版(2 埠 PoE、銀色外殼),使用中,, IGW-4600-B,NP-IG4600B,KM-G4600B,,工業閘道器 4600 B版(4 埠、非 PoE、黑色外殼),使用中,, IGW-4600,NP-IG4600,,,工業閘道器 4600 初版,停用,IGW-4600-A,2025 年起停用 IGW-4800-B,NP-IG4800,KM-G4800,,工業閘道器 4800 B版(8 埠 PoE),使用中,, PSU-0480-C,NP-PS048,MW-0480C,PSU-480,電源供應模組 48W C版,使用中,, ENC-AL-210,,AL-210-BK,,鋁擠機殼 210(黑色陽極),使用中,,客戶未提供料號
訂單編號,建立日期,客戶,內部料號,規格編號,規格摘要,數量,交期,狀態 SO-26-0912,2026-06-18,NorthPeak,IGW-4600-A,SP-0102,2 埠 PoE、銀色外殼、韌體 v2.3,600,2026-08-05,已出貨 SO-26-1004,2026-08-27,NorthPeak,IGW-4600-B,SP-0118,4 埠非 PoE、黑色外殼、韌體 v2.4,300,2026-10-02,已出貨 SO-26-1033,2026-09-15,Lumacrest,IGW-4600-A,SP-0102,2 埠 PoE、銀色外殼、韌體 v2.3,150,2026-10-20,生產中
第二段練習:客戶規格異動 這三個檔案都是虛構的練習資料: 1. 客戶規格異動信.txt:客戶寄來的信,裡面寫「其他規格沿用上次」。 2. 料號對照表.csv:同一個物料有內部料號、客戶料號、供應商料號、舊料號;其中一個舊料號已經停用。 3. 上次訂單紀錄.csv:同一個客戶最近兩筆訂單,一筆是 A 版、一筆是 B 版。 第一輪:三個檔案都附上,只輸入一句「幫我整理這次客戶要改什麼,要通知哪些部門」,同一句話送兩次,比較兩次結果哪裡不一樣。 第二輪:加上你自己寫的規範再送一次,例如「上次」以哪一筆訂單為準、料號一律以對照表為準、停用料號不得使用、找不到資料就寫待確認、不得承諾交期。比較兩輪的結果。
# 常見需求:AI 能與不能對照表 台林電通|眼見為憑:主管的 AI 工作流程實作課 | 常見需求 | 現在的情況 | 比較務實的做法 | |---|---|---| | 把 LINE 訊息整理到行事曆,或跟 Email 資訊整合 | 一般 AI 工具目前沒有讀取個人或群組 LINE 對話的功能。要自動串接,需要 LINE 官方帳號與開發串接,技術門檻高,還要處理群組權限與個資 | 有紀律地每天固定時間把重要訊息複製到一份雲端文件,或用聊天室的「傳送聊天記錄」匯出文字檔,存到固定資料夾,再交給 AI 整理成待辦與行程草稿,由本人確認後寫進行事曆 | | 讀信、擬回覆、找會議時段 | 公司帳號若開放 AI 連接信箱與行事曆,就能讀信、摘要、找空檔、擬草稿 | 寄出與建立行程前由本人確認;沒有開放時,把信件內容複製或匯出後交給 AI | | 從 ERP、MES 拿資料做分析 | 不建議讓 AI 直接連正式系統,更不建議直接寫入 | 先從系統匯出報表,交給 AI 或程式處理;寫回系統由人或資訊部門執行 | | 同一物料有好幾個料號、跨檔比對 | AI 能找出疑似同一物料的候選,但會猜錯 | 先建立料號對照表,比對交給公式或程式;AI 只提候選,由人確認,不直接改主檔 | | 紙本與手寫單據 | 拍照可以辨識,手寫容易出錯 | 標出低信心欄位由人抽查;長期改成電子表單 | | 對帳、計算、統計 | 生成式 AI 直接算,每次結果可能不同 | 請 AI 寫公式或小程式,保留驗算 | | 每週固定報表與提醒 | 可以排程執行 | 先手動跑幾次確認穩定,再用 AI 工具的排程功能、試算表自動化或 Power Automate 改成固定執行 | | 會議錄音轉成會議紀錄 | 逐字稿可以自動產生,人名與專有名詞常錯 | 用人員名冊與名詞表校正;對不上的標「需人工確認」 | | 紙本簽核改成電子簽核 | 這是流程系統的題目,生成式 AI 本身解決不了 | 另外提案;這一輪先讓送簽附件一次就齊 | | 需求預測、生產排程 | 需要預測模型或排程工具 | 生成式 AI 負責整理條件與解釋結果,正式排程交給排程系統 | | 自己做一個小工具 | AI 可以做出網頁工具或試算表工具,像第一段的比對、查詢、燈號、提醒與匯出行事曆檔;但小工具不會自動讀取系統資料、不能多人同時編輯、關掉網頁就不記得資料 | 先用小工具證明做法可行;要給整個部門長期使用,再確認誰維護、放在哪裡、誰能用 | 工具功能變化很快,表內凡是牽涉帳號方案、系統連接與權限的項目,都以公司的帳號設定與資訊部門規範為準。
# 一頁試行說明|完整示例 台林電通|眼見為憑:主管的 AI 工作流程實作課 以下是一份寫好的示例。寫自己的試行說明時,照同樣的八個項目寫:要改善的工作、先試哪一步、使用的工具、誰確認、試行範圍、怎樣算有效、不如預期時先改什麼、有效之後擴大到哪裡。 **要改善的工作**:業務處把客戶在 LINE 群組提出的交期、數量變更,整理到 Outlook 行事曆,並與 Email 訂單核對。目前業管改完系統後多半口頭通知,三邊資料常常對不齊。 **先試哪一步**:每天下午五點,由業務助理把一個客戶群組當天的聊天文字匯出,存進共用資料夾;客戶名稱改成代號後交給 AI,整理成固定欄位的變更清單(客戶、訂單編號、原交期、新交期、原數量、新數量、提出時間、原始訊息)。「下週五」這類說法,AI 要寫出推算日期並標示需確認。 **使用的工具**:LINE 聊天室的「傳送聊天記錄」、共用雲端資料夾、公司核准的 ChatGPT 組織帳號。 **誰確認**:負責該客戶的業務,逐筆對照原始訊息。 **試行範圍**:一個客戶群組,先處理約 20 至 40 筆變更。 **怎樣算有效**:與業務自己整理的結果相比,漏掉與弄錯的筆數少於三筆,而且每天整理時間縮短一半。 **不如預期時**:先修改欄位定義與提示詞裡的日期規則;仍然不準,就把範圍縮到只處理交期變更。 **有效之後**:加上 Email 訂單與 ERP 匯出報表的比對,再擴大到第二個客戶群組;業管改完系統,在清單上標「已改」與日期,取代口頭通知。