Project

General

Profile

Feature #223 » sv.mes.標籤樣式設定.html

鎮源 游, 09/30/2026 07:10 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;
--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;flex-wrap:wrap;}
.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-danger-outline{background:#fff;color:var(--red-600);border:1px solid #fecaca;}
.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.full{grid-column:1 / -1;}
.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:12.5px;font-weight:700;background:var(--gray-100);color:var(--gray-700,#334155);border:1px solid var(--gray-300);}

select.api-select{padding:8px 10px;border:1px solid var(--gray-300);border-radius:6px;font-size:13px;min-width:200px;background:#fff;}
.endpoint-text{font-family:Consolas,monospace;font-size:11.5px;color:var(--gray-500);word-break:break-all;}

.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:disabled{background:var(--gray-100);color:var(--gray-400,#94a3b8);cursor:not-allowed;opacity:.7;}
.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-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;}

.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;display:inline-flex;align-items:center;gap:4px;}
.icon-btn:hover{background:var(--gray-50);}
.icon-btn.plain{color:var(--blue-600);}
.icon-btn.plain:hover{background:#eff6ff;border-color:#bfdbfe;}
.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);}

.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;}

/* Modals */
.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:960px;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:160px;}
.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-grid{display:grid;grid-template-columns:1fr 1fr 1fr auto;gap:10px;align-items:end;}
.test-grid .field label{font-size:11.5px;}
.test-grid input{padding:9px 10px;border:1px solid var(--gray-300);border-radius:6px;font-size:12.5px;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 code{background:rgba(0,0,0,.06);padding:1px 5px;border-radius:4px;}

.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;}
.cross-circle{width:52px;height:52px;border-radius:50%;background:var(--red-50);color:var(--red-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:180px;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">品質管理</div>
<div class="nav-item">異常管理</div>
<div class="nav-item">課程模組</div>
<div class="nav-item active">製造執行中心</div>
<div class="nav-sub">工單管理</div>
<div class="nav-sub">工單生產紀錄查詢</div>
<div class="nav-sub">生產即時監控看板</div>
<div class="nav-sub">工單列印設定</div>
<div class="nav-sub current">標籤樣式設定</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">製造執行中心 / 標籤樣式設定</div>
</div>

<div class="content">

<div class="card">
<div class="card-head">
<div>
<h2>標籤樣式 ↔ 適用條件 ↔ API 綁定</h2>
<p>每個標籤樣式各自綁定一支 BarTender API,並以「客戶/產品設計碼/產品類別」三個條件(皆支援正規表示式)判斷此工單應套用哪個樣式;由上而下依序比對,第一個命中的樣式生效</p>
</div>
<div style="display:flex;gap:8px;flex-wrap:wrap;">
<button class="btn btn-outline btn-sm" id="addStyleBtn">+ 新增樣式</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:16px 20px 0;">
<span class="ico">🔒</span>
<div>目前為檢視模式,樣式名稱/API/適用條件/狀態皆不可編輯,「儲存」按鈕也會暫時隱藏。請點「✎ 修改」解鎖後即可編輯,並會出現「儲存全部設定」按鈕。</div>
</div>

<div style="overflow-x:auto;">
<table>
<thead>
<tr>
<th>順序</th>
<th>樣式名稱</th>
<th>對應 API</th>
<th>API Endpoint</th>
<th>適用條件</th>
<th>狀態</th>
<th>上次測試結果</th>
<th>操作</th>
</tr>
</thead>
<tbody id="styleBody"></tbody>
</table>
</div>
<div id="emptyState" style="display:none;padding:40px 20px;text-align:center;color:var(--gray-500);font-size:13px;">尚無任何標籤樣式,請點「✎ 修改」後再點「+ 新增樣式」開始建立</div>

<div class="card-foot">
<span class="last-saved" id="lastSaved">尚未儲存</span>
<span class="last-saved">共 <span id="styleCount">0</span> 個標籤樣式</span>
</div>
</div>

</div>
</div>

<!-- Add style modal -->
<div class="modal-overlay" id="addStyleModal">
<div class="modal">
<h3>新增標籤樣式</h3>
<p>建立一個全新的標籤樣式,適用條件先留空(不限),可於清單中再編輯</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="newStyleName" placeholder="例如 客製化出貨標籤">
</div>
<div class="field">
<label>對應 API</label>
<select class="api-select" id="newStyleApi" style="width:100%;"></select>
</div>
</div>
<div style="display:flex;gap:10px;margin-top:20px;">
<button class="btn btn-gray" style="flex:1;" id="cancelAddStyleBtn">取消</button>
<button class="btn btn-green" style="flex:1;" id="confirmAddStyleBtn">建立</button>
</div>
</div>
</div>

<!-- Match rule editor modal (Regex: 客戶/產品設計碼/產品類別) -->
<div class="modal-overlay" id="matchRuleModal">
<div class="modal rule-modal">
<div class="rule-modal-head">
<div>
<h3 id="matchRuleModalTitle">編輯適用條件</h3>
<p id="matchRuleModalSub"></p>
</div>
<button class="icon-btn plain" id="closeMatchRuleModalBtn">✕ 關閉</button>
</div>
<div class="rule-modal-body">
<div class="note-box" style="margin-bottom:14px;">
<span class="ico">ℹ️</span>
<div>規則由上而下依序比對,「客戶」「產品設計碼」「產品類別」三個條件皆符合(欄位留空視為不限)的第一條規則即命中,系統將自動套用此標籤樣式。</div>
</div>

<table class="rule-table">
<thead>
<tr>
<th>順序</th>
<th style="min-width:160px;">客戶 (Regex)</th>
<th style="min-width:160px;">產品設計碼 (Regex)</th>
<th style="min-width:160px;">產品類別 (Regex)</th>
<th style="min-width:140px;">說明</th>
<th style="width:56px;">啟用</th>
<th style="width:110px;">排序</th>
<th style="width:36px;"></th>
</tr>
</thead>
<tbody id="matchRuleTableBody"></tbody>
</table>

<button class="btn btn-outline btn-sm" id="addMatchRuleBtn" style="margin-top:12px;">+ 新增條件</button>

<div class="test-panel">
<h4>🧪 測試比對</h4>
<div class="test-grid">
<div class="field"><label>客戶</label><input type="text" id="mrTestCustomer" placeholder="例如 HOYA"></div>
<div class="field"><label>產品設計碼</label><input type="text" id="mrTestDesignCode" placeholder="例如 FK203"></div>
<div class="field"><label>產品類別</label><input type="text" id="mrTestProdCategory" placeholder="例如 RGP試片"></div>
<button class="btn btn-purple btn-sm" id="runMatchTestBtn">執行測試</button>
</div>
<div id="mrTestResult"></div>
</div>
</div>
<div class="rule-modal-foot">
<span style="font-size:12px;color:var(--gray-500);" id="matchRuleCountLabel"></span>
<div style="display:flex;gap:8px;">
<button class="btn btn-gray btn-sm" id="cancelMatchRuleBtn">取消</button>
<button class="btn btn-green btn-sm" id="applyMatchRuleBtn">套用條件</button>
</div>
</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>

<!-- Test print / 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 id="resultIcon"></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>
// 已在 BarTender 後台建立好的 API 清單(沿用工單列印設定頁的清單)
const availableApis = [
{ name:"OK_Label_API", endpoint:"http://192.168.10.55:6608/api/print/OK_Label" },
{ name:"MYOK_Label_API", endpoint:"http://192.168.10.55:6608/api/print/MYOK_Label" },
{ name:"RGP_Label_API", endpoint:"http://192.168.10.55:6608/api/print/RGP_Label" },
{ name:"Backup_Label_API", endpoint:"http://192.168.10.55:6608/api/print/Backup_Label" },
{ name:"Zeiss_Custom_Label_API", endpoint:"http://192.168.10.55:6608/api/print/Zeiss_Custom_Label" }
];
function apiByName(name){ return availableApis.find(a=>a.name===name); }

// ---- 標籤樣式清單:每個樣式綁定一支 API,並以「客戶/產品設計碼/產品類別」三條件判斷是否套用 ----
let styles = [
{
name:"標準出貨標籤",
api:"OK_Label_API",
enabled:true,
matchRules:[
{ customer:"", designCode:"", prodCategory:"", desc:"預設兜底規則,三欄位皆不限", enabled:true }
],
lastTest:{ ok:true, time:"2026/08/09 上午 09:40" }
},
{
name:"蔡司客製標籤",
api:"Zeiss_Custom_Label_API",
enabled:true,
matchRules:[
{ customer:"ZEISS|卡爾蔡司", designCode:"", prodCategory:"", desc:"客戶為卡爾蔡司即套用", enabled:true }
],
lastTest:{ ok:true, time:"2026/08/08 下午 03:12" }
},
{
name:"RGP試片標籤",
api:"RGP_Label_API",
enabled:true,
matchRules:[
{ customer:"", designCode:"", prodCategory:"^RGP試片$", desc:"產品類別為 RGP試片", enabled:true }
],
lastTest:{ ok:false, time:"2026/08/07 上午 11:03" }
},
{
name:"備用標籤",
api:"Backup_Label_API",
enabled:false,
matchRules:[],
lastTest:null
}
];

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

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 populateApiSelect(select, selected){
select.innerHTML = availableApis.map(a=>`<option value="${a.name}" ${a.name===selected?"selected":""}>${a.name}</option>`).join("");
}

function isValidRegex(pattern){
try{ new RegExp(pattern); return true; } catch(e){ return false; }
}
function fieldMatches(pattern, value){
if(!pattern || pattern.trim()==="") return true;
if(!isValidRegex(pattern)) return false;
return new RegExp(pattern).test(value||"");
}
function runMatchRuleTest(rules, customer, designCode, prodCategory){
for(const r of rules){
if(!r.enabled) continue;
if(fieldMatches(r.customer, customer) && fieldMatches(r.designCode, designCode) && fieldMatches(r.prodCategory, prodCategory)){
return r;
}
}
return null;
}

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

function renderStyles(){
const body = document.getElementById("styleBody");
const empty = document.getElementById("emptyState");
body.innerHTML = "";
const lockAttr = editMode ? "" : "disabled";

if(styles.length===0){
empty.style.display = "block";
} else {
empty.style.display = "none";
styles.forEach((s, idx)=>{
const ep = apiByName(s.api) ? apiByName(s.api).endpoint : "";
const ruleCount = s.matchRules.length;
const enabledCount = s.matchRules.filter(r=>r.enabled).length;
const firstRule = s.matchRules[0];
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="rule-order">${idx+1}</td>
<td><span class="tag">${escapeHtml(s.name)}</span></td>
<td><select class="api-select" data-idx="${idx}" data-role="api" ${lockAttr}></select></td>
<td><span class="endpoint-text">${ep}</span></td>
<td>
<button class="rule-summary-btn" data-idx="${idx}" data-action="edit-rules" ${lockAttr}>
<span class="count-row">🧪 適用條件 <span class="count-badge">${ruleCount} 條</span></span>
<span class="preview-pattern">${ruleCount===0 ? "尚未設定,預設不適用任何工單" : `${escapeHtml(firstRule.desc || "(無說明)")}${ruleCount>1?` … 等 ${enabledCount} 條啟用中`:""}`}</span>
</button>
</td>
<td>
<label class="toggle">
<input type="checkbox" data-idx="${idx}" data-role="enabled" ${s.enabled?"checked":""} ${lockAttr}>
<span class="slider"></span>
</label>
</td>
<td>
${s.lastTest
? `<span class="status-pill ${s.lastTest.ok?"status-ok":"status-fail"}"><span class="status-dot"></span>${s.lastTest.ok?"成功":"失敗"} · ${s.lastTest.time}</span>`
: `<span style="color:var(--gray-500);font-size:12.5px;">尚未測試</span>`}
</td>
<td>
<div class="row-actions">
<button class="icon-btn plain" data-idx="${idx}" data-action="test" ${lockAttr}>🖨️ 測試列印</button>
<button class="icon-btn danger" data-idx="${idx}" data-action="delete" ${lockAttr}>🗑 刪除</button>
</div>
</td>
`;
body.appendChild(tr);
const sel = tr.querySelector('select[data-role="api"]');
populateApiSelect(sel, s.api);
});
}

document.getElementById("styleCount").textContent = styles.length;
updateEditModeUI();
}

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

document.getElementById("styleBody").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") openMatchRuleModal(idx);
if(action==="test") runTestPrint(styles[idx]);
if(action==="delete") confirmDeleteStyle(idx);
});

// ---- 編輯鎖定 ----
document.getElementById("editModeBtn").addEventListener("click", ()=>{
editMode = true;
editSnapshot = JSON.parse(JSON.stringify(styles));
renderStyles();
});
document.getElementById("cancelEditBtn").addEventListener("click", ()=>{
if(editSnapshot){ styles = editSnapshot; }
editMode = false;
editSnapshot = null;
renderStyles();
showToast("已取消編輯,變更未儲存");
});

// ---- 新增樣式 ----
const addStyleModal = document.getElementById("addStyleModal");
document.getElementById("addStyleBtn").addEventListener("click", ()=>{
if(editMode){
showToast("請先儲存或取消目前的編輯,才能新增樣式");
return;
}
document.getElementById("newStyleName").value = "";
populateApiSelect(document.getElementById("newStyleApi"), availableApis[0].name);
addStyleModal.classList.add("show");
setTimeout(()=> document.getElementById("newStyleName").focus(), 50);
});
document.getElementById("cancelAddStyleBtn").addEventListener("click", ()=> addStyleModal.classList.remove("show"));
document.getElementById("confirmAddStyleBtn").addEventListener("click", ()=>{
const name = document.getElementById("newStyleName").value.trim();
const api = document.getElementById("newStyleApi").value;
if(!name){ showToast("請輸入樣式名稱"); return; }
if(styles.some(s=>s.name===name)){ showToast(`樣式「${name}」已存在`); return; }

editSnapshot = JSON.parse(JSON.stringify(styles)); // 記錄新增前狀態,供「取消」還原(會一併移除本次新增的樣式)
styles.push({ name, api, enabled:true, matchRules:[], lastTest:null });
editMode = true;
addStyleModal.classList.remove("show");
renderStyles();
showToast(`已新增樣式「${name}」,目前已進入編輯模式,可設定適用條件後儲存`);
});

// ---- 刪除樣式 ----
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 confirmDeleteStyle(idx){
if(!editMode) return;
if(styles.length<=1){
showToast("至少需保留一個標籤樣式,無法刪除");
return;
}
const s = styles[idx];
openConfirm(
`刪除標籤樣式「${s.name}」?`,
`即將刪除此樣式的 API 綁定與適用條件設定,此動作無法復原。`,
()=>{
styles.splice(idx,1);
renderStyles();
showToast(`已刪除「${s.name}」,別忘了點「儲存全部設定」`);
}
);
}

// ---- 適用條件(客戶/產品設計碼/產品類別)編輯 modal ----
let editingStyleIdx = null;
let editingMatchRules = [];
const matchRuleModal = document.getElementById("matchRuleModal");

function openMatchRuleModal(idx){
editingStyleIdx = idx;
editingMatchRules = JSON.parse(JSON.stringify(styles[idx].matchRules));
const s = styles[idx];
document.getElementById("matchRuleModalTitle").textContent = `編輯適用條件 — ${s.name}`;
document.getElementById("matchRuleModalSub").textContent = `符合條件時,系統將自動套用「${s.name}」並呼叫 ${s.api} 列印`;
document.getElementById("mrTestCustomer").value = "";
document.getElementById("mrTestDesignCode").value = "";
document.getElementById("mrTestProdCategory").value = "";
document.getElementById("mrTestResult").innerHTML = "";
renderMatchRuleTable();
matchRuleModal.classList.add("show");
}

function renderMatchRuleTable(){
const tbody = document.getElementById("matchRuleTableBody");
tbody.innerHTML = "";
editingMatchRules.forEach((r,i)=>{
const validCustomer = isValidRegex(r.customer);
const validDesign = isValidRegex(r.designCode);
const validProd = isValidRegex(r.prodCategory);
const tr = document.createElement("tr");
tr.innerHTML = `
<td class="rule-order">${i+1}</td>
<td>
<input type="text" class="rule-input wide ${validCustomer?"":"invalid"}" data-i="${i}" data-role="customer" value="${escapeHtml(r.customer)}" placeholder="例如 ^HOYA.*(留空=不限)">
${validCustomer?"":'<div class="regex-error">⚠ 無效的正規表示式</div>'}
</td>
<td>
<input type="text" class="rule-input wide ${validDesign?"":"invalid"}" data-i="${i}" data-role="designCode" value="${escapeHtml(r.designCode)}" placeholder="例如 ^FK.*(留空=不限)">
${validDesign?"":'<div class="regex-error">⚠ 無效的正規表示式</div>'}
</td>
<td>
<input type="text" class="rule-input wide ${validProd?"":"invalid"}" data-i="${i}" data-role="prodCategory" value="${escapeHtml(r.prodCategory)}" placeholder="例如 RGP試片(留空=不限)">
${validProd?"":'<div class="regex-error">⚠ 無效的正規表示式</div>'}
</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 plain" data-i="${i}" data-action="move-up" ${i===0?"disabled":""} title="上移">↑</button>
<button class="icon-btn plain" data-i="${i}" data-action="move-down" ${i===editingMatchRules.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("matchRuleCountLabel").textContent = `共 ${editingMatchRules.length} 條條件`;
}

document.getElementById("matchRuleTableBody").addEventListener("input", e=>{
const role = e.target.dataset.role;
const i = e.target.dataset.i;
if(i===undefined || !role) return;
editingMatchRules[i][role] = e.target.value;
if(role==="customer" || role==="designCode" || role==="prodCategory"){
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("matchRuleTableBody").addEventListener("change", e=>{
const role = e.target.dataset.role;
const i = e.target.dataset.i;
if(role==="enabled") editingMatchRules[i].enabled = e.target.checked;
});

document.getElementById("matchRuleTableBody").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"){ editingMatchRules.splice(i,1); renderMatchRuleTable(); }
if(action==="move-up" && i>0){ [editingMatchRules[i-1],editingMatchRules[i]]=[editingMatchRules[i],editingMatchRules[i-1]]; renderMatchRuleTable(); }
if(action==="move-down" && i<editingMatchRules.length-1){ [editingMatchRules[i+1],editingMatchRules[i]]=[editingMatchRules[i],editingMatchRules[i+1]]; renderMatchRuleTable(); }
});

document.getElementById("addMatchRuleBtn").addEventListener("click", ()=>{
editingMatchRules.push({ customer:"", designCode:"", prodCategory:"", desc:"", enabled:true });
renderMatchRuleTable();
const inputs = document.querySelectorAll('#matchRuleTableBody input[data-role="customer"]');
const last = inputs[inputs.length-1];
if(last) last.focus();
});

document.getElementById("closeMatchRuleModalBtn").addEventListener("click", ()=> matchRuleModal.classList.remove("show"));
document.getElementById("cancelMatchRuleBtn").addEventListener("click", ()=> matchRuleModal.classList.remove("show"));

document.getElementById("applyMatchRuleBtn").addEventListener("click", ()=>{
const hasInvalid = editingMatchRules.some(r=>
(r.customer.trim()!=="" && !isValidRegex(r.customer)) ||
(r.designCode.trim()!=="" && !isValidRegex(r.designCode)) ||
(r.prodCategory.trim()!=="" && !isValidRegex(r.prodCategory))
);
if(hasInvalid){
showToast("有條件的正規表示式格式錯誤,請修正後再套用");
return;
}
styles[editingStyleIdx].matchRules = JSON.parse(JSON.stringify(editingMatchRules));
matchRuleModal.classList.remove("show");
renderStyles();
showToast(`已更新「${styles[editingStyleIdx].name}」的適用條件,別忘了點「儲存全部設定」`);
});

document.getElementById("runMatchTestBtn").addEventListener("click", ()=>{
const customer = document.getElementById("mrTestCustomer").value;
const designCode = document.getElementById("mrTestDesignCode").value;
const prodCategory = document.getElementById("mrTestProdCategory").value;
const resultBox = document.getElementById("mrTestResult");
const result = runMatchRuleTest(editingMatchRules, customer, designCode, prodCategory);
if(result){
const idx = editingMatchRules.indexOf(result);
resultBox.innerHTML = `<div class="test-result hit">
✓ 命中條件 #${idx+1}:客戶 <code>/${escapeHtml(result.customer||".*")}/</code>、產品設計碼 <code>/${escapeHtml(result.designCode||".*")}/</code>、產品類別 <code>/${escapeHtml(result.prodCategory||".*")}/</code><br>
將自動套用此標籤樣式${result.desc?`(${escapeHtml(result.desc)})`:""}
</div>`;
} else {
resultBox.innerHTML = `<div class="test-result miss">✕ 沒有條件命中,請檢查設定或新增兜底條件(三個欄位皆留空即符合所有工單)</div>`;
}
});

// ---- 測試列印 / 儲存全部設定 ----
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({ok, title, desc, log}){
modalLoading.style.display="none";
modalResult.style.display="block";
document.getElementById("resultIcon").innerHTML = ok
? `<div class="check-circle">✓</div>` : `<div class="cross-circle">✕</div>`;
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"));

function runTestPrint(style){
const ep = apiByName(style.api) ? apiByName(style.api).endpoint : "";
runModalSequence({
title:`測試列印 — ${style.name}`,
desc:`呼叫「${style.api}」送出測試標籤`,
steps:[
`呼叫 API:${ep}`,
"驗證適用條件設定...",
"送出測試列印工作..."
],
onDone: ()=>{
const success = style.enabled;
style.lastTest = { ok: success, time: new Date().toLocaleString('zh-TW',{hour12:false}).slice(0,16).replace(',','') };
showModalResult({
ok: success,
title: success ? "測試列印成功" : "測試列印失敗",
desc: success ? `已成功呼叫「${style.api}」送出列印` : "此樣式目前為停用狀態,未送出列印",
log: success
? [`✓ API 回應 200 OK`, `✓ 已透過 ${style.api} 送出`]
: [`✕ 樣式狀態為停用,請先啟用後再測試`]
});
renderStyles();
}
});
}

document.getElementById("saveAllBtn").addEventListener("click", ()=>{
runModalSequence({
title:"儲存標籤樣式設定",
desc:"正在儲存標籤樣式、適用條件與 API 綁定",
steps:[
"驗證適用條件正規表示式語法...",
`寫入 ${styles.length} 個標籤樣式設定...`,
"更新樣式判斷與列印呼叫對照表..."
],
onDone: ()=>{
showModalResult({
ok:true,
title:"設定已儲存",
desc:`共 ${styles.length} 個標籤樣式設定已生效`,
log: styles.map(s=>`✓ ${s.name} → ${s.api}(${s.matchRules.length} 條適用條件)${s.enabled?"":"(停用)"}`)
});
document.getElementById("lastSaved").textContent = "上次儲存:" + new Date().toLocaleString('zh-TW',{hour12:true});
editMode = false;
editSnapshot = null;
renderStyles();
}
});
});

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

renderStyles();
</script>
</body>
</html>
(2-2/3)