|
|
@@ -0,0 +1,2457 @@
|
|
|
+(function () {
|
|
|
+ 'use strict';
|
|
|
+
|
|
|
+ const API_BASE = '';
|
|
|
+ const ACCOUNTS_KEY = 'qiwei-accounts';
|
|
|
+ const CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
|
|
|
+
|
|
|
+ const SYNCED_GROUPS_KEY = 'qiwei-synced-groups';
|
|
|
+
|
|
|
+ const state = {
|
|
|
+ page: 'groups',
|
|
|
+ accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'),
|
|
|
+ currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null,
|
|
|
+ status: null,
|
|
|
+ groups: [],
|
|
|
+ groupFilters: { keyword: '', status: '', showCustomerOnly: false },
|
|
|
+ groupKeywords: null,
|
|
|
+ jobs: {},
|
|
|
+ toastId: 0,
|
|
|
+ modal: null,
|
|
|
+ loginTimer: null,
|
|
|
+ loginPhase: 'idle',
|
|
|
+ autoRecoverAttempted: false,
|
|
|
+ preRecoveryPage: null,
|
|
|
+ selectedGroupIds: new Set(),
|
|
|
+ syncedGroupIds: JSON.parse(localStorage.getItem(SYNCED_GROUPS_KEY) || '{}'),
|
|
|
+ customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null },
|
|
|
+ portraits: { lastPortrait: null, lastTags: [], personalLabels: [], lastTransferPreview: null }
|
|
|
+ };
|
|
|
+
|
|
|
+ const els = {
|
|
|
+ content: document.getElementById('content'),
|
|
|
+ pageTitle: document.getElementById('page-title'),
|
|
|
+ nav: document.getElementById('nav'),
|
|
|
+ sidebar: document.getElementById('sidebar'),
|
|
|
+ menuBtn: document.getElementById('menu-btn'),
|
|
|
+ toastContainer: document.getElementById('toast-container'),
|
|
|
+ accountBtn: document.getElementById('account-btn'),
|
|
|
+ accountDropdown: document.getElementById('account-dropdown'),
|
|
|
+ accountName: document.getElementById('account-name'),
|
|
|
+ accountDot: document.getElementById('account-dot'),
|
|
|
+ accountList: document.getElementById('account-list')
|
|
|
+ };
|
|
|
+
|
|
|
+ function api(method, path, body) {
|
|
|
+ const opts = { method, headers: {} };
|
|
|
+ if (body !== undefined) {
|
|
|
+ opts.headers['Content-Type'] = 'application/json';
|
|
|
+ opts.body = JSON.stringify(body);
|
|
|
+ }
|
|
|
+ return fetch(`${API_BASE}${path}`, opts).then(async res => {
|
|
|
+ const data = await res.json().catch(() => ({}));
|
|
|
+ if (!res.ok) return Promise.reject(data);
|
|
|
+ return data;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function currentAccount() {
|
|
|
+ return state.accounts.find(a => a.id === state.currentAccountId) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getGuid() {
|
|
|
+ const acc = currentAccount();
|
|
|
+ return acc ? (acc.guid || acc.userId || '') : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveAccounts() {
|
|
|
+ localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(state.accounts));
|
|
|
+ localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveSyncedGroups() {
|
|
|
+ localStorage.setItem(SYNCED_GROUPS_KEY, JSON.stringify(state.syncedGroupIds));
|
|
|
+ }
|
|
|
+
|
|
|
+ function markGroupsSynced(roomIds) {
|
|
|
+ const now = Date.now();
|
|
|
+ for (const roomId of roomIds) {
|
|
|
+ state.syncedGroupIds[roomId] = now;
|
|
|
+ }
|
|
|
+ saveSyncedGroups();
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetSyncedGroups() {
|
|
|
+ state.syncedGroupIds = {};
|
|
|
+ saveSyncedGroups();
|
|
|
+ }
|
|
|
+
|
|
|
+ function isGroupSynced(roomId) {
|
|
|
+ return !!state.syncedGroupIds[roomId];
|
|
|
+ }
|
|
|
+
|
|
|
+ function addAccount(account) {
|
|
|
+ const existing = state.accounts.find(a => a.guid === account.guid && a.userId === account.userId);
|
|
|
+ if (existing) {
|
|
|
+ Object.assign(existing, account, { id: existing.id });
|
|
|
+ state.currentAccountId = existing.id;
|
|
|
+ } else {
|
|
|
+ account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
|
+ state.accounts.push(account);
|
|
|
+ state.currentAccountId = account.id;
|
|
|
+ }
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ }
|
|
|
+
|
|
|
+ function switchAccount(id) {
|
|
|
+ state.currentAccountId = id;
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ renderPage();
|
|
|
+ toast('已切换账号');
|
|
|
+ }
|
|
|
+
|
|
|
+ function removeAccount(id) {
|
|
|
+ state.accounts = state.accounts.filter(a => a.id !== id);
|
|
|
+ if (state.currentAccountId === id) {
|
|
|
+ state.currentAccountId = state.accounts.length ? state.accounts[0].id : null;
|
|
|
+ }
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ renderPage();
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateAccountSwitcher() {
|
|
|
+ const acc = currentAccount();
|
|
|
+ if (acc) {
|
|
|
+ els.accountName.textContent = acc.nickname || acc.userId || '已登录账号';
|
|
|
+ els.accountDot.className = 'account-dot ' + (acc.online ? 'online' : 'offline');
|
|
|
+ } else {
|
|
|
+ els.accountName.textContent = '未登录';
|
|
|
+ els.accountDot.className = 'account-dot offline';
|
|
|
+ }
|
|
|
+
|
|
|
+ els.accountList.innerHTML = state.accounts.map(a => `
|
|
|
+ <button class="account-option ${a.id === state.currentAccountId ? 'active' : ''}" data-id="${escapeHtml(a.id)}">
|
|
|
+ <span class="status-dot ${a.online ? 'online' : 'offline'}"></span>
|
|
|
+ <span class="account-info">
|
|
|
+ <strong>${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}</strong>
|
|
|
+ <span>${escapeHtml(a.corpName || '企业微信账号')}</span>
|
|
|
+ </span>
|
|
|
+ </button>
|
|
|
+ `).join('');
|
|
|
+
|
|
|
+ els.accountList.querySelectorAll('.account-option').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ switchAccount(btn.dataset.id);
|
|
|
+ els.accountDropdown.classList.remove('open');
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function toast(message, type = 'success') {
|
|
|
+ const id = ++state.toastId;
|
|
|
+ const el = document.createElement('div');
|
|
|
+ el.className = `toast ${type}`;
|
|
|
+ el.innerHTML = `<span>${escapeHtml(message)}</span>`;
|
|
|
+ els.toastContainer.appendChild(el);
|
|
|
+ setTimeout(() => {
|
|
|
+ el.style.opacity = '0';
|
|
|
+ el.style.transform = 'translateX(100%)';
|
|
|
+ setTimeout(() => el.remove(), 300);
|
|
|
+ }, 4000);
|
|
|
+ }
|
|
|
+
|
|
|
+ function escapeHtml(str) {
|
|
|
+ return String(str || '').replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatNumber(n) {
|
|
|
+ return Number(n || 0).toLocaleString();
|
|
|
+ }
|
|
|
+
|
|
|
+ function badge(status) {
|
|
|
+ const map = {
|
|
|
+ 'AUTO_CONFIRMED': ['badge-success', '自动确认'],
|
|
|
+ 'CONFIRMED': ['badge-success', '已确认'],
|
|
|
+ 'SUGGESTED': ['badge-warning', '建议确认'],
|
|
|
+ 'IMPORTED': ['badge-default', '普通群'],
|
|
|
+ 'REJECTED': ['badge-error', '已忽略']
|
|
|
+ };
|
|
|
+ const [cls, text] = map[status] || ['badge-default', status || '未知'];
|
|
|
+ return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function openModal(title, bodyHtml, actionsHtml) {
|
|
|
+ const overlay = document.createElement('div');
|
|
|
+ overlay.className = 'modal-overlay';
|
|
|
+ overlay.innerHTML = `<div class="modal"><div class="modal-title">${escapeHtml(title)}</div>${bodyHtml}<div class="modal-actions">${actionsHtml}</div></div>`;
|
|
|
+ document.body.appendChild(overlay);
|
|
|
+ state.modal = overlay;
|
|
|
+ overlay.addEventListener('click', e => {
|
|
|
+ if (e.target === overlay) closeModal();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ if (state.modal) {
|
|
|
+ state.modal.remove();
|
|
|
+ state.modal = null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ window.dashboardCloseModal = closeModal;
|
|
|
+
|
|
|
+ function startJob(name, path, body, onDone) {
|
|
|
+ const btn = document.querySelector(`[data-job="${name}"]`);
|
|
|
+ if (btn) {
|
|
|
+ btn.disabled = true;
|
|
|
+ btn.dataset.original = btn.innerHTML;
|
|
|
+ btn.innerHTML = `<span class="spinner"></span> 执行中…`;
|
|
|
+ }
|
|
|
+ api('POST', path, body)
|
|
|
+ .then(res => {
|
|
|
+ const id = res.data?.jobId;
|
|
|
+ if (!id) {
|
|
|
+ if (btn) {
|
|
|
+ btn.disabled = false;
|
|
|
+ btn.innerHTML = btn.dataset.original;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.jobs[id] = { name, onDone };
|
|
|
+ pollJob(id);
|
|
|
+ })
|
|
|
+ .catch(err => {
|
|
|
+ if (btn) {
|
|
|
+ btn.disabled = false;
|
|
|
+ btn.innerHTML = btn.dataset.original;
|
|
|
+ }
|
|
|
+ toast(err.message || '启动任务失败', 'error');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function pollJob(id) {
|
|
|
+ const interval = setInterval(() => {
|
|
|
+ api('GET', `/api/jobs/${id}`)
|
|
|
+ .then(res => {
|
|
|
+ const job = res.data;
|
|
|
+ updateJobUI(id, job);
|
|
|
+ if (job.status === 'done' || job.status === 'error') {
|
|
|
+ clearInterval(interval);
|
|
|
+ finishJob(id, job);
|
|
|
+ }
|
|
|
+ })
|
|
|
+ .catch(() => {
|
|
|
+ clearInterval(interval);
|
|
|
+ });
|
|
|
+ }, 1500);
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateJobUI(id, job) {
|
|
|
+ const wrap = document.getElementById(`job-${id}`);
|
|
|
+ if (!wrap) return;
|
|
|
+ const fill = wrap.querySelector('.progress-bar-fill');
|
|
|
+ if (fill) fill.style.width = `${job.progress}%`;
|
|
|
+ const text = wrap.querySelector('.job-status-text');
|
|
|
+ if (text) text.textContent = job.status === 'running' ? '执行中…' : (job.status === 'done' ? '已完成' : '执行失败');
|
|
|
+ }
|
|
|
+
|
|
|
+ function finishJob(id, job) {
|
|
|
+ const record = state.jobs[id];
|
|
|
+ if (record) {
|
|
|
+ const btn = document.querySelector(`[data-job="${record.name}"]`);
|
|
|
+ if (btn) {
|
|
|
+ btn.disabled = false;
|
|
|
+ btn.innerHTML = btn.dataset.original;
|
|
|
+ }
|
|
|
+ if (job.status === 'done') {
|
|
|
+ toast(job.result?.assistantMessage || '任务完成');
|
|
|
+ if (record.onDone) record.onDone(job.result);
|
|
|
+ } else {
|
|
|
+ toast(job.error || '任务失败', 'error');
|
|
|
+ }
|
|
|
+ delete state.jobs[id];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderJobTracker(name, id) {
|
|
|
+ return `<div class="progress-wrap" id="job-${id}">
|
|
|
+ <div class="progress-bar"><div class="progress-bar-fill" style="width:0%"></div></div>
|
|
|
+ <div class="job-status-text" style="font-size:12px;color:var(--text-muted);margin-top:6px">执行中…</div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function setActiveNav(page) {
|
|
|
+ state.page = page;
|
|
|
+ document.querySelectorAll('.nav-item').forEach(el => el.classList.remove('active'));
|
|
|
+ const active = document.querySelector(`.nav-item[data-page="${page}"]`);
|
|
|
+ if (active) active.classList.add('active');
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPage() {
|
|
|
+ const page = location.hash.slice(1) || 'groups';
|
|
|
+ setActiveNav(page);
|
|
|
+ els.content.innerHTML = '';
|
|
|
+
|
|
|
+ const acc = currentAccount();
|
|
|
+ if (!acc) {
|
|
|
+ renderLoginPage();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (page === 'status') renderStatusPage();
|
|
|
+ else if (page === 'customer-ops') renderCustomerOpsPage();
|
|
|
+ else if (page === 'portraits') renderPortraitsPage();
|
|
|
+ else if (page === 'transfers') renderTransfersPage();
|
|
|
+ else renderGroupsPage();
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Login ====================
|
|
|
+ function renderLoginPage() {
|
|
|
+ els.pageTitle.textContent = '登录企微账号';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'login-page';
|
|
|
+ page.innerHTML = `
|
|
|
+ <div class="login-card">
|
|
|
+ <div class="brand-icon" style="margin:0 auto 16px;width:48px;height:48px">
|
|
|
+ <svg viewBox="0 0 24 24" style="width:28px;height:28px" fill="currentColor"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 15h-2v-6h2v6zm4 0h-2v-6h2v6zm-2-8c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z"/></svg>
|
|
|
+ </div>
|
|
|
+ <h2>请先登录企业微信</h2>
|
|
|
+ <p>登录后才能扫描和管理客户群。如需管理多个账号,可重复登录并切换。</p>
|
|
|
+ <div id="login-qr-wrap">
|
|
|
+ <button class="btn" id="btn-start-login" style="width:100%">生成登录二维码</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+
|
|
|
+ page.querySelector('#btn-start-login').addEventListener('click', () => startLoginFlow());
|
|
|
+ }
|
|
|
+
|
|
|
+ async function startLoginFlow() {
|
|
|
+ const wrap = document.getElementById('login-qr-wrap');
|
|
|
+ wrap.innerHTML = `<div class="qr-wrap">
|
|
|
+ <div class="skeleton" style="width:220px;height:220px;margin:0 auto"></div>
|
|
|
+ </div>
|
|
|
+ <div class="qr-status" id="login-status-text">正在生成二维码…</div>`;
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/login/start', {});
|
|
|
+ if (res.status === 'needs_subscription') {
|
|
|
+ wrap.innerHTML = `<div class="hint-box" style="text-align:left">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>${escapeHtml(res.assistantMessage || '需要先开通订阅')}</div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-retry-login">重试</button>`;
|
|
|
+ document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const qrcodeFile = res.data?.qrcodeFile || res.summary?.qrcodeFile;
|
|
|
+ const flowUrl = res.data?.flowUrl;
|
|
|
+
|
|
|
+ if (flowUrl && !qrcodeFile) {
|
|
|
+ wrap.innerHTML = `<div class="hint-box" style="text-align:left">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>已打开登录流程页,请在新窗口中完成扫码。</div>
|
|
|
+ </div>
|
|
|
+ <a class="btn" href="${escapeHtml(flowUrl)}" target="_blank" style="width:100%;text-decoration:none">打开登录页面</a>`;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!qrcodeFile) {
|
|
|
+ wrap.innerHTML = `<div class="hint-box" style="text-align:left">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>未获取到二维码,请重试。</div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-retry-login">重试</button>`;
|
|
|
+ document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ wrap.innerHTML = `<div class="qr-wrap">
|
|
|
+ <img src="/api/outputs?path=${encodeURIComponent(qrcodeFile)}" alt="登录二维码" id="login-qr">
|
|
|
+ </div>
|
|
|
+ <div class="qr-status" id="login-status-text">请用企业微信扫码</div>
|
|
|
+ <button class="btn btn-secondary" id="btn-retry-login" style="margin-top:12px">重新生成二维码</button>`;
|
|
|
+
|
|
|
+ document.getElementById('btn-retry-login').addEventListener('click', () => {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ startLoginFlow();
|
|
|
+ });
|
|
|
+
|
|
|
+ state.loginPhase = 'polling';
|
|
|
+ pollLoginStatus();
|
|
|
+ } catch (err) {
|
|
|
+ wrap.innerHTML = `<div class="hint-box" style="text-align:left;background:var(--error-bg);color:var(--error);border-color:var(--error-border)">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>${escapeHtml(err.message || '登录启动失败')}</div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-retry-login">重试</button>`;
|
|
|
+ document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function pollLoginStatus() {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ let checkCount = 0;
|
|
|
+ const statusText = document.getElementById('login-status-text');
|
|
|
+
|
|
|
+ state.loginTimer = setInterval(async () => {
|
|
|
+ checkCount++;
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/login/check', {});
|
|
|
+ const statusCode = res.summary?.statusCode;
|
|
|
+
|
|
|
+ if (statusCode === 2) {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ if (statusText) statusText.textContent = '登录成功,正在获取账号信息…';
|
|
|
+ await finalizeLogin(res.data?.status || {});
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (res.status === 'needs_verify_code') {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ if (statusText) statusText.textContent = '请输入手机端验证码';
|
|
|
+ showVerifyCodeInput();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (statusCode === 4) {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ if (statusText) statusText.textContent = '登录已取消,请重新生成二维码';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (statusText) {
|
|
|
+ if (statusCode === 1) statusText.textContent = '已扫码,请在手机上确认';
|
|
|
+ else if (statusCode === 0 && checkCount > 3) statusText.textContent = '等待扫码…(可尝试免扫码登录)';
|
|
|
+ else statusText.textContent = '请用企业微信扫码';
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ if (statusText) statusText.textContent = '状态检测异常,重试中…';
|
|
|
+ }
|
|
|
+ }, 2500);
|
|
|
+ }
|
|
|
+
|
|
|
+ function showVerifyCodeInput() {
|
|
|
+ const wrap = document.getElementById('login-qr-wrap');
|
|
|
+ wrap.innerHTML = `
|
|
|
+ <div class="form-group" style="text-align:left">
|
|
|
+ <label>手机端显示的 6 位验证码</label>
|
|
|
+ <input id="verify-code" type="text" maxlength="6" placeholder="000000" style="font-size:22px;letter-spacing:12px;text-align:center" />
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-submit-verify" style="width:100%;margin-top:8px">提交验证码</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-back-qr" style="width:100%;margin-top:10px">返回二维码</button>
|
|
|
+ `;
|
|
|
+
|
|
|
+ const input = document.getElementById('verify-code');
|
|
|
+ input.focus();
|
|
|
+ input.addEventListener('input', () => {
|
|
|
+ if (/^\d{6}$/.test(input.value.trim())) submitVerifyCode(input.value.trim());
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('btn-submit-verify').addEventListener('click', () => {
|
|
|
+ submitVerifyCode(input.value.trim());
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('btn-back-qr').addEventListener('click', () => {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ startLoginFlow();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function submitVerifyCode(code) {
|
|
|
+ if (!/^\d{6}$/.test(code)) {
|
|
|
+ toast('请输入 6 位数字验证码', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await api('POST', '/api/login/verify', { code });
|
|
|
+ toast('验证码已提交,正在确认登录…');
|
|
|
+ pollLoginStatus();
|
|
|
+ const statusText = document.getElementById('login-status-text');
|
|
|
+ if (statusText) statusText.textContent = '验证码已提交,正在确认…';
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '验证码提交失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function finalizeLogin(detail) {
|
|
|
+ try {
|
|
|
+ const statusRes = await api('GET', '/api/status');
|
|
|
+ const loginDetail = statusRes.data?.login?.data?.detail || detail || {};
|
|
|
+ const account = {
|
|
|
+ guid: loginDetail.guid || loginDetail.userId || '',
|
|
|
+ userId: loginDetail.userId || '',
|
|
|
+ nickname: loginDetail.nickname || loginDetail.userId || '',
|
|
|
+ corpName: loginDetail.corpName || '',
|
|
|
+ corpId: loginDetail.corpId || '',
|
|
|
+ online: true
|
|
|
+ };
|
|
|
+ if (!account.guid) {
|
|
|
+ toast('登录成功,但未获取到账号标识,请重试', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ addAccount(account);
|
|
|
+ toast(`登录成功:${account.nickname || account.userId}`);
|
|
|
+ if (state.preRecoveryPage) {
|
|
|
+ location.hash = `#${state.preRecoveryPage}`;
|
|
|
+ state.preRecoveryPage = null;
|
|
|
+ }
|
|
|
+ renderPage();
|
|
|
+ } catch (err) {
|
|
|
+ toast('登录成功,但获取账号信息失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Login Recovery ====================
|
|
|
+ function renderRecoverButton(container, options = {}) {
|
|
|
+ const { loading = false, message = '' } = options;
|
|
|
+ if (!container) return;
|
|
|
+ container.innerHTML = '';
|
|
|
+
|
|
|
+ if (message) {
|
|
|
+ const msg = document.createElement('div');
|
|
|
+ msg.className = 'recover-message';
|
|
|
+ msg.style.cssText = 'font-size:13px;color:var(--text-secondary);margin-bottom:10px;display:flex;align-items:center;gap:8px';
|
|
|
+ msg.innerHTML = `${loading ? '<span class="spinner" style="width:14px;height:14px;border-color:rgba(250,140,22,0.3);border-top-color:var(--brand)"></span>' : ''}${escapeHtml(message)}`;
|
|
|
+ container.appendChild(msg);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!loading) {
|
|
|
+ const btn = document.createElement('button');
|
|
|
+ btn.className = 'btn btn-sm';
|
|
|
+ btn.id = 'btn-recover-login';
|
|
|
+ btn.innerHTML = '恢复登录';
|
|
|
+ btn.addEventListener('click', () => recoverLogin());
|
|
|
+ container.appendChild(btn);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateRecoverMessage(message, loading = false) {
|
|
|
+ const wrap = document.getElementById('status-recover-wrap');
|
|
|
+ if (wrap) renderRecoverButton(wrap, { loading, message });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function attemptAutoRecover(auto = false) {
|
|
|
+ if (state.loginPhase === 'recovering') return false;
|
|
|
+ state.loginPhase = 'recovering';
|
|
|
+ updateRecoverMessage('正在尝试免扫码恢复登录…', true);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/login/check', { manual: true });
|
|
|
+ const statusCode = res.summary?.statusCode;
|
|
|
+
|
|
|
+ if (statusCode === 2) {
|
|
|
+ await finalizeLogin(res.data?.status || {});
|
|
|
+ state.loginPhase = 'idle';
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (res.status === 'needs_verify_code') {
|
|
|
+ state.loginPhase = 'verify';
|
|
|
+ state.preRecoveryPage = location.hash.slice(1) || 'groups';
|
|
|
+ state.currentAccountId = null;
|
|
|
+ renderPage();
|
|
|
+ setTimeout(() => showVerifyCodeInput(), 100);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+
|
|
|
+ state.loginPhase = 'idle';
|
|
|
+ if (auto) {
|
|
|
+ toast('账号离线,无法自动恢复,请进入「账号状态」手动登录', 'warning');
|
|
|
+ } else {
|
|
|
+ updateRecoverMessage('无法自动恢复,请手动登录');
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ } catch (err) {
|
|
|
+ state.loginPhase = 'idle';
|
|
|
+ if (auto) {
|
|
|
+ toast('账号离线,自动恢复失败,请进入「账号状态」手动登录', 'warning');
|
|
|
+ } else {
|
|
|
+ updateRecoverMessage('自动恢复失败,请手动登录');
|
|
|
+ }
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function recoverLogin() {
|
|
|
+ state.autoRecoverAttempted = true;
|
|
|
+ const recovered = await attemptAutoRecover(false);
|
|
|
+ if (!recovered) {
|
|
|
+ state.preRecoveryPage = location.hash.slice(1) || 'groups';
|
|
|
+ state.currentAccountId = null;
|
|
|
+ renderPage();
|
|
|
+ setTimeout(() => startLoginFlow(), 100);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncAccountOnlineStatus(online, detail = {}) {
|
|
|
+ const current = currentAccount();
|
|
|
+ const currentGuid = current?.guid || detail?.guid || detail?.userId || '';
|
|
|
+ const currentUserId = current?.userId || detail?.userId || '';
|
|
|
+ let changed = false;
|
|
|
+ state.accounts.forEach(a => {
|
|
|
+ const match = (currentGuid && (a.guid === currentGuid || a.userId === currentGuid)) ||
|
|
|
+ (currentUserId && (a.userId === currentUserId || a.guid === currentUserId));
|
|
|
+ if (match) {
|
|
|
+ if (a.online !== online) {
|
|
|
+ a.online = online;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ if (detail?.nickname && a.nickname !== detail.nickname) {
|
|
|
+ a.nickname = detail.nickname;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ if (detail?.corpName && a.corpName !== detail.corpName) {
|
|
|
+ a.corpName = detail.corpName;
|
|
|
+ changed = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (changed) {
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Status Page ====================
|
|
|
+ async function renderStatusPage() {
|
|
|
+ els.pageTitle.textContent = '账号状态';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page';
|
|
|
+ page.innerHTML = `
|
|
|
+ <div class="card">
|
|
|
+ <div class="section-title">正在加载状态…</div>
|
|
|
+ <div class="skeleton" style="height:100px"></div>
|
|
|
+ </div>`;
|
|
|
+ els.content.appendChild(page);
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await api('GET', '/api/status');
|
|
|
+ const login = res.data?.login || {};
|
|
|
+ const sub = res.data?.subscription || {};
|
|
|
+ const online = !!login.summary?.online;
|
|
|
+ const subscribed = !!sub.summary?.subscribed;
|
|
|
+ const acc = currentAccount() || {};
|
|
|
+
|
|
|
+ // 同步已保存账号的在线状态,确保表格显示与当前状态一致
|
|
|
+ syncAccountOnlineStatus(online, login.data?.detail);
|
|
|
+
|
|
|
+ const currentAcc = {
|
|
|
+ nickname: acc.nickname || login.summary?.nickname || '当前账号',
|
|
|
+ corpName: acc.corpName || login.data?.detail?.corpName || '',
|
|
|
+ userId: acc.userId || login.data?.detail?.userId || ''
|
|
|
+ };
|
|
|
+
|
|
|
+ page.innerHTML = `
|
|
|
+ <div class="status-cards">
|
|
|
+ <div class="status-card">
|
|
|
+ <div class="status-icon ${online ? 'green' : 'red'}">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm0 2c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
|
|
+ </div>
|
|
|
+ <div class="status-content">
|
|
|
+ <h3>${escapeHtml(currentAcc.nickname)}</h3>
|
|
|
+ <p>${escapeHtml(currentAcc.corpName || '企业微信账号')}</p>
|
|
|
+ <div class="status-detail">
|
|
|
+ <span class="status-dot ${online ? 'online' : 'offline'}"></span>
|
|
|
+ ${online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
|
|
|
+ </div>
|
|
|
+ ${!online ? `<div id="status-recover-wrap" style="margin-top:12px"></div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="status-card">
|
|
|
+ <div class="status-icon ${subscribed ? 'green' : 'orange'}">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4zm0 10.99h7c-.53 4.12-3.28 7.79-7 8.94V12H5V6.3l7-3.11v8.8z"/></svg>
|
|
|
+ </div>
|
|
|
+ <div class="status-content">
|
|
|
+ <h3>${subscribed ? '订阅有效' : '未开通订阅'}</h3>
|
|
|
+ <p>${escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}</p>
|
|
|
+ ${sub.summary?.seats ? `<div class="status-detail">席位:${formatNumber(sub.summary.seats)} 个</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="status-card">
|
|
|
+ <div class="status-icon ${res.summary?.authConfigured ? 'green' : 'red'}">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z"/></svg>
|
|
|
+ </div>
|
|
|
+ <div class="status-content">
|
|
|
+ <h3>${res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}</h3>
|
|
|
+ <p>${res.summary?.authConfigured ? '已检测到 Fmode 鉴权 Token' : '请配置 QIWEI_AUTH_TOKEN 或 FMODE_API_KEY'}</p>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <h3 class="card-title">已保存账号</h3>
|
|
|
+ <button class="btn btn-sm" id="btn-add-account-page">添加账号</button>
|
|
|
+ </div>
|
|
|
+ ${state.accounts.length ? `
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>账号</th><th>企业</th><th>状态</th><th style="width:120px">操作</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${state.accounts.map(a => `
|
|
|
+ <tr class="${a.id === state.currentAccountId ? 'active-row' : ''}">
|
|
|
+ <td>${escapeHtml(a.nickname || a.userId || a.guid)}</td>
|
|
|
+ <td>${escapeHtml(a.corpName || '-')}</td>
|
|
|
+ <td><span class="status-dot ${a.online ? 'online' : 'offline'}"></span>${a.online ? '在线' : '离线'}</td>
|
|
|
+ <td>
|
|
|
+ <div style="display:flex;gap:6px">
|
|
|
+ ${a.id !== state.currentAccountId ? `<button class="btn btn-sm btn-secondary" data-action="switch" data-id="${escapeHtml(a.id)}">切换</button>` : ''}
|
|
|
+ <button class="btn btn-sm btn-danger" data-action="remove" data-id="${escapeHtml(a.id)}">删除</button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ ` : `
|
|
|
+ <div class="empty-state" style="padding:32px">
|
|
|
+ <div class="empty-state-title">还没有保存的账号</div>
|
|
|
+ <div class="empty-state-desc">点击右上角「添加账号」开始登录</div>
|
|
|
+ </div>
|
|
|
+ `}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ page.querySelector('#btn-add-account-page').addEventListener('click', () => {
|
|
|
+ state.currentAccountId = null;
|
|
|
+ renderPage();
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelectorAll('[data-action="switch"]').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => switchAccount(btn.dataset.id));
|
|
|
+ });
|
|
|
+ page.querySelectorAll('[data-action="remove"]').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ if (confirm('确定删除这个账号吗?')) removeAccount(btn.dataset.id);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ const recoverWrap = page.querySelector('#status-recover-wrap');
|
|
|
+ if (recoverWrap) {
|
|
|
+ renderRecoverButton(recoverWrap);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ page.innerHTML = `<div class="card">
|
|
|
+ <div class="hint-box" style="background:var(--error-bg);color:var(--error);border-color:var(--error-border)">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>加载状态失败:${escapeHtml(err.message || '未知错误')}</div>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Groups Page ====================
|
|
|
+ async function renderGroupsPage() {
|
|
|
+ els.pageTitle.textContent = '客户群管理';
|
|
|
+ state.selectedGroupIds = state.selectedGroupIds || new Set();
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page';
|
|
|
+ page.innerHTML = `
|
|
|
+ <div class="hint-box">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>点击「扫描客户群」从企业微信拉取群列表,系统会自动识别客户群。勾选群后点击「同步消息」可批量保存历史消息到本地。</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ ${renderGroupSyncCard()}
|
|
|
+ ${renderGroupStatsCard(null)}
|
|
|
+ ${renderGroupFilters()}
|
|
|
+ <div id="groups-table-card"></div>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+
|
|
|
+ bindGroupSync(page);
|
|
|
+ bindGroupFilters(page);
|
|
|
+
|
|
|
+ await loadGroups();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroupSyncCard() {
|
|
|
+ return `
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 4V1L8 5l4 4V6c3.31 0 6 2.69 6 6 0 1.01-.25 1.97-.7 2.8l1.46 1.46C19.54 15.03 20 13.57 20 12c0-4.42-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6 0-1.01.25-1.97.7-2.8L5.24 7.74C4.46 8.97 4 10.43 4 12c0 4.42 3.58 8 8 8v3l4-4-4-4v3z"/></svg>
|
|
|
+ 扫描客户群
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">从企业微信拉取群数据,并自动判断哪些可能是客户群</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>扫描范围</label>
|
|
|
+ <select id="sync-scope">
|
|
|
+ <option value="all">全部群</option>
|
|
|
+ <option value="self">我创建的群</option>
|
|
|
+ <option value="session">最近聊天里的群</option>
|
|
|
+ <option value="messages">从消息记录里找群</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>扫描深度</label>
|
|
|
+ <input id="sync-max-pages" type="number" value="20" min="1" max="300" />
|
|
|
+ <span style="font-size:12px;color:var(--text-muted);margin-top:4px">分页拉取的最大页数,数值越大扫描越完整、耗时越长</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:2;min-width:260px">
|
|
|
+ <label>客户群关键词(用逗号隔开)</label>
|
|
|
+ <input id="sync-keywords" type="text" value="客户群,服务群,售后群,咨询群,VIP群,专属群,业主群,项目群,订单群,用户群" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="flex:1;min-width:160px">
|
|
|
+ <label>高置信度词</label>
|
|
|
+ <input id="sync-high-confidence" type="text" value="客户群,服务群" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="checkbox-row" style="margin-bottom:14px">
|
|
|
+ <input id="sync-auto-classify" type="checkbox" checked />
|
|
|
+ <label for="sync-auto-classify">自动识别客户群</label>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button class="btn" id="btn-sync-groups" data-job="sync-groups">开始扫描</button>
|
|
|
+ <div id="sync-job-tracker"></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroupStatsCard(summary) {
|
|
|
+ const s = summary || {};
|
|
|
+ return `
|
|
|
+ <div class="stats-grid" id="group-stats">
|
|
|
+ <div class="stat-card"><div class="stat-value" data-count="${s.total || 0}">${formatNumber(s.total)}</div><div class="stat-label">扫描群数</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--success)" data-count="${s.autoConfirmed || 0}">${formatNumber(s.autoConfirmed)}</div><div class="stat-label">自动确认</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--warning)" data-count="${s.suggested || 0}">${formatNumber(s.suggested)}</div><div class="stat-label">建议确认</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--text-muted)" data-count="${s.imported || 0}">${formatNumber(s.imported)}</div><div class="stat-label">普通群</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--success)" data-count="${s.confirmed || 0}">${formatNumber(s.confirmed)}</div><div class="stat-label">已确认</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--error)" data-count="${s.rejected || 0}">${formatNumber(s.rejected)}</div><div class="stat-label">已忽略</div></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroupFilters() {
|
|
|
+ return `
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <h3 class="card-title">群列表</h3>
|
|
|
+ <div class="filter-bar">
|
|
|
+ <input id="filter-keyword" type="text" placeholder="搜索群名" value="${escapeHtml(state.groupFilters.keyword)}" />
|
|
|
+ <select id="filter-status">
|
|
|
+ <option value="">全部状态</option>
|
|
|
+ <option value="AUTO_CONFIRMED">自动确认</option>
|
|
|
+ <option value="SUGGESTED">建议确认</option>
|
|
|
+ <option value="CONFIRMED">已确认</option>
|
|
|
+ <option value="REJECTED">已忽略</option>
|
|
|
+ </select>
|
|
|
+ <button class="btn btn-sm btn-secondary ${state.groupFilters.showCustomerOnly ? 'active' : ''}" id="btn-filter-customer-only">${state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群'}</button>
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-refresh-groups">刷新</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroupTable(groups) {
|
|
|
+ const customerStatuses = new Set(['AUTO_CONFIRMED', 'SUGGESTED', 'CONFIRMED', 'REJECTED']);
|
|
|
+ const filtered = groups.filter(g => {
|
|
|
+ // 过滤无效群记录:无名称且无成员数通常是单聊或系统通知
|
|
|
+ if (!g.roomName && !g.memberCount) return false;
|
|
|
+ const kw = state.groupFilters.keyword.trim();
|
|
|
+ if (kw && !(g.roomName || '').includes(kw) && !(g.roomId || '').includes(kw)) return false;
|
|
|
+ if (state.groupFilters.status && g.reviewStatus !== state.groupFilters.status) return false;
|
|
|
+ if (state.groupFilters.showCustomerOnly && !customerStatuses.has(g.reviewStatus)) return false;
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ const card = document.getElementById('groups-table-card');
|
|
|
+ if (!filtered.length) {
|
|
|
+ card.innerHTML = `
|
|
|
+ <div class="card">
|
|
|
+ <div class="empty-state">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
|
|
|
+ <div class="empty-state-title">还没有群数据</div>
|
|
|
+ <div class="empty-state-desc">点击上方「开始扫描」从企业微信拉取</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const allSelected = filtered.length > 0 && filtered.every(g => state.selectedGroupIds.has(g.roomId));
|
|
|
+
|
|
|
+ card.innerHTML = `
|
|
|
+ <div class="card" style="padding:0;overflow:hidden">
|
|
|
+ <div style="padding:14px 16px;border-bottom:1px solid var(--border);background:var(--page-bg);display:flex;align-items:center;gap:12px;flex-wrap:wrap">
|
|
|
+ <label class="checkbox-row" style="margin:0">
|
|
|
+ <input type="checkbox" id="select-all-groups" ${allSelected ? 'checked' : ''} />
|
|
|
+ <span>全选本页</span>
|
|
|
+ </label>
|
|
|
+ <span style="color:var(--text-muted);font-size:13px">已选 ${formatNumber(state.selectedGroupIds.size)} 个</span>
|
|
|
+ <button class="btn btn-sm" id="btn-batch-confirm" ${state.selectedGroupIds.size ? '' : 'disabled'}>
|
|
|
+ 确认选中为客户群
|
|
|
+ </button>
|
|
|
+ <button class="btn btn-sm" id="btn-batch-sync-msg" ${state.selectedGroupIds.size ? '' : 'disabled'}>
|
|
|
+ 同步选中群消息
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th style="width:40px"></th>
|
|
|
+ <th>群名</th>
|
|
|
+ <th style="width:70px">人数</th>
|
|
|
+ <th style="width:100px">识别结果</th>
|
|
|
+ <th style="width:100px">置信度</th>
|
|
|
+ <th style="width:100px">消息同步</th>
|
|
|
+ <th>命中关键词</th>
|
|
|
+ <th style="width:120px">操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ ${filtered.map((g, i) => {
|
|
|
+ const synced = isGroupSynced(g.roomId);
|
|
|
+ return `
|
|
|
+ <tr style="animation:fadeInUp 0.3s ease ${i * 0.03}s both">
|
|
|
+ <td>
|
|
|
+ <input type="checkbox" class="group-checkbox" data-room="${escapeHtml(g.roomId)}" ${state.selectedGroupIds.has(g.roomId) ? 'checked' : ''} />
|
|
|
+ </td>
|
|
|
+ <td>
|
|
|
+ <div style="font-weight:600">${escapeHtml(g.roomName || '未命名')}</div>
|
|
|
+ <div style="font-size:12px;color:var(--text-muted);margin-top:2px">${escapeHtml(g.roomId || '')}</div>
|
|
|
+ </td>
|
|
|
+ <td>${formatNumber(g.memberCount)}</td>
|
|
|
+ <td>${badge(g.reviewStatus)}</td>
|
|
|
+ <td>
|
|
|
+ <div style="display:flex;align-items:center;gap:8px">
|
|
|
+ <div class="confidence-bar"><div class="confidence-bar-fill" style="width:${Math.round((g.confidence || 0) * 100)}%"></div></div>
|
|
|
+ <span style="font-size:12px;color:var(--text-muted)">${Math.round((g.confidence || 0) * 100)}%</span>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ <td>${synced ? `<span class="badge badge-success">已同步</span>` : `<span class="badge badge-default">未同步</span>`}</td>
|
|
|
+ <td>${(g.matchedKeywords || []).map(k => `<span class="tag" style="margin-bottom:4px">${escapeHtml(k)}</span>`).join(' ') || '-'}</td>
|
|
|
+ <td>
|
|
|
+ <div style="display:flex;gap:6px;flex-wrap:wrap">
|
|
|
+ ${!['CONFIRMED', 'AUTO_CONFIRMED'].includes(g.reviewStatus) ? `<button class="btn btn-sm" data-action="confirm" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">确认</button>` : ''}
|
|
|
+ ${g.reviewStatus === 'CONFIRMED' ? `<button class="btn btn-sm" data-action="detail" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">群详情</button>` : ''}
|
|
|
+ ${g.reviewStatus !== 'REJECTED' ? `<button class="btn btn-sm btn-secondary" data-action="reject" data-room="${escapeHtml(g.roomId)}" data-name="${escapeHtml(g.roomName || '')}">忽略</button>` : ''}
|
|
|
+ <button class="btn btn-sm btn-secondary" data-action="sync-msg" data-room="${escapeHtml(g.roomId)}">${synced ? '重新同步' : '同步消息'}</button>
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>`;
|
|
|
+ }).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ bindGroupTableActions(card, filtered);
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindGroupTableActions(card, filtered) {
|
|
|
+ const selectAll = card.querySelector('#select-all-groups');
|
|
|
+ if (selectAll) {
|
|
|
+ selectAll.addEventListener('change', () => {
|
|
|
+ filtered.forEach(g => {
|
|
|
+ if (selectAll.checked) state.selectedGroupIds.add(g.roomId);
|
|
|
+ else state.selectedGroupIds.delete(g.roomId);
|
|
|
+ });
|
|
|
+ renderGroupTable(state.groups);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ card.querySelectorAll('.group-checkbox').forEach(cb => {
|
|
|
+ cb.addEventListener('change', () => {
|
|
|
+ const roomId = cb.dataset.room;
|
|
|
+ if (cb.checked) state.selectedGroupIds.add(roomId);
|
|
|
+ else state.selectedGroupIds.delete(roomId);
|
|
|
+ renderGroupTable(state.groups);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ const batchConfirmBtn = card.querySelector('#btn-batch-confirm');
|
|
|
+ if (batchConfirmBtn) {
|
|
|
+ batchConfirmBtn.addEventListener('click', () => {
|
|
|
+ const roomIds = Array.from(state.selectedGroupIds);
|
|
|
+ if (!roomIds.length) return;
|
|
|
+ batchConfirmGroups(roomIds);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const batchSyncBtn = card.querySelector('#btn-batch-sync-msg');
|
|
|
+ if (batchSyncBtn) {
|
|
|
+ batchSyncBtn.addEventListener('click', () => {
|
|
|
+ const roomIds = Array.from(state.selectedGroupIds);
|
|
|
+ if (!roomIds.length) return;
|
|
|
+ syncGroupMessages(roomIds);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ card.querySelectorAll('[data-action]').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ const roomId = btn.dataset.room;
|
|
|
+ const roomName = btn.dataset.name;
|
|
|
+ if (btn.dataset.action === 'confirm') confirmGroup(roomId, roomName);
|
|
|
+ if (btn.dataset.action === 'reject') openRejectModal(roomId, roomName);
|
|
|
+ if (btn.dataset.action === 'sync-msg') syncGroupMessages([roomId]);
|
|
|
+ if (btn.dataset.action === 'detail') openGroupDetailModal(roomId, roomName);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function confirmGroup(roomId, roomName) {
|
|
|
+ const guid = getGuid();
|
|
|
+ if (!guid) {
|
|
|
+ toast('请先登录企业微信账号', 'warning');
|
|
|
+ state.currentAccountId = null;
|
|
|
+ renderPage();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const acc = currentAccount();
|
|
|
+ if (!acc || !acc.online) {
|
|
|
+ toast('账号当前离线,请先恢复登录后再确认客户群', 'warning');
|
|
|
+ location.hash = '#status';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/groups/confirm', { roomId });
|
|
|
+ toast(res.assistantMessage || '已确认为客户群');
|
|
|
+ loadGroups();
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '确认失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ async function batchConfirmGroups(roomIds) {
|
|
|
+ let success = 0;
|
|
|
+ let failed = 0;
|
|
|
+ for (const roomId of roomIds) {
|
|
|
+ try {
|
|
|
+ await api('POST', '/api/groups/confirm', { roomId });
|
|
|
+ success++;
|
|
|
+ } catch (err) {
|
|
|
+ failed++;
|
|
|
+ console.error('确认客户群失败', roomId, err);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ toast(`批量确认完成:成功 ${success} 个${failed ? `,失败 ${failed} 个` : ''}`);
|
|
|
+ loadGroups();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadGroups() {
|
|
|
+ try {
|
|
|
+ const res = await api('GET', '/api/groups/list');
|
|
|
+ state.groups = res.data?.groups || [];
|
|
|
+ updateGroupStats(res.summary);
|
|
|
+ renderGroupTable(state.groups);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '加载群列表失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateGroupStats(summary) {
|
|
|
+ if (!summary) {
|
|
|
+ const counts = state.groups.reduce((acc, g) => {
|
|
|
+ acc[g.reviewStatus] = (acc[g.reviewStatus] || 0) + 1;
|
|
|
+ return acc;
|
|
|
+ }, {});
|
|
|
+ summary = {
|
|
|
+ total: state.groups.length,
|
|
|
+ autoConfirmed: counts.AUTO_CONFIRMED || 0,
|
|
|
+ suggested: counts.SUGGESTED || 0,
|
|
|
+ imported: counts.IMPORTED || 0,
|
|
|
+ confirmed: counts.CONFIRMED || 0,
|
|
|
+ rejected: counts.REJECTED || 0
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const stats = document.getElementById('group-stats');
|
|
|
+ if (stats) stats.outerHTML = renderGroupStatsCard(summary);
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindGroupSync(page) {
|
|
|
+ page.querySelector('#btn-sync-groups').addEventListener('click', () => {
|
|
|
+ const guid = getGuid();
|
|
|
+ if (!guid) {
|
|
|
+ toast('请先登录企业微信账号', 'warning');
|
|
|
+ state.currentAccountId = null;
|
|
|
+ renderPage();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const acc = currentAccount();
|
|
|
+ if (!acc || !acc.online) {
|
|
|
+ toast('账号当前离线,请先恢复登录后再扫描群聊', 'warning');
|
|
|
+ location.hash = '#status';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const keywords = document.getElementById('sync-keywords').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ const highConfidence = document.getElementById('sync-high-confidence').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ const body = {
|
|
|
+ guid,
|
|
|
+ scope: document.getElementById('sync-scope').value,
|
|
|
+ maxPages: Number(document.getElementById('sync-max-pages').value) || 20,
|
|
|
+ customerKeywords: keywords,
|
|
|
+ highConfidenceTerms: highConfidence,
|
|
|
+ autoClassify: document.getElementById('sync-auto-classify').checked,
|
|
|
+ scanFromMessages: document.getElementById('sync-scope').value === 'messages'
|
|
|
+ };
|
|
|
+ const tracker = document.getElementById('sync-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('sync-groups', id);
|
|
|
+ startJob('sync-groups', '/api/groups/sync', body, result => {
|
|
|
+ updateGroupStats(result.summary);
|
|
|
+ loadGroups();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindGroupFilters(page) {
|
|
|
+ const refresh = () => {
|
|
|
+ state.groupFilters.keyword = document.getElementById('filter-keyword').value;
|
|
|
+ state.groupFilters.status = document.getElementById('filter-status').value;
|
|
|
+ renderGroupTable(state.groups);
|
|
|
+ };
|
|
|
+ page.querySelector('#filter-keyword').addEventListener('input', refresh);
|
|
|
+ page.querySelector('#filter-status').addEventListener('change', refresh);
|
|
|
+ page.querySelector('#btn-refresh-groups').addEventListener('click', loadGroups);
|
|
|
+
|
|
|
+ const customerOnlyBtn = page.querySelector('#btn-filter-customer-only');
|
|
|
+ customerOnlyBtn.addEventListener('click', () => {
|
|
|
+ state.groupFilters.showCustomerOnly = !state.groupFilters.showCustomerOnly;
|
|
|
+ customerOnlyBtn.classList.toggle('active', state.groupFilters.showCustomerOnly);
|
|
|
+ customerOnlyBtn.textContent = state.groupFilters.showCustomerOnly ? '展示全部状态的群聊' : '只看可能的客户群';
|
|
|
+ renderGroupTable(state.groups);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function openGroupDetailModal(roomId, roomName) {
|
|
|
+ const group = state.groups.find(g => g.roomId === roomId) || {};
|
|
|
+ const members = group.members || [];
|
|
|
+
|
|
|
+ // 自动识别客户:优先选择外部联系人(type === 2)
|
|
|
+ let suggestedCustomer = null;
|
|
|
+ const externalMembers = members.filter(m => m.type === 2 || String(m.type) === '2');
|
|
|
+ if (externalMembers.length) {
|
|
|
+ suggestedCustomer = externalMembers[0];
|
|
|
+ } else if (members.length) {
|
|
|
+ // 没有明确外部标识时,排除常见内部角色后取第一个
|
|
|
+ suggestedCustomer = members.find(m => {
|
|
|
+ const name = String(m.userName || '').trim();
|
|
|
+ return name && !/^(企业微信|WeChat|系统|客服|顾问|销售|管理员)/.test(name);
|
|
|
+ }) || members[0];
|
|
|
+ }
|
|
|
+
|
|
|
+ const defaultCustomerName = group.customerName || (suggestedCustomer?.userName) || '';
|
|
|
+ const defaultExternalUserId = group.externalUserId || (suggestedCustomer?.userId) || '';
|
|
|
+ const defaultCustomerId = group.customerId || '';
|
|
|
+
|
|
|
+ openModal('群详情 / 客户信息', `
|
|
|
+ <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
|
|
|
+ <div class="form-group" style="margin-bottom:12px">
|
|
|
+ <label>客户编号(可选)</label>
|
|
|
+ <input id="detail-customer-id" type="text" value="${escapeHtml(defaultCustomerId)}" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:12px">
|
|
|
+ <label>客户企微 ID(可选)</label>
|
|
|
+ <input id="detail-external-user-id" type="text" value="${escapeHtml(defaultExternalUserId)}" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:12px">
|
|
|
+ <label>客户姓名(可选)</label>
|
|
|
+ <input id="detail-customer-name" type="text" value="${escapeHtml(defaultCustomerName)}" />
|
|
|
+ </div>
|
|
|
+ ${members.length ? `
|
|
|
+ <div class="form-group">
|
|
|
+ <label>群成员(自动识别参考)</label>
|
|
|
+ <div style="max-height:120px;overflow-y:auto;border:1px solid var(--border);border-radius:var(--radius-sm);padding:8px;font-size:13px;color:var(--text-secondary)">
|
|
|
+ ${members.map(m => `<div style="padding:4px 0">${escapeHtml(m.userName || m.userId || '未知成员')} ${m.type === 2 ? '<span class="badge badge-info" style="margin-left:6px">外部联系人</span>' : ''}</div>`).join('')}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+ `, `
|
|
|
+ <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
|
|
|
+ <button class="btn" id="btn-save-group-detail">保存</button>
|
|
|
+ `);
|
|
|
+
|
|
|
+ document.getElementById('btn-save-group-detail').addEventListener('click', async () => {
|
|
|
+ const body = {
|
|
|
+ roomId,
|
|
|
+ customerId: document.getElementById('detail-customer-id').value.trim() || undefined,
|
|
|
+ externalUserId: document.getElementById('detail-external-user-id').value.trim() || undefined,
|
|
|
+ customerName: document.getElementById('detail-customer-name').value.trim() || undefined
|
|
|
+ };
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/groups/confirm', body);
|
|
|
+ closeModal();
|
|
|
+ toast(res.assistantMessage || '已保存');
|
|
|
+ loadGroups();
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '保存失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function openRejectModal(roomId, roomName) {
|
|
|
+ openModal('忽略这个群', `
|
|
|
+ <p style="color:var(--text-secondary);font-size:13px;margin-bottom:16px">群名:${escapeHtml(roomName || roomId)}</p>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>原因(可选)</label>
|
|
|
+ <input id="reject-reason" type="text" value="不是客户群" />
|
|
|
+ </div>
|
|
|
+ `, `
|
|
|
+ <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
|
|
|
+ <button class="btn btn-danger" id="btn-do-reject">忽略</button>
|
|
|
+ `);
|
|
|
+ document.getElementById('btn-do-reject').addEventListener('click', async () => {
|
|
|
+ const body = { roomId, reason: document.getElementById('reject-reason').value.trim() };
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/groups/reject', body);
|
|
|
+ closeModal();
|
|
|
+ toast(res.assistantMessage || '已忽略');
|
|
|
+ loadGroups();
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '忽略失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function syncGroupMessages(roomIds) {
|
|
|
+ const guid = getGuid();
|
|
|
+ if (!guid) {
|
|
|
+ toast('请先登录企业微信账号', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const acc = currentAccount();
|
|
|
+ if (!acc || !acc.online) {
|
|
|
+ toast('账号当前离线,请先恢复登录后再同步消息', 'warning');
|
|
|
+ location.hash = '#status';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const body = { guid, roomIds, maxPages: 20, maxMessagesPerRoom: 500 };
|
|
|
+ const name = 'sync-messages-' + roomIds[0];
|
|
|
+ startJob(name, '/api/groups/messages/sync', body, result => {
|
|
|
+ toast(`共同步 ${result.summary?.totalNew || 0} 条消息`);
|
|
|
+ markGroupsSynced(roomIds);
|
|
|
+ renderGroupTable(state.groups);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Helpers ====================
|
|
|
+ function requireOnlineAccount() {
|
|
|
+ const acc = currentAccount();
|
|
|
+ if (!acc) {
|
|
|
+ toast('请先登录企业微信账号', 'warning');
|
|
|
+ state.currentAccountId = null;
|
|
|
+ renderPage();
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ const guid = getGuid();
|
|
|
+ if (!guid) {
|
|
|
+ toast('当前账号缺少 guid,请重新登录', 'warning');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ if (!acc.online) {
|
|
|
+ toast('账号当前离线,请先恢复登录', 'warning');
|
|
|
+ location.hash = '#status';
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderFileLink(filePath, label = '下载文件') {
|
|
|
+ if (!filePath) return '';
|
|
|
+ return `<a class="btn btn-sm btn-secondary" href="/api/outputs?path=${encodeURIComponent(filePath)}" target="_blank">${escapeHtml(label)}</a>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function readFileBase64(file) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onload = () => resolve(String(reader.result).split(',')[1]);
|
|
|
+ reader.onerror = reject;
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Customer Operations ====================
|
|
|
+ function parseCustomerText(text) {
|
|
|
+ return text.split(/\n|\r/).map(line => line.trim()).filter(Boolean).map(line => {
|
|
|
+ const parts = line.split(/\s+/);
|
|
|
+ const phone = parts.find(p => /^1[3-9]\d{9}$/.test(p)) || '';
|
|
|
+ const name = parts.find(p => !/^1[3-9]\d{9}$/.test(p)) || '';
|
|
|
+ return { phone, name };
|
|
|
+ }).filter(c => c.phone);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderCustomerOpsPage() {
|
|
|
+ els.pageTitle.textContent = '客户运营';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page';
|
|
|
+ page.innerHTML = `
|
|
|
+ <div class="hint-box">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>批量加好友、检查好友状态、查询客户档案、自动建群。所有操作都会通过企业微信网关实时执行。</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M15 12c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm-9-2V7H4v3H1v2h3v3h2v-3h3v-2H6zm9 4c-2.67 0-8 1.34-8 4v2h16v-2c0-2.66-5.33-4-8-4z"/></svg>
|
|
|
+ 批量加好友
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">每行输入「手机号 姓名」,支持粘贴或上传 Excel</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:2;min-width:260px">
|
|
|
+ <label>客户列表</label>
|
|
|
+ <textarea id="co-customer-text" rows="6" placeholder="13800138000 张三\n13800138001 李四"></textarea>
|
|
|
+ <input type="file" id="co-customer-file" accept=".xlsx,.xls" style="margin-top:8px" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="flex:1;min-width:220px">
|
|
|
+ <label>默认验证消息</label>
|
|
|
+ <textarea id="co-default-greeting" rows="3">${escapeHtml(state.customerOps.defaultGreeting)}</textarea>
|
|
|
+ <span style="font-size:12px;color:var(--text-muted)">支持 {{name}}、{{phone}} 变量</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>速率限制(个/分钟)</label>
|
|
|
+ <input id="co-rate-limit" type="number" min="1" max="60" value="${state.customerOps.rateLimit}" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>最大重试次数</label>
|
|
|
+ <input id="co-max-attempts" type="number" min="1" max="5" value="${state.customerOps.maxAttempts}" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="co-customer-table-wrap"></div>
|
|
|
+
|
|
|
+ <div style="display:flex;gap:12px;align-items:center;margin-top:16px">
|
|
|
+ <button class="btn" id="btn-batch-add" data-job="batch-add-friends">开始批量添加</button>
|
|
|
+ <span id="co-preview-text" style="font-size:13px;color:var(--text-muted)"></span>
|
|
|
+ </div>
|
|
|
+ <div id="co-add-job-tracker"></div>
|
|
|
+ <div id="co-add-result"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08C16.71 17.92 14.5 19.2 12 19.2z"/></svg>
|
|
|
+ 好友状态检查
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">输入手机号查询是否已是好友或已发送申请</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>手机号列表(每行一个)</label>
|
|
|
+ <textarea id="co-check-phones" rows="4" placeholder="13800138000\n13800138001"></textarea>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-check-friends" data-job="check-friend-status">检查好友状态</button>
|
|
|
+ <div id="co-check-job-tracker"></div>
|
|
|
+ <div id="co-check-result"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M20 2H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h14l4 4V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z"/></svg>
|
|
|
+ 客户档案
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">通过手机号或 externalUserId 查询联系人、群、画像</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>手机号</label>
|
|
|
+ <input id="co-profile-phone" type="text" placeholder="13800138000" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>或 externalUserId</label>
|
|
|
+ <input id="co-profile-external" type="text" placeholder="企业微信外部联系人 ID" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-customer-profile">查询客户档案</button>
|
|
|
+ <div id="co-profile-result"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5s-3 1.34-3 3 1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
|
|
|
+ 自动建群
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">输入成员 externalUserId,自动创建客户群并设置群名、协作成员、欢迎语</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>成员 externalUserId(每行一个,第一个为默认客户)</label>
|
|
|
+ <textarea id="co-auto-members" rows="3" placeholder="externalUserId1\nexternalUserId2"></textarea>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>群名称</label>
|
|
|
+ <input id="co-auto-group-name" type="text" placeholder="专属服务群" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>协作成员 externalUserId(逗号分隔,可选)</label>
|
|
|
+ <input id="co-auto-support" type="text" placeholder="member1,member2" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>欢迎语(可选)</label>
|
|
|
+ <input id="co-auto-welcome" type="text" placeholder="您好,欢迎加入专属服务群" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-auto-create-group" data-job="auto-create-group">创建客户群</button>
|
|
|
+ <div id="co-auto-job-tracker"></div>
|
|
|
+ <div id="co-auto-result"></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+ bindCustomerOpsPage(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindCustomerOpsPage(page) {
|
|
|
+ const textArea = page.querySelector('#co-customer-text');
|
|
|
+ const fileInput = page.querySelector('#co-customer-file');
|
|
|
+ const greetingInput = page.querySelector('#co-default-greeting');
|
|
|
+ const previewText = page.querySelector('#co-preview-text');
|
|
|
+ const tableWrap = page.querySelector('#co-customer-table-wrap');
|
|
|
+
|
|
|
+ function renderCustomerTable() {
|
|
|
+ const customers = state.customerOps.customers;
|
|
|
+ if (!customers.length) {
|
|
|
+ tableWrap.innerHTML = '';
|
|
|
+ previewText.textContent = '';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const greeting = greetingInput.value.trim() || state.customerOps.defaultGreeting;
|
|
|
+ const first = customers[0];
|
|
|
+ const preview = greeting.replace(/\{\{name\}\}/g, first.name || '客户').replace(/\{\{phone\}\}/g, first.phone || '');
|
|
|
+ previewText.textContent = `预览:${preview}`;
|
|
|
+
|
|
|
+ tableWrap.innerHTML = `
|
|
|
+ <div class="table-wrap" style="margin-top:16px">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>手机号</th><th>姓名</th><th>自定义验证消息(留空使用默认模板)</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${customers.map((c, i) => `
|
|
|
+ <tr>
|
|
|
+ <td>${escapeHtml(c.phone)}</td>
|
|
|
+ <td><input type="text" data-idx="${i}" data-field="name" value="${escapeHtml(c.name || '')}" style="min-width:80px" /></td>
|
|
|
+ <td><input type="text" data-idx="${i}" data-field="greeting" value="${escapeHtml(c.greeting || '')}" placeholder="使用默认模板" style="width:100%" /></td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ tableWrap.querySelectorAll('input[data-idx]').forEach(input => {
|
|
|
+ input.addEventListener('change', () => {
|
|
|
+ const idx = Number(input.dataset.idx);
|
|
|
+ const field = input.dataset.field;
|
|
|
+ state.customerOps.customers[idx][field] = input.value.trim();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateCustomersFromText() {
|
|
|
+ state.customerOps.customers = parseCustomerText(textArea.value);
|
|
|
+ renderCustomerTable();
|
|
|
+ }
|
|
|
+
|
|
|
+ textArea.addEventListener('input', updateCustomersFromText);
|
|
|
+
|
|
|
+ greetingInput.addEventListener('input', renderCustomerTable);
|
|
|
+
|
|
|
+ fileInput.addEventListener('change', async () => {
|
|
|
+ const file = fileInput.files[0];
|
|
|
+ if (!file) return;
|
|
|
+ try {
|
|
|
+ const base64 = await readFileBase64(file);
|
|
|
+ const res = await api('POST', '/api/upload', { name: file.name, data: base64 });
|
|
|
+ const filePath = res.data?.path;
|
|
|
+ const result = await api('POST', '/api/customer-ops/batch-add-friends', { guid: getGuid(), filePath });
|
|
|
+ const uploaded = result.data?.customers || [];
|
|
|
+ if (uploaded.length) {
|
|
|
+ const existing = new Set(state.customerOps.customers.map(c => c.phone));
|
|
|
+ for (const c of uploaded) {
|
|
|
+ if (!existing.has(c.phone)) {
|
|
|
+ state.customerOps.customers.push(c);
|
|
|
+ existing.add(c.phone);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ textArea.value = state.customerOps.customers.map(c => `${c.phone} ${c.name || ''}`).join('\n');
|
|
|
+ renderCustomerTable();
|
|
|
+ toast(`已导入 ${uploaded.length} 位客户`);
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '导入 Excel 失败', 'error');
|
|
|
+ }
|
|
|
+ fileInput.value = '';
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-batch-add').addEventListener('click', () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ if (!state.customerOps.customers.length) {
|
|
|
+ toast('请先输入客户列表', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const defaultGreeting = greetingInput.value.trim() || state.customerOps.defaultGreeting;
|
|
|
+ const customers = state.customerOps.customers.map(c => ({
|
|
|
+ phone: c.phone,
|
|
|
+ name: c.name || undefined,
|
|
|
+ greeting: c.greeting || undefined
|
|
|
+ }));
|
|
|
+ const body = {
|
|
|
+ guid: getGuid(),
|
|
|
+ customers,
|
|
|
+ defaultGreeting,
|
|
|
+ rateLimitPerMinute: Number(page.querySelector('#co-rate-limit').value) || 12,
|
|
|
+ maxAttempts: Number(page.querySelector('#co-max-attempts').value) || 2
|
|
|
+ };
|
|
|
+ const tracker = page.querySelector('#co-add-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('batch-add-friends', id);
|
|
|
+ const resultWrap = page.querySelector('#co-add-result');
|
|
|
+ resultWrap.innerHTML = '';
|
|
|
+ startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, result => {
|
|
|
+ state.customerOps.lastResult = result;
|
|
|
+ resultWrap.innerHTML = renderBatchAddResult(result);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-check-friends').addEventListener('click', () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const phones = page.querySelector('#co-check-phones').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!phones.length) {
|
|
|
+ toast('请输入手机号', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const body = { guid: getGuid(), phones };
|
|
|
+ const tracker = page.querySelector('#co-check-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('check-friend-status', id);
|
|
|
+ const resultWrap = page.querySelector('#co-check-result');
|
|
|
+ resultWrap.innerHTML = '';
|
|
|
+ startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, result => {
|
|
|
+ resultWrap.innerHTML = renderCheckFriendResult(result);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-customer-profile').addEventListener('click', async () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const phone = page.querySelector('#co-profile-phone').value.trim();
|
|
|
+ const externalUserId = page.querySelector('#co-profile-external').value.trim();
|
|
|
+ if (!phone && !externalUserId) {
|
|
|
+ toast('请输入手机号或 externalUserId', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const resultWrap = page.querySelector('#co-profile-result');
|
|
|
+ resultWrap.innerHTML = '<div class="skeleton" style="height:120px;margin-top:16px"></div>';
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/customer-ops/customer-profile', { guid: getGuid(), phone, externalUserId });
|
|
|
+ resultWrap.innerHTML = renderCustomerProfileResult(res);
|
|
|
+ } catch (err) {
|
|
|
+ resultWrap.innerHTML = `<div class="hint-box" style="background:var(--error-bg);color:var(--error);border-color:var(--error-border);margin-top:16px"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg><div>${escapeHtml(err.message || '查询失败')}</div></div>`;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-auto-create-group').addEventListener('click', () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const members = page.querySelector('#co-auto-members').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!members.length) {
|
|
|
+ toast('请输入至少一个成员 externalUserId', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const support = page.querySelector('#co-auto-support').value.split(',').map(s => s.trim()).filter(Boolean);
|
|
|
+ const body = {
|
|
|
+ guid: getGuid(),
|
|
|
+ memberList: members,
|
|
|
+ groupName: page.querySelector('#co-auto-group-name').value.trim() || undefined,
|
|
|
+ supportMemberIds: support.length ? support : undefined,
|
|
|
+ welcomeText: page.querySelector('#co-auto-welcome').value.trim() || undefined
|
|
|
+ };
|
|
|
+ const tracker = page.querySelector('#co-auto-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('auto-create-group', id);
|
|
|
+ const resultWrap = page.querySelector('#co-auto-result');
|
|
|
+ resultWrap.innerHTML = '';
|
|
|
+ startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, result => {
|
|
|
+ resultWrap.innerHTML = renderAutoCreateGroupResult(result);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ updateCustomersFromText();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderBatchAddResult(result) {
|
|
|
+ const summary = result.summary || {};
|
|
|
+ const details = result.data?.details || [];
|
|
|
+ return `
|
|
|
+ <div class="stats-grid" style="margin-top:16px">
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.success)}</div><div class="stat-label">成功</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.failed)}</div><div class="stat-label">失败</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value">${formatNumber(summary.invalidRemoved)}</div><div class="stat-label">无效</div></div>
|
|
|
+ </div>
|
|
|
+ ${details.length ? `
|
|
|
+ <div class="table-wrap" style="margin-top:16px">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>手机号</th><th>姓名</th><th>状态</th><th>尝试次数</th><th>说明</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${details.map(d => `
|
|
|
+ <tr>
|
|
|
+ <td>${escapeHtml(d.phone)}</td>
|
|
|
+ <td>${escapeHtml(d.name || '-')}</td>
|
|
|
+ <td>${d.status === 'SUCCESS' ? '<span class="badge badge-success">成功</span>' : '<span class="badge badge-error">失败</span>'}</td>
|
|
|
+ <td>${formatNumber(d.attempts)}</td>
|
|
|
+ <td style="color:var(--text-secondary);font-size:12px">${escapeHtml(d.reason || d.status || '-')}</td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCheckFriendResult(result) {
|
|
|
+ const summary = result.summary || {};
|
|
|
+ const details = result.data?.details || [];
|
|
|
+ return `
|
|
|
+ <div class="stats-grid" style="margin-top:16px">
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.confirmed)}</div><div class="stat-label">已确认</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--warning)">${formatNumber(summary.pending)}</div><div class="stat-label">待通过</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.notFound)}</div><div class="stat-label">未找到</div></div>
|
|
|
+ </div>
|
|
|
+ ${details.length ? `
|
|
|
+ <div class="table-wrap" style="margin-top:16px">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>手机号/ID</th><th>姓名</th><th>状态</th><th>说明</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${details.map(d => `
|
|
|
+ <tr>
|
|
|
+ <td>${escapeHtml(d.phone || d.externalUserId || '-')}</td>
|
|
|
+ <td>${escapeHtml(d.name || '-')}</td>
|
|
|
+ <td>${friendStatusBadge(d.statusText)}</td>
|
|
|
+ <td style="color:var(--text-secondary);font-size:12px">${escapeHtml(d.message || '-')}</td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function friendStatusBadge(status) {
|
|
|
+ const map = {
|
|
|
+ 'already_friend': ['badge-success', '已是好友'],
|
|
|
+ 'already_added_by_other': ['badge-info', '已被其他人添加'],
|
|
|
+ 'not_added': ['badge-warning', '未添加'],
|
|
|
+ 'not_found': ['badge-error', '未找到'],
|
|
|
+ 'provided_external_user_id': ['badge-default', '仅提供 ID']
|
|
|
+ };
|
|
|
+ const [cls, text] = map[status] || ['badge-default', status || '未知'];
|
|
|
+ return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCustomerProfileResult(res) {
|
|
|
+ const data = res.data || {};
|
|
|
+ const contact = data.contact || {};
|
|
|
+ const contactList = data.contactList || [];
|
|
|
+ const rooms = data.rooms || [];
|
|
|
+ const portrait = data.portrait || null;
|
|
|
+ return `
|
|
|
+ <div style="margin-top:16px">
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${escapeHtml(contact.externalUserId || '未解析')}</div><div class="stat-label">externalUserId</div></div>
|
|
|
+ <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${formatNumber(contactList.length)}</div><div class="stat-label">匹配联系人</div></div>
|
|
|
+ <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${formatNumber(rooms.length)}</div><div class="stat-label">相关群</div></div>
|
|
|
+ <div class="stat-card" style="flex:1"><div class="stat-value" style="font-size:20px">${portrait ? '有' : '无'}</div><div class="stat-label">本地画像</div></div>
|
|
|
+ </div>
|
|
|
+ ${contactList.length ? `
|
|
|
+ <h4 style="font-size:14px;color:var(--text-secondary);margin:16px 0 8px">联系人列表</h4>
|
|
|
+ <div class="table-wrap"><table><thead><tr><th>userId</th><th>昵称</th><th>备注</th></tr></thead><tbody>
|
|
|
+ ${contactList.map(c => `<tr><td>${escapeHtml(c.userId || c.externalUserId || '-')}</td><td>${escapeHtml(c.nickName || c.userName || '-')}</td><td>${escapeHtml(c.remark || '-')}</td></tr>`).join('')}
|
|
|
+ </tbody></table></div>
|
|
|
+ ` : ''}
|
|
|
+ ${rooms.length ? `
|
|
|
+ <h4 style="font-size:14px;color:var(--text-secondary);margin:16px 0 8px">相关群</h4>
|
|
|
+ <div class="table-wrap"><table><thead><tr><th>群名</th><th>roomId</th></tr></thead><tbody>
|
|
|
+ ${rooms.map(r => `<tr><td>${escapeHtml(r.roomName || '未命名')}</td><td>${escapeHtml(r.roomId || '-')}</td></tr>`).join('')}
|
|
|
+ </tbody></table></div>
|
|
|
+ ` : ''}
|
|
|
+ ${portrait ? `
|
|
|
+ <h4 style="font-size:14px;color:var(--text-secondary);margin:16px 0 8px">客户画像</h4>
|
|
|
+ <div class="job-log">${escapeHtml(JSON.stringify(portrait.portrait || portrait, null, 2))}</div>
|
|
|
+ ` : ''}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAutoCreateGroupResult(result) {
|
|
|
+ const summary = result.summary || {};
|
|
|
+ const warnings = result.warnings || [];
|
|
|
+ return `
|
|
|
+ <div class="hint-box" style="margin-top:16px">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
|
|
+ <div>
|
|
|
+ <div style="font-weight:600">${escapeHtml(result.assistantMessage || '建群完成')}</div>
|
|
|
+ <div style="margin-top:6px;font-size:13px">roomId:${escapeHtml(summary.roomId || '-')}</div>
|
|
|
+ <div style="font-size:13px">群名:${escapeHtml(summary.groupName || '-')}</div>
|
|
|
+ <div style="font-size:13px">成员数:${formatNumber(summary.memberCount)}</div>
|
|
|
+ ${summary.supportInvited ? '<div style="font-size:13px">协作成员:已邀请</div>' : ''}
|
|
|
+ ${summary.welcomeSent ? '<div style="font-size:13px">欢迎语:已发送</div>' : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ${warnings.length ? `<div class="hint-box" style="background:var(--warning-bg);color:#d48806;border-color:#ffe58f"><svg viewBox="0 0 24 24"><path d="M1 21h22L12 2 1 21zm12-3h-2v-2h2v2zm0-4h-2v-4h2v4z"/></svg><div>${warnings.map(w => escapeHtml(w)).join('<br>')}</div></div>` : ''}
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Portraits & Tags ====================
|
|
|
+ async function renderPortraitsPage() {
|
|
|
+ els.pageTitle.textContent = '画像与标签';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page';
|
|
|
+ page.innerHTML = `
|
|
|
+ <div class="hint-box">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>基于群消息生成客户画像、管理本地标签、同步企微个人标签并应用到客户。</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 3c1.66 0 3 1.34 3 3s-1.34 3-3 3-3-1.34-3-3 1.34-3 3-3zm0 14.2c-2.5 0-4.71-1.28-6-3.22.03-1.99 4-3.08 6-3.08 1.99 0 5.97 1.09 6 3.08C16.71 17.92 14.5 19.2 12 19.2z"/></svg>
|
|
|
+ 客户画像
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">关键词模式直接生成保存;AI 模式生成 context 文件供进一步分析</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>externalUserId</label>
|
|
|
+ <input id="pt-external-user-id" type="text" placeholder="外部联系人 ID" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>模式</label>
|
|
|
+ <select id="pt-portrait-mode">
|
|
|
+ <option value="keyword">关键词模式</option>
|
|
|
+ <option value="ai">AI 分析模式</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
|
+ <button class="btn" id="btn-update-portrait" data-job="update-portrait">生成/更新画像</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-save-portrait">手动保存画像 JSON</button>
|
|
|
+ </div>
|
|
|
+ <div id="pt-portrait-job-tracker"></div>
|
|
|
+ <div id="pt-portrait-result"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14H7v-2h7v2zm3-4H7v-2h10v2zm0-4H7V7h10v2z"/></svg>
|
|
|
+ 批量画像
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">输入多个 externalUserId,逐一生成关键词画像</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>externalUserId 列表(每行一个)</label>
|
|
|
+ <textarea id="pt-batch-ids" rows="4" placeholder="externalUserId1\nexternalUserId2"></textarea>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
|
+ <button class="btn" id="btn-batch-portraits" data-job="batch-portraits">批量生成画像</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-export-portraits">导出全部画像 Excel</button>
|
|
|
+ </div>
|
|
|
+ <div id="pt-batch-job-tracker"></div>
|
|
|
+ <div id="pt-batch-result"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M21.41 11.58l-9-9C12.05 2.22 11.55 2 11 2H4c-1.1 0-2 .9-2 2v7c0 .55.22 1.05.59 1.42l9 9c.36.36.86.58 1.41.58.55 0 1.05-.22 1.41-.59l7-7c.37-.36.59-.86.59-1.41 0-.55-.23-1.06-.59-1.42zM5.5 7C4.67 7 4 6.33 4 5.5S4.67 4 5.5 4 7 4.67 7 5.5 6.33 7 5.5 7z"/></svg>
|
|
|
+ 本地标签
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">按客户增删标签、查看全部去重标签</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>externalUserId</label>
|
|
|
+ <input id="pt-tag-external" type="text" placeholder="外部联系人 ID" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>标签(逗号分隔)</label>
|
|
|
+ <input id="pt-tag-input" type="text" placeholder="高意向, 学区房, 急售" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
|
+ <button class="btn" id="btn-add-tags">添加标签</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-remove-tags">移除标签</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-list-tags">查询客户标签</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-list-all-tags">全部去重标签</button>
|
|
|
+ </div>
|
|
|
+ <div id="pt-tags-result"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8 8 3.59 8 8-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>
|
|
|
+ 企微个人标签
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">同步标签列表、创建/编辑/删除、应用到客户</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:16px">
|
|
|
+ <button class="btn btn-secondary" id="btn-sync-personal-labels">同步标签列表</button>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>标签名称</label>
|
|
|
+ <input id="pl-name" type="text" placeholder="标签名" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>父标签 ID(可选)</label>
|
|
|
+ <input id="pl-super" type="text" placeholder="labelSuperId" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>标签 ID(编辑/删除用)</label>
|
|
|
+ <input id="pl-id" type="text" placeholder="labelId" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="display:flex;gap:12px;flex-wrap:wrap">
|
|
|
+ <button class="btn" id="btn-create-personal-label">创建</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-update-personal-label">更新</button>
|
|
|
+ <button class="btn btn-danger" id="btn-delete-personal-label">删除</button>
|
|
|
+ </div>
|
|
|
+ <div id="pl-list-result" style="margin-top:16px"></div>
|
|
|
+ <div class="form-row" style="margin-top:20px">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>客户 externalUserId</label>
|
|
|
+ <input id="pl-apply-external" type="text" placeholder="外部联系人 ID" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>要应用的标签 ID(逗号分隔)</label>
|
|
|
+ <input id="pl-apply-ids" type="text" placeholder="labelId1,labelId2" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-apply-personal-labels">应用标签到客户</button>
|
|
|
+ <div id="pl-apply-result"></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+ bindPortraitsPage(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindPortraitsPage(page) {
|
|
|
+ page.querySelector('#btn-update-portrait').addEventListener('click', () => {
|
|
|
+ const externalUserId = page.querySelector('#pt-external-user-id').value.trim();
|
|
|
+ if (!externalUserId) {
|
|
|
+ toast('请输入 externalUserId', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const mode = page.querySelector('#pt-portrait-mode').value;
|
|
|
+ const body = { externalUserId, aiMode: mode === 'ai' ? 'ai' : 'keyword' };
|
|
|
+ const tracker = page.querySelector('#pt-portrait-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('update-portrait', id);
|
|
|
+ const resultWrap = page.querySelector('#pt-portrait-result');
|
|
|
+ resultWrap.innerHTML = '';
|
|
|
+ startJob('update-portrait', '/api/portraits/update', body, result => {
|
|
|
+ state.portraits.lastPortrait = result;
|
|
|
+ resultWrap.innerHTML = renderPortraitResult(result);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-save-portrait').addEventListener('click', () => {
|
|
|
+ const externalUserId = page.querySelector('#pt-external-user-id').value.trim();
|
|
|
+ if (!externalUserId) {
|
|
|
+ toast('请输入 externalUserId', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const jsonText = prompt('请输入画像 JSON 内容:', state.portraits.lastPortrait ? JSON.stringify(state.portraits.lastPortrait.data?.portrait || {}) : '{}');
|
|
|
+ if (!jsonText) return;
|
|
|
+ try {
|
|
|
+ const portrait = JSON.parse(jsonText);
|
|
|
+ api('POST', '/api/portraits/save', { externalUserId, portrait })
|
|
|
+ .then(res => {
|
|
|
+ toast(res.assistantMessage || '保存成功');
|
|
|
+ page.querySelector('#pt-portrait-result').innerHTML = renderPortraitResult(res);
|
|
|
+ })
|
|
|
+ .catch(err => toast(err.message || '保存失败', 'error'));
|
|
|
+ } catch (e) {
|
|
|
+ toast('JSON 格式错误', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-batch-portraits').addEventListener('click', () => {
|
|
|
+ const ids = page.querySelector('#pt-batch-ids').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!ids.length) {
|
|
|
+ toast('请输入 externalUserId 列表', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const tracker = page.querySelector('#pt-batch-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('batch-portraits', id);
|
|
|
+ const resultWrap = page.querySelector('#pt-batch-result');
|
|
|
+ resultWrap.innerHTML = '';
|
|
|
+ startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: 'keyword' }, result => {
|
|
|
+ resultWrap.innerHTML = renderBatchPortraitResult(result);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-export-portraits').addEventListener('click', () => {
|
|
|
+ const tracker = page.querySelector('#pt-batch-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('export-portraits', id);
|
|
|
+ const resultWrap = page.querySelector('#pt-batch-result');
|
|
|
+ resultWrap.innerHTML = '';
|
|
|
+ startJob('export-portraits', '/api/portraits/export', {}, result => {
|
|
|
+ resultWrap.innerHTML = renderExportPortraitResult(result);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-add-tags').addEventListener('click', async () => {
|
|
|
+ const externalUserId = page.querySelector('#pt-tag-external').value.trim();
|
|
|
+ const tags = page.querySelector('#pt-tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!externalUserId || !tags.length) {
|
|
|
+ toast('请输入 externalUserId 和标签', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const resultWrap = page.querySelector('#pt-tags-result');
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/tags/add', { externalUserId, tags });
|
|
|
+ resultWrap.innerHTML = renderTagsResult(res);
|
|
|
+ toast(res.assistantMessage || '添加成功');
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '添加失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-remove-tags').addEventListener('click', async () => {
|
|
|
+ const externalUserId = page.querySelector('#pt-tag-external').value.trim();
|
|
|
+ const tags = page.querySelector('#pt-tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!externalUserId || !tags.length) {
|
|
|
+ toast('请输入 externalUserId 和标签', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const resultWrap = page.querySelector('#pt-tags-result');
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/tags/remove', { externalUserId, tags });
|
|
|
+ resultWrap.innerHTML = renderTagsResult(res);
|
|
|
+ toast(res.assistantMessage || '移除成功');
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '移除失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-list-tags').addEventListener('click', async () => {
|
|
|
+ const externalUserId = page.querySelector('#pt-tag-external').value.trim();
|
|
|
+ if (!externalUserId) {
|
|
|
+ toast('请输入 externalUserId', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const resultWrap = page.querySelector('#pt-tags-result');
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/tags/list', { externalUserId });
|
|
|
+ resultWrap.innerHTML = renderTagsResult(res);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '查询失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-list-all-tags').addEventListener('click', async () => {
|
|
|
+ const resultWrap = page.querySelector('#pt-tags-result');
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/tags/all', {});
|
|
|
+ resultWrap.innerHTML = renderTagsResult(res, true);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '查询失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-sync-personal-labels').addEventListener('click', async () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const resultWrap = page.querySelector('#pl-list-result');
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/personal-labels/sync', { guid: getGuid() });
|
|
|
+ state.portraits.personalLabels = res.data?.labels || [];
|
|
|
+ resultWrap.innerHTML = renderPersonalLabelsResult(res);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '同步失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-create-personal-label').addEventListener('click', async () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const labelName = page.querySelector('#pl-name').value.trim();
|
|
|
+ if (!labelName) {
|
|
|
+ toast('请输入标签名称', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/personal-labels/create', { guid: getGuid(), labelName, labelSuperId: page.querySelector('#pl-super').value.trim() || undefined });
|
|
|
+ toast(res.assistantMessage || '创建成功');
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '创建失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-update-personal-label').addEventListener('click', async () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const labelId = page.querySelector('#pl-id').value.trim();
|
|
|
+ const labelName = page.querySelector('#pl-name').value.trim();
|
|
|
+ if (!labelId || !labelName) {
|
|
|
+ toast('请输入标签 ID 和名称', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/personal-labels/update', { guid: getGuid(), labelId, labelName, labelSuperId: page.querySelector('#pl-super').value.trim() || undefined });
|
|
|
+ toast(res.assistantMessage || '更新成功');
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '更新失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-delete-personal-label').addEventListener('click', async () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const labelId = page.querySelector('#pl-id').value.trim();
|
|
|
+ if (!labelId) {
|
|
|
+ toast('请输入标签 ID', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!confirm(`确定删除标签 ${labelId} 吗?`)) return;
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/personal-labels/delete', { guid: getGuid(), labelId });
|
|
|
+ toast(res.assistantMessage || '删除成功');
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '删除失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-apply-personal-labels').addEventListener('click', async () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const externalUserId = page.querySelector('#pl-apply-external').value.trim();
|
|
|
+ const addLabels = page.querySelector('#pl-apply-ids').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!externalUserId || !addLabels.length) {
|
|
|
+ toast('请输入 externalUserId 和标签 ID', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const resultWrap = page.querySelector('#pl-apply-result');
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/personal-labels/apply', { guid: getGuid(), externalUserId, addLabels });
|
|
|
+ resultWrap.innerHTML = `<div class="hint-box" style="margin-top:16px"><svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg><div>${escapeHtml(res.assistantMessage || '应用成功')}</div></div>`;
|
|
|
+ toast(res.assistantMessage || '应用成功');
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '应用失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitResult(result) {
|
|
|
+ const summary = result.summary || {};
|
|
|
+ const data = result.data || {};
|
|
|
+ const files = result.files || [];
|
|
|
+ return `
|
|
|
+ <div class="hint-box" style="margin-top:16px">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
|
|
+ <div>
|
|
|
+ <div style="font-weight:600">${escapeHtml(result.assistantMessage || '画像生成完成')}</div>
|
|
|
+ <div style="margin-top:6px;font-size:13px">消息数:${formatNumber(summary.messageCount || 0)}</div>
|
|
|
+ <div style="font-size:13px">字段:${escapeHtml((summary.fields || []).join('、') || '-')}</div>
|
|
|
+ ${files.length ? `<div style="margin-top:8px">${renderFileLink(files[0], '下载 context 文件')}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ${data.portrait ? `<div class="job-log" style="margin-top:12px">${escapeHtml(JSON.stringify(data.portrait, null, 2))}</div>` : ''}
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderBatchPortraitResult(result) {
|
|
|
+ const summary = result.summary || {};
|
|
|
+ const results = result.data?.results || [];
|
|
|
+ return `
|
|
|
+ <div class="stats-grid" style="margin-top:16px">
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.succeeded)}</div><div class="stat-label">成功</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.failed)}</div><div class="stat-label">失败</div></div>
|
|
|
+ </div>
|
|
|
+ ${results.length ? `
|
|
|
+ <div class="table-wrap" style="margin-top:16px">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>externalUserId</th><th>状态</th><th>消息数</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${results.map(r => `
|
|
|
+ <tr>
|
|
|
+ <td>${escapeHtml(r.externalUserId)}</td>
|
|
|
+ <td>${r.status === 'ok' ? '<span class="badge badge-success">成功</span>' : '<span class="badge badge-error">失败</span>'}</td>
|
|
|
+ <td>${formatNumber(r.summary?.messageCount || 0)}</td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderExportPortraitResult(result) {
|
|
|
+ const summary = result.summary || {};
|
|
|
+ const files = result.files || [];
|
|
|
+ const filePath = result.data?.filePath || files[0];
|
|
|
+ return `
|
|
|
+ <div class="hint-box" style="margin-top:16px">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
|
|
+ <div>
|
|
|
+ <div style="font-weight:600">${escapeHtml(result.assistantMessage || '导出完成')}</div>
|
|
|
+ <div style="margin-top:6px;font-size:13px">行数:${formatNumber(summary.rowCount || 0)}</div>
|
|
|
+ ${filePath ? `<div style="margin-top:8px">${renderFileLink(filePath, '下载 Excel')}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTagsResult(res, all = false) {
|
|
|
+ const tags = res.data?.tags || [];
|
|
|
+ return `
|
|
|
+ <div style="margin-top:16px">
|
|
|
+ <div style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">${all ? '全部去重标签' : `客户 ${escapeHtml(res.summary?.externalUserId || '')} 的标签`}(${tags.length} 个)</div>
|
|
|
+ <div class="tag-list">
|
|
|
+ ${tags.length ? tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '<span style="color:var(--text-muted);font-size:13px">暂无标签</span>'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPersonalLabelsResult(res) {
|
|
|
+ const labels = res.data?.labels || [];
|
|
|
+ return `
|
|
|
+ <div style="margin-top:16px">
|
|
|
+ <div style="font-size:13px;color:var(--text-secondary);margin-bottom:8px">共 ${labels.length} 个标签</div>
|
|
|
+ ${labels.length ? `
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>标签 ID</th><th>标签名</th><th>父标签</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${labels.map(l => `<tr><td>${escapeHtml(l.labelId || '-')}</td><td>${escapeHtml(l.labelName || '-')}</td><td>${escapeHtml(l.labelSuperId || '-')}</td></tr>`).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ ` : '<span style="color:var(--text-muted);font-size:13px">暂无标签</span>'}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Customer Transfers ====================
|
|
|
+ async function renderTransfersPage() {
|
|
|
+ els.pageTitle.textContent = '客户交接';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page';
|
|
|
+ page.innerHTML = `
|
|
|
+ <div class="hint-box">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div>选择原顾问和新顾问,预览交接包后执行。支持通过已确认群或 externalUserIds 指定客户。</div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M16.01 11H4v2h12.01v3L20 12l-3.99-4v3zM6 17l-4-4 4-4v3h12v2H6v3z"/></svg>
|
|
|
+ 交接包预览
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">生成预览文件,确认后再执行交接</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>原顾问 userId(fromUserId)</label>
|
|
|
+ <input id="tf-from" type="text" placeholder="原顾问 userId" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>新顾问 userId(toUserId)</label>
|
|
|
+ <input id="tf-to" type="text" placeholder="新顾问 userId" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>externalUserIds(每行一个,可选)</label>
|
|
|
+ <textarea id="tf-external-ids" rows="3" placeholder="externalUserId1\nexternalUserId2"></textarea>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>已确认群 roomIds(逗号分隔,可选)</label>
|
|
|
+ <input id="tf-room-ids" type="text" placeholder="roomId1,roomId2" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-preview-transfer">生成预览</button>
|
|
|
+ <div id="tf-preview-result"></div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ 执行交接
|
|
|
+ </h3>
|
|
|
+ <div class="card-subtitle">基于最新预览包执行,可移除原顾问并发送欢迎语</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-row">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>预览文件路径(可选,留空使用最新预览)</label>
|
|
|
+ <input id="tf-preview-file" type="text" placeholder="outputs/transfers/..." />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="flex:1">
|
|
|
+ <label>欢迎语</label>
|
|
|
+ <input id="tf-welcome" type="text" value="您好,我是您的新服务顾问,后续由我为您服务。" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="checkbox-row" style="margin-bottom:16px">
|
|
|
+ <input id="tf-remove-old" type="checkbox" />
|
|
|
+ <label for="tf-remove-old">交接后移除原顾问</label>
|
|
|
+ </div>
|
|
|
+ <button class="btn" id="btn-execute-transfer" data-job="execute-transfer">执行交接</button>
|
|
|
+ <div id="tf-execute-job-tracker"></div>
|
|
|
+ <div id="tf-execute-result"></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+ bindTransfersPage(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindTransfersPage(page) {
|
|
|
+ page.querySelector('#btn-preview-transfer').addEventListener('click', async () => {
|
|
|
+ const fromUserId = page.querySelector('#tf-from').value.trim();
|
|
|
+ const toUserId = page.querySelector('#tf-to').value.trim();
|
|
|
+ if (!fromUserId || !toUserId) {
|
|
|
+ toast('请输入原顾问和新顾问 userId', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const externalUserIds = page.querySelector('#tf-external-ids').value.split(/\n|\r/).map(s => s.trim()).filter(Boolean);
|
|
|
+ const roomIds = page.querySelector('#tf-room-ids').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!externalUserIds.length && !roomIds.length) {
|
|
|
+ toast('请输入 externalUserIds 或已确认群 roomIds', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const resultWrap = page.querySelector('#tf-preview-result');
|
|
|
+ resultWrap.innerHTML = '<div class="skeleton" style="height:80px;margin-top:16px"></div>';
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/transfers/preview', { fromUserId, toUserId, externalUserIds, roomIds });
|
|
|
+ state.portraits.lastTransferPreview = res.data?.preview || null;
|
|
|
+ resultWrap.innerHTML = renderTransferPreviewResult(res);
|
|
|
+ if (res.data?.filePath) {
|
|
|
+ page.querySelector('#tf-preview-file').value = res.data.filePath;
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ resultWrap.innerHTML = `<div class="hint-box" style="background:var(--error-bg);color:var(--error);border-color:var(--error-border);margin-top:16px"><svg viewBox="0 0 24 24"><path d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg><div>${escapeHtml(err.message || '预览失败')}</div></div>`;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#btn-execute-transfer').addEventListener('click', () => {
|
|
|
+ if (!requireOnlineAccount()) return;
|
|
|
+ const previewFile = page.querySelector('#tf-preview-file').value.trim() || undefined;
|
|
|
+ const welcomeText = page.querySelector('#tf-welcome').value.trim();
|
|
|
+ const removeOldBroker = page.querySelector('#tf-remove-old').checked;
|
|
|
+ const body = { guid: getGuid(), previewFile, welcomeText, removeOldBroker };
|
|
|
+ const tracker = page.querySelector('#tf-execute-job-tracker');
|
|
|
+ const id = Date.now().toString(36);
|
|
|
+ tracker.innerHTML = renderJobTracker('execute-transfer', id);
|
|
|
+ const resultWrap = page.querySelector('#tf-execute-result');
|
|
|
+ resultWrap.innerHTML = '';
|
|
|
+ startJob('execute-transfer', '/api/transfers/execute', body, result => {
|
|
|
+ resultWrap.innerHTML = renderTransferExecuteResult(result);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTransferPreviewResult(res) {
|
|
|
+ const data = res.data || {};
|
|
|
+ const preview = data.preview || {};
|
|
|
+ const items = preview.items || [];
|
|
|
+ const files = res.files || [];
|
|
|
+ return `
|
|
|
+ <div class="hint-box" style="margin-top:16px">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41z"/></svg>
|
|
|
+ <div>
|
|
|
+ <div style="font-weight:600">${escapeHtml(res.assistantMessage || '预览已生成')}</div>
|
|
|
+ <div style="margin-top:6px;font-size:13px">原顾问:${escapeHtml(preview.fromUserId || '-')}</div>
|
|
|
+ <div style="font-size:13px">新顾问:${escapeHtml(preview.toUserId || '-')}</div>
|
|
|
+ <div style="font-size:13px">记录数:${formatNumber(items.length)}</div>
|
|
|
+ ${files.length ? `<div style="margin-top:8px">${renderFileLink(files[0], '下载预览文件')}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ${items.length ? `
|
|
|
+ <div class="table-wrap" style="margin-top:16px">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>类型</th><th>roomId</th><th>externalUserId</th><th>客户名</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${items.map(item => `
|
|
|
+ <tr>
|
|
|
+ <td>${item.action === 'transfer_group_ownership' ? '群交接' : '客户交接'}</td>
|
|
|
+ <td>${escapeHtml(item.roomId || '-')}</td>
|
|
|
+ <td>${escapeHtml(item.externalUserId || '-')}</td>
|
|
|
+ <td>${escapeHtml(item.customerName || '-')}</td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTransferExecuteResult(result) {
|
|
|
+ const summary = result.summary || {};
|
|
|
+ const data = result.data || {};
|
|
|
+ const results = data.results || [];
|
|
|
+ const files = result.files || [];
|
|
|
+ return `
|
|
|
+ <div class="stats-grid" style="margin-top:16px">
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--brand)">${formatNumber(summary.total)}</div><div class="stat-label">总数</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--success)">${formatNumber(summary.success)}</div><div class="stat-label">成功</div></div>
|
|
|
+ <div class="stat-card"><div class="stat-value" style="color:var(--error)">${formatNumber(summary.failed)}</div><div class="stat-label">失败</div></div>
|
|
|
+ </div>
|
|
|
+ ${files.length ? `<div style="margin-top:12px">${renderFileLink(files[0], '下载执行结果')}</div>` : ''}
|
|
|
+ ${results.length ? `
|
|
|
+ <div class="table-wrap" style="margin-top:16px">
|
|
|
+ <table>
|
|
|
+ <thead><tr><th>类型</th><th>roomId</th><th>externalUserId</th><th>状态</th><th>说明</th></tr></thead>
|
|
|
+ <tbody>
|
|
|
+ ${results.map(item => `
|
|
|
+ <tr>
|
|
|
+ <td>${item.action === 'transfer_group_ownership' ? '群交接' : '客户交接'}</td>
|
|
|
+ <td>${escapeHtml(item.roomId || '-')}</td>
|
|
|
+ <td>${escapeHtml(item.externalUserId || '-')}</td>
|
|
|
+ <td>${item.status === 'SUCCESS' ? '<span class="badge badge-success">成功</span>' : item.status === 'SKIPPED' ? '<span class="badge badge-warning">跳过</span>' : '<span class="badge badge-error">失败</span>'}</td>
|
|
|
+ <td style="color:var(--text-secondary);font-size:12px">${escapeHtml(item.reason || '-')}</td>
|
|
|
+ </tr>
|
|
|
+ `).join('')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ ` : ''}
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Init ====================
|
|
|
+ async function checkExistingLogin() {
|
|
|
+ try {
|
|
|
+ const res = await api('GET', '/api/status');
|
|
|
+ const login = res.data?.login;
|
|
|
+ if (login?.summary?.online && login?.data?.detail) {
|
|
|
+ const detail = login.data.detail;
|
|
|
+ const guid = detail.guid || detail.userId;
|
|
|
+ const existing = state.accounts.find(a => a.guid === guid || a.userId === detail.userId);
|
|
|
+ if (!existing) {
|
|
|
+ addAccount({
|
|
|
+ guid,
|
|
|
+ userId: detail.userId || '',
|
|
|
+ nickname: detail.nickname || detail.userId || '',
|
|
|
+ corpName: detail.corpName || '',
|
|
|
+ corpId: detail.corpId || '',
|
|
|
+ online: true
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ existing.online = true;
|
|
|
+ existing.nickname = detail.nickname || existing.nickname;
|
|
|
+ existing.corpName = detail.corpName || existing.corpName;
|
|
|
+ saveAccounts();
|
|
|
+ }
|
|
|
+ if (!state.currentAccountId && state.accounts.length) {
|
|
|
+ state.currentAccountId = state.accounts[0].id;
|
|
|
+ saveAccounts();
|
|
|
+ }
|
|
|
+ updateAccountSwitcher();
|
|
|
+ } else {
|
|
|
+ // 账号不在线:更新账号状态,但不清空已同步记录
|
|
|
+ const acc = currentAccount();
|
|
|
+ const statusCode = login?.data?.statusCode;
|
|
|
+ if (acc) {
|
|
|
+ acc.online = false;
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ }
|
|
|
+ // 如果设备已配置且状态为 0(可免扫码),自动尝试恢复登录
|
|
|
+ if (acc && statusCode === 0 && !state.autoRecoverAttempted) {
|
|
|
+ state.autoRecoverAttempted = true;
|
|
|
+ const recovered = await attemptAutoRecover(true);
|
|
|
+ if (recovered) {
|
|
|
+ toast('账号已自动恢复登录');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ console.error('检测已有登录失败', err);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function init() {
|
|
|
+ updateAccountSwitcher();
|
|
|
+ await checkExistingLogin();
|
|
|
+
|
|
|
+ els.accountBtn.addEventListener('click', e => {
|
|
|
+ e.stopPropagation();
|
|
|
+ els.accountDropdown.classList.toggle('open');
|
|
|
+ });
|
|
|
+
|
|
|
+ document.addEventListener('click', e => {
|
|
|
+ if (!els.accountDropdown.contains(e.target) && e.target !== els.accountBtn) {
|
|
|
+ els.accountDropdown.classList.remove('open');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('btn-add-account').addEventListener('click', () => {
|
|
|
+ state.currentAccountId = null;
|
|
|
+ location.hash = '#groups';
|
|
|
+ renderPage();
|
|
|
+ });
|
|
|
+
|
|
|
+ els.menuBtn.addEventListener('click', () => {
|
|
|
+ els.sidebar.classList.toggle('open');
|
|
|
+ });
|
|
|
+
|
|
|
+ window.addEventListener('hashchange', renderPage);
|
|
|
+
|
|
|
+ document.addEventListener('click', e => {
|
|
|
+ const link = e.target.closest('.nav-item');
|
|
|
+ if (link) els.sidebar.classList.remove('open');
|
|
|
+ });
|
|
|
+
|
|
|
+ renderPage();
|
|
|
+ }
|
|
|
+
|
|
|
+ init();
|
|
|
+})();
|