Project

General

Profile

Feature #220 » sv.mes.雷刻檔設定.html

鎮源 游, 09/30/2026 07:07 AM

 
<!DOCTYPE html>
<html lang="zh-Hant">
<head>
<meta charset="UTF-8">
<title>MES系統 - 雷刻檔設定</title>
<style>
:root{
--blue-900:#1e3a8a; --blue-800:#1e40af; --blue-700:#1d4ed8; --blue-600:#2563eb; --blue-500:#3b82f6;
--gray-50:#f8fafc; --gray-100:#f1f5f9; --gray-200:#e2e8f0; --gray-300:#cbd5e1; --gray-500:#64748b; --gray-600:#475569; --gray-800:#1e293b;
--green-600:#16a34a; --green-50:#f0fdf4;
--amber-600:#d97706; --amber-50:#fffbeb;
--purple-600:#7c3aed; --purple-50:#f5f3ff;
--teal-600:#0d9488; --teal-50:#f0fdfa;
--red-600:#dc2626; --red-50:#fef2f2;
}
*{box-sizing:border-box;}
body{margin:0;font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang TC","Microsoft JhengHei",sans-serif;background:var(--gray-100);color:var(--gray-800);display:flex;min-height:100vh;font-size:14px;}

.sidebar{width:220px;background:linear-gradient(180deg,var(--blue-900),var(--blue-800));color:#fff;flex-shrink:0;display:flex;flex-direction:column;}
.sidebar-header{padding:16px 18px;font-weight:700;font-size:16px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid rgba(255,255,255,.12);}
.sidebar-header span.ver{font-size:11px;opacity:.6;font-weight:400;}
.nav-item{padding:12px 18px;font-size:13.5px;cursor:default;opacity:.85;border-left:3px solid transparent;}
.nav-item.active{background:rgba(255,255,255,.1);opacity:1;border-left-color:#fff;font-weight:600;}
.nav-sub{padding:10px 18px 10px 40px;font-size:13px;opacity:.85;}
.nav-sub.current{background:rgba(255,255,255,.15);opacity:1;font-weight:600;border-left:3px solid #fff;}
.sidebar-footer{margin-top:auto;padding:14px 18px;border-top:1px solid rgba(255,255,255,.12);display:flex;align-items:center;gap:10px;font-size:13px;}
.avatar{width:28px;height:28px;border-radius:50%;background:rgba(255,255,255,.2);display:flex;align-items:center;justify-content:center;font-size:12px;}

.main{flex:1;display:flex;flex-direction:column;min-width:0;}
.topbar{background:#fff;padding:16px 28px;font-size:19px;font-weight:700;border-bottom:1px solid var(--gray-200);}
.topbar .breadcrumb{font-size:12.5px;font-weight:400;color:var(--gray-500);margin-top:4px;}

.content{padding:20px 28px 40px;display:flex;flex-direction:column;gap:20px;}

.card{background:#fff;border-radius:10px;border:1px solid var(--gray-200);box-shadow:0 1px 2px rgba(0,0,0,.03);}
.card-head{padding:16px 20px;display:flex;align-items:center;justify-content:space-between;border-bottom:1px solid var(--gray-200);gap:16px;}
.card-head h2{margin:0;font-size:15.5px;}
.card-head p{margin:3px 0 0;font-size:12.5px;color:var(--gray-500);font-weight:400;}

.btn{border:none;border-radius:6px;padding:9px 16px;font-size:13.5px;font-weight:600;cursor:pointer;display:inline-flex;align-items:center;gap:6px;transition:filter .15s;white-space:nowrap;}
.btn:hover{filter:brightness(0.93);}
.btn:active{filter:brightness(0.85);}
.btn:disabled{opacity:.45;cursor:not-allowed;filter:none;}
.btn-green{background:var(--green-600);color:#fff;}
.btn-blue{background:var(--blue-600);color:#fff;}
.btn-gray{background:var(--gray-200);color:var(--gray-600);}
.btn-outline{background:#fff;color:var(--blue-600);border:1px solid var(--blue-500);}
.btn-purple{background:linear-gradient(135deg,#7c3aed,#6d28d9);color:#fff;}
.btn-sm{padding:6px 12px;font-size:12.5px;}

.status-pill{display:inline-flex;align-items:center;gap:6px;font-size:12.5px;font-weight:600;padding:5px 12px;border-radius:999px;}
.status-ok{background:var(--green-50);color:var(--green-600);}
.status-fail{background:var(--red-50);color:var(--red-600);}
.status-dot{width:7px;height:7px;border-radius:50%;background:currentColor;}

.field{display:flex;flex-direction:column;gap:5px;}
.field label{font-size:12.5px;color:var(--gray-600);font-weight:600;}
.field input[type=text],.field select{padding:9px 12px;border:1px solid var(--gray-300);border-radius:6px;font-size:13.5px;background:#fff;width:100%;}
.field input:focus,.field select:focus{outline:none;border-color:var(--blue-500);box-shadow:0 0 0 3px rgba(59,130,246,.15);}

table{width:100%;border-collapse:collapse;}
thead th{text-align:left;font-size:12.5px;color:var(--gray-500);font-weight:600;padding:12px 16px;border-bottom:1px solid var(--gray-200);white-space:nowrap;background:#fff;}
tbody td{padding:14px 16px;border-bottom:1px solid var(--gray-100);font-size:13.5px;vertical-align:middle;}
tbody tr:hover{background:var(--gray-50);}

.tag{display:inline-block;padding:4px 12px;border-radius:999px;font-size:12px;font-weight:700;letter-spacing:.3px;white-space:nowrap;}
.tag-rgp{background:var(--teal-50);color:var(--teal-600);border:1px solid #99f6e4;}
.tag-ok{background:var(--green-50);color:var(--green-600);border:1px solid #bbf7d0;}
.tag-rgptest{background:var(--blue-500);color:#fff;border:1px solid var(--blue-500);}
.tag-oktest{background:var(--amber-50);color:var(--amber-600);border:1px solid #fde68a;}
.tag-aok{background:var(--purple-50);color:var(--purple-600);border:1px solid #ddd6fe;}
.tag-aoktest{background:var(--red-50);color:var(--red-600);border:1px solid #fecaca;}
.tag-custom{background:var(--gray-100);color:var(--gray-600);border:1px solid var(--gray-300);}

.remark-input{font-size:12.5px;color:var(--gray-600);padding:8px 10px;border:1px solid var(--gray-300);border-radius:6px;width:240px;background:#fff;}
.remark-input:focus{outline:none;border-color:var(--blue-500);box-shadow:0 0 0 3px rgba(59,130,246,.15);}

.ftps-btn{
display:flex;flex-direction:column;align-items:flex-start;gap:3px;
background:#fff;border:1px solid var(--gray-300);border-radius:8px;padding:8px 12px;cursor:pointer;min-width:190px;font-size:12px;
}
.ftps-btn:hover{border-color:var(--blue-500);background:#f8fbff;}
.ftps-btn .ftps-title{font-weight:700;color:var(--gray-700,#334155);display:flex;align-items:center;gap:6px;}
.ftps-btn .ftps-dest{font-family:Consolas,monospace;font-size:11px;color:var(--gray-500);}
.ftps-btn .ftps-dest.unset{color:var(--amber-600);}

.form-grid{display:grid;grid-template-columns:1fr 1fr;gap:14px 16px;}
.field.full{grid-column:1 / -1;}
.pw-wrap{position:relative;}
.pw-wrap input{padding-right:52px !important;}
.pw-wrap button{position:absolute;right:8px;top:50%;transform:translateY(-50%);border:none;background:none;color:var(--blue-600);cursor:pointer;font-size:12px;font-weight:600;}

.rule-summary-btn{
display:flex;flex-direction:column;align-items:flex-start;gap:4px;
background:#fff;border:1px solid var(--gray-300);border-radius:8px;padding:8px 12px;cursor:pointer;min-width:220px;
}
.rule-summary-btn:hover{border-color:var(--blue-500);background:#f8fbff;}
.rule-summary-btn .count-row{display:flex;align-items:center;gap:8px;font-size:12.5px;font-weight:700;color:var(--blue-600);}
.rule-summary-btn .count-badge{background:var(--blue-600);color:#fff;border-radius:999px;padding:1px 8px;font-size:11px;}
.rule-summary-btn .preview-pattern{font-family:Consolas,monospace;font-size:11px;color:var(--gray-500);max-width:230px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;}

.toggle{position:relative;display:inline-block;width:38px;height:22px;}
.toggle input{opacity:0;width:0;height:0;}
.toggle .slider{position:absolute;cursor:pointer;inset:0;background:var(--gray-300);border-radius:999px;transition:.2s;}
.toggle .slider:before{position:absolute;content:"";height:16px;width:16px;left:3px;top:3px;background:#fff;border-radius:50%;transition:.2s;}
.toggle input:checked + .slider{background:var(--green-600);}
.toggle input:checked + .slider:before{transform:translateX(16px);}
.toggle input:disabled + .slider{opacity:.5;cursor:not-allowed;}
.remark-input:disabled, .rule-summary-btn:disabled, .ftps-btn:disabled{background:var(--gray-100);color:var(--gray-400,#94a3b8);cursor:not-allowed;opacity:.7;}

.row-actions{display:flex;gap:6px;flex-wrap:wrap;}
.icon-btn{border:1px solid var(--gray-300);background:#fff;color:var(--gray-600);border-radius:6px;padding:6px 10px;font-size:12px;cursor:pointer;}
.icon-btn:hover{background:var(--gray-50);}
.icon-btn.danger{color:var(--red-600);border-color:#fecaca;}
.icon-btn.danger:hover{background:var(--red-50);}
.icon-btn:disabled{opacity:.35;cursor:not-allowed;}

.card-foot{display:flex;justify-content:space-between;align-items:center;padding:14px 20px;border-top:1px solid var(--gray-200);}
.card-foot .last-saved{font-size:12px;color:var(--gray-500);}

/* Rule editor modal */
.modal-overlay{position:fixed;inset:0;background:rgba(15,23,42,.5);display:none;align-items:center;justify-content:center;z-index:50;padding:20px;}
.modal-overlay.show{display:flex;}
.modal{background:#fff;border-radius:12px;width:460px;max-width:92vw;padding:28px 26px;text-align:center;box-shadow:0 20px 50px rgba(0,0,0,.25);}
.modal.rule-modal{width:920px;max-width:96vw;text-align:left;padding:0;display:flex;flex-direction:column;max-height:88vh;}
.rule-modal-head{padding:20px 24px;border-bottom:1px solid var(--gray-200);display:flex;justify-content:space-between;align-items:flex-start;gap:12px;}
.rule-modal-head h3{margin:0 0 4px;font-size:16px;}
.rule-modal-head p{margin:0;font-size:12.5px;color:var(--gray-500);}
.rule-modal-body{padding:18px 24px;overflow-y:auto;flex:1;}
.rule-modal-foot{padding:16px 24px;border-top:1px solid var(--gray-200);display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap;}

.rule-table{width:100%;border-collapse:collapse;}
.rule-table th{text-align:left;font-size:11.5px;color:var(--gray-500);font-weight:600;padding:8px 8px;border-bottom:1px solid var(--gray-200);white-space:nowrap;}
.rule-table td{padding:6px 8px;border-bottom:1px solid var(--gray-100);vertical-align:middle;}
.rule-input{width:100%;padding:7px 9px;border:1px solid var(--gray-300);border-radius:6px;font-size:12.5px;font-family:Consolas,monospace;background:#fff;}
.rule-input.wide{min-width:200px;}
.rule-input.narrow{width:60px;font-family:inherit;text-align:center;}
.rule-input:focus{outline:none;border-color:var(--blue-500);box-shadow:0 0 0 3px rgba(59,130,246,.15);}
.rule-input.invalid{border-color:var(--red-600);background:var(--red-50);}
.rule-order{font-family:Consolas,monospace;color:var(--gray-500);font-size:12px;width:20px;text-align:center;}

.regex-error{font-size:10.5px;color:var(--red-600);margin-top:2px;}

.test-panel{margin-top:18px;background:var(--gray-50);border:1px solid var(--gray-200);border-radius:8px;padding:14px 16px;}
.test-panel h4{margin:0 0 10px;font-size:13px;}
.test-row{display:flex;gap:10px;align-items:center;flex-wrap:wrap;}
.test-row input{flex:1;min-width:220px;padding:9px 12px;border:1px solid var(--gray-300);border-radius:6px;font-size:13px;font-family:Consolas,monospace;}
.test-result{margin-top:12px;font-size:12.5px;padding:10px 12px;border-radius:8px;line-height:1.6;}
.test-result.hit{background:var(--green-50);color:#166534;border:1px solid #bbf7d0;}
.test-result.miss{background:var(--red-50);color:#991b1b;border:1px solid #fecaca;}
.test-result mark{background:#fde68a;padding:0 2px;border-radius:3px;}

.note-box{display:flex;gap:10px;background:#eff6ff;border:1px solid #bfdbfe;border-radius:8px;padding:12px 14px;font-size:12.5px;color:#1e40af;line-height:1.6;}
.note-box .ico{font-size:16px;}

.spinner{width:42px;height:42px;border:4px solid var(--gray-200);border-top-color:var(--purple-600);border-radius:50%;margin:18px auto 14px;animation:spin .8s linear infinite;}
@keyframes spin{to{transform:rotate(360deg);}}
.check-circle{width:52px;height:52px;border-radius:50%;background:var(--green-50);color:var(--green-600);display:flex;align-items:center;justify-content:center;margin:14px auto 16px;font-size:28px;}
.modal-log{text-align:left;background:var(--gray-50);border-radius:8px;padding:12px 14px;margin:14px 0;font-size:12px;font-family:Consolas,monospace;color:var(--gray-600);max-height:200px;overflow-y:auto;}
.modal-log div{padding:2px 0;}
.modal-close{margin-top:10px;width:100%;}

.toast{position:fixed;bottom:24px;right:24px;background:var(--gray-800);color:#fff;padding:12px 18px;border-radius:8px;font-size:13.5px;box-shadow:0 8px 20px rgba(0,0,0,.25);display:none;z-index:60;align-items:center;gap:8px;}
.toast.show{display:flex;}
</style>
</head>
<body>

<div class="sidebar">
<div class="sidebar-header">MES系統 <span class="ver">v1.2.1</span></div>
<div class="nav-item">首頁儀表板</div>
<div class="nav-item">部門管理</div>
<div class="nav-item">人員/權限管理</div>
<div class="nav-item">物料管理</div>
<div class="nav-item">設備管理</div>
<div class="nav-item">設備維修管理</div>
<div class="nav-item active">設備加工檔</div>
<div class="nav-sub current">雷刻檔設定(.txt)</div>
<div class="nav-sub">加工參數檔設定(.xml)</div>
<div class="nav-sub">加工參數檔設定(.dat)</div>
<div class="nav-item">品質管理</div>
<div class="nav-item">異常管理</div>
<div class="nav-item">課程模組</div>
<div class="nav-item">製造執行中心</div>
<div class="nav-sub">工單管理</div>
<div class="nav-sub">工單生產紀錄查詢</div>
<div class="nav-sub">生產即時監控看板</div>
<div class="nav-sub">工單列印設定</div>
<div class="nav-item">刀具管理</div>
<div class="sidebar-footer">
<div class="avatar">👤</div>
<div>admin<br><span style="opacity:.6;font-size:11px;">admin</span></div>
</div>
</div>

<div class="main">
<div class="topbar">
雷刻檔設定
<div class="breadcrumb">設備加工檔 / 雷刻檔設定(.txt)</div>
</div>

<div class="content">

<div class="card">
<div class="card-head">
<div>
<h2>類別 ↔ 正規表示式規則綁定</h2>
<p>目前依「產品型態」欄位分為 6 種類別,可視需要自行新增類別,各類別以正規表示式(Regular Expression)規則定義比對與轉換邏輯,由上而下依序比對,第一個命中的規則生效</p>
</div>
<div style="display:flex;gap:8px;">
<button class="btn btn-outline btn-sm" id="addCatBtn">+ 新增類別</button>
<button class="btn btn-blue btn-sm" id="editModeBtn">✎ 修改</button>
<button class="btn btn-gray btn-sm" id="cancelEditBtn" style="display:none;">✕ 取消</button>
<button class="btn btn-green btn-sm" id="saveAllBtn" style="display:none;">儲存全部設定</button>
</div>
</div>
<div class="note-box" id="lockedHint" style="margin:0 20px;">
<span class="ico">🔒</span>
<div>目前為檢視模式,備註/狀態/規則/FTPS 設定皆不可編輯,「儲存」按鈕也會暫時隱藏。請點「✎ 修改」解鎖後即可編輯,並會出現「儲存全部設定」按鈕。</div>
</div>

<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>類別</th>
<th>比對規則 (Regular Expression)</th>
<th>備註</th>
<th>FTPS 上傳設定</th>
<th>狀態</th>
<th>操作</th>
</tr>
</thead>
<tbody id="bindingBody"></tbody>
</table>
</div>

<div class="card-foot">
<span class="last-saved" id="lastSaved">上次儲存:2026/08/09 上午 10:05</span>
<span class="last-saved">共 <span id="bindingCount">4</span> 筆類別綁定</span>
</div>
</div>

</div>
</div>

<!-- Add category modal -->
<div class="modal-overlay" id="addCatModal">
<div class="modal">
<h3>新增類別</h3>
<p>建立一個全新的類別,內容全空,可自行編輯正規表示式規則與 FTPS 設定後儲存</p>
<div style="text-align:left;margin-top:16px;display:flex;flex-direction:column;gap:14px;">
<div class="field">
<label>類別名稱</label>
<input type="text" id="newCatName" placeholder="例如 RGP外銷">
</div>
<div class="field">
<label>備註(選填)</label>
<input type="text" id="newCatDesc" placeholder="例如 說明此類別用途">
</div>
</div>
<div style="display:flex;gap:10px;margin-top:20px;">
<button class="btn btn-gray" style="flex:1;" id="cancelAddCatBtn">取消</button>
<button class="btn btn-green" style="flex:1;" id="confirmAddCatBtn">建立</button>
</div>
</div>
</div>

<!-- Confirm modal -->
<div class="modal-overlay" id="confirmModal">
<div class="modal">
<h3 id="confirmTitle">確認刪除</h3>
<p id="confirmDesc"></p>
<div style="display:flex;gap:10px;margin-top:20px;">
<button class="btn btn-gray" style="flex:1;" id="confirmCancelBtn">取消</button>
<button class="btn" style="flex:1;background:var(--red-600);color:#fff;" id="confirmOkBtn">刪除</button>
</div>
</div>
</div>

<!-- Regex rule editor modal -->
<div class="modal-overlay" id="ruleModal">
<div class="modal rule-modal">
<div class="rule-modal-head">
<div>
<h3 id="ruleModalTitle">編輯正規表示式規則</h3>
<p id="ruleModalSub"></p>
</div>
<button class="icon-btn" id="closeRuleModalBtn">✕ 關閉</button>
</div>
<div class="rule-modal-body">
<div class="note-box" style="margin-bottom:14px;">
<span class="ico">ℹ️</span>
<div>規則由上而下依序比對,第一個 Pattern 命中即套用其「轉換結果」,可用 <code>()</code> 建立擷取群組,並在轉換結果欄用 <code>$1</code>、<code>$2</code> 帶入擷取內容。</div>
</div>

<table class="rule-table">
<thead>
<tr>
<th>順序</th>
<th style="min-width:220px;">Regular Expression Pattern</th>
<th style="min-width:140px;">轉換結果</th>
<th style="min-width:160px;">說明</th>
<th style="width:56px;">啟用</th>
<th style="width:110px;">排序</th>
<th style="width:36px;"></th>
</tr>
</thead>
<tbody id="ruleTableBody"></tbody>
</table>

<button class="btn btn-outline btn-sm" id="addRuleBtn" style="margin-top:12px;">+ 新增規則</button>

<div class="test-panel">
<h4>🧪 測試比對</h4>
<p style="margin:0 0 10px;font-size:11.5px;color:var(--gray-500);">比對字串組成規則:<b>物料代碼第 4、5 碼(物料設計碼)</b> + <b>客戶代碼</b> + <b>物料代碼第 3 碼</b></p>
<div class="test-row">
<input type="text" id="testMaterialCode" placeholder="物料代碼(例如 FHKCZAB123)">
<input type="text" id="testCustomerCode" placeholder="客戶代碼(例如 71)">
<button class="btn btn-purple btn-sm" id="runTestBtn">執行測試</button>
</div>
<div id="composedPreview" style="margin-top:8px;font-size:12px;color:var(--gray-500);font-family:Consolas,monospace;"></div>
<div id="testResult"></div>
</div>
</div>
<div class="rule-modal-foot">
<span style="font-size:12px;color:var(--gray-500);" id="ruleCountLabel"></span>
<div style="display:flex;gap:8px;">
<button class="btn btn-gray btn-sm" id="cancelRuleBtn">取消</button>
<button class="btn btn-green btn-sm" id="applyRuleBtn">套用規則</button>
</div>
</div>
</div>
</div>

<!-- FTPS settings modal -->
<div class="modal-overlay" id="ftpsModal">
<div class="modal wide">
<h3 id="ftpsModalTitle">FTPS 連線設定</h3>
<p id="ftpsModalSub"></p>
<div class="form-grid" style="text-align:left;margin-top:16px;">
<div class="field">
<label>Host</label>
<input type="text" id="ftpsHost" placeholder="ftps.example.com">
</div>
<div class="field">
<label>Port</label>
<input type="text" id="ftpsPort" placeholder="21">
</div>
<div class="field">
<label>帳號</label>
<input type="text" id="ftpsUser" placeholder="帳號">
</div>
<div class="field">
<label>密碼</label>
<div class="pw-wrap">
<input type="password" id="ftpsPass" placeholder="密碼">
<button type="button" id="toggleFtpsPw">顯示</button>
</div>
</div>
<div class="field full">
<label>遠端路徑</label>
<input type="text" id="ftpsPath" placeholder="/txt/一般片/">
</div>
<div class="field">
<label>傳輸模式</label>
<select id="ftpsMode">
<option value="explicit">明文 FTPS(Explicit)</option>
<option value="implicit">隱式 FTPS(Implicit)</option>
</select>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:20px;">
<button class="btn btn-outline" style="flex:1;" id="testFtpsBtn">🔌 測試連線</button>
<button class="btn btn-gray" style="flex:1;" id="cancelFtpsBtn">取消</button>
<button class="btn btn-green" style="flex:1;" id="saveFtpsBtn">儲存</button>
</div>
</div>
</div>

<!-- Save action modal -->
<div class="modal-overlay" id="actionModal">
<div class="modal">
<div id="modalLoading">
<h3 id="loadingTitle">處理中</h3>
<p id="loadingDesc">請稍候…</p>
<div class="spinner"></div>
<div class="modal-log" id="loadingLog"></div>
</div>
<div id="modalResult" style="display:none;">
<div class="check-circle">✓</div>
<h3 id="resultTitle"></h3>
<p id="resultDesc"></p>
<div class="modal-log" id="resultLog"></div>
<button class="btn btn-blue modal-close" id="modalCloseBtn">關閉</button>
</div>
</div>
</div>

<div class="toast" id="toast"></div>

<script>
// ---- 6 種類別(依「產品型態」欄位),各自綁定一組正規表示式規則(取代原本的 .flow 檔案) ----
let bindings = [
{
cat:"RGP",
tagClass:"tag-rgp",
enabled:true,
desc:"1:RGP 一般片",
ftps:{ host:"ftps.txtsvr.local", port:"21", user:"ftp_rgp", pass:"RgpPass!234", path:"/txt/RGP/", mode:"explicit" },
rules:[
{ pattern:"^(FK|Target|BC|CYL|DIA)$", output:"值 × 100", desc:"數值統一轉換為刻字格式", enabled:true },
{ pattern:".*", output:"C/CT", desc:"其餘 → C/CT(預設兜底規則)", enabled:true }
]
},
{
cat:"OK",
tagClass:"tag-ok",
enabled:true,
desc:"2:OK 一般片",
ftps:{ host:"ftps.txtsvr.local", port:"21", user:"ftp_ok", pass:"OkPass!234", path:"/txt/OK/", mode:"explicit" },
rules:[
{ pattern:"HiLine", output:"H/HT", desc:"HiLine → H/HT", enabled:true },
{ pattern:"myOK\\s*Classic", output:"C/CT", desc:"myOK Classic → C/CT", enabled:true },
{ pattern:"myOK\\s*Pro", output:"P/PT", desc:"myOK Pro → P/PT", enabled:true },
{ pattern:".*", output:"C/CT", desc:"其餘 → C/CT(預設兜底規則)", enabled:true }
]
},
{
cat:"RGP試片",
tagClass:"tag-rgptest",
enabled:true,
desc:"3:RGP 試片專用",
ftps:{ host:"", port:"21", user:"", pass:"", path:"", mode:"explicit" },
rules:[
{ pattern:".*", output:"上刻=流水序號/下刻=RGP", desc:"固定刻字規則,不比對代碼", enabled:true }
]
},
{
cat:"OK試片",
tagClass:"tag-oktest",
enabled:true,
desc:"4:OK 試片專用",
ftps:{ host:"", port:"21", user:"", pass:"", path:"", mode:"explicit" },
rules:[
{ pattern:".*", output:"上刻=流水序號/下刻=OK", desc:"固定刻字規則,不比對代碼", enabled:true }
]
},
{
cat:"AOK",
tagClass:"tag-aok",
enabled:false,
desc:"5:AOK 一般片",
ftps:{ host:"", port:"21", user:"", pass:"", path:"", mode:"explicit" },
rules:[
{ pattern:".*", output:"C/CT", desc:"規則待確認(預設兜底規則)", enabled:true }
]
},
{
cat:"AOK試片",
tagClass:"tag-aoktest",
enabled:false,
desc:"6:AOK 試片專用",
ftps:{ host:"", port:"21", user:"", pass:"", path:"", mode:"explicit" },
rules:[
{ pattern:".*", output:"上刻=流水序號/下刻=AOK", desc:"固定刻字規則,不比對代碼", enabled:true }
]
}
];

let editingIdx = null; // 目前正在編輯規則的 binding index
let editingRules = []; // 編輯中的暫存規則陣列(取消時可捨棄)

// ---- 編輯鎖定狀態:預設檢視(唯讀),點「修改」才能編輯,儲存或取消後鎖回 ----
let editMode = false;
let editSnapshot = null; // 進入編輯模式時,bindings 的深層快照,供「取消」還原

function updateEditModeUI(){
document.getElementById("editModeBtn").style.display = editMode ? "none" : "inline-flex";
document.getElementById("cancelEditBtn").style.display = editMode ? "inline-flex" : "none";
document.getElementById("saveAllBtn").style.display = editMode ? "inline-flex" : "none";
document.getElementById("lockedHint").style.display = editMode ? "none" : "flex";
}

function renderBindings(){
const body = document.getElementById("bindingBody");
body.innerHTML = "";
const lockAttr = editMode ? "" : "disabled";
bindings.forEach((b,idx)=>{
const tr = document.createElement("tr");
const enabledCount = b.rules.filter(r=>r.enabled).length;
const firstPattern = b.rules[0] ? b.rules[0].pattern : "";
tr.innerHTML = `
<td><span class="tag ${b.tagClass}">${b.cat}</span></td>
<td>
<button class="rule-summary-btn" data-idx="${idx}" data-action="edit-rules" ${lockAttr}>
<span class="count-row">📐 編輯規則 <span class="count-badge">${b.rules.length} 條</span></span>
<span class="preview-pattern">${b.rules.length===0 ? "尚未設定規則,請點擊編輯" : `/${escapeHtml(firstPattern)}/ ${b.rules.length>1?`… 等 ${enabledCount} 條啟用中`:""}`}</span>
</button>
</td>
<td><input type="text" class="remark-input" data-idx="${idx}" data-role="desc" value="${escapeHtml(b.desc)}" ${lockAttr}></td>
<td>
<button class="ftps-btn" data-idx="${idx}" data-action="edit-ftps" ${lockAttr}>
<span class="ftps-title">🔌 FTPS 設定</span>
<span class="ftps-dest ${ftpsIsSet(b)?"":"unset"}">${escapeHtml(ftpsDestString(b) || "未設定")}</span>
</button>
</td>
<td>
<label class="toggle">
<input type="checkbox" data-idx="${idx}" data-role="enabled" ${b.enabled?"checked":""} ${lockAttr}>
<span class="slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="icon-btn" data-idx="${idx}" data-action="test-quick" ${lockAttr}>▶ 快速測試</button>
<button class="icon-btn danger" data-idx="${idx}" data-action="delete-cat" ${lockAttr} title="刪除此類別">🗑 刪除</button>
</div>
</td>
`;
body.appendChild(tr);
});
document.getElementById("bindingCount").textContent = bindings.length;
updateEditModeUI();
}

// ---- 新增類別(全空,可自行編輯規則/FTPS 後儲存) ----
const addCatModal = document.getElementById("addCatModal");
document.getElementById("addCatBtn").addEventListener("click", ()=>{
if(editMode){
showToast("請先儲存或取消目前的編輯,才能新增類別");
return;
}
document.getElementById("newCatName").value = "";
document.getElementById("newCatDesc").value = "";
addCatModal.classList.add("show");
setTimeout(()=> document.getElementById("newCatName").focus(), 50);
});
document.getElementById("cancelAddCatBtn").addEventListener("click", ()=> addCatModal.classList.remove("show"));
document.getElementById("confirmAddCatBtn").addEventListener("click", ()=>{
const cat = document.getElementById("newCatName").value.trim();
const desc = document.getElementById("newCatDesc").value.trim();
if(!cat){ showToast("請輸入類別名稱"); return; }
if(bindings.some(b=>b.cat===cat)){ showToast(`類別「${cat}」已存在`); return; }

editSnapshot = JSON.parse(JSON.stringify(bindings)); // 記錄新增前的狀態,供「取消」還原(會一併移除本次新增的類別)
bindings.push({
cat,
tagClass: "tag-custom",
enabled: true,
desc,
ftps: { host:"", port:"21", user:"", pass:"", path:"", mode:"explicit" },
rules: []
});
editMode = true;
addCatModal.classList.remove("show");
renderBindings();
showToast(`已新增類別「${cat}」,目前已進入編輯模式,可編輯規則/FTPS 後儲存`);
});

function escapeHtml(s){
return (s||"").replace(/&/g,"&amp;").replace(/</g,"&lt;").replace(/>/g,"&gt;").replace(/"/g,"&quot;");
}

function ftpsIsSet(b){
const f = b.ftps;
return !!(f && f.host && f.user && f.path);
}
function ftpsDestString(b){
const f = b.ftps;
if(!ftpsIsSet(b)) return null;
return `${f.user}@${f.host}:${f.port||21}${f.path}`;
}

document.getElementById("editModeBtn").addEventListener("click", ()=>{
editMode = true;
editSnapshot = JSON.parse(JSON.stringify(bindings));
renderBindings();
});

document.getElementById("cancelEditBtn").addEventListener("click", ()=>{
if(editSnapshot){
bindings = editSnapshot;
}
editMode = false;
editSnapshot = null;
renderBindings();
showToast("已取消編輯,變更未儲存");
});

document.getElementById("bindingBody").addEventListener("change", e=>{
if(!editMode) return;
const idx = e.target.dataset.idx;
const role = e.target.dataset.role;
if(idx===undefined) return;
if(role==="desc") bindings[idx].desc = e.target.value;
if(role==="enabled") bindings[idx].enabled = e.target.checked;
});

document.getElementById("bindingBody").addEventListener("click", e=>{
const btn = e.target.closest("button[data-action]");
if(!btn) return;
const idx = Number(btn.dataset.idx);
const action = btn.dataset.action;
if(action==="edit-rules") openRuleModal(idx);
if(action==="test-quick") openRuleModal(idx, true);
if(action==="edit-ftps") openFtpsModal(idx);
if(action==="delete-cat") confirmDeleteCategory(idx);
});

// ---- 通用確認刪除 modal ----
const confirmModal = document.getElementById("confirmModal");
let confirmCallback = null;
function openConfirm(title, desc, onConfirm){
document.getElementById("confirmTitle").textContent = title;
document.getElementById("confirmDesc").textContent = desc;
confirmCallback = onConfirm;
confirmModal.classList.add("show");
}
document.getElementById("confirmCancelBtn").addEventListener("click", ()=>{
confirmModal.classList.remove("show");
confirmCallback = null;
});
document.getElementById("confirmOkBtn").addEventListener("click", ()=>{
confirmModal.classList.remove("show");
if(confirmCallback) confirmCallback();
confirmCallback = null;
});

// ---- 刪除整個類別(例如設定有誤時,可整份刪除重建)----
function confirmDeleteCategory(idx){
if(!editMode) return;
if(bindings.length<=1){
showToast("至少需保留一個類別,無法刪除");
return;
}
const b = bindings[idx];
openConfirm(
`刪除類別「${b.cat}」?`,
`即將刪除「${b.cat}」的正規表示式規則與 FTPS 設定,此動作無法復原。`,
()=>{
bindings.splice(idx,1);
renderBindings();
showToast(`已刪除「${b.cat}」,別忘了點「儲存全部設定」`);
}
);
}

// ---------- FTPS settings modal ----------
const ftpsModal = document.getElementById("ftpsModal");
let ftpsEditingIdx = null;

function openFtpsModal(idx){
ftpsEditingIdx = idx;
const b = bindings[idx];
const f = b.ftps;
document.getElementById("ftpsModalTitle").textContent = `FTPS 連線設定 — ${b.cat}`;
document.getElementById("ftpsModalSub").textContent = `此設定僅套用於「${b.cat}」產出的 .txt 檔案上傳`;
document.getElementById("ftpsHost").value = f.host;
document.getElementById("ftpsPort").value = f.port;
document.getElementById("ftpsUser").value = f.user;
document.getElementById("ftpsPass").value = f.pass;
document.getElementById("ftpsPath").value = f.path;
document.getElementById("ftpsMode").value = f.mode;
document.getElementById("ftpsPass").type = "password";
document.getElementById("toggleFtpsPw").textContent = "顯示";
ftpsModal.classList.add("show");
}
document.getElementById("cancelFtpsBtn").addEventListener("click", ()=> ftpsModal.classList.remove("show"));
document.getElementById("toggleFtpsPw").addEventListener("click", (e)=>{
const input = document.getElementById("ftpsPass");
if(input.type==="password"){ input.type="text"; e.target.textContent="隱藏"; }
else { input.type="password"; e.target.textContent="顯示"; }
});

document.getElementById("saveFtpsBtn").addEventListener("click", ()=>{
bindings[ftpsEditingIdx].ftps = {
host: document.getElementById("ftpsHost").value.trim(),
port: document.getElementById("ftpsPort").value.trim() || "21",
user: document.getElementById("ftpsUser").value.trim(),
pass: document.getElementById("ftpsPass").value,
path: document.getElementById("ftpsPath").value.trim(),
mode: document.getElementById("ftpsMode").value
};
ftpsModal.classList.remove("show");
renderBindings();
showToast(`已更新「${bindings[ftpsEditingIdx].cat}」的 FTPS 設定,請記得儲存全部設定`);
});

document.getElementById("testFtpsBtn").addEventListener("click", ()=>{
const host = document.getElementById("ftpsHost").value.trim();
const user = document.getElementById("ftpsUser").value.trim();
const port = document.getElementById("ftpsPort").value.trim() || "21";
const path = document.getElementById("ftpsPath").value.trim();
const mode = document.getElementById("ftpsMode").value;
if(!host || !user){
showToast("請至少填寫 Host 與帳號再測試連線");
return;
}
runModalSequence({
title:"測試 FTPS 連線",
desc:`正在連線至 ${host}:${port}`,
steps:[
`建立 ${mode==="implicit"?"隱式":"明文"} FTPS 連線...`,
`以帳號「${user}」登入...`,
`切換至路徑 ${path || "/"}...`,
"驗證讀寫權限..."
],
onDone: ()=> showModalResult({
title:"連線成功",
desc:`已成功連線並登入 ${host}`,
log:[`✓ TLS 交握成功`, `✓ 帳號驗證通過`, `✓ 路徑 ${path || "/"} 可讀寫`]
})
});
});

// ---------- Rule editor modal ----------
const ruleModal = document.getElementById("ruleModal");

function openRuleModal(idx, focusTest){
editingIdx = idx;
editingRules = JSON.parse(JSON.stringify(bindings[idx].rules));
const b = bindings[idx];
document.getElementById("ruleModalTitle").textContent = `編輯正規表示式規則 — ${b.cat}`;
document.getElementById("ruleModalSub").textContent = `${b.desc || ""}`;
document.getElementById("testMaterialCode").value = "";
document.getElementById("testCustomerCode").value = "";
document.getElementById("testResult").innerHTML = "";
document.getElementById("composedPreview").innerHTML = "";
renderRuleTable();
ruleModal.classList.add("show");
if(focusTest){
setTimeout(()=> document.getElementById("testMaterialCode").focus(), 50);
}
}

function isValidRegex(pattern){
try{ new RegExp(pattern); return true; } catch(e){ return false; }
}

function renderRuleTable(){
const tbody = document.getElementById("ruleTableBody");
tbody.innerHTML = "";
editingRules.forEach((r, i)=>{
const valid = isValidRegex(r.pattern);
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="rule-order">${i+1}</td>
<td>
<input type="text" class="rule-input wide ${valid?"":"invalid"}" data-i="${i}" data-role="pattern" value="${escapeHtml(r.pattern)}" placeholder="例如 ^FR..01$">
${valid?"":'<div class="regex-error">⚠ 無效的正規表示式</div>'}
</td>
<td><input type="text" class="rule-input" data-i="${i}" data-role="output" value="${escapeHtml(r.output)}" placeholder="轉換結果 / $1"></td>
<td><input type="text" class="rule-input" data-i="${i}" data-role="desc" value="${escapeHtml(r.desc)}" placeholder="說明(選填)"></td>
<td style="text-align:center;">
<label class="toggle">
<input type="checkbox" data-i="${i}" data-role="enabled" ${r.enabled?"checked":""}>
<span class="slider"></span>
</label>
</td>
<td>
<div class="row-actions">
<button class="icon-btn" data-i="${i}" data-action="move-up" ${i===0?"disabled":""} title="上移">↑</button>
<button class="icon-btn" data-i="${i}" data-action="move-down" ${i===editingRules.length-1?"disabled":""} title="下移">↓</button>
</div>
</td>
<td><button class="icon-btn danger" data-i="${i}" data-action="delete-rule" title="刪除">🗑</button></td>
`;
tbody.appendChild(tr);
});
document.getElementById("ruleCountLabel").textContent = `共 ${editingRules.length} 條規則`;
}

document.getElementById("ruleTableBody").addEventListener("input", e=>{
const role = e.target.dataset.role;
const i = e.target.dataset.i;
if(i===undefined || !role) return;
editingRules[i][role] = e.target.value;
if(role==="pattern"){
const valid = isValidRegex(e.target.value);
e.target.classList.toggle("invalid", !valid);
const errDiv = e.target.parentElement.querySelector(".regex-error");
if(!valid && !errDiv){
const div = document.createElement("div");
div.className = "regex-error";
div.textContent = "⚠ 無效的正規表示式";
e.target.parentElement.appendChild(div);
} else if(valid && errDiv){
errDiv.remove();
}
}
});

document.getElementById("ruleTableBody").addEventListener("change", e=>{
const role = e.target.dataset.role;
const i = e.target.dataset.i;
if(role==="enabled"){
editingRules[i].enabled = e.target.checked;
}
});

document.getElementById("ruleTableBody").addEventListener("click", e=>{
const btn = e.target.closest("button[data-action]");
if(!btn) return;
const i = Number(btn.dataset.i);
const action = btn.dataset.action;
if(action==="delete-rule"){
editingRules.splice(i,1);
renderRuleTable();
}
if(action==="move-up" && i>0){
[editingRules[i-1], editingRules[i]] = [editingRules[i], editingRules[i-1]];
renderRuleTable();
}
if(action==="move-down" && i<editingRules.length-1){
[editingRules[i+1], editingRules[i]] = [editingRules[i], editingRules[i+1]];
renderRuleTable();
}
});

document.getElementById("addRuleBtn").addEventListener("click", ()=>{
editingRules.push({ pattern:"", output:"", desc:"", enabled:true });
renderRuleTable();
const inputs = document.querySelectorAll('#ruleTableBody input[data-role="pattern"]');
const last = inputs[inputs.length-1];
if(last) last.focus();
});

document.getElementById("closeRuleModalBtn").addEventListener("click", ()=> ruleModal.classList.remove("show"));
document.getElementById("cancelRuleBtn").addEventListener("click", ()=> ruleModal.classList.remove("show"));

document.getElementById("applyRuleBtn").addEventListener("click", ()=>{
const hasInvalid = editingRules.some(r=> r.pattern.trim()!=="" && !isValidRegex(r.pattern));
if(hasInvalid){
showToast("有規則的正規表示式格式錯誤,請修正後再套用");
return;
}
bindings[editingIdx].rules = JSON.parse(JSON.stringify(editingRules));
renderBindings();
ruleModal.classList.remove("show");
showToast(`已更新「${bindings[editingIdx].cat}」的規則,請記得儲存全部設定`);
});

// ---------- Test matching engine ----------
function runRuleTest(rules, testStr){
for(const r of rules){
if(!r.enabled) continue;
if(!isValidRegex(r.pattern)) continue;
const re = new RegExp(r.pattern);
const m = testStr.match(re);
if(m){
return { rule:r, match:m };
}
}
return null;
}

// 比對字串組成規則:物料代碼第 4、5 碼(物料設計碼)+ 客戶代碼 + 物料代碼第 3 碼
function composeTestString(){
const material = document.getElementById("testMaterialCode").value || "";
const customer = document.getElementById("testCustomerCode").value || "";
const designCode = material.substring(3,5); // 第 4、5 碼
const materialCh3 = material.substring(2,3); // 第 3 碼
return { material, customer, designCode, materialCh3, composed: designCode + customer + materialCh3 };
}

function updateComposedPreview(){
const { material, customer, designCode, materialCh3, composed } = composeTestString();
const box = document.getElementById("composedPreview");
if(material.trim()===""){
box.innerHTML = "";
return;
}
if(material.length < 5){
box.innerHTML = `⚠ 物料代碼長度不足 5 碼,無法取出第 4、5 碼`;
return;
}
box.innerHTML = `組合後比對字串:<b>${escapeHtml(composed)}</b> (物料設計碼「${escapeHtml(designCode)}」+ 客戶代碼「${escapeHtml(customer)}」+ 物料第3碼「${escapeHtml(materialCh3)}」)`;
}
document.getElementById("testMaterialCode").addEventListener("input", updateComposedPreview);
document.getElementById("testCustomerCode").addEventListener("input", updateComposedPreview);

document.getElementById("runTestBtn").addEventListener("click", ()=>{
const resultBox = document.getElementById("testResult");
const { material, composed } = composeTestString();
if(material.trim()===""){
resultBox.innerHTML = `<div class="test-result miss">請輸入物料代碼</div>`;
return;
}
if(material.length < 5){
resultBox.innerHTML = `<div class="test-result miss">物料代碼長度不足 5 碼,無法取出第 4、5 碼,請確認輸入</div>`;
return;
}
const result = runRuleTest(editingRules, composed);
if(result){
const idx = editingRules.indexOf(result.rule);
const highlighted = composed.replace(result.match[0], `<mark>${escapeHtml(result.match[0])}</mark>`);
let outputText = result.rule.output;
result.match.forEach((g,i)=>{ if(i>0 && g!==undefined) outputText = outputText.replace(new RegExp("\\$"+i,"g"), g); });
resultBox.innerHTML = `<div class="test-result hit">
✓ 命中規則 #${idx+1}:<code>/${escapeHtml(result.rule.pattern)}/</code><br>
比對字串(物料設計碼+客戶代碼+物料第3碼):${highlighted}<br>
轉換結果:<b>${escapeHtml(outputText)}</b>${result.rule.desc?`(${escapeHtml(result.rule.desc)})`:""}
</div>`;
} else {
resultBox.innerHTML = `<div class="test-result miss">✕ 沒有規則命中「${escapeHtml(composed)}」,請檢查規則或新增兜底規則(如 <code>.*</code>)</div>`;
}
});
document.getElementById("testMaterialCode").addEventListener("keydown", e=>{
if(e.key==="Enter") document.getElementById("runTestBtn").click();
});
document.getElementById("testCustomerCode").addEventListener("keydown", e=>{
if(e.key==="Enter") document.getElementById("runTestBtn").click();
});

// ---------- Save all ----------
const actionModal = document.getElementById("actionModal");
const modalLoading = document.getElementById("modalLoading");
const modalResult = document.getElementById("modalResult");

function runModalSequence({title, desc, steps, onDone}){
actionModal.classList.add("show");
modalLoading.style.display="block";
modalResult.style.display="none";
document.getElementById("loadingTitle").textContent = title;
document.getElementById("loadingDesc").textContent = desc;
const log = document.getElementById("loadingLog");
log.innerHTML = "";
let i=0;
const interval = setInterval(()=>{
if(i<steps.length){
const line = document.createElement("div");
line.textContent = "› " + steps[i];
log.appendChild(line);
log.scrollTop = log.scrollHeight;
i++;
} else {
clearInterval(interval);
setTimeout(onDone, 400);
}
}, 260);
}
function showModalResult({title, desc, log}){
modalLoading.style.display="none";
modalResult.style.display="block";
document.getElementById("resultTitle").textContent = title;
document.getElementById("resultDesc").textContent = desc;
document.getElementById("resultLog").innerHTML = (log||[]).map(l=>`<div>${l}</div>`).join("");
}
document.getElementById("modalCloseBtn").addEventListener("click", ()=> actionModal.classList.remove("show"));

document.getElementById("saveAllBtn").addEventListener("click", ()=>{
if(!editMode) return;
const totalRules = bindings.reduce((sum,b)=> sum + b.rules.length, 0);
const missingFtps = bindings.filter(b=> b.enabled && !ftpsIsSet(b));
if(missingFtps.length>0){
showToast(`「${missingFtps.map(b=>b.cat).join("、")}」尚未設定 FTPS,請先完成設定`);
return;
}
runModalSequence({
title:"儲存雷刻檔設定",
desc:"正在儲存類別 ↔ 正規表示式規則 ↔ FTPS 設定",
steps:[
"驗證所有正規表示式語法...",
`寫入 ${bindings.length} 個類別、共 ${totalRules} 條規則...`,
"驗證各類別 FTPS 連線設定...",
"更新產製 .txt 比對引擎與上傳設定..."
],
onDone: ()=>{
showModalResult({
title:"設定已儲存",
desc:`共 ${bindings.length} 個類別、${totalRules} 條規則已生效`,
log: bindings.map(b=>`✓ ${b.cat} → ${b.rules.length} 條規則,FTPS → ${ftpsDestString(b) || "未設定"}${b.enabled?"":"(類別停用)"}`)
});
document.getElementById("lastSaved").textContent = "上次儲存:" + new Date().toLocaleString('zh-TW',{hour12:true});
editMode = false;
editSnapshot = null;
renderBindings();
}
});
});

function showToast(msg){
const t = document.getElementById("toast");
t.textContent = "✓ " + msg;
t.classList.add("show");
setTimeout(()=>t.classList.remove("show"), 3000);
}

renderBindings();
</script>
</body>
</html>
(3-3/3)