Files
onion-dmp/public/app.js
2026-04-08 14:52:09 +08:00

817 lines
28 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* DMP 前端应用
*
* 📚 交互逻辑 v2
* 1. 默认态每张卡片只显示全量人数coverage
* 2. 选第一个标签后:其他卡片实时预算"如果加上这个标签结果集有多少人"
* 3. 每次点击新增标签:卡片右上角显示相比点击前的人数/转化率变化 (±Δ)
* 4. 防抖 350ms避免快速点击频繁请求
*/
const API = '';
// ─────────────────────────────
// 全局状态
// ─────────────────────────────
const state = {
totalUsers: 0,
categories: [], // 全部分类+标签
selected: new Map(), // tagId → { tagId, name, mode, color }
lastResult: null, // 最新计算结果
prevResult: null, // 点击前的结果(用于计算 delta
computeTimer: null,
previewTimer: null,
previewAbortCtrl: null, // 用于取消过期的预览请求
phase: 'default', // 'default' | 'preview' | 'computed'
};
const CURRENT_THEME = 'onion';
// ─────────────────────────────
// 初始化
// ─────────────────────────────
async function init() {
try {
const data = await apiFetch(`/api/tags?theme=${CURRENT_THEME}`);
if (!data) return;
state.categories = data.categories;
state.totalUsers = data.totalUsers;
renderBoard(data.categories, data.totalUsers);
document.getElementById('tagBoard').classList.add('phase-default');
document.getElementById('rcTotal').textContent = fmtNum(data.totalUsers);
document.getElementById('rcRate').textContent = '—';
} catch (e) {
console.error('Init failed', e);
}
}
// ─────────────────────────────
// 渲染标签看板
// ─────────────────────────────
function renderBoard(categories, totalUsers) {
const board = document.getElementById('tagBoard');
board.innerHTML = '';
for (const cat of categories) {
const col = document.createElement('div');
col.className = 'col';
col.dataset.catKey = cat.key;
const header = document.createElement('div');
header.className = 'col-header';
header.innerHTML = `
<div class="col-header-dot" style="background:${cat.color}"></div>
<div class="col-header-name">${cat.name}</div>
<div class="col-header-count">${cat.tags.length}</div>
`;
col.appendChild(header);
for (const tag of cat.tags) {
col.appendChild(createTagCard(tag, totalUsers, cat.color));
}
board.appendChild(col);
}
document.getElementById('boardLoading').style.display = 'none';
}
function createTagCard(tag, totalUsers, catColor) {
const card = document.createElement('div');
card.className = 'tag-card';
card.dataset.tagId = tag.id;
card.dataset.tagKey = tag.key;
card.dataset.tagName = tag.name;
card.dataset.catColor = catColor;
const sourceLabel = tag.source === 'inferred' ? '推断' : '原始';
const sourceText = tag.source ? `来源:${sourceLabel}` : '';
const coverageW = totalUsers > 0 ? (tag.coverage / totalUsers * 100).toFixed(0) : 0;
card.innerHTML = `
<!-- Lift badge预览态时显示 ↑/↓ + lift值 -->
<div class="tc-lift-badge" id="tclb-${tag.id}"></div>
<!-- 标签名 + 描述(始终可见)-->
<div class="tc-head">
<div class="tc-name">${tag.name}</div>
<div class="tc-desc">${[tag.description || '', sourceText].filter(Boolean).join(' · ')}</div>
</div>
<!-- 默认态:全量覆盖数据 -->
<div class="tc-default-stats">
<div class="tc-coverage" id="tcc-${tag.id}">${fmtNum(tag.coverage)}</div>
<div class="tc-cov-rate" id="tccov-${tag.id}">${tag.coverage_rate || 0}%</div>
</div>
<!-- 预览态:交集 + 条件概率(.in-preview 时显示)-->
<div class="tc-preview-stats" id="tcps-${tag.id}">
<div class="tc-int-count" id="tcic-${tag.id}">—</div>
<div class="tc-base-mini">
<span>${fmtNum(tag.coverage)}</span>
<span class="tc-base-sep"> / </span>
<span>${tag.coverage_rate || 0}%</span>
</div>
<div class="tc-cond-rate" id="tccr-${tag.id}">—</div>
</div>
<!-- 进度条 -->
<div class="tc-progress">
<div class="tc-progress-fill" id="tcp-${tag.id}"
style="width:${coverageW}%; background:${catColor}88;"></div>
</div>
`;
card.addEventListener('click', (e) => {
e.preventDefault();
handleTagClick(tag, catColor, 'include');
});
card.addEventListener('contextmenu', (e) => {
e.preventDefault();
showContextMenu(e, tag, catColor);
});
return card;
}
// ─────────────────────────────
// 标签点击主入口
// ─────────────────────────────
async function handleTagClick(tag, color, mode) {
// 1. 记录点击前的结果
state.prevResult = state.lastResult ? { ...state.lastResult } : null;
// 2. 更新选择状态
const existing = state.selected.get(tag.id);
if (existing) {
if (existing.mode === mode) {
state.selected.delete(tag.id);
} else {
existing.mode = mode;
}
} else {
state.selected.set(tag.id, { tagId: tag.id, name: tag.name, mode, color });
}
// 移除/添加默认模式类名
document.getElementById('tagBoard').classList.toggle('phase-default', state.selected.size === 0);
updateCardState(tag.id);
updateSelectedBar();
if (state.selected.size === 0) {
resetToDefault();
return;
}
// 3. 触发计算 + 实时预览
scheduledCompute();
}
function toggleTag(tag, color, mode) {
handleTagClick(tag, color, mode);
}
function removeTag(tagId) {
state.prevResult = state.lastResult ? { ...state.lastResult } : null;
state.selected.delete(tagId);
updateCardState(tagId);
updateSelectedBar();
if (state.selected.size === 0) {
resetToDefault();
} else {
scheduledCompute();
}
}
function updateCardState(tagId) {
const card = document.querySelector(`.tag-card[data-tag-id="${tagId}"]`);
if (!card) return;
const sel = state.selected.get(tagId);
card.classList.remove('selected-include', 'selected-exclude');
if (sel) card.classList.add(`selected-${sel.mode}`);
}
// ─────────────────────────────
// Selected Bar
// ─────────────────────────────
function updateSelectedBar() {
const bar = document.getElementById('selectedBar');
const tagsEl = document.getElementById('selTags');
const computeBtn = document.getElementById('btnCompute');
const sampleBtn = document.getElementById('btnSample');
if (state.selected.size === 0) {
bar.style.display = 'none';
computeBtn.disabled = true;
sampleBtn.style.display = 'none';
return;
}
bar.style.display = 'flex';
computeBtn.disabled = false;
tagsEl.innerHTML = '';
for (const [id, tag] of state.selected) {
const el = document.createElement('span');
el.className = `sel-tag ${tag.mode}`;
const modeLabel = tag.mode === 'exclude' ? ' ✕' : '';
el.innerHTML = `${tag.name}${modeLabel} <span class="remove">×</span>`;
el.addEventListener('click', () => removeTag(id));
tagsEl.appendChild(el);
}
}
// ─────────────────────────────
// 计算(主结果)
// ─────────────────────────────
function scheduledCompute() {
clearTimeout(state.computeTimer);
if (state.selected.size === 0) return;
state.computeTimer = setTimeout(() => compute(), 350);
}
async function compute() {
if (state.selected.size === 0) return;
const payload = {
selected: [...state.selected.values()].map(s => ({ tagId: s.tagId, mode: s.mode }))
};
showOverlay(true);
const result = await apiFetch(`/api/compute?theme=${CURRENT_THEME}`, {
method: 'POST',
body: JSON.stringify(payload)
});
showOverlay(false);
if (!result) return;
// 更新主结果,同时保留 prevResult 用于 delta 展示
const prev = state.prevResult;
state.lastResult = result;
state.phase = 'computed';
// 顶部计数器
const numEl = document.getElementById('rcNum');
numEl.textContent = fmtNum(result.count);
numEl.classList.remove('num-pop');
void numEl.offsetWidth;
numEl.classList.add('num-pop');
document.getElementById('rcRate').textContent = result.rate;
document.getElementById('rcTotal').textContent = fmtNum(result.totalUsers);
document.getElementById('resultCounter').classList.add('has-result');
// delta 信息显示在顶部
updateDeltaDisplay(prev, result);
// 把 breakdown 回填到各卡片,并附上 delta
applyBreakdownWithDelta(result, prev);
// 预览所有未选择的卡片("如果加上这个标签,结果有多少"
schedulePreviewAll();
document.getElementById('btnSample').style.display = '';
}
// ─────────────────────────────
// Delta 展示(顶部计数器下方)
// ─────────────────────────────
function updateDeltaDisplay(prev, current) {
const el = document.getElementById('rcDelta');
if (!el) return;
if (!prev || !current) {
el.innerHTML = '';
el.style.display = 'none';
return;
}
const deltaCount = current.count - prev.count;
const deltaRate = (current.rate - prev.rate).toFixed(2);
const sign = deltaCount >= 0 ? '+' : '';
const rateSign = deltaRate >= 0 ? '+' : '';
const cls = deltaCount >= 0 ? 'delta-up' : 'delta-down';
el.innerHTML = `
<span class="delta-label">较上次:</span>
<span class="${cls}">${sign}${fmtNum(Math.abs(deltaCount))}人</span>
<span class="delta-sep">·</span>
<span class="${cls}">${rateSign}${deltaRate}%</span>
`;
el.style.display = 'flex';
}
// ─────────────────────────────
// Breakdown 回填(已选标签:移除预览态,更新进度条)
// ─────────────────────────────
function applyBreakdownWithDelta(result, prev) {
const selectedIds = new Set([...state.selected.keys()]);
// 已选卡片:退出预览模式,清 lift badge
for (const tagId of selectedIds) {
const card = document.querySelector(`.tag-card[data-tag-id="${tagId}"]`);
if (card) card.classList.remove('in-preview');
const lb = document.getElementById(`tclb-${tagId}`);
if (lb) { lb.className = 'tc-lift-badge'; lb.textContent = ''; }
}
// 更新已选标签的进度条(相对结果集比例)及实际命中数据
for (const bd of (result.breakdown || [])) {
const progEl = document.getElementById(`tcp-${bd.tagId}`);
if (progEl) progEl.style.width = bd.rate + '%';
// 把结果写到原本默认态的位置上
const covEl = document.getElementById(`tcc-${bd.tagId}`);
const rateEl = document.getElementById(`tccov-${bd.tagId}`);
if (covEl) covEl.textContent = fmtNum(bd.count);
if (rateEl) rateEl.textContent = bd.rate + '%';
}
}
// ─────────────────────────────
// 预览:未选标签"加上后有多少人"
// ─────────────────────────────
function schedulePreviewAll() {
clearTimeout(state.previewTimer);
state.previewTimer = setTimeout(() => previewAll(), 200);
}
async function previewAll() {
if (state.selected.size === 0) return;
// 取消上一批预览
if (state.previewAbortCtrl) state.previewAbortCtrl.abort();
state.previewAbortCtrl = new AbortController();
const signal = state.previewAbortCtrl.signal;
const currentResult = state.lastResult;
// 收集所有未选的 include 标签
const unselectedTags = [];
for (const cat of state.categories) {
for (const tag of cat.tags) {
if (!state.selected.has(tag.id)) {
unselectedTags.push({ tag, color: cat.color });
}
}
}
if (unselectedTags.length === 0) return;
// 批量计算:每个未选标签 + 当前已选 → 预估人数
const crossTagIds = unselectedTags.map(t => t.tag.id);
const currentSelected = [...state.selected.values()].map(s => ({ tagId: s.tagId, mode: s.mode }));
try {
const crossResult = await apiFetch(`/api/compute/cross?theme=${CURRENT_THEME}`, {
method: 'POST',
body: JSON.stringify({
selected: currentSelected,
crossTagIds
}),
signal
});
if (!crossResult || signal.aborted) return;
const { matrix } = crossResult;
const matrixMap = new Map(matrix.map(m => [m.tagId, m]));
for (const { tag } of unselectedTags) {
if (signal.aborted) break;
const m = matrixMap.get(tag.id);
if (!m) continue;
const tagInfo = findTag(tag.id);
const baseRate = tagInfo ? (tagInfo.coverage_rate || 0) : 0;
// 交集人数
const intersectionCount = m.count;
// 条件概率 P(B|A) = 交集 / 当前选中人数
const selectedCount = currentResult ? currentResult.count : 1;
const conditionalRate = selectedCount > 0
? +(intersectionCount / selectedCount * 100).toFixed(2)
: 0;
// Lift = P(B|A) / P(B)
const lift = baseRate > 0 ? +(conditionalRate / baseRate).toFixed(2) : 1;
const isUp = lift >= 1;
// ① 更新 lift badge
const liftEl = document.getElementById(`tclb-${tag.id}`);
if (liftEl) {
liftEl.className = `tc-lift-badge ${isUp ? 'lift-up' : 'lift-down'}`;
liftEl.textContent = `${isUp ? '↑' : '↓'}${lift.toFixed(2)}`;
}
// ② 更新交集人数(大字)
const intCountEl = document.getElementById(`tcic-${tag.id}`);
if (intCountEl) {
intCountEl.textContent = fmtNum(intersectionCount);
intCountEl.className = `tc-int-count ${isUp ? 'int-up' : 'int-down'}`;
}
// ③ 更新条件概率(彩色小字)
const condRateEl = document.getElementById(`tccr-${tag.id}`);
if (condRateEl) {
condRateEl.textContent = conditionalRate.toFixed(2) + '%';
condRateEl.className = `tc-cond-rate ${isUp ? 'cond-up' : 'cond-down'}`;
}
// ④ 切换卡片到预览模式
const cardEl = document.querySelector(`.tag-card[data-tag-id="${tag.id}"]`);
if (cardEl) cardEl.classList.add('in-preview');
}
} catch (e) {
if (e.name !== 'AbortError') console.error('preview error', e);
}
}
// ─────────────────────────────
// 重置到默认态
// ─────────────────────────────
function resetToDefault() {
document.getElementById('tagBoard').classList.add('phase-default');
state.lastResult = null;
state.prevResult = null;
state.phase = 'default';
document.getElementById('rcNum').textContent = '—';
document.getElementById('rcRate').textContent = '—';
document.getElementById('rcTotal').textContent = fmtNum(state.totalUsers);
document.getElementById('resultCounter').classList.remove('has-result');
const deltaEl = document.getElementById('rcDelta');
if (deltaEl) { deltaEl.innerHTML = ''; deltaEl.style.display = 'none'; }
// 移除所有卡片预览态 + 清 lift badge
document.querySelectorAll('.tag-card.in-preview').forEach(c => c.classList.remove('in-preview'));
document.querySelectorAll('.tc-lift-badge').forEach(el => {
el.className = 'tc-lift-badge';
el.textContent = '';
});
// 恢复进度条和默认数据到全量比例
for (const cat of state.categories) {
for (const tag of cat.tags) {
const progEl = document.getElementById(`tcp-${tag.id}`);
if (progEl) {
const w = state.totalUsers > 0 ? (tag.coverage / state.totalUsers * 100) : 0;
progEl.style.width = w.toFixed(0) + '%';
}
const covEl = document.getElementById(`tcc-${tag.id}`);
const rateEl = document.getElementById(`tccov-${tag.id}`);
if (covEl) covEl.textContent = fmtNum(tag.coverage);
if (rateEl) rateEl.textContent = (tag.coverage_rate || 0) + '%';
}
}
}
// ─────────────────────────────
// clearResults只在 resetAll 时调用)
// ─────────────────────────────
function clearResults() {
resetToDefault();
}
// ─────────────────────────────
// Reset All
// ─────────────────────────────
function resetAll() {
const ids = [...state.selected.keys()];
state.selected.clear();
ids.forEach(id => updateCardState(id));
updateSelectedBar();
clearResults();
closePanel();
}
// ─────────────────────────────
// Context Menu
// ─────────────────────────────
let ctxMenu = null;
function showContextMenu(e, tag, color) {
removeContextMenu();
const menu = document.createElement('div');
menu.className = 'context-menu';
menu.style.cssText = `display:block; left:${e.clientX}px; top:${e.clientY}px`;
menu.innerHTML = `
<div class="cm-item" onclick="handleCtx('include', ${tag.id})">
<span>✅</span> 包含此标签
</div>
<div class="cm-item danger" onclick="handleCtx('exclude', ${tag.id})">
<span>🚫</span> 排除此标签
</div>
<div class="cm-item" onclick="handleCtx('remove', ${tag.id})">
<span>✕</span> 移除条件
</div>
`;
document.body.appendChild(menu);
ctxMenu = { el: menu, tag, color };
setTimeout(() => document.addEventListener('click', removeContextMenu, { once: true }), 0);
}
function handleCtx(action, tagId) {
if (!ctxMenu) return;
const { tag, color } = ctxMenu;
if (action === 'remove') removeTag(tagId);
else toggleTag(tag, color, action);
removeContextMenu();
}
function removeContextMenu() {
if (ctxMenu) { ctxMenu.el.remove(); ctxMenu = null; }
}
// ─────────────────────────────
// Right Panel
// ─────────────────────────────
function closePanel() {
document.getElementById('rightPanel').style.display = 'none';
}
async function loadSample(body) {
if (state.selected.size === 0) return;
body.innerHTML = '<div style="display:flex;gap:8px;align-items:center;color:var(--text2);padding:8px"><div class="spinner"></div>加载中...</div>';
const result = await apiFetch(`/api/users/sample?theme=${CURRENT_THEME}`, {
method: 'POST',
body: JSON.stringify({
selected: [...state.selected.values()].map(s => ({ tagId: s.tagId, mode: s.mode })),
limit: 50
})
});
if (!result || result.users.length === 0) {
body.innerHTML = '<div style="color:var(--text2);padding:12px;text-align:center">暂无用户数据</div>';
return;
}
const note = state.lastResult ? `
<div style="font-size:11px;color:var(--text2);padding:0 0 10px 0">
共 <strong style="color:var(--text0)">${fmtNum(state.lastResult.count)}</strong> 人
(${state.lastResult.rate}%),展示前 ${result.users.length}
</div>
` : '';
body.innerHTML = note + `
<table class="sample-table">
<thead>
<tr>
<th>UID</th>
<th>Name</th>
<th>Email</th>
</tr>
</thead>
<tbody>
${result.users.map(u => `
<tr>
<td>${u.uid}</td>
<td style="font-family:var(--font);color:var(--text1)">${u.name || '-'}</td>
<td style="color:var(--text2)">${u.email || '-'}</td>
</tr>
`).join('')}
</tbody>
</table>
`;
}
// ─────────────────────────────
// Duration Stats
// ─────────────────────────────
async function loadDurationStats(body) {
body.innerHTML = '<div style="display:flex;gap:8px;align-items:center;color:var(--text2);padding:8px"><div class="spinner"></div>加载中...</div>';
const result = await apiFetch(`/api/duration-stats?theme=${CURRENT_THEME}`);
if (!result) {
body.innerHTML = '<div style="color:var(--text2);padding:12px;text-align:center">加载失败</div>';
return;
}
const { totalUsers, durationBreakdown } = result;
let html = `
<div style="padding:12px;border-bottom:1px solid var(--border);background:var(--bg3);border-radius:4px 4px 0 0;">
<div style="font-size:11px;color:var(--text2);margin-bottom:4px">总参与人数</div>
<div style="font-size:22px;font-weight:700;color:var(--text0)">${fmtNum(totalUsers)}</div>
</div>
<div style="padding:12px">
<div style="font-size:12px;font-weight:600;color:var(--text0);margin-bottom:8px">指导周期分布</div>
`;
for (const duration of durationBreakdown) {
const pct = duration.count > 0 ? (duration.count / totalUsers * 100).toFixed(1) : 0;
html += `
<div style="margin-bottom:12px;padding:10px;background:var(--bg3);border-radius:4px;border-left:3px solid var(--acc)">
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<span style="font-weight:600;color:var(--text0)">${duration.name}</span>
<span style="color:var(--acc);font-weight:700">${fmtNum(duration.count)}</span>
</div>
<div style="display:flex;align-items:center;gap:8px;font-size:11px">
<div style="flex:1;height:6px;background:var(--bg4);border-radius:3px;overflow:hidden">
<div style="height:100%;width:${pct}%;background:var(--acc);transition:width 0.3s ease"></div>
</div>
<span style="color:var(--text2);min-width:40px">${pct}%</span>
</div>
</div>
`;
}
html += `
</div>
<div style="padding:12px;font-size:11px;color:var(--text2);background:var(--bg3);border-top:1px solid var(--border)">
<strong>📌 说明:</strong><br>
• 60天课程短期集中指导<br>
• 180天课程深度长期指导<br>
共计 ${durationBreakdown.reduce((sum, d) => sum + d.count, 0)} 人参与
</div>
`;
body.innerHTML = html;
}
// ─────────────────────────────
// Import Modal
// ─────────────────────────────
function showImportModal() {
document.getElementById('importModal').style.display = 'flex';
document.getElementById('importModalBody').innerHTML = renderImportDocs();
}
function renderImportDocs() {
return `
<div style="margin-bottom:8px;font-size:12px;color:var(--text2);line-height:1.7">
以下是所有数据接入接口。支持定时任务cron、ETL 管道直接调用。
</div>
<div class="api-block">
<div class="api-block-header">
<span class="api-method POST">POST</span>
<span class="api-path">/api/import/users</span>
<span class="api-desc">批量导入/更新用户基础信息</span>
</div>
<div class="api-body">
<pre>{
"source": "crm_sync",
"users": [
{ "uid": "u_001", "name": "Alice", "email": "alice@example.com" },
{ "uid": "u_002", "name": "Bob", "email": "bob@example.com",
"extra_json": { "plan": "pro", "country": "US" } }
]
}</pre>
<div class="api-note">
✅ <strong>Upsert</strong>:已存在的 uid 会更新,新 uid 会插入<br>
✅ <strong>批量提交</strong>:每 1000 条一个事务,避免锁超时<br>
✅ 返回:<code>{ batchId, imported, total }</code>
</div>
</div>
</div>
<div class="api-block">
<div class="api-block-header">
<span class="api-method POST">POST</span>
<span class="api-path">/api/import/user-tags</span>
<span class="api-desc">批量建立用户↔标签关联</span>
</div>
<div class="api-body">
<pre>{
"source": "ml_model_v2",
"mode": "replace",
"assignments": [
{ "uid": "u_001", "tagKey": "sub_plus" },
{ "uid": "u_001", "tagKey": "uc_coding" }
]
}</pre>
<div class="api-note">
✅ <strong>mode=replace</strong>:先删除该用户全部旧标签<br>
✅ <strong>mode=append</strong>:仅追加,适合增量更新<br>
✅ 自动重新计算所有标签覆盖率
</div>
</div>
</div>
<div class="api-block">
<div class="api-block-header">
<span class="api-method GET">GET</span>
<span class="api-path">/api/import/batches</span>
<span class="api-desc">查看导入历史记录</span>
</div>
<div class="api-body">
<div class="api-note">返回最近 50 条导入批次,含状态、记录数、耗时。</div>
</div>
</div>
<div style="font-size:11px;color:var(--text2);margin-top:16px;padding:12px;background:var(--bg1);border-radius:6px;line-height:1.8">
<strong>💡 推荐接入方式:</strong><br>
• <strong>定期全量</strong>:每日凌晨 cron调用 import/users + import/user-tags(mode=replace)<br>
• <strong>实时增量</strong>用户行为事件触发append 模式追加新标签<br>
• <strong>ML 模型输出</strong>:预测模型每周跑一次,批量写入倾向标签
</div>
`;
}
function closeModal(id) {
document.getElementById(id).style.display = 'none';
}
// ─────────────────────────────
// Overlay
// ─────────────────────────────
let overlayTimer;
function showOverlay(show) {
clearTimeout(overlayTimer);
const el = document.getElementById('computeOverlay');
if (show) {
// 延迟 300ms 显示,避免极速响应时的屏幕闪烁
overlayTimer = setTimeout(() => { el.style.display = 'flex'; }, 300);
} else {
el.style.display = 'none';
}
}
// ─────────────────────────────
// Utilities
// ─────────────────────────────
function fmtNum(n) {
if (n === null || n === undefined) return '—';
if (n >= 10000) return (n / 10000).toFixed(1) + 'w';
return n.toLocaleString('zh-CN');
}
async function apiFetch(url, opts = {}) {
try {
const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!r.ok) throw new Error(`HTTP ${r.status}`);
return r.json();
} catch (e) {
if (e.name !== 'AbortError') console.error(url, e);
return null;
}
}
function findTag(id) {
for (const cat of state.categories) {
const t = cat.tags.find(t => t.id === id);
if (t) return { ...t, _color: cat.color };
}
return null;
}
function findTagById(id) { return findTag(id); }
// ─────────────────────────────
// showPanel (global)
// ─────────────────────────────
window.showPanel = function(type) {
if (type === 'import') {
showImportModal();
return;
}
const panel = document.getElementById('rightPanel');
const title = document.getElementById('rpTitle');
const body = document.getElementById('rpBody');
panel.style.display = 'flex';
if (type === 'sample') {
title.textContent = '👥 用户样本';
loadSample(body);
} else if (type === 'duration') {
title.textContent = '📊 指导周期分析';
loadDurationStats(body);
}
};
// ─────────────────────────────
// Keyboard shortcuts
// ─────────────────────────────
document.addEventListener('keydown', e => {
if (e.key === 'Escape') {
closeModal('importModal');
closePanel();
removeContextMenu();
}
if (e.key === 'Enter' && state.selected.size > 0 && !e.target.matches('input,textarea')) {
compute();
}
if (e.key === 'r' && !e.target.matches('input,textarea')) resetAll();
});
document.getElementById('importModal')?.addEventListener('click', e => {
if (e.target.id === 'importModal') closeModal('importModal');
});
document.getElementById('computeOverlay')?.addEventListener('click', () => showOverlay(false));
// Boot
init();