| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374 |
- # -*- coding: utf-8 -*-
- new_customer_ops = ''' async function renderCustomerOpsPage() {
- els.pageTitle.textContent = '客户运营看板';
- const page = document.createElement('div');
- page.className = 'page';
- page.innerHTML = `
- <div class="dashboard-grid" id="co-dashboard">
- <div class="kpi-row" id="co-kpi-row"></div>
- <div class="chart-card">
- <h3 class="chart-title"><span>好友状态分布</span><span class="chart-subtitle">全部客户</span></h3>
- <div class="chart-container" id="co-friend-chart"></div>
- </div>
- <div class="chart-card">
- <h3 class="chart-title"><span>近7日运营趋势</span><span class="chart-subtitle">加好友 / 建群</span></h3>
- <div class="chart-container" id="co-trend-chart"></div>
- </div>
- <div class="operation-toolbar">
- <div class="toolbar-left">
- <button class="btn" id="btn-import-customers">导入客户</button>
- <button class="btn btn-secondary" id="btn-co-refresh">刷新</button>
- <button class="btn btn-secondary" id="btn-co-rebuild">重建索引</button>
- </div>
- <div class="toolbar-right">
- <button class="btn btn-sm" id="btn-batch-add" data-job="batch-add-friends" disabled>批量加好友</button>
- <button class="btn btn-sm btn-secondary" id="btn-check-friends" data-job="check-friend-status" disabled>检查好友状态</button>
- <button class="btn btn-sm btn-secondary" id="btn-auto-group" data-job="auto-create-group" disabled>为通过好友建群</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="co-filter-keyword" type="text" placeholder="搜索手机号/姓名/externalUserId" style="width:220px" />
- <select id="co-filter-status">
- <option value="">全部好友状态</option>
- <option value="ACCEPTED">已是好友</option>
- <option value="PENDING">待通过</option>
- <option value="NOT_FOUND">未找到</option>
- <option value="FAILED">失败</option>
- </select>
- <select id="co-filter-portrait">
- <option value="">全部画像状态</option>
- <option value="true">已生成画像</option>
- <option value="false">未生成画像</option>
- </select>
- <select id="co-filter-tag">
- <option value="">全部标签</option>
- </select>
- </div>
- <div id="co-customer-table-wrap"></div>
- <div id="co-pagination"></div>
- </div>
- </div>
- `;
- els.content.appendChild(page);
- await loadCustomerOpsDashboard(page);
- dashboardRefreshTimer = setInterval(() => {
- if (state.page === 'customer-ops') loadCustomerOpsDashboard(page, true);
- }, 30000);
- page.querySelector('#btn-co-refresh').addEventListener('click', () => loadCustomerOpsDashboard(page));
- page.querySelector('#btn-co-rebuild').addEventListener('click', async () => {
- try {
- await api('POST', '/api/dashboard/rebuild');
- toast('索引已重建');
- loadCustomerOpsDashboard(page);
- } catch (err) {
- toast(err.message || '重建失败', 'error');
- }
- });
- page.querySelector('#btn-import-customers').addEventListener('click', () => openImportCustomersModal(page));
- page.querySelector('#co-filter-keyword').addEventListener('input', () => loadCustomerOpsTable(page));
- page.querySelector('#co-filter-status').addEventListener('change', () => loadCustomerOpsTable(page));
- page.querySelector('#co-filter-portrait').addEventListener('change', () => loadCustomerOpsTable(page));
- page.querySelector('#co-filter-tag').addEventListener('change', () => loadCustomerOpsTable(page));
- page.querySelector('#btn-batch-add').addEventListener('click', () => {
- const selected = getSelectedCustomers(page);
- if (!selected.length) return;
- const body = { customers: selected.map(c => ({ phone: c.phone, name: c.name })), guid: getGuid() };
- startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, () => loadCustomerOpsDashboard(page));
- });
- page.querySelector('#btn-check-friends').addEventListener('click', () => {
- const selected = getSelectedCustomers(page);
- if (!selected.length) return;
- const body = { phones: selected.map(c => c.phone).filter(Boolean), guid: getGuid() };
- startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, () => loadCustomerOpsDashboard(page));
- });
- page.querySelector('#btn-auto-group').addEventListener('click', () => {
- const selected = getSelectedCustomers(page).filter(c => c.friendRequestStatus === 'ACCEPTED' && c.groupStatus !== 'CREATED');
- if (!selected.length) {
- toast('请选中已是好友且未建群的客户', 'warning');
- return;
- }
- const body = { memberList: selected.map(c => c.externalUserId).filter(Boolean), guid: getGuid() };
- startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, () => loadCustomerOpsDashboard(page));
- });
- }
- async function loadCustomerOpsDashboard(page, silent = false) {
- if (!silent) page.querySelector('#co-customer-table-wrap').innerHTML = '<div class="skeleton" style="height:200px"></div>';
- try {
- const [summaryRes, customersRes, tagsRes] = await Promise.all([
- api('GET', '/api/dashboard/summary'),
- api('GET', '/api/dashboard/customers'),
- api('GET', '/api/dashboard/tags')
- ]);
- const summary = summaryRes.data || {};
- const customers = Object.values(customersRes.data?.customers || {});
- const tags = tagsRes.data?.allTags || [];
- renderCustomerOpsKpi(page, summary);
- renderCustomerOpsCharts(page, summary, customers);
- renderCustomerOpsTagFilter(page, tags);
- state.customerOps.allCustomers = customers;
- loadCustomerOpsTable(page);
- } catch (err) {
- if (!silent) toast(err.message || '加载看板失败', 'error');
- }
- }
- function renderCustomerOpsKpi(page, summary) {
- const wrap = page.querySelector('#co-kpi-row');
- const c = summary.customers || {};
- const friendStatus = c.friendStatusCounts || {};
- const kpi = [
- { label: '本地客户总数', value: formatNumber(c.total) },
- { label: '已是好友', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '已通过好友申请' },
- { label: '待通过', value: formatNumber(friendStatus.PENDING || 0), sub: '等待对方确认' },
- { label: '自动建群', value: formatNumber(c.autoCreatedGroups || 0), sub: '已创建客户群' },
- { label: '画像覆盖率', value: (c.portraitCoverage || 0) + '%', sub: '已生成画像客户占比' },
- { label: '今日新增', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '今日通过好友' }
- ];
- wrap.innerHTML = renderKpiRow(kpi);
- }
- function renderCustomerOpsCharts(page, summary, customers) {
- const friendStatus = summary.customers?.friendStatusCounts || {};
- initEChart('co-friend-chart', {
- tooltip: { trigger: 'item' },
- legend: { bottom: 0 },
- series: [{
- type: 'pie',
- radius: ['45%', '70%'],
- center: ['50%', '45%'],
- data: [
- { name: '已是好友', value: friendStatus.ACCEPTED || 0, itemStyle: { color: '#52c41a' } },
- { name: '待通过', value: friendStatus.PENDING || 0, itemStyle: { color: '#faad14' } },
- { name: '未找到', value: friendStatus.NOT_FOUND || 0, itemStyle: { color: '#ff4d4f' } },
- { name: '失败', value: friendStatus.FAILED || 0, itemStyle: { color: '#722ed1' } },
- { name: '未知', value: friendStatus.UNKNOWN || 0, itemStyle: { color: '#8c8c8c' } }
- ]
- }]
- });
- const days = last7Days();
- const addCounts = countByDay(customers.filter(c => c.lastAddAttemptAt), 'lastAddAttemptAt');
- const groupCounts = countByDay(customers.filter(c => c.autoCreatedAt), 'autoCreatedAt');
- initEChart('co-trend-chart', {
- tooltip: { trigger: 'axis' },
- legend: { bottom: 0 },
- xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
- yAxis: { type: 'value' },
- series: [
- { name: '加好友', type: 'bar', data: addCounts, itemStyle: { color: '#fa8c16' } },
- { name: '建群', type: 'line', data: groupCounts, itemStyle: { color: '#1890ff' } }
- ]
- });
- }
- function renderCustomerOpsTagFilter(page, tags) {
- const select = page.querySelector('#co-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 getSelectedCustomers(page) {
- const checkboxes = page.querySelectorAll('#co-customer-table-wrap input[type="checkbox"]:checked');
- return Array.from(checkboxes).map(cb => {
- const idx = Number(cb.dataset.index);
- return state.customerOps.filteredCustomers?.[idx];
- }).filter(Boolean);
- }
- function loadCustomerOpsTable(page) {
- const wrap = page.querySelector('#co-customer-table-wrap');
- const customers = state.customerOps.allCustomers || [];
- const keyword = page.querySelector('#co-filter-keyword').value.trim().toLowerCase();
- const status = page.querySelector('#co-filter-status').value;
- const portrait = page.querySelector('#co-filter-portrait').value;
- const tag = page.querySelector('#co-filter-tag').value;
- let filtered = customers.filter(c => {
- if (keyword) {
- const text = `${c.phone || ''} ${c.name || ''} ${c.externalUserId || ''}`.toLowerCase();
- if (!text.includes(keyword)) return false;
- }
- if (status && c.friendRequestStatus !== status) return false;
- if (portrait === 'true' && !c.hasPortrait) return false;
- if (portrait === 'false' && c.hasPortrait) return false;
- if (tag && !(Array.isArray(c.tags) && c.tags.includes(tag))) return false;
- return true;
- });
- state.customerOps.filteredCustomers = filtered;
- const pageSize = 20;
- const currentPage = state.customerOps.tablePage || 1;
- const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
- if (currentPage > totalPages) state.customerOps.tablePage = totalPages;
- const start = (state.customerOps.tablePage - 1) * pageSize;
- const pageItems = filtered.slice(start, start + pageSize);
- const friendBadge = (status) => {
- const map = {
- ACCEPTED: ['badge-success', '已是好友'],
- PENDING: ['badge-warning', '待通过'],
- NOT_FOUND: ['badge-error', '未找到'],
- FAILED: ['badge-error', '失败']
- };
- const [cls, text] = map[status] || ['badge-default', '未知'];
- return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
- };
- wrap.innerHTML = `
- <div class="table-wrap">
- <table>
- <thead>
- <tr>
- <th><input type="checkbox" id="co-select-all"></th>
- <th>手机号</th>
- <th>姓名</th>
- <th>externalUserId</th>
- <th>好友状态</th>
- <th>建群状态</th>
- <th>画像</th>
- <th>标签</th>
- <th>最后操作</th>
- <th>操作</th>
- </tr>
- </thead>
- <tbody>
- ${pageItems.length ? pageItems.map((c, i) => `
- <tr>
- <td><input type="checkbox" data-index="${start + i}"></td>
- <td>${escapeHtml(c.phone || '-')}</td>
- <td>${escapeHtml(c.name || '-')}</td>
- <td style="font-family:monospace;font-size:12px">${escapeHtml(c.externalUserId || '-')}</td>
- <td>${friendBadge(c.friendRequestStatus)}</td>
- <td>${c.groupStatus === 'CREATED' ? '<span class="badge badge-success">已建群</span>' : '<span class="badge badge-default">未建群</span>'}</td>
- <td>${c.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>'}</td>
- <td>${Array.isArray(c.tags) && c.tags.length ? c.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
- <td>${formatDateTime(c.updatedAt)}</td>
- <td>
- <button class="btn btn-sm btn-secondary" onclick="window.dashboardCustomerDetail('${escapeHtml(c.externalUserId || c.phone || '')}')">详情</button>
- </td>
- </tr>
- `).join('') : '<tr><td colspan="10" style="text-align:center;color:var(--text-muted);padding:40px">暂无客户数据</td></tr>'}
- </tbody>
- </table>
- </div>
- `;
- const pagination = page.querySelector('#co-pagination');
- pagination.innerHTML = renderPagination(state.customerOps.tablePage, filtered.length, pageSize);
- pagination.querySelectorAll('button').forEach(btn => {
- btn.addEventListener('click', () => {
- state.customerOps.tablePage = Number(btn.dataset.page);
- loadCustomerOpsTable(page);
- });
- });
- const selectAll = wrap.querySelector('#co-select-all');
- const rowCheckboxes = wrap.querySelectorAll('tbody input[type="checkbox"]');
- selectAll?.addEventListener('change', () => {
- rowCheckboxes.forEach(cb => cb.checked = selectAll.checked);
- updateCustomerOpsActionButtons(page);
- });
- rowCheckboxes.forEach(cb => cb.addEventListener('change', () => updateCustomerOpsActionButtons(page)));
- updateCustomerOpsActionButtons(page);
- }
- function updateCustomerOpsActionButtons(page) {
- const selected = getSelectedCustomers(page);
- page.querySelector('#btn-batch-add').disabled = !selected.length;
- page.querySelector('#btn-check-friends').disabled = !selected.length;
- page.querySelector('#btn-auto-group').disabled = !selected.some(c => c.friendRequestStatus === 'ACCEPTED' && c.groupStatus !== 'CREATED');
- }
- function openImportCustomersModal(page) {
- const body = `
- <div class="form-group" style="margin-bottom:16px">
- <label>粘贴客户列表(每行:手机号 姓名)</label>
- <textarea id="import-customer-text" rows="6" placeholder="13800138000 张三"></textarea>
- </div>
- <div class="form-group" style="margin-bottom:16px">
- <label>或上传 Excel</label>
- <input type="file" id="import-customer-file" accept=".xlsx,.xls" />
- </div>
- <div class="form-group">
- <label>默认验证消息</label>
- <input id="import-greeting" type="text" value="${escapeHtml(state.customerOps.defaultGreeting)}" />
- </div>
- `;
- openModal('导入客户', body, `
- <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
- <button class="btn" id="btn-confirm-import">导入</button>
- `);
- document.getElementById('btn-confirm-import').addEventListener('click', async () => {
- const text = document.getElementById('import-customer-text').value;
- const file = document.getElementById('import-customer-file').files[0];
- const greeting = document.getElementById('import-greeting').value;
- let customers = parseCustomerText(text);
- if (file) {
- const base64 = await readFileBase64(file);
- const upload = await api('POST', '/api/upload', { data: base64, name: file.name });
- const filePath = upload.data?.path;
- if (filePath) {
- const res = await api('POST', '/api/customer-ops/batch-add-friends', { filePath, defaultGreeting: greeting, guid: getGuid() });
- toast(res.assistantMessage || '导入任务已启动');
- closeModal();
- loadCustomerOpsDashboard(page);
- return;
- }
- }
- if (!customers.length) {
- toast('请输入有效客户', 'warning');
- return;
- }
- closeModal();
- startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', { customers, defaultGreeting: greeting, guid: getGuid() }, () => loadCustomerOpsDashboard(page));
- });
- }
- window.dashboardCustomerDetail = 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><strong>匹配联系人:</strong>${formatNumber(data.contactList?.length || 0)}</div>
- <div><strong>相关群:</strong>${formatNumber(data.rooms?.length || 0)}</div>
- </div>
- <div class="detail-panel">${escapeHtml(JSON.stringify(portrait || data.contact || {}, 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 = 1425
- end = 1865
- new_lines = lines[:start] + [new_customer_ops + '\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('renderCustomerOpsPage replaced successfully')
|