# -*- coding: utf-8 -*-
new_portraits = ''' async function renderPortraitsPage() {
els.pageTitle.textContent = '画像与标签看板';
const page = document.createElement('div');
page.className = 'page';
page.innerHTML = `
`;
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 = `
| externalUserId |
姓名 |
标签 |
画像字段 |
来源 |
消息数 |
更新时间 |
操作 |
${pageItems.length ? pageItems.map(p => `
| ${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)} |
|
`).join('') : '| 暂无画像数据 |
'}
`;
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')