// ===== 授權中心 License Hub 後台前端 ===== let TOKEN = localStorage.getItem('lh_token') || ''; let META = null; const CACHE = { products: [], customers: [], plans: [], bundles: [] }; let CURRENT_TAB = 'dashboard'; // ---------- API ---------- async function api(path, opts = {}) { const headers = Object.assign({}, opts.headers); if (TOKEN) headers['Authorization'] = 'Bearer ' + TOKEN; if (opts.body !== undefined && !(opts.body instanceof FormData)) { headers['Content-Type'] = 'application/json'; opts.body = JSON.stringify(opts.body); } const res = await fetch(path, Object.assign({}, opts, { headers })); if (res.status === 401) { logout(); throw new Error('登入逾時,請重新登入'); } const data = await res.json().catch(() => null); if (!res.ok) throw new Error((data && data.detail) || ('錯誤 ' + res.status)); return data; } // ---------- 小工具 ---------- function esc(s) { return String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); } function fmtDate(iso) { return iso ? iso.slice(0, 10) : '—'; } function daysLeft(iso) { if (!iso) return null; return Math.ceil((new Date(iso) - new Date()) / 86400000); } function money(n) { return 'NT$ ' + Number(n || 0).toLocaleString(); } function toast(msg, type = 'info') { const el = document.createElement('div'); el.className = 'toast ' + type; el.textContent = msg; document.getElementById('toast-root').appendChild(el); setTimeout(() => el.remove(), 3200); } const STATUS_LABEL = { active: '生效中', suspended: '已暫停', revoked: '已撤銷', expired: '已到期' }; function statusBadge(s) { return `${STATUS_LABEL[s] || s}`; } function typeBadge(l) { const cls = l.license_type === 'trial' ? 'warn-soon' : 'type'; return `${esc(l.license_type_label)}`; } function copyText(t, msg = '已複製') { navigator.clipboard.writeText(t).then(() => toast(msg, 'success'), () => toast('複製失敗', 'error')); } function typeOptions(selected) { return META.license_types.map(t => ``).join(''); } // ---------- 登入 ---------- async function doLogin(e) { e.preventDefault(); try { const data = await api('/api/admin/login', { method: 'POST', body: { username: document.getElementById('login-user').value, password: document.getElementById('login-pass').value }, }); TOKEN = data.token; localStorage.setItem('lh_token', TOKEN); await boot(); } catch (err) { toast(err.message, 'error'); } } function logout() { if (TOKEN) fetch('/api/admin/logout', { method: 'POST', headers: { 'Authorization': 'Bearer ' + TOKEN } }).catch(() => {}); TOKEN = ''; localStorage.removeItem('lh_token'); document.getElementById('app').classList.add('hidden'); document.getElementById('login').classList.remove('hidden'); } // ---------- 啟動 ---------- async function boot() { document.getElementById('login').classList.add('hidden'); document.getElementById('app').classList.remove('hidden'); try { const health = await api('/api/health'); document.getElementById('ver').textContent = 'v' + health.version; META = await api('/api/admin/meta'); setTab(CURRENT_TAB); } catch (err) { toast(err.message, 'error'); } } // ---------- 導航 ---------- function setTab(name) { CURRENT_TAB = name; document.querySelectorAll('.nav-item').forEach(n => n.classList.toggle('active', n.dataset.tab === name)); ({ dashboard: renderDashboard, licenses: renderLicenses, customers: renderCustomers, products: renderProducts, bundles: renderBundles, plans: renderPlans, usage: renderUsage, audit: renderAudit }[name])(); } document.querySelectorAll('.nav-item').forEach(n => n.addEventListener('click', () => setTab(n.dataset.tab))); function pageHead(title, desc, actionHtml = '') { return `

${title}

