817 lines
28 KiB
JavaScript
817 lines
28 KiB
JavaScript
/**
|
||
* 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();
|