Update README and project cleanup

This commit is contained in:
inkling
2026-04-08 14:52:09 +08:00
commit fafd267288
71 changed files with 14865 additions and 0 deletions

816
public/app.js Normal file
View File

@@ -0,0 +1,816 @@
/**
* 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();