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();

123
public/index.html Normal file
View File

@@ -0,0 +1,123 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>洋葱客户大数据标签系统</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
<link rel="stylesheet" href="style.css">
</head>
<body>
<!-- Top Bar -->
<header class="topbar">
<div class="topbar-left">
<div class="brand">
<div class="brand-icon">
<!-- Simplified Onion Icon -->
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path stroke-linecap="round" stroke-linejoin="round" d="M12 2C6.48 2 2 6.48 2 12C2 17.52 6.48 22 12 22C17.52 22 22 17.52 22 12C22 6.48 17.52 2 12 2Z" />
<path stroke-linecap="round" stroke-linejoin="round" d="M12 6C8.69 6 6 8.69 6 12C6 15.31 8.69 18 12 18C15.31 18 18 15.31 18 12C18 8.69 15.31 6 12 6Z" />
</svg>
</div>
<div class="brand-text">
<span class="brand-name">洋葱客户大数据标签系统</span>
<span class="brand-sub">AMBER ONION DATA INTELLIGENCE</span>
</div>
</div>
</div>
<div class="topbar-center">
<!-- 原 result-counter 已移至底部 -->
</div>
<div class="topbar-right">
<button class="action-btn primary" id="btnCompute" onclick="compute()" disabled style="display:none">
<span class="btn-icon"></span> 实时计算
</button>
<button class="action-btn ghost" onclick="resetAll()">重置</button>
<button class="action-btn ghost" onclick="showPanel('sample')" id="btnSample" style="display:none">查看用户样本</button>
<button class="action-btn ghost" onclick="showPanel('duration')" id="btnDuration">指导周期分析</button>
<button class="action-btn ghost" onclick="showPanel('import')">导入数据</button>
</div>
</header>
<!-- Selected Tags Bar -->
<div class="selected-bar" id="selectedBar" style="display:none">
<span class="sel-label">已选条件:</span>
<div class="sel-tags" id="selTags"></div>
<span class="sel-logic" style="color:#666; font-size:12px; margin-left:12px;">
<span style="color:#999;">(同分类: OR | 不同分类: AND</span>
</span>
<button class="sel-clear" onclick="resetAll()">✕ 清空</button>
</div>
<!-- Main Layout -->
<div class="main-layout">
<!-- Tag Board -->
<div class="board" id="tagBoard">
<!-- Columns rendered by JS -->
<div class="board-loading" id="boardLoading">
<div class="spinner"></div>
<span>加载标签体系...</span>
</div>
</div>
<!-- Right Panel (collapsible) -->
<div class="right-panel" id="rightPanel" style="display:none">
<div class="rp-header">
<span class="rp-title" id="rpTitle">用户样本</span>
<button class="rp-close" onclick="closePanel()"></button>
</div>
<div class="rp-body" id="rpBody"></div>
</div>
</div>
<!-- Compute result overlay on cards -->
<div class="compute-overlay" id="computeOverlay" style="display:none">
<div class="co-inner">
<div class="spinner-lg"></div>
<span>计算中...</span>
</div>
</div>
<!-- Import Panel Modal -->
<div class="modal-mask" id="importModal" style="display:none">
<div class="modal">
<div class="modal-header">
<span>📡 数据导入接口</span>
<button onclick="closeModal('importModal')"></button>
</div>
<div class="modal-body" id="importModalBody"></div>
</div>
</div>
<!-- Bottom Bar (Footer) -->
<div class="bottom-bar">
<div class="result-counter" id="resultCounter">
<!-- 左:当前规模 -->
<div class="rc-metric">
<div class="rc-metric-label">当前规模</div>
<div class="rc-main">
<span class="rc-num" id="rcNum"></span>
<span class="rc-unit"></span>
</div>
<div class="rc-sub">/ 共 <span id="rcTotal"></span></div>
</div>
<!-- 分隔线 -->
<div class="rc-divider"></div>
<!-- 右:预估转化 -->
<div class="rc-metric">
<div class="rc-metric-label">预估转化</div>
<div class="rc-main">
<span class="rc-num rc-num-rate" id="rcRate"></span>
<span class="rc-unit">%</span>
</div>
<div class="rc-delta" id="rcDelta" style="display:none"></div>
</div>
</div>
</div>
<script src="app.js"></script>
</body>
</html>

830
public/style.css Normal file
View File

@@ -0,0 +1,830 @@
/* ═══════════════════════════════════════════════
DMP · 客户大数据标签系统
Design: Dark, data-dense, reference-image style
═══════════════════════════════════════════════ */
:root {
--bg0: #0d0d17;
--bg1: #111120;
--bg2: #161628;
--bg3: #1e1e36;
--bg4: #252542;
--border: rgba(255,255,255,0.07);
--border2: rgba(255,255,255,0.12);
--text0: #f0f0ff;
--text1: #b0b0d0;
--text2: #6868a0;
--text3: #444460;
--acc: #6366f1;
--acc2: #8b5cf6;
--grn: #22c55e;
--red: #ef4444;
--ylw: #f59e0b;
--col-w: 206px;
--row-h: 88px;
--radius: 6px;
--topbar-h: 56px;
--selbar-h: 40px;
--font: 'Inter', sans-serif;
--mono: 'JetBrains Mono', monospace;
}
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html, body {
height: 100%;
background: var(--bg0);
color: var(--text0);
font-family: var(--font);
font-size: 13px;
line-height: 1.5;
overflow: hidden;
-webkit-font-smoothing: antialiased;
display: flex;
flex-direction: column;
}
/* ── Scrollbar ── */
::-webkit-scrollbar { width: 5px; height: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--bg4); border-radius: 99px; }
/* ════════════════════════════════
TOP BAR
════════════════════════════════ */
.topbar {
height: var(--topbar-h);
background: var(--bg1);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 16px;
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: blur(12px);
}
.topbar-left, .topbar-right {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.topbar-center {
flex: 1;
display: flex;
justify-content: center;
}
/* Brand */
.brand { display: flex; align-items: center; gap: 10px; }
.brand-icon {
font-size: 22px;
background: linear-gradient(135deg, var(--acc), var(--acc2));
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.brand-text { display: flex; flex-direction: column; }
.brand-name { font-size: 15px; font-weight: 700; letter-spacing: -0.3px; }
.brand-sub { font-size: 9px; color: var(--text2); letter-spacing: 2px; font-weight: 500; margin-top: -1px; }
/* Result Counter — two metric blocks side by side */
.result-counter {
display: flex;
align-items: stretch;
gap: 0;
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 10px;
overflow: hidden;
transition: border-color 0.2s, box-shadow 0.2s;
}
.result-counter.has-result {
border-color: rgba(99,102,241,0.4);
box-shadow: 0 0 24px rgba(99,102,241,0.10);
}
/* Each metric block */
.rc-metric {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 8px 28px;
gap: 1px;
min-width: 148px;
}
.rc-metric-label {
font-size: 9px;
font-weight: 700;
letter-spacing: 1.4px;
text-transform: uppercase;
color: var(--text3);
margin-bottom: 2px;
}
/* Vertical divider */
.rc-divider {
width: 1px;
background: var(--border);
margin: 10px 0;
flex-shrink: 0;
}
.rc-main { display: flex; align-items: baseline; gap: 3px; }
.rc-num {
font-size: 24px;
font-weight: 800;
font-family: var(--mono);
background: linear-gradient(135deg, #c7d2fe, #a5b4fc);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
letter-spacing: -1px;
transition: all 0.3s;
}
/* Rate number uses warm amber gradient to distinguish */
.rc-num-rate {
background: linear-gradient(135deg, #fde68a, #f97316);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.rc-unit { font-size: 12px; color: var(--text2); }
.rc-sub { font-size: 10px; color: var(--text3); font-family: var(--mono); margin-top: 1px; }
/* Delta row (inside 预估转化 block) */
.rc-delta {
display: flex;
align-items: center;
font-family: var(--mono);
margin-top: 1px;
}
.delta-label { color: var(--text3); }
.delta-sep { color: var(--text3); }
.delta-up { color: var(--grn); font-weight: 700; }
.delta-down { color: var(--red); font-weight: 700; }
/* Logic Toggle */
.logic-group { display: flex; background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
.logic-btn {
padding: 5px 14px;
background: transparent;
border: none;
color: var(--text2);
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: var(--font);
transition: all 0.15s;
}
.logic-btn.active { background: var(--acc); color: white; }
.logic-btn:not(.active):hover { color: var(--text0); }
/* Action Buttons */
.action-btn {
height: 32px;
padding: 0 14px;
border-radius: 6px;
border: none;
font-size: 12px;
font-weight: 600;
cursor: pointer;
font-family: var(--font);
display: flex;
align-items: center;
gap: 5px;
transition: all 0.15s;
white-space: nowrap;
}
.action-btn.primary {
background: linear-gradient(135deg, var(--acc), var(--acc2));
color: white;
}
.action-btn.primary:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 16px rgba(99,102,241,0.4);
}
.action-btn.primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.action-btn.ghost {
background: var(--bg3);
color: var(--text1);
border: 1px solid var(--border);
}
.action-btn.ghost:hover { border-color: var(--border2); color: var(--text0); }
.btn-icon { font-size: 14px; }
/* ════════════════════════════════
SELECTED BAR
════════════════════════════════ */
.selected-bar {
height: var(--selbar-h);
background: var(--bg1);
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
padding: 0 20px;
gap: 10px;
overflow-x: auto;
flex-shrink: 0;
}
.sel-label { font-size: 11px; color: var(--text2); font-weight: 600; letter-spacing: 0.5px; flex-shrink: 0; }
.sel-tags { display: flex; gap: 6px; flex-wrap: nowrap; }
.sel-tag {
display: inline-flex;
align-items: center;
gap: 5px;
height: 24px;
padding: 0 10px;
border-radius: 5px;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
flex-shrink: 0;
white-space: nowrap;
}
.sel-tag.include { background: rgba(99,102,241,0.15); color: #a5b4fc; border: 1px solid rgba(99,102,241,0.3); }
.sel-tag.exclude { background: rgba(239,68,68,0.12); color: #fca5a5; border: 1px solid rgba(239,68,68,0.3); }
.sel-tag:hover { filter: brightness(1.2); }
.sel-tag .remove { opacity: 0.6; font-size: 12px; }
.sel-tag:hover .remove { opacity: 1; }
.sel-clear {
margin-left: auto;
background: none;
border: none;
color: var(--text3);
font-size: 11px;
cursor: pointer;
flex-shrink: 0;
font-family: var(--font);
}
.sel-clear:hover { color: var(--text1); }
/* ════════════════════════════════
MAIN LAYOUT
════════════════════════════════ */
.main-layout {
display: flex;
flex: 1;
overflow: hidden;
height: calc(100vh - var(--topbar-h));
}
/* ════════════════════════════════
TAG BOARD
════════════════════════════════ */
.board {
flex: 1;
overflow-x: auto;
overflow-y: auto;
padding: 16px;
display: flex;
gap: 12px;
align-items: flex-start;
}
.board-loading {
display: flex;
align-items: center;
gap: 12px;
color: var(--text2);
margin: auto;
}
/* Column */
.col {
flex-shrink: 0;
width: var(--col-w);
display: flex;
flex-direction: column;
gap: 6px;
}
/* Column Header */
.col-header {
padding: 8px 10px 8px 12px;
display: flex;
align-items: center;
gap: 6px;
border-radius: var(--radius);
background: var(--bg2);
border: 1px solid var(--border);
margin-bottom: 2px;
position: sticky;
top: 0;
z-index: 5;
}
.col-header-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
}
.col-header-name {
font-size: 12px;
font-weight: 700;
flex: 1;
letter-spacing: 0.2px;
}
.col-header-count {
font-size: 10px;
color: var(--text2);
font-family: var(--mono);
}
/* Tag Card */
.tag-card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 10px 12px;
cursor: pointer;
transition: all 0.15s;
position: relative;
overflow: hidden;
min-height: var(--row-h);
display: flex;
flex-direction: column;
justify-content: space-between;
user-select: none;
}
/* Hover state */
.tag-card:hover {
border-color: var(--border2);
background: var(--bg3);
}
/* Include selected */
.tag-card.selected-include {
border-color: rgba(99,102,241,0.5) !important;
background: rgba(99,102,241,0.08) !important;
box-shadow: inset 0 0 0 1px rgba(99,102,241,0.25);
}
.tag-card.selected-include::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--acc);
border-radius: 3px 0 0 3px;
}
/* Exclude selected */
.tag-card.selected-exclude {
border-color: rgba(239,68,68,0.4) !important;
background: rgba(239,68,68,0.06) !important;
box-shadow: inset 0 0 0 1px rgba(239,68,68,0.2);
}
.tag-card.selected-exclude::before {
content: '';
position: absolute;
left: 0; top: 0; bottom: 0;
width: 3px;
background: var(--red);
border-radius: 3px 0 0 3px;
}
/* Card content */
.tc-name {
font-size: 12px;
font-weight: 600;
color: var(--text0);
line-height: 1.35;
margin-bottom: 4px;
}
.tc-desc {
font-size: 10px;
color: var(--text2);
line-height: 1.4;
flex: 1;
}
/* Card content wrapper */
.tc-head {
display: flex;
flex-direction: column;
flex: 1;
}
/* ── Lift Badge (Top Right) ── */
.tc-lift-badge {
position: absolute;
top: 8px;
right: 10px;
font-size: 10px;
font-family: var(--mono);
font-weight: 700;
padding: 1px 4px;
border-radius: 4px;
opacity: 0;
background: var(--bg3);
transition: opacity 0.2s;
pointer-events: none;
}
.tc-lift-badge.lift-up { color: #4ade80; background: rgba(74,222,128,0.1); }
.tc-lift-badge.lift-down { color: #f87171; background: rgba(248,113,113,0.1); }
.tag-card.in-preview .tc-lift-badge { opacity: 1; }
/* ── Default Stats ── */
.tc-default-stats {
display: flex;
align-items: baseline;
gap: 6px;
margin-top: 8px;
transition: opacity 0.2s;
}
.tc-coverage {
font-size: 14px;
font-weight: 700;
font-family: var(--mono);
color: var(--text1);
}
.tc-cov-rate {
font-size: 11px;
color: var(--text2);
}
.tag-card.in-preview .tc-default-stats {
opacity: 0;
position: absolute;
pointer-events: none;
}
/* ── Preview Stats (Intersection + Condition Rate) ── */
.tc-preview-stats {
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 8px;
opacity: 0;
position: absolute;
pointer-events: none;
transition: opacity 0.2s;
}
.tag-card.in-preview .tc-preview-stats {
opacity: 1;
position: relative;
}
/* 预览大数字(交集人数) */
.tc-int-count {
font-size: 14px;
font-weight: 800;
font-family: var(--mono);
}
.tc-int-count.int-up { color: #c7d2fe; }
.tc-int-count.int-down { color: #e2e8f0; } /* 稍微暗一点 */
/* 预览中字(条件概率 P(B|A) */
.tc-cond-rate {
font-size: 12px;
font-weight: 700;
font-family: var(--mono);
}
.tc-cond-rate.cond-up { color: #4ade80; }
.tc-cond-rate.cond-down { color: #f87171; }
/* 基准比对小字(原覆盖人数/费率) */
.tc-base-mini {
font-size: 9px;
color: var(--text3);
font-family: var(--mono);
margin-bottom: 2px;
}
.tc-base-sep { opacity: 0.5; }
/* Progress bar on card */
.tc-progress {
position: absolute;
bottom: 0; left: 0; right: 0;
height: 2px;
background: var(--border);
overflow: hidden;
}
.tc-progress-fill {
height: 100%;
border-radius: 2px;
transition: width 0.6s cubic-bezier(0.25, 0.46, 0.45, 0.94);
}
/* Right-click context menu */
.context-menu {
position: fixed;
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 8px;
padding: 6px;
z-index: 999;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
min-width: 140px;
display: none;
}
.cm-item {
padding: 7px 12px;
border-radius: 5px;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 8px;
color: var(--text1);
transition: background 0.1s;
}
.cm-item:hover { background: var(--bg4); color: var(--text0); }
.cm-item.danger:hover { background: rgba(239,68,68,0.15); color: #fca5a5; }
.cm-item span { font-size: 13px; }
/* ════════════════════════════════
RIGHT PANEL
════════════════════════════════ */
.right-panel {
width: 340px;
flex-shrink: 0;
background: var(--bg1);
border-left: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow: hidden;
animation: slideIn 0.25s ease;
}
@keyframes slideIn {
from { transform: translateX(20px); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.rp-header {
padding: 12px 16px;
border-bottom: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: space-between;
}
.rp-title { font-size: 13px; font-weight: 700; }
.rp-close {
background: none;
border: none;
color: var(--text2);
cursor: pointer;
font-size: 14px;
padding: 2px 6px;
border-radius: 4px;
}
.rp-close:hover { background: var(--bg3); color: var(--text0); }
.rp-body { flex: 1; overflow-y: auto; padding: 12px; }
/* User sample table */
.sample-table {
width: 100%;
border-collapse: collapse;
font-size: 11px;
}
.sample-table th {
color: var(--text2);
font-weight: 600;
text-align: left;
padding: 4px 8px;
border-bottom: 1px solid var(--border);
white-space: nowrap;
}
.sample-table td {
padding: 6px 8px;
border-bottom: 1px solid var(--border);
color: var(--text1);
font-family: var(--mono);
font-size: 10px;
}
.sample-table tr:hover td { background: var(--bg2); }
.sample-table tr:last-child td { border-bottom: none; }
/* ════════════════════════════════
OVERLAY & SPINNER
════════════════════════════════ */
.compute-overlay {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.4);
backdrop-filter: blur(2px);
z-index: 500;
display: flex;
align-items: center;
justify-content: center;
}
.co-inner {
background: var(--bg3);
border: 1px solid var(--border2);
border-radius: 12px;
padding: 24px 40px;
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
font-size: 13px;
color: var(--text1);
}
.spinner, .spinner-lg {
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
.spinner {
width: 20px; height: 20px;
border: 2px solid var(--border);
border-top-color: var(--acc);
}
.spinner-lg {
width: 32px; height: 32px;
border: 3px solid var(--bg4);
border-top-color: var(--acc);
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ════════════════════════════════
MODAL
════════════════════════════════ */
.modal-mask {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.6);
backdrop-filter: blur(4px);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
animation: fadeIn 0.2s ease;
}
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
.modal {
background: var(--bg2);
border: 1px solid var(--border2);
border-radius: 12px;
width: 100%;
max-width: 720px;
max-height: 85vh;
display: flex;
flex-direction: column;
animation: slideUp 0.25s ease;
}
@keyframes slideUp {
from { transform: translateY(16px); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 14px 20px;
border-bottom: 1px solid var(--border);
font-weight: 700;
font-size: 14px;
}
.modal-header button {
background: none;
border: none;
color: var(--text2);
font-size: 16px;
cursor: pointer;
width: 28px;
height: 28px;
border-radius: 6px;
display: flex;
align-items: center;
justify-content: center;
}
.modal-header button:hover { background: var(--bg3); color: var(--text0); }
.modal-body {
flex: 1;
overflow-y: auto;
padding: 20px;
}
/* Import docs */
.api-block {
background: var(--bg1);
border: 1px solid var(--border);
border-radius: 8px;
margin-bottom: 16px;
overflow: hidden;
}
.api-block-header {
padding: 10px 14px;
display: flex;
align-items: center;
gap: 10px;
background: var(--bg0);
border-bottom: 1px solid var(--border);
}
.api-method {
font-size: 10px;
font-weight: 700;
padding: 2px 8px;
border-radius: 4px;
font-family: var(--mono);
}
.api-method.POST { background: rgba(99,102,241,0.2); color: #a5b4fc; }
.api-method.GET { background: rgba(34,197,94,0.2); color: #86efac; }
.api-method.DELETE { background: rgba(239,68,68,0.2); color: #fca5a5; }
.api-path { font-size: 12px; font-family: var(--mono); color: var(--text1); }
.api-desc { font-size: 11px; color: var(--text2); margin-left: auto; }
.api-body { padding: 14px; }
pre {
background: var(--bg0);
border: 1px solid var(--border);
border-radius: 6px;
padding: 12px;
font-family: var(--mono);
font-size: 11px;
color: #a5b4fc;
overflow-x: auto;
line-height: 1.6;
}
.api-note {
font-size: 11px;
color: var(--text2);
margin-top: 8px;
line-height: 1.6;
}
.api-note strong { color: var(--text1); }
/* Breakdown bar in right panel */
.breakdown-item {
margin-bottom: 10px;
}
.bd-label {
display: flex;
justify-content: space-between;
font-size: 11px;
margin-bottom: 3px;
color: var(--text1);
}
.bd-rate { font-family: var(--mono); color: var(--acc); font-weight: 600; }
.bd-bar { height: 4px; background: var(--bg4); border-radius: 2px; overflow: hidden; }
.bd-fill { height: 100%; border-radius: 2px; transition: width 0.4s ease; }
/* Number animation */
@keyframes numPop {
0% { transform: scale(0.9); }
60% { transform: scale(1.04); }
100% { transform: scale(1); }
}
.num-pop { animation: numPop 0.3s ease; }
/* Scan line effect on selected cards */
@keyframes scan {
from { transform: translateY(-100%); }
to { transform: translateY(100%); }
}
.tag-card.selected-include .tc-progress-fill {
background: var(--acc);
}
.tag-card.selected-exclude .tc-progress-fill {
background: var(--red);
}
/* ════════════════════════════════
BOTTOM BAR
════════════════════════════════ */
.bottom-bar {
flex-shrink: 0;
height: 60px;
background: var(--bg1);
border-top: 1px solid var(--border);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
box-shadow: 0 -4px 20px rgba(0,0,0,0.5);
}
.bottom-bar .result-counter {
height: 100%;
border: none;
background: transparent;
gap: 20px;
}
.bottom-bar .rc-metric {
flex-direction: row;
padding: 0 10px;
gap: 15px;
}
.bottom-bar .rc-metric-label {
margin-bottom: 0;
font-size: 11px;
}
.bottom-bar .rc-divider {
margin: 15px 0;
}
.bottom-bar .rc-delta {
margin-left: 10px;
}