| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385 |
- # -*- coding: utf-8 -*-
- new_portraits = ''' async function renderPortraitsPage() {
- els.pageTitle.textContent = '画像与标签看板';
- const page = document.createElement('div');
- page.className = 'page';
- page.innerHTML = `
- <div class="dashboard-grid" id="pt-dashboard">
- <div class="kpi-row" id="pt-kpi-row"></div>
- <div class="chart-card third">
- <h3 class="chart-title"><span>画像覆盖率</span></h3>
- <div class="chart-container small" id="pt-coverage-chart"></div>
- </div>
- <div class="chart-card third">
- <h3 class="chart-title"><span>画像来源分布</span></h3>
- <div class="chart-container small" id="pt-source-chart"></div>
- </div>
- <div class="chart-card third">
- <h3 class="chart-title"><span>Top 标签</span></h3>
- <div class="chart-container small" id="pt-tags-chart"></div>
- </div>
- <div class="chart-card full">
- <h3 class="chart-title"><span>近7日画像生成趋势</span></h3>
- <div class="chart-container" id="pt-trend-chart"></div>
- </div>
- <div class="operation-toolbar">
- <div class="toolbar-left">
- <button class="btn" id="btn-pt-batch" data-job="batch-portraits">批量生成画像</button>
- <button class="btn btn-secondary" id="btn-pt-tag">为客户打标签</button>
- <button class="btn btn-secondary" id="btn-pt-export">导出全部画像</button>
- </div>
- <div class="toolbar-right">
- <button class="btn btn-sm btn-secondary" id="btn-pt-refresh">刷新</button>
- <button class="btn btn-sm btn-secondary" id="btn-pt-rebuild">重建索引</button>
- </div>
- </div>
- <div class="data-table-card">
- <div class="card-header">
- <div>
- <h3 class="card-title">客户画像列表</h3>
- <div class="card-subtitle">支持按画像状态、来源、标签筛选</div>
- </div>
- </div>
- <div class="filter-bar" style="margin-bottom:16px">
- <input id="pt-filter-keyword" type="text" placeholder="搜索 externalUserId / 姓名" style="width:220px" />
- <select id="pt-filter-source">
- <option value="">全部来源</option>
- <option value="keyword">关键词</option>
- <option value="agent">AI</option>
- </select>
- <select id="pt-filter-tag">
- <option value="">全部标签</option>
- </select>
- </div>
- <div id="pt-portrait-table-wrap"></div>
- <div id="pt-pagination"></div>
- </div>
- </div>
- `;
- 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 = '<div class="skeleton" style="height:200px"></div>';
- 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 = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).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 = `
- <div class="table-wrap">
- <table>
- <thead>
- <tr>
- <th>externalUserId</th>
- <th>姓名</th>
- <th>标签</th>
- <th>画像字段</th>
- <th>来源</th>
- <th>消息数</th>
- <th>更新时间</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- ${pageItems.length ? pageItems.map(p => `
- <tr>
- <td style="font-family:monospace;font-size:12px">${escapeHtml(p.externalUserId || '-')}</td>
- <td>${escapeHtml(p.name || '-')}</td>
- <td>${p.tags.length ? p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
- <td>${formatNumber(p.fields?.length || 0)} 个字段</td>
- <td>${p.source === 'keyword' ? '<span class="badge badge-warning">关键词</span>' : p.source === 'agent' ? '<span class="badge badge-info">AI</span>' : '<span class="badge badge-default">未知</span>'}</td>
- <td>${formatNumber(p.messageCount)}</td>
- <td>${formatDateTime(p.updatedAt)}</td>
- <td>
- <button class="btn btn-sm btn-secondary" onclick="window.dashboardPortraitDetail('${escapeHtml(p.externalUserId)}')">查看</button>
- </td>
- </tr>
- `).join('') : '<tr><td colspan="8" style="text-align:center;color:var(--text-muted);padding:40px">暂无画像数据</td></tr>'}
- </tbody>
- </table>
- </div>
- `;
- 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 = `
- <div class="form-group" style="margin-bottom:16px">
- <label>externalUserIds(每行一个)</label>
- <textarea id="batch-portrait-ids" rows="6" placeholder="externalUserId1\nexternalUserId2"></textarea>
- </div>
- <div class="form-group">
- <label>生成模式</label>
- <select id="batch-portrait-mode">
- <option value="keyword">关键词模式(直接保存)</option>
- <option value="ai">AI 模式(生成 context 供分析)</option>
- </select>
- </div>
- `;
- openModal('批量生成画像', body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-batch-portrait">生成</button>
- `);
- 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 = `
- <div class="form-group" style="margin-bottom:16px">
- <label>externalUserId</label>
- <input id="tag-external-id" type="text" placeholder="客户 externalUserId" />
- </div>
- <div class="form-group" style="margin-bottom:16px">
- <label>标签(逗号分隔)</label>
- <input id="tag-input" type="text" placeholder="高意向,学区房" />
- </div>
- <div class="form-group">
- <label>操作</label>
- <select id="tag-action">
- <option value="add">添加标签</option>
- <option value="remove">移除标签</option>
- </select>
- </div>
- `;
- openModal('为客户打标签', body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-tag">确定</button>
- `);
- 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('画像详情', `
- <div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">
- <div><strong>externalUserId:</strong>${escapeHtml(externalUserId)}</div>
- </div>
- <div class="detail-panel">${escapeHtml(JSON.stringify(portrait || {}, null, 2))}</div>
- `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
- } 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')
|