${desc}
${actionHtml}
`; } const content = () => document.getElementById('content'); // ---------- Modal ---------- function openModal(html, wide = false) { document.getElementById('modal-root').innerHTML = `
${html}
`; document.getElementById('modal-root').classList.add('show'); } function closeModal() { document.getElementById('modal-root').classList.remove('show'); document.getElementById('modal-root').innerHTML = ''; } // ---------- 版本紀錄 ---------- async function openChangelog() { let d; try { d = await api('/api/changelog'); } catch (e) { toast('讀取版本紀錄失敗', 'error'); return; } const TYPE_COLOR = { '功能': 'type', '修正': 'warn-soon', '初版': 'deploy' }; const rows = (d.entries || []).map((e, i) => `
v${esc(e.version)} ${e.type ? `${esc(e.type)}` : ''} ${esc(e.title || '')} ${esc(e.date || '')}
`).join('') || '
尚無版本紀錄
'; openModal(` `, true); } // ================= 儀表板 ================= async function renderDashboard() { content().innerHTML = pageHead('儀表板', '授權與營收總覽'); const s = await api('/api/admin/stats'); CACHE.customers = await api('/api/admin/customers'); const cards = [ ['客戶數', s.customers, '🏢'], ['產品數', s.products, '📦'], ['授權總數', s.licenses_total, '🎫'], ['生效中授權', s.licenses_active, '✅'], ['試用中', s.licenses_trial || 0, '🧪'], ['已到期', s.licenses_expired || 0, '⌛'], ['啟用裝置', s.activations, '💻'], ].map(([l, v, i]) => `
${i}
${l}
${v}
`).join(''); const valueCard = `
💰
生效授權總額
${money(s.active_value)}
`; const mt = s.maintenance || {}; const fmtTs = t => t ? t.replace('T', ' ').slice(0, 16) : '尚未'; const maintPanel = `
🛠 維護狀態 到期掃描每小時;備份每日自動(保留 30 份)
排程執行緒
${mt.running ? '運作中' : '未啟動'}
上次到期掃描
${fmtTs(mt.sweep_at)}${mt.sweep_count ? ` 翻 ${mt.sweep_count} 張` : ''}
上次備份
${fmtTs(mt.backup_at)} ${esc(mt.backup_file || '')}
${mt.backup_error ? `
備份錯誤:${esc(mt.backup_error)}
` : ''}
備份目錄
${esc(mt.backup_dir || '')}
`; const typeRows = s.by_type.map(t => `
${t.label}${t.count}
`).join('') || '
尚無資料
'; const expRows = s.expiring.length ? s.expiring.map(l => { const d = daysLeft(l.expires_at); return `${esc(l.customer_name)}${esc(l.product_name)} ${fmtDate(l.expires_at)}剩 ${d} 天 `; }).join('') : '近 30 天沒有即將到期的授權 🎉'; const recentRows = s.recent.map(l => `${esc(l.license_key)}${esc(l.customer_name)} ${esc(l.product_name)}${statusBadge(l.effective_status)}`).join('') || '尚無授權'; content().innerHTML += `
${cards}${valueCard}
${maintPanel}
⏰ 即將到期(30 天內)
${expRows}
客戶產品到期日剩餘
📊 授權類型分布
${typeRows}
🆕 最近開立的授權
${recentRows}
授權碼客戶產品狀態
`; } async function runBackup() { try { const d = await api('/api/admin/maintenance/backup', { method: 'POST' }); toast('備份完成:' + d.file, 'success'); renderDashboard(); } catch (err) { toast(err.message, 'error'); } } async function runSweep() { try { const d = await api('/api/admin/maintenance/sweep', { method: 'POST' }); toast(`掃描完成,翻成到期 ${d.expired} 張`, 'success'); renderDashboard(); } catch (err) { toast(err.message, 'error'); } } // ================= 授權管理 ================= async function renderLicenses() { content().innerHTML = pageHead('授權管理', '開立、續期、暫停、撤銷授權', ''); const rows = await api('/api/admin/licenses'); const body = rows.length ? rows.map(l => { const d = daysLeft(l.expires_at); const expTxt = l.expires_at ? `${fmtDate(l.expires_at)}${d != null && d <= 30 && d >= 0 ? ` 剩${d}天` : ''}` : '永久'; const seats = l.seat_limit ? `${l.active_seats}/${l.seat_limit}` : `${l.active_seats}/∞`; return ` ${esc(l.license_key)} ${esc(l.customer_name)} ${esc(l.product_name)}
${l.modules.map(m => `${esc(m)}`).join('')}
${typeBadge(l)}${l.converted_from ? `
由${esc(l.converted_from === 'trial' ? '試用' : l.converted_from)}轉
` : ''} ${seats} ${expTxt} ${statusBadge(l.effective_status)} `; }).join('') : '還沒有任何授權,點右上角「開立授權」'; content().innerHTML += `
${body}
授權碼客戶產品 / 模組類型席次到期狀態
`; } async function openIssueModal() { CACHE.customers = await api('/api/admin/customers'); CACHE.products = await api('/api/admin/products'); CACHE.plans = await api('/api/admin/plans'); if (!CACHE.customers.length || !CACHE.products.length) { toast('請先建立客戶與產品', 'error'); return; } const custOpts = CACHE.customers.map(c => ``).join(''); const prodOpts = CACHE.products.map(p => ``).join(''); openModal(` `, true); onIssueProductChange(); onIssueTypeChange(); } function onIssueTypeChange() { const t = document.getElementById('i-type').value; const wrap = document.getElementById('i-days-wrap'); wrap.style.display = (t === 'perpetual') ? 'none' : ''; const def = (META.license_types.find(x => x.value === t) || {}).days; if (def) document.getElementById('i-days').value = def; if (t === 'trial') document.getElementById('i-price').value = 0; } function onIssueProductChange() { const pid = +document.getElementById('i-prod').value; const prod = CACHE.products.find(p => p.id === pid); document.getElementById('i-mods').innerHTML = (prod ? prod.modules : []).map(m => ``).join('') || '此產品尚未建立模組'; const plans = CACHE.plans.filter(p => p.product_id === pid); document.getElementById('i-plan').innerHTML = '' + plans.map(p => ``).join(''); } function onIssuePlanChange() { const pid = +document.getElementById('i-plan').value; if (!pid) return; const plan = CACHE.plans.find(p => p.id === pid); if (!plan) return; document.getElementById('i-type').value = plan.license_type; onIssueTypeChange(); document.getElementById('i-seats').value = plan.seat_limit; document.getElementById('i-grace').value = plan.grace_days; document.getElementById('i-price').value = plan.price; const ids = plan.modules.map(m => m.id); document.querySelectorAll('.i-mod').forEach(cb => { cb.checked = ids.includes(+cb.value); }); } async function submitIssue() { const mods = [...document.querySelectorAll('.i-mod:checked')].map(cb => +cb.value); if (!mods.length) { toast('請至少勾選一個模組', 'error'); return; } const start = document.getElementById('i-start').value; const ltype = document.getElementById('i-type').value; const typeDef = (META.license_types.find(x => x.value === ltype) || {}).days; const days = +document.getElementById('i-days').value; try { const lic = await api('/api/admin/licenses', { method: 'POST', body: { customer_id: +document.getElementById('i-cust').value, product_id: +document.getElementById('i-prod').value, plan_id: +document.getElementById('i-plan').value || null, license_type: ltype, seat_limit: +document.getElementById('i-seats').value, grace_days: +document.getElementById('i-grace').value, price: +document.getElementById('i-price').value, module_ids: mods, starts_at: start ? start + 'T00:00:00' : null, duration_days: (ltype !== 'perpetual' && days && days !== typeDef) ? days : null, note: document.getElementById('i-note').value, }, }); closeModal(); openModal(` `); toast('開立成功', 'success'); } catch (err) { toast(err.message, 'error'); } } async function openLicenseDetail(id) { const l = await api('/api/admin/licenses/' + id); const prods = await api('/api/admin/products'); const prod = prods.find(p => p.id === l.product_id); const modChecks = (prod && prod.modules.length) ? prod.modules.map(m => { const on = l.module_keys.includes(m.key); return `
✓
${esc(m.name)}
${esc(m.key)}
`; }).join('') : '此產品尚未建立模組(功能模組待系統回報)'; const acts = l.activations.length ? l.activations.map(a => `${esc(a.device_name || '—')}${esc(a.device_fingerprint.slice(0, 16))}… ${esc(a.hostname || '—')}${a.status === 'kicked' ? '已踢除' : '啟用中'} ${fmtDate(a.last_heartbeat_at)} ${a.status === 'active' ? `` : ''}`).join('') : '尚無裝置啟用'; const isSusp = l.status === 'suspended'; const canConvert = l.status !== 'revoked'; const convLabel = l.is_trial ? '⭐ 轉正式' : (l.effective_status === 'expired' ? '重新開期 / 改類型' : '改類型'); const expCell = l.expires_at ? `${fmtDate(l.expires_at)}${l.days_left != null ? ` (${l.days_left >= 0 ? `剩 ${l.days_left} 天` : `已過 ${-l.days_left} 天`})` : ''}` : '永久'; openModal(` `, true); } async function openConvertModal(id) { const l = await api('/api/admin/licenses/' + id); openModal(` `); onConvertTypeChange(); } function onConvertTypeChange() { document.getElementById('cv-days-wrap').style.display = document.getElementById('cv-type').value === 'perpetual' ? 'none' : ''; } async function submitConvert(id) { const days = +document.getElementById('cv-days').value; try { const l = await api(`/api/admin/licenses/${id}/convert`, { method: 'POST', body: { license_type: document.getElementById('cv-type').value, days: days > 0 ? days : null, price: +document.getElementById('cv-price').value, seat_limit: +document.getElementById('cv-seats').value, }}); toast(`已轉為${l.license_type_label},到期 ${l.expires_at ? fmtDate(l.expires_at) : '永久'}`, 'success'); openLicenseDetail(id); } catch (err) { toast(err.message, 'error'); } } async function openOfflineFileModal(id) { const l = await api('/api/admin/licenses/' + id); const acts = l.activations.filter(a => a.status === 'active' && !a.device_fingerprint.startsWith('user:')); const opts = acts.map(a => ``).join(''); openModal(` `); } async function downloadLicenseFile(id) { const fp = (document.getElementById('of-fp').value || '').trim(); if (fp.length < 16) { toast('請填裝置指紋(至少 16 碼)', 'error'); return; } try { const d = await api(`/api/admin/licenses/${id}/file?device=${encodeURIComponent(fp)}`); const blob = new Blob([d.token], { type: 'text/plain' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = d.filename; document.body.appendChild(a); a.click(); a.remove(); URL.revokeObjectURL(a.href); toast(d.warning || '離線授權檔已下載,只有綁定的那台裝置能用', d.warning ? 'info' : 'success'); openLicenseDetail(id); } catch (err) { toast(err.message, 'error'); } } async function saveLicenseModules(id) { const ids = [...document.querySelectorAll('#ld-mods .mod-toggle.on')].map(el => +el.dataset.id); try { await api(`/api/admin/licenses/${id}/modules`, { method: 'PUT', body: { module_ids: ids } }); toast('模組已更新(產品端下次 refresh 生效)', 'success'); openLicenseDetail(id); } catch (err) { toast(err.message, 'error'); } } async function licAction(id, action) { const names = { revoke: '撤銷', suspend: '暫停', resume: '恢復' }; if (action === 'revoke' && !confirm('確定撤銷此授權?產品下次心跳就會停用。')) return; try { await api(`/api/admin/licenses/${id}/${action}`, { method: 'POST' }); toast(names[action] + '成功', 'success'); closeModal(); renderLicenses(); } catch (err) { toast(err.message, 'error'); } } async function renewLicense(id) { try { await api(`/api/admin/licenses/${id}/renew`, { method: 'POST', body: {} }); toast('已續期一期', 'success'); closeModal(); setTab(CURRENT_TAB); } catch (err) { toast(err.message, 'error'); } } async function kickDevice(aid, lid) { if (!confirm('踢除此裝置?該裝置下次心跳會被停用,席次釋出。')) return; try { await api(`/api/admin/activations/${aid}/kick`, { method: 'POST' }); toast('已踢除', 'success'); openLicenseDetail(lid); } catch (err) { toast(err.message, 'error'); } } // ================= 客戶 ================= async function renderCustomers() { content().innerHTML = pageHead('客戶 / 租戶', '購買授權的公司。每個客戶有一把 SaaS API key,產品端帶它只能查自己的租戶', ''); const rows = await api('/api/admin/customers'); CACHE.customers = rows; const body = rows.length ? rows.map(c => ` ${esc(c.code)}${esc(c.name)}
${esc(c.contact || '')}${c.email ? ' · ' + esc(c.email) : ''}${c.phone ? ' · ' + esc(c.phone) : ''}
${c.api_key ? `${esc(c.api_key.slice(0, 12))}…${esc(c.api_key.slice(-4))} ` : '—'} ${c.license_count} `).join('') : '還沒有客戶'; content().innerHTML += `
${body}
代號 / 租戶 ID名稱SaaS API key授權數
${META.global_api_key_enabled ? '
⚠ 全域 API key(LH_API_KEY)仍啟用中,給還沒換 key 的部署過渡。全部產品端都改用租戶 key 後,在 .env 設 LH_ALLOW_GLOBAL_API_KEY=false 關掉。
' : ''}`; } async function rotateApiKey(id) { if (!confirm('重發這個租戶的 API key?舊的立刻失效,產品端設定要同步更新。')) return; try { const c = await api(`/api/admin/customers/${id}/api-key`, { method: 'POST' }); copyText(c.api_key, '新 API key 已複製到剪貼簿'); renderCustomers(); } catch (err) { toast(err.message, 'error'); } } function openCustomerModal(id) { const c = id ? CACHE.customers.find(x => x.id === id) : null; openModal(` `); } async function submitCustomer(id) { const code = document.getElementById('c-code').value.trim(); const name = document.getElementById('c-name').value.trim(); if (!code || !name) { toast('代號與名稱必填', 'error'); return; } const body = { code, name, contact: document.getElementById('c-contact').value, email: document.getElementById('c-email').value, phone: document.getElementById('c-phone').value, note: document.getElementById('c-note').value, }; try { if (id) await api('/api/admin/customers/' + id, { method: 'PUT', body }); else await api('/api/admin/customers', { method: 'POST', body }); closeModal(); renderCustomers(); toast(id ? '已更新' : '已新增客戶(API key 已自動產生)', 'success'); } catch (err) { toast(err.message, 'error'); } } async function deleteCustomer(id) { if (!confirm('刪除此客戶?')) return; try { await api('/api/admin/customers/' + id, { method: 'DELETE' }); renderCustomers(); toast('已刪除', 'success'); } catch (err) { toast(err.message, 'error'); } } // ================= 產品與模組 ================= async function renderProducts() { content().innerHTML = pageHead('產品與模組', '你要販售的專案,以及每個專案可分配的模組', ''); const rows = await api('/api/admin/products'); CACHE.products = rows; content().innerHTML += rows.length ? rows.map(p => `
📦 ${esc(p.name)} ${esc(p.key)} ${esc(p.deploy_label)} ${p.category ? `${esc(p.category)}` : ''} ${p.monthly_price ? `月 $${Number(p.monthly_price).toLocaleString()}` : ''}
${esc(p.description || '')}
${p.modules.length ? p.modules.map(m => `${esc(m.name)} ${esc(m.key)} ✕`).join('') : '功能模組待各系統回報(如智能物流的 盤點/雜收發…)'}
`).join('') : '
還沒有產品
'; } const CATEGORIES = ['平台層', '流程層', '通用層', 'T100綁定']; function openProductModal(id) { const p = id ? CACHE.products.find(x => x.id === id) : null; const deployOpts = META.deploy_types.map(d => ``).join(''); const catOpts = [''].concat(CATEGORIES.map(c => ``)).join(''); openModal(` `); } async function submitProduct(id) { const key = document.getElementById('p-key').value.trim(); const name = document.getElementById('p-name').value.trim(); if (!key || !name) { toast('代號與名稱必填', 'error'); return; } const body = { key, name, deploy_type: document.getElementById('p-deploy').value, category: document.getElementById('p-cat').value, monthly_price: +document.getElementById('p-price').value, description: document.getElementById('p-desc').value }; try { if (id) await api('/api/admin/products/' + id, { method: 'PUT', body }); else await api('/api/admin/products', { method: 'POST', body }); closeModal(); renderProducts(); toast(id ? '已更新產品' : '已新增產品', 'success'); } catch (err) { toast(err.message, 'error'); } } async function deleteProduct(id) { if (!confirm('刪除此產品及其所有模組?')) return; try { await api('/api/admin/products/' + id, { method: 'DELETE' }); renderProducts(); toast('已刪除', 'success'); } catch (err) { toast(err.message, 'error'); } } function openModuleModal(pid, mid) { const p = CACHE.products.find(x => x.id === pid); const m = mid && p ? p.modules.find(x => x.id === mid) : null; openModal(` `); } async function submitModule(pid, mid) { const key = document.getElementById('m-key').value.trim(); const name = document.getElementById('m-name').value.trim(); if (!key || !name) { toast('代號與名稱必填', 'error'); return; } try { if (mid) await api('/api/admin/modules/' + mid, { method: 'PUT', body: { name, description: document.getElementById('m-desc').value } }); else await api('/api/admin/modules', { method: 'POST', body: { product_id: pid, key, name, description: document.getElementById('m-desc').value }}); closeModal(); renderProducts(); toast(mid ? '已更新模組' : '已新增模組', 'success'); } catch (err) { toast(err.message, 'error'); } } async function deleteModule(id) { if (!confirm('刪除此模組?')) return; try { await api('/api/admin/modules/' + id, { method: 'DELETE' }); renderProducts(); toast('已刪除', 'success'); } catch (err) { toast(err.message, 'error'); } } // ================= 套組(跨產品打包)================= async function renderBundles() { content().innerHTML = pageHead('套組', '把多個系統打包賣(套組折扣)。買套組=對每個系統各發一張授權', ''); const rows = await api('/api/admin/bundles'); CACHE.bundles = rows; content().innerHTML += rows.length ? rows.map(b => `
🎁 ${esc(b.name)} ${esc(b.code)} 折 ${b.discount_pct}%
原價(月)
${money(b.list_monthly)}
套組價(月)
${money(b.bundle_monthly)}
套組價(年)
${money(b.bundle_yearly)}
含 ${b.products.length} 個系統:
${b.products.map(p => `${esc(p.name)} $${Number(p.monthly_price).toLocaleString()}`).join('')}
`).join('') : '
還沒有套組,點右上角「新增套組」把多個系統打包
'; } async function openBundleModal(id) { CACHE.products = await api('/api/admin/products'); const b = id ? (CACHE.bundles || []).find(x => x.id === id) : null; const inB = new Set(b ? b.products.map(p => p.id) : []); const checks = CACHE.products.map(p => ``).join(''); openModal(` `, true); } async function submitBundle(id) { const code = document.getElementById('b-code').value.trim(); const name = document.getElementById('b-name').value.trim(); if (!code || !name) { toast('代號與名稱必填', 'error'); return; } const ids = [...document.querySelectorAll('.b-prod:checked')].map(cb => +cb.value); if (!ids.length) { toast('請至少選一個系統', 'error'); return; } const body = { code, name, discount_pct: +document.getElementById('b-disc').value, product_ids: ids, note: document.getElementById('b-note').value }; try { if (id) await api('/api/admin/bundles/' + id, { method: 'PUT', body }); else await api('/api/admin/bundles', { method: 'POST', body }); closeModal(); renderBundles(); toast(id ? '已更新套組' : '已新增套組', 'success'); } catch (err) { toast(err.message, 'error'); } } async function openBundleIssueModal(bid) { CACHE.customers = await api('/api/admin/customers'); const b = (CACHE.bundles || []).find(x => x.id === bid); if (!CACHE.customers.length) { toast('請先建立客戶', 'error'); return; } const custOpts = CACHE.customers.map(c => ``).join(''); openModal(` `); } async function submitBundleIssue(bid) { const days = +document.getElementById('bi-days').value; try { const d = await api('/api/admin/licenses/bundle-issue', { method: 'POST', body: { customer_id: +document.getElementById('bi-cust').value, bundle_id: bid, license_type: document.getElementById('bi-type').value, duration_days: days > 0 ? days : null, seat_limit: +document.getElementById('bi-seats').value, grace_days: +document.getElementById('bi-grace').value, note: document.getElementById('bi-note').value }}); closeModal(); openModal(` `, true); } catch (err) { toast(err.message, 'error'); } } async function deleteBundle(id) { if (!confirm('刪除此套組?')) return; try { await api('/api/admin/bundles/' + id, { method: 'DELETE' }); renderBundles(); toast('已刪除', 'success'); } catch (err) { toast(err.message, 'error'); } } // ================= 方案定價 ================= async function renderPlans() { content().innerHTML = pageHead('方案定價', '把產品 + 模組組合 + 授權類型 + 席次包成可販售的方案', ''); const rows = await api('/api/admin/plans'); CACHE.plans = rows; const body = rows.length ? rows.map(p => ` ${esc(p.code)}${esc(p.name)}${esc(p.product_name)} ${typeBadge(p)} ${money(p.price)}${p.seat_limit || '∞'}
${p.modules.map(m => `${esc(m.name)}`).join('')}
`).join('') : '還沒有方案'; content().innerHTML += `
${body}
代號方案名產品類型價格席次含模組
`; } async function openPlanModal(id) { CACHE.products = await api('/api/admin/products'); if (!CACHE.products.length) { toast('請先建立產品', 'error'); return; } const pl = id ? (CACHE.plans || []).find(x => x.id === id) : null; const prodOpts = CACHE.products.map(p => ``).join(''); openModal(` `, true); onPlanProductChange(pl ? new Set(pl.modules.map(m => m.id)) : null); } function onPlanProductChange(selectedIds) { const pid = +document.getElementById('pl-prod').value; const prod = CACHE.products.find(p => p.id === pid); document.getElementById('pl-mods').innerHTML = (prod ? prod.modules : []).map(m => ``).join('') || '此產品尚未建立模組'; } async function submitPlan(id) { const code = document.getElementById('pl-code').value.trim(); const name = document.getElementById('pl-name').value.trim(); if (!code || !name) { toast('代號與名稱必填', 'error'); return; } const mods = [...document.querySelectorAll('.pl-mod:checked')].map(cb => +cb.value); const body = { code, name, product_id: +document.getElementById('pl-prod').value, license_type: document.getElementById('pl-type').value, price: +document.getElementById('pl-price').value, seat_limit: +document.getElementById('pl-seats').value, grace_days: +document.getElementById('pl-grace').value, module_ids: mods, note: document.getElementById('pl-note').value }; try { if (id) await api('/api/admin/plans/' + id, { method: 'PUT', body }); else await api('/api/admin/plans', { method: 'POST', body }); closeModal(); renderPlans(); toast(id ? '已更新方案' : '已新增方案', 'success'); } catch (err) { toast(err.message, 'error'); } } async function deletePlan(id) { if (!confirm('刪除此方案?')) return; try { await api('/api/admin/plans/' + id, { method: 'DELETE' }); renderPlans(); toast('已刪除', 'success'); } catch (err) { toast(err.message, 'error'); } } // ================= 用量監控(SaaS active-user)================= async function renderUsage() { content().innerHTML = pageHead('用量監控', 'SaaS 租戶的 active user 用量 / 上限 / 超量狀態(產品端定期回報)'); const d = await api('/api/admin/usage-monitor'); // 超量的排前面 const rows = d.licenses.slice().sort((a, b) => (b.over_limit - a.over_limit) || (b.active_users - a.active_users)); const body = rows.length ? rows.map(l => { const lim = l.seat_limit || 0; const pct = lim ? Math.round((l.active_users / lim) * 100) : 0; const barColor = l.over_limit ? 'var(--danger)' : (pct >= 80 ? 'var(--warn)' : 'var(--success)'); const usageCell = lim ? `
${l.active_users}/${lim}
` : `${l.active_users} / 不限`; const statusCell = l.over_limit ? `超量${l.grace_until ? ` 寬限到 ${fmtDate(l.grace_until)}` : ''}` : `正常`; return ` ${esc(l.customer)} ${esc(l.product)} ${typeBadge(l)} ${usageCell} ${statusCell} ${statusBadge(l.effective_status)} `; }).join('') : '尚無授權'; const usageRows = d.usage.length ? d.usage.map(u => ` ${u.at ? u.at.replace('T', ' ').slice(0, 19) : ''} ${esc(u.customer || '—')}${esc(u.product)} ${esc(u.module)} ${esc(u.source)}`).join('') : '尚無用量回報'; content().innerHTML += `
👥 租戶 active-user 用量
${body}
客戶 / 租戶產品用量(active/上限)超量狀態授權
📊 最近用量回報(usageReport,計量計費)
${usageRows}
時間客戶產品模組來源
`; } // ================= 稽核日誌 ================= async function renderAudit() { content().innerHTML = pageHead('稽核日誌', '所有後台操作與授權變更紀錄(最近 100 筆)'); const rows = await api('/api/admin/audit'); const body = rows.length ? rows.map(r => ` ${r.created_at ? r.created_at.replace('T', ' ').slice(0, 19) : ''} ${esc(r.actor)}${esc(r.action)} ${esc(r.target)}${esc(r.detail)}`).join('') : '尚無紀錄'; content().innerHTML += `
${body}
時間操作者動作對象細節
`; } // ---------- 進場 ---------- if (TOKEN) boot();