// ===== 授權中心 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 `
`;
}
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 || '')}
${(e.changes || []).map(c => `- ${esc(c)}
`).join('')}
`).join('') || '尚無版本紀錄
';
openModal(`
📋 版本紀錄
目前版本 v${esc(d.current || '')}
${rows}
`, 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]) => ``).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}
`;
}
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(`
✅ 授權已開立
把這組授權碼交給客戶,在產品啟用時輸入:
${esc(lic.license_key)}
${lic.is_trial ? `
試用 ${lic.days_left + 1} 天,到期自動鎖;要轉正式到「授權詳情 → 轉正式」。
` : ''}
`);
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(`
授權詳情
${esc(l.license_key)}
| 客戶 | ${esc(l.customer_name)} | 產品 | ${esc(l.product_name)} |
| 類型 | ${typeBadge(l)}${l.converted_from ? ` 由${esc(l.converted_from === 'trial' ? '試用' : l.converted_from)}轉於 ${fmtDate(l.converted_at)}` : ''} | 狀態 | ${statusBadge(l.effective_status)} |
| 席次 | ${l.active_seats}/${l.seat_limit || '∞'} | 寬限 | ${l.grace_days} 天 |
| 起始 | ${fmtDate(l.starts_at)} | 到期 | ${expCell} |
${l.price ? `| 價格 | ${money(l.price)} |
` : ''}
${l.note ? `| 備註 | ${esc(l.note)} |
` : ''}
📦 授權模組(在這裡勾選,直接控制租戶能用哪些)
勾選=給這個租戶。改完按儲存,產品端下次 refresh 就生效。
${modChecks}
`, true);
}
async function openConvertModal(id) {
const l = await api('/api/admin/licenses/' + id);
openModal(`
${l.is_trial ? '試用轉正式' : '改授權類型 / 重新開期'}
授權碼 ${esc(l.license_key)} 不變,客戶端不用重新啟用;從「現在」起算新的一期,產品端下次 refresh 就拿到新到期日。
`);
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(`
⬇ 離線授權檔
給沒外網的地端客戶:本機驗章、不需連線。離線檔中心收不回來,所以必須綁定一台裝置;到期前只有這台能用。
${l.license_type === 'perpetual' ? '
買斷 + 離線 = 永遠無法遠端撤銷,請確認
' : ''}
${acts.length ? `
` : ''}
`);
}
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 += `
| 代號 / 租戶 ID | 名稱 | SaaS API key | 授權數 | |
${body}
${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(`
${c ? '編輯客戶' : '新增客戶'}
`);
}
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(`
${p ? '編輯產品' : '新增產品'}
`);
}
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(`
${m ? '編輯模組' : '新增模組'}
`);
}
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(`
${b ? '編輯套組' : '新增套組'}
`, 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(`
⚡ 套組一鍵發授權:${esc(b.name)}
會對套組內 ${b.products.length} 個系統各開一張授權(該系統全模組)。價格=系統月費 × 期數 × (1 − ${b.discount_pct}%);試用 / 買斷填 0。
`);
}
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(`
✅ 已發放 ${d.count} 張授權
| 產品 | 授權碼 | 類型 | 到期 | 價格 |
${d.licenses.map(l => `| ${esc(l.product_name)} | ${esc(l.license_key)} | ${typeBadge(l)} | ${l.expires_at ? fmtDate(l.expires_at) : '永久'} | ${money(l.price)} |
`).join('')}
`, 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 += ``;
}
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(`
${pl ? '編輯方案' : '新增方案'}
`, 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} / 不限`;
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 用量
| 客戶 / 租戶 | 產品 | 用量(active/上限) | 超量狀態 | 授權 |
${body}
📊 最近用量回報(usageReport,計量計費)
`;
}
// ================= 稽核日誌 =================
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 += ``;
}
// ---------- 進場 ----------
if (TOKEN) boot();