# -*- coding: utf-8 -*- new_portraits = ''' async function renderPortraitsPage() { els.pageTitle.textContent = '画像与标签看板'; const page = document.createElement('div'); page.className = 'page'; page.innerHTML = `

画像覆盖率

画像来源分布

Top 标签

近7日画像生成趋势

客户画像列表

支持按画像状态、来源、标签筛选
`; els.content.appendChild(page); await loadPortraitsDashboard(page); dashboardRefreshTimer = setInterval(() => { if (state.page === 'portraits') loadPortraitsDashboard(page, true); }, 30000); page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page)); page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => { try { await api('POST', '/api/dashboard/rebuild'); toast('索引已重建'); loadPortraitsDashboard(page); } catch (err) { toast(err.message || '重建失败', 'error'); } }); page.querySelector('#pt-filter-keyword').addEventListener('input', () => loadPortraitsTable(page)); page.querySelector('#pt-filter-source').addEventListener('change', () => loadPortraitsTable(page)); page.querySelector('#pt-filter-tag').addEventListener('change', () => loadPortraitsTable(page)); page.querySelector('#btn-pt-batch').addEventListener('click', () => openBatchPortraitModal(page)); page.querySelector('#btn-pt-tag').addEventListener('click', () => openTagModal(page)); page.querySelector('#btn-pt-export').addEventListener('click', () => { startJob('export-portraits', '/api/portraits/export', {}, (result) => { if (result.files && result.files[0]) { toast('导出成功,点击下载', 'success'); window.open(`/api/outputs?path=${encodeURIComponent(result.files[0])}`, '_blank'); } }); }); } async function loadPortraitsDashboard(page, silent = false) { if (!silent) page.querySelector('#pt-portrait-table-wrap').innerHTML = '
'; try { const [summaryRes, portraitsRes, tagsRes, customersRes] = await Promise.all([ api('GET', '/api/dashboard/summary'), api('GET', '/api/dashboard/portraits'), api('GET', '/api/dashboard/tags'), api('GET', '/api/dashboard/customers') ]); const summary = summaryRes.data || {}; const portraits = portraitsRes.data?.portraits || {}; const tags = tagsRes.data?.allTags || []; const customers = customersRes.data?.customers || {}; renderPortraitsKpi(page, summary); renderPortraitsCharts(page, summary, portraits, tags); renderPortraitsTagFilter(page, tags); state.portraits.allPortraits = portraits; state.portraits.allCustomers = customers; loadPortraitsTable(page); } catch (err) { if (!silent) toast(err.message || '加载看板失败', 'error'); } } function renderPortraitsKpi(page, summary) { const wrap = page.querySelector('#pt-kpi-row'); const p = summary.portraits || {}; const t = summary.tags || {}; const kpi = [ { label: '已生成画像', value: formatNumber(p.total) }, { label: '画像覆盖率', value: (p.coverage || 0) + '%', sub: '有画像客户占比' }, { label: '本地标签', value: formatNumber(t.total), sub: '去重后标签数' }, { label: '人均标签', value: t.avgPerCustomer || 0, sub: '每个客户平均标签' }, { label: 'AI 画像', value: formatNumber(p.bySource?.agent || 0), sub: 'Agent 分析生成' }, { label: '关键词画像', value: formatNumber(p.bySource?.keyword || 0), sub: '关键词匹配生成' } ]; wrap.innerHTML = renderKpiRow(kpi); } function renderPortraitsCharts(page, summary, portraits, tags) { const coverage = summary.portraits?.coverage || 0; initEChart('pt-coverage-chart', { series: [{ type: 'gauge', startAngle: 180, endAngle: 0, min: 0, max: 100, progress: { show: true, width: 12 }, axisLine: { lineStyle: { width: 12 } }, axisTick: { show: false }, splitLine: { show: false }, axisLabel: { show: false }, pointer: { show: false }, anchor: { show: false }, title: { show: false }, detail: { valueAnimation: true, fontSize: 28, fontWeight: 800, color: '#1f1f1f', formatter: '{value}%', offsetCenter: [0, '0%'] }, data: [{ value: coverage }] }] }); const bySource = summary.portraits?.bySource || {}; initEChart('pt-source-chart', { tooltip: { trigger: 'item' }, legend: { bottom: 0 }, series: [{ type: 'pie', radius: ['40%', '65%'], center: ['50%', '45%'], data: [ { name: '关键词', value: bySource.keyword || 0, itemStyle: { color: '#fa8c16' } }, { name: 'AI', value: bySource.agent || 0, itemStyle: { color: '#1890ff' } }, { name: '其他', value: bySource.other || 0, itemStyle: { color: '#8c8c8c' } } ] }] }); const tagCounts = {}; for (const p of Object.values(portraits)) { // 标签统计需要从 customers 中读取 } const topTags = tags.slice(0, 10).map(t => ({ name: t, value: Object.values(state.portraits.allCustomers || {}).filter(c => Array.isArray(c.tags) && c.tags.includes(t)).length })); initEChart('pt-tags-chart', { tooltip: { trigger: 'axis' }, xAxis: { type: 'value' }, yAxis: { type: 'category', data: topTags.map(t => t.name).reverse() }, series: [{ type: 'bar', data: topTags.map(t => t.value).reverse(), itemStyle: { color: '#fa8c16' } }] }); const days = last7Days(); const portraitItems = Object.values(portraits); const counts = countByDay(portraitItems, 'updatedAt'); initEChart('pt-trend-chart', { tooltip: { trigger: 'axis' }, xAxis: { type: 'category', data: days.map(d => formatDate(d)) }, yAxis: { type: 'value' }, series: [{ name: '画像生成', type: 'line', smooth: true, data: counts, itemStyle: { color: '#fa8c16' }, areaStyle: { color: 'rgba(250,140,22,0.15)' } }] }); } function renderPortraitsTagFilter(page, tags) { const select = page.querySelector('#pt-filter-tag'); const current = select.value; select.innerHTML = '' + tags.map(t => ``).join(''); select.value = current; } function loadPortraitsTable(page) { const wrap = page.querySelector('#pt-portrait-table-wrap'); const portraits = state.portraits.allPortraits || {}; const customers = state.portraits.allCustomers || {}; const keyword = page.querySelector('#pt-filter-keyword').value.trim().toLowerCase(); const source = page.querySelector('#pt-filter-source').value; const tag = page.querySelector('#pt-filter-tag').value; let items = Object.values(portraits).map(p => { const customer = Object.values(customers).find(c => c.externalUserId === p.externalUserId) || {}; return { ...p, name: customer.name, phone: customer.phone, tags: customer.tags || [] }; }); let filtered = items.filter(p => { if (keyword) { const text = `${p.externalUserId || ''} ${p.name || ''} ${p.phone || ''}`.toLowerCase(); if (!text.includes(keyword)) return false; } if (source && p.source !== source) return false; if (tag && !p.tags.includes(tag)) return false; return true; }); const pageSize = 20; const currentPage = state.portraits.tablePage || 1; const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize)); if (currentPage > totalPages) state.portraits.tablePage = totalPages; const start = (state.portraits.tablePage - 1) * pageSize; const pageItems = filtered.slice(start, start + pageSize); wrap.innerHTML = `
${pageItems.length ? pageItems.map(p => ` `).join('') : ''}
externalUserId 姓名 标签 画像字段 来源 消息数 更新时间 操作
${escapeHtml(p.externalUserId || '-')} ${escapeHtml(p.name || '-')} ${p.tags.length ? p.tags.map(t => `${escapeHtml(t)}`).join('') : '-'} ${formatNumber(p.fields?.length || 0)} 个字段 ${p.source === 'keyword' ? '关键词' : p.source === 'agent' ? 'AI' : '未知'} ${formatNumber(p.messageCount)} ${formatDateTime(p.updatedAt)}
暂无画像数据
`; const pagination = page.querySelector('#pt-pagination'); pagination.innerHTML = renderPagination(state.portraits.tablePage, filtered.length, pageSize); pagination.querySelectorAll('button').forEach(btn => { btn.addEventListener('click', () => { state.portraits.tablePage = Number(btn.dataset.page); loadPortraitsTable(page); }); }); } function openBatchPortraitModal(page) { const body = `
`; openModal('批量生成画像', body, ` `); document.getElementById('btn-confirm-batch-portrait').addEventListener('click', () => { const ids = document.getElementById('batch-portrait-ids').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean); const mode = document.getElementById('batch-portrait-mode').value; if (!ids.length) { toast('请输入 externalUserIds', 'warning'); return; } closeModal(); startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: mode, guid: getGuid() }, () => loadPortraitsDashboard(page)); }); } function openTagModal(page) { const body = `
`; openModal('为客户打标签', body, ` `); document.getElementById('btn-confirm-tag').addEventListener('click', async () => { const externalUserId = document.getElementById('tag-external-id').value.trim(); const tags = document.getElementById('tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean); const action = document.getElementById('tag-action').value; if (!externalUserId || !tags.length) { toast('请输入 externalUserId 和标签', 'warning'); return; } try { await api('POST', `/api/tags/${action}`, { externalUserId, tags }); toast('标签已更新'); closeModal(); loadPortraitsDashboard(page); } catch (err) { toast(err.message || '更新失败', 'error'); } }); } window.dashboardPortraitDetail = async (externalUserId) => { if (!externalUserId) return; try { const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() }); const data = res.data || {}; const portrait = data.portrait; openModal('画像详情', `
externalUserId:${escapeHtml(externalUserId)}
${escapeHtml(JSON.stringify(portrait || {}, null, 2))}
`, ``); } catch (err) { toast(err.message || '查询失败', 'error'); } }; ''' with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f: lines = f.readlines() start = 1784 end = 2228 new_lines = lines[:start] + [new_portraits + '\n'] + lines[end:] with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f: f.writelines(new_lines) print('renderPortraitsPage replaced successfully')