replace_customer_ops.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. # -*- coding: utf-8 -*-
  2. new_customer_ops = ''' async function renderCustomerOpsPage() {
  3. els.pageTitle.textContent = '客户运营看板';
  4. const page = document.createElement('div');
  5. page.className = 'page';
  6. page.innerHTML = `
  7. <div class="dashboard-grid" id="co-dashboard">
  8. <div class="kpi-row" id="co-kpi-row"></div>
  9. <div class="chart-card">
  10. <h3 class="chart-title"><span>好友状态分布</span><span class="chart-subtitle">全部客户</span></h3>
  11. <div class="chart-container" id="co-friend-chart"></div>
  12. </div>
  13. <div class="chart-card">
  14. <h3 class="chart-title"><span>近7日运营趋势</span><span class="chart-subtitle">加好友 / 建群</span></h3>
  15. <div class="chart-container" id="co-trend-chart"></div>
  16. </div>
  17. <div class="operation-toolbar">
  18. <div class="toolbar-left">
  19. <button class="btn" id="btn-import-customers">导入客户</button>
  20. <button class="btn btn-secondary" id="btn-co-refresh">刷新</button>
  21. <button class="btn btn-secondary" id="btn-co-rebuild">重建索引</button>
  22. </div>
  23. <div class="toolbar-right">
  24. <button class="btn btn-sm" id="btn-batch-add" data-job="batch-add-friends" disabled>批量加好友</button>
  25. <button class="btn btn-sm btn-secondary" id="btn-check-friends" data-job="check-friend-status" disabled>检查好友状态</button>
  26. <button class="btn btn-sm btn-secondary" id="btn-auto-group" data-job="auto-create-group" disabled>为通过好友建群</button>
  27. </div>
  28. </div>
  29. <div class="data-table-card">
  30. <div class="card-header">
  31. <div>
  32. <h3 class="card-title">客户列表</h3>
  33. <div class="card-subtitle">支持按好友状态、画像、标签筛选</div>
  34. </div>
  35. </div>
  36. <div class="filter-bar" style="margin-bottom:16px">
  37. <input id="co-filter-keyword" type="text" placeholder="搜索手机号/姓名/externalUserId" style="width:220px" />
  38. <select id="co-filter-status">
  39. <option value="">全部好友状态</option>
  40. <option value="ACCEPTED">已是好友</option>
  41. <option value="PENDING">待通过</option>
  42. <option value="NOT_FOUND">未找到</option>
  43. <option value="FAILED">失败</option>
  44. </select>
  45. <select id="co-filter-portrait">
  46. <option value="">全部画像状态</option>
  47. <option value="true">已生成画像</option>
  48. <option value="false">未生成画像</option>
  49. </select>
  50. <select id="co-filter-tag">
  51. <option value="">全部标签</option>
  52. </select>
  53. </div>
  54. <div id="co-customer-table-wrap"></div>
  55. <div id="co-pagination"></div>
  56. </div>
  57. </div>
  58. `;
  59. els.content.appendChild(page);
  60. await loadCustomerOpsDashboard(page);
  61. dashboardRefreshTimer = setInterval(() => {
  62. if (state.page === 'customer-ops') loadCustomerOpsDashboard(page, true);
  63. }, 30000);
  64. page.querySelector('#btn-co-refresh').addEventListener('click', () => loadCustomerOpsDashboard(page));
  65. page.querySelector('#btn-co-rebuild').addEventListener('click', async () => {
  66. try {
  67. await api('POST', '/api/dashboard/rebuild');
  68. toast('索引已重建');
  69. loadCustomerOpsDashboard(page);
  70. } catch (err) {
  71. toast(err.message || '重建失败', 'error');
  72. }
  73. });
  74. page.querySelector('#btn-import-customers').addEventListener('click', () => openImportCustomersModal(page));
  75. page.querySelector('#co-filter-keyword').addEventListener('input', () => loadCustomerOpsTable(page));
  76. page.querySelector('#co-filter-status').addEventListener('change', () => loadCustomerOpsTable(page));
  77. page.querySelector('#co-filter-portrait').addEventListener('change', () => loadCustomerOpsTable(page));
  78. page.querySelector('#co-filter-tag').addEventListener('change', () => loadCustomerOpsTable(page));
  79. page.querySelector('#btn-batch-add').addEventListener('click', () => {
  80. const selected = getSelectedCustomers(page);
  81. if (!selected.length) return;
  82. const body = { customers: selected.map(c => ({ phone: c.phone, name: c.name })), guid: getGuid() };
  83. startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, () => loadCustomerOpsDashboard(page));
  84. });
  85. page.querySelector('#btn-check-friends').addEventListener('click', () => {
  86. const selected = getSelectedCustomers(page);
  87. if (!selected.length) return;
  88. const body = { phones: selected.map(c => c.phone).filter(Boolean), guid: getGuid() };
  89. startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, () => loadCustomerOpsDashboard(page));
  90. });
  91. page.querySelector('#btn-auto-group').addEventListener('click', () => {
  92. const selected = getSelectedCustomers(page).filter(c => c.friendRequestStatus === 'ACCEPTED' && c.groupStatus !== 'CREATED');
  93. if (!selected.length) {
  94. toast('请选中已是好友且未建群的客户', 'warning');
  95. return;
  96. }
  97. const body = { memberList: selected.map(c => c.externalUserId).filter(Boolean), guid: getGuid() };
  98. startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, () => loadCustomerOpsDashboard(page));
  99. });
  100. }
  101. async function loadCustomerOpsDashboard(page, silent = false) {
  102. if (!silent) page.querySelector('#co-customer-table-wrap').innerHTML = '<div class="skeleton" style="height:200px"></div>';
  103. try {
  104. const [summaryRes, customersRes, tagsRes] = await Promise.all([
  105. api('GET', '/api/dashboard/summary'),
  106. api('GET', '/api/dashboard/customers'),
  107. api('GET', '/api/dashboard/tags')
  108. ]);
  109. const summary = summaryRes.data || {};
  110. const customers = Object.values(customersRes.data?.customers || {});
  111. const tags = tagsRes.data?.allTags || [];
  112. renderCustomerOpsKpi(page, summary);
  113. renderCustomerOpsCharts(page, summary, customers);
  114. renderCustomerOpsTagFilter(page, tags);
  115. state.customerOps.allCustomers = customers;
  116. loadCustomerOpsTable(page);
  117. } catch (err) {
  118. if (!silent) toast(err.message || '加载看板失败', 'error');
  119. }
  120. }
  121. function renderCustomerOpsKpi(page, summary) {
  122. const wrap = page.querySelector('#co-kpi-row');
  123. const c = summary.customers || {};
  124. const friendStatus = c.friendStatusCounts || {};
  125. const kpi = [
  126. { label: '本地客户总数', value: formatNumber(c.total) },
  127. { label: '已是好友', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '已通过好友申请' },
  128. { label: '待通过', value: formatNumber(friendStatus.PENDING || 0), sub: '等待对方确认' },
  129. { label: '自动建群', value: formatNumber(c.autoCreatedGroups || 0), sub: '已创建客户群' },
  130. { label: '画像覆盖率', value: (c.portraitCoverage || 0) + '%', sub: '已生成画像客户占比' },
  131. { label: '今日新增', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '今日通过好友' }
  132. ];
  133. wrap.innerHTML = renderKpiRow(kpi);
  134. }
  135. function renderCustomerOpsCharts(page, summary, customers) {
  136. const friendStatus = summary.customers?.friendStatusCounts || {};
  137. initEChart('co-friend-chart', {
  138. tooltip: { trigger: 'item' },
  139. legend: { bottom: 0 },
  140. series: [{
  141. type: 'pie',
  142. radius: ['45%', '70%'],
  143. center: ['50%', '45%'],
  144. data: [
  145. { name: '已是好友', value: friendStatus.ACCEPTED || 0, itemStyle: { color: '#52c41a' } },
  146. { name: '待通过', value: friendStatus.PENDING || 0, itemStyle: { color: '#faad14' } },
  147. { name: '未找到', value: friendStatus.NOT_FOUND || 0, itemStyle: { color: '#ff4d4f' } },
  148. { name: '失败', value: friendStatus.FAILED || 0, itemStyle: { color: '#722ed1' } },
  149. { name: '未知', value: friendStatus.UNKNOWN || 0, itemStyle: { color: '#8c8c8c' } }
  150. ]
  151. }]
  152. });
  153. const days = last7Days();
  154. const addCounts = countByDay(customers.filter(c => c.lastAddAttemptAt), 'lastAddAttemptAt');
  155. const groupCounts = countByDay(customers.filter(c => c.autoCreatedAt), 'autoCreatedAt');
  156. initEChart('co-trend-chart', {
  157. tooltip: { trigger: 'axis' },
  158. legend: { bottom: 0 },
  159. xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
  160. yAxis: { type: 'value' },
  161. series: [
  162. { name: '加好友', type: 'bar', data: addCounts, itemStyle: { color: '#fa8c16' } },
  163. { name: '建群', type: 'line', data: groupCounts, itemStyle: { color: '#1890ff' } }
  164. ]
  165. });
  166. }
  167. function renderCustomerOpsTagFilter(page, tags) {
  168. const select = page.querySelector('#co-filter-tag');
  169. const current = select.value;
  170. select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
  171. select.value = current;
  172. }
  173. function getSelectedCustomers(page) {
  174. const checkboxes = page.querySelectorAll('#co-customer-table-wrap input[type="checkbox"]:checked');
  175. return Array.from(checkboxes).map(cb => {
  176. const idx = Number(cb.dataset.index);
  177. return state.customerOps.filteredCustomers?.[idx];
  178. }).filter(Boolean);
  179. }
  180. function loadCustomerOpsTable(page) {
  181. const wrap = page.querySelector('#co-customer-table-wrap');
  182. const customers = state.customerOps.allCustomers || [];
  183. const keyword = page.querySelector('#co-filter-keyword').value.trim().toLowerCase();
  184. const status = page.querySelector('#co-filter-status').value;
  185. const portrait = page.querySelector('#co-filter-portrait').value;
  186. const tag = page.querySelector('#co-filter-tag').value;
  187. let filtered = customers.filter(c => {
  188. if (keyword) {
  189. const text = `${c.phone || ''} ${c.name || ''} ${c.externalUserId || ''}`.toLowerCase();
  190. if (!text.includes(keyword)) return false;
  191. }
  192. if (status && c.friendRequestStatus !== status) return false;
  193. if (portrait === 'true' && !c.hasPortrait) return false;
  194. if (portrait === 'false' && c.hasPortrait) return false;
  195. if (tag && !(Array.isArray(c.tags) && c.tags.includes(tag))) return false;
  196. return true;
  197. });
  198. state.customerOps.filteredCustomers = filtered;
  199. const pageSize = 20;
  200. const currentPage = state.customerOps.tablePage || 1;
  201. const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
  202. if (currentPage > totalPages) state.customerOps.tablePage = totalPages;
  203. const start = (state.customerOps.tablePage - 1) * pageSize;
  204. const pageItems = filtered.slice(start, start + pageSize);
  205. const friendBadge = (status) => {
  206. const map = {
  207. ACCEPTED: ['badge-success', '已是好友'],
  208. PENDING: ['badge-warning', '待通过'],
  209. NOT_FOUND: ['badge-error', '未找到'],
  210. FAILED: ['badge-error', '失败']
  211. };
  212. const [cls, text] = map[status] || ['badge-default', '未知'];
  213. return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
  214. };
  215. wrap.innerHTML = `
  216. <div class="table-wrap">
  217. <table>
  218. <thead>
  219. <tr>
  220. <th><input type="checkbox" id="co-select-all"></th>
  221. <th>手机号</th>
  222. <th>姓名</th>
  223. <th>externalUserId</th>
  224. <th>好友状态</th>
  225. <th>建群状态</th>
  226. <th>画像</th>
  227. <th>标签</th>
  228. <th>最后操作</th>
  229. <th>操作</th>
  230. </tr>
  231. </thead>
  232. <tbody>
  233. ${pageItems.length ? pageItems.map((c, i) => `
  234. <tr>
  235. <td><input type="checkbox" data-index="${start + i}"></td>
  236. <td>${escapeHtml(c.phone || '-')}</td>
  237. <td>${escapeHtml(c.name || '-')}</td>
  238. <td style="font-family:monospace;font-size:12px">${escapeHtml(c.externalUserId || '-')}</td>
  239. <td>${friendBadge(c.friendRequestStatus)}</td>
  240. <td>${c.groupStatus === 'CREATED' ? '<span class="badge badge-success">已建群</span>' : '<span class="badge badge-default">未建群</span>'}</td>
  241. <td>${c.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>'}</td>
  242. <td>${Array.isArray(c.tags) && c.tags.length ? c.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
  243. <td>${formatDateTime(c.updatedAt)}</td>
  244. <td>
  245. <button class="btn btn-sm btn-secondary" onclick="window.dashboardCustomerDetail('${escapeHtml(c.externalUserId || c.phone || '')}')">详情</button>
  246. </td>
  247. </tr>
  248. `).join('') : '<tr><td colspan="10" style="text-align:center;color:var(--text-muted);padding:40px">暂无客户数据</td></tr>'}
  249. </tbody>
  250. </table>
  251. </div>
  252. `;
  253. const pagination = page.querySelector('#co-pagination');
  254. pagination.innerHTML = renderPagination(state.customerOps.tablePage, filtered.length, pageSize);
  255. pagination.querySelectorAll('button').forEach(btn => {
  256. btn.addEventListener('click', () => {
  257. state.customerOps.tablePage = Number(btn.dataset.page);
  258. loadCustomerOpsTable(page);
  259. });
  260. });
  261. const selectAll = wrap.querySelector('#co-select-all');
  262. const rowCheckboxes = wrap.querySelectorAll('tbody input[type="checkbox"]');
  263. selectAll?.addEventListener('change', () => {
  264. rowCheckboxes.forEach(cb => cb.checked = selectAll.checked);
  265. updateCustomerOpsActionButtons(page);
  266. });
  267. rowCheckboxes.forEach(cb => cb.addEventListener('change', () => updateCustomerOpsActionButtons(page)));
  268. updateCustomerOpsActionButtons(page);
  269. }
  270. function updateCustomerOpsActionButtons(page) {
  271. const selected = getSelectedCustomers(page);
  272. page.querySelector('#btn-batch-add').disabled = !selected.length;
  273. page.querySelector('#btn-check-friends').disabled = !selected.length;
  274. page.querySelector('#btn-auto-group').disabled = !selected.some(c => c.friendRequestStatus === 'ACCEPTED' && c.groupStatus !== 'CREATED');
  275. }
  276. function openImportCustomersModal(page) {
  277. const body = `
  278. <div class="form-group" style="margin-bottom:16px">
  279. <label>粘贴客户列表(每行:手机号 姓名)</label>
  280. <textarea id="import-customer-text" rows="6" placeholder="13800138000 张三"></textarea>
  281. </div>
  282. <div class="form-group" style="margin-bottom:16px">
  283. <label>或上传 Excel</label>
  284. <input type="file" id="import-customer-file" accept=".xlsx,.xls" />
  285. </div>
  286. <div class="form-group">
  287. <label>默认验证消息</label>
  288. <input id="import-greeting" type="text" value="${escapeHtml(state.customerOps.defaultGreeting)}" />
  289. </div>
  290. `;
  291. openModal('导入客户', body, `
  292. <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
  293. <button class="btn" id="btn-confirm-import">导入</button>
  294. `);
  295. document.getElementById('btn-confirm-import').addEventListener('click', async () => {
  296. const text = document.getElementById('import-customer-text').value;
  297. const file = document.getElementById('import-customer-file').files[0];
  298. const greeting = document.getElementById('import-greeting').value;
  299. let customers = parseCustomerText(text);
  300. if (file) {
  301. const base64 = await readFileBase64(file);
  302. const upload = await api('POST', '/api/upload', { data: base64, name: file.name });
  303. const filePath = upload.data?.path;
  304. if (filePath) {
  305. const res = await api('POST', '/api/customer-ops/batch-add-friends', { filePath, defaultGreeting: greeting, guid: getGuid() });
  306. toast(res.assistantMessage || '导入任务已启动');
  307. closeModal();
  308. loadCustomerOpsDashboard(page);
  309. return;
  310. }
  311. }
  312. if (!customers.length) {
  313. toast('请输入有效客户', 'warning');
  314. return;
  315. }
  316. closeModal();
  317. startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', { customers, defaultGreeting: greeting, guid: getGuid() }, () => loadCustomerOpsDashboard(page));
  318. });
  319. }
  320. window.dashboardCustomerDetail = async (externalUserId) => {
  321. if (!externalUserId) return;
  322. try {
  323. const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
  324. const data = res.data || {};
  325. const portrait = data.portrait;
  326. openModal('客户详情', `
  327. <div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px">
  328. <div><strong>externalUserId:</strong>${escapeHtml(externalUserId)}</div>
  329. <div><strong>匹配联系人:</strong>${formatNumber(data.contactList?.length || 0)}</div>
  330. <div><strong>相关群:</strong>${formatNumber(data.rooms?.length || 0)}</div>
  331. </div>
  332. <div class="detail-panel">${escapeHtml(JSON.stringify(portrait || data.contact || {}, null, 2))}</div>
  333. `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
  334. } catch (err) {
  335. toast(err.message || '查询失败', 'error');
  336. }
  337. };
  338. '''
  339. with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'r', encoding='utf-8') as f:
  340. lines = f.readlines()
  341. start = 1425
  342. end = 1865
  343. new_lines = lines[:start] + [new_customer_ops + '\n'] + lines[end:]
  344. with open('d:/caidawork/openclaw-voc-skill/claude-code/claude-code-qiwe-assistant/mcp/src/dashboard/app.js', 'w', encoding='utf-8') as f:
  345. f.writelines(new_lines)
  346. print('renderCustomerOpsPage replaced successfully')