|
|
@@ -0,0 +1,7629 @@
|
|
|
+(function () {
|
|
|
+ 'use strict';
|
|
|
+
|
|
|
+ const API_BASE = '';
|
|
|
+ const LEGACY_ACCOUNTS_KEY = 'qiwei-accounts';
|
|
|
+ const LEGACY_CURRENT_ACCOUNT_KEY = 'qiwei-current-account';
|
|
|
+ let ACCOUNTS_KEY = LEGACY_ACCOUNTS_KEY;
|
|
|
+ let CURRENT_ACCOUNT_KEY = LEGACY_CURRENT_ACCOUNT_KEY;
|
|
|
+
|
|
|
+ const CO_FILTERS_KEY = 'qiwei-co-filters';
|
|
|
+ const PT_FILTERS_KEY = 'qiwei-pt-filters';
|
|
|
+ const TF_FILTERS_KEY = 'qiwei-tf-filters';
|
|
|
+ const GROUP_FILTERS_KEY = 'qiwei-group-filters';
|
|
|
+ const AGENT_SNAPSHOT_KEY = 'qiwei-agent-snapshot-v1';
|
|
|
+
|
|
|
+ function loadFilters(key, defaults = {}) {
|
|
|
+ try {
|
|
|
+ return { ...defaults, ...JSON.parse(localStorage.getItem(key) || '{}') };
|
|
|
+ } catch {
|
|
|
+ return defaults;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ function saveFilters(key, filters) {
|
|
|
+ localStorage.setItem(key, JSON.stringify(filters));
|
|
|
+ }
|
|
|
+
|
|
|
+ function loadAgentSnapshot() {
|
|
|
+ try {
|
|
|
+ const snapshot = JSON.parse(sessionStorage.getItem(AGENT_SNAPSHOT_KEY) || 'null');
|
|
|
+ return snapshot && typeof snapshot === 'object' ? snapshot : {};
|
|
|
+ } catch {
|
|
|
+ return {};
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveAgentSnapshot(snapshot) {
|
|
|
+ try {
|
|
|
+ sessionStorage.setItem(AGENT_SNAPSHOT_KEY, JSON.stringify(snapshot));
|
|
|
+ } catch {
|
|
|
+ // 快照只是刷新体验优化;容量不足时不影响真实会话数据。
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ const initialAgentSnapshot = loadAgentSnapshot();
|
|
|
+
|
|
|
+ const state = {
|
|
|
+ page: 'overview',
|
|
|
+ accounts: [],
|
|
|
+ currentAccountId: null,
|
|
|
+ status: null,
|
|
|
+ groups: [],
|
|
|
+ groupAutoSyncStarted: false,
|
|
|
+ groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }),
|
|
|
+ groupKeywords: null,
|
|
|
+ groupOps: { overview: null, playbooks: [], loading: false, view: 'overview' },
|
|
|
+ overview: { data: null, query: '', diagnosis: null, loading: false, loadPromise: null, candidateActionIds: new Set() },
|
|
|
+ jobs: {},
|
|
|
+ renderToken: 0,
|
|
|
+ bootstrapping: true,
|
|
|
+ toastId: 0,
|
|
|
+ modal: null,
|
|
|
+ loginTimer: null,
|
|
|
+ loginPhase: 'idle',
|
|
|
+ loginUid: null,
|
|
|
+ loginPreviousAccountId: null,
|
|
|
+ autoRecoverAttempted: false,
|
|
|
+ preRecoveryPage: null,
|
|
|
+ selectedGroupIds: new Set(),
|
|
|
+ customerOps: { customers: [], defaultGreeting: '您好 {{name}},我是您的专属顾问,方便加您企业微信沟通。', rateLimit: 12, maxAttempts: 2, lastResult: null, master: { hub: null, selectedId: null, keyword: '', loading: false }, ...loadFilters(CO_FILTERS_KEY, { tablePage: 1, keyword: '', status: '', portrait: '', tag: '' }) },
|
|
|
+ portraits: { lastPortrait: null, lastTags: [], personalLabels: [], personalLabelsLoaded: false, directoryCustomers: [], dashboardSnapshot: null, loadingPromise: null, lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) },
|
|
|
+ agent: {
|
|
|
+ status: initialAgentSnapshot.status || null,
|
|
|
+ conversations: Array.isArray(initialAgentSnapshot.conversations) ? initialAgentSnapshot.conversations : [],
|
|
|
+ responseMonitor: null,
|
|
|
+ groupAgents: [],
|
|
|
+ selectedId: initialAgentSnapshot.selectedId || null,
|
|
|
+ selectedGroupId: null,
|
|
|
+ listMode: 'private',
|
|
|
+ timer: null,
|
|
|
+ loading: false,
|
|
|
+ signature: initialAgentSnapshot.signature || null,
|
|
|
+ replyEdits: {},
|
|
|
+ groupReplyEdits: {},
|
|
|
+ voiceTone: 'auto',
|
|
|
+ renderPending: false,
|
|
|
+ insightInteractionUntil: 0,
|
|
|
+ openDrawer: null,
|
|
|
+ activityOpen: false,
|
|
|
+ intakePolicy: { mode: 'allowlist_only', welcomeEnabled: false, welcomeText: '', welcomeSendMode: 'draft' },
|
|
|
+ intakePolicyLoaded: false,
|
|
|
+ intakePolicyLoading: false,
|
|
|
+ intakePolicyError: '',
|
|
|
+ cachedAccountKey: initialAgentSnapshot.accountKey || null
|
|
|
+ },
|
|
|
+ skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' },
|
|
|
+ knowledge: { tree: null, selectedNodeId: null, file: null, expandedFolderIds: new Set(), foldersInitialized: false, meetings: { hub: null, selectedId: null, loading: false }, documents: { hub: null, selectedId: null, loading: false }, todos: { hub: null, selectedId: null, loading: false, userResults: [] }, tasks: { hub: null, loading: false, filters: { q: '', source: '', status: 'active' } } },
|
|
|
+ transfers: { lastTransferPreview: null, candidates: [], groups: [], dashboardSnapshot: null, loadingPromise: null, ...loadFilters(TF_FILTERS_KEY, { tablePage: 1, status: '', user: '' }) }
|
|
|
+ };
|
|
|
+
|
|
|
+ const els = {
|
|
|
+ content: document.getElementById('content'),
|
|
|
+ pageTitle: document.getElementById('page-title'),
|
|
|
+ nav: document.getElementById('nav'),
|
|
|
+ sidebar: document.getElementById('sidebar'),
|
|
|
+ sidebarBackdrop: document.getElementById('sidebar-backdrop'),
|
|
|
+ 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, timeoutMs = 0) {
|
|
|
+ const opts = { method, headers: {} };
|
|
|
+ const controller = timeoutMs > 0 ? new AbortController() : null;
|
|
|
+ const timeout = controller ? setTimeout(() => controller.abort(), timeoutMs) : null;
|
|
|
+ if (controller) opts.signal = controller.signal;
|
|
|
+ if (body !== undefined) {
|
|
|
+ opts.headers['Content-Type'] = 'application/json; charset=utf-8';
|
|
|
+ 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;
|
|
|
+ }).catch(error => {
|
|
|
+ if (error?.name === 'AbortError') throw new Error('状态检查超时,请稍后重试');
|
|
|
+ throw error;
|
|
|
+ }).finally(() => {
|
|
|
+ if (timeout) clearTimeout(timeout);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function currentAccount() {
|
|
|
+ return state.accounts.find(a => a.id === state.currentAccountId) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentAccountKey(account = currentAccount()) {
|
|
|
+ return String(account?.uid || account?.guid || account?.userId || '').trim();
|
|
|
+ }
|
|
|
+
|
|
|
+ function getGuid() {
|
|
|
+ const acc = currentAccount();
|
|
|
+ return acc ? ((acc.guid && acc.guid !== 'server-managed' ? acc.guid : '') || acc.userId || '') : '';
|
|
|
+ }
|
|
|
+
|
|
|
+ function saveAccounts() {
|
|
|
+ localStorage.setItem(ACCOUNTS_KEY, JSON.stringify(state.accounts));
|
|
|
+ localStorage.setItem(CURRENT_ACCOUNT_KEY, state.currentAccountId || '');
|
|
|
+ }
|
|
|
+
|
|
|
+ function parseStoredAccounts(value) {
|
|
|
+ try {
|
|
|
+ const parsed = JSON.parse(value || '[]');
|
|
|
+ return Array.isArray(parsed) ? parsed.filter(item => item && typeof item === 'object') : [];
|
|
|
+ } catch {
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function initializeWorkspaceAccountStorage() {
|
|
|
+ let workspaceId = '';
|
|
|
+ let activeAccountUid = '';
|
|
|
+ try {
|
|
|
+ const health = await api('GET', '/api/health', undefined, 4000);
|
|
|
+ workspaceId = String(health.data?.workspaceId || '').trim();
|
|
|
+ activeAccountUid = String(health.data?.activeAccountUid || '').trim();
|
|
|
+ } catch {
|
|
|
+ // 旧版服务端或短暂启动延迟时继续使用原键,避免阻断工作台。
|
|
|
+ }
|
|
|
+
|
|
|
+ if (workspaceId) {
|
|
|
+ ACCOUNTS_KEY = `qiwei-${workspaceId}-accounts`;
|
|
|
+ CURRENT_ACCOUNT_KEY = `qiwei-${workspaceId}-current-account`;
|
|
|
+ }
|
|
|
+
|
|
|
+ let accounts = parseStoredAccounts(localStorage.getItem(ACCOUNTS_KEY));
|
|
|
+ let currentId = localStorage.getItem(CURRENT_ACCOUNT_KEY) || null;
|
|
|
+ if (workspaceId && accounts.length === 0 && activeAccountUid) {
|
|
|
+ const legacy = parseStoredAccounts(localStorage.getItem(LEGACY_ACCOUNTS_KEY));
|
|
|
+ const activeLegacy = legacy.find(item => String(item.uid || '').trim() === activeAccountUid);
|
|
|
+ if (activeLegacy) {
|
|
|
+ accounts = legacy;
|
|
|
+ currentId = activeLegacy.id;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!accounts.some(item => item.id === currentId)) currentId = null;
|
|
|
+ state.accounts = accounts;
|
|
|
+ state.currentAccountId = currentId;
|
|
|
+ saveAccounts();
|
|
|
+ }
|
|
|
+
|
|
|
+ function isGroupSynced(roomId) {
|
|
|
+ return !!(state.groupSync[roomId] && state.groupSync[roomId].lastSyncAt);
|
|
|
+ }
|
|
|
+
|
|
|
+ function addAccount(account, options = {}) {
|
|
|
+ const activate = options.activate !== false;
|
|
|
+ const existing = state.accounts.find(a =>
|
|
|
+ (account.uid && a.uid === account.uid) ||
|
|
|
+ ((account.guid || account.userId) && a.guid === account.guid && a.userId === account.userId)
|
|
|
+ );
|
|
|
+ if (existing) {
|
|
|
+ Object.assign(existing, account, { id: existing.id });
|
|
|
+ if (activate) state.currentAccountId = existing.id;
|
|
|
+ } else {
|
|
|
+ account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`;
|
|
|
+ state.accounts.push(account);
|
|
|
+ if (activate) state.currentAccountId = account.id;
|
|
|
+ }
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ return existing?.id || account.id;
|
|
|
+ }
|
|
|
+
|
|
|
+ function beginAddAccountLogin() {
|
|
|
+ state.loginPreviousAccountId = state.currentAccountId;
|
|
|
+ state.loginUid = createDeviceUid();
|
|
|
+ state.currentAccountId = null;
|
|
|
+ saveAccounts();
|
|
|
+ }
|
|
|
+
|
|
|
+ function restorePreviousAccountSelection() {
|
|
|
+ const previousId = state.loginPreviousAccountId;
|
|
|
+ if (previousId && state.accounts.some(item => item.id === previousId)) {
|
|
|
+ state.currentAccountId = previousId;
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ }
|
|
|
+ state.loginPreviousAccountId = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function switchAccount(id, options = {}) {
|
|
|
+ const account = state.accounts.find(item => item.id === id);
|
|
|
+ if (!account) return false;
|
|
|
+ if (!account.uid) {
|
|
|
+ toast('该账号缺少 Fmode 设备绑定,请重新扫码登录后再切换', 'error');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const accountChanged = state.currentAccountId !== id;
|
|
|
+ const result = await api('POST', '/api/accounts/switch', {
|
|
|
+ uid: account.uid,
|
|
|
+ guid: account.guid === 'server-managed' ? '' : account.guid,
|
|
|
+ userId: account.userId,
|
|
|
+ nickname: account.nickname,
|
|
|
+ corpName: account.corpName,
|
|
|
+ });
|
|
|
+ const active = result.data?.account || {};
|
|
|
+ state.currentAccountId = id;
|
|
|
+ account.uid = active.uid || account.uid;
|
|
|
+ account.guid = active.guid || account.guid;
|
|
|
+ account.userId = active.userId || account.userId;
|
|
|
+ account.nickname = account.nickname || active.nickname || account.userId;
|
|
|
+ account.corpName = account.corpName || active.corpName || '';
|
|
|
+ account.online = Boolean(active.online);
|
|
|
+ if (accountChanged) {
|
|
|
+ state.agent.status = null;
|
|
|
+ state.agent.conversations = [];
|
|
|
+ state.agent.selectedId = null;
|
|
|
+ state.agent.signature = null;
|
|
|
+ state.agent.replyEdits = {};
|
|
|
+ state.agent.groupReplyEdits = {};
|
|
|
+ state.agent.voiceTone = 'auto';
|
|
|
+ state.agent.renderPending = false;
|
|
|
+ state.agent.insightInteractionUntil = 0;
|
|
|
+ state.agent.openDrawer = null;
|
|
|
+ state.agent.activityOpen = false;
|
|
|
+ state.agent.intakePolicy = { mode: 'allowlist_only', welcomeEnabled: false, welcomeText: '', welcomeSendMode: 'draft' };
|
|
|
+ state.agent.intakePolicyLoaded = false;
|
|
|
+ state.agent.intakePolicyError = '';
|
|
|
+ state.agent.cachedAccountKey = null;
|
|
|
+ try { sessionStorage.removeItem(AGENT_SNAPSHOT_KEY); } catch {}
|
|
|
+ }
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ if (!options.deferRender) void renderPage();
|
|
|
+ if (!options.silent) toast(result.assistantMessage || `已切换账号:${account.nickname}`);
|
|
|
+ return true;
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '账号切换失败', 'error');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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', async () => {
|
|
|
+ const switched = await switchAccount(btn.dataset.id);
|
|
|
+ if (switched) els.accountDropdown.classList.remove('open');
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function refreshAgentAccountHeader() {
|
|
|
+ if (currentAccount()) return;
|
|
|
+ try {
|
|
|
+ const result = await api('GET', '/api/agent/status');
|
|
|
+ if (currentAccount() || !['skills', 'knowledge', 'customer-ops'].includes(state.page)) return;
|
|
|
+ const account = result.data?.account || {};
|
|
|
+ if (!account.configured) return;
|
|
|
+ els.accountName.textContent = account.nickname || '企微账号';
|
|
|
+ els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+
|
|
|
+ function toast(message, type = 'success') {
|
|
|
+ const id = ++state.toastId;
|
|
|
+ const el = document.createElement('div');
|
|
|
+ el.className = `toast ${type}`;
|
|
|
+ el.innerHTML = `<span style="flex:1;min-width:0;word-break:break-word">${escapeHtml(message)}</span>
|
|
|
+ <button class="toast-close" aria-label="关闭" title="关闭">×</button>`;
|
|
|
+ const closeBtn = el.querySelector('.toast-close');
|
|
|
+ let timer = null;
|
|
|
+ const remove = () => {
|
|
|
+ if (timer) clearTimeout(timer);
|
|
|
+ el.style.opacity = '0';
|
|
|
+ el.style.transform = 'translateX(100%)';
|
|
|
+ setTimeout(() => el.remove(), 300);
|
|
|
+ };
|
|
|
+ closeBtn.addEventListener('click', remove);
|
|
|
+ els.toastContainer.appendChild(el);
|
|
|
+ timer = setTimeout(remove, 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>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const modalStack = [];
|
|
|
+
|
|
|
+ function openModal(title, bodyHtml, actionsHtml, modalClass = '') {
|
|
|
+ closeModal();
|
|
|
+ const previousFocus = document.activeElement;
|
|
|
+ const overlay = document.createElement('div');
|
|
|
+ overlay.className = 'modal-overlay';
|
|
|
+ if (modalClass === 'portrait-modal') overlay.classList.add('portrait-modal-overlay');
|
|
|
+ if (modalClass === 'customer-selector-modal') overlay.classList.add('customer-selector-modal-overlay');
|
|
|
+ if (modalClass === 'customer-detail-modal') overlay.classList.add('customer-detail-modal-overlay');
|
|
|
+ overlay.innerHTML = `<div class="modal ${escapeHtml(modalClass)}"><div class="modal-title">${escapeHtml(title)}</div>${bodyHtml}<div class="modal-actions">${actionsHtml}</div></div>`;
|
|
|
+ document.body.appendChild(overlay);
|
|
|
+ document.body.classList.add('modal-open');
|
|
|
+ modalStack.push(overlay);
|
|
|
+ state.modal = overlay;
|
|
|
+ const onKeydown = event => {
|
|
|
+ if (event.key === 'Escape') closeModal();
|
|
|
+ };
|
|
|
+ document.addEventListener('keydown', onKeydown);
|
|
|
+ overlay.__modalCleanup = () => document.removeEventListener('keydown', onKeydown);
|
|
|
+ overlay.__previousFocus = previousFocus;
|
|
|
+ overlay.addEventListener('click', e => {
|
|
|
+ if (e.target === overlay) closeModal();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function removeModalElement(overlay) {
|
|
|
+ if (!overlay) return;
|
|
|
+ overlay.__modalCleanup?.();
|
|
|
+ overlay.remove();
|
|
|
+ if (!document.querySelector('.modal-overlay')) document.body.classList.remove('modal-open');
|
|
|
+ if (state.modal === overlay) state.modal = null;
|
|
|
+ if (overlay.__previousFocus?.isConnected) overlay.__previousFocus.focus();
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeModal() {
|
|
|
+ const overlay = modalStack.pop() || state.modal;
|
|
|
+ if (!overlay) return;
|
|
|
+ if (typeof overlay.__cancelModal === 'function') {
|
|
|
+ overlay.__cancelModal();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ removeModalElement(overlay);
|
|
|
+ }
|
|
|
+
|
|
|
+ function closeAllModals() {
|
|
|
+ while (modalStack.length) closeModal();
|
|
|
+ state.modal = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ window.dashboardCloseModal = closeModal;
|
|
|
+
|
|
|
+ function businessModalField(field, index) {
|
|
|
+ const id = `business-modal-field-${Date.now()}-${index}`;
|
|
|
+ const value = field.value ?? '';
|
|
|
+ const required = field.required ? 'required' : '';
|
|
|
+ const readonly = field.readonly ? 'readonly' : '';
|
|
|
+ const placeholder = field.placeholder ? `placeholder="${escapeHtml(field.placeholder)}"` : '';
|
|
|
+ const autocomplete = field.autocomplete ? `autocomplete="${escapeHtml(field.autocomplete)}"` : 'autocomplete="off"';
|
|
|
+ const step = field.step ? `step="${escapeHtml(field.step)}"` : '';
|
|
|
+ let control;
|
|
|
+ if (field.type === 'textarea') {
|
|
|
+ control = `<textarea id="${id}" name="${escapeHtml(field.name)}" ${required} ${readonly} ${placeholder}>${escapeHtml(value)}</textarea>`;
|
|
|
+ } else if (field.type === 'select') {
|
|
|
+ control = `<select id="${id}" name="${escapeHtml(field.name)}" ${required}>${(field.options || []).map(option => `<option value="${escapeHtml(option.value)}" ${String(option.value) === String(value) ? 'selected' : ''}>${escapeHtml(option.label)}</option>`).join('')}</select>`;
|
|
|
+ } else {
|
|
|
+ const type = ['text', 'datetime-local', 'date', 'number', 'email'].includes(field.type) ? field.type : 'text';
|
|
|
+ control = `<input id="${id}" name="${escapeHtml(field.name)}" type="${type}" value="${escapeHtml(value)}" ${required} ${readonly} ${placeholder} ${autocomplete} ${step} />`;
|
|
|
+ }
|
|
|
+ return `<label class="business-modal-field ${field.readonly ? 'readonly' : ''}" data-business-field="${escapeHtml(field.name)}"><span>${escapeHtml(field.label)}${field.required ? '<em>*</em>' : ''}</span>${control}${field.help ? `<small>${escapeHtml(field.help)}</small>` : ''}</label>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function showBusinessModal(options = {}) {
|
|
|
+ closeModal();
|
|
|
+ const previousFocus = document.activeElement;
|
|
|
+ const overlay = document.createElement('div');
|
|
|
+ const titleId = `business-modal-title-${Date.now()}`;
|
|
|
+ const descriptionId = `business-modal-description-${Date.now()}`;
|
|
|
+ const fields = Array.isArray(options.fields) ? options.fields : [];
|
|
|
+ const shortcuts = Array.isArray(options.shortcuts) ? options.shortcuts : [];
|
|
|
+ overlay.className = 'business-modal-backdrop';
|
|
|
+ overlay.innerHTML = `<section class="business-modal" role="dialog" aria-modal="true" aria-labelledby="${titleId}" ${options.description ? `aria-describedby="${descriptionId}"` : ''}>
|
|
|
+ <header class="business-modal-header"><div>${options.eyebrow ? `<span>${escapeHtml(options.eyebrow)}</span>` : ''}<h3 id="${titleId}">${escapeHtml(options.title || '确认操作')}</h3>${options.description ? `<p id="${descriptionId}">${escapeHtml(options.description)}</p>` : ''}</div><button type="button" class="business-modal-close" data-business-cancel aria-label="关闭">×</button></header>
|
|
|
+ <form class="business-modal-form" novalidate>
|
|
|
+ <div class="business-modal-fields">${fields.map(businessModalField).join('')}</div>
|
|
|
+ ${shortcuts.length ? `<div class="business-modal-shortcuts"><span>${escapeHtml(options.shortcutLabel || '快捷选择')}</span><div>${shortcuts.map((shortcut, index) => `<button type="button" data-business-shortcut="${index}">${escapeHtml(shortcut.label)}</button>`).join('')}</div></div>` : ''}
|
|
|
+ ${options.warning ? `<div class="business-modal-live-warning"><i>!</i><div><strong>${escapeHtml(options.warningTitle || '真实业务操作')}</strong><span>${escapeHtml(options.warning)}</span></div></div>` : ''}
|
|
|
+ ${options.dontAskAgainLabel ? `<label class="business-modal-dont-ask"><input type="checkbox" name="dontAskAgain" value="1" /><span>${escapeHtml(options.dontAskAgainLabel)}</span></label>` : ''}
|
|
|
+ <p class="business-modal-error" data-business-error role="alert" hidden></p>
|
|
|
+ <footer class="business-modal-actions">${options.cancelText !== null ? `<button type="button" class="btn btn-secondary" data-business-cancel>${escapeHtml(options.cancelText || '取消')}</button>` : ''}<button type="submit" class="btn business-modal-confirm ${options.danger ? 'danger' : ''}" data-business-primary>${escapeHtml(options.confirmText || '确认')}</button></footer>
|
|
|
+ </form>
|
|
|
+ </section>`;
|
|
|
+
|
|
|
+ document.body.appendChild(overlay);
|
|
|
+ document.body.classList.add('business-modal-open');
|
|
|
+ state.modal = overlay;
|
|
|
+ overlay.__previousFocus = previousFocus;
|
|
|
+
|
|
|
+ return new Promise(resolve => {
|
|
|
+ let settled = false;
|
|
|
+ const form = overlay.querySelector('.business-modal-form');
|
|
|
+ const errorBox = overlay.querySelector('[data-business-error]');
|
|
|
+ const focusableSelector = 'button:not([disabled]), input:not([disabled]):not([readonly]), textarea:not([disabled]):not([readonly]), select:not([disabled])';
|
|
|
+
|
|
|
+ const finish = value => {
|
|
|
+ if (settled) return;
|
|
|
+ settled = true;
|
|
|
+ document.body.classList.remove('business-modal-open');
|
|
|
+ removeModalElement(overlay);
|
|
|
+ resolve(value);
|
|
|
+ };
|
|
|
+ overlay.__cancelModal = () => finish(null);
|
|
|
+
|
|
|
+ const showError = validation => {
|
|
|
+ const detail = typeof validation === 'string' ? { message: validation } : validation || {};
|
|
|
+ overlay.querySelectorAll('.business-modal-field.invalid').forEach(field => field.classList.remove('invalid'));
|
|
|
+ errorBox.textContent = detail.message || '请检查填写内容';
|
|
|
+ errorBox.hidden = false;
|
|
|
+ if (detail.field) {
|
|
|
+ const wrapper = overlay.querySelector(`[data-business-field="${detail.field}"]`);
|
|
|
+ wrapper?.classList.add('invalid');
|
|
|
+ wrapper?.querySelector('input, textarea, select')?.focus();
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ overlay.querySelectorAll('[data-business-cancel]').forEach(button => button.addEventListener('click', () => finish(null)));
|
|
|
+ overlay.addEventListener('click', event => {
|
|
|
+ if (event.target === overlay) finish(null);
|
|
|
+ });
|
|
|
+ overlay.querySelectorAll('[data-business-shortcut]').forEach(button => button.addEventListener('click', () => {
|
|
|
+ const shortcut = shortcuts[Number(button.dataset.businessShortcut)];
|
|
|
+ const input = form.elements.namedItem(shortcut?.field || 'dueAt');
|
|
|
+ if (!input || !shortcut) return;
|
|
|
+ input.value = String(typeof shortcut.value === 'function' ? shortcut.value() : shortcut.value || '');
|
|
|
+ input.dispatchEvent(new Event('input', { bubbles: true }));
|
|
|
+ input.focus();
|
|
|
+ }));
|
|
|
+ form.addEventListener('input', event => {
|
|
|
+ event.target.closest('.business-modal-field')?.classList.remove('invalid');
|
|
|
+ errorBox.hidden = true;
|
|
|
+ });
|
|
|
+ form.addEventListener('submit', event => {
|
|
|
+ event.preventDefault();
|
|
|
+ const values = Object.fromEntries(new FormData(form).entries());
|
|
|
+ const missing = fields.find(field => field.required && !String(values[field.name] || '').trim());
|
|
|
+ if (missing) {
|
|
|
+ showError({ field: missing.name, message: `请填写${missing.label}` });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const validation = typeof options.validate === 'function' ? options.validate(values) : null;
|
|
|
+ if (validation) {
|
|
|
+ showError(validation);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ finish(values);
|
|
|
+ });
|
|
|
+
|
|
|
+ const onKeydown = event => {
|
|
|
+ if (event.key === 'Escape') {
|
|
|
+ event.preventDefault();
|
|
|
+ finish(null);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.key !== 'Tab') return;
|
|
|
+ const focusable = [...overlay.querySelectorAll(focusableSelector)].filter(item => item.offsetParent !== null);
|
|
|
+ if (!focusable.length) return;
|
|
|
+ const first = focusable[0];
|
|
|
+ const last = focusable[focusable.length - 1];
|
|
|
+ if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); }
|
|
|
+ else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); }
|
|
|
+ };
|
|
|
+ document.addEventListener('keydown', onKeydown);
|
|
|
+ overlay.__modalCleanup = () => document.removeEventListener('keydown', onKeydown);
|
|
|
+ requestAnimationFrame(() => (overlay.querySelector('.business-modal-field input:not([readonly]), .business-modal-field textarea:not([readonly]), .business-modal-field select') || overlay.querySelector(focusableSelector))?.focus());
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const CONFIRM_DISMISS_PREFIX = 'qiwei:confirm-dismiss:';
|
|
|
+
|
|
|
+ function isConfirmDismissed(key) {
|
|
|
+ try { return localStorage.getItem(`${CONFIRM_DISMISS_PREFIX}${key}`) === '1'; } catch { return false; }
|
|
|
+ }
|
|
|
+
|
|
|
+ function dismissConfirm(key) {
|
|
|
+ try { localStorage.setItem(`${CONFIRM_DISMISS_PREFIX}${key}`, '1'); } catch {}
|
|
|
+ }
|
|
|
+
|
|
|
+ function confirmModal(message, options = {}) {
|
|
|
+ const dismissKey = String(options.dismissKey || '').trim();
|
|
|
+ if (dismissKey && isConfirmDismissed(dismissKey)) return Promise.resolve(true);
|
|
|
+ return showBusinessModal({
|
|
|
+ title: options.title || '确认操作',
|
|
|
+ description: message,
|
|
|
+ confirmText: options.confirmText || '确认',
|
|
|
+ cancelText: options.cancelText || '取消',
|
|
|
+ warning: options.warning,
|
|
|
+ warningTitle: options.warningTitle,
|
|
|
+ danger: options.danger,
|
|
|
+ dontAskAgainLabel: dismissKey ? (options.dismissLabel || '以后不再提示') : null,
|
|
|
+ }).then(result => {
|
|
|
+ if (result === null) return false;
|
|
|
+ if (dismissKey && result.dontAskAgain) dismissConfirm(dismissKey);
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function alertModal(message, options = {}) {
|
|
|
+ return showBusinessModal({
|
|
|
+ title: options.title || '提示',
|
|
|
+ description: message,
|
|
|
+ confirmText: options.confirmText || '知道了',
|
|
|
+ cancelText: null,
|
|
|
+ }).then(() => {});
|
|
|
+ }
|
|
|
+
|
|
|
+ function promptModal(message, defaultValue = '', options = {}) {
|
|
|
+ return showBusinessModal({
|
|
|
+ title: options.title || '请输入',
|
|
|
+ description: message,
|
|
|
+ fields: [{ name: 'value', label: options.label || '内容', type: options.textarea ? 'textarea' : 'text', value: String(defaultValue ?? ''), required: options.required !== false, placeholder: options.placeholder || '' }],
|
|
|
+ confirmText: options.confirmText || '确认',
|
|
|
+ cancelText: options.cancelText || '取消',
|
|
|
+ }).then(result => result ? String(result.value || '').trim() : null);
|
|
|
+ }
|
|
|
+
|
|
|
+ function localDeadlineValue(dayOffset, hour = 18, minute = 0) {
|
|
|
+ const date = new Date();
|
|
|
+ date.setDate(date.getDate() + dayOffset);
|
|
|
+ date.setHours(hour, minute, 0, 0);
|
|
|
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function deadlineInputValue(value) {
|
|
|
+ const text = String(value || '').trim();
|
|
|
+ const direct = text.match(/^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})[ T](\d{1,2}):(\d{1,2})(?::\d{1,2})?$/);
|
|
|
+ if (direct) return `${direct[1]}-${String(direct[2]).padStart(2, '0')}-${String(direct[3]).padStart(2, '0')}T${String(direct[4]).padStart(2, '0')}:${String(direct[5]).padStart(2, '0')}`;
|
|
|
+ const date = new Date(text);
|
|
|
+ if (Number.isNaN(date.getTime())) return '';
|
|
|
+ return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}T${String(date.getHours()).padStart(2, '0')}:${String(date.getMinutes()).padStart(2, '0')}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function showOfficialTodoSyncModal(task = {}) {
|
|
|
+ const currentOwner = task.owner && task.owner !== '待分配' ? task.owner : '';
|
|
|
+ const values = await showBusinessModal({
|
|
|
+ eyebrow: 'OFFICIAL TODO · LIVE',
|
|
|
+ title: '同步至企微待办',
|
|
|
+ description: '确认负责人和截止时间后,将本地跟进任务同步到企业微信。',
|
|
|
+ fields: [
|
|
|
+ { name: 'title', label: '待办内容', type: 'text', value: task.title || '客户跟进', readonly: true },
|
|
|
+ { name: 'owner', label: '企微内部负责人', type: 'text', value: currentOwner, placeholder: '输入姓名或别名', required: true, help: '系统会自动匹配唯一的企微成员' },
|
|
|
+ { name: 'dueAt', label: '截止时间', type: 'datetime-local', value: deadlineInputValue(task.dueAt) || localDeadlineValue(1), required: true, step: '60', help: '直接选择或使用下方快捷时间,无需手写格式' },
|
|
|
+ ],
|
|
|
+ shortcutLabel: '常用截止时间',
|
|
|
+ shortcuts: [
|
|
|
+ { label: '今天 18:00', field: 'dueAt', value: () => localDeadlineValue(0) },
|
|
|
+ { label: '明天 18:00', field: 'dueAt', value: () => localDeadlineValue(1) },
|
|
|
+ { label: '三天后', field: 'dueAt', value: () => localDeadlineValue(3) },
|
|
|
+ { label: '一周后', field: 'dueAt', value: () => localDeadlineValue(7) },
|
|
|
+ ],
|
|
|
+ warningTitle: '将写入真实企业微信',
|
|
|
+ warning: '点击确认后会创建真实企微待办,并与当前本地任务建立关联。',
|
|
|
+ confirmText: '创建企微待办',
|
|
|
+ validate: values => String(values.owner || '').trim() ? null : { field: 'owner', message: '请填写企微内部负责人' },
|
|
|
+ });
|
|
|
+ if (!values) return null;
|
|
|
+ return { owner: String(values.owner).trim(), dueAt: String(values.dueAt).replace('T', ' ').slice(0, 16) };
|
|
|
+ }
|
|
|
+
|
|
|
+ async function copyText(value) {
|
|
|
+ const text = String(value || '');
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(text);
|
|
|
+ } catch {
|
|
|
+ const textarea = document.createElement('textarea');
|
|
|
+ textarea.value = text;
|
|
|
+ textarea.style.position = 'fixed';
|
|
|
+ textarea.style.opacity = '0';
|
|
|
+ document.body.appendChild(textarea);
|
|
|
+ textarea.select();
|
|
|
+ document.execCommand('copy');
|
|
|
+ textarea.remove();
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function openAgentSessionGuide(guide = {}) {
|
|
|
+ const ready = Boolean(guide.ready);
|
|
|
+ openModal(
|
|
|
+ '客户 Claude Code 会话',
|
|
|
+ `<div class="agent-session-modal">
|
|
|
+ <div class="agent-session-modal-status ${ready ? 'ready' : ''}"><strong>${ready ? '已检测到客户专属 Session' : 'Session 等待首次运行'}</strong><span>${escapeHtml(guide.guidance || '')}</span></div>
|
|
|
+ <dl><div><dt>会话名称</dt><dd>${escapeHtml(guide.displayName || '首次运行后生成')}</dd></div><div><dt>打开位置</dt><dd>${escapeHtml(guide.openLocation || 'Fmode Studio → 当前企微项目 → 终端')}</dd></div></dl>
|
|
|
+ <label>安全打开命令</label>
|
|
|
+ <pre>${escapeHtml(guide.openCommand || 'npm run agent:session:list')}</pre>
|
|
|
+ <p>打开时会自动 fork 审阅副本。你可以查看历史、模型思考和工具记录,也可以继续追问;生产客户 Session 不会被修改。</p>
|
|
|
+ </div>`,
|
|
|
+ `<button class="btn btn-secondary" data-session-close>关闭</button><button class="btn" data-session-copy ${guide.openCommand ? '' : 'disabled'}>复制打开命令</button>`
|
|
|
+ );
|
|
|
+ state.modal.querySelector('[data-session-close]')?.addEventListener('click', closeModal);
|
|
|
+ state.modal.querySelector('[data-session-copy]')?.addEventListener('click', async () => {
|
|
|
+ await copyText(guide.openCommand || '');
|
|
|
+ toast('打开命令已复制');
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function startJob(name, path, body, onDone, trackerContainerId) {
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ if (trackerContainerId) {
|
|
|
+ const container = document.getElementById(trackerContainerId);
|
|
|
+ if (container) container.innerHTML = renderJobTracker(name, id);
|
|
|
+ }
|
|
|
+ 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;
|
|
|
+ }
|
|
|
+ const isError = job.status === 'error' || job.result?.status === 'error';
|
|
|
+ if (job.status === 'done' && !isError) {
|
|
|
+ toast(job.result?.assistantMessage || '任务完成');
|
|
|
+ if (record.onDone) record.onDone(job.result);
|
|
|
+ } else {
|
|
|
+ toast(job.error || job.result?.assistantMessage || '任务失败', 'error');
|
|
|
+ }
|
|
|
+ delete state.jobs[id];
|
|
|
+ // 清理该任务对应的进度条
|
|
|
+ setTimeout(() => {
|
|
|
+ const tracker = document.getElementById(`job-${id}`);
|
|
|
+ if (tracker) tracker.remove();
|
|
|
+ }, 2000);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderJobTracker(name, id, title) {
|
|
|
+ const label = title || (name === 'batch-portraits' ? '正在调用 LLM 生成画像…' : '执行中…');
|
|
|
+ return `<div class="progress-wrap" id="job-${id}">
|
|
|
+ <div style="font-size:13px;color:var(--text-muted);margin-bottom:6px">${escapeHtml(label)}</div>
|
|
|
+ <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>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Dashboard 辅助函数 ====================
|
|
|
+ const dashboardCharts = new Map();
|
|
|
+ let dashboardRefreshTimer = null;
|
|
|
+
|
|
|
+ function disposeDashboard() {
|
|
|
+ if (dashboardRefreshTimer) {
|
|
|
+ clearInterval(dashboardRefreshTimer);
|
|
|
+ dashboardRefreshTimer = null;
|
|
|
+ }
|
|
|
+ for (const chart of dashboardCharts.values()) {
|
|
|
+ try { chart.dispose(); } catch {}
|
|
|
+ }
|
|
|
+ dashboardCharts.clear();
|
|
|
+ }
|
|
|
+
|
|
|
+ function showLoading(container, type = 'overlay') {
|
|
|
+ if (!container) return;
|
|
|
+ container.classList.add('loading-overlay');
|
|
|
+ if (type === 'overlay') {
|
|
|
+ container.innerHTML = '<div class="loading-spinner"></div>';
|
|
|
+ } else if (type === 'list') {
|
|
|
+ container.innerHTML = `
|
|
|
+ <div class="skeleton-list">
|
|
|
+ <div class="skeleton skeleton-line" style="width:100%"></div>
|
|
|
+ <div class="skeleton skeleton-line" style="width:92%"></div>
|
|
|
+ <div class="skeleton skeleton-line" style="width:96%"></div>
|
|
|
+ <div class="skeleton skeleton-line" style="width:88%"></div>
|
|
|
+ </div>`;
|
|
|
+ } else if (type === 'table') {
|
|
|
+ container.innerHTML = `
|
|
|
+ <div class="table-skeleton">
|
|
|
+ <div class="skeleton skeleton-line"></div>
|
|
|
+ <div class="skeleton skeleton-line" style="opacity:0.85"></div>
|
|
|
+ <div class="skeleton skeleton-line" style="opacity:0.7"></div>
|
|
|
+ <div class="skeleton skeleton-line" style="opacity:0.55"></div>
|
|
|
+ <div class="skeleton skeleton-line" style="opacity:0.4"></div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function hideLoading(container) {
|
|
|
+ if (!container) return;
|
|
|
+ container.classList.remove('loading-overlay');
|
|
|
+ }
|
|
|
+
|
|
|
+ function showError(container, message, onRetry) {
|
|
|
+ if (!container) return;
|
|
|
+ container.innerHTML = `
|
|
|
+ <div class="error-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 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z"/></svg>
|
|
|
+ <div class="error-state-title">加载失败</div>
|
|
|
+ <div class="error-state-desc">${escapeHtml(message || '请稍后重试')}</div>
|
|
|
+ ${onRetry ? '<button class="btn btn-sm" id="btn-retry-loading">重试</button>' : ''}
|
|
|
+ </div>`;
|
|
|
+ if (onRetry) {
|
|
|
+ container.querySelector('#btn-retry-loading').addEventListener('click', onRetry);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderEmptyChart(container, title = '暂无数据') {
|
|
|
+ if (!container) return;
|
|
|
+ container.innerHTML = `
|
|
|
+ <div class="empty-chart">
|
|
|
+ <svg viewBox="0 0 24 24" style="width:48px;height:48px;fill:var(--text-muted);opacity:0.5;margin-bottom:10px">
|
|
|
+ <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-2zm0 16H5V5h14v14zM7 10h2v7H7zm4-3h2v10h-2zm4 6h2v4h-2z"/>
|
|
|
+ </svg>
|
|
|
+ <div>${escapeHtml(title)}</div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function isEmptyChartData(option) {
|
|
|
+ if (!option || !option.series) return true;
|
|
|
+ const series = Array.isArray(option.series) ? option.series : [option.series];
|
|
|
+ if (!series.length) return true;
|
|
|
+ return series.every(s => {
|
|
|
+ if (!s.data || !s.data.length) return true;
|
|
|
+ return s.data.every(item => {
|
|
|
+ const value = typeof item === 'object' ? (item.value ?? 0) : (item ?? 0);
|
|
|
+ return Number(value) === 0;
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function initEChart(containerId, option) {
|
|
|
+ const container = document.getElementById(containerId);
|
|
|
+ if (!container) return null;
|
|
|
+ if (typeof echarts === 'undefined') {
|
|
|
+ renderEmptyChart(container, '图表库加载失败,请检查网络');
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (isEmptyChartData(option)) {
|
|
|
+ renderEmptyChart(container, '暂无数据');
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ if (dashboardCharts.has(containerId)) {
|
|
|
+ try { dashboardCharts.get(containerId).dispose(); } catch {}
|
|
|
+ }
|
|
|
+ const chart = echarts.init(container);
|
|
|
+ chart.setOption(option, true);
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ try { chart.resize(); } catch {}
|
|
|
+ });
|
|
|
+ dashboardCharts.set(containerId, chart);
|
|
|
+ return chart;
|
|
|
+ }
|
|
|
+
|
|
|
+ function getChartColors() {
|
|
|
+ if (!getChartColors.cache) {
|
|
|
+ const root = getComputedStyle(document.documentElement);
|
|
|
+ const read = (name, fallback) => root.getPropertyValue(name).trim() || fallback;
|
|
|
+ getChartColors.cache = {
|
|
|
+ brand: read('--brand', '#fa8c16'),
|
|
|
+ success: read('--success', '#52c41a'),
|
|
|
+ error: read('--error', '#ff4d4f'),
|
|
|
+ warning: read('--warning', '#faad14'),
|
|
|
+ info: read('--info', '#1890ff'),
|
|
|
+ muted: read('--text-muted', '#8c8c8c'),
|
|
|
+ textPrimary: read('--text-primary', '#1f1f1f'),
|
|
|
+ purple: '#722ed1',
|
|
|
+ cyan: '#13c2c2'
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return getChartColors.cache;
|
|
|
+ }
|
|
|
+
|
|
|
+ function hexToRgba(hex, alpha) {
|
|
|
+ const clean = hex.replace('#', '');
|
|
|
+ if (clean.length === 3) {
|
|
|
+ const r = parseInt(clean[0] + clean[0], 16);
|
|
|
+ const g = parseInt(clean[1] + clean[1], 16);
|
|
|
+ const b = parseInt(clean[2] + clean[2], 16);
|
|
|
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
|
+ }
|
|
|
+ const r = parseInt(clean.slice(0, 2), 16);
|
|
|
+ const g = parseInt(clean.slice(2, 4), 16);
|
|
|
+ const b = parseInt(clean.slice(4, 6), 16);
|
|
|
+ return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function resizeDashboardCharts() {
|
|
|
+ for (const chart of dashboardCharts.values()) {
|
|
|
+ try { chart.resize(); } catch {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ window.addEventListener('resize', resizeDashboardCharts);
|
|
|
+
|
|
|
+ document.addEventListener('keydown', (e) => {
|
|
|
+ if (e.key === 'Escape' && modalStack.length) {
|
|
|
+ closeModal();
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ function renderKpiRow(items) {
|
|
|
+ return items.map((item, i) => `
|
|
|
+ <div class="kpi-card" style="animation:fadeInUp 0.4s ease ${i * 0.05}s both">
|
|
|
+ <div class="kpi-label">${escapeHtml(item.label)}</div>
|
|
|
+ <div class="kpi-value">${escapeHtml(String(item.value))}</div>
|
|
|
+ ${item.sub ? `<div class="kpi-sub">${escapeHtml(item.sub)}</div>` : ''}
|
|
|
+ ${item.trend ? `<div class="kpi-sub kpi-trend ${item.trend > 0 ? 'up' : item.trend < 0 ? 'down' : ''}">${item.trend > 0 ? '↑' : item.trend < 0 ? '↓' : ''} ${Math.abs(item.trend)}%</div>` : ''}
|
|
|
+ </div>
|
|
|
+ `).join('');
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderChartCard(id, title, subtitle, heightClass = '') {
|
|
|
+ return `<div class="chart-card ${heightClass}">
|
|
|
+ <h3 class="chart-title">
|
|
|
+ <span>${escapeHtml(title)}</span>
|
|
|
+ ${subtitle ? `<span class="chart-subtitle">${escapeHtml(subtitle)}</span>` : ''}
|
|
|
+ </h3>
|
|
|
+ <div class="chart-container ${heightClass}" id="${id}"></div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderOperationToolbar(leftHtml, rightHtml) {
|
|
|
+ return `<div class="operation-toolbar">
|
|
|
+ <div class="toolbar-left">${leftHtml}</div>
|
|
|
+ <div class="toolbar-right">${rightHtml}</div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderEmptyTableRow(colspan, title = '暂无数据', desc = '') {
|
|
|
+ return `
|
|
|
+ <tr>
|
|
|
+ <td colspan="${colspan}" style="padding:0;border-bottom:none">
|
|
|
+ <div class="empty-state" style="padding:32px 20px">
|
|
|
+ <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">${escapeHtml(title)}</div>
|
|
|
+ ${desc ? `<div class="empty-state-desc">${escapeHtml(desc)}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </td>
|
|
|
+ </tr>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDataTableCard(title, subtitle, tableHtml, metaHtml = '') {
|
|
|
+ return `<div class="data-table-card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">${escapeHtml(title)}</h3>
|
|
|
+ ${subtitle ? `<div class="card-subtitle">${escapeHtml(subtitle)}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ${metaHtml ? `<div class="table-meta">${metaHtml}</div>` : ''}
|
|
|
+ ${tableHtml}
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPagination(current, total, pageSize, onPageChange) {
|
|
|
+ const totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
+ return `<div class="pagination">
|
|
|
+ <span>共 ${formatNumber(total)} 条,第 ${current} / ${totalPages} 页</span>
|
|
|
+ <button ${current <= 1 ? 'disabled' : ''} data-page="${current - 1}">上一页</button>
|
|
|
+ <button ${current >= totalPages ? 'disabled' : ''} data-page="${current + 1}">下一页</button>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openCustomerSelector(options = {}) {
|
|
|
+ const { multiple = true, selectedIds = [], title = '选择客户', confirmText = '确定' } = options;
|
|
|
+ const selected = new Set(selectedIds.filter(Boolean).map(String));
|
|
|
+ const loadedCustomers = new Map();
|
|
|
+ let query = '';
|
|
|
+ let portraitFilter = '';
|
|
|
+ let sortBy = 'default';
|
|
|
+ let currentPage = 1;
|
|
|
+ const pageSize = 20;
|
|
|
+ let total = 0;
|
|
|
+ let totalPages = 1;
|
|
|
+ let isLoading = false;
|
|
|
+ let searchTimer = null;
|
|
|
+
|
|
|
+ async function loadCustomers(resetPage = false) {
|
|
|
+ if (isLoading) return;
|
|
|
+ isLoading = true;
|
|
|
+ if (resetPage) currentPage = 1;
|
|
|
+ const offset = (currentPage - 1) * pageSize;
|
|
|
+
|
|
|
+ const listEl = document.getElementById('customer-selector-list');
|
|
|
+ if (listEl) listEl.innerHTML = '<div class="customer-selector-empty">加载中…</div>';
|
|
|
+
|
|
|
+ try {
|
|
|
+ const params = new URLSearchParams();
|
|
|
+ params.set('keyword', query);
|
|
|
+ params.set('limit', String(pageSize));
|
|
|
+ params.set('offset', String(offset));
|
|
|
+ params.set('sortBy', sortBy);
|
|
|
+ if (portraitFilter) params.set('hasPortrait', portraitFilter === 'true' ? 'true' : 'false');
|
|
|
+ const res = await api('GET', `/api/dashboard/customers/search?${params.toString()}`);
|
|
|
+ const data = res.data || {};
|
|
|
+ const items = data.customers || [];
|
|
|
+ total = data.total || 0;
|
|
|
+ totalPages = Math.max(1, Math.ceil(total / pageSize));
|
|
|
+ if (currentPage > totalPages) currentPage = totalPages || 1;
|
|
|
+ loadedCustomers.clear();
|
|
|
+ for (const c of items) {
|
|
|
+ const id = String(c.externalUserId || '').trim();
|
|
|
+ if (id) loadedCustomers.set(id, { ...c, externalUserId: id });
|
|
|
+ }
|
|
|
+ renderList();
|
|
|
+ renderPagination();
|
|
|
+ } catch (err) {
|
|
|
+ const listEl = document.getElementById('customer-selector-list');
|
|
|
+ if (listEl) listEl.innerHTML = '<div class="customer-selector-empty error">客户列表加载失败,请稍后重试</div>';
|
|
|
+ toast('加载客户列表失败', 'error');
|
|
|
+ } finally {
|
|
|
+ isLoading = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function toggleSelected(id) {
|
|
|
+ if (multiple) {
|
|
|
+ if (selected.has(id)) selected.delete(id); else selected.add(id);
|
|
|
+ } else {
|
|
|
+ selected.clear();
|
|
|
+ selected.add(id);
|
|
|
+ }
|
|
|
+ renderList();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderList() {
|
|
|
+ const listEl = document.getElementById('customer-selector-list');
|
|
|
+ const countEl = document.getElementById('customer-selector-count');
|
|
|
+ if (!listEl) return;
|
|
|
+ if (countEl) countEl.textContent = `已选 ${selected.size} / 共 ${total} 位客户`;
|
|
|
+
|
|
|
+ const items = Array.from(loadedCustomers.values());
|
|
|
+ if (!items.length) {
|
|
|
+ listEl.innerHTML = '<div class="customer-selector-empty">无匹配客户</div>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ listEl.innerHTML = items.map(c => {
|
|
|
+ const isSelected = selected.has(String(c.externalUserId || ''));
|
|
|
+ const portraitBadge = c.hasPortrait ? '<span class="badge badge-info" style="margin-left:6px">已画像</span>' : '';
|
|
|
+ return `
|
|
|
+ <div class="customer-selector-item ${isSelected ? 'selected' : ''}" data-id="${escapeHtml(c.externalUserId || '')}">
|
|
|
+ <input type="${multiple ? 'checkbox' : 'radio'}" ${isSelected ? 'checked' : ''} />
|
|
|
+ <div class="customer-selector-info">
|
|
|
+ <div class="customer-selector-name">${escapeHtml(c.name || '未命名客户')}${portraitBadge}</div>
|
|
|
+ <div class="customer-selector-phone">${escapeHtml(c.phone || '-')}${c.externalUserId ? ` · ${escapeHtml(c.externalUserId)}` : ''}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }).join('');
|
|
|
+
|
|
|
+ listEl.querySelectorAll('.customer-selector-item').forEach(item => {
|
|
|
+ item.addEventListener('click', (e) => {
|
|
|
+ if (e.target.tagName === 'INPUT') return;
|
|
|
+ toggleSelected(item.dataset.id);
|
|
|
+ });
|
|
|
+ const input = item.querySelector('input');
|
|
|
+ input.addEventListener('change', () => {
|
|
|
+ const id = item.dataset.id;
|
|
|
+ if (multiple) {
|
|
|
+ if (input.checked) selected.add(id); else selected.delete(id);
|
|
|
+ } else {
|
|
|
+ selected.clear();
|
|
|
+ if (input.checked) selected.add(id);
|
|
|
+ }
|
|
|
+ renderList();
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPagination() {
|
|
|
+ const wrap = document.getElementById('customer-selector-pagination');
|
|
|
+ if (!wrap) return;
|
|
|
+ if (totalPages <= 1) {
|
|
|
+ wrap.innerHTML = '';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const pages = [];
|
|
|
+ const maxButtons = 5;
|
|
|
+ let start = Math.max(1, currentPage - Math.floor(maxButtons / 2));
|
|
|
+ let end = Math.min(totalPages, start + maxButtons - 1);
|
|
|
+ if (end - start + 1 < maxButtons) start = Math.max(1, end - maxButtons + 1);
|
|
|
+ for (let i = start; i <= end; i++) {
|
|
|
+ pages.push(`<button class="btn btn-sm ${i === currentPage ? 'btn-primary' : 'btn-secondary'}" data-page="${i}">${i}</button>`);
|
|
|
+ }
|
|
|
+ wrap.innerHTML = `
|
|
|
+ <button class="btn btn-sm btn-secondary" data-page="prev" ${currentPage === 1 ? 'disabled' : ''}>上一页</button>
|
|
|
+ ${pages.join('')}
|
|
|
+ <button class="btn btn-sm btn-secondary" data-page="next" ${currentPage === totalPages ? 'disabled' : ''}>下一页</button>
|
|
|
+ <span style="font-size:12px;color:var(--text-muted);margin-left:8px">${currentPage} / ${totalPages} 页</span>
|
|
|
+ `;
|
|
|
+
|
|
|
+ wrap.querySelectorAll('button').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ let page = btn.dataset.page;
|
|
|
+ if (page === 'prev') page = currentPage - 1;
|
|
|
+ else if (page === 'next') page = currentPage + 1;
|
|
|
+ else page = Number(page);
|
|
|
+ if (page >= 1 && page <= totalPages && page !== currentPage) {
|
|
|
+ currentPage = page;
|
|
|
+ loadCustomers(false);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ const body = `
|
|
|
+ <div class="form-group" style="margin-bottom:0">
|
|
|
+ <div class="customer-selector-filters">
|
|
|
+ <input id="customer-selector-search" class="customer-selector-search" type="text" placeholder="搜索姓名、手机号或 externalUserId" />
|
|
|
+ <select id="customer-selector-portrait" class="customer-selector-filter">
|
|
|
+ <option value="">全部画像状态</option>
|
|
|
+ <option value="true">已生成画像</option>
|
|
|
+ <option value="false">未生成画像</option>
|
|
|
+ </select>
|
|
|
+ <select id="customer-selector-sort" class="customer-selector-filter">
|
|
|
+ <option value="default">默认排序</option>
|
|
|
+ <option value="lastActive">最近活跃</option>
|
|
|
+ <option value="portraitDesc">已画像优先</option>
|
|
|
+ <option value="portraitAsc">未画像优先</option>
|
|
|
+ <option value="nameAsc">姓名 A-Z</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="customer-selector-count" id="customer-selector-count">已选 0 / 共 0 位客户</div>
|
|
|
+ <div class="customer-selector-list" id="customer-selector-list">
|
|
|
+ <div class="customer-selector-empty">加载中…</div>
|
|
|
+ </div>
|
|
|
+ <div class="customer-selector-pagination" id="customer-selector-pagination"></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ return new Promise((resolve) => {
|
|
|
+ openModal(title, body, `
|
|
|
+ <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
|
|
|
+ <button class="btn" id="btn-confirm-customer-selector">${escapeHtml(confirmText)}</button>
|
|
|
+ `, 'customer-selector-modal');
|
|
|
+
|
|
|
+ loadCustomers(true);
|
|
|
+
|
|
|
+ const searchEl = document.getElementById('customer-selector-search');
|
|
|
+ const portraitEl = document.getElementById('customer-selector-portrait');
|
|
|
+ const sortEl = document.getElementById('customer-selector-sort');
|
|
|
+
|
|
|
+ const scheduleReload = () => {
|
|
|
+ if (searchTimer) clearTimeout(searchTimer);
|
|
|
+ searchTimer = setTimeout(() => {
|
|
|
+ query = searchEl.value.trim();
|
|
|
+ portraitFilter = portraitEl.value;
|
|
|
+ sortBy = sortEl.value;
|
|
|
+ loadCustomers(true);
|
|
|
+ }, 300);
|
|
|
+ };
|
|
|
+
|
|
|
+ searchEl.addEventListener('input', scheduleReload);
|
|
|
+ portraitEl.addEventListener('change', scheduleReload);
|
|
|
+ sortEl.addEventListener('change', scheduleReload);
|
|
|
+
|
|
|
+ document.getElementById('btn-confirm-customer-selector').addEventListener('click', () => {
|
|
|
+ const selectedCustomers = Array.from(loadedCustomers.values()).filter(c => selected.has(String(c.externalUserId || '')));
|
|
|
+ const externalUserIds = selectedCustomers.map(c => String(c.externalUserId || '')).filter(Boolean);
|
|
|
+ closeModal();
|
|
|
+ resolve({ externalUserIds, customers: selectedCustomers });
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCustomerSelectorValue(containerId, customers) {
|
|
|
+ const container = document.getElementById(containerId);
|
|
|
+ if (!container) return;
|
|
|
+ if (!customers.length) {
|
|
|
+ container.innerHTML = '<span style="color:var(--text-muted);font-size:13px">未选择客户</span>';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ container.innerHTML = customers.map(c => `
|
|
|
+ <span class="tag">${escapeHtml(c.name || '未命名')} ${c.phone ? `(${escapeHtml(c.phone)})` : ''}</span>
|
|
|
+ `).join('');
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatDateTime(iso) {
|
|
|
+ if (!iso) return '-';
|
|
|
+ try {
|
|
|
+ const d = new Date(iso);
|
|
|
+ return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')} ${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
|
+ } catch {
|
|
|
+ return iso;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatDate(iso) {
|
|
|
+ if (!iso) return '-';
|
|
|
+ try {
|
|
|
+ const d = new Date(iso);
|
|
|
+ return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
|
+ } catch {
|
|
|
+ return iso;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function last7Days() {
|
|
|
+ const days = [];
|
|
|
+ for (let i = 6; i >= 0; i--) {
|
|
|
+ const d = new Date();
|
|
|
+ d.setDate(d.getDate() - i);
|
|
|
+ days.push(d.toISOString().slice(0, 10));
|
|
|
+ }
|
|
|
+ return days;
|
|
|
+ }
|
|
|
+
|
|
|
+ function countByDay(items, dateField) {
|
|
|
+ const days = last7Days();
|
|
|
+ const counts = Object.fromEntries(days.map(d => [d, 0]));
|
|
|
+ for (const item of items) {
|
|
|
+ const date = item[dateField] ? item[dateField].slice(0, 10) : null;
|
|
|
+ if (date && counts[date] !== undefined) counts[date]++;
|
|
|
+ }
|
|
|
+ return days.map(d => counts[d]);
|
|
|
+ }
|
|
|
+
|
|
|
+ function setActiveNav(page) {
|
|
|
+ state.page = page;
|
|
|
+ document.querySelectorAll('.nav-item').forEach(el => {
|
|
|
+ el.classList.remove('active');
|
|
|
+ el.removeAttribute('aria-current');
|
|
|
+ });
|
|
|
+ const active = document.querySelector(`.nav-item[data-page="${page}"]`);
|
|
|
+ if (active) {
|
|
|
+ active.classList.add('active');
|
|
|
+ active.setAttribute('aria-current', 'page');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderPage() {
|
|
|
+ const renderToken = ++state.renderToken;
|
|
|
+ closeAllModals();
|
|
|
+ const page = location.hash.slice(1) || 'overview';
|
|
|
+ const loggedIn = !!currentAccount();
|
|
|
+ els.nav.classList.toggle('nav-locked', !loggedIn);
|
|
|
+ setActiveNav(page);
|
|
|
+ updateAccountSwitcher();
|
|
|
+ els.content.innerHTML = '';
|
|
|
+ if (state.agent.timer) {
|
|
|
+ clearInterval(state.agent.timer);
|
|
|
+ state.agent.timer = null;
|
|
|
+ }
|
|
|
+ disposeDashboard();
|
|
|
+
|
|
|
+ const acc = currentAccount();
|
|
|
+ if (!acc && !['overview', 'agent', 'skills', 'knowledge', 'customer-ops'].includes(page)) {
|
|
|
+ renderLoginPage();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ if (page === 'overview') await renderOverviewPage();
|
|
|
+ else if (page === 'agent') await renderAgentPage();
|
|
|
+ else if (page === 'skills') await renderSkillsPage();
|
|
|
+ else if (page === 'knowledge') await renderKnowledgePage();
|
|
|
+ else if (page === 'status') await renderStatusPage();
|
|
|
+ else if (page === 'customer-ops') await renderCustomerOpsPage();
|
|
|
+ else if (page === 'portraits') await renderPortraitsPage();
|
|
|
+ else if (page === 'transfers') await renderTransfersPage();
|
|
|
+ else if (page === 'group-ops') await renderGroupOperationsPage();
|
|
|
+ else await renderGroupsPage();
|
|
|
+ } catch (error) {
|
|
|
+ if (renderToken !== state.renderToken || page !== state.page) return;
|
|
|
+ const current = els.content.querySelector('.page');
|
|
|
+ if (current) showError(current, error.message || '页面加载失败', () => renderPage());
|
|
|
+ else {
|
|
|
+ const fallback = document.createElement('div');
|
|
|
+ fallback.className = 'page';
|
|
|
+ els.content.appendChild(fallback);
|
|
|
+ showError(fallback, error.message || '页面加载失败', () => renderPage());
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Business Overview ====================
|
|
|
+ function overviewNumber(value, fallback = '0') {
|
|
|
+ const number = Number(value);
|
|
|
+ return Number.isFinite(number) ? number.toLocaleString('zh-CN') : fallback;
|
|
|
+ }
|
|
|
+
|
|
|
+ function overviewMetric(value, suffix = '') {
|
|
|
+ return value === null || value === undefined ? '-' : `${overviewNumber(value)}${suffix}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function overviewIcon(name) {
|
|
|
+ const paths = {
|
|
|
+ customer: 'M16 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3ZM8 11c1.66 0 3-1.34 3-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.5C15 14.17 10.33 13 8 13Zm8 0c-.29 0-.62.02-.97.05A4.2 4.2 0 0 1 17 16.5V19h6v-2.5c0-2.33-4.67-3.5-7-3.5Z',
|
|
|
+ portrait: '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',
|
|
|
+ task: '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-8 14-4-4 1.4-1.4 2.6 2.6 5.6-5.6L18 10l-7 7Z',
|
|
|
+ risk: 'M1 21h22L12 2 1 21Zm12-3h-2v-2h2v2Zm0-4h-2v-4h2v4Z',
|
|
|
+ chat: 'M20 2H4a2 2 0 0 0-2 2v18l4-4h14a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2Zm-2 11H6v-2h12v2Zm0-3H6V8h12v2Zm0-3H6V5h12v2Z',
|
|
|
+ refresh: 'M17.65 6.35C16.2 4.9 14.21 4 12 4a8 8 0 1 0 7.75 10h-2.1A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h8V3l-3.35 3.35Z',
|
|
|
+ arrow: 'm12 4-1.41 1.41L16.17 11H4v2h12.17l-5.58 5.59L12 20l8-8-8-8Z'
|
|
|
+ };
|
|
|
+ return `<svg viewBox="0 0 24 24" aria-hidden="true"><path d="${paths[name] || paths.task}"/></svg>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildOverviewDiagnosis(query, data) {
|
|
|
+ const text = String(query || '').trim();
|
|
|
+ const normalized = text.toLowerCase();
|
|
|
+ const summary = data.summary || {};
|
|
|
+ const customer = summary.customers || {};
|
|
|
+ const group = data.groupOverview?.metrics || {};
|
|
|
+ const conversations = data.conversations || [];
|
|
|
+ const pendingReplies = conversations.filter(item => item.pendingReply).length;
|
|
|
+ const openCustomerTasks = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.openTasks || 0), 0);
|
|
|
+ const missingPortraits = Math.max(0, Number(customer.total || 0) - Number(summary.portraits?.total || 0));
|
|
|
+
|
|
|
+ if (/复购|成交|gmv|销售额|订单|转化率/.test(normalized)) {
|
|
|
+ return {
|
|
|
+ tone: 'info',
|
|
|
+ label: '需要补充交易数据',
|
|
|
+ title: '当前企微数据不足以直接判断复购变化',
|
|
|
+ summary: '系统目前能验证客户触达、画像和社群执行情况,但尚未接入订单与复购口径。继续给出确定性结论会造成误导。',
|
|
|
+ evidence: [`客户主档 ${overviewNumber(customer.total)} 人`, `画像覆盖率 ${overviewMetric(summary.portraits?.coverage, '%')}`, `近 7 日运营记录 ${overviewNumber(summary.operations?.recent7d)} 条`],
|
|
|
+ actions: [
|
|
|
+ { label: '检查重点客户', description: '从画像完整度和待跟进任务开始排查', hash: '#customer-ops' },
|
|
|
+ { label: '查看社群执行', description: '确认近期计划是否按时触达', hash: '#group-ops' },
|
|
|
+ { label: '补充经营数据', description: '在知识库中接入订单或 ERP 导出', hash: '#knowledge' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/群|社群|sop|活动|话术|投诉|风险|质检/.test(normalized)) {
|
|
|
+ const highRisk = Number(group.highRisk || 0);
|
|
|
+ const pendingReview = Number(group.pendingReview || 0);
|
|
|
+ return {
|
|
|
+ tone: highRisk ? 'danger' : pendingReview ? 'warning' : 'success',
|
|
|
+ label: highRisk ? '存在高风险待闭环' : pendingReview ? '存在待审核计划' : '当前未发现高风险积压',
|
|
|
+ title: highRisk ? `${highRisk} 项高风险需要优先处理` : `${overviewNumber(group.groups)} 个群的运营状态已汇总`,
|
|
|
+ summary: `今日计划 ${overviewNumber(group.dueItems)} 项,完成 ${overviewNumber(group.completedItems)} 项,待审核 ${overviewNumber(pendingReview)} 项。所有风险结论均应回到原消息核验。`,
|
|
|
+ evidence: [`执行率 ${overviewMetric(group.executionRate, '%')}`, `开放预警 ${overviewNumber(group.openFindings)}`, `健康度 ${overviewMetric(group.averageHealthScore, ' 分')}`],
|
|
|
+ actions: [
|
|
|
+ { label: '处理风险', description: '查看原消息证据并建立整改任务', hash: '#group-ops' },
|
|
|
+ { label: '审核今日计划', description: '批准话术或退回修改', hash: '#group-ops' },
|
|
|
+ { label: '核对客户群', description: '确认群身份与回调接入范围', hash: '#groups' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (/客户|跟进|画像|标签|重点|流失|意向/.test(normalized)) {
|
|
|
+ return {
|
|
|
+ tone: openCustomerTasks || missingPortraits ? 'warning' : 'success',
|
|
|
+ label: openCustomerTasks ? '存在客户待办' : missingPortraits ? '画像覆盖仍需提升' : '客户资产状态稳定',
|
|
|
+ title: openCustomerTasks ? `${openCustomerTasks} 项客户跟进任务待处理` : `${overviewNumber(customer.total)} 位客户已进入统一主档`,
|
|
|
+ summary: `当前画像覆盖率 ${overviewMetric(summary.portraits?.coverage, '%')},仍有 ${overviewNumber(missingPortraits)} 位客户缺少完整画像。建议优先处理有任务、有预警且画像证据不足的客户。`,
|
|
|
+ evidence: [`已形成画像 ${overviewNumber(summary.portraits?.total)}`, `近 7 日新增画像 ${overviewNumber(summary.portraits?.recent7d)}`, `客户标签 ${overviewNumber(summary.tags?.total)} 个`],
|
|
|
+ actions: [
|
|
|
+ { label: '打开客户主档', description: '按任务、预警和画像完整度处理', hash: '#customer-ops' },
|
|
|
+ { label: '补齐画像', description: '查看字段依据并人工核对', hash: '#portraits' },
|
|
|
+ { label: '进入客服工作台', description: '从真实会话继续跟进', hash: '#agent' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ tone: pendingReplies ? 'warning' : 'info',
|
|
|
+ label: pendingReplies ? '有草稿等待审核' : '经营数据已就绪',
|
|
|
+ title: pendingReplies ? `${pendingReplies} 条 Agent 草稿等待处理` : '可以从客户、社群或客服问题开始诊断',
|
|
|
+ summary: `系统已汇总 ${overviewNumber(customer.total)} 位客户、${overviewNumber(group.groups)} 个运营群和 ${overviewNumber(conversations.length)} 个真实会话。请输入更具体的业务问题以缩小范围。`,
|
|
|
+ evidence: [`真实会话 ${overviewNumber(conversations.length)}`, `今日群任务 ${overviewNumber(group.dueItems)}`, `近 7 日运营记录 ${overviewNumber(summary.operations?.recent7d)}`],
|
|
|
+ actions: [
|
|
|
+ { label: '处理客服草稿', description: '审核、驳回或人工接管', hash: '#agent' },
|
|
|
+ { label: '查看今日行动', description: '统一处理任务与 AI 候选', hash: '#knowledge' },
|
|
|
+ { label: '查看社群运营', description: '处理计划、风险与自动化', hash: '#group-ops' }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderOverviewDiagnosis(diagnosis) {
|
|
|
+ if (!diagnosis) return '<div class="overview-diagnosis-empty"><strong>从一个经营问题开始</strong><span>诊断只使用已接入的真实企微数据;数据不足时会明确提示,不生成虚假结论。</span></div>';
|
|
|
+ const gaps = Array.isArray(diagnosis.dataGaps) ? diagnosis.dataGaps : [];
|
|
|
+ return `<article class="overview-diagnosis-result ${escapeHtml(diagnosis.tone)}">
|
|
|
+ <header><span>${escapeHtml(diagnosis.label)}</span><strong>${escapeHtml(diagnosis.title)}</strong></header>
|
|
|
+ <p>${escapeHtml(diagnosis.summary)}</p>
|
|
|
+ <div class="overview-evidence">${diagnosis.evidence.map(item => `<span>${escapeHtml(item)}</span>`).join('')}</div>
|
|
|
+ ${gaps.length ? `<div class="overview-data-gaps"><strong>数据缺口</strong><div>${gaps.slice(0, 3).map(item => `<span title="${escapeHtml(item.impact || '')}">${escapeHtml(item.label)}<small>${escapeHtml(item.nextAction || '')}</small></span>`).join('')}</div></div>` : ''}
|
|
|
+ <div class="overview-diagnosis-actions">${diagnosis.actions.map((action, index) => {
|
|
|
+ const actionKey = `${String(state.overview.query || '')}:${String(action.id || action.label || index)}`;
|
|
|
+ const added = state.overview.candidateActionIds.has(actionKey);
|
|
|
+ return `<article class="overview-diagnosis-action">
|
|
|
+ <button type="button" class="overview-diagnosis-go" data-overview-go="${escapeHtml(action.hash || action.route || '#overview')}"><span>${escapeHtml(action.label)}</span><small>${escapeHtml(action.description)}</small>${overviewIcon('arrow')}</button>
|
|
|
+ <button type="button" class="overview-candidate-btn ${added ? 'added' : ''}" data-overview-candidate="${index}" ${added ? 'disabled' : ''}>${added ? '已加入今日行动' : '加入今日行动'}</button>
|
|
|
+ </article>`;
|
|
|
+ }).join('')}</div>
|
|
|
+ </article>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderOverviewWorkspace(page) {
|
|
|
+ const data = state.overview.data || {};
|
|
|
+ const summary = data.summary || {};
|
|
|
+ const customers = summary.customers || {};
|
|
|
+ const portraits = summary.portraits || {};
|
|
|
+ const group = data.groupOverview?.metrics || {};
|
|
|
+ const agent = data.agentStatus || {};
|
|
|
+ const conversations = data.conversations || [];
|
|
|
+ const pendingReplies = conversations.filter(item => item.pendingReply).length;
|
|
|
+ const openCustomerTasks = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.openTasks || 0), 0);
|
|
|
+ const highAlerts = conversations.reduce((sum, item) => sum + Number(item.customerIntelligence?.summary?.highAlerts || 0), 0);
|
|
|
+ const actionCount = pendingReplies + Number(group.pendingReview || 0) + Number(group.highRisk || 0) + openCustomerTasks;
|
|
|
+ const connectionOnline = Boolean(agent.account?.online || currentAccount()?.online);
|
|
|
+ const listenerRunning = Boolean(agent.ingress?.running ?? agent.listener?.running);
|
|
|
+ const healthScore = group.averageHealthScore;
|
|
|
+ const query = state.overview.query || '';
|
|
|
+
|
|
|
+ const actions = [
|
|
|
+ { tone: 'danger', count: Number(group.highRisk || 0) + highAlerts, title: '高风险待闭环', desc: '回到原消息核验并建立整改任务', hash: '#group-ops' },
|
|
|
+ { tone: 'warning', count: Number(group.pendingReview || 0) + pendingReplies, title: '内容待审核', desc: '包括社群话术和 Agent 回复草稿', hash: pendingReplies ? '#agent' : '#group-ops' },
|
|
|
+ { tone: 'info', count: openCustomerTasks, title: '客户待跟进', desc: '基于真实会话形成的内部任务', hash: '#customer-ops' }
|
|
|
+ ];
|
|
|
+
|
|
|
+ page.innerHTML = `<div class="overview-page">
|
|
|
+ <section class="overview-command">
|
|
|
+ <div class="overview-command-copy">
|
|
|
+ <div class="overview-kicker"><span class="overview-live ${connectionOnline ? 'online' : ''}"></span>${connectionOnline ? '真实企微数据已连接' : '本地经营数据模式'}</div>
|
|
|
+ <h2>今天先处理什么?</h2>
|
|
|
+ <p>从真实会话、客户主档和社群执行记录中定位问题,再进入对应工作台处理。</p>
|
|
|
+ <div class="overview-command-status" aria-label="今日经营状态">
|
|
|
+ <span><strong>${overviewNumber(actionCount)}</strong> 项今日待处理</span>
|
|
|
+ <span class="${Number(group.highRisk || 0) + highAlerts ? 'is-risk' : ''}"><strong>${overviewNumber(Number(group.highRisk || 0) + highAlerts)}</strong> 项高风险</span>
|
|
|
+ <span><strong>${overviewNumber(conversations.length)}</strong> 个真实会话</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <form id="overview-diagnosis-form" class="overview-query-form">
|
|
|
+ <label for="overview-query">经营问题</label>
|
|
|
+ <div><input id="overview-query" name="query" value="${escapeHtml(query)}" placeholder="例如:哪些客户今天必须跟进?哪些群存在高风险?" autocomplete="off" /><button class="btn" type="submit">开始诊断</button></div>
|
|
|
+ <div class="overview-query-chips">
|
|
|
+ ${['哪些客户今天必须跟进?', '哪些群存在高风险?', '今天有哪些内容待审核?', '最近为什么复购下降?'].map(item => `<button type="button" data-overview-query="${escapeHtml(item)}">${escapeHtml(item)}</button>`).join('')}
|
|
|
+ </div>
|
|
|
+ </form>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="overview-kpi-grid" aria-label="核心经营指标">
|
|
|
+ <button type="button" class="overview-kpi" data-overview-go="#customer-ops"><span class="overview-kpi-icon customer">${overviewIcon('customer')}</span><div><small>客户资产</small><strong>${overviewNumber(customers.total)}</strong><em>今日新增 ${overviewNumber(customers.todayNew)}</em></div></button>
|
|
|
+ <button type="button" class="overview-kpi" data-overview-go="#portraits"><span class="overview-kpi-icon portrait">${overviewIcon('portrait')}</span><div><small>画像覆盖</small><strong>${overviewMetric(portraits.coverage, '%')}</strong><em>近 7 日更新 ${overviewNumber(portraits.recent7d)}</em></div></button>
|
|
|
+ <button type="button" class="overview-kpi" data-overview-go="#group-ops"><span class="overview-kpi-icon task">${overviewIcon('task')}</span><div><small>今日群任务</small><strong>${overviewNumber(group.dueItems)}</strong><em>执行率 ${overviewMetric(group.executionRate, '%')}</em></div></button>
|
|
|
+ <button type="button" class="overview-kpi ${Number(group.highRisk || 0) + highAlerts ? 'attention' : ''}" data-overview-go="#group-ops"><span class="overview-kpi-icon risk">${overviewIcon('risk')}</span><div><small>高风险未闭环</small><strong>${overviewNumber(Number(group.highRisk || 0) + highAlerts)}</strong><em>${Number(group.openFindings || 0)} 项开放预警</em></div></button>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="overview-main-grid">
|
|
|
+ <div class="overview-primary-column">
|
|
|
+ <section class="overview-panel overview-diagnosis-panel">
|
|
|
+ <header class="overview-panel-header"><div><span>BUSINESS DIAGNOSIS</span><h3>经营诊断</h3></div><small>基于当前数据快照</small></header>
|
|
|
+ <div id="overview-diagnosis-result">${renderOverviewDiagnosis(state.overview.diagnosis)}</div>
|
|
|
+ </section>
|
|
|
+ <section class="overview-panel overview-actions-panel">
|
|
|
+ <header class="overview-panel-header"><div><span>TODAY'S ACTIONS</span><h3>今日行动</h3></div><strong>${overviewNumber(actionCount)} 项</strong></header>
|
|
|
+ <div class="overview-action-list">${actions.map(item => `<button type="button" class="overview-action ${item.tone}" data-overview-go="${item.hash}"><i></i><strong>${overviewNumber(item.count)}</strong><span><b>${item.title}</b><small>${item.desc}</small></span>${overviewIcon('arrow')}</button>`).join('')}</div>
|
|
|
+ </section>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <aside class="overview-secondary-column">
|
|
|
+ <section class="overview-panel overview-health-panel">
|
|
|
+ <header class="overview-panel-header"><div><span>OPERATING HEALTH</span><h3>运营健康度</h3></div><strong>${overviewMetric(healthScore, '')}</strong></header>
|
|
|
+ <div class="overview-health-track"><i style="width:${Math.max(0, Math.min(100, Number(healthScore || 0)))}%"></i></div>
|
|
|
+ <dl><div><dt>群执行率</dt><dd>${overviewMetric(group.executionRate, '%')}</dd></div><div><dt>数据覆盖</dt><dd>${overviewMetric(group.averageHealthCoverage, '%')}</dd></div><div><dt>画像覆盖</dt><dd>${overviewMetric(portraits.coverage, '%')}</dd></div></dl>
|
|
|
+ <button type="button" class="overview-text-action" data-overview-go="#group-ops">查看指标依据 ${overviewIcon('arrow')}</button>
|
|
|
+ </section>
|
|
|
+ <section class="overview-panel overview-workforce-panel">
|
|
|
+ <header class="overview-panel-header"><div><span>AI WORKFORCE</span><h3>数字员工</h3></div><small>${listenerRunning ? '运行中' : '待命'}</small></header>
|
|
|
+ <div class="overview-worker"><span class="overview-worker-icon">${overviewIcon('chat')}</span><div><strong>AI 客服</strong><small>${overviewNumber(conversations.length)} 个真实会话 · ${overviewNumber(pendingReplies)} 条待审</small></div><em class="${listenerRunning ? 'online' : ''}">${listenerRunning ? '已上岗' : '待启动'}</em></div>
|
|
|
+ <div class="overview-worker"><span class="overview-worker-icon">${overviewIcon('task')}</span><div><strong>社群运营官</strong><small>${overviewNumber(group.groups)} 个群 · ${overviewNumber(group.pendingReview)} 项待审</small></div><em class="${group.groups ? 'online' : ''}">${group.groups ? '已接入' : '待接入'}</em></div>
|
|
|
+ <button type="button" class="overview-text-action" data-overview-go="#skills">查看能力与边界 ${overviewIcon('arrow')}</button>
|
|
|
+ </section>
|
|
|
+ </aside>
|
|
|
+ </section>
|
|
|
+ <footer class="overview-data-note"><span>数据时间:${escapeHtml(data.loadedAt ? formatAgentTime(data.loadedAt) : '刚刚')}</span><span>不包含交易、订单和 GMV 数据;相关问题会提示补充数据。</span><button type="button" id="overview-refresh">${overviewIcon('refresh')}刷新</button></footer>
|
|
|
+ </div>`;
|
|
|
+
|
|
|
+ const runDiagnosis = async (value) => {
|
|
|
+ state.overview.query = String(value || '').trim();
|
|
|
+ const result = page.querySelector('#overview-diagnosis-result');
|
|
|
+ const submit = page.querySelector('#overview-diagnosis-form button[type="submit"]');
|
|
|
+ if (submit) { submit.disabled = true; submit.textContent = '诊断中...'; }
|
|
|
+ if (result) result.innerHTML = '<div class="overview-diagnosis-empty"><div class="spinner"></div><strong>正在核对经营数据</strong><span>汇总客户、会话和社群运营证据...</span></div>';
|
|
|
+ try {
|
|
|
+ const response = await api('POST', '/api/business-diagnosis', { question: state.overview.query, accountKey: agentAccountKey() || undefined });
|
|
|
+ state.overview.diagnosis = response.data?.diagnosis || buildOverviewDiagnosis(state.overview.query, state.overview.data || {});
|
|
|
+ } catch {
|
|
|
+ state.overview.diagnosis = buildOverviewDiagnosis(state.overview.query, state.overview.data || {});
|
|
|
+ toast('诊断服务暂不可用,已使用本地规则完成降级诊断', 'error');
|
|
|
+ } finally {
|
|
|
+ if (submit) { submit.disabled = false; submit.textContent = '开始诊断'; }
|
|
|
+ }
|
|
|
+ if (result) result.innerHTML = renderOverviewDiagnosis(state.overview.diagnosis);
|
|
|
+ bindOverviewNavigation(result || page);
|
|
|
+ bindOverviewCandidates(result || page, state.overview.diagnosis);
|
|
|
+ };
|
|
|
+ page.querySelector('#overview-diagnosis-form').addEventListener('submit', async event => {
|
|
|
+ event.preventDefault();
|
|
|
+ const input = page.querySelector('#overview-query');
|
|
|
+ if (!input.value.trim()) { toast('请先输入一个经营问题', 'error'); input.focus(); return; }
|
|
|
+ await runDiagnosis(input.value);
|
|
|
+ });
|
|
|
+ page.querySelectorAll('[data-overview-query]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ page.querySelector('#overview-query').value = button.dataset.overviewQuery;
|
|
|
+ await runDiagnosis(button.dataset.overviewQuery);
|
|
|
+ }));
|
|
|
+ page.querySelector('#overview-refresh').addEventListener('click', () => loadOverviewData(page, false));
|
|
|
+ bindOverviewNavigation(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindOverviewNavigation(root) {
|
|
|
+ if (!root) return;
|
|
|
+ root.querySelectorAll('[data-overview-go]').forEach(button => {
|
|
|
+ if (button.dataset.overviewBound) return;
|
|
|
+ button.dataset.overviewBound = '1';
|
|
|
+ button.addEventListener('click', () => { location.hash = button.dataset.overviewGo; });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindOverviewCandidates(root, diagnosis) {
|
|
|
+ if (!root || !diagnosis) return;
|
|
|
+ root.querySelectorAll('[data-overview-candidate]').forEach(button => {
|
|
|
+ button.addEventListener('click', async () => {
|
|
|
+ const index = Number(button.dataset.overviewCandidate);
|
|
|
+ const action = diagnosis.actions?.[index];
|
|
|
+ if (!action) return;
|
|
|
+ const actionKey = `${String(diagnosis.query || state.overview.query || '')}:${String(action.id || action.label || index)}`;
|
|
|
+ button.disabled = true;
|
|
|
+ button.textContent = '正在加入...';
|
|
|
+ try {
|
|
|
+ const response = await api('POST', '/api/knowledge/tasks/diagnosis-candidate', {
|
|
|
+ question: diagnosis.query || state.overview.query,
|
|
|
+ diagnosisType: diagnosis.type,
|
|
|
+ scope: diagnosis.scope,
|
|
|
+ action,
|
|
|
+ evidence: diagnosis.evidenceDetails?.length ? diagnosis.evidenceDetails : diagnosis.evidence,
|
|
|
+ dataGaps: diagnosis.dataGaps || []
|
|
|
+ });
|
|
|
+ if (response.status === 'error') throw new Error(response.assistantMessage || '加入行动候选失败');
|
|
|
+ state.overview.candidateActionIds.add(actionKey);
|
|
|
+ button.classList.add('added');
|
|
|
+ button.textContent = response.summary?.created === false ? '已在今日行动' : '已加入今日行动';
|
|
|
+ toast(response.assistantMessage || '已加入今日行动候选');
|
|
|
+ } catch (error) {
|
|
|
+ button.disabled = false;
|
|
|
+ button.textContent = '加入今日行动';
|
|
|
+ toast(error.message || '加入行动候选失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadOverviewData(page, silent = false) {
|
|
|
+ if (!state.overview.loadPromise) {
|
|
|
+ state.overview.loading = true;
|
|
|
+ const accountKey = agentAccountKey();
|
|
|
+ state.overview.loadPromise = (async () => {
|
|
|
+ const requests = [
|
|
|
+ api('GET', '/api/dashboard/summary', undefined, 10000),
|
|
|
+ api('GET', '/api/agent/status', undefined, 10000),
|
|
|
+ api('GET', '/api/agent/conversations', undefined, 10000)
|
|
|
+ ];
|
|
|
+ if (accountKey) requests.push(api('GET', `/api/group-ops/overview?accountKey=${encodeURIComponent(accountKey)}`, undefined, 10000));
|
|
|
+ const results = await Promise.allSettled(requests);
|
|
|
+ const read = (index, fallback = {}) => results[index]?.status === 'fulfilled' ? results[index].value : fallback;
|
|
|
+ state.overview.data = {
|
|
|
+ summary: read(0).data || {},
|
|
|
+ agentStatus: read(1).data || {},
|
|
|
+ conversations: read(2).data?.conversations || [],
|
|
|
+ groupOverview: accountKey ? (read(3).data || {}) : {},
|
|
|
+ loadedAt: new Date().toISOString()
|
|
|
+ };
|
|
|
+ if (state.overview.query && !state.overview.diagnosis?.scope) state.overview.diagnosis = buildOverviewDiagnosis(state.overview.query, state.overview.data);
|
|
|
+ })().finally(() => {
|
|
|
+ state.overview.loading = false;
|
|
|
+ state.overview.loadPromise = null;
|
|
|
+ });
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await state.overview.loadPromise;
|
|
|
+ if (state.page === 'overview' && page.isConnected) renderOverviewWorkspace(page);
|
|
|
+ } catch (error) {
|
|
|
+ if (!silent) toast(error.message || '经营总览加载失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderOverviewPage() {
|
|
|
+ els.pageTitle.textContent = '经营总览';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page overview-root';
|
|
|
+ page.innerHTML = '<div class="loading"><div class="spinner"></div><span>正在汇总经营数据...</span></div>';
|
|
|
+ els.content.appendChild(page);
|
|
|
+ await loadOverviewData(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Unified Skill Center ====================
|
|
|
+ function markdownInline(value) {
|
|
|
+ return escapeHtml(value)
|
|
|
+ .replace(/`([^`]+)`/g, '<code>$1</code>')
|
|
|
+ .replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
|
|
|
+ .replace(/\*([^*]+)\*/g, '<em>$1</em>');
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMarkdown(content) {
|
|
|
+ const source = String(content || '').replace(/^---\s*\r?\n[\s\S]*?\r?\n---\s*/, '').trim();
|
|
|
+ const lines = source.split(/\r?\n/);
|
|
|
+ const html = [];
|
|
|
+ let listType = '';
|
|
|
+ let inCode = false;
|
|
|
+ let codeLines = [];
|
|
|
+ const closeList = () => {
|
|
|
+ if (!listType) return;
|
|
|
+ html.push(`</${listType}>`);
|
|
|
+ listType = '';
|
|
|
+ };
|
|
|
+ for (const line of lines) {
|
|
|
+ if (line.trim().startsWith('```')) {
|
|
|
+ closeList();
|
|
|
+ if (inCode) {
|
|
|
+ html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
|
|
+ codeLines = [];
|
|
|
+ inCode = false;
|
|
|
+ } else {
|
|
|
+ inCode = true;
|
|
|
+ }
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ if (inCode) { codeLines.push(line); continue; }
|
|
|
+ const heading = line.match(/^(#{1,4})\s+(.+)$/);
|
|
|
+ if (heading) {
|
|
|
+ closeList();
|
|
|
+ const level = Math.min(4, heading[1].length + 1);
|
|
|
+ html.push(`<h${level}>${markdownInline(heading[2])}</h${level}>`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const unordered = line.match(/^\s*[-*]\s+(.+)$/);
|
|
|
+ if (unordered) {
|
|
|
+ if (listType !== 'ul') { closeList(); listType = 'ul'; html.push('<ul>'); }
|
|
|
+ html.push(`<li>${markdownInline(unordered[1])}</li>`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
|
|
|
+ if (ordered) {
|
|
|
+ if (listType !== 'ol') { closeList(); listType = 'ol'; html.push('<ol>'); }
|
|
|
+ html.push(`<li>${markdownInline(ordered[1])}</li>`);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ closeList();
|
|
|
+ if (!line.trim()) continue;
|
|
|
+ if (line.startsWith('>')) html.push(`<blockquote>${markdownInline(line.replace(/^>\s?/, ''))}</blockquote>`);
|
|
|
+ else if (/^\|.*\|$/.test(line.trim())) html.push(`<div class="md-table-line">${markdownInline(line.trim())}</div>`);
|
|
|
+ else html.push(`<p>${markdownInline(line)}</p>`);
|
|
|
+ }
|
|
|
+ if (inCode) html.push(`<pre><code>${escapeHtml(codeLines.join('\n'))}</code></pre>`);
|
|
|
+ closeList();
|
|
|
+ return html.join('');
|
|
|
+ }
|
|
|
+
|
|
|
+ function skillStatusLabel(status) {
|
|
|
+ return ({ running: '主运行包', connected: '已接入', merged: '已合并', integrated: '已集成', 'data-connected': '数据已接入' })[status] || status;
|
|
|
+ }
|
|
|
+
|
|
|
+ function selectedSkillPackages() {
|
|
|
+ const registry = state.skills.registry;
|
|
|
+ if (!registry) return [];
|
|
|
+ const keyword = state.skills.keyword.trim().toLowerCase();
|
|
|
+ return registry.packages.map(pkg => ({
|
|
|
+ ...pkg,
|
|
|
+ skills: pkg.skills.filter(skill => !keyword || `${skill.name} ${skill.title} ${skill.description} ${(skill.tags || []).join(' ')}`.toLowerCase().includes(keyword)),
|
|
|
+ })).filter(pkg => !keyword || pkg.skills.length);
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderSkillsWorkspace(page) {
|
|
|
+ const registry = state.skills.registry || { packages: [], summary: {} };
|
|
|
+ const packages = selectedSkillPackages();
|
|
|
+ const selectedPackage = packages.find(item => item.id === state.skills.selectedPackageId) || packages[0] || null;
|
|
|
+ if (selectedPackage) state.skills.selectedPackageId = selectedPackage.id;
|
|
|
+ const selectedSkill = selectedPackage?.skills.find(item => item.id === state.skills.selectedSkillId)
|
|
|
+ || selectedPackage?.skills[0]
|
|
|
+ || packages.flatMap(item => item.skills)[0]
|
|
|
+ || null;
|
|
|
+ if (selectedSkill) state.skills.selectedSkillId = selectedSkill.id;
|
|
|
+
|
|
|
+ page.innerHTML = `
|
|
|
+ <section class="workspace-hero skills-hero">
|
|
|
+ <div><span>UNIFIED QIWEI CAPABILITY HUB</span><h2>技能中心</h2><p>把通用企微 Skills、Agent Workbench 和运行工具统一到一个 4320 工作台。</p></div>
|
|
|
+ <div class="workspace-stat-row">
|
|
|
+ <div><strong>${registry.summary.packageCount || 0}</strong><span>能力来源</span></div>
|
|
|
+ <div><strong>${registry.summary.skillCount || 0}</strong><span>技能实例</span></div>
|
|
|
+ <div><strong>${registry.summary.uniqueCapabilityCount || 0}</strong><span>去重能力</span></div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section class="skill-toolbar">
|
|
|
+ <div><strong>统一能力目录</strong><span>已有操作页直接跳转;其余 Skill/MCP 能力展示完整说明和调用边界。</span></div>
|
|
|
+ <input id="skill-search" type="search" value="${escapeHtml(state.skills.keyword)}" placeholder="搜索技能、能力或触发语" />
|
|
|
+ </section>
|
|
|
+ <section class="skill-hub-layout">
|
|
|
+ <aside class="skill-package-list">
|
|
|
+ ${packages.map(pkg => `<button class="skill-package-item ${pkg.id === selectedPackage?.id ? 'active' : ''}" data-skill-package="${escapeHtml(pkg.id)}"><span>${escapeHtml(pkg.label)}</span><em>${pkg.skills.length}</em><small>${escapeHtml(skillStatusLabel(pkg.status))}</small></button>`).join('') || '<div class="library-empty">没有匹配的技能包</div>'}
|
|
|
+ </aside>
|
|
|
+ <main class="skill-card-panel">
|
|
|
+ <header><div><strong>${escapeHtml(selectedPackage?.label || '技能包')}</strong><span>${escapeHtml(selectedPackage?.description || '')}</span></div><code>${escapeHtml(selectedPackage?.root || '')}</code></header>
|
|
|
+ <div class="skill-card-grid">
|
|
|
+ ${(selectedPackage?.skills || []).map(skill => `<button class="skill-capability-card ${skill.id === selectedSkill?.id ? 'active' : ''}" data-skill-id="${escapeHtml(skill.id)}"><div><span>${escapeHtml(skill.title || skill.name)}</span><em>${escapeHtml(skillStatusLabel(skill.status))}</em></div><p>${escapeHtml(skill.description || '暂无说明')}</p><footer>${(skill.tags || []).map(tag => `<i>${escapeHtml(tag)}</i>`).join('')}</footer></button>`).join('') || '<div class="library-empty">这个包中没有匹配的技能</div>'}
|
|
|
+ </div>
|
|
|
+ </main>
|
|
|
+ <aside class="skill-detail-panel">
|
|
|
+ ${selectedSkill ? `<div class="skill-detail-heading"><span>${escapeHtml(selectedPackage?.label || '')}</span><h3>${escapeHtml(selectedSkill.title || selectedSkill.name)}</h3><p>${escapeHtml(selectedSkill.description || '')}</p>${selectedSkill.page ? `<button class="btn" data-open-page="${escapeHtml(selectedSkill.page)}">打开对应功能页</button>` : '<span class="badge badge-default">通过 Skill / MCP 调用</span>'}</div><div class="markdown-view skill-markdown">${renderMarkdown(selectedSkill.content)}</div>` : '<div class="library-empty">选择一个技能查看详情</div>'}
|
|
|
+ </aside>
|
|
|
+ </section>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderSkillsPage() {
|
|
|
+ els.pageTitle.textContent = '技能中心';
|
|
|
+ void refreshAgentAccountHeader();
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page skills-page';
|
|
|
+ page.innerHTML = '<div class="agent-loading">正在扫描本地技能包…</div>';
|
|
|
+ els.content.appendChild(page);
|
|
|
+ page.addEventListener('click', event => {
|
|
|
+ const packageButton = event.target.closest('[data-skill-package]');
|
|
|
+ if (packageButton) {
|
|
|
+ state.skills.selectedPackageId = packageButton.dataset.skillPackage;
|
|
|
+ state.skills.selectedSkillId = null;
|
|
|
+ renderSkillsWorkspace(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const skillButton = event.target.closest('[data-skill-id]');
|
|
|
+ if (skillButton) {
|
|
|
+ state.skills.selectedSkillId = skillButton.dataset.skillId;
|
|
|
+ renderSkillsWorkspace(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const openButton = event.target.closest('[data-open-page]');
|
|
|
+ if (openButton) location.hash = `#${openButton.dataset.openPage}`;
|
|
|
+ });
|
|
|
+ page.addEventListener('input', event => {
|
|
|
+ if (event.target.id !== 'skill-search') return;
|
|
|
+ state.skills.keyword = event.target.value;
|
|
|
+ renderSkillsWorkspace(page);
|
|
|
+ const input = page.querySelector('#skill-search');
|
|
|
+ if (input) { input.focus(); input.setSelectionRange(input.value.length, input.value.length); }
|
|
|
+ });
|
|
|
+ try {
|
|
|
+ const result = await api('GET', '/api/skills');
|
|
|
+ state.skills.registry = result.data;
|
|
|
+ state.skills.selectedPackageId = state.skills.selectedPackageId || result.data.packages[0]?.id || null;
|
|
|
+ renderSkillsWorkspace(page);
|
|
|
+ } catch (error) {
|
|
|
+ page.innerHTML = `<div class="library-error">${escapeHtml(error.message || '技能包扫描失败')}</div>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Knowledge Library ====================
|
|
|
+ function flattenKnowledgeNodes(nodes = []) {
|
|
|
+ return nodes.flatMap(node => [node, ...(node.children ? flattenKnowledgeNodes(node.children) : [])]);
|
|
|
+ }
|
|
|
+
|
|
|
+ function knowledgeIcon(node) {
|
|
|
+ if (node.type === 'folder') return '▸';
|
|
|
+ return ({ 'meeting-dashboard': '会', 'doc-dashboard': '文', 'todo-dashboard': '办', 'task-dashboard': '任', markdown: 'M', 'skill-document': 'S', json: '{ }', code: '</>', csv: '▦' })[node.kind] || '•';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderKnowledgeTreeNode(node, depth = 0) {
|
|
|
+ if (node.type === 'folder') {
|
|
|
+ const isOpen = state.knowledge.expandedFolderIds.has(node.id);
|
|
|
+ return `<details class="knowledge-tree-folder" data-knowledge-folder="${escapeHtml(node.id)}" ${isOpen ? 'open' : ''}><summary style="--tree-depth:${depth}"><span>${knowledgeIcon(node)}</span><strong>${escapeHtml(node.name)}</strong><em>${node.fileCount || 0}</em></summary><div>${(node.children || []).map(child => renderKnowledgeTreeNode(child, depth + 1)).join('')}</div></details>`;
|
|
|
+ }
|
|
|
+ return `<button class="knowledge-tree-file ${node.id === state.knowledge.selectedNodeId ? 'active' : ''}" style="--tree-depth:${depth}" data-knowledge-node="${escapeHtml(node.id)}"><span>${knowledgeIcon(node)}</span><strong>${escapeHtml(node.name)}</strong><small>${escapeHtml(node.kind)}</small></button>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatFileSize(value) {
|
|
|
+ const bytes = Number(value || 0);
|
|
|
+ if (bytes < 1024) return `${bytes} B`;
|
|
|
+ if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
|
|
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function meetingInputValue(value) {
|
|
|
+ return String(value || '').replace(' ', 'T').slice(0, 16);
|
|
|
+ }
|
|
|
+
|
|
|
+ function meetingAnalysisLabel(status) {
|
|
|
+ return ({ completed: 'AI 已分析', stale: '资料已变化', failed: '分析失败', pending: '待分析' })[status] || '待分析';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMeetingBullets(items, emptyText) {
|
|
|
+ return (items || []).length
|
|
|
+ ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>`
|
|
|
+ : `<p class="meeting-empty-copy">${escapeHtml(emptyText)}</p>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMeetingDetail(meeting) {
|
|
|
+ if (!meeting) {
|
|
|
+ return '<div class="library-empty large"><strong>暂无真实会议</strong><span>完成官方 CLI 扫码授权并同步后,会议详情会显示在这里;系统不会生成 Mock 会议。</span></div>';
|
|
|
+ }
|
|
|
+ const analysis = meeting.analysis || {};
|
|
|
+ const actionItems = analysis.actionItems || [];
|
|
|
+ const analysisReady = analysis.status === 'completed';
|
|
|
+ return `<article class="meeting-detail">
|
|
|
+ <header>
|
|
|
+ <div><span>OFFICIAL CLI · LIVE DATA</span><h3>${escapeHtml(meeting.title)}</h3><p>${escapeHtml(meeting.startAt || '时间待确认')}${meeting.endAt ? ` — ${escapeHtml(meeting.endAt)}` : ''} · ${escapeHtml(meeting.statusLabel || '未知状态')}</p></div>
|
|
|
+ <button class="btn" data-analyze-meeting="${escapeHtml(meeting.id)}" ${state.knowledge.meetings.loading ? 'disabled' : ''}>${analysisReady ? '重新分析' : '生成 AI 分析'}</button>
|
|
|
+ </header>
|
|
|
+ <div class="meeting-meta-grid">
|
|
|
+ <div><small>会议类型</small><strong>${escapeHtml(meeting.meetingTypeLabel || '-')}</strong></div>
|
|
|
+ <div><small>会议时长</small><strong>${meeting.durationSeconds ? `${Math.round(meeting.durationSeconds / 60)} 分钟` : '-'}</strong></div>
|
|
|
+ <div><small>参会统计</small><strong>${meeting.attendees?.internalCount || 0} 内部 / ${meeting.attendees?.externalCount || 0} 外部</strong></div>
|
|
|
+ <div><small>AI 状态</small><strong>${escapeHtml(meetingAnalysisLabel(analysis.status))}</strong></div>
|
|
|
+ </div>
|
|
|
+ <section class="meeting-source-note"><strong>会议描述(官方资料)</strong><p>${escapeHtml(meeting.description || '官方会议资料未填写描述。')}</p><small>会议 ID:${escapeHtml(meeting.id)} · 同步于 ${escapeHtml(formatAgentTime(meeting.syncedAt))}</small></section>
|
|
|
+ ${analysis.status === 'failed' ? `<div class="meeting-analysis-error"><strong>AI 分析未生成</strong><span>${escapeHtml(analysis.error || '模型调用失败,真实会议资料已保留。')}</span></div>` : ''}
|
|
|
+ <section class="meeting-analysis-summary"><span>AI SUMMARY · 需人工复核</span><h4>${escapeHtml(analysis.summary || '尚未生成会议摘要')}</h4><p>${escapeHtml(analysis.analysisBasis || '仅基于官方会议元数据和描述,不包含录音、转写或人工纪要。')}</p></section>
|
|
|
+ <div class="meeting-analysis-grid">
|
|
|
+ <section><h4>关键议题</h4>${renderMeetingBullets(analysis.topics, '资料中暂无可验证的关键议题')}</section>
|
|
|
+ <section><h4>明确决策</h4>${renderMeetingBullets(analysis.decisions, '资料中没有可验证的明确决策')}</section>
|
|
|
+ <section><h4>风险与待确认</h4>${renderMeetingBullets(analysis.risks, '暂无已识别风险')}</section>
|
|
|
+ <section><h4>建议动作(非已确认任务)</h4>${renderMeetingBullets(analysis.suggestedActions, '暂无建议动作')}</section>
|
|
|
+ </div>
|
|
|
+ <section class="meeting-actions"><header><div><span>ACTION CANDIDATES</span><h4>有依据的待办候选</h4></div><em>${actionItems.length} 条</em></header>${actionItems.length ? actionItems.map(item => `<div class="meeting-action-item"><i class="${escapeHtml(item.priority || 'medium')}"></i><div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.evidence || '依据待人工确认')}</p><small>${item.owner ? `负责人:${escapeHtml(item.owner)}` : '负责人待确认'}${item.dueDate ? ` · 截止:${escapeHtml(item.dueDate)}` : ' · 截止时间待确认'}</small></div><em>${Math.round(Number(item.confidence || 0) * 100)}%</em></div>`).join('') : '<p class="meeting-empty-copy">没有从现有资料中提取出有明确依据的待办。</p>'}</section>
|
|
|
+ <section class="meeting-tags"><strong>知识标签</strong><div>${(analysis.knowledgeTags || []).map(tag => `<span>${escapeHtml(tag)}</span>`).join('') || '<small>暂无标签</small>'}</div></section>
|
|
|
+ </article>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderMeetingKnowledgeView() {
|
|
|
+ const hub = state.knowledge.meetings.hub || { summary: {}, data: { cli: {}, range: {}, meetings: [] } };
|
|
|
+ const data = hub.data || {};
|
|
|
+ const summary = hub.summary || {};
|
|
|
+ const cli = data.cli || {};
|
|
|
+ const meetingReady = Boolean(summary.meetingReady);
|
|
|
+ const capabilityBlocked = Boolean(cli.ready && !meetingReady);
|
|
|
+ const meetings = [...(data.meetings || [])].sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)));
|
|
|
+ const selected = meetings.find(item => item.id === state.knowledge.meetings.selectedId) || meetings[0] || null;
|
|
|
+ if (selected) state.knowledge.meetings.selectedId = selected.id;
|
|
|
+ const range = data.range || {};
|
|
|
+ const meetingDetail = selected
|
|
|
+ ? renderMeetingDetail(selected)
|
|
|
+ : capabilityBlocked
|
|
|
+ ? '<div class="library-empty large"><strong>当前企业无法通过官方 CLI 拉取会议</strong><span>扫码授权已经成功;请换用 10 人及以下测试企业演示会议,当前企业可继续演示官方文档和待办能力。</span></div>'
|
|
|
+ : renderMeetingDetail(null);
|
|
|
+ return `<div class="meeting-workbench">
|
|
|
+ <header class="knowledge-view-header"><div><span>WECOM MEETING KNOWLEDGE</span><h3>企微会议知识沉淀</h3><p>官方 CLI 真实同步 · 本地脱敏存储 · AI 分析需人工复核</p></div><em>${meetingReady ? '真实通道已就绪' : capabilityBlocked ? '企业未开放会议' : '等待扫码授权'}</em></header>
|
|
|
+ <div class="meeting-stat-row"><div><strong>${summary.meetingCount || 0}</strong><span>累计会议</span></div><div><strong>${summary.analyzedCount || 0}</strong><span>已分析</span></div><div><strong>${summary.actionItemCount || 0}</strong><span>待办候选</span></div><div><strong>${summary.lastSyncedAt ? formatAgentTime(summary.lastSyncedAt) : '-'}</strong><span>最近同步</span></div></div>
|
|
|
+ <section class="meeting-cli-banner ${meetingReady ? 'ready' : capabilityBlocked ? 'blocked' : 'waiting'}"><div><i></i><strong>${meetingReady ? '企业微信官方会议 CLI 已就绪' : capabilityBlocked ? '扫码授权成功,但当前企业未开放会议 CLI' : '企业微信官方 CLI 尚未完成扫码授权'}</strong><span>${meetingReady ? `当前版本 ${escapeHtml(cli.version || '-')}` : capabilityBlocked ? escapeHtml(cli.capabilityMessage || '当前企业暂不支持授权机器人的会议权限。') : '授权前无法拉取真实会议,页面不会展示演示或 Mock 会议。'}</span></div>${!cli.ready && cli.initCommand ? `<code>${escapeHtml(cli.initCommand)}</code><button class="btn secondary" data-copy-init="${escapeHtml(cli.initCommand)}">复制初始化命令</button>` : ''}</section>
|
|
|
+ <form class="meeting-sync-form" id="meeting-sync-form"><label><span>开始时间</span><input name="beginDatetime" type="datetime-local" value="${escapeHtml(meetingInputValue(range.beginDatetime))}" required /></label><label><span>结束时间</span><input name="endDatetime" type="datetime-local" value="${escapeHtml(meetingInputValue(range.endDatetime))}" required /></label><label class="meeting-limit"><span>最多拉取</span><input name="limit" type="number" min="1" max="100" value="20" /></label><button class="btn" type="submit" ${!meetingReady || state.knowledge.meetings.loading ? 'disabled' : ''}>${state.knowledge.meetings.loading ? '处理中…' : meetingReady ? '同步真实会议' : capabilityBlocked ? '当前企业不可同步' : '等待授权'}</button></form>
|
|
|
+ <div class="meeting-browser"><aside><header><strong>会议列表</strong><span>${meetings.length} 场已沉淀</span></header><div class="meeting-list">${meetings.map(meeting => `<button class="meeting-list-item ${selected?.id === meeting.id ? 'active' : ''}" data-meeting-id="${escapeHtml(meeting.id)}"><div><strong>${escapeHtml(meeting.title)}</strong><em>${escapeHtml(meeting.statusLabel || '')}</em></div><p>${escapeHtml(meeting.startAt || '时间待确认')} · ${escapeHtml(meeting.meetingTypeLabel || '')}</p><footer><span>${meeting.attendees?.internalCount || 0} 位内部参会人</span><small>${escapeHtml(meetingAnalysisLabel(meeting.analysis?.status))}</small></footer></button>`).join('') || `<div class="library-empty"><strong>暂无会议</strong><span>${capabilityBlocked ? '当前企业受企微官方会议 CLI 范围限制。' : '授权并确认会议权限后再同步。'}</span></div>`}</div></aside><main>${meetingDetail}</main></div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function officeCapabilityBanner(cli, label) {
|
|
|
+ const ready = Boolean(cli?.ready && cli?.available);
|
|
|
+ return `<section class="office-cli-banner ${ready ? 'ready' : 'blocked'}"><i></i><div><strong>${ready ? `企业微信官方${label} CLI 已就绪` : `官方${label} CLI 当前不可用`}</strong><span>${escapeHtml(cli?.message || (ready ? `当前版本 ${cli?.version || '-'}` : '请检查扫码授权和企业权限。'))}</span></div></section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDocumentDetail(document) {
|
|
|
+ if (!document) return '<div class="library-empty large"><strong>尚未沉淀企微文档</strong><span>可创建一篇真实企微文档,或粘贴已有文档链接读取到知识库。</span></div>';
|
|
|
+ const analysis = document.analysis || {};
|
|
|
+ const bullets = (items, empty) => (items || []).length ? `<ul>${items.map(item => `<li>${escapeHtml(item)}</li>`).join('')}</ul>` : `<p>${escapeHtml(empty)}</p>`;
|
|
|
+ const link = /^https?:\/\//i.test(document.url || '') ? `<a href="${escapeHtml(document.url)}" target="_blank" rel="noopener">在企微中打开</a>` : '';
|
|
|
+ return `<article class="doc-detail"><header><div><span>OFFICIAL DOC · LIVE</span><h3>${escapeHtml(document.title)}</h3><p>${escapeHtml(formatAgentTime(document.syncedAt))} · ${escapeHtml(document.docid || document.id)}</p></div><div>${link}${document.readbackStatus === 'pending-permission' ? `<button class="btn secondary" data-refresh-doc="${escapeHtml(document.id)}" ${state.knowledge.documents.loading ? 'disabled' : ''}>重新读取复核</button>` : ''}<button class="btn" data-analyze-doc="${escapeHtml(document.id)}" ${state.knowledge.documents.loading ? 'disabled' : ''}>${analysis.status === 'completed' ? '重新分析' : 'AI 分析文档'}</button></div></header>
|
|
|
+ ${document.readbackStatus === 'pending-permission' ? '<div class="doc-readback-warning"><strong>创建和写入已成功</strong><span>机器人暂缺读取授权,当前原文来自本次已确认写入的 Markdown;授权后可重新读取复核。</span></div>' : ''}
|
|
|
+ <section class="doc-analysis"><span>AI KNOWLEDGE · 需人工复核</span><h4>${escapeHtml(analysis.summary || '尚未生成 AI 摘要')}</h4><div><section><strong>关键要点</strong>${bullets(analysis.keyPoints, '暂无')}</section><section><strong>明确决策</strong>${bullets(analysis.decisions, '原文中暂无明确决策')}</section><section><strong>风险</strong>${bullets(analysis.risks, '暂无')}</section></div></section>
|
|
|
+ <section class="doc-original"><header><strong>企微文档原文</strong><span>Markdown 只读预览</span></header><article class="markdown-view">${renderMarkdown(document.markdown || '文档为空')}</article></section></article>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDocumentKnowledgeView() {
|
|
|
+ const hub = state.knowledge.documents.hub || { summary: {}, data: { cli: {}, documents: [] } };
|
|
|
+ const data = hub.data || {};
|
|
|
+ const docs = data.documents || [];
|
|
|
+ const selected = docs.find(item => item.id === state.knowledge.documents.selectedId) || docs[0] || null;
|
|
|
+ if (selected) state.knowledge.documents.selectedId = selected.id;
|
|
|
+ return `<div class="office-workbench doc-workbench"><header class="knowledge-view-header"><div><span>WECOM OFFICIAL DOCUMENTS</span><h3>企微文档知识库</h3><p>真实创建/读取 · Markdown 沉淀 · 逐篇 AI 分析</p></div><em>${hub.summary?.docReady ? '官方通道已就绪' : '等待文档权限'}</em></header>
|
|
|
+ <div class="office-stat-row"><div><strong>${hub.summary?.documentCount || 0}</strong><span>已沉淀文档</span></div><div><strong>${hub.summary?.analyzedCount || 0}</strong><span>已分析</span></div><div><strong>${hub.summary?.lastSyncedAt ? formatAgentTime(hub.summary.lastSyncedAt) : '-'}</strong><span>最近同步</span></div></div>
|
|
|
+ ${officeCapabilityBanner(data.cli, '文档')}
|
|
|
+ <div class="office-action-grid"><form id="doc-create-form"><header><strong>创建真实企微文档</strong><span>创建普通文档并写入 Markdown</span></header><input name="title" placeholder="文档标题" required /><textarea name="content" rows="5" placeholder="# 文档正文 支持 Markdown"></textarea><button class="btn" type="submit" ${!hub.summary?.docReady || state.knowledge.documents.loading ? 'disabled' : ''}>创建并沉淀</button></form><form id="doc-import-form"><header><strong>读取已有企微文档</strong><span>粘贴 doc.weixin.qq.com 链接或 docid</span></header><input name="locator" placeholder="企微文档链接或 docid" required /><input name="title" placeholder="显示标题(可选)" /><button class="btn secondary" type="submit" ${!hub.summary?.docReady || state.knowledge.documents.loading ? 'disabled' : ''}>读取到知识库</button></form></div>
|
|
|
+ <div class="office-browser"><aside><header><strong>文档目录</strong><span>${docs.length} 篇</span></header>${docs.map(doc => `<button class="office-list-item ${selected?.id === doc.id ? 'active' : ''}" data-doc-id="${escapeHtml(doc.id)}"><strong>${escapeHtml(doc.title)}</strong><span>${escapeHtml(formatAgentTime(doc.syncedAt))}</span><small>${doc.analysis?.status === 'completed' ? 'AI 已分析' : '待分析'}</small></button>`).join('') || '<div class="library-empty">暂无文档</div>'}</aside><main>${renderDocumentDetail(selected)}</main></div></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTodoDetail(todo) {
|
|
|
+ if (!todo) return '<div class="library-empty large"><strong>选择一条待办</strong><span>这里只展示当前机器人创建并同步的真实企微待办。</span></div>';
|
|
|
+ return `<article class="todo-detail"><span>OFFICIAL TODO · LIVE</span><h3>${escapeHtml(todo.content)}</h3><div class="todo-status ${todo.status === 0 ? 'done' : 'active'}">${escapeHtml(todo.statusLabel)}</div><dl><div><dt>截止时间</dt><dd>${escapeHtml(todo.endTime || '未设置')}</dd></div><div><dt>参与人</dt><dd>${escapeHtml((todo.followers || []).map(item => item.name || '企微成员').join('、') || '未返回')}</dd></div><div><dt>待办 ID</dt><dd>${escapeHtml(todo.id)}</dd></div></dl>${todo.status !== 0 ? `<button class="btn" data-complete-todo="${escapeHtml(todo.id)}" ${state.knowledge.todos.loading ? 'disabled' : ''}>标记为已完成</button>` : ''}<p>官方边界:只能操作由当前机器人创建的待办。</p></article>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTodoKnowledgeView() {
|
|
|
+ const hub = state.knowledge.todos.hub || { summary: {}, data: { cli: {}, todos: [] } };
|
|
|
+ const data = hub.data || {};
|
|
|
+ const todos = data.todos || [];
|
|
|
+ const selected = todos.find(item => item.id === state.knowledge.todos.selectedId) || todos[0] || null;
|
|
|
+ if (selected) state.knowledge.todos.selectedId = selected.id;
|
|
|
+ const users = state.knowledge.todos.userResults || [];
|
|
|
+ return `<div class="office-workbench todo-workbench"><header class="knowledge-view-header"><div><span>WECOM OFFICIAL TODO</span><h3>企微官方待办中心</h3><p>成员搜索 · 真实待办同步 · 创建与完成</p></div><em>${hub.summary?.todoReady ? '官方通道已就绪' : '等待待办权限'}</em></header>
|
|
|
+ <div class="office-stat-row"><div><strong>${hub.summary?.todoCount || 0}</strong><span>待办总数</span></div><div><strong>${hub.summary?.activeCount || 0}</strong><span>进行中</span></div><div><strong>${hub.summary?.completedCount || 0}</strong><span>已完成</span></div></div>
|
|
|
+ ${officeCapabilityBanner(data.cli, '待办')}
|
|
|
+ <div class="todo-toolbar"><form id="todo-user-search-form"><input name="keyword" placeholder="按姓名或别名搜索 userid" required /><button class="btn secondary" type="submit">搜索成员</button></form><form id="todo-sync-form"><input name="followerId" value="${escapeHtml(data.followerId || '')}" placeholder="参与人 userid" required /><button class="btn secondary" type="submit" ${!hub.summary?.todoReady || state.knowledge.todos.loading ? 'disabled' : ''}>同步真实待办</button></form></div>
|
|
|
+ ${users.length ? `<div class="todo-user-results">${users.map(user => `<button data-use-todo-user="${escapeHtml(user.id)}"><strong>${escapeHtml(user.name || user.id)}</strong><span>${escapeHtml(user.alias || user.id)}</span></button>`).join('')}</div>` : ''}
|
|
|
+ <form class="todo-create-form" id="todo-create-form"><input name="content" placeholder="待办内容,例如:整理客户演示材料" required /><input name="followerIds" value="${escapeHtml(data.followerId || '')}" placeholder="参与人 userid,多个用逗号分隔" required /><input name="endTime" type="datetime-local" /><select name="remindType"><option value="1">到期提醒</option><option value="3">提前 15 分钟</option><option value="5">提前 1 小时</option><option value="7">提前 1 天</option><option value="0">不提醒</option></select><button class="btn" type="submit" ${!hub.summary?.todoReady || state.knowledge.todos.loading ? 'disabled' : ''}>创建真实待办</button></form>
|
|
|
+ <div class="office-browser"><aside><header><strong>待办列表</strong><span>${todos.length} 条</span></header>${todos.map(todo => `<button class="office-list-item ${selected?.id === todo.id ? 'active' : ''}" data-todo-id="${escapeHtml(todo.id)}"><strong>${escapeHtml(todo.content)}</strong><span>${escapeHtml(todo.endTime || '无截止时间')}</span><small>${escapeHtml(todo.statusLabel)}</small></button>`).join('') || '<div class="library-empty">当前没有已同步待办</div>'}</aside><main>${renderTodoDetail(selected)}</main></div></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function unifiedTaskStatusLabel(status) {
|
|
|
+ return ({ open: '待处理', in_progress: '处理中', blocked: '已阻塞', done: '已完成', dismissed: '已忽略', suggested: 'AI 候选' })[status] || status || '待处理';
|
|
|
+ }
|
|
|
+
|
|
|
+ function unifiedTaskDueLabel(task) {
|
|
|
+ if (!task.dueAt) return '未设置截止时间';
|
|
|
+ return `${task.overdue ? '已逾期 · ' : ''}${formatAgentTime(task.dueAt)}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function diagnosisOutcomeLabel(value) {
|
|
|
+ return ({ effective: '有效', partially_effective: '部分有效', ineffective: '无效', unknown: '暂无法判断' })[value] || '待评价';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDiagnosisFeedback(task) {
|
|
|
+ if (!task.actions?.diagnosisFeedback) return '';
|
|
|
+ const current = task.diagnosisOutcome || {};
|
|
|
+ const outcomes = [
|
|
|
+ ['effective', '有效'],
|
|
|
+ ['partially_effective', '部分有效'],
|
|
|
+ ['ineffective', '无效'],
|
|
|
+ ['unknown', '暂无法判断'],
|
|
|
+ ];
|
|
|
+ return `<form class="diagnosis-feedback-form" data-diagnosis-feedback-form="${escapeHtml(task.id)}">
|
|
|
+ <div class="diagnosis-feedback-head"><div><strong>行动效果复盘</strong><span>仅内部记录,不会发送客户消息</span></div>${current.value ? `<em class="diagnosis-outcome ${escapeHtml(current.value)}">当前:${escapeHtml(diagnosisOutcomeLabel(current.value))}</em>` : '<em>尚未评价</em>'}</div>
|
|
|
+ <div class="diagnosis-outcome-options" role="radiogroup" aria-label="行动效果">${outcomes.map(([value, label]) => `<label class="${current.value === value ? 'selected' : ''}"><input type="radio" name="outcome" value="${value}" ${current.value === value ? 'checked' : ''} required /><span>${label}</span></label>`).join('')}</div>
|
|
|
+ <label class="diagnosis-feedback-note"><span>复盘依据(可选)</span><input name="note" maxlength="1000" value="${escapeHtml(current.note || '')}" placeholder="例如:跟进完成率提升,但客户回复率未明显变化" /></label>
|
|
|
+ <button class="btn btn-sm" type="submit" ${state.knowledge.tasks.loading ? 'disabled' : ''}>${current.value ? '更新反馈' : '保存反馈'}</button>
|
|
|
+ </form>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function unifiedTaskActions(task) {
|
|
|
+ if (task.sourceKind === 'candidate') {
|
|
|
+ return task.promotedTo
|
|
|
+ ? '<span class="task-promoted">已转为正式任务</span>'
|
|
|
+ : `<button class="btn btn-sm" data-task-action="promote" data-task-id="${escapeHtml(task.id)}">确认并转为任务</button>`;
|
|
|
+ }
|
|
|
+ const active = ['open', 'in_progress', 'blocked'].includes(task.status);
|
|
|
+ const buttons = [];
|
|
|
+ if (task.source === 'customer') {
|
|
|
+ buttons.push(`<button class="btn btn-sm btn-secondary" data-task-open-conversation="${escapeHtml(task.conversationId)}">转到智能会话</button>`);
|
|
|
+ if (active && task.actions?.syncOfficial) buttons.push(`<button class="btn btn-sm btn-secondary" data-task-action="sync-official" data-task-id="${escapeHtml(task.id)}">同步企微待办</button>`);
|
|
|
+ }
|
|
|
+ if (active && task.actions?.dismiss) buttons.push(`<button class="btn btn-sm btn-secondary" data-task-action="dismiss" data-task-id="${escapeHtml(task.id)}">忽略</button>`);
|
|
|
+ if (active && task.actions?.complete) buttons.push(`<button class="btn btn-sm" data-task-action="complete" data-task-id="${escapeHtml(task.id)}">完成</button>`);
|
|
|
+ return buttons.join('') || `<span>${escapeHtml(unifiedTaskStatusLabel(task.status))}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderUnifiedTaskCenter() {
|
|
|
+ const hub = state.knowledge.tasks.hub || { summary: {}, data: { tasks: [], policy: {} } };
|
|
|
+ const summary = hub.summary || {};
|
|
|
+ const data = hub.data || {};
|
|
|
+ const filters = state.knowledge.tasks.filters;
|
|
|
+ const allTasks = data.tasks || [];
|
|
|
+ const q = String(filters.q || '').trim().toLowerCase();
|
|
|
+ const tasks = allTasks.filter(task => {
|
|
|
+ if (q && ![task.title, task.owner, task.contactName, task.sourceTitle, task.evidence].some(value => String(value || '').toLowerCase().includes(q))) return false;
|
|
|
+ if (filters.source && task.source !== filters.source) return false;
|
|
|
+ if (filters.status === 'active' && !['open', 'in_progress', 'blocked'].includes(task.status)) return false;
|
|
|
+ if (filters.status === 'overdue' && !task.overdue) return false;
|
|
|
+ if (filters.status === 'completed' && task.status !== 'done') return false;
|
|
|
+ if (filters.status === 'suggested' && task.status !== 'suggested') return false;
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+ const sourceOptions = Object.keys(summary.bySource || {}).map(source => {
|
|
|
+ const sample = allTasks.find(task => task.source === source);
|
|
|
+ return `<option value="${escapeHtml(source)}" ${filters.source === source ? 'selected' : ''}>${escapeHtml(sample?.sourceLabel || source)}(${summary.bySource[source]})</option>`;
|
|
|
+ }).join('');
|
|
|
+ const diagnosisFeedback = summary.diagnosisFeedback || {};
|
|
|
+ const percentLabel = value => value === null || value === undefined ? '-' : `${Math.round(Number(value) * 100)}%`;
|
|
|
+ return `<div class="unified-task-workbench">
|
|
|
+ <header class="knowledge-view-header"><div><span>UNIFIED ACTION CENTER</span><h3>统一任务中心</h3><p>真实企微会话 · 官方待办 · 目标计划 · 文档/会议行动项</p></div><em>内部任务中枢</em></header>
|
|
|
+ <div class="task-stat-row"><div><strong>${summary.active || 0}</strong><span>待处理</span></div><div><strong>${summary.overdue || 0}</strong><span>已逾期</span></div><div><strong>${summary.completed || 0}</strong><span>已完成</span></div><div><strong>${summary.candidates || 0}</strong><span>待确认候选</span></div></div>
|
|
|
+ <section class="diagnosis-feedback-summary" aria-label="经营诊断行动闭环"><header><strong>经营诊断行动闭环</strong><span>从建议采纳到结果验证</span></header><div><span><strong>${diagnosisFeedback.candidates || 0}</strong>候选</span><span><strong>${diagnosisFeedback.promoted || 0}</strong>已采纳 · ${percentLabel(diagnosisFeedback.adoptionRate)}</span><span><strong>${diagnosisFeedback.completed || 0}</strong>已完成</span><span><strong>${diagnosisFeedback.effective || 0}</strong>验证有效</span><span><strong>${percentLabel(diagnosisFeedback.effectivenessRate)}</strong>有效率</span></div></section>
|
|
|
+ <section class="task-policy-banner"><div><strong>客户任务不会被原样发给客户</strong><span>${escapeHtml(data.policy?.externalCustomerSafety || '')}</span></div><div><strong>AI 候选不会自动成为正式任务</strong><span>${escapeHtml(data.policy?.candidateSafety || '')}</span></div></section>
|
|
|
+ <form class="task-create-form" id="unified-task-create-form"><div><strong>新建内部任务</strong><span>不依赖 CLI,先在本地形成可管理的执行项</span></div><input name="title" placeholder="任务标题" required /><input name="owner" placeholder="负责人(可选)" /><input name="dueAt" type="datetime-local" /><select name="priority"><option value="medium">普通</option><option value="high">高</option><option value="urgent">紧急</option><option value="low">低</option></select><button class="btn" type="submit" ${state.knowledge.tasks.loading ? 'disabled' : ''}>创建</button></form>
|
|
|
+ <form class="task-filterbar" id="unified-task-filter-form"><input name="q" value="${escapeHtml(filters.q)}" placeholder="搜索任务、客户、负责人或证据" /><select name="source"><option value="">全部来源</option>${sourceOptions}</select><select name="status"><option value="active" ${filters.status === 'active' ? 'selected' : ''}>待处理</option><option value="overdue" ${filters.status === 'overdue' ? 'selected' : ''}>已逾期</option><option value="completed" ${filters.status === 'completed' ? 'selected' : ''}>已完成</option><option value="suggested" ${filters.status === 'suggested' ? 'selected' : ''}>AI 候选</option><option value="all" ${filters.status === 'all' ? 'selected' : ''}>全部</option></select><button class="btn secondary" type="submit">筛选</button><span>当前 ${tasks.length} 条</span></form>
|
|
|
+ <div class="unified-task-list">${tasks.map(task => `<article class="unified-task-card ${task.overdue ? 'overdue' : ''} ${task.sourceKind === 'candidate' ? 'candidate' : ''}"><i class="task-priority ${escapeHtml(task.priority)}"></i><div class="unified-task-main"><header><div><span class="task-source">${escapeHtml(task.sourceLabel)}</span>${task.sourceRef?.type === 'diagnosis-candidate' ? '<span class="task-source diagnosis">诊断行动</span>' : ''}<span class="task-status ${escapeHtml(task.status)}">${escapeHtml(unifiedTaskStatusLabel(task.status))}</span>${task.audience === 'external' ? '<span class="task-audience">客户跟进 · 内部使用</span>' : ''}</div><small>${escapeHtml(unifiedTaskDueLabel(task))}</small></header><h4>${escapeHtml(task.title)}</h4><p>${escapeHtml(task.evidence || '暂无补充证据')}</p><footer><span>${task.owner ? `负责人:${escapeHtml(task.owner)}` : '负责人待分配'}</span><span>${escapeHtml(task.sourceTitle || '')}</span>${task.confidence !== null ? `<span>可信度 ${Math.round(Number(task.confidence || 0) * 100)}%</span>` : ''}</footer>${renderDiagnosisFeedback(task)}</div><div class="unified-task-actions">${unifiedTaskActions(task)}</div></article>`).join('') || '<div class="library-empty large"><strong>当前筛选下没有任务</strong><span>可以新建本地内部任务,或切换为“全部 / AI 候选”查看其他来源。</span></div>'}</div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderKnowledgeFileView(file) {
|
|
|
+ if (!file) return '<div class="library-empty large"><strong>选择左侧文件</strong><span>Markdown、JSON、CSV 和代码文件会在这里可视化预览。</span></div>';
|
|
|
+ const header = `<header class="knowledge-view-header"><div><span>${escapeHtml(file.library)}</span><h3>${escapeHtml(file.name)}</h3><p>${escapeHtml(file.relativePath)} · ${formatFileSize(file.size)} · ${formatAgentTime(file.modifiedAt)}</p></div><em>${escapeHtml(file.kind)}</em></header>`;
|
|
|
+ if (['markdown', 'skill-document'].includes(file.kind)) return `${header}<article class="markdown-view">${renderMarkdown(file.content)}</article>`;
|
|
|
+ if (file.kind === 'json') {
|
|
|
+ const summary = Array.isArray(file.structured)
|
|
|
+ ? `${file.structured.length} 条数组记录`
|
|
|
+ : `${Object.keys(file.structured || {}).length} 个顶层字段`;
|
|
|
+ return `${header}<div class="json-summary">${escapeHtml(summary)}</div><pre class="code-view"><code>${escapeHtml(JSON.stringify(file.structured ?? file.content, null, 2))}</code></pre>`;
|
|
|
+ }
|
|
|
+ return `${header}<pre class="code-view"><code>${escapeHtml(file.content)}</code></pre>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderKnowledgeWorkspace(page) {
|
|
|
+ const tree = state.knowledge.tree || { roots: [], summary: {} };
|
|
|
+ const selectedNode = flattenKnowledgeNodes(tree.roots).find(item => item.id === state.knowledge.selectedNodeId);
|
|
|
+ const viewer = selectedNode?.kind === 'meeting-dashboard'
|
|
|
+ ? renderMeetingKnowledgeView()
|
|
|
+ : selectedNode?.kind === 'doc-dashboard'
|
|
|
+ ? renderDocumentKnowledgeView()
|
|
|
+ : selectedNode?.kind === 'todo-dashboard'
|
|
|
+ ? renderTodoKnowledgeView()
|
|
|
+ : selectedNode?.kind === 'task-dashboard'
|
|
|
+ ? renderUnifiedTaskCenter()
|
|
|
+ : renderKnowledgeFileView(state.knowledge.file);
|
|
|
+ page.innerHTML = `
|
|
|
+ <section class="workspace-hero knowledge-hero"><div><span>LOCAL KNOWLEDGE & FILE LIBRARY</span><h2>知识库</h2><p>用文件夹方式统一管理规则、技能说明和运行输出,点击文件即可可视化查看。</p></div><div class="workspace-stat-row"><div><strong>${tree.summary.libraryCount || 0}</strong><span>目录来源</span></div><div><strong>${tree.summary.fileCount || 0}</strong><span>可预览文件</span></div><div><strong>${tree.summary.skillCount || 0}</strong><span>通用 Skills</span></div></div></section>
|
|
|
+ <section class="knowledge-layout"><aside class="knowledge-tree-panel"><header><strong>知识库目录</strong><span>本地文件 · 只读预览</span></header><div class="knowledge-tree">${(tree.roots || []).map(root => renderKnowledgeTreeNode(root)).join('')}</div></aside><main class="knowledge-viewer">${viewer}</main></section>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadMeetingKnowledgeHub(page) {
|
|
|
+ const result = await api('GET', '/api/knowledge/meetings');
|
|
|
+ state.knowledge.meetings.hub = result;
|
|
|
+ const meetings = result.data?.meetings || [];
|
|
|
+ if (!meetings.some(item => item.id === state.knowledge.meetings.selectedId)) {
|
|
|
+ state.knowledge.meetings.selectedId = [...meetings].sort((a, b) => String(b.startAt).localeCompare(String(a.startAt)))[0]?.id || null;
|
|
|
+ }
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadDocumentKnowledgeHub(page) {
|
|
|
+ const result = await api('GET', '/api/knowledge/docs');
|
|
|
+ state.knowledge.documents.hub = result;
|
|
|
+ const documents = result.data?.documents || [];
|
|
|
+ if (!documents.some(item => item.id === state.knowledge.documents.selectedId)) state.knowledge.documents.selectedId = documents[0]?.id || null;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadTodoKnowledgeHub(page) {
|
|
|
+ const result = await api('GET', '/api/knowledge/todos');
|
|
|
+ state.knowledge.todos.hub = result;
|
|
|
+ const todos = result.data?.todos || [];
|
|
|
+ if (!todos.some(item => item.id === state.knowledge.todos.selectedId)) state.knowledge.todos.selectedId = todos[0]?.id || null;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadUnifiedTaskHub(page) {
|
|
|
+ const result = await api('GET', '/api/knowledge/tasks');
|
|
|
+ state.knowledge.tasks.hub = result;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openKnowledgeNode(page, nodeId) {
|
|
|
+ const node = flattenKnowledgeNodes(state.knowledge.tree?.roots || []).find(item => item.id === nodeId);
|
|
|
+ if (!node || node.type !== 'file') return;
|
|
|
+ state.knowledge.selectedNodeId = nodeId;
|
|
|
+ state.knowledge.file = null;
|
|
|
+ if (node.kind === 'meeting-dashboard') await loadMeetingKnowledgeHub(page);
|
|
|
+ else if (node.kind === 'doc-dashboard') await loadDocumentKnowledgeHub(page);
|
|
|
+ else if (node.kind === 'todo-dashboard') await loadTodoKnowledgeHub(page);
|
|
|
+ else if (node.kind === 'task-dashboard') await loadUnifiedTaskHub(page);
|
|
|
+ else {
|
|
|
+ const result = await api('GET', `/api/knowledge/file?id=${encodeURIComponent(nodeId)}`);
|
|
|
+ state.knowledge.file = result.data;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderKnowledgePage() {
|
|
|
+ els.pageTitle.textContent = '知识库';
|
|
|
+ void refreshAgentAccountHeader();
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page knowledge-page';
|
|
|
+ page.innerHTML = '<div class="agent-loading">正在扫描知识库目录…</div>';
|
|
|
+ els.content.appendChild(page);
|
|
|
+ page.addEventListener('click', async event => {
|
|
|
+ const fileButton = event.target.closest('[data-knowledge-node]');
|
|
|
+ if (fileButton) {
|
|
|
+ try { await openKnowledgeNode(page, fileButton.dataset.knowledgeNode); }
|
|
|
+ catch (error) { toast(error.message || '文件读取失败', 'error'); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const copyInitButton = event.target.closest('[data-copy-init]');
|
|
|
+ if (copyInitButton) {
|
|
|
+ try {
|
|
|
+ await navigator.clipboard.writeText(copyInitButton.dataset.copyInit);
|
|
|
+ toast('初始化命令已复制');
|
|
|
+ } catch { toast('复制失败,请手动选择命令', 'error'); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const analyzeMeetingButton = event.target.closest('[data-analyze-meeting]');
|
|
|
+ if (analyzeMeetingButton) {
|
|
|
+ state.knowledge.meetings.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/knowledge/meetings/${encodeURIComponent(analyzeMeetingButton.dataset.analyzeMeeting)}/analyze`, {});
|
|
|
+ await loadMeetingKnowledgeHub(page);
|
|
|
+ toast(result.assistantMessage || (result.status === 'ok' ? '会议分析完成' : '会议分析未完成'), result.status === 'ok' ? 'success' : 'error');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '会议分析失败', 'error'); }
|
|
|
+ finally {
|
|
|
+ state.knowledge.meetings.loading = false;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const meetingButton = event.target.closest('[data-meeting-id]');
|
|
|
+ if (meetingButton) {
|
|
|
+ state.knowledge.meetings.selectedId = meetingButton.dataset.meetingId;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const analyzeDocButton = event.target.closest('[data-analyze-doc]');
|
|
|
+ if (analyzeDocButton) {
|
|
|
+ state.knowledge.documents.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/knowledge/docs/${encodeURIComponent(analyzeDocButton.dataset.analyzeDoc)}/analyze`, {});
|
|
|
+ await loadDocumentKnowledgeHub(page);
|
|
|
+ toast(result.assistantMessage || '文档分析完成', result.status === 'ok' ? 'success' : 'error');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '文档分析失败', 'error'); }
|
|
|
+ finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const refreshDocButton = event.target.closest('[data-refresh-doc]');
|
|
|
+ if (refreshDocButton) {
|
|
|
+ state.knowledge.documents.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/knowledge/docs/${encodeURIComponent(refreshDocButton.dataset.refreshDoc)}/refresh`, {});
|
|
|
+ await loadDocumentKnowledgeHub(page);
|
|
|
+ toast(result.assistantMessage || '文档重新读取完成', result.status === 'ok' ? 'success' : 'error');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '文档重新读取失败', 'error'); }
|
|
|
+ finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const docButton = event.target.closest('[data-doc-id]');
|
|
|
+ if (docButton) {
|
|
|
+ state.knowledge.documents.selectedId = docButton.dataset.docId;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const useTodoUserButton = event.target.closest('[data-use-todo-user]');
|
|
|
+ if (useTodoUserButton) {
|
|
|
+ const id = useTodoUserButton.dataset.useTodoUser;
|
|
|
+ page.querySelectorAll('input[name="followerId"], input[name="followerIds"]').forEach(input => { input.value = id; });
|
|
|
+ toast(`已填入成员 userid:${id}`);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const completeTodoButton = event.target.closest('[data-complete-todo]');
|
|
|
+ if (completeTodoButton) {
|
|
|
+ state.knowledge.todos.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/knowledge/todos/${encodeURIComponent(completeTodoButton.dataset.completeTodo)}/complete`, {});
|
|
|
+ await loadTodoKnowledgeHub(page);
|
|
|
+ toast(result.assistantMessage || '待办已完成', result.status === 'ok' ? 'success' : 'error');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '待办更新失败', 'error'); }
|
|
|
+ finally { state.knowledge.todos.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const todoButton = event.target.closest('[data-todo-id]');
|
|
|
+ if (todoButton) {
|
|
|
+ state.knowledge.todos.selectedId = todoButton.dataset.todoId;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const openTaskConversationButton = event.target.closest('[data-task-open-conversation]');
|
|
|
+ if (openTaskConversationButton) {
|
|
|
+ state.agent.selectedId = openTaskConversationButton.dataset.taskOpenConversation;
|
|
|
+ location.hash = '#agent';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const taskActionButton = event.target.closest('[data-task-action]');
|
|
|
+ if (taskActionButton) {
|
|
|
+ const task = (state.knowledge.tasks.hub?.data?.tasks || []).find(item => item.id === taskActionButton.dataset.taskId);
|
|
|
+ if (!task) { toast('任务记录已变化,请刷新后重试', 'error'); return; }
|
|
|
+ const action = taskActionButton.dataset.taskAction;
|
|
|
+ state.knowledge.tasks.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ let result;
|
|
|
+ if (action === 'promote') {
|
|
|
+ if (!await confirmModal(`任务:${task.title}\n依据:${task.evidence || '待补充'}\n\n创建后仍需人工分配负责人并推进。`, { title: '转为正式内部任务', confirmText: '创建任务' })) return;
|
|
|
+ result = await api('POST', '/api/knowledge/tasks/create', { title: task.title, owner: task.owner, dueAt: task.dueAt, priority: task.priority, evidence: task.evidence, sourceTitle: task.sourceTitle, source: task.source, sourceKey: task.id });
|
|
|
+ } else if (action === 'sync-official') {
|
|
|
+ const syncInput = await showOfficialTodoSyncModal(task);
|
|
|
+ if (!syncInput) return;
|
|
|
+ result = await api('POST', `/api/agent/tasks/${encodeURIComponent(task.id)}/sync-official`, syncInput);
|
|
|
+ } else {
|
|
|
+ const nextStatus = action === 'complete' ? 'done' : 'dismissed';
|
|
|
+ const officialWarning = task.source === 'official' || task.officialTodoId ? '\n\n该操作会同步更新真实企微待办。' : '';
|
|
|
+ if (!await confirmModal(`确认将“${task.title}”标记为${action === 'complete' ? '已完成' : '已忽略'}?${officialWarning}`, {
|
|
|
+ title: action === 'complete' ? '完成任务' : '忽略任务',
|
|
|
+ confirmText: action === 'complete' ? '标记完成' : '确认忽略',
|
|
|
+ warning: officialWarning ? '该操作会同步更新真实企微待办。' : '',
|
|
|
+ danger: action !== 'complete',
|
|
|
+ })) return;
|
|
|
+ result = await api('POST', '/api/knowledge/tasks/update', { source: task.source, taskId: task.id, goalId: task.goalId, status: nextStatus });
|
|
|
+ }
|
|
|
+ if (result && result.status !== 'ok') throw result;
|
|
|
+ await loadUnifiedTaskHub(page);
|
|
|
+ toast(result?.assistantMessage || '任务已更新');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '任务操作失败', 'error'); }
|
|
|
+ finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ page.addEventListener('submit', async event => {
|
|
|
+ const diagnosisFeedbackForm = event.target.closest('[data-diagnosis-feedback-form]');
|
|
|
+ if (diagnosisFeedbackForm) {
|
|
|
+ event.preventDefault();
|
|
|
+ const form = new FormData(diagnosisFeedbackForm);
|
|
|
+ state.knowledge.tasks.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', '/api/knowledge/tasks/diagnosis-feedback', {
|
|
|
+ taskId: diagnosisFeedbackForm.dataset.diagnosisFeedbackForm,
|
|
|
+ outcome: String(form.get('outcome') || ''),
|
|
|
+ note: String(form.get('note') || '').trim(),
|
|
|
+ actor: 'dashboard-user',
|
|
|
+ });
|
|
|
+ if (result.status !== 'ok') throw result;
|
|
|
+ await loadUnifiedTaskHub(page);
|
|
|
+ toast(result.assistantMessage || '行动效果反馈已保存');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '行动效果反馈保存失败', 'error'); }
|
|
|
+ finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.target.id === 'unified-task-filter-form') {
|
|
|
+ event.preventDefault();
|
|
|
+ const form = new FormData(event.target);
|
|
|
+ state.knowledge.tasks.filters.q = String(form.get('q') || '').trim();
|
|
|
+ state.knowledge.tasks.filters.source = String(form.get('source') || '').trim();
|
|
|
+ state.knowledge.tasks.filters.status = String(form.get('status') || 'active');
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.target.id === 'unified-task-create-form') {
|
|
|
+ event.preventDefault();
|
|
|
+ const form = new FormData(event.target);
|
|
|
+ state.knowledge.tasks.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', '/api/knowledge/tasks/create', {
|
|
|
+ title: String(form.get('title') || '').trim(),
|
|
|
+ owner: String(form.get('owner') || '').trim(),
|
|
|
+ dueAt: String(form.get('dueAt') || '').trim(),
|
|
|
+ priority: String(form.get('priority') || 'medium'),
|
|
|
+ sourceTitle: '任务中心人工创建',
|
|
|
+ });
|
|
|
+ if (result.status !== 'ok') throw result;
|
|
|
+ await loadUnifiedTaskHub(page);
|
|
|
+ toast(result.assistantMessage || '内部任务已创建');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '内部任务创建失败', 'error'); }
|
|
|
+ finally { state.knowledge.tasks.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.target.id === 'doc-create-form' || event.target.id === 'doc-import-form') {
|
|
|
+ event.preventDefault();
|
|
|
+ const formId = event.target.id;
|
|
|
+ const form = new FormData(event.target);
|
|
|
+ const body = formId === 'doc-create-form'
|
|
|
+ ? { title: String(form.get('title') || '').trim(), content: String(form.get('content') || '') }
|
|
|
+ : { ...(String(form.get('locator') || '').trim().startsWith('http') ? { url: String(form.get('locator') || '').trim() } : { docid: String(form.get('locator') || '').trim() }), title: String(form.get('title') || '').trim() };
|
|
|
+ state.knowledge.documents.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', formId === 'doc-create-form' ? '/api/knowledge/docs/create' : '/api/knowledge/docs/import', body);
|
|
|
+ if (result.status !== 'ok') throw result;
|
|
|
+ state.knowledge.documents.selectedId = result.data?.document?.id || null;
|
|
|
+ const treeResult = await api('GET', '/api/knowledge/tree');
|
|
|
+ state.knowledge.tree = treeResult.data;
|
|
|
+ await loadDocumentKnowledgeHub(page);
|
|
|
+ toast(result.assistantMessage || '企微文档处理完成');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '企微文档处理失败', 'error'); }
|
|
|
+ finally { state.knowledge.documents.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.target.id === 'todo-user-search-form') {
|
|
|
+ event.preventDefault();
|
|
|
+ const form = new FormData(event.target);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', '/api/knowledge/todos/search-users', { keyword: String(form.get('keyword') || '').trim() });
|
|
|
+ state.knowledge.todos.userResults = result.data?.users || [];
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ toast(result.assistantMessage || '成员搜索完成', result.status === 'ok' ? 'success' : 'error');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '成员搜索失败', 'error'); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.target.id === 'todo-sync-form' || event.target.id === 'todo-create-form') {
|
|
|
+ event.preventDefault();
|
|
|
+ const formId = event.target.id;
|
|
|
+ const form = new FormData(event.target);
|
|
|
+ const body = formId === 'todo-sync-form'
|
|
|
+ ? { followerId: String(form.get('followerId') || '').trim(), limit: 20 }
|
|
|
+ : { content: String(form.get('content') || '').trim(), followerIds: String(form.get('followerIds') || '').trim(), endTime: String(form.get('endTime') || '').trim(), remindType: Number(form.get('remindType') || 1) };
|
|
|
+ state.knowledge.todos.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', formId === 'todo-sync-form' ? '/api/knowledge/todos/sync' : '/api/knowledge/todos/create', body);
|
|
|
+ if (result.status !== 'ok') throw result;
|
|
|
+ state.knowledge.todos.selectedId = result.data?.todo?.id || state.knowledge.todos.selectedId;
|
|
|
+ const treeResult = await api('GET', '/api/knowledge/tree');
|
|
|
+ state.knowledge.tree = treeResult.data;
|
|
|
+ await loadTodoKnowledgeHub(page);
|
|
|
+ toast(result.assistantMessage || '企微待办处理完成');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '企微待办处理失败', 'error'); }
|
|
|
+ finally { state.knowledge.todos.loading = false; renderKnowledgeWorkspace(page); }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.target.id === 'meeting-sync-form') {
|
|
|
+ event.preventDefault();
|
|
|
+ const form = new FormData(event.target);
|
|
|
+ state.knowledge.meetings.loading = true;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', '/api/knowledge/meetings/sync', {
|
|
|
+ beginDatetime: String(form.get('beginDatetime') || ''),
|
|
|
+ endDatetime: String(form.get('endDatetime') || ''),
|
|
|
+ limit: Number(form.get('limit') || 20),
|
|
|
+ });
|
|
|
+ if (result.status !== 'ok') throw result;
|
|
|
+ const treeResult = await api('GET', '/api/knowledge/tree');
|
|
|
+ state.knowledge.tree = treeResult.data;
|
|
|
+ await loadMeetingKnowledgeHub(page);
|
|
|
+ toast(result.assistantMessage || '真实会议同步完成');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '会议同步失败', 'error'); }
|
|
|
+ finally {
|
|
|
+ state.knowledge.meetings.loading = false;
|
|
|
+ renderKnowledgeWorkspace(page);
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ page.addEventListener('toggle', event => {
|
|
|
+ const folder = event.target.closest?.('[data-knowledge-folder]');
|
|
|
+ if (!folder) return;
|
|
|
+ if (folder.open) state.knowledge.expandedFolderIds.add(folder.dataset.knowledgeFolder);
|
|
|
+ else state.knowledge.expandedFolderIds.delete(folder.dataset.knowledgeFolder);
|
|
|
+ }, true);
|
|
|
+ try {
|
|
|
+ const result = await api('GET', '/api/knowledge/tree');
|
|
|
+ state.knowledge.tree = result.data;
|
|
|
+ if (!state.knowledge.foldersInitialized) {
|
|
|
+ for (const root of result.data.roots) {
|
|
|
+ if (['agent-knowledge', 'meeting-knowledge', 'doc-knowledge', 'todo-knowledge', 'task-knowledge'].includes(root.libraryId)) {
|
|
|
+ state.knowledge.expandedFolderIds.add(root.id);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ state.knowledge.foldersInitialized = true;
|
|
|
+ }
|
|
|
+ const nodes = flattenKnowledgeNodes(result.data.roots);
|
|
|
+ const defaultNode = nodes.find(item => item.kind === 'task-dashboard') || nodes.find(item => item.kind === 'doc-dashboard') || nodes.find(item => item.kind === 'todo-dashboard') || nodes.find(item => item.kind === 'meeting-dashboard') || nodes.find(item => item.type === 'file');
|
|
|
+ if (defaultNode) await openKnowledgeNode(page, state.knowledge.selectedNodeId || defaultNode.id);
|
|
|
+ else renderKnowledgeWorkspace(page);
|
|
|
+ } catch (error) {
|
|
|
+ page.innerHTML = `<div class="library-error">${escapeHtml(error.message || '知识库扫描失败')}</div>`;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Intelligent Conversations ====================
|
|
|
+ function formatAgentTime(value) {
|
|
|
+ const date = new Date(value || 0);
|
|
|
+ if (Number.isNaN(date.getTime())) return '-';
|
|
|
+ return date.toLocaleString('zh-CN', { month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit' });
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentTimestamp(value) {
|
|
|
+ const timestamp = Date.parse(value || '');
|
|
|
+ return Number.isFinite(timestamp) ? timestamp : Number.NEGATIVE_INFINITY;
|
|
|
+ }
|
|
|
+
|
|
|
+ function sortAgentItemsByLatest(items = [], value = item => item?.lastMessageAt) {
|
|
|
+ return items
|
|
|
+ .map((item, index) => ({ item, index, timestamp: agentTimestamp(value(item)) }))
|
|
|
+ .sort((a, b) => b.timestamp - a.timestamp || a.index - b.index)
|
|
|
+ .map(entry => entry.item);
|
|
|
+ }
|
|
|
+
|
|
|
+ function latestAgentMessageSource(sources = []) {
|
|
|
+ return sources.reduce((latest, source, index) => {
|
|
|
+ const timestamp = agentTimestamp(source?.time);
|
|
|
+ if (!latest || timestamp > latest.timestamp) return { ...source, timestamp, index };
|
|
|
+ return latest;
|
|
|
+ }, null);
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentTaskStatusLabel(status) {
|
|
|
+ return ({ open: '待处理', in_progress: '处理中', done: '已完成', dismissed: '已忽略' })[status] || status || '待处理';
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentOfficialTodoLabel(task = {}) {
|
|
|
+ if (!task.officialTodoId) return '仅本地';
|
|
|
+ return task.officialSyncStatus === 'done'
|
|
|
+ ? '企微已完成'
|
|
|
+ : task.officialSyncStatus === 'completion_pending'
|
|
|
+ ? '企微待确认完成'
|
|
|
+ : task.officialSyncStatus === 'error'
|
|
|
+ ? '企微同步异常'
|
|
|
+ : '已同步企微';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentCustomerTask(task = {}) {
|
|
|
+ const evidenceCount = Array.isArray(task.evidenceItems) ? task.evidenceItems.length : (task.evidence ? 1 : 0);
|
|
|
+ const active = ['open', 'in_progress'].includes(task.status);
|
|
|
+ const officialClass = task.officialSyncStatus === 'error' ? 'error' : task.officialTodoId ? 'synced' : 'local';
|
|
|
+ const needsOfficialCompletion = task.status === 'done' && task.officialTodoId && task.officialSyncStatus !== 'done';
|
|
|
+ const actions = active
|
|
|
+ ? `${task.officialTodoId ? '' : `<button class="btn btn-sm btn-secondary" data-agent-action="customer-task-sync-official" data-id="${escapeHtml(task.id)}">同步企微待办</button>`}<button class="btn btn-sm btn-secondary" data-agent-action="customer-task-dismiss" data-id="${escapeHtml(task.id)}">忽略</button><button class="btn btn-sm" data-agent-action="customer-task-done" data-id="${escapeHtml(task.id)}">完成</button>`
|
|
|
+ : `${needsOfficialCompletion ? `<button class="btn btn-sm" data-agent-action="customer-task-complete-official" data-id="${escapeHtml(task.id)}">同步完成到企微</button>` : ''}<span>${escapeHtml(agentTaskStatusLabel(task.status))}${task.resolutionReason === 'profile_condition_resolved' ? ' · 画像条件已补齐' : ''}</span>`;
|
|
|
+ return `<div class="agent-customer-task ${escapeHtml(task.priority)} ${escapeHtml(task.status)}">
|
|
|
+ <div><strong>${escapeHtml(task.title)}</strong><small>${escapeHtml([task.owner, task.dueAt].filter(Boolean).join(' · ') || '待分配')}</small><p>${escapeHtml(task.reason || task.evidence || '')}</p><div class="agent-task-meta"><span>${evidenceCount} 条依据</span><span class="${officialClass}">${escapeHtml(agentOfficialTodoLabel(task))}</span></div></div>
|
|
|
+ <div class="agent-intelligence-actions">${actions}</div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function demandItems(demand = {}) {
|
|
|
+ const min = demand.budgetMin ?? demand.budget_min;
|
|
|
+ const max = demand.budgetMax ?? demand.budget_max;
|
|
|
+ const values = [
|
|
|
+ ['客户意图', demand.intent || demand.purpose],
|
|
|
+ ['核心需求', demand.need || demand.needs || demand.keyConcerns],
|
|
|
+ ['预算', demand.budgetWan ? `${demand.budgetWan}${demand.budgetType && demand.budgetType !== '待确认' ? `(${demand.budgetType})` : ''}` : (min && max ? `${min}-${max}` : (max ? `${max}内` : ''))],
|
|
|
+ ['计划时间', demand.timeline],
|
|
|
+ ['决策人', demand.decisionMaker],
|
|
|
+ ['沟通偏好', demand.contactPreference],
|
|
|
+ ['服务阶段', demand.serviceStage || demand.negotiationStage],
|
|
|
+ ['紧迫度', demand.urgency],
|
|
|
+ ];
|
|
|
+ return values.filter(([, value]) => value !== undefined && value !== null && String(value).trim());
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentMessage(message) {
|
|
|
+ const roleLabel = message.senderName || (message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议');
|
|
|
+ const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
|
|
|
+ const voiceMeta = message.raw?.msgType === 16 ? message.raw : null;
|
|
|
+ const voiceAudio = voiceMeta?.audioAvailable && message.id ? `/api/agent/messages/${encodeURIComponent(message.id)}/voice-audio` : null;
|
|
|
+ const voiceTranscript = voiceMeta ? String(message.content || '').replace(/^\[语音[^\]]*\]\s*/, '') : '';
|
|
|
+ const transcriptId = voiceMeta && message.id ? `voice-transcript-${String(message.id).replace(/[^a-zA-Z0-9_-]/g, '')}` : '';
|
|
|
+ const voiceDuration = Math.max(1, Number(voiceMeta?.duration) || 1);
|
|
|
+ const voiceBubbleWidth = Math.min(232, Math.max(118, Math.round(112 + voiceDuration * 2)));
|
|
|
+ return `<div class="agent-message ${escapeHtml(message.role)}" data-message-id="${escapeHtml(message.id || '')}">
|
|
|
+ <div class="agent-message-meta"><span>${roleLabel}</span><span>${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}</span></div>
|
|
|
+ ${voiceAudio ? `<div class="agent-message-voice-row">
|
|
|
+ <button type="button" class="agent-voice-transcript-toggle" data-voice-transcript-toggle aria-expanded="false" aria-controls="${escapeHtml(transcriptId)}">${voiceIcon('text')}<span>转文字</span></button>
|
|
|
+ <button type="button" class="agent-voice-bubble" data-voice-play aria-pressed="false" style="--voice-bubble-width:${voiceBubbleWidth}px" aria-label="播放语音,时长 ${Math.ceil(voiceDuration)} 秒">
|
|
|
+ <span class="agent-voice-play-icon" aria-hidden="true">${voiceIcon('volume')}</span>
|
|
|
+ <span class="agent-voice-wave" aria-hidden="true"><i></i><i></i><i></i><i></i></span>
|
|
|
+ <span class="agent-voice-duration">${Math.ceil(voiceDuration)}″</span>
|
|
|
+ <audio class="agent-voice-audio" src="${escapeHtml(voiceAudio)}" preload="metadata"></audio>
|
|
|
+ </button>
|
|
|
+ </div><div class="agent-voice-transcript" id="${escapeHtml(transcriptId)}" hidden>${escapeHtml(voiceTranscript).replace(/\n/g, '<br>')}</div>` : `<div class="agent-message-body">${escapeHtml(message.content).replace(/\n/g, '<br>')}</div>`}
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentModeLabel(mode) {
|
|
|
+ return ({ review: '待审核', auto: '高置信自动', human: '人工接管', paused: '会话暂停' })[mode] || mode || '待审核';
|
|
|
+ }
|
|
|
+
|
|
|
+ function formatReplyWaiting(minutes) {
|
|
|
+ const value = Math.max(0, Number(minutes) || 0);
|
|
|
+ if (value < 60) return `${value} 分钟`;
|
|
|
+ if (value < 1440) return `${Math.floor(value / 60)} 小时 ${value % 60} 分钟`;
|
|
|
+ return `${Math.floor(value / 1440)} 天 ${Math.floor((value % 1440) / 60)} 小时`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderResponseMonitor(monitor = {}) {
|
|
|
+ const summary = monitor.summary || {};
|
|
|
+ const config = monitor.config || {};
|
|
|
+ const scope = monitor.scope || { customers: [], groups: [] };
|
|
|
+ const reminders = monitor.reminders || [];
|
|
|
+ const scopeItems = (scope.customers || []).map(item => ({ ...item, typeLabel: '客户' }));
|
|
|
+ return `
|
|
|
+ <section class="response-monitor ${summary.urgent ? 'has-urgent' : ''}">
|
|
|
+ <header class="response-monitor-header">
|
|
|
+ <div>
|
|
|
+ <span>RESPONSE SLA MONITOR</span>
|
|
|
+ <strong>客服回复提醒</strong>
|
|
|
+ <small>最后一条是客户消息且尚未回复时计时;仅本地提醒,不自动外发</small>
|
|
|
+ </div>
|
|
|
+ <div class="response-monitor-kpis">
|
|
|
+ <span><b>${summary.monitoredCustomers || 0}</b> 个客户</span>
|
|
|
+ <span class="${summary.overdue ? 'danger' : ''}"><b>${summary.overdue || 0}</b> 个超时</span>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+ <div class="response-monitor-body">
|
|
|
+ <div class="response-monitor-scope">
|
|
|
+ <div class="response-monitor-section-title"><strong>当前监听范围</strong><span>${config.warningMinutes || 15} 分钟提醒 · ${config.urgentMinutes || 60} 分钟紧急</span></div>
|
|
|
+ <div class="response-scope-list">
|
|
|
+ ${scopeItems.length ? scopeItems.map(item => `<span class="response-scope-chip ${escapeHtml(item.status || '')}"><i>${escapeHtml(item.typeLabel)}</i>${escapeHtml(item.name)}<em>${item.status === 'awaiting_sync' ? '待同步' : item.status === 'overdue' ? '待回复' : item.status === 'replied' ? '已回复' : item.status === 'no_reply_needed' ? '无需回复' : '监控中'}</em></span>`).join('') : '<span class="response-monitor-empty">尚未配置监听客户</span>'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="response-reminder-list">
|
|
|
+ <div class="response-monitor-section-title"><strong>需要客服处理</strong><span>${summary.urgent || 0} 个紧急 · ${summary.warning || 0} 个提醒</span></div>
|
|
|
+ ${reminders.length ? reminders.slice(0, 8).map(item => `
|
|
|
+ <article class="response-reminder ${escapeHtml(item.severity)}">
|
|
|
+ <div><i>${item.kind === 'group' ? '群' : '客'}</i><strong>${escapeHtml(item.name)}</strong><em>${item.severity === 'urgent' ? '紧急' : '提醒'}</em></div>
|
|
|
+ <p>${escapeHtml(item.lastMessagePreview || '收到新消息')}</p>
|
|
|
+ <footer><span>已等待 ${formatReplyWaiting(item.waitingMinutes)}</span><button class="btn btn-sm" data-response-monitor-target="${escapeHtml(item.kind)}" data-id="${escapeHtml(item.conversationId || item.id)}">${item.kind === 'group' ? '查看群聊' : '去回复'}</button></footer>
|
|
|
+ </article>
|
|
|
+ `).join('') : '<div class="response-monitor-empty success">当前没有超时未回复会话</div>'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentGroupMonitorItems(conversations = [], monitor = {}, groupAgents = []) {
|
|
|
+ const groups = new Map();
|
|
|
+ for (const item of monitor.scope?.groups || []) {
|
|
|
+ groups.set(String(item.id), { ...item, id: String(item.id), channelType: 'group', channelLabel: '客户群聊' });
|
|
|
+ }
|
|
|
+ for (const conversation of conversations.filter(item => item.channelType === 'group')) {
|
|
|
+ const id = String(conversation.channelId || conversation.id);
|
|
|
+ const latest = conversation.messages?.[conversation.messages.length - 1] || null;
|
|
|
+ const current = groups.get(id) || {};
|
|
|
+ const latestSource = latestAgentMessageSource([
|
|
|
+ { time: current.lastMessageAt, preview: current.lastMessagePreview, senderName: current.lastSenderName },
|
|
|
+ { time: conversation.lastMessageAt || latest?.timestamp, preview: latest?.content, senderName: latest?.senderName },
|
|
|
+ ]);
|
|
|
+ groups.set(id, {
|
|
|
+ ...current,
|
|
|
+ id,
|
|
|
+ name: current.name || conversation.displayName,
|
|
|
+ conversationId: conversation.id,
|
|
|
+ messages: conversation.messages || [],
|
|
|
+ lastMessageAt: latestSource?.timestamp > Number.NEGATIVE_INFINITY ? latestSource.time : null,
|
|
|
+ lastMessagePreview: latestSource?.preview || '',
|
|
|
+ lastSenderName: latestSource?.senderName || '',
|
|
|
+ status: current.status || 'monitoring',
|
|
|
+ channelType: 'group',
|
|
|
+ channelLabel: '客户群聊',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ for (const groupAgent of groupAgents || []) {
|
|
|
+ const id = String(groupAgent.roomId || '');
|
|
|
+ if (!id) continue;
|
|
|
+ const latest = groupAgent.messages?.[groupAgent.messages.length - 1] || null;
|
|
|
+ const current = groups.get(id) || {};
|
|
|
+ const latestSource = latestAgentMessageSource([
|
|
|
+ { time: current.lastMessageAt, preview: current.lastMessagePreview, senderName: current.lastSenderName },
|
|
|
+ { time: latest?.timestamp, preview: latest?.content, senderName: latest?.senderName },
|
|
|
+ ]);
|
|
|
+ groups.set(id, {
|
|
|
+ ...current,
|
|
|
+ id,
|
|
|
+ name: current.name || groupAgent.roomName || id,
|
|
|
+ messages: groupAgent.messages || [],
|
|
|
+ lastMessageAt: latestSource?.timestamp > Number.NEGATIVE_INFINITY ? latestSource.time : null,
|
|
|
+ lastMessagePreview: latestSource?.preview || '',
|
|
|
+ lastSenderName: latestSource?.senderName || '',
|
|
|
+ pendingReply: groupAgent.pendingReply || null,
|
|
|
+ mode: groupAgent.mode === 'auto' ? 'auto' : 'review',
|
|
|
+ agentError: groupAgent.agentError || null,
|
|
|
+ sendError: groupAgent.sendError || null,
|
|
|
+ agentNotice: groupAgent.agentNotice || null,
|
|
|
+ lastOutcome: groupAgent.lastOutcome || null,
|
|
|
+ audit: groupAgent.audit || [],
|
|
|
+ channelType: 'group',
|
|
|
+ channelLabel: '客户群聊',
|
|
|
+ });
|
|
|
+ }
|
|
|
+ return sortAgentItemsByLatest([...groups.values()]);
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentMonitorStatusLabel(status) {
|
|
|
+ return ({
|
|
|
+ overdue: '待客服回复',
|
|
|
+ warning: '即将超时',
|
|
|
+ replied: '已回复',
|
|
|
+ no_reply_needed: '无需回复',
|
|
|
+ awaiting_sync: '等待回调新消息',
|
|
|
+ monitoring: '监控中',
|
|
|
+ })[status] || '监控中';
|
|
|
+ }
|
|
|
+
|
|
|
+ function privateConversationFlow(conversation) {
|
|
|
+ const messages = conversation?.messages || [];
|
|
|
+ const inboundIndex = messages.findLastIndex(message => message.role === 'customer');
|
|
|
+ const outboundAfterInbound = inboundIndex >= 0 && messages.slice(inboundIndex + 1).some(message => ['agent', 'human'].includes(message.role));
|
|
|
+ const hasInbound = inboundIndex >= 0;
|
|
|
+ const hasDraft = Boolean(conversation?.pendingReply);
|
|
|
+ const failed = Boolean(conversation?.agentError);
|
|
|
+ const noReply = Boolean(conversation?.agentNotice);
|
|
|
+ const human = conversation?.mode === 'human';
|
|
|
+ const paused = conversation?.mode === 'paused';
|
|
|
+ return [
|
|
|
+ { label: '消息接入', detail: hasInbound ? '已收到企微消息' : '等待客户消息', state: hasInbound ? 'done' : 'current' },
|
|
|
+ { label: '范围校验', detail: hasInbound ? '白名单私聊' : '等待校验', state: hasInbound ? 'done' : 'waiting' },
|
|
|
+ { label: 'Agent 分析', detail: failed ? '处理失败' : noReply ? '判断无需回复' : hasDraft || outboundAfterInbound ? '分析完成' : human ? '人工路径' : paused ? '已暂停' : '等待处理', state: failed ? 'error' : noReply || hasDraft || outboundAfterInbound ? 'done' : hasInbound && !human && !paused ? 'current' : 'waiting' },
|
|
|
+ { label: '审核 / 接管', detail: hasDraft ? '草稿待审核' : human ? '人工处理中' : outboundAfterInbound || noReply ? '已完成' : '等待上一步', state: hasDraft || human ? 'current' : outboundAfterInbound || noReply ? 'done' : 'waiting' },
|
|
|
+ { label: '处理完成', detail: outboundAfterInbound ? '回复已发送' : noReply ? '无需外发' : '尚未完成', state: outboundAfterInbound || noReply ? 'done' : 'waiting' },
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ function groupConversationFlow(group) {
|
|
|
+ const confirmed = Boolean(group?.roomId);
|
|
|
+ const hasMessage = Boolean(group?.messages?.length || group?.lastMessageAt);
|
|
|
+ const pending = Boolean(group?.pendingReply);
|
|
|
+ const failed = Boolean(group?.agentError);
|
|
|
+ const sendFailed = Boolean(group?.sendError);
|
|
|
+ const noReply = Boolean(group?.agentNotice);
|
|
|
+ const auto = group?.mode === 'auto';
|
|
|
+ const sent = ['group_message_sent', 'group_message_auto_sent'].includes(group?.lastOutcome?.action) || group?.status === 'replied';
|
|
|
+ return [
|
|
|
+ { label: '群消息接入', detail: hasMessage ? '已收到回调新消息' : '等待回调新消息', state: hasMessage ? 'done' : 'current' },
|
|
|
+ { label: '范围识别', detail: confirmed ? '已确认客户群' : '等待确认', state: confirmed ? 'done' : 'waiting' },
|
|
|
+ { label: 'Agent 分析', detail: failed ? '生成失败' : pending || sent || noReply ? '分析完成' : '等待生成', state: failed ? 'error' : pending || sent || noReply ? 'done' : hasMessage ? 'current' : 'waiting' },
|
|
|
+ { label: auto ? '自动放行' : '人工审核', detail: auto ? (sendFailed ? '发送失败待处理' : sent ? '已自动放行' : noReply ? '无需放行' : '等待 Agent 分析') : pending ? '草稿待审核' : sent || noReply ? '已完成' : '等待草稿', state: sendFailed ? 'error' : auto ? (sent || noReply ? 'done' : 'waiting') : pending ? 'current' : sent || noReply ? 'done' : 'waiting' },
|
|
|
+ { label: auto ? '自动发送' : '群内发送', detail: sent ? '回复已发送' : noReply ? '无需回复' : sendFailed ? '发送失败' : '尚未发送', state: sent || noReply ? 'done' : sendFailed ? 'error' : 'waiting' },
|
|
|
+ ];
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentFlow(steps, type) {
|
|
|
+ return `<section class="agent-flow ${escapeHtml(type)}" aria-label="${type === 'group' ? '群聊监控流程' : '客户私聊处理流程'}">
|
|
|
+ <div class="agent-flow-heading"><strong>当前处理流程</strong><span>${type === 'group' ? '群聊按当前群策略处理,审核与全自动互不混用' : '客户私聊进入 Agent 与人工协作链路'}</span></div>
|
|
|
+ <ol>${steps.map((step, index) => `<li class="${escapeHtml(step.state)}"><i>${index + 1}</i><div><strong>${escapeHtml(step.label)}</strong><small>${escapeHtml(step.detail)}</small></div></li>`).join('')}</ol>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentAuditLabel(action) {
|
|
|
+ return ({
|
|
|
+ message_received: '收到真实企微消息',
|
|
|
+ draft_created: 'Agent 生成草稿',
|
|
|
+ draft_approved: '人工审核通过',
|
|
|
+ draft_rejected: '人工驳回草稿',
|
|
|
+ draft_regenerate_requested: '请求重新生成',
|
|
|
+ message_sent: '回复已发送',
|
|
|
+ manual_message_sent: '人工消息已发送',
|
|
|
+ message_send_failed: '发送失败',
|
|
|
+ agent_failed: 'Agent 上游失败',
|
|
|
+ agent_not_configured: 'Agent 未配置',
|
|
|
+ agent_no_reply_needed: 'Agent 判断无需回复',
|
|
|
+ agent_skipped_global_paused: '全局暂停,跳过 Agent',
|
|
|
+ agent_skipped_human: '人工接管,跳过 Agent',
|
|
|
+ agent_skipped_paused: '会话暂停,跳过 Agent',
|
|
|
+ message_duplicate_content_skipped: '重复消息已拦截',
|
|
|
+ conversation_mode_changed: '会话模式已更新',
|
|
|
+ manual_agent_run_requested: '手动请求 Agent',
|
|
|
+ conversation_history_synced: '人工补采企微会话',
|
|
|
+ customer_intelligence_updated: '客户画像、待办与预警已更新',
|
|
|
+ customer_memory_updated: '客户长期记忆已自动更新',
|
|
|
+ customer_memory_added: '人工添加客户记忆',
|
|
|
+ customer_memory_confirmed: '人工确认客户事实',
|
|
|
+ customer_memory_rejected: '客户记忆已拒绝',
|
|
|
+ customer_memory_forgotten: '客户记忆已遗忘',
|
|
|
+ customer_memory_backfilled: '历史客户记忆已回填',
|
|
|
+ customer_task_updated: '客户待办状态已更新',
|
|
|
+ customer_task_synced_to_official_todo: '客户待办已同步企微',
|
|
|
+ customer_alert_updated: '客户预警状态已更新',
|
|
|
+ })[action] || action;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentTraceText(value, maxLength = 96) {
|
|
|
+ const text = String(value ?? '')
|
|
|
+ .replace(/\b1\d{10}\b/g, match => `${match.slice(0, 3)}****${match.slice(-4)}`)
|
|
|
+ .replace(/([?&](?:token|key|secret|password)=)[^&\s]+/gi, '$1[已脱敏]');
|
|
|
+ return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentTraceSummary(value, key = '', depth = 0) {
|
|
|
+ if (/token|secret|password|authorization|cookie|masterkey|session/i.test(key)) return '[已脱敏]';
|
|
|
+ if (value === null || value === undefined || value === '') return '';
|
|
|
+ if (Array.isArray(value)) return `${value.length} 项`;
|
|
|
+ if (typeof value === 'object') {
|
|
|
+ if (depth >= 1) return `${Object.keys(value).length} 个字段`;
|
|
|
+ return Object.fromEntries(Object.entries(value).slice(0, 6).map(([childKey, childValue]) => [
|
|
|
+ childKey,
|
|
|
+ agentTraceSummary(childValue, childKey, depth + 1),
|
|
|
+ ]));
|
|
|
+ }
|
|
|
+ return agentTraceText(value);
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentToolStatus(call = {}) {
|
|
|
+ const result = call.result || {};
|
|
|
+ if (call.error || result.error || result.ok === false || result.success === false) return 'failed';
|
|
|
+ if (call.status === 'running') return 'running';
|
|
|
+ return 'completed';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentActivityTimeline(viewModel = {}) {
|
|
|
+ const traces = Array.isArray(viewModel.toolTrace) ? viewModel.toolTrace : [];
|
|
|
+ const audit = Array.isArray(viewModel.audit) ? viewModel.audit : [];
|
|
|
+ const planAudit = audit.filter(item => ['message_received', 'manual_agent_run_requested', 'conversation_history_synced'].includes(item.action)).slice(-2);
|
|
|
+ const evaluateAudit = audit.filter(item => ['draft_created', 'agent_failed', 'agent_no_reply_needed', 'message_send_failed', 'draft_approved', 'message_sent'].includes(item.action)).slice(-3);
|
|
|
+ const phase = (code, label, items, empty) => `
|
|
|
+ <section class="agent-activity-phase ${code}">
|
|
|
+ <header><i>${code.slice(0, 1).toUpperCase()}</i><div><strong>${label}</strong><small>${items.length} 条证据</small></div></header>
|
|
|
+ <div class="agent-activity-items">${items.length ? items.join('') : `<span class="agent-activity-empty">${empty}</span>`}</div>
|
|
|
+ </section>`;
|
|
|
+ const auditCard = item => `<article class="agent-activity-item"><div><strong>${escapeHtml(agentAuditLabel(item.action))}</strong><em>${escapeHtml(formatAgentTime(item.created_at || item.createdAt || item.at || item.timestamp))}</em></div><small>${escapeHtml(agentTraceText(item.detail || item.message || item.reason || '已记录到会话审计'))}</small></article>`;
|
|
|
+ const actItems = traces.map(call => {
|
|
|
+ const status = agentToolStatus(call);
|
|
|
+ const args = agentTraceSummary(call.args || call.params || {});
|
|
|
+ const argsText = typeof args === 'object'
|
|
|
+ ? Object.entries(args).map(([key, value]) => `${key}: ${typeof value === 'object' ? '结构化参数' : value}`).join(' · ')
|
|
|
+ : args;
|
|
|
+ return `<article class="agent-activity-item tool ${status}"><div><strong>${escapeHtml(call.tool || call.name || 'Agent 工具')}</strong><em>${status === 'failed' ? '失败' : status === 'running' ? '执行中' : '已执行'}</em></div><small>${escapeHtml(argsText || '无公开参数')}</small></article>`;
|
|
|
+ });
|
|
|
+ const observeItems = traces.map(call => {
|
|
|
+ const result = call.result || {};
|
|
|
+ const status = agentToolStatus(call);
|
|
|
+ const duration = Number(call.durationMs ?? result.durationMs ?? call.elapsedMs ?? result.elapsedMs);
|
|
|
+ const retries = Number(call.retryCount ?? result.retryCount ?? call.retries ?? result.retries);
|
|
|
+ const resultSummary = agentTraceSummary(call.error || result.error || result.message || result.summary || result);
|
|
|
+ const summaryText = typeof resultSummary === 'object'
|
|
|
+ ? Object.entries(resultSummary).map(([key, value]) => `${key}: ${typeof value === 'object' ? '结构化结果' : value}`).join(' · ')
|
|
|
+ : resultSummary;
|
|
|
+ return `<article class="agent-activity-item observe ${status}"><div><strong>${escapeHtml(call.tool || call.name || 'Agent 工具')} 结果</strong><em>${Number.isFinite(duration) ? `${Math.max(0, Math.round(duration))} ms` : status === 'failed' ? '失败' : '完成'}${Number.isFinite(retries) && retries > 0 ? ` · 重试 ${retries}` : ''}</em></div><small>${escapeHtml(summaryText || (status === 'failed' ? '执行失败,详情已进入审计' : '工具返回成功'))}</small></article>`;
|
|
|
+ });
|
|
|
+ return `<div class="agent-activity-timeline">
|
|
|
+ ${phase('plan', 'Plan · 任务计划', planAudit.map(auditCard), '等待真实消息或人工运行请求')}
|
|
|
+ ${phase('act', 'Act · 工具执行', actItems, '本轮没有工具调用')}
|
|
|
+ ${phase('observe', 'Observe · 结果观察', observeItems, '暂无工具结果')}
|
|
|
+ ${phase('evaluate', 'Evaluate · 评估决策', evaluateAudit.map(auditCard), '等待 Agent 形成处理结论')}
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function scrollAgentMessagesToLatest(page) {
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ const stream = page.querySelector('.agent-message-stream');
|
|
|
+ if (stream) stream.scrollTop = stream.scrollHeight;
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function captureAgentWorkspaceScroll(page) {
|
|
|
+ const list = page.querySelector('.agent-list-scroll');
|
|
|
+ const stream = page.querySelector('.agent-message-stream');
|
|
|
+ return {
|
|
|
+ viewKey: page.dataset.agentViewKey || '',
|
|
|
+ listTop: list?.scrollTop || 0,
|
|
|
+ messageTop: stream?.scrollTop || 0,
|
|
|
+ messageAtBottom: stream
|
|
|
+ ? stream.scrollHeight - stream.scrollTop - stream.clientHeight <= 48
|
|
|
+ : true,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function restoreAgentWorkspaceScroll(page, snapshot, viewKey, incremental) {
|
|
|
+ if (!incremental || !snapshot || snapshot.viewKey !== viewKey) {
|
|
|
+ scrollAgentMessagesToLatest(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ const list = page.querySelector('.agent-list-scroll');
|
|
|
+ const stream = page.querySelector('.agent-message-stream');
|
|
|
+ if (list) list.scrollTop = Math.min(snapshot.listTop, Math.max(0, list.scrollHeight - list.clientHeight));
|
|
|
+ if (!stream) return;
|
|
|
+ stream.scrollTop = snapshot.messageAtBottom
|
|
|
+ ? stream.scrollHeight
|
|
|
+ : Math.min(snapshot.messageTop, Math.max(0, stream.scrollHeight - stream.clientHeight));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentDomNodeKey(node) {
|
|
|
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) return '';
|
|
|
+ for (const attribute of ['id', 'data-message-id', 'data-conversation-id', 'data-group-monitor-id']) {
|
|
|
+ const value = node.getAttribute(attribute);
|
|
|
+ if (value) return `${node.tagName}:${attribute}:${value}`;
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentDomNodesCompatible(current, next) {
|
|
|
+ return current?.nodeType === next?.nodeType
|
|
|
+ && (current.nodeType !== Node.ELEMENT_NODE || current.tagName === next.tagName);
|
|
|
+ }
|
|
|
+
|
|
|
+ function syncAgentDomAttributes(current, next) {
|
|
|
+ for (const attribute of [...current.attributes]) {
|
|
|
+ if (!next.hasAttribute(attribute.name)) current.removeAttribute(attribute.name);
|
|
|
+ }
|
|
|
+ for (const attribute of [...next.attributes]) {
|
|
|
+ if (current.getAttribute(attribute.name) !== attribute.value) current.setAttribute(attribute.name, attribute.value);
|
|
|
+ }
|
|
|
+ if (current instanceof HTMLTextAreaElement && document.activeElement !== current && current.value !== next.value) {
|
|
|
+ current.value = next.value;
|
|
|
+ }
|
|
|
+ if (current instanceof HTMLInputElement && document.activeElement !== current && current.value !== next.value) {
|
|
|
+ current.value = next.value;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function patchAgentDomNode(current, next) {
|
|
|
+ if (!agentDomNodesCompatible(current, next)) {
|
|
|
+ current.replaceWith(next.cloneNode(true));
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (current.nodeType === Node.TEXT_NODE || current.nodeType === Node.COMMENT_NODE) {
|
|
|
+ if (current.nodeValue !== next.nodeValue) current.nodeValue = next.nodeValue;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ syncAgentDomAttributes(current, next);
|
|
|
+ let cursor = current.firstChild;
|
|
|
+ for (const nextChild of [...next.childNodes]) {
|
|
|
+ const nextKey = agentDomNodeKey(nextChild);
|
|
|
+ let match = cursor;
|
|
|
+ if (nextKey) {
|
|
|
+ if (agentDomNodeKey(match) !== nextKey) {
|
|
|
+ match = [...current.childNodes].find(candidate => agentDomNodeKey(candidate) === nextKey) || null;
|
|
|
+ }
|
|
|
+ } else if (!agentDomNodesCompatible(match, nextChild) || agentDomNodeKey(match)) {
|
|
|
+ match = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!match) {
|
|
|
+ match = nextChild.cloneNode(true);
|
|
|
+ current.insertBefore(match, cursor);
|
|
|
+ } else if (match !== cursor) {
|
|
|
+ current.insertBefore(match, cursor);
|
|
|
+ }
|
|
|
+ patchAgentDomNode(match, nextChild);
|
|
|
+ cursor = match.nextSibling;
|
|
|
+ }
|
|
|
+ while (cursor) {
|
|
|
+ const nextSibling = cursor.nextSibling;
|
|
|
+ cursor.remove();
|
|
|
+ cursor = nextSibling;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function patchAgentPage(page, html) {
|
|
|
+ const nextPage = page.cloneNode(false);
|
|
|
+ nextPage.innerHTML = html;
|
|
|
+ patchAgentDomNode(page, nextPage);
|
|
|
+ }
|
|
|
+
|
|
|
+ function captureAgentInsightScroll(page) {
|
|
|
+ const panel = page.querySelector('.agent-insight-panel');
|
|
|
+ if (!panel) return null;
|
|
|
+ return {
|
|
|
+ conversationId: panel.dataset.conversationId || '',
|
|
|
+ scrollTop: panel.scrollTop,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function restoreAgentInsightScroll(page, snapshot, conversationId) {
|
|
|
+ if (!snapshot || snapshot.conversationId !== String(conversationId || '')) return;
|
|
|
+ requestAnimationFrame(() => {
|
|
|
+ const panel = page.querySelector('.agent-insight-panel');
|
|
|
+ if (!panel || panel.dataset.conversationId !== snapshot.conversationId) return;
|
|
|
+ const maxScrollTop = Math.max(0, panel.scrollHeight - panel.clientHeight);
|
|
|
+ panel.scrollTop = Math.min(snapshot.scrollTop, maxScrollTop);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function maskAllowlistId(value) {
|
|
|
+ const id = String(value || '');
|
|
|
+ if (id.length <= 8) return id;
|
|
|
+ return `${id.slice(0, 4)}…${id.slice(-4)}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openAllowlistModal(page) {
|
|
|
+ openModal(
|
|
|
+ '管理 AI 白名单',
|
|
|
+ `<div class="allowlist-intro"><strong>选择允许 AI 处理的客户</strong><span>未选中的联系人不会进入 Agent,也不会收到自动回复。</span></div>
|
|
|
+ <div class="allowlist-toolbar">
|
|
|
+ <input id="allowlist-search" type="search" placeholder="搜索客户姓名、备注或企业" autocomplete="off">
|
|
|
+ <button class="btn btn-secondary btn-sm" id="allowlist-refresh" type="button">刷新联系人</button>
|
|
|
+ </div>
|
|
|
+ <div class="allowlist-summary"><span id="allowlist-count">正在读取联系人…</span><em id="allowlist-warning"></em></div>
|
|
|
+ <div class="allowlist-contact-list" id="allowlist-contact-list"><div class="allowlist-loading">正在从企微读取联系人…</div></div>
|
|
|
+ <details class="allowlist-manual"><summary>找不到联系人?手工添加联系人 ID</summary><div><input id="allowlist-manual-id" placeholder="输入联系人 ID"><button class="btn btn-secondary btn-sm" id="allowlist-manual-add" type="button">添加</button></div><small id="allowlist-manual-error"></small></details>`,
|
|
|
+ `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button><button class="btn" id="allowlist-save" disabled>保存白名单</button>`,
|
|
|
+ 'allowlist-modal'
|
|
|
+ );
|
|
|
+ const modal = state.modal;
|
|
|
+ const previousCleanup = modal.__modalCleanup;
|
|
|
+ document.body.classList.add('allowlist-modal-open');
|
|
|
+ modal.__modalCleanup = () => {
|
|
|
+ previousCleanup?.();
|
|
|
+ document.body.classList.remove('allowlist-modal-open');
|
|
|
+ };
|
|
|
+ const listEl = modal.querySelector('#allowlist-contact-list');
|
|
|
+ const searchEl = modal.querySelector('#allowlist-search');
|
|
|
+ const countEl = modal.querySelector('#allowlist-count');
|
|
|
+ const warningEl = modal.querySelector('#allowlist-warning');
|
|
|
+ const saveButton = modal.querySelector('#allowlist-save');
|
|
|
+ let contacts = [];
|
|
|
+ let selected = new Set();
|
|
|
+
|
|
|
+ const updateCount = () => {
|
|
|
+ countEl.textContent = `已选择 ${selected.size} 位联系人`;
|
|
|
+ saveButton.textContent = `保存白名单${selected.size ? `(${selected.size})` : ''}`;
|
|
|
+ };
|
|
|
+ const renderContacts = () => {
|
|
|
+ const keyword = searchEl.value.trim().toLowerCase();
|
|
|
+ const visible = contacts.filter(contact => !keyword || [contact.displayName, contact.remark, contact.company, contact.id]
|
|
|
+ .some(value => String(value || '').toLowerCase().includes(keyword)));
|
|
|
+ listEl.innerHTML = visible.length ? visible.map(contact => {
|
|
|
+ const checked = selected.has(contact.id);
|
|
|
+ const meta = [contact.remark && contact.remark !== contact.displayName ? `备注:${contact.remark}` : '', contact.company, maskAllowlistId(contact.id)].filter(Boolean).join(' · ');
|
|
|
+ return `<label class="allowlist-contact ${checked ? 'selected' : ''}">
|
|
|
+ <input type="checkbox" data-allowlist-id="${escapeHtml(contact.id)}" ${checked ? 'checked' : ''}>
|
|
|
+ <span class="allowlist-avatar">${escapeHtml((contact.displayName || '客').slice(0, 1))}</span>
|
|
|
+ <span class="allowlist-contact-copy"><strong>${escapeHtml(contact.displayName || '企微客户')}</strong><small>${escapeHtml(meta)}</small></span>
|
|
|
+ <em>${checked ? '已选择' : '未选择'}</em>
|
|
|
+ </label>`;
|
|
|
+ }).join('') : `<div class="allowlist-empty"><strong>${contacts.length ? '没有匹配的联系人' : '暂未读取到联系人'}</strong><span>${contacts.length ? '换一个姓名或备注试试。' : '请确认企微在线,或使用下方联系人 ID 添加。'}</span></div>`;
|
|
|
+ updateCount();
|
|
|
+ };
|
|
|
+ const loadContacts = async () => {
|
|
|
+ listEl.innerHTML = '<div class="allowlist-loading">正在从企微读取联系人…</div>';
|
|
|
+ warningEl.textContent = '';
|
|
|
+ saveButton.disabled = true;
|
|
|
+ try {
|
|
|
+ const result = await api('GET', '/api/agent/allowlist', undefined, 35000);
|
|
|
+ if (!modal.isConnected) return;
|
|
|
+ contacts = result.data?.contacts || [];
|
|
|
+ selected = new Set(result.data?.selectedIds || []);
|
|
|
+ warningEl.textContent = result.warnings?.[0] || '';
|
|
|
+ renderContacts();
|
|
|
+ } catch (error) {
|
|
|
+ if (!modal.isConnected) return;
|
|
|
+ listEl.innerHTML = `<div class="allowlist-empty error"><strong>联系人读取失败</strong><span>${escapeHtml(error.message || '请稍后重试,或使用联系人 ID 添加。')}</span></div>`;
|
|
|
+ countEl.textContent = '尚未加载白名单';
|
|
|
+ } finally {
|
|
|
+ if (modal.isConnected) saveButton.disabled = false;
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ searchEl.addEventListener('input', renderContacts);
|
|
|
+ listEl.addEventListener('change', event => {
|
|
|
+ const checkbox = event.target.closest('[data-allowlist-id]');
|
|
|
+ if (!checkbox) return;
|
|
|
+ if (checkbox.checked) selected.add(checkbox.dataset.allowlistId);
|
|
|
+ else selected.delete(checkbox.dataset.allowlistId);
|
|
|
+ renderContacts();
|
|
|
+ });
|
|
|
+ modal.querySelector('#allowlist-refresh').addEventListener('click', loadContacts);
|
|
|
+ modal.querySelector('#allowlist-manual-add').addEventListener('click', () => {
|
|
|
+ const input = modal.querySelector('#allowlist-manual-id');
|
|
|
+ const errorEl = modal.querySelector('#allowlist-manual-error');
|
|
|
+ const id = input.value.trim();
|
|
|
+ if (!/^[A-Za-z0-9._:@-]{1,128}$/.test(id)) {
|
|
|
+ errorEl.textContent = '请输入有效的联系人 ID';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ errorEl.textContent = '';
|
|
|
+ if (!contacts.some(contact => contact.id === id)) contacts.unshift({ id, displayName: '手工添加联系人', remark: '', company: '' });
|
|
|
+ selected.add(id);
|
|
|
+ input.value = '';
|
|
|
+ searchEl.value = '';
|
|
|
+ renderContacts();
|
|
|
+ });
|
|
|
+ saveButton.addEventListener('click', async () => {
|
|
|
+ if (!selected.size && !await confirmModal('白名单清空后,AI 监听会立即停止,且不会再处理任何联系人消息。', {
|
|
|
+ title: '清空联系人白名单',
|
|
|
+ confirmText: '清空并停止监听',
|
|
|
+ warning: '此操作会立即改变当前监听状态。',
|
|
|
+ danger: true,
|
|
|
+ })) return;
|
|
|
+ saveButton.disabled = true;
|
|
|
+ try {
|
|
|
+ const contactIds = [...selected];
|
|
|
+ const contactNames = {};
|
|
|
+ for (const contact of contacts) {
|
|
|
+ if (selected.has(contact.id) && String(contact.displayName || '').trim()) contactNames[contact.id] = contact.displayName;
|
|
|
+ }
|
|
|
+ const result = await api('POST', '/api/agent/allowlist', { contactIds, contactNames, autoStart: true });
|
|
|
+ toast(result.assistantMessage || '白名单已保存');
|
|
|
+ closeModal();
|
|
|
+ await loadAgentData(page);
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '白名单保存失败', 'error');
|
|
|
+ saveButton.disabled = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+ await loadContacts();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentWorkspace(page) {
|
|
|
+ const status = state.agent.status || {};
|
|
|
+ const listener = status.listener || {};
|
|
|
+ const ingress = status.ingress || {};
|
|
|
+ const account = status.account || {};
|
|
|
+ const config = status.config || {};
|
|
|
+ const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
|
|
|
+ const mode = status.globalMode || 'suggest';
|
|
|
+ const aiActive = Boolean(listener.running && mode === 'auto');
|
|
|
+ const conversations = state.agent.conversations || [];
|
|
|
+ const selected = conversations.find(item => item.id === state.agent.selectedId) || conversations[0] || null;
|
|
|
+ if (selected) state.agent.selectedId = selected.id;
|
|
|
+
|
|
|
+ const analysis = selected?.analysis || {};
|
|
|
+ const customerIntelligence = selected?.customerIntelligence || {};
|
|
|
+ const customerTasks = customerIntelligence.tasks || [];
|
|
|
+ const customerAlerts = customerIntelligence.alerts || [];
|
|
|
+ const intelligenceSummary = customerIntelligence.summary || {};
|
|
|
+ const demands = demandItems(analysis.demand || {});
|
|
|
+ const pendingText = selected?.pendingReply?.content || '';
|
|
|
+ const isHistorical = selected?.source === 'verified-history';
|
|
|
+ const manualActive = Boolean(!aiActive || selected?.mode === 'manual');
|
|
|
+
|
|
|
+ els.accountName.textContent = accountName;
|
|
|
+ els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
|
|
|
+
|
|
|
+ page.innerHTML = `
|
|
|
+ <section class="agent-hero">
|
|
|
+ <div>
|
|
|
+ <div class="agent-eyebrow">WECOM · AGENTIC SERVICE</div>
|
|
|
+ <h2>把企微消息变成可执行的客户线索</h2>
|
|
|
+ <p>启动监听后由 AI 自动回复白名单客户;关闭监听后停止 AI,由人工继续回复。</p>
|
|
|
+ </div>
|
|
|
+ <div class="agent-safety-card">
|
|
|
+ <span class="agent-live-dot ${account.online ? 'online' : ''}"></span>
|
|
|
+ <div><strong>${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'}</strong><small>${escapeHtml(account.corpName || config.transport || '企微通道')} · ${aiActive ? 'AI 自动回复中' : '当前人工回复'}</small></div>
|
|
|
+ <div class="agent-safety-actions">
|
|
|
+ <button class="btn ${aiActive ? 'btn-secondary' : ''}" data-agent-action="${aiActive ? 'stop-listener' : 'start-listener'}" ${account.online ? '' : 'disabled'}>${aiActive ? '关闭 AI 监听' : '启动 AI 监听'}</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="agent-modebar">
|
|
|
+ <div class="agent-mode-copy"><strong>当前处理方式</strong><span>启停监听即切换 AI / 人工</span></div>
|
|
|
+ <div class="agent-mode-switch">
|
|
|
+ <span class="agent-mode-btn ${!aiActive ? 'active' : ''}">人工回复</span>
|
|
|
+ <span class="agent-mode-btn ${aiActive ? 'active' : ''}">AI 自动回复</span>
|
|
|
+ </div>
|
|
|
+ <div class="agent-guard"><span>安全边界</span>仅处理 ${Number(config.allowedSenderCount) || 0} 位白名单联系人</div>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <section class="agent-workspace">
|
|
|
+ <aside class="agent-conversation-list">
|
|
|
+ <div class="agent-panel-heading"><div><strong>客户会话</strong><small>${conversations.length} 个已归集会话</small></div><span class="agent-count">${conversations.length}</span></div>
|
|
|
+ <div class="agent-list-scroll">
|
|
|
+ ${conversations.length ? conversations.map(item => {
|
|
|
+ const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
|
|
|
+ return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
|
|
|
+ <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
|
|
|
+ <span class="agent-conversation-copy"><strong>${escapeHtml(item.displayName)}</strong><small>${escapeHtml(last?.content || '等待新消息')}</small><em>${item.source === 'verified-history' ? '真实链路记录' : !aiActive || item.mode === 'manual' ? '人工回复' : 'AI 自动回复'}</em></span>
|
|
|
+ <time>${formatAgentTime(item.lastMessageAt)}</time>
|
|
|
+ </button>`;
|
|
|
+ }).join('') : `<div class="agent-empty"><strong>等待第一条真实消息</strong><span>启动监听后,用白名单企微发送测试内容。</span></div>`}
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <main class="agent-chat-panel">
|
|
|
+ ${selected ? `
|
|
|
+ <header class="agent-chat-header">
|
|
|
+ <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · ${isHistorical ? '已验证真实收发记录' : '实时企微会话'}</span></div>
|
|
|
+ ${aiActive ? `<button class="btn ${selected.mode === 'manual' ? '' : 'btn-secondary'}" data-agent-action="${selected.mode === 'manual' ? 'resume' : 'takeover'}" data-id="${selected.id}">${selected.mode === 'manual' ? '恢复 Agent' : '人工接管'}</button>` : '<span class="badge badge-success">人工回复中</span>'}
|
|
|
+ </header>
|
|
|
+ <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
|
|
|
+ <div class="agent-composer ${manualActive ? 'manual' : ''}">
|
|
|
+ <div class="agent-composer-label"><strong>${manualActive ? '人工回复' : 'AI 自动回复'}</strong><span>${selected.pendingReply ? '等待处理' : (isHistorical ? '历史记录无待发送内容' : aiActive ? 'AI 将自动发送新回复' : 'AI 已停止')}</span></div>
|
|
|
+ <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : 'AI 自动回复运行中…'}">${escapeHtml(pendingText)}</textarea>
|
|
|
+ <div class="agent-composer-actions"><span>${manualActive ? '也可以直接在企业微信中人工回复' : '新消息将由 AI 自动回复'}</span><button class="btn" data-agent-action="approve" data-id="${selected.id}" ${pendingText || manualActive ? '' : 'disabled'}>${manualActive ? '人工发送' : '发送建议'}</button></div>
|
|
|
+ </div>
|
|
|
+ ` : `<div class="agent-empty large"><strong>智能会话准备就绪</strong><span>启动监听,然后从白名单企微发送一条真实业务咨询。</span></div>`}
|
|
|
+ </main>
|
|
|
+
|
|
|
+ <aside class="agent-insight-panel">
|
|
|
+ ${selected ? `
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
|
|
|
+ <div class="agent-intent-card"><strong>${escapeHtml(analysis.intentLabel || analysis.intent || '待识别')}</strong><span>画像完整度 ${Math.round(analysis.completenessScore || 0)}%</span><div><i style="width:${Math.min(100, analysis.completenessScore || 0)}%"></i></div></div>
|
|
|
+ <p class="agent-reasoning">${escapeHtml(analysis.reasoning || '等待新消息进入。')}</p>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>02</span><div><strong>需求画像</strong><small>从多轮会话持续归集</small></div></div>
|
|
|
+ <div class="agent-demand-grid">${demands.length ? demands.map(([label, value]) => `<div><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('') : '<p class="agent-muted">暂无明确需求</p>'}</div>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section knowledge">
|
|
|
+ <div class="agent-section-title"><span>03</span><div><strong>依据与边界</strong><small>让建议可解释、可追溯</small></div></div>
|
|
|
+ <ul>${(analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
|
|
|
+ </div>
|
|
|
+ ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
|
|
|
+ </aside>
|
|
|
+ </section>
|
|
|
+ `;
|
|
|
+ scrollAgentMessagesToLatest(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ function voiceIcon(name) {
|
|
|
+ const paths = {
|
|
|
+ mic: '<path d="M12 2a3 3 0 0 0-3 3v7a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Z"/><path d="M19 10v2a7 7 0 0 1-14 0v-2M12 19v3"/>',
|
|
|
+ play: '<path d="m6 3 14 9-14 9V3Z"/>',
|
|
|
+ volume: '<path d="M11 5 6 9H3v6h3l5 4V5Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18 6a8.5 8.5 0 0 1 0 12"/>',
|
|
|
+ text: '<path d="M4 5h16M8 10h8M8 15h8M6 20h12"/>',
|
|
|
+ upload: '<path d="M12 16V4m0 0L7 9m5-5 5 5"/><path d="M20 16v4H4v-4"/>',
|
|
|
+ trash: '<path d="M3 6h18M8 6V4h8v2m-9 0 1 15h8l1-15M10 11v5m4-5v5"/>',
|
|
|
+ stop: '<rect x="6" y="6" width="12" height="12" rx="1"/>',
|
|
|
+ };
|
|
|
+ return `<svg class="voice-icon" viewBox="0 0 24 24" aria-hidden="true" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">${paths[name] || paths.mic}</svg>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function fileToBase64(file) {
|
|
|
+ return new Promise((resolve, reject) => {
|
|
|
+ const reader = new FileReader();
|
|
|
+ reader.onload = () => resolve(String(reader.result || '').split(',').pop() || '');
|
|
|
+ reader.onerror = () => reject(reader.error || new Error('读取音频失败'));
|
|
|
+ reader.readAsDataURL(file);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openVoiceProfileModal(page) {
|
|
|
+ const voice = state.agent.status?.voice || {};
|
|
|
+ const profile = voice.profile || {};
|
|
|
+ openModal('本人声音', `
|
|
|
+ <div class="voice-profile-modal-body">
|
|
|
+ <div class="voice-profile-status ${voice.enrolled ? 'ready' : ''}">
|
|
|
+ <span class="voice-profile-dot"></span>
|
|
|
+ <div><strong>${voice.enrolled ? '声音已初始化' : '尚未初始化声音'}</strong><small>${voice.enrolled ? `${Number(profile.duration || 0).toFixed(1)} 秒 · ${Number(profile.sampleRate || 0) / 1000}kHz · ${escapeHtml(profile.originalName || '参考录音')}` : '支持 5~30 秒音频'}</small></div>
|
|
|
+ </div>
|
|
|
+ <div class="voice-capture-row">
|
|
|
+ <button type="button" class="voice-capture-button" data-voice-record>${voiceIcon('mic')}<span>开始录音</span></button>
|
|
|
+ <button type="button" class="voice-capture-button" data-voice-stop disabled>${voiceIcon('stop')}<span>停止</span></button>
|
|
|
+ <button type="button" class="voice-capture-button" data-voice-upload>${voiceIcon('upload')}<span>选择音频</span></button>
|
|
|
+ <input type="file" accept="audio/*,.m4a" data-voice-file hidden />
|
|
|
+ </div>
|
|
|
+ <div class="voice-selected-file" data-voice-selected>未选择新录音</div>
|
|
|
+ <audio class="voice-profile-preview" data-voice-audio controls hidden></audio>
|
|
|
+ </div>
|
|
|
+ `, `
|
|
|
+ <button type="button" class="btn btn-secondary" data-voice-cancel>取消</button>
|
|
|
+ ${voice.enrolled ? `<button type="button" class="btn btn-secondary voice-delete-button" data-voice-delete>${voiceIcon('trash')}删除声音</button>` : ''}
|
|
|
+ <button type="button" class="btn" data-voice-save disabled>保存声音</button>
|
|
|
+ `, 'voice-profile-modal');
|
|
|
+ const modal = state.modal;
|
|
|
+ const fileInput = modal.querySelector('[data-voice-file]');
|
|
|
+ const recordButton = modal.querySelector('[data-voice-record]');
|
|
|
+ const stopButton = modal.querySelector('[data-voice-stop]');
|
|
|
+ const uploadButton = modal.querySelector('[data-voice-upload]');
|
|
|
+ const saveButton = modal.querySelector('[data-voice-save]');
|
|
|
+ const selectedLabel = modal.querySelector('[data-voice-selected]');
|
|
|
+ const audio = modal.querySelector('[data-voice-audio]');
|
|
|
+ let selectedFile = null;
|
|
|
+ let stream = null;
|
|
|
+ let recorder = null;
|
|
|
+ let chunks = [];
|
|
|
+ let previewUrl = '';
|
|
|
+
|
|
|
+ const cleanupMedia = () => {
|
|
|
+ if (recorder?.state === 'recording') recorder.stop();
|
|
|
+ stream?.getTracks().forEach(track => track.stop());
|
|
|
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
|
+ };
|
|
|
+ const originalCleanup = modal.__modalCleanup;
|
|
|
+ modal.__modalCleanup = () => { originalCleanup?.(); cleanupMedia(); };
|
|
|
+
|
|
|
+ const selectFile = file => {
|
|
|
+ if (!file) return;
|
|
|
+ selectedFile = file;
|
|
|
+ if (previewUrl) URL.revokeObjectURL(previewUrl);
|
|
|
+ previewUrl = URL.createObjectURL(file);
|
|
|
+ audio.src = previewUrl;
|
|
|
+ audio.hidden = false;
|
|
|
+ selectedLabel.textContent = `${file.name} · ${(file.size / 1024).toFixed(0)}KB`;
|
|
|
+ saveButton.disabled = false;
|
|
|
+ };
|
|
|
+
|
|
|
+ uploadButton.addEventListener('click', () => fileInput.click());
|
|
|
+ fileInput.addEventListener('change', () => selectFile(fileInput.files?.[0]));
|
|
|
+ if (!navigator.mediaDevices?.getUserMedia || typeof MediaRecorder === 'undefined') {
|
|
|
+ recordButton.disabled = true;
|
|
|
+ recordButton.title = '当前浏览器不支持录音';
|
|
|
+ }
|
|
|
+ recordButton.addEventListener('click', async () => {
|
|
|
+ try {
|
|
|
+ stream = await navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, echoCancellation: true, noiseSuppression: true } });
|
|
|
+ const preferred = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4'].find(type => MediaRecorder.isTypeSupported(type));
|
|
|
+ recorder = preferred ? new MediaRecorder(stream, { mimeType: preferred }) : new MediaRecorder(stream);
|
|
|
+ chunks = [];
|
|
|
+ recorder.ondataavailable = event => { if (event.data.size) chunks.push(event.data); };
|
|
|
+ recorder.onstop = () => {
|
|
|
+ const type = recorder.mimeType || 'audio/webm';
|
|
|
+ const ext = type.includes('mp4') ? 'm4a' : 'webm';
|
|
|
+ selectFile(new File(chunks, `voice-reference.${ext}`, { type }));
|
|
|
+ stream?.getTracks().forEach(track => track.stop());
|
|
|
+ stream = null;
|
|
|
+ recordButton.disabled = false;
|
|
|
+ stopButton.disabled = true;
|
|
|
+ recordButton.classList.remove('recording');
|
|
|
+ };
|
|
|
+ recorder.start();
|
|
|
+ recordButton.disabled = true;
|
|
|
+ stopButton.disabled = false;
|
|
|
+ recordButton.classList.add('recording');
|
|
|
+ selectedLabel.textContent = '正在录音…';
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '无法使用麦克风', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ stopButton.addEventListener('click', () => recorder?.state === 'recording' && recorder.stop());
|
|
|
+ modal.querySelector('[data-voice-cancel]').addEventListener('click', closeModal);
|
|
|
+ modal.querySelector('[data-voice-delete]')?.addEventListener('click', async event => {
|
|
|
+ if (!await confirmModal('确认删除当前账号保存的声音档案?', { title: '删除声音', confirmText: '确认删除', warning: '删除后无法继续生成克隆语音。', danger: true })) return;
|
|
|
+ event.currentTarget.disabled = true;
|
|
|
+ try {
|
|
|
+ const result = await api('DELETE', '/api/agent/voice/profile');
|
|
|
+ state.agent.status.voice = { ...voice, ...(result.data || {}), enrolled: false, profile: null };
|
|
|
+ closeModal();
|
|
|
+ toast(result.assistantMessage || '声音档案已删除');
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ } catch (error) {
|
|
|
+ event.currentTarget.disabled = false;
|
|
|
+ toast(error.message || '删除声音失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ saveButton.addEventListener('click', async () => {
|
|
|
+ if (!selectedFile) return;
|
|
|
+ saveButton.disabled = true;
|
|
|
+ saveButton.textContent = '正在处理…';
|
|
|
+ try {
|
|
|
+ const result = await api('POST', '/api/agent/voice/profile', {
|
|
|
+ name: selectedFile.name,
|
|
|
+ type: selectedFile.type,
|
|
|
+ data: await fileToBase64(selectedFile),
|
|
|
+ }, 120000);
|
|
|
+ state.agent.status.voice = result.data || {};
|
|
|
+ closeModal();
|
|
|
+ toast(result.assistantMessage || '声音已初始化');
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ } catch (error) {
|
|
|
+ saveButton.disabled = false;
|
|
|
+ saveButton.textContent = '保存声音';
|
|
|
+ toast(error.message || '声音初始化失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function voiceComposerToolbar({ selected, content, draftId, disabled }) {
|
|
|
+ const voice = state.agent.status?.voice || {};
|
|
|
+ const tones = voice.tones || [];
|
|
|
+ const profileLabel = voice.enrolled ? '声音设置' : '初始化声音';
|
|
|
+ const voiceContentDisabled = disabled || !voice.enrolled || !content;
|
|
|
+ const sendDisabled = voiceContentDisabled || !voice.sendConfigured;
|
|
|
+ return `<div class="voice-composer-toolbar">
|
|
|
+ <button type="button" class="voice-profile-button" data-agent-action="voice-profile" data-id="${escapeHtml(selected?.id || '')}">${voiceIcon('mic')}<span>${profileLabel}</span><i class="${voice.enrolled ? 'ready' : ''}"></i></button>
|
|
|
+ <label class="voice-tone-select"><span>语气</span><select id="agent-voice-tone" aria-label="语音语气" ${disabled ? 'disabled' : ''}><option value="auto" ${state.agent.voiceTone === 'auto' ? 'selected' : ''}>自动识别</option>${tones.map(item => `<option value="${escapeHtml(item.id)}" ${state.agent.voiceTone === item.id ? 'selected' : ''}>${escapeHtml(item.label)}</option>`).join('')}</select></label>
|
|
|
+ <div class="voice-composer-actions">
|
|
|
+ <button type="button" class="btn voice-action-button" data-agent-action="voice-send" data-id="${escapeHtml(selected?.id || '')}" data-draft-id="${escapeHtml(draftId || '')}" ${sendDisabled ? 'disabled' : ''} ${!voice.sendConfigured ? 'title="媒体上传未配置"' : ''}>${voiceIcon('mic')}语音发送</button>
|
|
|
+ </div>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentErrorPresentation(error = {}) {
|
|
|
+ return ({
|
|
|
+ quota_exhausted: { title: '模型额度不足', next: '恢复额度后,可点击“初始化 Session”或“让 Agent 处理”重试。' },
|
|
|
+ task_budget_exceeded: { title: '单次任务预算不足', next: '请提高 Claude Code 单次任务预算后重试。' },
|
|
|
+ authentication_failed: { title: 'Claude Code 登录失效', next: '请重新登录或检查模型凭据后重试。' },
|
|
|
+ permission_denied: { title: 'Claude Code 权限不足', next: '请检查当前账号与模型权限。' },
|
|
|
+ not_configured: { title: 'Claude Code 尚未配置', next: '请先在“连接与设置”中完成模型配置。' },
|
|
|
+ cli_not_found: { title: '未找到 Claude Code CLI', next: '请先完成 CLI 安装或配置可执行文件路径。' },
|
|
|
+ timeout: { title: 'Claude Code 运行超时', next: '请稍后重试,必要时缩短当前会话上下文。' },
|
|
|
+ network_error: { title: 'Claude Code 网络异常', next: '请检查网络连接后重试。' },
|
|
|
+ })[error.code] || { title: 'Claude Code 暂时不可用', next: '请稍后重试;详细原因已记录在审计日志中。' };
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentIntakeSettings() {
|
|
|
+ const policy = state.agent.intakePolicy || {};
|
|
|
+ const mode = policy.mode || 'allowlist_only';
|
|
|
+ const sendMode = policy.welcomeSendMode || 'draft';
|
|
|
+ return `
|
|
|
+ <form class="agent-intake-form" data-agent-intake-form>
|
|
|
+ <div class="agent-drawer-section-heading"><strong>个人消息接入</strong><span>未知群聊始终不会自动纳入</span></div>
|
|
|
+ ${state.agent.intakePolicyError ? `<div class="agent-drawer-error">${escapeHtml(state.agent.intakePolicyError)}</div>` : ''}
|
|
|
+ <label class="agent-field"><span>接入策略</span><select name="mode">
|
|
|
+ <option value="allowlist_only" ${mode === 'allowlist_only' ? 'selected' : ''}>仅白名单(默认)</option>
|
|
|
+ <option value="auto_enroll_review" ${mode === 'auto_enroll_review' ? 'selected' : ''}>新私聊自动纳入 · 强制待审核</option>
|
|
|
+ <option value="auto_enroll_autopilot" ${mode === 'auto_enroll_autopilot' ? 'selected' : ''}>新私聊自动纳入 · 受控自动</option>
|
|
|
+ </select></label>
|
|
|
+ <label class="agent-toggle-field"><input type="checkbox" name="welcomeEnabled" ${policy.welcomeEnabled ? 'checked' : ''}><span><strong>启用欢迎语</strong><small>默认只生成草稿,避免未经审核外发</small></span></label>
|
|
|
+ <label class="agent-field"><span>欢迎语</span><textarea name="welcomeText" rows="4" placeholder="输入首次联系时的欢迎内容">${escapeHtml(policy.welcomeText || '')}</textarea></label>
|
|
|
+ <label class="agent-field"><span>欢迎语处理</span><select name="welcomeSendMode">
|
|
|
+ <option value="draft" ${sendMode === 'draft' ? 'selected' : ''}>生成待审草稿</option>
|
|
|
+ <option value="send" ${sendMode === 'send' ? 'selected' : ''}>直接发送(仅受控自动策略)</option>
|
|
|
+ </select></label>
|
|
|
+ <div class="agent-policy-warning ${mode === 'auto_enroll_autopilot' ? 'danger' : ''}">${mode === 'auto_enroll_autopilot' ? '受控自动接入会把新私聊加入白名单并可直接发送欢迎语;后续回复仍受置信度与风险门禁。保存时必须再次确认。' : '通用默认仅处理人工选择的白名单联系人;待审核模式不会自动外发。'}</div>
|
|
|
+ <button class="btn" type="submit" ${state.agent.intakePolicyLoading ? 'disabled' : ''}>${state.agent.intakePolicyLoading ? '正在保存…' : '保存接入设置'}</button>
|
|
|
+ </form>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function agentReminderForConversation(conversationId, monitor = {}) {
|
|
|
+ const id = String(conversationId || '');
|
|
|
+ return (monitor.reminders || []).find(item => String(item.conversationId || item.id || '') === id) || null;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentWorkspaceV2(page) {
|
|
|
+ const insightScroll = captureAgentInsightScroll(page);
|
|
|
+ const workspaceScroll = captureAgentWorkspaceScroll(page);
|
|
|
+ const status = state.agent.status || {};
|
|
|
+ const listener = status.listener || {};
|
|
|
+ const ingress = status.ingress || {};
|
|
|
+ const account = status.account || {};
|
|
|
+ const config = status.config || {};
|
|
|
+ const product = status.product || { mode: 'personal', label: '个人版', collectionLabel: '本地主动监听', relayReady: false };
|
|
|
+ const accountName = currentAccount()?.nickname || account.nickname || account.userId || '当前企微账号';
|
|
|
+ const global = status.global || {};
|
|
|
+ const globalPaused = global.paused !== undefined ? Boolean(global.paused) : status.globalMode === 'paused';
|
|
|
+ const globalMode = global.defaultMode || (status.globalMode === 'auto' ? 'auto' : 'review');
|
|
|
+ const listenerRunning = Boolean(listener.running);
|
|
|
+ const aiListenerActive = listenerRunning && !globalPaused;
|
|
|
+ const messageIngressRunning = product.mode === 'enterprise' ? Boolean(ingress.running) : listenerRunning;
|
|
|
+ const allowlistCount = Number(config.allowedSenderCount) || 0;
|
|
|
+ const allowlistReady = allowlistCount > 0;
|
|
|
+ const agent = status.agent || {};
|
|
|
+ const knowledge = status.knowledge || {};
|
|
|
+ const conversations = state.agent.conversations || [];
|
|
|
+ const monitor = state.agent.responseMonitor || {};
|
|
|
+ const monitorSummary = monitor.summary || {};
|
|
|
+ const privateConversations = sortAgentItemsByLatest(conversations.filter(item => item.channelType !== 'group'));
|
|
|
+ const groupAgents = [];
|
|
|
+ const groupConversations = [];
|
|
|
+ const pendingReplyCount = privateConversations.filter(item => item.pendingReply).length;
|
|
|
+ const reminderCount = (monitor.reminders || []).length;
|
|
|
+ const overdueCount = Number(monitorSummary.overdue) || (monitor.reminders || []).filter(item => item.severity === 'urgent').length;
|
|
|
+ const listMode = 'private';
|
|
|
+ const selected = privateConversations.find(item => item.id === state.agent.selectedId) || privateConversations[0] || null;
|
|
|
+ const selectedGroup = null;
|
|
|
+ if (selected) state.agent.selectedId = selected.id;
|
|
|
+ if (selectedGroup) state.agent.selectedGroupId = selectedGroup.id;
|
|
|
+ const sessionGuide = selected?.claudeSession || null;
|
|
|
+ const groupPending = selectedGroup?.pendingReply || null;
|
|
|
+ const groupReplyEditKey = selectedGroup && groupPending ? `${selectedGroup.id}:${groupPending.id}` : '';
|
|
|
+ const hasGroupReplyEdit = Boolean(groupReplyEditKey && Object.prototype.hasOwnProperty.call(state.agent.groupReplyEdits, groupReplyEditKey));
|
|
|
+ const groupReplyText = hasGroupReplyEdit ? state.agent.groupReplyEdits[groupReplyEditKey] : (groupPending?.content || '');
|
|
|
+
|
|
|
+ const analysis = selected?.analysis || {};
|
|
|
+ const customerIntelligence = selected?.customerIntelligence || {};
|
|
|
+ const customerTasks = customerIntelligence.tasks || [];
|
|
|
+ const customerAlerts = customerIntelligence.alerts || [];
|
|
|
+ const intelligenceSummary = customerIntelligence.summary || {};
|
|
|
+ const demands = demandItems(analysis.demand || {});
|
|
|
+ const pending = selected?.pendingReply || null;
|
|
|
+ const noReplyNotice = selected?.agentNotice || null;
|
|
|
+ const activeDraft = pending;
|
|
|
+ const manualActive = selected?.mode === 'human';
|
|
|
+ const conversationPaused = selected?.mode === 'paused';
|
|
|
+ const replyEditKey = selected ? `${selected.id}:${pending?.id || (manualActive ? 'manual' : 'composer')}` : '';
|
|
|
+ const hasReplyEdit = Boolean(replyEditKey && Object.prototype.hasOwnProperty.call(state.agent.replyEdits, replyEditKey));
|
|
|
+ const pendingText = hasReplyEdit
|
|
|
+ ? state.agent.replyEdits[replyEditKey]
|
|
|
+ : (pending?.content || '');
|
|
|
+ const audit = selected?.audit || [];
|
|
|
+ const toolTrace = activeDraft?.toolTrace || activeDraft?.tool_trace || [];
|
|
|
+ const citations = activeDraft?.citations || [];
|
|
|
+
|
|
|
+ els.accountName.textContent = accountName;
|
|
|
+ els.accountDot.className = `account-dot ${account.online ? 'online' : 'offline'}`;
|
|
|
+ page.dataset.theme = String(config.theme || status.tenant?.theme || 'fmode').toLowerCase() === 'xiaoniu' ? 'xiaoniu' : 'fmode';
|
|
|
+
|
|
|
+ const nextHtml = `
|
|
|
+ <header class="agent-commandbar">
|
|
|
+ <div class="agent-command-account"><span class="agent-live-dot ${account.online ? 'online' : ''}"></span><div><strong>${escapeHtml(accountName)}</strong><small>${account.online ? '账号在线' : '账号离线'} · ${messageIngressRunning ? '监听运行中' : '监听待恢复'}</small></div></div>
|
|
|
+ <div class="agent-mode-switch">
|
|
|
+ <button class="agent-mode-btn ${!globalPaused && globalMode === 'review' ? 'active' : ''}" data-agent-mode="review">待审核</button>
|
|
|
+ <button class="agent-mode-btn ${!globalPaused && globalMode === 'auto' ? 'active' : ''}" data-agent-mode="auto">高置信自动</button>
|
|
|
+ <button class="agent-mode-btn ${globalPaused ? 'active danger' : ''}" data-agent-mode="paused">全局暂停</button>
|
|
|
+ </div>
|
|
|
+ <div class="agent-command-kpis" aria-label="工作台摘要"><span><b>${allowlistCount}</b> 白名单</span><span class="${pendingReplyCount ? 'attention' : ''}"><b>${pendingReplyCount}</b> 待审核</span><span class="${overdueCount ? 'danger' : ''}"><b>${reminderCount}</b> 待回复</span></div>
|
|
|
+ <div class="agent-command-actions">
|
|
|
+ <button class="btn btn-secondary" data-agent-action="manage-allowlist">白名单</button>
|
|
|
+ <button class="btn btn-secondary" data-agent-action="sync-conversations" ${account.online && allowlistReady ? '' : 'disabled'}>补采全部</button>
|
|
|
+ ${product.mode === 'personal' ? `<button class="btn ${aiListenerActive ? 'btn-secondary' : ''}" data-agent-action="${allowlistReady ? (aiListenerActive ? 'stop-listener' : 'start-listener') : 'manage-allowlist'}" ${account.online ? '' : 'disabled'}>${allowlistReady ? (aiListenerActive ? '关闭监听' : '启动监听') : '选择白名单'}</button>` : ''}
|
|
|
+ <button class="btn btn-secondary" data-agent-drawer="settings" aria-expanded="${state.agent.openDrawer === 'settings'}">设置</button>
|
|
|
+ <button class="btn btn-secondary ${reminderCount ? 'has-alert' : ''}" data-agent-drawer="reminders" aria-expanded="${state.agent.openDrawer === 'reminders'}">提醒${reminderCount ? ` ${reminderCount}` : ''}</button>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+
|
|
|
+ ${selected?.agentError
|
|
|
+ ? (() => { const errorUi = agentErrorPresentation(selected.agentError); return `<div class="agent-runtime-alert"><strong>${escapeHtml(errorUi.title)}</strong><span>${escapeHtml(selected.agentError.message)} ${escapeHtml(errorUi.next)} 真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`; })()
|
|
|
+ : noReplyNotice
|
|
|
+ ? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
|
|
|
+ : ''}
|
|
|
+ ${selected?.onboarding?.state === 'failed'
|
|
|
+ ? `<div class="agent-runtime-alert"><strong>欢迎语发送失败</strong><span>${escapeHtml(selected.onboarding.lastError || '可在确认企微连接后重试。')}</span><button class="btn btn-sm" data-agent-action="retry-welcome" data-id="${escapeHtml(selected.id)}">重试欢迎语</button></div>`
|
|
|
+ : selected?.onboarding?.state === 'delivery_unknown'
|
|
|
+ ? '<div class="agent-runtime-alert notice"><strong>欢迎语发送结果待确认</strong><span>为避免重复打扰,系统不会自动重发,请先在企微聊天中核对。</span></div>'
|
|
|
+ : ''}
|
|
|
+
|
|
|
+ <section class="agent-workspace">
|
|
|
+ <aside class="agent-conversation-list">
|
|
|
+ <div class="agent-panel-heading"><div><strong>消息入口</strong><small>仅展示白名单客户私聊</small></div><span class="agent-count">${privateConversations.length}</span></div>
|
|
|
+ <div class="agent-list-tabs" role="tablist" aria-label="消息类型">
|
|
|
+ <button class="active" role="tab" aria-selected="true" data-agent-list-mode="private"><span>客户私聊</span><b>${privateConversations.length}</b></button>
|
|
|
+ </div>
|
|
|
+ <div class="agent-list-scroll">
|
|
|
+ ${listMode === 'private' && privateConversations.length ? privateConversations.map(item => {
|
|
|
+ const last = [...(item.messages || [])].reverse().find(message => message.role === 'customer') || item.messages?.[item.messages.length - 1];
|
|
|
+ const reminder = agentReminderForConversation(item.id, monitor);
|
|
|
+ const reminderStatus = reminder?.severity === 'urgent' || reminder?.status === 'overdue' ? 'overdue' : reminder ? 'warning' : item.pendingReply ? 'pending' : '';
|
|
|
+ const reminderLabel = reminderStatus === 'overdue' ? '已超时' : reminderStatus === 'warning' ? '待回复' : reminderStatus === 'pending' ? '待审核' : '';
|
|
|
+ return `<button class="agent-conversation-item ${item.id === selected?.id ? 'active' : ''}" data-conversation-id="${item.id}">
|
|
|
+ <span class="agent-avatar">${escapeHtml((item.displayName || '客').slice(0, 1))}</span>
|
|
|
+ <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.displayName)}</strong><i class="agent-channel-badge private">私聊</i></span><small>${escapeHtml(last?.content || '等待新消息')}</small><em>${agentModeLabel(item.mode)}${item.pendingReply ? ' · 有待审草稿' : ''}${item.customerIntelligence?.summary?.openTasks ? ` · ${item.customerIntelligence.summary.openTasks}待办` : ''}${item.customerIntelligence?.summary?.highAlerts ? ` · ${item.customerIntelligence.summary.highAlerts}预警` : ''}</em></span>
|
|
|
+ <span class="agent-conversation-meta"><time>${formatAgentTime(item.lastMessageAt)}</time>${reminderLabel ? `<i class="${reminderStatus}">${reminderLabel}</i>` : ''}</span>
|
|
|
+ </button>`;
|
|
|
+ }).join('') : listMode === 'group' && groupConversations.length ? groupConversations.map(item => `<button class="agent-conversation-item group ${item.id === selectedGroup?.id ? 'active' : ''}" data-group-monitor-id="${escapeHtml(item.id)}">
|
|
|
+ <span class="agent-avatar group">群</span>
|
|
|
+ <span class="agent-conversation-copy"><span class="agent-conversation-name"><strong>${escapeHtml(item.name || '客户群')}</strong><i class="agent-channel-badge group">群聊</i></span><small>${escapeHtml(item.lastMessagePreview || '等待群回调新消息')}</small><em class="${escapeHtml(item.status || '')}">${item.mode === 'auto' ? '全自动' : item.pendingReply ? '草稿待审核' : item.agentError ? 'Agent 生成失败' : escapeHtml(agentMonitorStatusLabel(item.status))}${item.waitingMinutes ? ` · ${formatReplyWaiting(item.waitingMinutes)}` : ''}</em></span>
|
|
|
+ <time>${formatAgentTime(item.lastMessageAt)}</time>
|
|
|
+ </button>`).join('') : `<div class="agent-empty"><strong>${listMode === 'group' ? '还没有已确认客户群' : allowlistReady ? '等待第一条真实消息' : '还没有选择白名单'}</strong><span>${listMode === 'group' ? '先在客户群管理中确认需要智能回复的客户群。' : allowlistReady ? (product.mode === 'enterprise' ? '公网回调会持续接收消息,请从已选企微客户发送测试内容。' : '启动监听后,请从已选企微客户发送测试内容。') : '点击上方“管理白名单”,直接从企微联系人中选择。'}</span></div>`}
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+
|
|
|
+ <main class="agent-chat-panel ${selectedGroup ? 'group-mode' : ''}" tabindex="-1">
|
|
|
+ ${selectedGroup ? `
|
|
|
+ <header class="agent-chat-header group">
|
|
|
+ <div><strong>${escapeHtml(selectedGroup.name || '客户群')}</strong><span>客户群聊 · ${selectedGroup.mode === 'auto' ? '全自动(忽略风险)' : 'Agent 草稿 · 人工审核发送'}</span></div>
|
|
|
+ <div class="agent-conversation-modes group-modes">
|
|
|
+ <button class="btn btn-sm ${selectedGroup.mode === 'auto' ? 'btn-secondary' : ''}" data-agent-group-mode="review" data-room-id="${escapeHtml(selectedGroup.id)}">人工审核</button>
|
|
|
+ <button class="btn btn-sm agent-group-mode-danger ${selectedGroup.mode === 'auto' ? 'active' : 'btn-secondary'}" data-agent-group-mode="auto" data-room-id="${escapeHtml(selectedGroup.id)}">无视风险 · 全自动</button>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+ ${renderAgentFlow(groupConversationFlow(selectedGroup), 'group')}
|
|
|
+ <div class="agent-message-stream group">${selectedGroup.messages?.length ? selectedGroup.messages.map(renderAgentMessage).join('') : '<div class="agent-empty"><strong>等待群消息</strong><span>同步群消息后,Agent 可以生成待审核回复。</span></div>'}</div>
|
|
|
+ <div class="agent-composer group">
|
|
|
+ <div class="agent-composer-label"><strong>${groupPending ? (selectedGroup.sendError ? '全自动发送失败 · 草稿已保留' : '群聊 Agent 待审核草稿') : selectedGroup.agentNotice ? 'Agent 已处理' : selectedGroup.agentError ? 'Agent 生成失败' : selectedGroup.mode === 'auto' ? '群聊全自动回复' : '群聊智能回复'}</strong><span data-agent-group-edit-status>${groupPending ? (hasGroupReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((groupPending.confidence || 0) * 100)}% · ${selectedGroup.mode === 'auto' ? '自动发送未完成,可人工处理' : '必须人工审核'}`) : selectedGroup.agentNotice ? '最新群消息无需回复' : selectedGroup.agentError ? '可重新生成或直接在企微回复' : selectedGroup.mode === 'auto' ? '忽略置信度、敏感判断和人工风险标记,生成后直接发送' : 'Agent 只生成草稿,不会自动外发'}</span></div>
|
|
|
+ <textarea id="agent-group-reply-editor" data-agent-group-edit-key="${escapeHtml(groupReplyEditKey)}" placeholder="${groupPending ? '可先编辑 Agent 群聊草稿…' : '点击下方按钮,让 Agent 分析最近一条群消息'}">${escapeHtml(groupReplyText)}</textarea>
|
|
|
+ <div class="agent-composer-actions">
|
|
|
+ <span>${groupPending ? escapeHtml(groupPending.reason || '请核对群成员、事实与表达后再发送') : `回复时效:${selectedGroup.waitingMinutes ? formatReplyWaiting(selectedGroup.waitingMinutes) : agentMonitorStatusLabel(selectedGroup.status)}`}</span>
|
|
|
+ <div class="agent-review-actions">
|
|
|
+ ${groupPending ? `<button class="btn btn-secondary" data-agent-action="group-reject" data-room-id="${escapeHtml(selectedGroup.id)}" data-draft-id="${escapeHtml(groupPending.id)}">驳回</button><button class="btn btn-secondary" data-agent-action="group-regenerate" data-room-id="${escapeHtml(selectedGroup.id)}" data-draft-id="${escapeHtml(groupPending.id)}">重新生成</button><button class="btn" data-agent-action="group-approve" data-room-id="${escapeHtml(selectedGroup.id)}" data-draft-id="${escapeHtml(groupPending.id)}">批准并发送到群</button>` : `<button class="btn ${selectedGroup.mode === 'auto' ? 'agent-group-auto-send' : ''}" data-agent-action="group-generate" data-room-id="${escapeHtml(selectedGroup.id)}">${selectedGroup.mode === 'auto' ? '立即生成并自动发送' : '让 Agent 生成草稿'}</button>`}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ` : selected ? `
|
|
|
+ <header class="agent-chat-header">
|
|
|
+ <div><strong>${escapeHtml(selected.displayName)}</strong><span>${escapeHtml(selected.maskedId)} · 真实企微会话 · ${agentModeLabel(selected.mode)}</span></div>
|
|
|
+ <div class="agent-conversation-modes">
|
|
|
+ <button class="btn btn-sm btn-secondary" data-agent-action="sync-current-conversation" data-id="${selected.id}" ${account.online ? '' : 'disabled'}>采集当前会话</button>
|
|
|
+ <button class="btn btn-sm ${selected.mode === 'review' ? '' : 'btn-secondary'}" data-agent-conversation-mode="review" data-id="${selected.id}">待审核</button>
|
|
|
+ <button class="btn btn-sm ${selected.mode === 'auto' ? '' : 'btn-secondary'}" data-agent-conversation-mode="auto" data-id="${selected.id}">自动</button>
|
|
|
+ <button class="btn btn-sm ${selected.mode === 'human' ? '' : 'btn-secondary'}" data-agent-conversation-mode="human" data-id="${selected.id}">人工接管</button>
|
|
|
+ <button class="btn btn-sm ${selected.mode === 'paused' ? '' : 'btn-secondary'}" data-agent-conversation-mode="paused" data-id="${selected.id}">暂停</button>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+ ${renderAgentFlow(privateConversationFlow(selected), 'private')}
|
|
|
+ <div class="agent-session-strip ${sessionGuide?.ready ? 'ready' : ''}">
|
|
|
+ <span class="agent-session-dot"></span>
|
|
|
+ <div><strong>${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'}</strong><small>${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}</small></div>
|
|
|
+ <button class="btn btn-sm btn-secondary agent-session-action" data-agent-action="session-guide" data-id="${selected.id}">${sessionGuide?.ready ? '查看会话与打开方式' : '初始化 Session'}</button>
|
|
|
+ </div>
|
|
|
+ <div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
|
|
|
+ <div class="agent-composer ${manualActive ? 'manual' : ''}">
|
|
|
+ <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span data-agent-edit-status>${pending ? (hasReplyEdit ? '已人工修改 · 批准后发送当前内容' : `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核,可直接编辑' : '可直接编辑后发送'}`) : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
|
|
|
+ <textarea id="agent-reply-editor" data-agent-edit-key="${escapeHtml(replyEditKey)}" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
|
|
|
+ ${voiceComposerToolbar({ selected, content: pendingText, draftId: pending?.id, disabled: conversationPaused })}
|
|
|
+ <div class="agent-composer-actions">
|
|
|
+ <span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
|
|
|
+ <div class="agent-review-actions">
|
|
|
+ ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">${noReplyNotice ? '重新处理' : '让 Agent 处理'}</button>`}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ` : `<div class="agent-empty large"><strong>真实 Agent 会话准备就绪</strong><span>开始接收后,请从已选白名单客户的企微发送测试消息。</span></div>`}
|
|
|
+ </main>
|
|
|
+
|
|
|
+ <aside class="agent-insight-panel" data-conversation-id="${escapeHtml(selected?.id || selectedGroup?.id || '')}">
|
|
|
+ ${selectedGroup ? `
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>01</span><div><strong>智能回复状态</strong><small>群聊独立上下文</small></div></div>
|
|
|
+ <div class="agent-intent-card group ${selectedGroup.mode === 'auto' ? 'auto' : ''}"><strong>${selectedGroup.mode === 'auto' ? '全自动 · 忽略风险' : groupPending ? '草稿待审核' : selectedGroup.agentError ? '生成失败' : selectedGroup.agentNotice ? '无需回复' : '等待 Agent'}</strong><span>${escapeHtml(agentMonitorStatusLabel(selectedGroup.status))}</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>02</span><div><strong>回复时效</strong><small>本地计时与提醒</small></div></div>
|
|
|
+ <div class="agent-group-sla"><strong>${selectedGroup.waitingMinutes ? formatReplyWaiting(selectedGroup.waitingMinutes) : '未开始计时'}</strong><span>${selectedGroup.status === 'overdue' ? '已超过紧急提醒阈值' : selectedGroup.status === 'warning' ? '已进入提醒区间' : '当前未超时'}</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>03</span><div><strong>处理边界</strong><small>不会串入客户私聊</small></div></div>
|
|
|
+ <ul class="agent-boundary-list ${selectedGroup.mode === 'auto' ? 'danger' : ''}">${selectedGroup.mode === 'auto' ? '<li>忽略置信度、敏感判断和人工风险标记</li><li>Agent 返回非空内容后立即真实群发</li><li>模型或发送失败时停止并保留草稿</li><li>不读取其他客户私聊与画像</li>' : '<li>已确认客户群自动生成回复草稿</li><li>每条草稿必须人工编辑或审核</li><li>不读取其他客户私聊与画像</li><li>确认后才通过 Fmode 网关真实群发</li>'}</ul>
|
|
|
+ </div>
|
|
|
+ ` : selected ? `
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
|
|
|
+ <div class="agent-intent-card"><strong>${escapeHtml(analysis.intentLabel || analysis.intent || '待识别')}</strong><span>画像完整度 ${Math.round(analysis.completenessScore || 0)}%</span><div><i style="width:${Math.min(100, analysis.completenessScore || 0)}%"></i></div></div>
|
|
|
+ <p class="agent-reasoning">${escapeHtml(analysis.reasoning || '等待新消息进入。')}</p>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>02</span><div><strong>需求画像</strong><small>从多轮会话持续归集</small></div></div>
|
|
|
+ <div class="agent-demand-grid">${demands.length ? demands.map(([label, value]) => `<div><small>${escapeHtml(label)}</small><strong>${escapeHtml(value)}</strong></div>`).join('') : '<p class="agent-muted">暂无明确需求</p>'}</div>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>03</span><div><strong>客户待办</strong><small>${intelligenceSummary.openTasks || 0} 项待处理 · 仅内部建议</small></div></div>
|
|
|
+ <div class="agent-customer-task-list">${customerTasks.length ? customerTasks.slice(0, 8).map(renderAgentCustomerTask).join('') : '<p class="agent-muted">继续对话后,Agent 会从明确需求中生成内部跟进待办</p>'}</div>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>04</span><div><strong>客户预警</strong><small>${intelligenceSummary.openAlerts || 0} 项未处理 · ${intelligenceSummary.highAlerts || 0} 项高优先级</small></div></div>
|
|
|
+ <div class="agent-customer-alert-list">${customerAlerts.length ? customerAlerts.slice(0, 6).map(alert => `<div class="agent-customer-alert ${escapeHtml(alert.severity)} ${escapeHtml(alert.status)}"><div class="agent-alert-heading"><strong>${escapeHtml(alert.title)}</strong><em>${escapeHtml(alert.severity)}</em></div><p>${escapeHtml(alert.detail || '')}</p><small>依据:${escapeHtml(alert.evidence || '待补充')}</small><div class="agent-intelligence-actions">${alert.status === 'open' ? `<button class="btn btn-sm btn-secondary" data-agent-action="customer-alert-dismiss" data-id="${alert.id}">忽略</button><button class="btn btn-sm" data-agent-action="customer-alert-ack" data-id="${alert.id}">已知悉</button>` : `<span>${alert.status === 'acknowledged' ? '已知悉' : alert.status === 'resolved' ? '已解决' : '已忽略'}</span>`}</div></div>`).join('') : '<p class="agent-muted">当前没有高意向、投诉、时效或矛盾预警</p>'}</div>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section knowledge">
|
|
|
+ <div class="agent-section-title"><span>05</span><div><strong>依据与引用</strong><small>${knowledge.knowledgeChunks || 0} 个知识块 · 通用知识库</small></div></div>
|
|
|
+ <ul>${(citations.length ? citations.map(item => `${item.heading || item.source} · ${item.source || ''}`) : analysis.knowledgeSources || []).map(source => `<li>${escapeHtml(source)}</li>`).join('')}</ul>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>06</span><div><strong>活动与工具轨迹</strong><small>Plan / Act / Observe / Evaluate</small></div></div>
|
|
|
+ <details class="agent-activity-disclosure" data-agent-activity ${state.agent.activityOpen ? 'open' : ''}><summary>查看执行证据与脱敏参数</summary>${renderAgentActivityTimeline({ toolTrace, audit })}</details>
|
|
|
+ </div>
|
|
|
+ <div class="agent-insight-section">
|
|
|
+ <div class="agent-section-title"><span>07</span><div><strong>审计日志</strong><small>接收、决策、审核和发送全留痕</small></div></div>
|
|
|
+ <div class="agent-audit-list">${audit.length ? audit.slice(0, 10).map(item => `<div><span>${escapeHtml(agentAuditLabel(item.action))}</span><time>${formatAgentTime(item.created_at)}</time></div>`).join('') : '<p class="agent-muted">暂无审计记录</p>'}</div>
|
|
|
+ </div>
|
|
|
+ ` : '<div class="agent-empty"><strong>暂无分析</strong><span>选择会话后显示 Agent 工作过程。</span></div>'}
|
|
|
+ </aside>
|
|
|
+ </section>
|
|
|
+
|
|
|
+ <div class="agent-drawer-backdrop ${state.agent.openDrawer ? 'open' : ''}" data-agent-drawer-close></div>
|
|
|
+ <aside class="agent-drawer settings ${state.agent.openDrawer === 'settings' ? 'open' : ''}" aria-hidden="${state.agent.openDrawer !== 'settings'}" aria-label="Agent 设置">
|
|
|
+ <header><div><strong>Agent 设置</strong><span>接入、欢迎语与发送边界</span></div><button type="button" data-agent-drawer-close>关闭</button></header>
|
|
|
+ <div class="agent-drawer-body">
|
|
|
+ <section class="agent-connection-summary">
|
|
|
+ <div><span class="agent-live-dot ${account.online ? 'online' : ''}"></span><strong>${escapeHtml(accountName)}</strong></div>
|
|
|
+ <dl><div><dt>产品模式</dt><dd>${escapeHtml(product.label)}</dd></div><div><dt>消息接收</dt><dd>${messageIngressRunning ? '运行中' : '待恢复'}</dd></div><div><dt>默认模式</dt><dd>${escapeHtml(agentModeLabel(globalPaused ? 'paused' : globalMode))}</dd></div><div><dt>白名单</dt><dd>${allowlistCount} 人</dd></div></dl>
|
|
|
+ </section>
|
|
|
+ ${renderAgentIntakeSettings()}
|
|
|
+ <section class="agent-drawer-boundary"><strong>当前发送边界</strong><p>未知群聊始终拒绝;自动纳入联系人按当前账号隔离。通用包的受控自动模式仍受置信度与人工风险门禁。</p></section>
|
|
|
+ </div>
|
|
|
+ </aside>
|
|
|
+ <aside class="agent-drawer reminders ${state.agent.openDrawer === 'reminders' ? 'open' : ''}" aria-hidden="${state.agent.openDrawer !== 'reminders'}" aria-label="回复提醒">
|
|
|
+ <header><div><strong>回复提醒</strong><span>${reminderCount ? `${reminderCount} 个会话需要关注` : '当前没有待处理提醒'}</span></div><button type="button" data-agent-drawer-close>关闭</button></header>
|
|
|
+ <div class="agent-drawer-body">${renderResponseMonitor(monitor)}</div>
|
|
|
+ </aside>
|
|
|
+ `;
|
|
|
+ const viewKey = `${listMode}:${selected?.id || selectedGroup?.id || 'empty'}`;
|
|
|
+ const incremental = page.dataset.agentViewKey === viewKey && Boolean(page.querySelector('.agent-workspace'));
|
|
|
+ if (incremental) patchAgentPage(page, nextHtml);
|
|
|
+ else page.innerHTML = nextHtml;
|
|
|
+ page.dataset.agentViewKey = viewKey;
|
|
|
+ restoreAgentInsightScroll(page, insightScroll, selected?.id || selectedGroup?.id);
|
|
|
+ restoreAgentWorkspaceScroll(page, workspaceScroll, viewKey, incremental);
|
|
|
+ }
|
|
|
+
|
|
|
+ function responseMonitorRenderSignature(monitor = {}) {
|
|
|
+ const withoutWaitingMinutes = item => {
|
|
|
+ const { waitingMinutes: _waitingMinutes, ...stableItem } = item || {};
|
|
|
+ return stableItem;
|
|
|
+ };
|
|
|
+ const { checkedAt: _checkedAt, ...stableMonitor } = monitor || {};
|
|
|
+ const scope = monitor?.scope || {};
|
|
|
+ return {
|
|
|
+ ...stableMonitor,
|
|
|
+ scope: {
|
|
|
+ ...scope,
|
|
|
+ customers: (scope.customers || []).map(withoutWaitingMinutes),
|
|
|
+ groups: (scope.groups || []).map(withoutWaitingMinutes),
|
|
|
+ },
|
|
|
+ reminders: (monitor?.reminders || []).map(withoutWaitingMinutes),
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function normalizeAgentIntakePolicy(payload = {}) {
|
|
|
+ const policy = payload.policy || payload;
|
|
|
+ return {
|
|
|
+ mode: ['allowlist_only', 'auto_enroll_review', 'auto_enroll_autopilot'].includes(policy.mode) ? policy.mode : 'allowlist_only',
|
|
|
+ welcomeEnabled: Boolean(policy.welcomeEnabled),
|
|
|
+ welcomeText: String(policy.welcomeText || ''),
|
|
|
+ welcomeSendMode: policy.welcomeSendMode === 'send' ? 'send' : 'draft',
|
|
|
+ effectiveConversationMode: policy.effectiveConversationMode || 'review',
|
|
|
+ onboarding: policy.onboarding || null,
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadAgentIntakePolicy(page, silent = false) {
|
|
|
+ if (state.agent.intakePolicyLoading) return;
|
|
|
+ state.agent.intakePolicyLoading = true;
|
|
|
+ try {
|
|
|
+ const result = await api('GET', '/api/agent/intake-policy');
|
|
|
+ state.agent.intakePolicy = normalizeAgentIntakePolicy(result.data || result);
|
|
|
+ state.agent.intakePolicyLoaded = true;
|
|
|
+ state.agent.intakePolicyError = '';
|
|
|
+ } catch (error) {
|
|
|
+ state.agent.intakePolicyError = error.message || '接入策略读取失败,当前展示安全默认值';
|
|
|
+ if (!silent) toast(state.agent.intakePolicyError, 'error');
|
|
|
+ } finally {
|
|
|
+ state.agent.intakePolicyLoading = false;
|
|
|
+ if (page?.isConnected && state.page === 'agent') renderAgentWorkspaceV2(page);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadAgentData(page, silent = false) {
|
|
|
+ if (state.agent.loading) return;
|
|
|
+ state.agent.loading = true;
|
|
|
+ try {
|
|
|
+ const [statusRes, conversationsRes, responseMonitorRes] = await Promise.all([
|
|
|
+ api('GET', '/api/agent/status'),
|
|
|
+ api('GET', '/api/agent/conversations'),
|
|
|
+ api('GET', '/api/agent/response-monitor'),
|
|
|
+ ]);
|
|
|
+ const nextStatus = statusRes.data || {};
|
|
|
+ const nextConversations = conversationsRes.data?.conversations || [];
|
|
|
+ const rawResponseMonitor = responseMonitorRes.data || {};
|
|
|
+ const nextResponseMonitor = {
|
|
|
+ ...rawResponseMonitor,
|
|
|
+ scope: { ...(rawResponseMonitor.scope || {}), groups: [] },
|
|
|
+ reminders: (rawResponseMonitor.reminders || []).filter(item => item.kind !== 'group'),
|
|
|
+ };
|
|
|
+ const nextGroupAgents = [];
|
|
|
+ const localAccount = currentAccount();
|
|
|
+ const activeAccount = nextStatus.account || {};
|
|
|
+ if (localAccount && localAccount.uid && activeAccount.uid === localAccount.uid) {
|
|
|
+ let accountChanged = false;
|
|
|
+ for (const [key, value] of [
|
|
|
+ ['guid', activeAccount.guid],
|
|
|
+ ['userId', activeAccount.userId],
|
|
|
+ ['corpName', activeAccount.corpName],
|
|
|
+ ]) {
|
|
|
+ if (!localAccount[key] && value) {
|
|
|
+ localAccount[key] = value;
|
|
|
+ accountChanged = true;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!localAccount.nickname && activeAccount.nickname) {
|
|
|
+ localAccount.nickname = activeAccount.nickname;
|
|
|
+ accountChanged = true;
|
|
|
+ }
|
|
|
+ if (localAccount.online !== Boolean(activeAccount.online)) {
|
|
|
+ localAccount.online = Boolean(activeAccount.online);
|
|
|
+ accountChanged = true;
|
|
|
+ }
|
|
|
+ if (accountChanged) {
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ const nextSignature = JSON.stringify({
|
|
|
+ globalMode: nextStatus.globalMode,
|
|
|
+ listenerRunning: Boolean(nextStatus.listener?.running),
|
|
|
+ ingressRunning: Boolean(nextStatus.ingress?.running),
|
|
|
+ account: nextStatus.account,
|
|
|
+ config: nextStatus.config,
|
|
|
+ voice: nextStatus.voice,
|
|
|
+ conversations: nextConversations,
|
|
|
+ responseMonitor: responseMonitorRenderSignature(nextResponseMonitor),
|
|
|
+ groupAgents: nextGroupAgents,
|
|
|
+ });
|
|
|
+ const changed = nextSignature !== state.agent.signature;
|
|
|
+ state.agent.status = nextStatus;
|
|
|
+ state.agent.conversations = nextConversations;
|
|
|
+ state.agent.signature = nextSignature;
|
|
|
+ state.agent.responseMonitor = nextResponseMonitor;
|
|
|
+ state.agent.groupAgents = nextGroupAgents;
|
|
|
+ if (!state.agent.selectedId && state.agent.conversations.length) {
|
|
|
+ state.agent.selectedId = state.agent.conversations.find(item => item.channelType !== 'group')?.id || state.agent.conversations[0].id || null;
|
|
|
+ }
|
|
|
+ state.agent.cachedAccountKey = String(activeAccount.uid || agentAccountKey(localAccount) || '').trim() || null;
|
|
|
+ saveAgentSnapshot({
|
|
|
+ accountKey: state.agent.cachedAccountKey,
|
|
|
+ status: state.agent.status,
|
|
|
+ conversations: state.agent.conversations,
|
|
|
+ selectedId: state.agent.selectedId,
|
|
|
+ signature: state.agent.signature,
|
|
|
+ savedAt: new Date().toISOString()
|
|
|
+ });
|
|
|
+ const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
|
|
|
+ const editorFocused = document.activeElement === page.querySelector('#agent-reply-editor') || document.activeElement === page.querySelector('#agent-group-reply-editor');
|
|
|
+ const insightPanel = page.querySelector('.agent-insight-panel');
|
|
|
+ const insightActive = Date.now() < Number(state.agent.insightInteractionUntil || 0) || Boolean(insightPanel?.matches(':hover'));
|
|
|
+ const voicePlaying = [...page.querySelectorAll('.agent-voice-audio')].some(audio => !audio.paused && !audio.ended);
|
|
|
+ if (!editorFocused && !insightActive && !voicePlaying) {
|
|
|
+ state.agent.signature = nextSignature;
|
|
|
+ if ((changed || waitingForFirstRender || state.agent.renderPending) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
|
|
|
+ state.agent.renderPending = false;
|
|
|
+ } else if (changed || waitingForFirstRender) {
|
|
|
+ state.agent.renderPending = true;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (!silent) toast(error.message || '智能会话加载失败', 'error');
|
|
|
+ } finally {
|
|
|
+ state.agent.loading = false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function startAgentPageRuntime(page) {
|
|
|
+ if (!page || !page.isConnected || page.__agentRuntimeStarted) return;
|
|
|
+ page.__agentRuntimeStarted = true;
|
|
|
+ void loadAgentData(page);
|
|
|
+ state.agent.timer = setInterval(() => loadAgentData(page, true), 5000);
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderAgentPage() {
|
|
|
+ els.pageTitle.textContent = '智能会话';
|
|
|
+ const currentAgentKey = agentAccountKey();
|
|
|
+ if (state.agent.cachedAccountKey && currentAgentKey && state.agent.cachedAccountKey !== currentAgentKey) {
|
|
|
+ state.agent.status = null;
|
|
|
+ state.agent.conversations = [];
|
|
|
+ state.agent.selectedId = null;
|
|
|
+ state.agent.signature = null;
|
|
|
+ state.agent.cachedAccountKey = null;
|
|
|
+ }
|
|
|
+ if (!currentAccount()) {
|
|
|
+ els.accountName.textContent = '正在检测账号';
|
|
|
+ els.accountDot.className = 'account-dot offline';
|
|
|
+ }
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page agent-page';
|
|
|
+ page.innerHTML = state.agent.status
|
|
|
+ ? ''
|
|
|
+ : '<div class="agent-loading">正在读取企微智能会话…</div>';
|
|
|
+ els.content.appendChild(page);
|
|
|
+ if (state.agent.status) renderAgentWorkspaceV2(page);
|
|
|
+
|
|
|
+ const markInsightInteraction = event => {
|
|
|
+ if (event.target?.closest?.('.agent-insight-panel')) state.agent.insightInteractionUntil = Date.now() + 2000;
|
|
|
+ };
|
|
|
+ page.addEventListener('pointerdown', markInsightInteraction, true);
|
|
|
+ page.addEventListener('scroll', markInsightInteraction, true);
|
|
|
+ page.addEventListener('toggle', event => {
|
|
|
+ if (event.target?.matches?.('[data-agent-activity]')) state.agent.activityOpen = event.target.open;
|
|
|
+ }, true);
|
|
|
+
|
|
|
+ page.addEventListener('submit', async event => {
|
|
|
+ const form = event.target.closest('[data-agent-intake-form]');
|
|
|
+ if (!form) return;
|
|
|
+ event.preventDefault();
|
|
|
+ const formData = new FormData(form);
|
|
|
+ const policy = {
|
|
|
+ mode: String(formData.get('mode') || 'allowlist_only'),
|
|
|
+ welcomeEnabled: formData.get('welcomeEnabled') === 'on',
|
|
|
+ welcomeText: String(formData.get('welcomeText') || '').trim(),
|
|
|
+ welcomeSendMode: String(formData.get('welcomeSendMode') || 'draft') === 'send' ? 'send' : 'draft',
|
|
|
+ };
|
|
|
+ if (policy.welcomeSendMode === 'send' && policy.mode !== 'auto_enroll_autopilot') {
|
|
|
+ toast('欢迎语直接发送仅可用于已明确确认的受控自动接入策略', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (policy.mode === 'auto_enroll_autopilot' && !await confirmModal('新私聊会自动加入白名单,欢迎语可以真实发送;后续回复仍受置信度与风险门禁。', {
|
|
|
+ title: '确认开启受控自动新联系人接入',
|
|
|
+ confirmText: '确认受控自动接入',
|
|
|
+ warning: '请仅在已确认业务范围和发送规则后开启。',
|
|
|
+ danger: true,
|
|
|
+ })) return;
|
|
|
+ state.agent.intakePolicy = policy;
|
|
|
+ state.agent.intakePolicyLoading = true;
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ try {
|
|
|
+ const body = { ...policy, ...(policy.mode === 'auto_enroll_autopilot' ? { confirmation: 'ENABLE_AUTO_ENROLL_AUTOPILOT' } : {}) };
|
|
|
+ const result = await api('POST', '/api/agent/intake-policy', body);
|
|
|
+ state.agent.intakePolicy = normalizeAgentIntakePolicy(result.data || result);
|
|
|
+ state.agent.intakePolicyLoaded = true;
|
|
|
+ state.agent.intakePolicyError = '';
|
|
|
+ toast(result.assistantMessage || '接入策略已保存');
|
|
|
+ } catch (error) {
|
|
|
+ state.agent.intakePolicyError = error.message || '接入策略保存失败';
|
|
|
+ toast(state.agent.intakePolicyError, 'error');
|
|
|
+ } finally {
|
|
|
+ state.agent.intakePolicyLoading = false;
|
|
|
+ if (page.isConnected) renderAgentWorkspaceV2(page);
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.addEventListener('input', event => {
|
|
|
+ const editor = event.target.closest('#agent-reply-editor');
|
|
|
+ if (editor?.dataset.agentEditKey) {
|
|
|
+ state.agent.replyEdits[editor.dataset.agentEditKey] = editor.value;
|
|
|
+ const voiceReady = Boolean(state.agent.status?.voice?.enrolled && editor.value.trim() && !editor.disabled);
|
|
|
+ page.querySelectorAll('[data-agent-action="voice-send"]').forEach(button => { button.disabled = !voiceReady || !state.agent.status?.voice?.sendConfigured; });
|
|
|
+ const status = page.querySelector('[data-agent-edit-status]');
|
|
|
+ if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const groupEditor = event.target.closest('#agent-group-reply-editor');
|
|
|
+ if (!groupEditor?.dataset.agentGroupEditKey) return;
|
|
|
+ state.agent.groupReplyEdits[groupEditor.dataset.agentGroupEditKey] = groupEditor.value;
|
|
|
+ const status = page.querySelector('[data-agent-group-edit-status]');
|
|
|
+ if (status) status.textContent = '已人工修改 · 批准后发送当前内容';
|
|
|
+ });
|
|
|
+
|
|
|
+ page.addEventListener('change', event => {
|
|
|
+ if (event.target.id !== 'agent-voice-tone') return;
|
|
|
+ state.agent.voiceTone = event.target.value || 'auto';
|
|
|
+ });
|
|
|
+
|
|
|
+ const updateVoicePlaybackUi = audio => {
|
|
|
+ const bubble = audio?.closest('.agent-voice-bubble');
|
|
|
+ if (!bubble) return;
|
|
|
+ const playing = !audio.paused && !audio.ended;
|
|
|
+ bubble.classList.toggle('playing', playing);
|
|
|
+ bubble.setAttribute('aria-pressed', String(playing));
|
|
|
+ const duration = Math.max(1, Number(audio.duration) || Number(bubble.closest('.agent-message')?.querySelector('.agent-voice-duration')?.textContent) || 1);
|
|
|
+ bubble.setAttribute('aria-label', `${playing ? '暂停' : '播放'}语音,时长 ${Math.ceil(duration)} 秒`);
|
|
|
+ };
|
|
|
+ page.addEventListener('play', event => {
|
|
|
+ if (!event.target.matches?.('.agent-voice-audio')) return;
|
|
|
+ page.querySelectorAll('.agent-voice-audio').forEach(audio => {
|
|
|
+ if (audio !== event.target && !audio.paused) audio.pause();
|
|
|
+ });
|
|
|
+ updateVoicePlaybackUi(event.target);
|
|
|
+ }, true);
|
|
|
+ page.addEventListener('pause', event => {
|
|
|
+ if (event.target.matches?.('.agent-voice-audio')) updateVoicePlaybackUi(event.target);
|
|
|
+ }, true);
|
|
|
+ page.addEventListener('ended', event => {
|
|
|
+ if (event.target.matches?.('.agent-voice-audio')) updateVoicePlaybackUi(event.target);
|
|
|
+ }, true);
|
|
|
+
|
|
|
+ page.addEventListener('click', async event => {
|
|
|
+ const drawerClose = event.target.closest('[data-agent-drawer-close]');
|
|
|
+ if (drawerClose) {
|
|
|
+ state.agent.openDrawer = null;
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const drawerButton = event.target.closest('[data-agent-drawer]');
|
|
|
+ if (drawerButton) {
|
|
|
+ const drawer = drawerButton.dataset.agentDrawer;
|
|
|
+ state.agent.openDrawer = state.agent.openDrawer === drawer ? null : drawer;
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ if (state.agent.openDrawer === 'settings' && !state.agent.intakePolicyLoaded) await loadAgentIntakePolicy(page, true);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const transcriptButton = event.target.closest('[data-voice-transcript-toggle]');
|
|
|
+ if (transcriptButton) {
|
|
|
+ const transcript = document.getElementById(transcriptButton.getAttribute('aria-controls'));
|
|
|
+ const expanded = transcriptButton.getAttribute('aria-expanded') === 'true';
|
|
|
+ transcriptButton.setAttribute('aria-expanded', String(!expanded));
|
|
|
+ transcriptButton.querySelector('span').textContent = expanded ? '转文字' : '收起';
|
|
|
+ if (transcript) transcript.hidden = expanded;
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const voicePlayButton = event.target.closest('[data-voice-play]');
|
|
|
+ if (voicePlayButton) {
|
|
|
+ const audio = voicePlayButton.querySelector('.agent-voice-audio');
|
|
|
+ if (!audio) return;
|
|
|
+ if (audio.paused || audio.ended) {
|
|
|
+ if (audio.ended) audio.currentTime = 0;
|
|
|
+ try {
|
|
|
+ await audio.play();
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '语音播放失败', 'error');
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ audio.pause();
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
|
|
|
+ if (responseMonitorButton) {
|
|
|
+ if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
|
|
|
+ state.agent.listMode = 'private';
|
|
|
+ state.agent.selectedId = responseMonitorButton.dataset.id;
|
|
|
+ } else {
|
|
|
+ state.agent.listMode = 'group';
|
|
|
+ state.agent.selectedGroupId = responseMonitorButton.dataset.id;
|
|
|
+ }
|
|
|
+ state.agent.openDrawer = null;
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ page.querySelector('.agent-chat-panel')?.focus({ preventScroll: true });
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const listModeButton = event.target.closest('[data-agent-list-mode]');
|
|
|
+ if (listModeButton) {
|
|
|
+ state.agent.listMode = listModeButton.dataset.agentListMode === 'group' ? 'group' : 'private';
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const groupMonitorButton = event.target.closest('[data-group-monitor-id]');
|
|
|
+ if (groupMonitorButton) {
|
|
|
+ state.agent.listMode = 'group';
|
|
|
+ state.agent.selectedGroupId = groupMonitorButton.dataset.groupMonitorId;
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const conversationButton = event.target.closest('[data-conversation-id]');
|
|
|
+ if (conversationButton) {
|
|
|
+ state.agent.listMode = 'private';
|
|
|
+ state.agent.selectedId = conversationButton.dataset.conversationId;
|
|
|
+ renderAgentWorkspaceV2(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const modeButton = event.target.closest('[data-agent-mode]');
|
|
|
+ if (modeButton) {
|
|
|
+ const mode = modeButton.dataset.agentMode;
|
|
|
+ const allowlistCount = Number(state.agent.status?.config?.allowedSenderCount) || 0;
|
|
|
+ if (mode === 'auto' && !await confirmModal(`高置信自动模式会向白名单中的 ${allowlistCount} 位联系人真实发送回复。`, {
|
|
|
+ title: '开启高置信自动模式',
|
|
|
+ confirmText: '确认开启',
|
|
|
+ warning: '符合条件的回复将不经人工审核直接发送。',
|
|
|
+ danger: true,
|
|
|
+ })) return;
|
|
|
+ try {
|
|
|
+ const result = await api('POST', '/api/agent/mode', { mode });
|
|
|
+ toast(result.assistantMessage || '模式已更新');
|
|
|
+ await loadAgentData(page);
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '模式切换失败', 'error');
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const conversationModeButton = event.target.closest('[data-agent-conversation-mode]');
|
|
|
+ if (conversationModeButton) {
|
|
|
+ const id = conversationModeButton.dataset.id;
|
|
|
+ const mode = conversationModeButton.dataset.agentConversationMode;
|
|
|
+ if (mode === 'auto' && !await confirmModal('该会话的高置信草稿可能自动真实发送。', {
|
|
|
+ title: '将会话转为自动模式',
|
|
|
+ confirmText: '确认转为自动',
|
|
|
+ warning: '符合条件的回复将不经人工审核直接发送给当前联系人。',
|
|
|
+ danger: true,
|
|
|
+ })) return;
|
|
|
+ const action = ({ review: 'resume', auto: 'auto', human: 'takeover', paused: 'pause' })[mode];
|
|
|
+ conversationModeButton.disabled = true;
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/agent/conversations/${id}/${action}`, {});
|
|
|
+ toast(result.assistantMessage || '会话模式已更新');
|
|
|
+ await loadAgentData(page);
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '会话模式切换失败', 'error');
|
|
|
+ } finally {
|
|
|
+ if (conversationModeButton.isConnected) conversationModeButton.disabled = false;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const groupModeButton = event.target.closest('[data-agent-group-mode]');
|
|
|
+ if (groupModeButton) {
|
|
|
+ const roomId = groupModeButton.dataset.roomId;
|
|
|
+ const mode = groupModeButton.dataset.agentGroupMode;
|
|
|
+ if (mode === 'auto' && !await confirmModal('全自动模式会忽略置信度、敏感判断和人工风险标记。Agent 只要返回非空内容,就会直接真实发送到该客户群。', {
|
|
|
+ title: '开启客户群全自动模式',
|
|
|
+ confirmText: '确认开启全自动',
|
|
|
+ warning: '这是高风险真实发送操作,请确认当前群可以无人审核发送。',
|
|
|
+ danger: true,
|
|
|
+ })) return;
|
|
|
+ groupModeButton.disabled = true;
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/agent/groups/${encodeURIComponent(roomId)}/mode`, {
|
|
|
+ mode,
|
|
|
+ confirmation: mode === 'auto' ? 'AUTO_SEND_GROUP_MESSAGES' : '',
|
|
|
+ });
|
|
|
+ toast(result.assistantMessage || '群聊模式已更新');
|
|
|
+ await loadAgentData(page);
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '群聊模式切换失败', 'error');
|
|
|
+ } finally {
|
|
|
+ if (groupModeButton.isConnected) groupModeButton.disabled = false;
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const actionButton = event.target.closest('[data-agent-action]');
|
|
|
+ if (!actionButton) return;
|
|
|
+ const action = actionButton.dataset.agentAction;
|
|
|
+ const id = actionButton.dataset.id;
|
|
|
+ const editor = page.querySelector('#agent-reply-editor');
|
|
|
+ const replyEditKey = editor?.dataset.agentEditKey || '';
|
|
|
+ const groupEditor = page.querySelector('#agent-group-reply-editor');
|
|
|
+ const groupReplyEditKey = groupEditor?.dataset.agentGroupEditKey || '';
|
|
|
+ if (action === 'manage-allowlist') {
|
|
|
+ await openAllowlistModal(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (action === 'session-guide') {
|
|
|
+ const conversation = (state.agent.conversations || []).find(item => item.id === id);
|
|
|
+ const guide = conversation?.claudeSession || {};
|
|
|
+ if (guide.ready) {
|
|
|
+ openAgentSessionGuide(guide);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ actionButton.disabled = true;
|
|
|
+ actionButton.setAttribute('aria-busy', 'true');
|
|
|
+ actionButton.innerHTML = '<span class="agent-session-spinner" aria-hidden="true"></span>初始化中…';
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/agent/conversations/${id}/generate`, {}, 240000);
|
|
|
+ await loadAgentData(page);
|
|
|
+ const updated = (state.agent.conversations || []).find(item => item.id === id);
|
|
|
+ if (!updated?.claudeSession?.ready) {
|
|
|
+ throw new Error(result.assistantMessage || result.data?.error || '客户 Session 初始化未完成');
|
|
|
+ }
|
|
|
+ toast('客户 Session 已初始化,生成内容已进入人工审核流程');
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '客户 Session 初始化失败', 'error');
|
|
|
+ } finally {
|
|
|
+ if (actionButton.isConnected) {
|
|
|
+ actionButton.disabled = false;
|
|
|
+ actionButton.removeAttribute('aria-busy');
|
|
|
+ actionButton.textContent = '初始化 Session';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (action === 'voice-profile') {
|
|
|
+ await openVoiceProfileModal(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ actionButton.disabled = true;
|
|
|
+ try {
|
|
|
+ let result;
|
|
|
+ if (action === 'sync-conversations' || action === 'sync-current-conversation') {
|
|
|
+ const current = (state.agent.conversations || []).find(item => item.id === (id || state.agent.selectedId));
|
|
|
+ const single = action === 'sync-current-conversation';
|
|
|
+ if (single && !current) throw new Error('当前会话不存在,请刷新后重试');
|
|
|
+ const prompt = single
|
|
|
+ ? `只采集“${current.displayName || '当前客户'}”最近的真实消息,不运行 Agent,也不发送回复。`
|
|
|
+ : '补采全部白名单联系人的最近真实消息,不运行 Agent,也不发送回复。';
|
|
|
+ if (!await confirmModal(prompt, { title: single ? '采集当前会话' : '补采全部白名单', confirmText: '开始采集' })) return;
|
|
|
+ result = await api('POST', '/api/agent/conversations/sync', single ? { conversationId: current.id } : {});
|
|
|
+ const synced = result.data?.conversations || [];
|
|
|
+ if (synced.length) {
|
|
|
+ state.agent.conversations = synced;
|
|
|
+ state.agent.listMode = 'private';
|
|
|
+ state.agent.selectedId = single && synced.some(item => item.id === current.id)
|
|
|
+ ? current.id
|
|
|
+ : synced.find(item => item.channelType !== 'group')?.id || null;
|
|
|
+ }
|
|
|
+ } else if (action === 'customer-task-sync-official') {
|
|
|
+ const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
|
|
|
+ const task = selected?.customerIntelligence?.tasks?.find(item => item.id === id);
|
|
|
+ if (!task) throw new Error('待办记录已变化,请刷新后重试');
|
|
|
+ const syncInput = await showOfficialTodoSyncModal(task);
|
|
|
+ if (!syncInput) return;
|
|
|
+ result = await api('POST', `/api/agent/tasks/${encodeURIComponent(id)}/sync-official`, syncInput);
|
|
|
+ } else if (action === 'customer-task-complete-official') {
|
|
|
+ if (!await confirmModal('本地待办已经完成。确认现在将绑定的真实企业微信待办也标记为完成?', { title: '同步完成企微待办', confirmText: '同步完成', warning: '该操作会更新真实企业微信待办。' })) return;
|
|
|
+ result = await api('POST', `/api/agent/tasks/${id}`, { status: 'done' });
|
|
|
+ } else if (action === 'customer-task-done' || action === 'customer-task-dismiss') {
|
|
|
+ const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId) || (state.agent.conversations || [])[0];
|
|
|
+ const task = selected?.customerIntelligence?.tasks?.find(item => item.id === id);
|
|
|
+ if (action === 'customer-task-done' && task?.officialTodoId && !await confirmModal('该待办已绑定企业微信。确认后会同时将真实企微待办标记为完成。', { title: '完成关联待办', confirmText: '确认完成', warning: '该操作会更新真实企业微信待办。' })) return;
|
|
|
+ result = await api('POST', `/api/agent/tasks/${id}`, { status: action === 'customer-task-done' ? 'done' : 'dismissed' });
|
|
|
+ } else if (action === 'customer-alert-ack' || action === 'customer-alert-dismiss') {
|
|
|
+ result = await api('POST', `/api/agent/alerts/${id}`, { status: action === 'customer-alert-ack' ? 'acknowledged' : 'dismissed' });
|
|
|
+ } else if (action === 'start-listener' || action === 'stop-listener') {
|
|
|
+ if (action === 'start-listener') {
|
|
|
+ const autoGroups = (state.agent.groupAgents || []).filter(item => item.mode === 'auto');
|
|
|
+ const groupNotice = autoGroups.length
|
|
|
+ ? `其中 ${autoGroups.length} 个客户群已开启“无视风险 · 全自动”,Agent 非空回复会直接真实群发。`
|
|
|
+ : '当前所有客户群均为人工审核模式,不会自动群发。';
|
|
|
+ if (!await confirmModal(`启动后,白名单私聊会按当前策略处理。${groupNotice}`, { title: '启动 AI 监听', confirmText: '启动监听', warning: autoGroups.length ? '存在全自动客户群,监听启动后可能发生真实群发。' : '' , danger: autoGroups.length > 0 })) return;
|
|
|
+ }
|
|
|
+ result = await api('POST', `/api/agent/listener/${action === 'start-listener' ? 'start' : 'stop'}`, {});
|
|
|
+ } else if (action === 'group-generate') {
|
|
|
+ const group = (state.agent.groupAgents || []).find(item => item.roomId === actionButton.dataset.roomId);
|
|
|
+ if (group?.mode === 'auto' && !await confirmModal(`当前为全自动模式。继续后,Agent 生成的非空内容会立即真实发送到“${group.roomName || group.roomId}”。`, { title: '生成并自动发送群回复', confirmText: '继续生成并发送', warning: '生成的非空内容将不经人工审核直接发送。', danger: true })) return;
|
|
|
+ result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/generate`, {});
|
|
|
+ } else if (action === 'group-approve') {
|
|
|
+ const content = groupEditor?.value.trim();
|
|
|
+ if (!content) throw new Error('请先输入群聊回复内容');
|
|
|
+ const selectedGroup = (state.agent.groupAgents || []).find(item => item.roomId === actionButton.dataset.roomId);
|
|
|
+ if (!await confirmModal(`确认审核通过,并真实发送到客户群“${selectedGroup?.roomName || actionButton.dataset.roomId}”?`, { title: '发送群聊回复', confirmText: '批准并发送', warning: '该操作会立即发送真实群消息。', danger: true })) return;
|
|
|
+ result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/drafts/${encodeURIComponent(actionButton.dataset.draftId)}/approve`, { content });
|
|
|
+ } else if (action === 'group-reject') {
|
|
|
+ const reason = await promptModal('可填写需要调整的内容,留空也可以直接驳回。', '需要人工调整', { title: '驳回群聊草稿', label: '驳回原因', textarea: true, required: false, confirmText: '确认驳回' });
|
|
|
+ if (reason === null) return;
|
|
|
+ result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/drafts/${encodeURIComponent(actionButton.dataset.draftId)}/reject`, { reason });
|
|
|
+ } else if (action === 'group-regenerate') {
|
|
|
+ result = await api('POST', `/api/agent/groups/${encodeURIComponent(actionButton.dataset.roomId)}/drafts/${encodeURIComponent(actionButton.dataset.draftId)}/regenerate`, {});
|
|
|
+ } else if (action === 'approve') {
|
|
|
+ const content = page.querySelector('#agent-reply-editor')?.value.trim();
|
|
|
+ if (!content) throw new Error('请先输入回复内容');
|
|
|
+ const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
|
|
|
+ if (!await confirmModal(`确认审核通过,并真实发送给白名单联系人“${selected?.displayName || '当前客户'}”?`, { title: '发送客户回复', confirmText: '批准并发送', warning: '该操作会立即发送真实私聊消息。', danger: true })) return;
|
|
|
+ result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/approve`, { content });
|
|
|
+ } else if (action === 'reject') {
|
|
|
+ const reason = await promptModal('可填写需要调整的内容,留空也可以直接驳回。', '需要人工调整', { title: '驳回回复草稿', label: '驳回原因', textarea: true, required: false, confirmText: '确认驳回' });
|
|
|
+ if (reason === null) return;
|
|
|
+ result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/reject`, { reason });
|
|
|
+ } else if (action === 'regenerate') {
|
|
|
+ result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/regenerate`, {});
|
|
|
+ } else if (action === 'retry-welcome') {
|
|
|
+ if (!await confirmModal('确认重试这位新联系人的欢迎语?', { title: '重试欢迎语', confirmText: '确认重试', warning: '该操作会立即发送真实私聊消息。', danger: true })) return;
|
|
|
+ result = await api('POST', `/api/agent/onboarding/${encodeURIComponent(id)}/retry-welcome`, {});
|
|
|
+ } else if (action === 'generate') {
|
|
|
+ result = await api('POST', `/api/agent/conversations/${id}/generate`, {});
|
|
|
+ } else if (action === 'manual-send') {
|
|
|
+ const content = page.querySelector('#agent-reply-editor')?.value.trim();
|
|
|
+ if (!content) throw new Error('请先输入人工回复内容');
|
|
|
+ const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
|
|
|
+ if (!await confirmModal(`确认由人工真实发送给白名单联系人“${selected?.displayName || '当前客户'}”?`, { title: '人工发送客户回复', confirmText: '确认发送', warning: '该操作会立即发送真实私聊消息。', danger: true })) return;
|
|
|
+ result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
|
|
|
+ } else if (action === 'voice-send') {
|
|
|
+ const content = page.querySelector('#agent-reply-editor')?.value.trim();
|
|
|
+ if (!content) throw new Error('请先输入语音内容');
|
|
|
+ const selected = (state.agent.conversations || []).find(item => item.id === state.agent.selectedId);
|
|
|
+ const toneLabel = state.agent.voiceTone === 'auto'
|
|
|
+ ? '自动识别语气'
|
|
|
+ : (state.agent.status?.voice?.tones || []).find(item => item.id === state.agent.voiceTone)?.label || '当前语气';
|
|
|
+ const voiceAccountKey = agentAccountKey() || state.agent.cachedAccountKey || 'default';
|
|
|
+ if (!await confirmModal(`确认使用“${toneLabel}”生成语音,并真实发送给“${selected?.displayName || '当前客户'}”?`, { title: '发送克隆语音', confirmText: '生成并发送', warning: '该操作会调用云端声音克隆服务并立即发送真实企微语音。', danger: true, dismissKey: `voice-send:${voiceAccountKey}` })) return;
|
|
|
+ result = await api('POST', `/api/agent/conversations/${id}/voice-send`, { content, draftId: actionButton.dataset.draftId || undefined, tone: state.agent.voiceTone, confirmed: true }, 240000);
|
|
|
+ }
|
|
|
+ if (result) toast(result.assistantMessage || '操作成功');
|
|
|
+ if (result && replyEditKey && ['approve', 'reject', 'regenerate', 'generate', 'manual-send', 'voice-send'].includes(action)) {
|
|
|
+ delete state.agent.replyEdits[replyEditKey];
|
|
|
+ }
|
|
|
+ if (result && groupReplyEditKey && ['group-approve', 'group-reject', 'group-regenerate', 'group-generate'].includes(action)) {
|
|
|
+ delete state.agent.groupReplyEdits[groupReplyEditKey];
|
|
|
+ }
|
|
|
+ await loadAgentData(page);
|
|
|
+ } catch (error) {
|
|
|
+ toast(error.message || '操作失败', 'error');
|
|
|
+ } finally {
|
|
|
+ if (actionButton.isConnected) actionButton.disabled = false;
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // The workbench is backed by local state and must remain usable while the
|
|
|
+ // remote login probe is slow or unavailable.
|
|
|
+ startAgentPageRuntime(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Login ====================
|
|
|
+ async function openSubscribeFlowPage() {
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/login/start', { flowUi: true });
|
|
|
+ const flowUrl = res.data?.flowUrl || res.summary?.flowUrl;
|
|
|
+ if (flowUrl) {
|
|
|
+ window.open(flowUrl, '_blank');
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+ toast('未获取到订阅流程页地址', 'warning');
|
|
|
+ return false;
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '打开订阅流程页失败', 'error');
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ 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());
|
|
|
+ }
|
|
|
+
|
|
|
+ function createDeviceUid() {
|
|
|
+ const id = typeof crypto?.randomUUID === 'function'
|
|
|
+ ? crypto.randomUUID()
|
|
|
+ : `${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`;
|
|
|
+ return `qiwei-${id}`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function startLoginFlow() {
|
|
|
+ if (!state.loginUid) state.loginUid = currentAccount()?.uid || createDeviceUid();
|
|
|
+ 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', { uid: state.loginUid, persistConfig: false });
|
|
|
+ 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-subscribe-login" style="width:100%;margin-bottom:10px">打开订阅与登录流程页</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-retry-login" style="width:100%">重试</button>`;
|
|
|
+ document.getElementById('btn-subscribe-login').addEventListener('click', openSubscribeFlowPage);
|
|
|
+ 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) {
|
|
|
+ const message = res.assistantMessage || res.errors?.[0]?.message || '未获取到二维码,请重试。';
|
|
|
+ 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(message)}</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', { uid: state.loginUid, persistConfig: false });
|
|
|
+ const statusCode = res.summary?.statusCode;
|
|
|
+
|
|
|
+ if (statusCode === 2) {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ if (statusText) statusText.textContent = '登录成功,正在获取账号信息…';
|
|
|
+ await finalizeLogin(res.data || {});
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (res.status === 'needs_verify_code') {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ if (statusText) statusText.textContent = '请输入手机端验证码';
|
|
|
+ showVerifyCodeInput();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (statusCode === 4) {
|
|
|
+ clearInterval(state.loginTimer);
|
|
|
+ restorePreviousAccountSelection();
|
|
|
+ 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, uid: state.loginUid });
|
|
|
+ toast('验证码已提交,正在确认登录…');
|
|
|
+ pollLoginStatus();
|
|
|
+ const statusText = document.getElementById('login-status-text');
|
|
|
+ if (statusText) statusText.textContent = '验证码已提交,正在确认…';
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '验证码提交失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function finalizeLogin(result = {}) {
|
|
|
+ try {
|
|
|
+ const loginDetail = result.status || result.detail || {};
|
|
|
+ const uid = String(result.uid || state.loginUid || '').trim();
|
|
|
+ const guid = String(result.guid || loginDetail.guid || loginDetail.userId || '').trim();
|
|
|
+ const account = {
|
|
|
+ uid,
|
|
|
+ guid,
|
|
|
+ userId: loginDetail.userId || '',
|
|
|
+ nickname: loginDetail.nickname || loginDetail.userId || '',
|
|
|
+ corpName: loginDetail.corpName || '',
|
|
|
+ corpId: loginDetail.corpId || '',
|
|
|
+ online: true
|
|
|
+ };
|
|
|
+ if (!account.uid) {
|
|
|
+ toast('登录成功,但未获取到 Fmode 设备标识,请重新扫码', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const previousAccountId = state.loginPreviousAccountId;
|
|
|
+ const accountId = addAccount(account, { activate: false });
|
|
|
+ const switched = await switchAccount(accountId, { silent: true, deferRender: state.bootstrapping });
|
|
|
+ if (!switched) {
|
|
|
+ state.currentAccountId = previousAccountId && state.accounts.some(item => item.id === previousAccountId)
|
|
|
+ ? previousAccountId
|
|
|
+ : null;
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ state.loginUid = null;
|
|
|
+ state.loginPreviousAccountId = null;
|
|
|
+ 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.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 uid = state.loginUid || currentAccount()?.uid || '';
|
|
|
+ if (!uid) throw new Error('该账号缺少 Fmode 设备绑定,请重新扫码登录');
|
|
|
+ state.loginUid = uid;
|
|
|
+ const res = await api('POST', '/api/login/check', { manual: true, uid, persistConfig: false });
|
|
|
+ const statusCode = res.summary?.statusCode;
|
|
|
+
|
|
|
+ if (statusCode === 2) {
|
|
|
+ await finalizeLogin(res.data || {});
|
|
|
+ state.loginPhase = 'idle';
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ if (res.status === 'needs_verify_code') {
|
|
|
+ state.loginPhase = 'verify';
|
|
|
+ state.preRecoveryPage = location.hash.slice(1) || 'groups';
|
|
|
+ state.loginPreviousAccountId ||= state.currentAccountId;
|
|
|
+ state.currentAccountId = null;
|
|
|
+ saveAccounts();
|
|
|
+ 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;
|
|
|
+ state.loginPreviousAccountId = state.currentAccountId;
|
|
|
+ state.loginUid = currentAccount()?.uid || null;
|
|
|
+ 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 ====================
|
|
|
+ function renderStatusContent(page, res, { localOnly = false, scheduleRefresh = true } = {}) {
|
|
|
+ const login = res.data?.login || {};
|
|
|
+ const sub = res.data?.subscription || {};
|
|
|
+ const loginPending = res.summary?.loginPending === true;
|
|
|
+ const subscriptionPending = res.summary?.subscriptionPending === true;
|
|
|
+ const authPending = res.summary?.authConfigured === null;
|
|
|
+ const online = !loginPending && !!login.summary?.online;
|
|
|
+ const subscribed = !subscriptionPending && !!sub.summary?.subscribed;
|
|
|
+ const acc = currentAccount() || {};
|
|
|
+
|
|
|
+ // 同步已保存账号的在线状态,确保表格显示与当前状态一致
|
|
|
+ if (!localOnly && !loginPending) 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 ${loginPending ? 'orange' : 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>
|
|
|
+ ${loginPending ? '正在后台确认账号连接状态…' : online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
|
|
|
+ </div>
|
|
|
+ ${!loginPending && !online ? `<div id="status-recover-wrap" style="margin-top:12px"></div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="status-card">
|
|
|
+ <div class="status-icon ${subscriptionPending ? 'orange' : 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>${subscriptionPending ? '正在检查订阅' : subscribed ? '订阅有效' : '未开通订阅'}</h3>
|
|
|
+ <p>${subscriptionPending ? '页面已先展示,远程订阅状态将在后台补充' : escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}</p>
|
|
|
+ ${sub.summary?.seats ? `<div class="status-detail">席位:${formatNumber(sub.summary.seats)} 个</div>` : ''}
|
|
|
+ <button class="btn btn-sm" id="btn-subscribe-action" style="margin-top:10px" ${subscriptionPending ? 'disabled' : ''}>${subscriptionPending ? '检查中…' : subscribed ? '续费/管理' : '开通订阅'}</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div class="status-card">
|
|
|
+ <div class="status-icon ${authPending ? 'orange' : 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>${authPending ? '正在检查鉴权' : res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}</h3>
|
|
|
+ <p>${authPending ? '正在读取当前账号配置' : 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', () => {
|
|
|
+ beginAddAccountLogin();
|
|
|
+ renderPage();
|
|
|
+ });
|
|
|
+
|
|
|
+ const subscribeBtn = page.querySelector('#btn-subscribe-action');
|
|
|
+ if (subscribeBtn) {
|
|
|
+ subscribeBtn.addEventListener('click', openSubscribeFlowPage);
|
|
|
+ }
|
|
|
+
|
|
|
+ page.querySelectorAll('[data-action="switch"]').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => { void switchAccount(btn.dataset.id); });
|
|
|
+ });
|
|
|
+ page.querySelectorAll('[data-action="remove"]').forEach(btn => {
|
|
|
+ btn.addEventListener('click', async () => {
|
|
|
+ if (await confirmModal('删除后,本地 Dashboard 将不再保留这个账号入口。', { title: '删除已保存账号', confirmText: '确认删除', warning: '如需再次使用,必须重新添加或登录账号。', danger: true })) removeAccount(btn.dataset.id);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ const recoverWrap = page.querySelector('#status-recover-wrap');
|
|
|
+ if (recoverWrap) {
|
|
|
+ renderRecoverButton(recoverWrap);
|
|
|
+ }
|
|
|
+ if (scheduleRefresh && (loginPending || subscriptionPending)) {
|
|
|
+ setTimeout(() => {
|
|
|
+ if (state.page === 'status' && page.isConnected) renderPage();
|
|
|
+ }, 900);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderStatusPage() {
|
|
|
+ els.pageTitle.textContent = '账号状态';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page';
|
|
|
+ els.content.appendChild(page);
|
|
|
+ const account = currentAccount() || {};
|
|
|
+ const accountKey = account.id || account.guid || account.uid || account.userId || 'local';
|
|
|
+ const cached = state.status?.accountKey === accountKey ? state.status.response : null;
|
|
|
+ const immediate = cached || {
|
|
|
+ status: 'ok',
|
|
|
+ summary: {
|
|
|
+ authConfigured: null,
|
|
|
+ online: Boolean(account.online),
|
|
|
+ subscribed: null,
|
|
|
+ loginPending: false,
|
|
|
+ subscriptionPending: true
|
|
|
+ },
|
|
|
+ data: {
|
|
|
+ login: {
|
|
|
+ status: 'ok',
|
|
|
+ summary: { online: Boolean(account.online), nickname: account.nickname || account.userId || '当前账号' },
|
|
|
+ data: { detail: { userId: account.userId || '', corpName: account.corpName || '' } }
|
|
|
+ },
|
|
|
+ subscription: { status: 'pending', summary: {}, data: {} }
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+ // 首帧直接使用本地账号或上次结果,避免页面切换后出现空白/骨架等待。
|
|
|
+ renderStatusContent(page, immediate, { localOnly: !cached, scheduleRefresh: false });
|
|
|
+
|
|
|
+ try {
|
|
|
+ const res = await api('GET', '/api/status?fast=true');
|
|
|
+ state.status = { accountKey, response: res };
|
|
|
+ if (state.page !== 'status' || !page.isConnected) return;
|
|
|
+ renderStatusContent(page, res);
|
|
|
+ } catch (err) {
|
|
|
+ // 已有本地首屏时保留内容,只提示刷新失败,避免整页退化为空白错误态。
|
|
|
+ toast(err.message || '账号状态刷新失败,当前显示本地缓存', 'warning');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== Groups Page ====================
|
|
|
+ // ==================== Group Operations ====================
|
|
|
+ function groupOpsAccountKey() {
|
|
|
+ const account = currentAccount();
|
|
|
+ return account?.guid || account?.uid || account?.userId || 'local-default';
|
|
|
+ }
|
|
|
+
|
|
|
+ function groupOpsStatusBadge(status) {
|
|
|
+ const labels = { pending_review: '待审核', approved: '已批准', sent: '已执行', sending: '发送中', failed: '失败', dry_run: '预演通过', blocked: '已拦截', rejected: '已拒绝', skipped: '已跳过', published: '已发布', draft: '草稿', open: '待处理', in_progress: '处理中', done: '已完成', acknowledged: '处理中', resolved: '已关闭', false_positive: '误报' };
|
|
|
+ const style = status === 'sent' || status === 'published' || status === 'resolved' || status === 'done' || status === 'dry_run' ? 'success' : status === 'failed' || status === 'open' ? 'error' : status === 'rejected' || status === 'false_positive' || status === 'blocked' ? 'default' : 'warning';
|
|
|
+ return `<span class="badge badge-${style}">${escapeHtml(labels[status] || status || '-')}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroupOpsContent(page) {
|
|
|
+ const overview = state.groupOps.overview || { metrics: {}, plans: [], findings: [] };
|
|
|
+ const m = overview.metrics || {};
|
|
|
+ const plans = overview.plans || [];
|
|
|
+ const findings = overview.findings || [];
|
|
|
+ const playbooks = state.groupOps.playbooks || [];
|
|
|
+ const stores = overview.stores || [];
|
|
|
+ const insights = overview.insights || [];
|
|
|
+ const scores = overview.scores || [];
|
|
|
+ const tasks = overview.tasks || [];
|
|
|
+ const settings = overview.qualitySettings || { sla_minutes: 120, risk_terms: [], forbidden_claims: [] };
|
|
|
+ const automation = overview.automation || { policies: [], state: { paused: 1, breaker_open: 0 }, candidates: [], attempts: [], liveSendEnabled: false };
|
|
|
+ const automationPolicy = automation.activePolicy?.policy || null;
|
|
|
+ const activeView = state.groupOps.view || 'overview';
|
|
|
+ const hidden = view => activeView === view ? '' : 'hidden';
|
|
|
+ page.innerHTML = `
|
|
|
+ <section class="workspace-hero customer-workspace-hero group-ops-hero">
|
|
|
+ <div>
|
|
|
+ <span>WECOM COMMUNITY OPERATIONS</span>
|
|
|
+ <h2>社群运营中心</h2>
|
|
|
+ <p>把总部 SOP 转化为门店每日可审核、可执行、可追溯的客户群任务。</p>
|
|
|
+ </div>
|
|
|
+ <div class="group-ops-hero-side">
|
|
|
+ <div class="group-ops-hero-status" aria-label="社群运营当前状态">
|
|
|
+ <span><strong>${formatNumber(m.dueItems || 0)}</strong>今日任务</span>
|
|
|
+ <span><strong>${formatNumber(m.pendingReview || 0)}</strong>待审话术</span>
|
|
|
+ <span class="${Number(m.highRisk || 0) > 0 ? 'is-risk' : ''}"><strong>${formatNumber(m.highRisk || 0)}</strong>高风险</span>
|
|
|
+ </div>
|
|
|
+ <button class="btn group-ops-refresh" id="group-ops-refresh" aria-label="刷新社群运营数据">
|
|
|
+ <svg viewBox="0 0 24 24" aria-hidden="true"><path d="M17.65 6.35A7.95 7.95 0 0 0 12 4a8 8 0 1 0 7.75 10h-2.1A6 6 0 1 1 12 6c1.66 0 3.14.69 4.22 1.78L13 11h8V3l-3.35 3.35Z"/></svg>
|
|
|
+ 刷新数据
|
|
|
+ </button>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <nav class="group-ops-tabs" aria-label="社群运营功能">
|
|
|
+ ${[['overview','运营总览'],['workbench','今日工作台'],['groups','群洞察'],['playbooks','SOP 版本'],['quality','质检督导'],['automation','受控自动化']].map(([key,label]) => `<button class="${activeView === key ? 'active' : ''}" data-group-ops-view="${key}">${label}</button>`).join('')}
|
|
|
+ </nav>
|
|
|
+ <div class="kpi-row group-ops-stats" data-group-ops-panel="overview" ${hidden('overview')}>
|
|
|
+ <div class="kpi-card"><span class="kpi-label">纳管客户群</span><strong class="kpi-value">${formatNumber(m.groups || 0)}</strong><span class="kpi-sub">已进入运营范围</span></div>
|
|
|
+ <div class="kpi-card"><span class="kpi-label">今日运营任务</span><strong class="kpi-value">${formatNumber(m.dueItems || 0)}</strong><span class="kpi-sub">待门店执行</span></div>
|
|
|
+ <div class="kpi-card"><span class="kpi-label">今日执行率</span><strong class="kpi-value">${m.executionRate === null || m.executionRate === undefined ? '-' : `${m.executionRate}%`}</strong><span class="kpi-sub">无任务时不计算</span></div>
|
|
|
+ <div class="kpi-card"><span class="kpi-label">平均群健康分</span><strong class="kpi-value">${m.averageHealthScore === null || m.averageHealthScore === undefined ? '-' : m.averageHealthScore}</strong><span class="kpi-sub">数据覆盖 ${m.averageHealthCoverage || 0}%</span></div>
|
|
|
+ <div class="kpi-card"><span class="kpi-label">待审核话术</span><strong class="kpi-value">${formatNumber(m.pendingReview || 0)}</strong><span class="kpi-sub">需人工确认后执行</span></div>
|
|
|
+ <div class="kpi-card danger"><span class="kpi-label">高风险未闭环</span><strong class="kpi-value">${formatNumber(m.highRisk || 0)}</strong><span class="kpi-sub">建议优先处理</span></div>
|
|
|
+ </div>
|
|
|
+ <section class="card group-ops-workbench" data-group-ops-panel="overview" ${hidden('overview')}>
|
|
|
+ <div class="card-header"><div><h3>门店执行对比</h3><p>优先显示高风险多、执行率低的门店;无任务时不计算执行率。</p></div><span>${stores.length} 家门店</span></div>
|
|
|
+ <div class="table-wrap"><table><thead><tr><th>门店</th><th>客户群</th><th>今日任务</th><th>已完成</th><th>执行率</th><th>风险</th></tr></thead><tbody>
|
|
|
+ ${stores.map(item => `<tr><td><strong>${escapeHtml(item.storeId)}</strong></td><td>${item.groups}</td><td>${item.dueItems}</td><td>${item.completedItems}</td><td>${item.executionRate === null ? '数据不足' : `${item.executionRate}%`}</td><td>${item.highRisk ? `<span class="badge badge-error">${item.highRisk} 高风险</span>` : `<span class="badge badge-success">${item.openFindings} 待处理</span>`}</td></tr>`).join('') || '<tr><td colspan="6">暂无门店数据</td></tr>'}
|
|
|
+ </tbody></table></div>
|
|
|
+ </section>
|
|
|
+ <section class="card group-ops-workbench" data-group-ops-panel="groups" ${hidden('groups')}>
|
|
|
+ <div class="card-header"><div><h3>群活跃趋势与生命周期建议</h3><p>活跃度只反映消息事实,不用于员工绩效排名。</p></div><span>${insights.length} 个群</span></div>
|
|
|
+ <div class="table-wrap"><table><thead><tr><th>客户群</th><th>门店</th><th>近 7 天消息</th><th>活跃成员</th><th>趋势</th><th>建议</th><th>覆盖率</th></tr></thead><tbody>
|
|
|
+ ${insights.map(item => `<tr><td><strong>${escapeHtml(item.room_name || item.room_id)}</strong><br><small>${escapeHtml(item.lifecycle_stage)}</small></td><td>${escapeHtml(item.store_id || '未分配')}</td><td>${item.activity.currentMessages}</td><td>${item.activity.activeSpeakers}</td><td>${item.activity.trend === null ? '无对比基线' : `${item.activity.trend > 0 ? '+' : ''}${item.activity.trend}%`}</td><td>${escapeHtml(item.lifecycleSuggestion)}</td><td>${item.dataCoverage}%</td></tr>`).join('') || '<tr><td colspan="7">暂无群消息数据</td></tr>'}
|
|
|
+ </tbody></table></div>
|
|
|
+ <div class="card-header"><div><h3>今日群健康评分</h3><p>只按有可靠数据的分项重新分配权重;点击证据数量可继续定位计划、消息和预警。</p></div><span>健康 ${m.healthDistribution?.healthy || 0} · 关注 ${m.healthDistribution?.attention || 0} · 风险 ${m.healthDistribution?.risk || 0} · 暂不可评估 ${m.healthDistribution?.unavailable || 0}</span></div>
|
|
|
+ <div class="table-wrap"><table><thead><tr><th>客户群</th><th>健康分</th><th>覆盖率</th><th>SOP</th><th>响应</th><th>风险</th><th>互动</th></tr></thead><tbody>
|
|
|
+ ${scores.map(item => `<tr><td><strong>${escapeHtml(item.roomName || item.roomId)}</strong></td><td>${item.totalScore === null ? '暂不可评估' : item.totalScore}</td><td>${item.coverage}%</td>${['sop','response','risk','engagement'].map(key => { const component = item.components?.[key]; return `<td title="证据 ${component?.evidenceCount || 0} 条">${component?.available ? component.score : '数据不足'}</td>`; }).join('')}</tr>`).join('') || '<tr><td colspan="7">暂无可评分群数据</td></tr>'}
|
|
|
+ </tbody></table></div>
|
|
|
+ </section>
|
|
|
+ <section class="card group-ops-automation" data-group-ops-panel="automation" ${hidden('automation')}>
|
|
|
+ <div class="card-header"><div><h3>受控自动化策略</h3><p>默认暂停。真实发送还需要环境开关、白名单、灰度、时间窗、频控和内容安全全部通过。</p></div><div class="group-ops-automation-state"><span class="badge ${automation.state?.breaker_open ? 'badge-error' : automation.state?.paused ? 'badge-warning' : 'badge-success'}">${automation.state?.breaker_open ? '已熔断' : automation.state?.paused ? '已暂停' : '运行中'}</span><span class="badge ${automation.liveSendEnabled ? 'badge-warning' : 'badge-default'}">真实发送 ${automation.liveSendEnabled ? '已开放' : '未开放'}</span></div></div>
|
|
|
+ <div class="group-ops-automation-grid">
|
|
|
+ <form id="group-ops-automation-form" class="group-ops-form">
|
|
|
+ <label for="group-ops-auto-groups">群白名单</label><textarea id="group-ops-auto-groups" rows="3" placeholder="多个 roomId 用逗号隔开">${escapeHtml((automationPolicy?.allowedGroupIds || []).join(','))}</textarea>
|
|
|
+ <label for="group-ops-auto-types">允许内容类型</label><input id="group-ops-auto-types" value="${escapeHtml((automationPolicy?.allowedTypes || ['welcome','content']).join(','))}" />
|
|
|
+ <label for="group-ops-auto-window-start">允许时间窗</label><div class="group-ops-time-window"><input id="group-ops-auto-window-start" type="time" value="${escapeHtml(automationPolicy?.windows?.[0]?.start || '09:00')}" /><span>至</span><input id="group-ops-auto-window-end" type="time" value="${escapeHtml(automationPolicy?.windows?.[0]?.end || '18:00')}" /></div>
|
|
|
+ <label for="group-ops-auto-gray">灰度比例</label><input id="group-ops-auto-gray" type="number" min="0" max="100" value="${Number(automationPolicy?.grayPercent ?? 0)}" />
|
|
|
+ <label for="group-ops-auto-daily">单群每日上限</label><input id="group-ops-auto-daily" type="number" min="1" max="20" value="${Number(automationPolicy?.dailyLimitPerGroup || 1)}" />
|
|
|
+ <label for="group-ops-auto-failures">连续失败熔断</label><input id="group-ops-auto-failures" type="number" min="1" max="20" value="${Number(automationPolicy?.consecutiveFailureLimit || 3)}" />
|
|
|
+ <button class="btn" type="submit">保存新策略草稿</button>
|
|
|
+ </form>
|
|
|
+ <div class="group-ops-automation-summary">
|
|
|
+ <h4>${automation.activePolicy ? `当前策略 v${automation.activePolicy.version}` : '尚未激活策略'}</h4>
|
|
|
+ <dl><div><dt>模式</dt><dd>${escapeHtml(automationPolicy?.mode || 'dry_run')}</dd></div><div><dt>白名单群</dt><dd>${automationPolicy?.allowedGroupIds?.length || 0}</dd></div><div><dt>灰度</dt><dd>${automationPolicy?.grayPercent || 0}%</dd></div><div><dt>连续失败</dt><dd>${automation.state?.consecutive_failures || 0}/${automationPolicy?.consecutiveFailureLimit || 3}</dd></div></dl>
|
|
|
+ <div class="group-ops-automation-actions">${automation.policies?.find(item => item.status === 'draft') ? `<button class="btn btn-secondary" data-group-ops-policy-activate="${escapeHtml(automation.policies.find(item => item.status === 'draft').id)}">激活最新草稿</button>` : ''}<button class="btn btn-secondary" data-group-ops-automation-action="preview">预演闸门</button><button class="btn btn-secondary" data-group-ops-automation-action="run">执行 dry-run</button>${automation.state?.paused ? '<button class="btn btn-secondary" data-group-ops-automation-action="resume">恢复运行</button>' : '<button class="btn btn-danger" data-group-ops-automation-action="pause">一键暂停</button>'}${automation.state?.breaker_open ? '<button class="btn btn-secondary" data-group-ops-automation-action="reset_breaker">人工复位熔断</button>' : ''}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="card-header group-ops-automation-log-header"><div><h3>预演与执行记录</h3><p>每次判断都保存策略版本、通过/拦截原因和回执。</p></div><span>${automation.attempts?.length || 0} 条</span></div>
|
|
|
+ <div class="table-wrap"><table><thead><tr><th>时间</th><th>群</th><th>模式</th><th>状态</th><th>策略</th><th>错误</th></tr></thead><tbody>${(automation.attempts || []).map(item => `<tr><td>${escapeHtml(formatAgentTime(item.created_at))}</td><td>${escapeHtml(item.room_id)}</td><td>${escapeHtml(item.mode)}</td><td>${groupOpsStatusBadge(item.status)}</td><td>v${item.policy_version || '-'}</td><td>${escapeHtml(item.error || '-')}</td></tr>`).join('') || '<tr><td colspan="6">暂无自动化记录。先保存并激活 dry-run 策略。</td></tr>'}</tbody></table></div>
|
|
|
+ </section>
|
|
|
+ <section class="card group-ops-builder" data-group-ops-panel="playbooks" ${hidden('playbooks')}>
|
|
|
+ <div class="card-header"><div><h3>SOP 与计划</h3><p>先发布公司标准,再为客户群生成每日任务。</p></div></div>
|
|
|
+ <form id="group-ops-playbook-form" class="group-ops-form">
|
|
|
+ <label for="group-ops-playbook-name">SOP 名称</label>
|
|
|
+ <input id="group-ops-playbook-name" required placeholder="例如:新客群 7 天服务 SOP" />
|
|
|
+ <label for="group-ops-template">首条标准话术</label>
|
|
|
+ <textarea id="group-ops-template" rows="3" required placeholder="支持 {{room_name}} 变量">欢迎加入 {{room_name}},今天由您的专属顾问为大家服务。</textarea>
|
|
|
+ <button class="btn" type="submit">创建 SOP 草稿</button>
|
|
|
+ </form>
|
|
|
+ <div class="group-ops-playbooks">
|
|
|
+ ${playbooks.map(item => `<article><div><strong>${escapeHtml(item.name)}</strong><span>v${item.version || 1} · ${escapeHtml(item.group_type)}</span></div>${groupOpsStatusBadge(item.status)}<button class="btn btn-sm btn-secondary" data-group-ops-preview="${escapeHtml(item.id)}">影响预览</button><button class="btn btn-sm btn-secondary" data-group-ops-new-version="${escapeHtml(item.id)}" data-template="${escapeHtml(item.content?.cadence?.[0]?.template || '')}">新版本</button>${item.status !== 'published' ? `<button class="btn btn-sm" data-group-ops-publish="${escapeHtml(item.id)}">发布</button>` : `<button class="btn btn-sm btn-secondary" data-group-ops-rollback="${escapeHtml(item.id)}">回滚</button>`}</article>`).join('') || '<p class="agent-muted">还没有 SOP。创建草稿并审核发布后即可生成计划。</p>'}
|
|
|
+ </div>
|
|
|
+ <form id="group-ops-plan-form" class="group-ops-form group-ops-plan-form">
|
|
|
+ <label for="group-ops-room-id">客户群 ID</label><input id="group-ops-room-id" required placeholder="roomId" />
|
|
|
+ <label for="group-ops-room-name">群名称</label><input id="group-ops-room-name" placeholder="例如:张先生服务群" />
|
|
|
+ <button class="btn" type="submit" ${playbooks.some(item => item.status === 'published') ? '' : 'disabled'}>生成今日计划</button>
|
|
|
+ </form>
|
|
|
+ </section>
|
|
|
+ <section class="card group-ops-workbench" data-group-ops-panel="workbench" ${hidden('workbench')}>
|
|
|
+ <div class="card-header"><div><h3>批量生成与整改任务</h3><p>按门店生成今日计划;失败群不会影响成功群。</p></div><span>${tasks.filter(item => ['open','in_progress'].includes(item.status)).length} 个整改任务</span></div>
|
|
|
+ <form id="group-ops-batch-form" class="group-ops-inline-form"><label for="group-ops-store-id">门店</label><select id="group-ops-store-id"><option value="">全部门店</option>${stores.map(item => `<option value="${escapeHtml(item.storeId === '未分配门店' ? '' : item.storeId)}">${escapeHtml(item.storeId)}</option>`).join('')}</select><button class="btn" type="submit" ${playbooks.some(item => item.status === 'published') ? '' : 'disabled'}>批量生成今日计划</button></form>
|
|
|
+ <div class="group-ops-task-list">${tasks.map(task => `<article><div><strong>${escapeHtml(task.title)}</strong><span>${escapeHtml(task.room_id || '跨群')} · ${escapeHtml(task.owner || '待分配')} · ${escapeHtml(task.due_at || '未设期限')}</span></div>${groupOpsStatusBadge(task.status)}${task.status !== 'done' && task.owner && task.due_at && !task.official_todo_id ? `<button class="btn btn-sm btn-secondary" data-group-ops-task-sync="${escapeHtml(task.id)}" data-owner="${escapeHtml(task.owner)}" data-due-at="${escapeHtml(task.due_at)}">同步企微待办</button>` : ''}${task.status !== 'done' ? `<button class="btn btn-sm btn-secondary" data-group-ops-task-done="${escapeHtml(task.id)}" data-official="${escapeHtml(task.official_todo_id || '')}">完成</button>` : ''}</article>`).join('') || '<p class="agent-muted">当前没有整改任务。</p>'}</div>
|
|
|
+ </section>
|
|
|
+ <section class="card group-ops-risks" data-group-ops-panel="quality" ${hidden('quality')}>
|
|
|
+ <div class="card-header"><div><h3>质检规则</h3><p>配置当前账号的响应 SLA、风险词和禁止承诺。</p></div><span>误报率 ${overview.feedback?.falsePositiveRate || 0}%</span></div>
|
|
|
+ <form id="group-ops-quality-form" class="group-ops-inline-form group-ops-quality-form"><label for="group-ops-sla">响应 SLA(分钟)</label><input id="group-ops-sla" type="number" min="1" value="${Number(settings.sla_minutes || 120)}" /><label for="group-ops-risk-terms">风险词</label><input id="group-ops-risk-terms" value="${escapeHtml((settings.risk_terms || []).join(','))}" /><label for="group-ops-forbidden">禁止承诺</label><input id="group-ops-forbidden" value="${escapeHtml((settings.forbidden_claims || []).join(','))}" /><button class="btn" type="submit">保存规则</button></form>
|
|
|
+ <div class="card-header"><div><h3>质检与预警</h3><p>所有结论都保留原消息证据。</p></div><span class="badge badge-error">${findings.length} 项</span></div>
|
|
|
+ <div class="group-ops-list">
|
|
|
+ ${findings.map(item => `<article class="group-ops-risk ${escapeHtml(item.severity)}"><div><strong>${escapeHtml(item.title)}</strong><p>${escapeHtml(item.detail || '')}</p><small>${escapeHtml(item.room_id)} · ${escapeHtml(item.evidence?.[0]?.content || '证据已记录')}</small></div><div>${groupOpsStatusBadge(item.status)}<button class="btn btn-sm btn-secondary" data-group-ops-create-task="${escapeHtml(item.id)}" data-room-id="${escapeHtml(item.room_id)}" data-title="${escapeHtml(item.title)}">建整改任务</button><button class="btn btn-sm btn-secondary" data-group-ops-false-positive="${escapeHtml(item.id)}">标记误报</button><button class="btn btn-sm" data-group-ops-finding="${escapeHtml(item.id)}">关闭</button></div></article>`).join('') || '<div class="empty-state"><div class="empty-state-title">当前没有待处理预警</div><div class="empty-state-desc">通过 Agent 质检群消息后,风险会出现在这里。</div></div>'}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <section class="card group-ops-workbench" data-group-ops-panel="workbench" ${hidden('workbench')}>
|
|
|
+ <div class="card-header"><div><h3>今日工作台</h3><p>话术须人工批准;实际人工发送后再标记完成。</p></div><span>${plans.length} 个群计划</span></div>
|
|
|
+ <div class="table-wrap"><table><thead><tr><th>客户群</th><th>时间与目标</th><th>话术草稿</th><th>状态</th><th>操作</th></tr></thead><tbody>
|
|
|
+ ${plans.flatMap(plan => plan.items.map(item => `<tr><td><strong>${escapeHtml(plan.room_id)}</strong><br><small>${escapeHtml(plan.plan_date)}</small></td><td>${escapeHtml(formatAgentTime(item.scheduled_at))}<br><small>${escapeHtml(item.objective || item.type)}</small></td><td class="group-ops-copy">${escapeHtml(item.final_content || item.draft_content || '-')}</td><td>${groupOpsStatusBadge(item.status)}</td><td>${item.status === 'pending_review' ? `<button class="btn btn-sm" data-group-ops-item="${escapeHtml(item.id)}" data-action="approve">批准</button>` : item.status === 'approved' ? `<button class="btn btn-sm" data-group-ops-item="${escapeHtml(item.id)}" data-action="mark-sent">标记已执行</button>` : '-'}</td></tr>`)).join('') || '<tr><td colspan="5"><div class="empty-state"><div class="empty-state-title">今天还没有运营计划</div><div class="empty-state-desc">选择已发布 SOP,并为客户群生成今日计划。</div></div></td></tr>'}
|
|
|
+ </tbody></table></div>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadGroupOperations(page) {
|
|
|
+ state.groupOps.loading = true;
|
|
|
+ try {
|
|
|
+ const key = encodeURIComponent(groupOpsAccountKey());
|
|
|
+ const [overview, playbooks] = await Promise.all([
|
|
|
+ api('GET', `/api/group-ops/overview?accountKey=${key}`),
|
|
|
+ api('GET', `/api/group-ops/playbooks?accountKey=${key}`)
|
|
|
+ ]);
|
|
|
+ state.groupOps.overview = overview.data;
|
|
|
+ state.groupOps.playbooks = playbooks.data?.playbooks || [];
|
|
|
+ renderGroupOpsContent(page);
|
|
|
+ bindGroupOperations(page);
|
|
|
+ } catch (error) { showError(page, error.message || '社群运营数据加载失败', () => loadGroupOperations(page)); }
|
|
|
+ finally { state.groupOps.loading = false; }
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindGroupOperations(page) {
|
|
|
+ page.querySelector('#group-ops-refresh')?.addEventListener('click', () => loadGroupOperations(page));
|
|
|
+ page.querySelectorAll('[data-group-ops-view]').forEach(button => button.addEventListener('click', () => {
|
|
|
+ state.groupOps.view = button.dataset.groupOpsView;
|
|
|
+ renderGroupOpsContent(page);
|
|
|
+ bindGroupOperations(page);
|
|
|
+ }));
|
|
|
+ page.querySelector('#group-ops-automation-form')?.addEventListener('submit', async event => {
|
|
|
+ event.preventDefault(); const button = event.submitter; if (button) button.disabled = true;
|
|
|
+ const ids = page.querySelector('#group-ops-auto-groups').value.split(/[,,\s]+/).map(item => item.trim()).filter(Boolean);
|
|
|
+ const types = page.querySelector('#group-ops-auto-types').value.split(/[,,\s]+/).map(item => item.trim()).filter(Boolean);
|
|
|
+ try {
|
|
|
+ const result = await api('POST', '/api/group-ops/automation', { accountKey: groupOpsAccountKey(), action: 'create_policy', policy: { enabled: true, mode: 'dry_run', allowedGroupIds: ids, allowedTypes: types, windows: [{ days: [1,2,3,4,5], start: page.querySelector('#group-ops-auto-window-start').value, end: page.querySelector('#group-ops-auto-window-end').value }], grayPercent: Number(page.querySelector('#group-ops-auto-gray').value), dailyLimitPerGroup: Number(page.querySelector('#group-ops-auto-daily').value), weeklyLimitPerGroup: 7, consecutiveFailureLimit: Number(page.querySelector('#group-ops-auto-failures').value), requireNoOpenHighRisk: true }, changeNote: 'Dashboard 创建受控自动化策略', actor: 'dashboard' });
|
|
|
+ if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage || '策略草稿已保存'); await loadGroupOperations(page);
|
|
|
+ } catch (error) { toast(error.message || '保存策略失败', 'error'); if (button) button.disabled = false; }
|
|
|
+ });
|
|
|
+ page.querySelectorAll('[data-group-ops-policy-activate]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const ok = await confirmModal('激活后仍保持暂停。请确认白名单、时间窗、频控、灰度和熔断阈值均正确。', { title: '激活自动化策略' });
|
|
|
+ if (!ok) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { const result = await api('POST', '/api/group-ops/automation', { accountKey: groupOpsAccountKey(), action: 'activate', policyId: button.dataset.groupOpsPolicyActivate, confirmed: true, actor: 'dashboard' }); if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '策略激活失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-automation-action]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const action = button.dataset.groupOpsAutomationAction;
|
|
|
+ const confirmations = { resume: '恢复自动化运行?真实发送仍受独立环境开关保护。', pause: '立即暂停所有社群自动化?', reset_breaker: '确认故障原因已经排除并人工复位熔断?', run: '执行一次 dry-run?不会向真实群发送消息。' };
|
|
|
+ if (confirmations[action]) {
|
|
|
+ const ok = await confirmModal(confirmations[action], { title: '自动化操作确认' });
|
|
|
+ if (!ok) return;
|
|
|
+ }
|
|
|
+ button.disabled = true;
|
|
|
+ try { const result = await api('POST', '/api/group-ops/automation', { accountKey: groupOpsAccountKey(), action, execute: false, confirmed: ['resume','reset_breaker'].includes(action), actor: 'dashboard' }); if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage || '自动化操作完成'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '自动化操作失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelector('#group-ops-playbook-form')?.addEventListener('submit', async event => {
|
|
|
+ event.preventDefault();
|
|
|
+ const button = event.submitter; if (button) button.disabled = true;
|
|
|
+ try {
|
|
|
+ const name = page.querySelector('#group-ops-playbook-name').value.trim();
|
|
|
+ const template = page.querySelector('#group-ops-template').value.trim();
|
|
|
+ const result = await api('POST', '/api/group-ops/playbooks', { accountKey: groupOpsAccountKey(), name, groupType: 'customer', content: { cadence: [{ time: '09:30', type: 'welcome', objective: '完成标准客户服务触达', template }], rules: { responseSlaMinutes: 120, riskTerms: ['投诉','退款','举报'] } }, actor: 'dashboard' });
|
|
|
+ toast(result.assistantMessage || 'SOP 草稿已创建'); await loadGroupOperations(page);
|
|
|
+ } catch (error) { toast(error.message || '创建失败', 'error'); if (button) button.disabled = false; }
|
|
|
+ });
|
|
|
+ page.querySelectorAll('[data-group-ops-publish]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ button.disabled = true;
|
|
|
+ try {
|
|
|
+ const preview = await api('GET', `/api/group-ops/playbooks/${button.dataset.groupOpsPublish}/preview?accountKey=${encodeURIComponent(groupOpsAccountKey())}`);
|
|
|
+ const impact = preview.data?.preview;
|
|
|
+ const ok = await confirmModal(`将发布 v${impact?.targetVersion || '-'},影响 ${impact?.affectedGroupCount || 0} 个群、${impact?.affectedStores?.length || 0} 家门店。确认发布吗?`, { title: '发布 SOP' });
|
|
|
+ if (!ok) { button.disabled = false; return; }
|
|
|
+ await api('POST', `/api/group-ops/playbooks/${button.dataset.groupOpsPublish}/publish`, { accountKey: groupOpsAccountKey(), confirmed: true, actor: 'dashboard' }); toast('SOP 已发布'); await loadGroupOperations(page);
|
|
|
+ }
|
|
|
+ catch (error) { toast(error.message || '发布失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-preview]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ try {
|
|
|
+ const result = await api('GET', `/api/group-ops/playbooks/${button.dataset.groupOpsPreview}/preview?accountKey=${encodeURIComponent(groupOpsAccountKey())}`);
|
|
|
+ const item = result.data?.preview;
|
|
|
+ await alertModal(`目标版本:v${item.targetVersion}\n当前版本:${item.currentVersion ? `v${item.currentVersion}` : '未发布'}\n影响群:${item.affectedGroupCount}\n影响门店:${item.affectedStores.length}\n变更字段:${item.changes.map(change => change.key).join('、') || '无'}`, { title: 'SOP 影响预览' });
|
|
|
+ }
|
|
|
+ catch (error) { toast(error.message || '预览失败', 'error'); }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-new-version]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const template = await promptModal('请输入新版本的首条标准话术:', button.dataset.template || '', { title: '创建新版本', label: '首条标准话术' });
|
|
|
+ if (template === null || !template.trim()) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { await api('POST', `/api/group-ops/playbooks/${button.dataset.groupOpsNewVersion}/versions`, { accountKey: groupOpsAccountKey(), content: { cadence: [{ time: '09:30', type: 'content', objective: '完成标准客户服务触达', template: template.trim() }] }, changeNote: '由 Dashboard 创建新版本', actor: 'dashboard' }); toast('新版本草稿已创建'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '创建版本失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-rollback]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const versionText = await promptModal('请输入要回滚并重新发布的版本号:', '1', { title: 'SOP 回滚', label: '版本号' });
|
|
|
+ if (versionText === null) return;
|
|
|
+ const version = Number(versionText);
|
|
|
+ if (!Number.isInteger(version) || version < 1) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try {
|
|
|
+ const preview = await api('GET', `/api/group-ops/playbooks/${button.dataset.groupOpsRollback}/preview?accountKey=${encodeURIComponent(groupOpsAccountKey())}&version=${version}`);
|
|
|
+ const impact = preview.data?.preview;
|
|
|
+ const ok = await confirmModal(`确认回滚到 v${version}?将影响 ${impact?.affectedGroupCount || 0} 个群。`, { title: '确认回滚' });
|
|
|
+ if (!ok) { button.disabled = false; return; }
|
|
|
+ await api('POST', `/api/group-ops/playbooks/${button.dataset.groupOpsRollback}/rollback`, { accountKey: groupOpsAccountKey(), version, confirmed: true, actor: 'dashboard' }); toast(`已回滚到 v${version}`); await loadGroupOperations(page);
|
|
|
+ }
|
|
|
+ catch (error) { toast(error.message || '回滚失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelector('#group-ops-plan-form')?.addEventListener('submit', async event => {
|
|
|
+ event.preventDefault(); const button = event.submitter; if (button) button.disabled = true;
|
|
|
+ const roomId = page.querySelector('#group-ops-room-id').value.trim(); const roomName = page.querySelector('#group-ops-room-name').value.trim();
|
|
|
+ try { const result = await api('POST', `/api/group-ops/groups/${encodeURIComponent(roomId)}/generate-plan`, { accountKey: groupOpsAccountKey(), roomName, group: { roomId, roomName }, actor: 'dashboard' }); toast(result.assistantMessage || '计划已生成'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '生成计划失败', 'error'); if (button) button.disabled = false; }
|
|
|
+ });
|
|
|
+ page.querySelectorAll('[data-group-ops-item]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const action = button.dataset.action; const label = action === 'approve' ? '批准这条最终话术' : '确认已由人工实际发送';
|
|
|
+ const ok = await confirmModal(`${label}?此操作会写入审计记录。`, { title: '计划项确认' });
|
|
|
+ if (!ok) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { await api('POST', `/api/group-ops/plan-items/${button.dataset.groupOpsItem}/${action}`, { accountKey: groupOpsAccountKey(), actor: 'dashboard' }); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '操作失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelector('#group-ops-batch-form')?.addEventListener('submit', async event => {
|
|
|
+ event.preventDefault(); const button = event.submitter;
|
|
|
+ const ok = await confirmModal('将为筛选范围内所有客户群生成今日待审核计划,确认继续?', { title: '批量生成计划' });
|
|
|
+ if (!ok) return;
|
|
|
+ if (button) button.disabled = true;
|
|
|
+ try { const storeId = page.querySelector('#group-ops-store-id').value; const result = await api('POST', '/api/group-ops/plans/batch', { accountKey: groupOpsAccountKey(), storeId, actor: 'dashboard' }); toast(result.assistantMessage || '批量计划已完成'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '批量生成失败', 'error'); if (button) button.disabled = false; }
|
|
|
+ });
|
|
|
+ page.querySelector('#group-ops-quality-form')?.addEventListener('submit', async event => {
|
|
|
+ event.preventDefault(); const button = event.submitter; if (button) button.disabled = true;
|
|
|
+ try { await api('POST', '/api/group-ops/quality-settings', { accountKey: groupOpsAccountKey(), slaMinutes: Number(page.querySelector('#group-ops-sla').value), riskTerms: page.querySelector('#group-ops-risk-terms').value.split(/[,,]/).map(item => item.trim()).filter(Boolean), forbiddenClaims: page.querySelector('#group-ops-forbidden').value.split(/[,,]/).map(item => item.trim()).filter(Boolean), actor: 'dashboard' }); toast('质检规则已保存'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '保存规则失败', 'error'); if (button) button.disabled = false; }
|
|
|
+ });
|
|
|
+ page.querySelectorAll('[data-group-ops-create-task]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const owner = await promptModal('请输入整改负责人姓名(可暂时留空):', '', { title: '创建整改任务', label: '负责人', required: false });
|
|
|
+ if (owner === null) return;
|
|
|
+ const dueAt = await promptModal('请输入截止时间,例如 2026-07-20 18:00(可暂时留空):', '', { title: '创建整改任务', label: '截止时间', required: false });
|
|
|
+ if (dueAt === null) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { await api('POST', '/api/group-ops/tasks', { accountKey: groupOpsAccountKey(), findingId: button.dataset.groupOpsCreateTask, roomId: button.dataset.roomId, title: `处理:${button.dataset.title}`, owner, dueAt, priority: 'high', actor: 'dashboard' }); toast('整改任务已创建'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '创建任务失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-false-positive]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const reason = await promptModal('请说明误报原因,该反馈会进入质检评测集:', '规则不适用于当前语境', { title: '标记误报', label: '误报原因' });
|
|
|
+ if (reason === null) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { await api('POST', `/api/group-ops/findings/${button.dataset.groupOpsFalsePositive}`, { accountKey: groupOpsAccountKey(), status: 'false_positive', resolution: reason, actor: 'dashboard' }); toast('已标记误报并保存反馈'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '提交反馈失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-task-done]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const hasOfficial = Boolean(button.dataset.official);
|
|
|
+ const ok = await confirmModal(hasOfficial ? '完成后将同步完成企微官方待办,确认继续?' : '确认完成该整改任务?', { title: '完成整改任务' });
|
|
|
+ if (!ok) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { await api('POST', `/api/group-ops/tasks/${button.dataset.groupOpsTaskDone}/update`, { accountKey: groupOpsAccountKey(), status: 'done', confirmed: hasOfficial, actor: 'dashboard' }); toast('整改任务已完成'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '任务更新失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-task-sync]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const ok = await confirmModal(`将为 ${button.dataset.owner} 创建真实企微待办,截止 ${button.dataset.dueAt}。确认继续?`, { title: '同步企微待办' });
|
|
|
+ if (!ok) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { const result = await api('POST', `/api/group-ops/tasks/${button.dataset.groupOpsTaskSync}/sync-official`, { accountKey: groupOpsAccountKey(), owner: button.dataset.owner, dueAt: button.dataset.dueAt, confirmed: true, actor: 'dashboard' }); if (result.status === 'error') throw new Error(result.assistantMessage); toast(result.assistantMessage || '已同步企微待办'); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '企微待办同步失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ page.querySelectorAll('[data-group-ops-finding]').forEach(button => button.addEventListener('click', async () => {
|
|
|
+ const ok = await confirmModal('确认该风险已经处理完成?', { title: '关闭风险' });
|
|
|
+ if (!ok) return;
|
|
|
+ button.disabled = true;
|
|
|
+ try { await api('POST', `/api/group-ops/findings/${button.dataset.groupOpsFinding}`, { accountKey: groupOpsAccountKey(), status: 'resolved', resolution: '由 Dashboard 人工确认关闭', actor: 'dashboard' }); await loadGroupOperations(page); }
|
|
|
+ catch (error) { toast(error.message || '关闭失败', 'error'); button.disabled = false; }
|
|
|
+ }));
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderGroupOperationsPage() {
|
|
|
+ els.pageTitle.textContent = '社群运营';
|
|
|
+ const page = document.createElement('div'); page.className = 'page customer-workspace-page group-ops-page'; els.content.appendChild(page);
|
|
|
+ page.innerHTML = '<div class="loading"><div class="spinner"></div><span>正在汇总社群运营数据...</span></div>';
|
|
|
+ await loadGroupOperations(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderGroupsPage() {
|
|
|
+ els.pageTitle.textContent = '客户群管理';
|
|
|
+ state.selectedGroupIds = state.selectedGroupIds || new Set();
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page customer-workspace-page';
|
|
|
+ page.innerHTML = `
|
|
|
+ <section class="workspace-hero customer-workspace-hero group-hero">
|
|
|
+ <div>
|
|
|
+ <span>WECOM CUSTOMER GROUP OPERATIONS</span>
|
|
|
+ <h2>客户群管理</h2>
|
|
|
+ <p>识别并确认真实客户群,确认后自动接收公网回调中的群新消息。</p>
|
|
|
+ </div>
|
|
|
+ ${renderGroupStatsCard(null)}
|
|
|
+ </section>
|
|
|
+ <section class="group-workspace">
|
|
|
+ <aside class="group-sync-panel">${renderGroupSyncCard()}</aside>
|
|
|
+ <main class="group-list-panel">
|
|
|
+ <div class="operation-note">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z"/></svg>
|
|
|
+ <div><strong>先识别,再确认</strong><span>确认后的客户群会自动接收回调新消息;未确认的普通群不会进入客户运营流程。</span></div>
|
|
|
+ </div>
|
|
|
+ ${renderGroupFilters()}
|
|
|
+ <div id="groups-table-card"></div>
|
|
|
+ </main>
|
|
|
+ </section>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+
|
|
|
+ bindGroupSync(page);
|
|
|
+ bindGroupFilters(page);
|
|
|
+
|
|
|
+ await loadGroups();
|
|
|
+ if (!state.groups.length) startAutomaticGroupSync();
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroupSyncCard() {
|
|
|
+ return `
|
|
|
+ <div class="card group-sync-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="group-sync-fields">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>扫描范围</label>
|
|
|
+ <select id="sync-scope">
|
|
|
+ <option value="all">全部群</option>
|
|
|
+ <option value="self">我创建的群</option>
|
|
|
+ <option value="session">最近聊天里的群</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>扫描深度</label>
|
|
|
+ <input id="sync-max-pages" type="number" value="20" min="1" max="300" />
|
|
|
+ <span class="field-help">数值越大扫描越完整,耗时也越长</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <details class="advanced-settings">
|
|
|
+ <summary>识别规则设置</summary>
|
|
|
+ <div class="advanced-settings-body">
|
|
|
+ <div class="form-group">
|
|
|
+ <label>客户群关键词(用逗号隔开)</label>
|
|
|
+ <textarea id="sync-keywords" rows="3">客户群,服务群,售后群,咨询群,VIP群,专属群,业主群,项目群,订单群,用户群</textarea>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>高置信度词</label>
|
|
|
+ <input id="sync-high-confidence" type="text" value="客户群,服务群" />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </details>
|
|
|
+
|
|
|
+ <div class="checkbox-row group-auto-classify">
|
|
|
+ <input id="sync-auto-classify" type="checkbox" checked />
|
|
|
+ <label for="sync-auto-classify">自动识别客户群</label>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <button class="btn group-primary-action" 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="workspace-stat-row group-stat-row" id="group-stats">
|
|
|
+ <div><strong data-count="${s.total || 0}">${formatNumber(s.total)}</strong><span>已扫描群聊</span></div>
|
|
|
+ <div><strong data-count="${(s.autoConfirmed || 0) + (s.confirmed || 0)}">${formatNumber((s.autoConfirmed || 0) + (s.confirmed || 0))}</strong><span>确认客户群</span></div>
|
|
|
+ <div><strong data-count="${s.suggested || 0}">${formatNumber(s.suggested)}</strong><span>待人工确认</span></div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderGroupFilters() {
|
|
|
+ return `
|
|
|
+ <div class="card group-filter-card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div><h3 class="card-title">群列表</h3><div class="card-subtitle">识别结果与回调接入范围</div></div>
|
|
|
+ <div class="filter-bar">
|
|
|
+ <input id="filter-keyword" type="text" placeholder="搜索群名" value="${escapeHtml(state.groupFilters.keyword)}" />
|
|
|
+ <select id="filter-status">
|
|
|
+ <option value="" ${state.groupFilters.status === '' ? 'selected' : ''}>全部状态</option>
|
|
|
+ <option value="AUTO_CONFIRMED" ${state.groupFilters.status === 'AUTO_CONFIRMED' ? 'selected' : ''}>自动确认</option>
|
|
|
+ <option value="SUGGESTED" ${state.groupFilters.status === 'SUGGESTED' ? 'selected' : ''}>建议确认</option>
|
|
|
+ <option value="CONFIRMED" ${state.groupFilters.status === 'CONFIRMED' ? 'selected' : ''}>已确认</option>
|
|
|
+ <option value="REJECTED" ${state.groupFilters.status === 'REJECTED' ? 'selected' : ''}>已忽略</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>
|
|
|
+ </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>命中关键词</th>
|
|
|
+ <th style="width:120px">操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ ${filtered.map((g, i) => {
|
|
|
+ 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>${(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>` : ''}
|
|
|
+ </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);
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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 === '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, accountKey: groupOpsAccountKey() });
|
|
|
+ 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, accountKey: groupOpsAccountKey() });
|
|
|
+ success++;
|
|
|
+ } catch (err) {
|
|
|
+ failed++;
|
|
|
+ console.error('确认客户群失败', roomId, err);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ toast(`批量确认完成:成功 ${success} 个${failed ? `,失败 ${failed} 个` : ''}`);
|
|
|
+ loadGroups();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadGroups() {
|
|
|
+ try {
|
|
|
+ const groupsRes = await api('GET', '/api/groups/list');
|
|
|
+ state.groups = groupsRes.data?.groups || [];
|
|
|
+ updateGroupStats(groupsRes.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 startAutomaticGroupSync() {
|
|
|
+ if (state.groupAutoSyncStarted) return;
|
|
|
+ const account = currentAccount();
|
|
|
+ const guid = getGuid();
|
|
|
+ if (!guid || !account?.online) return;
|
|
|
+ state.groupAutoSyncStarted = true;
|
|
|
+ const tracker = document.getElementById('sync-job-tracker');
|
|
|
+ if (tracker) tracker.innerHTML = '<div class="loading"><div class="spinner"></div><span>正在自动读取企业微信群目录...</span></div>';
|
|
|
+ startJob('sync-groups', '/api/groups/sync', {
|
|
|
+ guid,
|
|
|
+ scope: 'all',
|
|
|
+ maxPages: 20,
|
|
|
+ customerKeywords: ['客户群', '服务群', '售后群', '咨询群', 'VIP群', '专属群', '业主群', '项目群', '订单群', '用户群'],
|
|
|
+ highConfidenceTerms: ['客户群', '服务群'],
|
|
|
+ autoClassify: true,
|
|
|
+ }, result => {
|
|
|
+ if (tracker) tracker.innerHTML = '';
|
|
|
+ updateGroupStats(result.summary);
|
|
|
+ loadGroups();
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ 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
|
|
|
+ };
|
|
|
+ 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 => {
|
|
|
+ tracker.innerHTML = '';
|
|
|
+ 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;
|
|
|
+ saveFilters(GROUP_FILTERS_KEY, state.groupFilters);
|
|
|
+ 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 ? '展示全部状态的群聊' : '只看可能的客户群';
|
|
|
+ saveFilters(GROUP_FILTERS_KEY, state.groupFilters);
|
|
|
+ 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) {
|
|
|
+ // 没有明确外部标识时,排除常见内部角色与当前账号后取第一个
|
|
|
+ const current = currentAccount();
|
|
|
+ const currentIds = new Set([current?.userId, current?.guid, current?.nickname].filter(Boolean));
|
|
|
+ suggestedCustomer = members.find(m => {
|
|
|
+ const name = String(m.userName || '').trim();
|
|
|
+ const id = String(m.userId || '').trim();
|
|
|
+ if (!name) return false;
|
|
|
+ if (currentIds.has(name) || currentIds.has(id)) return false;
|
|
|
+ return !/^(企业微信|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');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ // ==================== 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" rel="noopener">${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);
|
|
|
+ }
|
|
|
+
|
|
|
+ function customerProfileInput(profile, ...keys) {
|
|
|
+ for (const key of keys) {
|
|
|
+ const value = profile?.[key];
|
|
|
+ if (value !== undefined && value !== null) return Array.isArray(value) ? value.join('、') : String(value);
|
|
|
+ }
|
|
|
+ return '';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCustomerMasterSection() {
|
|
|
+ const hub = state.customerOps.master.hub || { summary: {}, data: { customers: [], policy: {} } };
|
|
|
+ const summary = hub.summary || {};
|
|
|
+ const data = hub.data || {};
|
|
|
+ const keyword = String(state.customerOps.master.keyword || '').trim().toLowerCase();
|
|
|
+ const customers = (data.customers || []).filter(customer => !keyword || [customer.displayName, customer.maskedId, customer.stage?.label, ...(customer.fields || []).flatMap(field => [field.label, field.displayValue]), ...(customer.tags || [])].some(value => String(value || '').toLowerCase().includes(keyword)));
|
|
|
+ const selected = (data.customers || []).find(item => item.id === state.customerOps.master.selectedId) || customers[0] || data.customers?.[0] || null;
|
|
|
+ if (selected) state.customerOps.master.selectedId = selected.id;
|
|
|
+ const profile = selected?.profile || {};
|
|
|
+ const evidenceItems = (selected?.fields || []).filter(field => field.evidence);
|
|
|
+ const openTasks = (selected?.tasks || []).filter(task => ['open', 'in_progress'].includes(task.status));
|
|
|
+ const openAlerts = (selected?.alerts || []).filter(alert => alert.status === 'open');
|
|
|
+ const nextActions = selected?.nextActions || [];
|
|
|
+ const timeline = selected?.timeline || [];
|
|
|
+ const intelligenceItems = `${openTasks.map(task => `<div class="customer-master-action-item"><strong>${escapeHtml(task.title)}</strong><p>${escapeHtml(task.evidence || task.reason || '')}</p><small>内部任务 · ${escapeHtml(agentTaskStatusLabel(task.status))}</small></div>`).join('')}${openAlerts.map(alert => `<div class="customer-master-action-item alert"><strong>${escapeHtml(alert.title)}</strong><p>${escapeHtml(alert.evidence || alert.detail || '')}</p><small>客户预警 · ${escapeHtml(alert.severity)}</small></div>`).join('')}`;
|
|
|
+ const isFallback = Boolean(summary.fallback);
|
|
|
+ const fallbackBadge = isFallback ? '<span class="customer-fallback-badge">客户运营数据</span>' : '';
|
|
|
+ const detail = selected ? `<article class="customer-master-detail">
|
|
|
+ <header class="customer-master-detail-head"><div><span>${isFallback ? 'CUSTOMER OPS REFERENCE' : 'REAL WECOM CUSTOMER'}</span><h3>${escapeHtml(selected.displayName)} ${fallbackBadge}</h3><p>${escapeHtml(selected.maskedId)} · ${escapeHtml(selected.source)} · 最近互动 ${escapeHtml(formatAgentTime(selected.lastMessageAt))}</p></div><div><span class="customer-stage ${escapeHtml(selected.stage?.key)}">${escapeHtml(selected.stage?.label)}</span>${selected.fallback ? '' : `<button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">转到智能会话</button>`}</div></header>
|
|
|
+ <div class="customer-master-score-row"><div><strong>${selected.completenessScore}%</strong><span>画像完整度</span></div><div><strong>${selected.evidenceCoverage}%</strong><span>字段证据覆盖</span></div><div><strong>${selected.messageCount}</strong><span>已纳入消息</span></div><div><strong>${selected.openTaskCount}</strong><span>待跟进任务</span></div></div>
|
|
|
+ <section class="customer-profile-fields"><header><strong>自动沉淀画像</strong><span>${escapeHtml(formatAgentTime(selected.profileUpdatedAt))} 更新</span></header><div>${(selected.fields || []).map(field => `<div><small>${escapeHtml(field.label)}</small><strong>${escapeHtml(field.displayValue || '-')}</strong><em>${field.evidence ? field.evidence.source === 'human' ? '人工核对' : '有原话依据' : '历史字段 · 待补依据'}</em></div>`).join('') || '<p class="agent-muted">还没有形成结构化画像,后续真实对话会持续补齐。</p>'}</div>${selected.tags?.length ? `<footer>${selected.tags.map(tag => `<span>${escapeHtml(tag)}</span>`).join('')}</footer>` : ''}</section>
|
|
|
+ <section class="customer-next-action-section"><header><div><strong>下一步行动工作台</strong><span>规则建议 · 需人工判断 · 不自动外发</span></div><em>${nextActions.length} 项建议</em></header><div>${nextActions.map(action => `<article class="customer-next-action-card ${escapeHtml(action.priority)}"><div><span>${action.confidence === 'rule' ? '规则建议' : 'AI 建议'}</span><strong>${escapeHtml(action.title)}</strong><p>${escapeHtml(action.description)}</p></div><ul>${(action.evidence || []).slice(0, 3).map(item => `<li>${escapeHtml(item)}</li>`).join('') || '<li>当前依据待补充</li>'}</ul>${selected.fallback ? '' : `<button class="btn btn-sm btn-secondary" data-customer-master-open="${escapeHtml(selected.id)}">去智能会话处理</button>`}</article>`).join('') || '<p class="agent-muted">当前没有新的行动建议。</p>'}</div></section>
|
|
|
+ <div class="customer-master-intelligence-grid">
|
|
|
+ <section><header><strong>字段依据</strong><span>${evidenceItems.length} 条</span></header>${evidenceItems.map(field => `<div class="customer-evidence-item"><strong>${escapeHtml(field.label)}</strong><p>${escapeHtml(field.evidence.text || '人工核对')}</p><small>${field.evidence.source === 'human' ? '人工修正记录' : '真实企微消息'} · ${escapeHtml(formatAgentTime(field.evidence.updatedAt))}</small></div>`).join('') || '<p class="agent-muted">现有历史画像没有逐字段原话定位;新消息产生的字段会自动记录证据。</p>'}</section>
|
|
|
+ <section><header><strong>跟进任务与预警</strong><span>${openTasks.length + openAlerts.length} 项</span></header>${intelligenceItems || '<p class="agent-muted">当前没有待处理任务或预警。</p>'}</section>
|
|
|
+ </div>
|
|
|
+ ${selected.latestMessage ? `<section class="customer-latest-message"><strong>最近纳入主档的消息</strong><p>${escapeHtml(selected.latestMessage.content)}</p><small>${selected.latestMessage.role === 'customer' ? '客户' : '内部回复'} · ${escapeHtml(formatAgentTime(selected.latestMessage.timestamp))}</small></section>` : ''}
|
|
|
+ <section class="customer-timeline-section"><header><div><strong>客户时间线</strong><span>消息、画像、任务和预警统一留痕</span></div><em>${timeline.length} 个事件</em></header><div>${timeline.slice(0, 20).map(event => `<article class="customer-timeline-item ${escapeHtml(event.type)}"><i></i><time>${escapeHtml(formatAgentTime(event.occurredAt))}</time><div><strong>${escapeHtml(event.title)}</strong><p>${escapeHtml(event.description || '')}</p><small>${escapeHtml(event.source || '内部主账')}${event.status ? ` · ${escapeHtml(event.status)}` : ''}</small></div></article>`).join('') || '<p class="agent-muted">暂无客户事件。</p>'}</div></section>
|
|
|
+ ${selected.fallback ? `<div class="customer-master-fallback-note"><strong>参考数据</strong><span>该客户来自客户运营导入或群成员发现,尚未建立真实企微会话。同步会话后,画像、任务和证据会自动沉淀到这里。</span></div>` : `<form class="customer-master-edit-form" id="customer-master-profile-form" data-customer-id="${escapeHtml(selected.id)}"><header><div><strong>人工核对画像</strong><span>只修正主档,不会向客户发送消息</span></div><button class="btn btn-sm" type="submit" ${state.customerOps.master.loading ? 'disabled' : ''}>保存主档</button></header><div><label><span>客户意图</span><input name="intent" value="${escapeHtml(customerProfileInput(profile, 'intent', 'purpose'))}" /></label><label><span>核心需求</span><input name="need" value="${escapeHtml(customerProfileInput(profile, 'need', 'needs', 'keyConcerns'))}" /></label><label><span>预算</span><input name="budgetWan" type="number" value="${escapeHtml(customerProfileInput(profile, 'budgetWan', 'budget'))}" /></label><label><span>计划时间</span><input name="timeline" value="${escapeHtml(customerProfileInput(profile, 'timeline'))}" /></label><label><span>决策人</span><input name="decisionMaker" value="${escapeHtml(customerProfileInput(profile, 'decisionMaker'))}" /></label><label><span>标签</span><input name="tags" value="${escapeHtml((selected.tags || []).join(','))}" /></label></div><label class="customer-master-reason"><span>修正依据(可选)</span><input name="reason" placeholder="例如:已电话确认;仅用于审计留痕" /></label></form>`}
|
|
|
+ </article>` : '<div class="library-empty large"><strong>暂无已同步客户</strong><span>从真实企微会话同步消息后,画像会自动沉淀到这里。</span></div>';
|
|
|
+ return `<section class="customer-master-shell">
|
|
|
+ <header class="customer-master-hero"><div><span>CUSTOMER 360 · CANONICAL PROFILE</span><h2>客户管理</h2><p>${isFallback ? '当前展示客户运营导入/群成员发现数据作为参考;同步真实企微会话后会自动替换为画像主档。' : '真实企微消息持续生成客户主档,画像、标签、任务、预警和证据统一归档。'}</p></div><button class="btn secondary" id="btn-customer-master-refresh" ${state.customerOps.master.loading ? 'disabled' : ''}>刷新主档</button></header>
|
|
|
+ <div class="customer-master-stats"><div><strong>${summary.customerCount || 0}</strong><span>${isFallback ? '客户运营客户' : '真实会话客户'}</span></div><div><strong>${summary.profiledCount || 0}</strong><span>已形成画像</span></div><div><strong>${summary.priorityCount || 0}</strong><span>重点跟进</span></div><div><strong>${summary.openTaskCount || 0}</strong><span>待跟进任务</span></div></div>
|
|
|
+ <section class="customer-master-policy ${isFallback ? 'fallback' : ''}"><strong>${isFallback ? '参考视图已启用' : '自动沉淀已启用'}</strong><span>${escapeHtml(data.policy?.autoSync || '')}</span><small>${escapeHtml(data.policy?.evidence || '')}</small></section>
|
|
|
+ <div class="customer-master-layout"><aside><header><strong>客户主档</strong><input id="customer-master-search" value="${escapeHtml(state.customerOps.master.keyword)}" placeholder="搜索姓名、画像或标签" /></header><div>${customers.map(customer => `<button class="customer-master-list-item ${selected?.id === customer.id ? 'active' : ''}" data-customer-master-id="${escapeHtml(customer.id)}"><div><strong>${escapeHtml(customer.displayName)}</strong>${customer.fallback ? '<span class="customer-fallback-badge">参考</span>' : `<span class="customer-stage ${escapeHtml(customer.stage?.key)}">${escapeHtml(customer.stage?.label)}</span>`}</div><p>${(customer.fields || []).slice(0, 3).map(field => `${field.label}:${field.displayValue}`).join(' · ') || '画像待完善'}</p><footer><span>完整度 ${customer.completenessScore}%</span><span>${customer.openTaskCount} 待办 / ${customer.openAlertCount} 预警</span></footer></button>`).join('') || '<p class="agent-muted">没有匹配客户</p>'}</div></aside><main>${detail}</main></div>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function renderCustomerOpsPage() {
|
|
|
+ const renderToken = state.renderToken;
|
|
|
+ els.pageTitle.textContent = '客户管理';
|
|
|
+ void refreshAgentAccountHeader();
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page';
|
|
|
+ els.content.appendChild(page);
|
|
|
+ showLoading(page, 'list');
|
|
|
+ try {
|
|
|
+ state.customerOps.master.hub = await api('GET', '/api/customers');
|
|
|
+ const customers = state.customerOps.master.hub.data?.customers || [];
|
|
|
+ if (!customers.some(item => item.id === state.customerOps.master.selectedId)) state.customerOps.master.selectedId = customers[0]?.id || null;
|
|
|
+ } catch (error) {
|
|
|
+ state.customerOps.master.hub = { status: 'error', summary: {}, data: { customers: [], policy: {} }, assistantMessage: error.message || '客户主档读取失败' };
|
|
|
+ }
|
|
|
+ if (renderToken !== state.renderToken || state.page !== 'customer-ops') return;
|
|
|
+
|
|
|
+ page.innerHTML = `
|
|
|
+ <div id="customer-master-root">${renderCustomerMasterSection()}</div>
|
|
|
+ <section class="workspace-hero customer-workspace-hero" style="margin-top:20px">
|
|
|
+ <div>
|
|
|
+ <span>OPTIONAL BATCH OPERATIONS</span>
|
|
|
+ <h2>批量客户运营(可选)</h2>
|
|
|
+ <p>用于手机号或 externalUserId 导入、批量加好友和自动建群;上方真实企微会话客户无需重复导入。</p>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <div class="dashboard-grid" id="co-dashboard">
|
|
|
+ <div class="kpi-row" id="co-kpi-row"></div>
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3 class="chart-title"><span>好友状态分布</span><span class="chart-subtitle">全部客户</span></h3>
|
|
|
+ <div class="chart-container" id="co-friend-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3 class="chart-title"><span>近7日运营趋势</span><span class="chart-subtitle">加好友 / 建群</span></h3>
|
|
|
+ <div class="chart-container" id="co-trend-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="operation-toolbar">
|
|
|
+ <div class="toolbar-left">
|
|
|
+ <button class="btn" id="btn-import-customers">导入客户</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-co-refresh">刷新</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-co-rebuild">重建索引</button>
|
|
|
+ </div>
|
|
|
+ <div class="toolbar-right">
|
|
|
+ <button class="btn btn-sm" id="btn-batch-add" data-job="batch-add-friends" disabled>批量加好友</button>
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-check-friends" data-job="check-friend-status" disabled>检查好友状态</button>
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-auto-group" data-job="auto-create-group" disabled>为通过好友建群</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="data-table-card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">客户列表</h3>
|
|
|
+ <div class="card-subtitle">支持按好友状态、画像、标签筛选</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="filter-bar" style="margin-bottom:16px">
|
|
|
+ <input id="co-filter-keyword" type="text" placeholder="搜索手机号/姓名/externalUserId" style="width:220px" value="${escapeHtml(state.customerOps.keyword || '')}" />
|
|
|
+ <select id="co-filter-status">
|
|
|
+ <option value="">全部好友状态</option>
|
|
|
+ <option value="ACCEPTED" ${state.customerOps.status === 'ACCEPTED' ? 'selected' : ''}>已是好友</option>
|
|
|
+ <option value="PENDING" ${state.customerOps.status === 'PENDING' ? 'selected' : ''}>待通过</option>
|
|
|
+ <option value="NOT_FOUND" ${state.customerOps.status === 'NOT_FOUND' ? 'selected' : ''}>未找到</option>
|
|
|
+ <option value="FAILED" ${state.customerOps.status === 'FAILED' ? 'selected' : ''}>失败</option>
|
|
|
+ </select>
|
|
|
+ <select id="co-filter-portrait">
|
|
|
+ <option value="">全部画像状态</option>
|
|
|
+ <option value="true" ${state.customerOps.portrait === 'true' ? 'selected' : ''}>已生成画像</option>
|
|
|
+ <option value="false" ${state.customerOps.portrait === 'false' ? 'selected' : ''}>未生成画像</option>
|
|
|
+ </select>
|
|
|
+ <select id="co-filter-tag">
|
|
|
+ <option value="">全部标签</option>
|
|
|
+ </select>
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-co-reset-filters">重置筛选</button>
|
|
|
+ </div>
|
|
|
+ <div id="co-customer-table-wrap"></div>
|
|
|
+ <div id="co-pagination"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ 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');
|
|
|
+ const masterRoot = page.querySelector('#customer-master-root');
|
|
|
+
|
|
|
+ function rerenderCustomerMaster() {
|
|
|
+ if (masterRoot) masterRoot.innerHTML = renderCustomerMasterSection();
|
|
|
+ }
|
|
|
+
|
|
|
+ async function reloadCustomerMaster(message = '') {
|
|
|
+ state.customerOps.master.loading = true;
|
|
|
+ rerenderCustomerMaster();
|
|
|
+ try {
|
|
|
+ state.customerOps.master.hub = await api('GET', '/api/customers');
|
|
|
+ const customers = state.customerOps.master.hub.data?.customers || [];
|
|
|
+ if (!customers.some(item => item.id === state.customerOps.master.selectedId)) state.customerOps.master.selectedId = customers[0]?.id || null;
|
|
|
+ rerenderCustomerMaster();
|
|
|
+ if (message) toast(message);
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '客户主档读取失败', 'error'); }
|
|
|
+ finally { state.customerOps.master.loading = false; rerenderCustomerMaster(); }
|
|
|
+ }
|
|
|
+
|
|
|
+ masterRoot?.addEventListener('click', async event => {
|
|
|
+ const customerButton = event.target.closest('[data-customer-master-id]');
|
|
|
+ if (customerButton) {
|
|
|
+ state.customerOps.master.selectedId = customerButton.dataset.customerMasterId;
|
|
|
+ rerenderCustomerMaster();
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const openButton = event.target.closest('[data-customer-master-open]');
|
|
|
+ if (openButton) {
|
|
|
+ state.agent.selectedId = openButton.dataset.customerMasterOpen;
|
|
|
+ location.hash = '#agent';
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (event.target.closest('#btn-customer-master-refresh')) void reloadCustomerMaster('客户主档已刷新');
|
|
|
+ });
|
|
|
+ masterRoot?.addEventListener('change', event => {
|
|
|
+ if (event.target.id !== 'customer-master-search') return;
|
|
|
+ state.customerOps.master.keyword = event.target.value;
|
|
|
+ rerenderCustomerMaster();
|
|
|
+ });
|
|
|
+ masterRoot?.addEventListener('submit', async event => {
|
|
|
+ if (event.target.id !== 'customer-master-profile-form') return;
|
|
|
+ event.preventDefault();
|
|
|
+ const form = new FormData(event.target);
|
|
|
+ const budgetText = String(form.get('budgetWan') || '').trim();
|
|
|
+ const profile = {
|
|
|
+ intent: String(form.get('intent') || '').trim(),
|
|
|
+ need: String(form.get('need') || '').trim(),
|
|
|
+ budgetWan: budgetText ? Number(budgetText) : '',
|
|
|
+ timeline: String(form.get('timeline') || '').trim(),
|
|
|
+ decisionMaker: String(form.get('decisionMaker') || '').trim(),
|
|
|
+ };
|
|
|
+ state.customerOps.master.loading = true;
|
|
|
+ rerenderCustomerMaster();
|
|
|
+ try {
|
|
|
+ const result = await api('POST', `/api/customers/${encodeURIComponent(event.target.dataset.customerId)}/profile`, {
|
|
|
+ profile,
|
|
|
+ tags: String(form.get('tags') || '').split(/[,,]/).map(item => item.trim()).filter(Boolean),
|
|
|
+ reason: String(form.get('reason') || '').trim(),
|
|
|
+ });
|
|
|
+ if (result.status !== 'ok') throw result;
|
|
|
+ state.customerOps.master.hub = await api('GET', '/api/customers');
|
|
|
+ toast(result.assistantMessage || '客户主档已保存');
|
|
|
+ } catch (error) { toast(error.assistantMessage || error.message || '客户主档保存失败', 'error'); }
|
|
|
+ finally { state.customerOps.master.loading = false; rerenderCustomerMaster(); }
|
|
|
+ });
|
|
|
+ void loadCustomerOpsDashboard(page);
|
|
|
+ dashboardRefreshTimer = setInterval(() => {
|
|
|
+ if (state.page === 'customer-ops') loadCustomerOpsDashboard(page, true);
|
|
|
+ }, 30000);
|
|
|
+
|
|
|
+ page.querySelector('#btn-co-refresh').addEventListener('click', () => loadCustomerOpsDashboard(page));
|
|
|
+ page.querySelector('#btn-co-rebuild').addEventListener('click', async () => {
|
|
|
+ try {
|
|
|
+ await api('POST', '/api/dashboard/rebuild');
|
|
|
+ toast('索引已重建');
|
|
|
+ loadCustomerOpsDashboard(page);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '重建失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ page.querySelector('#btn-import-customers').addEventListener('click', () => openImportCustomersModal(page));
|
|
|
+
|
|
|
+ page.querySelector('#co-filter-keyword').addEventListener('input', () => loadCustomerOpsTable(page));
|
|
|
+ page.querySelector('#co-filter-status').addEventListener('change', () => loadCustomerOpsTable(page));
|
|
|
+ page.querySelector('#co-filter-portrait').addEventListener('change', () => loadCustomerOpsTable(page));
|
|
|
+ page.querySelector('#co-filter-tag').addEventListener('change', () => loadCustomerOpsTable(page));
|
|
|
+ page.querySelector('#btn-co-reset-filters').addEventListener('click', () => resetCustomerOpsFilters(page));
|
|
|
+
|
|
|
+ page.querySelector('#btn-batch-add').addEventListener('click', () => {
|
|
|
+ const selected = getSelectedCustomers(page);
|
|
|
+ if (!selected.length) return;
|
|
|
+ const phoneCustomers = selected.filter(c => c.phone).map(c => ({ phone: c.phone, name: c.name }));
|
|
|
+ if (!phoneCustomers.length) {
|
|
|
+ toast('选中的客户没有手机号,无法批量加好友', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const body = { customers: phoneCustomers, guid: getGuid() };
|
|
|
+ startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', body, () => loadCustomerOpsDashboard(page));
|
|
|
+ });
|
|
|
+ page.querySelector('#btn-check-friends').addEventListener('click', () => {
|
|
|
+ const selected = getSelectedCustomers(page);
|
|
|
+ if (!selected.length) return;
|
|
|
+ const body = {
|
|
|
+ phones: selected.map(c => c.phone).filter(Boolean),
|
|
|
+ externalUserIds: selected.map(c => c.externalUserId).filter(Boolean),
|
|
|
+ guid: getGuid()
|
|
|
+ };
|
|
|
+ startJob('check-friend-status', '/api/customer-ops/check-friend-status', body, () => loadCustomerOpsDashboard(page));
|
|
|
+ });
|
|
|
+ page.querySelector('#btn-auto-group').addEventListener('click', () => {
|
|
|
+ const selected = getSelectedCustomers(page).filter(c => c.friendRequestStatus === 'ACCEPTED' && !['CREATED', 'IN_GROUP'].includes(c.groupStatus));
|
|
|
+ if (!selected.length) {
|
|
|
+ toast('请选中已是好友且未建群的客户', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const body = { memberList: selected.map(c => c.externalUserId).filter(Boolean), guid: getGuid() };
|
|
|
+ startJob('auto-create-group', '/api/customer-ops/auto-create-group', body, () => loadCustomerOpsDashboard(page));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadCustomerOpsDashboard(page, silent = false) {
|
|
|
+ const tableWrap = page.querySelector('#co-customer-table-wrap');
|
|
|
+ if (!silent) showLoading(tableWrap, 'table');
|
|
|
+ try {
|
|
|
+ const [summaryRes, customersRes, tagsRes] = await Promise.all([
|
|
|
+ api('GET', '/api/dashboard/summary'),
|
|
|
+ api('GET', '/api/dashboard/customers'),
|
|
|
+ api('GET', '/api/dashboard/tags')
|
|
|
+ ]);
|
|
|
+ const summary = summaryRes.data || {};
|
|
|
+ const customers = Object.values(customersRes.data?.customers || {});
|
|
|
+ const tags = tagsRes.data?.allTags || [];
|
|
|
+
|
|
|
+ hideLoading(tableWrap);
|
|
|
+ renderCustomerOpsKpi(page, summary);
|
|
|
+ renderCustomerOpsCharts(page, summary, customers);
|
|
|
+ renderCustomerOpsTagFilter(page, tags);
|
|
|
+ state.customerOps.allCustomers = customers;
|
|
|
+ loadCustomerOpsTable(page);
|
|
|
+ } catch (err) {
|
|
|
+ if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadCustomerOpsDashboard(page));
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCustomerOpsKpi(page, summary) {
|
|
|
+ const wrap = page.querySelector('#co-kpi-row');
|
|
|
+ const c = summary.customers || {};
|
|
|
+ const friendStatus = c.friendStatusCounts || {};
|
|
|
+ const kpi = [
|
|
|
+ { label: '批量导入客户', value: formatNumber(c.total) },
|
|
|
+ { label: '已是好友', value: formatNumber(friendStatus.ACCEPTED || 0), sub: '已通过好友申请' },
|
|
|
+ { label: '待通过', value: formatNumber(friendStatus.PENDING || 0), sub: '等待对方确认' },
|
|
|
+ { label: '自动建群', value: formatNumber(c.autoCreatedGroups || 0), sub: '已创建客户群' },
|
|
|
+ { label: '今日新增', value: formatNumber(c.todayNew || 0), sub: '今日通过好友' }
|
|
|
+ ];
|
|
|
+ wrap.innerHTML = renderKpiRow(kpi);
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCustomerOpsCharts(page, summary, customers) {
|
|
|
+ const colors = getChartColors();
|
|
|
+ const friendStatus = summary.customers?.friendStatusCounts || {};
|
|
|
+ initEChart('co-friend-chart', {
|
|
|
+ tooltip: { trigger: 'item' },
|
|
|
+ legend: { bottom: 0 },
|
|
|
+ color: [colors.success, colors.warning, colors.error, colors.purple, colors.muted],
|
|
|
+ series: [{
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['45%', '70%'],
|
|
|
+ center: ['50%', '45%'],
|
|
|
+ data: [
|
|
|
+ { name: '已是好友', value: friendStatus.ACCEPTED || 0 },
|
|
|
+ { name: '待通过', value: friendStatus.PENDING || 0 },
|
|
|
+ { name: '未找到', value: friendStatus.NOT_FOUND || 0 },
|
|
|
+ { name: '失败', value: friendStatus.FAILED || 0 },
|
|
|
+ { name: '未知', value: friendStatus.UNKNOWN || 0 }
|
|
|
+ ]
|
|
|
+ }]
|
|
|
+ });
|
|
|
+
|
|
|
+ const days = last7Days();
|
|
|
+ const addCounts = countByDay(customers.filter(c => c.lastAddAttemptAt), 'lastAddAttemptAt');
|
|
|
+ const groupCounts = countByDay(customers.filter(c => c.autoCreatedAt), 'autoCreatedAt');
|
|
|
+ const newCustomerCounts = countByDay(customers.filter(c => c.createdAt), 'createdAt');
|
|
|
+ initEChart('co-trend-chart', {
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ legend: { bottom: 0 },
|
|
|
+ grid: { left: 8, right: 18, top: 20, bottom: 42, containLabel: true },
|
|
|
+ xAxis: { type: 'category', data: days.map(d => formatDate(d)), axisTick: { show: false } },
|
|
|
+ yAxis: { type: 'value', minInterval: 1 },
|
|
|
+ series: [
|
|
|
+ { name: '新增客户', type: 'bar', data: newCustomerCounts, itemStyle: { color: colors.brand, borderRadius: [4, 4, 0, 0] }, barMaxWidth: 28 },
|
|
|
+ { name: '加好友', type: 'line', smooth: true, data: addCounts, itemStyle: { color: colors.info } },
|
|
|
+ { name: '建群', type: 'line', smooth: true, data: groupCounts, itemStyle: { color: colors.success } }
|
|
|
+ ]
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCustomerOpsTagFilter(page, tags) {
|
|
|
+ const select = page.querySelector('#co-filter-tag');
|
|
|
+ const current = select.value;
|
|
|
+ select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
|
|
|
+ select.value = current;
|
|
|
+ }
|
|
|
+
|
|
|
+ function resetCustomerOpsFilters(page) {
|
|
|
+ state.customerOps.keyword = '';
|
|
|
+ state.customerOps.status = '';
|
|
|
+ state.customerOps.portrait = '';
|
|
|
+ state.customerOps.tag = '';
|
|
|
+ state.customerOps.tablePage = 1;
|
|
|
+ saveFilters(CO_FILTERS_KEY, {
|
|
|
+ tablePage: 1,
|
|
|
+ keyword: '',
|
|
|
+ status: '',
|
|
|
+ portrait: '',
|
|
|
+ tag: ''
|
|
|
+ });
|
|
|
+ page.querySelector('#co-filter-keyword').value = '';
|
|
|
+ page.querySelector('#co-filter-status').value = '';
|
|
|
+ page.querySelector('#co-filter-portrait').value = '';
|
|
|
+ page.querySelector('#co-filter-tag').value = '';
|
|
|
+ loadCustomerOpsTable(page);
|
|
|
+ toast('筛选已重置');
|
|
|
+ }
|
|
|
+
|
|
|
+ function getSelectedCustomers(page) {
|
|
|
+ const checkboxes = page.querySelectorAll('#co-customer-table-wrap input[type="checkbox"]:checked');
|
|
|
+ return Array.from(checkboxes).map(cb => {
|
|
|
+ const idx = Number(cb.dataset.index);
|
|
|
+ return state.customerOps.filteredCustomers?.[idx];
|
|
|
+ }).filter(Boolean);
|
|
|
+ }
|
|
|
+
|
|
|
+ function loadCustomerOpsTable(page) {
|
|
|
+ const wrap = page.querySelector('#co-customer-table-wrap');
|
|
|
+ const customers = state.customerOps.allCustomers || [];
|
|
|
+ const keyword = page.querySelector('#co-filter-keyword').value.trim().toLowerCase();
|
|
|
+ const status = page.querySelector('#co-filter-status').value;
|
|
|
+ const portrait = page.querySelector('#co-filter-portrait').value;
|
|
|
+ const tag = page.querySelector('#co-filter-tag').value;
|
|
|
+
|
|
|
+ state.customerOps.keyword = keyword;
|
|
|
+ state.customerOps.status = status;
|
|
|
+ state.customerOps.portrait = portrait;
|
|
|
+ state.customerOps.tag = tag;
|
|
|
+ saveFilters(CO_FILTERS_KEY, {
|
|
|
+ tablePage: state.customerOps.tablePage,
|
|
|
+ keyword,
|
|
|
+ status,
|
|
|
+ portrait,
|
|
|
+ tag
|
|
|
+ });
|
|
|
+
|
|
|
+ let filtered = customers.filter(c => {
|
|
|
+ if (keyword) {
|
|
|
+ const text = `${c.phone || ''} ${c.name || ''} ${c.externalUserId || ''}`.toLowerCase();
|
|
|
+ if (!text.includes(keyword)) return false;
|
|
|
+ }
|
|
|
+ if (status && c.friendRequestStatus !== status) return false;
|
|
|
+ if (portrait === 'true' && !c.hasPortrait) return false;
|
|
|
+ if (portrait === 'false' && c.hasPortrait) return false;
|
|
|
+ if (tag && !(Array.isArray(c.tags) && c.tags.includes(tag))) return false;
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ state.customerOps.filteredCustomers = filtered;
|
|
|
+ const pageSize = 20;
|
|
|
+ const currentPage = state.customerOps.tablePage || 1;
|
|
|
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
|
|
+ if (currentPage > totalPages) {
|
|
|
+ state.customerOps.tablePage = totalPages;
|
|
|
+ saveFilters(CO_FILTERS_KEY, {
|
|
|
+ tablePage: state.customerOps.tablePage,
|
|
|
+ keyword: state.customerOps.keyword,
|
|
|
+ status: state.customerOps.status,
|
|
|
+ portrait: state.customerOps.portrait,
|
|
|
+ tag: state.customerOps.tag
|
|
|
+ });
|
|
|
+ }
|
|
|
+ const start = (state.customerOps.tablePage - 1) * pageSize;
|
|
|
+ const pageItems = filtered.slice(start, start + pageSize);
|
|
|
+
|
|
|
+ const friendBadge = (status) => {
|
|
|
+ const map = {
|
|
|
+ ACCEPTED: ['badge-success', '已是好友'],
|
|
|
+ PENDING: ['badge-warning', '待通过'],
|
|
|
+ NOT_FOUND: ['badge-error', '未找到'],
|
|
|
+ FAILED: ['badge-error', '失败']
|
|
|
+ };
|
|
|
+ const [cls, text] = map[status] || ['badge-default', '未知'];
|
|
|
+ return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
|
|
|
+ };
|
|
|
+ const groupBadge = (status) => {
|
|
|
+ if (status === 'CREATED') return '<span class="badge badge-success">已建群</span>';
|
|
|
+ if (status === 'IN_GROUP') return '<span class="badge badge-info">群内客户</span>';
|
|
|
+ return '<span class="badge badge-default">未建群</span>';
|
|
|
+ };
|
|
|
+
|
|
|
+ wrap.innerHTML = `
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th><input type="checkbox" id="co-select-all"></th>
|
|
|
+ <th>手机号</th>
|
|
|
+ <th>姓名</th>
|
|
|
+ <th>externalUserId</th>
|
|
|
+ <th>好友状态</th>
|
|
|
+ <th>建群状态</th>
|
|
|
+ <th>画像</th>
|
|
|
+ <th>标签</th>
|
|
|
+ <th>最后操作</th>
|
|
|
+ <th>操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ ${pageItems.length ? pageItems.map((c, i) => `
|
|
|
+ <tr>
|
|
|
+ <td><input type="checkbox" data-index="${start + i}"></td>
|
|
|
+ <td>${escapeHtml(c.phone || '-')}</td>
|
|
|
+ <td>${escapeHtml(c.name || '-')}</td>
|
|
|
+ <td style="font-family:monospace;font-size:12px">${escapeHtml(c.externalUserId || '-')}</td>
|
|
|
+ <td>${friendBadge(c.friendRequestStatus)}</td>
|
|
|
+ <td>${groupBadge(c.groupStatus)}</td>
|
|
|
+ <td>${c.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>'}</td>
|
|
|
+ <td>${Array.isArray(c.tags) && c.tags.length ? c.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
|
|
|
+ <td>${formatDateTime(c.updatedAt)}</td>
|
|
|
+ <td>
|
|
|
+ <button class="btn btn-sm btn-secondary" onclick="window.dashboardCustomerDetail('${escapeHtml(c.customerId || c.externalUserId || c.phone || '')}')">详情</button>
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `).join('') : renderEmptyTableRow(10, '暂无客户数据', '导入客户或同步群成员后将显示')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ const pagination = page.querySelector('#co-pagination');
|
|
|
+ pagination.innerHTML = renderPagination(state.customerOps.tablePage, filtered.length, pageSize);
|
|
|
+ pagination.querySelectorAll('button').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ state.customerOps.tablePage = Number(btn.dataset.page);
|
|
|
+ saveFilters(CO_FILTERS_KEY, {
|
|
|
+ tablePage: state.customerOps.tablePage,
|
|
|
+ keyword: state.customerOps.keyword,
|
|
|
+ status: state.customerOps.status,
|
|
|
+ portrait: state.customerOps.portrait,
|
|
|
+ tag: state.customerOps.tag
|
|
|
+ });
|
|
|
+ loadCustomerOpsTable(page);
|
|
|
+ });
|
|
|
+ });
|
|
|
+
|
|
|
+ const selectAll = wrap.querySelector('#co-select-all');
|
|
|
+ const rowCheckboxes = wrap.querySelectorAll('tbody input[type="checkbox"]');
|
|
|
+ selectAll?.addEventListener('change', () => {
|
|
|
+ rowCheckboxes.forEach(cb => cb.checked = selectAll.checked);
|
|
|
+ updateCustomerOpsActionButtons(page);
|
|
|
+ });
|
|
|
+ rowCheckboxes.forEach(cb => cb.addEventListener('change', () => updateCustomerOpsActionButtons(page)));
|
|
|
+ updateCustomerOpsActionButtons(page);
|
|
|
+ }
|
|
|
+
|
|
|
+ function updateCustomerOpsActionButtons(page) {
|
|
|
+ const selected = getSelectedCustomers(page);
|
|
|
+ page.querySelector('#btn-batch-add').disabled = !selected.some(c => c.phone);
|
|
|
+ page.querySelector('#btn-check-friends').disabled = !selected.length;
|
|
|
+ page.querySelector('#btn-auto-group').disabled = !selected.some(c => c.friendRequestStatus === 'ACCEPTED' && !['CREATED', 'IN_GROUP'].includes(c.groupStatus));
|
|
|
+ }
|
|
|
+
|
|
|
+ function openImportCustomersModal(page) {
|
|
|
+ const body = `
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>粘贴客户列表(每行:手机号 姓名)</label>
|
|
|
+ <textarea id="import-customer-text" rows="6" placeholder="13800138000 张三"></textarea>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>或上传 Excel</label>
|
|
|
+ <input type="file" id="import-customer-file" accept=".xlsx,.xls" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>默认验证消息</label>
|
|
|
+ <input id="import-greeting" type="text" value="${escapeHtml(state.customerOps.defaultGreeting)}" />
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ openModal('导入客户', body, `
|
|
|
+ <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
|
|
|
+ <button class="btn" id="btn-confirm-import">导入</button>
|
|
|
+ `);
|
|
|
+ document.getElementById('btn-confirm-import').addEventListener('click', async () => {
|
|
|
+ const text = document.getElementById('import-customer-text').value;
|
|
|
+ const file = document.getElementById('import-customer-file').files[0];
|
|
|
+ const greeting = document.getElementById('import-greeting').value;
|
|
|
+ let customers = parseCustomerText(text);
|
|
|
+ if (file) {
|
|
|
+ const base64 = await readFileBase64(file);
|
|
|
+ const upload = await api('POST', '/api/upload', { data: base64, name: file.name });
|
|
|
+ const filePath = upload.data?.path;
|
|
|
+ if (filePath) {
|
|
|
+ const res = await api('POST', '/api/customer-ops/batch-add-friends', { filePath, defaultGreeting: greeting, guid: getGuid() });
|
|
|
+ toast(res.assistantMessage || '导入任务已启动');
|
|
|
+ closeModal();
|
|
|
+ loadCustomerOpsDashboard(page);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!customers.length) {
|
|
|
+ toast('请输入有效客户', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ closeModal();
|
|
|
+ startJob('batch-add-friends', '/api/customer-ops/batch-add-friends', { customers, defaultGreeting: greeting, guid: getGuid() }, () => loadCustomerOpsDashboard(page));
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDetailCard(title, iconSvg, contentHtml) {
|
|
|
+ return `
|
|
|
+ <div class="detail-card">
|
|
|
+ <div class="detail-section-title">
|
|
|
+ ${iconSvg}
|
|
|
+ ${escapeHtml(title)}
|
|
|
+ </div>
|
|
|
+ ${contentHtml}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderDetailItem(label, value) {
|
|
|
+ return `
|
|
|
+ <div class="detail-item">
|
|
|
+ <div class="detail-item-label">${escapeHtml(label)}</div>
|
|
|
+ <div class="detail-item-value">${value || '-'}</div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitField(label, value) {
|
|
|
+ const displayValue = value === undefined || value === null || value === '' ? '-' : String(value);
|
|
|
+ return renderDetailItem(label, escapeHtml(displayValue));
|
|
|
+ }
|
|
|
+
|
|
|
+ function customerFriendBadge(status) {
|
|
|
+ const map = {
|
|
|
+ ACCEPTED: ['badge-success', '已是好友'],
|
|
|
+ PENDING: ['badge-warning', '待通过'],
|
|
|
+ NOT_FOUND: ['badge-error', '未找到'],
|
|
|
+ FAILED: ['badge-error', '失败'],
|
|
|
+ UNKNOWN: ['badge-default', '未知']
|
|
|
+ };
|
|
|
+ const [cls, text] = map[status] || map.UNKNOWN;
|
|
|
+ return `<span class="badge ${cls}">${escapeHtml(text)}</span>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function customerGroupBadge(status) {
|
|
|
+ if (status === 'CREATED') return '<span class="badge badge-success">已建群</span>';
|
|
|
+ if (status === 'IN_GROUP') return '<span class="badge badge-info">群内客户</span>';
|
|
|
+ return '<span class="badge badge-default">未建群</span>';
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderCustomerOpsSummary(customer) {
|
|
|
+ const tags = Array.isArray(customer.tags) ? customer.tags : [];
|
|
|
+ const groupNames = Array.isArray(customer.groupNames) ? customer.groupNames.filter(Boolean) : [];
|
|
|
+ const sourceRoomIds = Array.isArray(customer.sourceRoomIds) ? customer.sourceRoomIds.filter(Boolean) : [];
|
|
|
+ const groupLabels = groupNames.length
|
|
|
+ ? groupNames
|
|
|
+ : sourceRoomIds;
|
|
|
+ const sourceGroupHtml = groupLabels.length
|
|
|
+ ? `<div class="detail-list compact">
|
|
|
+ ${groupLabels.slice(0, 5).map((name, index) => `
|
|
|
+ <div class="detail-list-item">
|
|
|
+ <div>
|
|
|
+ <div>${escapeHtml(name)}</div>
|
|
|
+ ${sourceRoomIds[index] && sourceRoomIds[index] !== name ? `<div class="detail-muted">${escapeHtml(sourceRoomIds[index])}</div>` : ''}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `).join('')}
|
|
|
+ ${groupLabels.length > 5 ? `<div class="detail-muted">还有 ${formatNumber(groupLabels.length - 5)} 个来源群未展示</div>` : ''}
|
|
|
+ </div>`
|
|
|
+ : '<div class="detail-empty">暂无来源群记录</div>';
|
|
|
+
|
|
|
+ const basicInfo = `
|
|
|
+ <div class="detail-grid compact">
|
|
|
+ ${renderDetailItem('姓名', escapeHtml(customer.name || '-'))}
|
|
|
+ ${renderDetailItem('手机号', escapeHtml(customer.phone || '-'))}
|
|
|
+ ${renderDetailItem('好友状态', customerFriendBadge(customer.friendRequestStatus))}
|
|
|
+ ${renderDetailItem('群状态', customerGroupBadge(customer.groupStatus))}
|
|
|
+ ${renderDetailItem('画像状态', customer.hasPortrait ? '<span class="badge badge-info">已生成</span>' : '<span class="badge badge-default">未生成</span>')}
|
|
|
+ ${renderDetailItem('最近更新', formatDateTime(customer.updatedAt))}
|
|
|
+ </div>
|
|
|
+ ${tags.length ? `<div class="detail-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
|
|
|
+ <div class="detail-id-line">externalUserId:${escapeHtml(customer.externalUserId || '-')}</div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ return {
|
|
|
+ basicInfo,
|
|
|
+ sourceGroupHtml
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderRawToggle(rawData) {
|
|
|
+ return `
|
|
|
+ <div class="raw-toggle" id="raw-toggle">
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M7 10l5 5 5-5z"/></svg>
|
|
|
+ <span>查看原始数据</span>
|
|
|
+ </div>
|
|
|
+ <div class="detail-panel raw-panel" id="raw-panel">${escapeHtml(JSON.stringify(rawData || {}, null, 2))}</div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function bindRawToggle() {
|
|
|
+ const toggle = document.getElementById('raw-toggle');
|
|
|
+ const panel = document.getElementById('raw-panel');
|
|
|
+ if (toggle && panel) {
|
|
|
+ toggle.addEventListener('click', () => {
|
|
|
+ const isOpen = panel.style.display === 'block';
|
|
|
+ panel.style.display = isOpen ? 'none' : 'block';
|
|
|
+ toggle.classList.toggle('open', !isOpen);
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadCustomerFullProfile(externalUserId) {
|
|
|
+ const target = document.getElementById('customer-full-profile');
|
|
|
+ const button = document.getElementById('btn-load-customer-full-profile');
|
|
|
+ if (!target || !externalUserId) return;
|
|
|
+ if (button) {
|
|
|
+ button.disabled = true;
|
|
|
+ button.textContent = '加载中...';
|
|
|
+ }
|
|
|
+ target.innerHTML = '<div class="detail-empty">正在加载完整资料...</div>';
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid(), mode: 'full' });
|
|
|
+ const data = res.data || {};
|
|
|
+ const portrait = data.portrait || {};
|
|
|
+ const contactList = data.contactList || [];
|
|
|
+ const rooms = data.rooms || [];
|
|
|
+
|
|
|
+ const contactHtml = contactList.length
|
|
|
+ ? `<div class="detail-list compact">
|
|
|
+ ${contactList.slice(0, 5).map(c => `
|
|
|
+ <div class="detail-list-item">
|
|
|
+ <div>
|
|
|
+ <div>${escapeHtml(c.nickName || c.userName || c.externalUserId || '未知联系人')}</div>
|
|
|
+ <div class="detail-muted">${escapeHtml(c.externalUserId || c.userId || '-')}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `).join('')}
|
|
|
+ </div>`
|
|
|
+ : '<div class="detail-empty">暂无匹配联系人</div>';
|
|
|
+
|
|
|
+ const roomsHtml = rooms.length
|
|
|
+ ? `<div class="detail-list compact">
|
|
|
+ ${rooms.slice(0, 8).map(r => `
|
|
|
+ <div class="detail-list-item">
|
|
|
+ <div>
|
|
|
+ <div>${escapeHtml(r.roomName || '未命名群')}</div>
|
|
|
+ <div class="detail-muted">${escapeHtml(r.roomId || '-')}</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `).join('')}
|
|
|
+ </div>`
|
|
|
+ : '<div class="detail-empty">暂无相关群</div>';
|
|
|
+
|
|
|
+ const portraitFields = portrait.portrait || {};
|
|
|
+ const portraitKeys = Object.keys(portraitFields);
|
|
|
+ const portraitHtml = portraitKeys.length
|
|
|
+ ? `<div class="detail-grid compact">
|
|
|
+ ${portraitKeys.slice(0, 8).map(k => renderPortraitField(k, portraitFields[k])).join('')}
|
|
|
+ </div>`
|
|
|
+ : '<div class="detail-empty">暂无画像数据</div>';
|
|
|
+
|
|
|
+ const contactsIcon = '<svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 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.5z"/></svg>';
|
|
|
+ const roomsIcon = '<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>';
|
|
|
+ const portraitIcon = '<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>';
|
|
|
+
|
|
|
+ target.innerHTML = `
|
|
|
+ ${renderDetailCard(`联系人 (${formatNumber(contactList.length)})`, contactsIcon, contactHtml)}
|
|
|
+ ${renderDetailCard(`相关群 (${formatNumber(rooms.length)})`, roomsIcon, roomsHtml)}
|
|
|
+ ${renderDetailCard('客户画像', portraitIcon, portraitHtml)}
|
|
|
+ ${renderRawToggle(data)}
|
|
|
+ `;
|
|
|
+ bindRawToggle();
|
|
|
+ if (button) button.remove();
|
|
|
+ } catch (err) {
|
|
|
+ target.innerHTML = '<div class="detail-empty">完整资料加载失败</div>';
|
|
|
+ if (button) {
|
|
|
+ button.disabled = false;
|
|
|
+ button.textContent = '重新加载完整资料';
|
|
|
+ }
|
|
|
+ toast(err.message || '查询失败', 'error');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ window.dashboardCustomerDetail = (externalUserId) => {
|
|
|
+ if (!externalUserId) return;
|
|
|
+ const localCustomer = (state.customerOps.allCustomers || []).find(c => c.externalUserId === externalUserId || c.phone === externalUserId) || { externalUserId };
|
|
|
+ const { basicInfo, sourceGroupHtml } = renderCustomerOpsSummary(localCustomer);
|
|
|
+ const personIcon = '<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>';
|
|
|
+ const roomsIcon = '<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>';
|
|
|
+
|
|
|
+ openModal('客户详情', `
|
|
|
+ ${renderDetailCard('运营信息', personIcon, basicInfo)}
|
|
|
+ ${renderDetailCard('来源群聊', roomsIcon, sourceGroupHtml)}
|
|
|
+ <div class="detail-card" style="margin-top:16px">
|
|
|
+ <div class="detail-card-header">
|
|
|
+ <svg viewBox="0 0 24 24" width="18" height="18"><path d="M3 17.25V21h3.75L17.81 9.94l-3.75-3.75L3 17.25zM20.71 7.04c.39-.39.39-1.02 0-1.41l-2.34-2.34c-.39-.39-1.02-.39-1.41 0l-1.83 1.83 3.75 3.75 1.83-1.83z"/></svg>
|
|
|
+ <span>编辑资料</span>
|
|
|
+ </div>
|
|
|
+ <div class="detail-card-body">
|
|
|
+ <div class="form-group" style="margin-bottom:12px">
|
|
|
+ <label>姓名</label>
|
|
|
+ <input id="customer-edit-name" type="text" value="${escapeHtml(localCustomer.name || '')}" placeholder="客户姓名" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:12px">
|
|
|
+ <label>手机号</label>
|
|
|
+ <input id="customer-edit-phone" type="text" value="${escapeHtml(localCustomer.phone || '')}" placeholder="手机号" />
|
|
|
+ </div>
|
|
|
+ <div id="customer-edit-message" style="font-size:13px;color:var(--text-muted);min-height:20px"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="detail-actions">
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-load-customer-full-profile">加载完整资料</button>
|
|
|
+ <button class="btn btn-sm" id="btn-save-customer-info">保存修改</button>
|
|
|
+ </div>
|
|
|
+ <div id="customer-full-profile"></div>
|
|
|
+ `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'customer-detail-modal');
|
|
|
+
|
|
|
+ document.getElementById('btn-load-customer-full-profile')?.addEventListener('click', () => loadCustomerFullProfile(externalUserId));
|
|
|
+ document.getElementById('btn-save-customer-info')?.addEventListener('click', async () => {
|
|
|
+ const name = document.getElementById('customer-edit-name').value.trim();
|
|
|
+ const phone = document.getElementById('customer-edit-phone').value.trim();
|
|
|
+ const msgEl = document.getElementById('customer-edit-message');
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/customer-ops/update', { externalUserId, name, phone });
|
|
|
+ if (res.status === 'ok') {
|
|
|
+ msgEl.textContent = '已同步更新';
|
|
|
+ msgEl.style.color = 'var(--success)';
|
|
|
+ // 更新本地客户列表状态
|
|
|
+ const customer = (state.customerOps.allCustomers || []).find(c => c.externalUserId === externalUserId);
|
|
|
+ if (customer) {
|
|
|
+ customer.name = name || customer.name;
|
|
|
+ customer.phone = phone || customer.phone;
|
|
|
+ customer.updatedAt = new Date().toISOString();
|
|
|
+ }
|
|
|
+ // 刷新客户运营列表
|
|
|
+ const page = document.querySelector('#co-customer-table-wrap')?.closest('.page');
|
|
|
+ if (page) loadCustomerOpsTable(page);
|
|
|
+ toast('客户资料已同步更新');
|
|
|
+ } else {
|
|
|
+ msgEl.textContent = res.assistantMessage || '保存失败';
|
|
|
+ msgEl.style.color = 'var(--error)';
|
|
|
+ }
|
|
|
+ } catch (err) {
|
|
|
+ msgEl.textContent = err.message || '保存失败';
|
|
|
+ msgEl.style.color = 'var(--error)';
|
|
|
+ }
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ async function renderPortraitsPage() {
|
|
|
+ els.pageTitle.textContent = '画像与标签';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page customer-workspace-page';
|
|
|
+
|
|
|
+ page.innerHTML = `
|
|
|
+ <section class="workspace-hero customer-workspace-hero portrait-dashboard-hero">
|
|
|
+ <div>
|
|
|
+ <span>CUSTOMER INTELLIGENCE & TAGS</span>
|
|
|
+ <h2>画像与标签</h2>
|
|
|
+ <p>把客户对话沉淀为可追溯的结构化画像,用统一标签支持筛选、运营和后续跟进。</p>
|
|
|
+ </div>
|
|
|
+ <div class="hero-feature-list" aria-label="能力说明">
|
|
|
+ <span>AI 画像</span><span>证据可追溯</span><span>批量标签</span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <div class="dashboard-grid customer-dashboard-grid" id="pt-dashboard">
|
|
|
+ <div class="kpi-row" id="pt-kpi-row"></div>
|
|
|
+ <div class="chart-card third">
|
|
|
+ <h3 class="chart-title"><span>画像覆盖率</span></h3>
|
|
|
+ <div class="chart-container small" id="pt-coverage-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-card third">
|
|
|
+ <h3 class="chart-title"><span>画像来源分布</span></h3>
|
|
|
+ <div class="chart-container small" id="pt-source-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-card third">
|
|
|
+ <h3 class="chart-title"><span>Top 标签</span></h3>
|
|
|
+ <div class="chart-container small" id="pt-tags-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-card full">
|
|
|
+ <h3 class="chart-title"><span>近7日画像生成趋势</span></h3>
|
|
|
+ <div class="chart-container" id="pt-trend-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="operation-toolbar">
|
|
|
+ <div class="toolbar-left">
|
|
|
+ <button class="btn" id="btn-pt-incremental" data-job="incremental-portraits">批量增量更新</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-pt-full" data-job="full-portraits">批量全量更新</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-pt-tag">为客户打标签</button>
|
|
|
+ <button class="btn btn-secondary" id="btn-pt-export">导出全部画像</button>
|
|
|
+ </div>
|
|
|
+ <div class="toolbar-right">
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-pt-refresh">刷新</button>
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-pt-rebuild">重建索引</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div id="pt-job-tracker" style="margin:12px 0"></div>
|
|
|
+ <div class="data-table-card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">客户画像列表</h3>
|
|
|
+ <div class="card-subtitle">支持按画像状态、来源、标签筛选</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="filter-bar" style="margin-bottom:16px">
|
|
|
+ <input id="pt-filter-keyword" type="text" placeholder="搜索 externalUserId / 姓名" style="width:220px" value="${escapeHtml(state.portraits.keyword || '')}" />
|
|
|
+ <select id="pt-filter-source">
|
|
|
+ <option value="">全部来源</option>
|
|
|
+ <option value="keyword" ${state.portraits.source === 'keyword' ? 'selected' : ''}>关键词</option>
|
|
|
+ <option value="agent" ${state.portraits.source === 'agent' ? 'selected' : ''}>AI</option>
|
|
|
+ </select>
|
|
|
+ <select id="pt-filter-tag">
|
|
|
+ <option value="">全部标签</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ <div id="pt-portrait-table-wrap"></div>
|
|
|
+ <div id="pt-pagination"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+
|
|
|
+ if (renderPortraitsDashboardSnapshot(page)) void loadPortraitsDashboard(page, true);
|
|
|
+ else await loadPortraitsDashboard(page);
|
|
|
+ dashboardRefreshTimer = setInterval(() => {
|
|
|
+ if (state.page === 'portraits') loadPortraitsDashboard(page, true);
|
|
|
+ }, 30000);
|
|
|
+
|
|
|
+ page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page, false, true));
|
|
|
+ page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
|
|
|
+ try {
|
|
|
+ await api('POST', '/api/dashboard/rebuild');
|
|
|
+ toast('索引已重建');
|
|
|
+ loadPortraitsDashboard(page, false, true);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '重建失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ page.querySelector('#pt-filter-keyword').addEventListener('input', () => loadPortraitsTable(page));
|
|
|
+ page.querySelector('#pt-filter-source').addEventListener('change', () => loadPortraitsTable(page));
|
|
|
+ page.querySelector('#pt-filter-tag').addEventListener('change', () => loadPortraitsTable(page));
|
|
|
+
|
|
|
+ page.querySelector('#btn-pt-incremental').addEventListener('click', () => openBatchPortraitSelector(page, 'incremental'));
|
|
|
+ page.querySelector('#btn-pt-full').addEventListener('click', () => openBatchPortraitSelector(page, 'full'));
|
|
|
+ page.querySelector('#btn-pt-tag').addEventListener('click', () => openTagModal(page));
|
|
|
+ page.querySelector('#btn-pt-export').addEventListener('click', () => {
|
|
|
+ const portraits = state.portraits?.allPortraits || {};
|
|
|
+ if (!Object.keys(portraits).length) {
|
|
|
+ toast('当前没有可导出的画像,请先生成画像', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ startJob('export-portraits', '/api/portraits/export', {}, (result) => {
|
|
|
+ if (result?.status === 'error') {
|
|
|
+ toast(result.assistantMessage || '导出失败', 'error');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const filePath = result?.files?.[0];
|
|
|
+ if (!filePath) {
|
|
|
+ toast('导出未生成文件', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ toast('导出成功,点击下载', 'success');
|
|
|
+ window.open(`/api/outputs?path=${encodeURIComponent(filePath)}`, '_blank');
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitsDashboardSnapshot(page) {
|
|
|
+ const snapshot = state.portraits.dashboardSnapshot;
|
|
|
+ if (!snapshot || !page?.isConnected) return false;
|
|
|
+ const signature = JSON.stringify({
|
|
|
+ summary: snapshot.summary,
|
|
|
+ portraitIds: Object.entries(snapshot.portraits || {}).map(([id, item]) => [id, item.updatedAt || item.profileUpdatedAt || '']),
|
|
|
+ customerIds: Object.keys(snapshot.customers || {}),
|
|
|
+ tags: snapshot.tags || [],
|
|
|
+ });
|
|
|
+ if (page.dataset.portraitSignature === signature && page.querySelector('#pt-portrait-table-wrap')?.children.length) return true;
|
|
|
+ state.portraits.allPortraits = snapshot.portraits;
|
|
|
+ state.portraits.allCustomers = snapshot.customers;
|
|
|
+ state.portraits.personalLabels = snapshot.personalLabels || [];
|
|
|
+ hideLoading(page.querySelector('#pt-portrait-table-wrap'));
|
|
|
+ renderPortraitsKpi(page, snapshot.summary);
|
|
|
+ renderPortraitsCharts(page, snapshot.summary, snapshot.portraits, snapshot.tags);
|
|
|
+ renderPortraitsTagFilter(page, snapshot.tags);
|
|
|
+ loadPortraitsTable(page);
|
|
|
+ page.dataset.portraitSignature = signature;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ function buildPortraitsDashboardSnapshot({ summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes, directoryCustomers, personalLabels }) {
|
|
|
+ const summary = structuredClone(summaryRes.data || {});
|
|
|
+ const legacyPortraits = portraitsRes.data?.portraits || {};
|
|
|
+ const legacyCustomers = customersRes.data?.customers || {};
|
|
|
+ const customerMaster = customerMasterRes.data?.customers || [];
|
|
|
+ const canonicalPortraits = {};
|
|
|
+ const canonicalCustomers = {};
|
|
|
+ for (const customer of directoryCustomers || []) {
|
|
|
+ const externalUserId = String(customer.externalUserId || '').trim();
|
|
|
+ if (!externalUserId) continue;
|
|
|
+ canonicalCustomers[externalUserId] = {
|
|
|
+ externalUserId,
|
|
|
+ name: customer.name || customer.displayName || '',
|
|
|
+ phone: customer.phone || '',
|
|
|
+ tags: customer.tags || [],
|
|
|
+ messageCount: customer.messageCount || 0,
|
|
|
+ remote: customer.source === 'qiwei-contact',
|
|
|
+ };
|
|
|
+ }
|
|
|
+ for (const customer of customerMaster) {
|
|
|
+ const externalUserId = String(customer.externalUserId || customer.id || '').trim();
|
|
|
+ if (!externalUserId) continue;
|
|
|
+ const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
|
|
|
+ if (hasProfile) {
|
|
|
+ canonicalPortraits[externalUserId] = {
|
|
|
+ externalUserId,
|
|
|
+ displayId: customer.maskedId,
|
|
|
+ fields: customer.fields || [],
|
|
|
+ profile: customer.profile || {},
|
|
|
+ source: 'agent',
|
|
|
+ canonical: true,
|
|
|
+ messageCount: customer.messageCount || 0,
|
|
|
+ updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ canonicalCustomers[externalUserId] = {
|
|
|
+ ...(canonicalCustomers[externalUserId] || {}),
|
|
|
+ externalUserId,
|
|
|
+ name: customer.displayName,
|
|
|
+ phone: customer.maskedId,
|
|
|
+ tags: customer.tags || [],
|
|
|
+ messageCount: customer.messageCount || 0,
|
|
|
+ };
|
|
|
+ }
|
|
|
+ const portraits = { ...legacyPortraits, ...canonicalPortraits };
|
|
|
+ const customers = { ...legacyCustomers, ...canonicalCustomers };
|
|
|
+ const tags = [...new Set([
|
|
|
+ ...(tagsRes.data?.allTags || []),
|
|
|
+ ...customerMaster.flatMap(customer => customer.tags || []),
|
|
|
+ ...(personalLabels || []).map(label => label.name).filter(Boolean),
|
|
|
+ ])];
|
|
|
+ const portraitItems = Object.values(portraits);
|
|
|
+ const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.length, Object.keys(customers).length);
|
|
|
+ const bySource = portraitItems.reduce((counts, portrait) => {
|
|
|
+ const source = portrait.source === 'agent' ? 'agent' : portrait.source === 'keyword' ? 'keyword' : 'other';
|
|
|
+ counts[source] += 1;
|
|
|
+ return counts;
|
|
|
+ }, { agent: 0, keyword: 0, other: 0 });
|
|
|
+ summary.portraits = {
|
|
|
+ ...(summary.portraits || {}),
|
|
|
+ total: portraitItems.length,
|
|
|
+ coverage: customerTotal ? Math.round((portraitItems.length / customerTotal) * 100) : 0,
|
|
|
+ bySource,
|
|
|
+ };
|
|
|
+ summary.tags = {
|
|
|
+ ...(summary.tags || {}),
|
|
|
+ total: tags.length,
|
|
|
+ avgPerCustomer: customerTotal ? Number((tags.length / customerTotal).toFixed(1)) : 0,
|
|
|
+ };
|
|
|
+ return { summary, portraits, customers, tags, personalLabels: personalLabels || [] };
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadPortraitsDashboard(page, silent = false, force = false) {
|
|
|
+ if (state.portraits.loadingPromise) return state.portraits.loadingPromise;
|
|
|
+ const tableWrap = page.querySelector('#pt-portrait-table-wrap');
|
|
|
+ if (!silent && !state.portraits.dashboardSnapshot) showLoading(tableWrap, 'table');
|
|
|
+ const run = (async () => {
|
|
|
+ const fastPromise = Promise.all([
|
|
|
+ api('GET', '/api/dashboard/summary'),
|
|
|
+ api('GET', '/api/dashboard/portraits'),
|
|
|
+ api('GET', '/api/dashboard/tags'),
|
|
|
+ api('GET', '/api/dashboard/customers'),
|
|
|
+ api('GET', '/api/customers'),
|
|
|
+ ]);
|
|
|
+ const directoryPromise = api('GET', `/api/dashboard/customers/search?scope=portrait&limit=100&sortBy=nameAsc${force ? '&refresh=1' : ''}`);
|
|
|
+ const labelsPromise = getGuid() && (force || !state.portraits.personalLabelsLoaded)
|
|
|
+ ? api('POST', '/api/personal-labels/sync', { guid: getGuid() }).catch(() => ({ data: { labels: [] } }))
|
|
|
+ : Promise.resolve({ data: { labels: state.portraits.personalLabels || [] } });
|
|
|
+ const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes] = await fastPromise;
|
|
|
+ const baseInput = { summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes };
|
|
|
+ state.portraits.dashboardSnapshot = buildPortraitsDashboardSnapshot({
|
|
|
+ ...baseInput,
|
|
|
+ directoryCustomers: state.portraits.directoryCustomers || [],
|
|
|
+ personalLabels: state.portraits.personalLabels || [],
|
|
|
+ });
|
|
|
+ renderPortraitsDashboardSnapshot(page);
|
|
|
+
|
|
|
+ const [directoryRes, personalLabelsRes] = await Promise.all([directoryPromise, labelsPromise]);
|
|
|
+ state.portraits.directoryCustomers = directoryRes.data?.customers || [];
|
|
|
+ state.portraits.personalLabels = (personalLabelsRes.data?.labels || []).filter(label => label && label.isDeleted !== 1 && label.dataType !== 2);
|
|
|
+ state.portraits.personalLabelsLoaded = true;
|
|
|
+ state.portraits.dashboardSnapshot = buildPortraitsDashboardSnapshot({
|
|
|
+ ...baseInput,
|
|
|
+ directoryCustomers: state.portraits.directoryCustomers,
|
|
|
+ personalLabels: state.portraits.personalLabels,
|
|
|
+ });
|
|
|
+ renderPortraitsDashboardSnapshot(page);
|
|
|
+ })().catch(err => {
|
|
|
+ if (!silent && page?.isConnected) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page, false, true));
|
|
|
+ }).finally(() => { state.portraits.loadingPromise = null; });
|
|
|
+ state.portraits.loadingPromise = run;
|
|
|
+ return run;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitsKpi(page, summary) {
|
|
|
+ const wrap = page.querySelector('#pt-kpi-row');
|
|
|
+ const p = summary.portraits || {};
|
|
|
+ const t = summary.tags || {};
|
|
|
+ const kpi = [
|
|
|
+ { label: '已生成画像', value: formatNumber(p.total) },
|
|
|
+ { label: '画像覆盖率', value: (p.coverage || 0) + '%', sub: '有画像客户占比' },
|
|
|
+ { label: '可用标签', value: formatNumber(t.total), sub: '本地与企微标签去重' },
|
|
|
+ { label: '人均标签', value: t.avgPerCustomer || 0, sub: '每个客户平均标签' },
|
|
|
+ { label: 'AI 画像', value: formatNumber(p.bySource?.agent || 0), sub: 'Agent 分析生成' },
|
|
|
+ { label: '关键词画像', value: formatNumber(p.bySource?.keyword || 0), sub: '关键词匹配生成' }
|
|
|
+ ];
|
|
|
+ wrap.innerHTML = renderKpiRow(kpi);
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitsCharts(page, summary, portraits, tags) {
|
|
|
+ const colors = getChartColors();
|
|
|
+ const coverage = summary.portraits?.coverage || 0;
|
|
|
+ initEChart('pt-coverage-chart', {
|
|
|
+ series: [{
|
|
|
+ type: 'gauge',
|
|
|
+ startAngle: 180,
|
|
|
+ endAngle: 0,
|
|
|
+ min: 0,
|
|
|
+ max: 100,
|
|
|
+ progress: { show: true, width: 12, itemStyle: { color: colors.brand } },
|
|
|
+ axisLine: { lineStyle: { width: 12, color: [[1, 'rgba(0,0,0,0.06)']] } },
|
|
|
+ axisTick: { show: false },
|
|
|
+ splitLine: { show: false },
|
|
|
+ axisLabel: { show: false },
|
|
|
+ pointer: { show: false },
|
|
|
+ anchor: { show: false },
|
|
|
+ title: { show: false },
|
|
|
+ detail: {
|
|
|
+ valueAnimation: true,
|
|
|
+ fontSize: 28,
|
|
|
+ fontWeight: 800,
|
|
|
+ color: colors.textPrimary,
|
|
|
+ formatter: '{value}%',
|
|
|
+ offsetCenter: [0, '0%']
|
|
|
+ },
|
|
|
+ data: [{ value: coverage }]
|
|
|
+ }]
|
|
|
+ });
|
|
|
+
|
|
|
+ const bySource = summary.portraits?.bySource || {};
|
|
|
+ initEChart('pt-source-chart', {
|
|
|
+ tooltip: { trigger: 'item' },
|
|
|
+ legend: { bottom: 0 },
|
|
|
+ color: [colors.brand, colors.info, colors.muted],
|
|
|
+ series: [{
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['40%', '65%'],
|
|
|
+ center: ['50%', '45%'],
|
|
|
+ data: [
|
|
|
+ { name: '关键词', value: bySource.keyword || 0 },
|
|
|
+ { name: 'AI', value: bySource.agent || 0 },
|
|
|
+ { name: '其他', value: bySource.other || 0 }
|
|
|
+ ]
|
|
|
+ }]
|
|
|
+ });
|
|
|
+
|
|
|
+ const customers = Object.values(state.portraits.allCustomers || {});
|
|
|
+ const topTags = tags
|
|
|
+ .map(t => ({
|
|
|
+ name: t,
|
|
|
+ value: customers.filter(c => Array.isArray(c.tags) && c.tags.includes(t)).length
|
|
|
+ }))
|
|
|
+ .filter(t => t.value > 0)
|
|
|
+ .sort((a, b) => b.value - a.value)
|
|
|
+ .slice(0, 10);
|
|
|
+ initEChart('pt-tags-chart', {
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ grid: { left: 8, right: 34, top: 10, bottom: 8, containLabel: true },
|
|
|
+ xAxis: {
|
|
|
+ type: 'value',
|
|
|
+ splitLine: { lineStyle: { color: 'rgba(0,0,0,0.06)' } },
|
|
|
+ axisLabel: { color: colors.muted }
|
|
|
+ },
|
|
|
+ yAxis: {
|
|
|
+ type: 'category',
|
|
|
+ data: topTags.map(t => t.name).reverse(),
|
|
|
+ axisLabel: {
|
|
|
+ width: 96,
|
|
|
+ overflow: 'truncate',
|
|
|
+ interval: 0,
|
|
|
+ color: colors.textPrimary
|
|
|
+ },
|
|
|
+ axisTick: { show: false }
|
|
|
+ },
|
|
|
+ series: [{
|
|
|
+ type: 'bar',
|
|
|
+ data: topTags.map(t => t.value).reverse(),
|
|
|
+ itemStyle: { color: colors.brand, borderRadius: [0, 5, 5, 0] },
|
|
|
+ barWidth: 14,
|
|
|
+ label: {
|
|
|
+ show: true,
|
|
|
+ position: 'right',
|
|
|
+ color: colors.textPrimary,
|
|
|
+ fontWeight: 600
|
|
|
+ }
|
|
|
+ }]
|
|
|
+ });
|
|
|
+
|
|
|
+ const days = last7Days();
|
|
|
+ const portraitItems = Object.values(portraits);
|
|
|
+ const counts = countByDay(portraitItems, 'updatedAt');
|
|
|
+ initEChart('pt-trend-chart', {
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
|
|
|
+ yAxis: { type: 'value' },
|
|
|
+ series: [{ name: '画像生成', type: 'line', smooth: true, data: counts, itemStyle: { color: colors.brand }, areaStyle: { color: hexToRgba(colors.brand, 0.15) } }]
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitsTagFilter(page, tags) {
|
|
|
+ const select = page.querySelector('#pt-filter-tag');
|
|
|
+ const current = select.value;
|
|
|
+ select.innerHTML = '<option value="">全部标签</option>' + tags.map(t => `<option value="${escapeHtml(t)}">${escapeHtml(t)}</option>`).join('');
|
|
|
+ select.value = current;
|
|
|
+ }
|
|
|
+
|
|
|
+ function loadPortraitsTable(page) {
|
|
|
+ const wrap = page.querySelector('#pt-portrait-table-wrap');
|
|
|
+ const portraits = state.portraits.allPortraits || {};
|
|
|
+ const customers = state.portraits.allCustomers || {};
|
|
|
+ const keyword = page.querySelector('#pt-filter-keyword').value.trim().toLowerCase();
|
|
|
+ const source = page.querySelector('#pt-filter-source').value;
|
|
|
+ const tag = page.querySelector('#pt-filter-tag').value;
|
|
|
+
|
|
|
+ state.portraits.keyword = keyword;
|
|
|
+ state.portraits.source = source;
|
|
|
+ state.portraits.tag = tag;
|
|
|
+ saveFilters(PT_FILTERS_KEY, {
|
|
|
+ tablePage: state.portraits.tablePage,
|
|
|
+ keyword,
|
|
|
+ source,
|
|
|
+ tag
|
|
|
+ });
|
|
|
+
|
|
|
+ const customerById = new Map(Object.values(customers).map(customer => [String(customer.externalUserId || ''), customer]));
|
|
|
+ const ids = new Set([...customerById.keys(), ...Object.keys(portraits)]);
|
|
|
+ ids.delete('');
|
|
|
+ let items = Array.from(ids).map(externalUserId => {
|
|
|
+ const portrait = portraits[externalUserId] || {};
|
|
|
+ const customer = customerById.get(externalUserId) || {};
|
|
|
+ return {
|
|
|
+ externalUserId,
|
|
|
+ fields: [],
|
|
|
+ source: 'pending',
|
|
|
+ messageCount: customer.messageCount || 0,
|
|
|
+ ...portrait,
|
|
|
+ name: customer.name,
|
|
|
+ phone: customer.phone,
|
|
|
+ tags: customer.tags || [],
|
|
|
+ };
|
|
|
+ });
|
|
|
+
|
|
|
+ let filtered = items.filter(p => {
|
|
|
+ if (keyword) {
|
|
|
+ const text = `${p.externalUserId || ''} ${p.name || ''} ${p.phone || ''}`.toLowerCase();
|
|
|
+ if (!text.includes(keyword)) return false;
|
|
|
+ }
|
|
|
+ if (source && p.source !== source) return false;
|
|
|
+ if (tag && !p.tags.includes(tag)) return false;
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ const pageSize = 20;
|
|
|
+ const currentPage = state.portraits.tablePage || 1;
|
|
|
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
|
|
+ if (currentPage > totalPages) {
|
|
|
+ state.portraits.tablePage = totalPages;
|
|
|
+ saveFilters(PT_FILTERS_KEY, {
|
|
|
+ tablePage: state.portraits.tablePage,
|
|
|
+ keyword: state.portraits.keyword,
|
|
|
+ source: state.portraits.source,
|
|
|
+ tag: state.portraits.tag
|
|
|
+ });
|
|
|
+ }
|
|
|
+ const start = (state.portraits.tablePage - 1) * pageSize;
|
|
|
+ const pageItems = filtered.slice(start, start + pageSize);
|
|
|
+
|
|
|
+ wrap.innerHTML = `
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>externalUserId</th>
|
|
|
+ <th>姓名</th>
|
|
|
+ <th>标签</th>
|
|
|
+ <th>画像字段</th>
|
|
|
+ <th>来源</th>
|
|
|
+ <th>消息数</th>
|
|
|
+ <th>更新时间</th>
|
|
|
+ <th>操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ ${pageItems.length ? pageItems.map(p => `
|
|
|
+ <tr>
|
|
|
+ <td style="font-family:monospace;font-size:12px">${escapeHtml(p.displayId || p.externalUserId || '-')}</td>
|
|
|
+ <td>${escapeHtml(p.name || '-')}</td>
|
|
|
+ <td>${p.tags.length ? p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
|
|
|
+ <td>${formatNumber(p.fields?.length || 0)} 个字段</td>
|
|
|
+ <td>${p.source === 'pending' ? '<span class="badge badge-default">待生成</span>' : p.source === 'keyword' ? '<span class="badge badge-warning">关键词</span>' : p.source === 'agent' ? '<span class="badge badge-info">AI</span>' : '<span class="badge badge-default">未知</span>'}</td>
|
|
|
+ <td>${formatNumber(p.messageCount)}</td>
|
|
|
+ <td>${formatDateTime(p.updatedAt)}</td>
|
|
|
+ <td>
|
|
|
+ ${p.source === 'pending'
|
|
|
+ ? '<button class="btn btn-sm btn-secondary" disabled title="客户对话产生画像证据后可查看">待沉淀</button>'
|
|
|
+ : `<button class="btn btn-sm btn-secondary" onclick="window.dashboardPortraitDetail('${escapeHtml(p.externalUserId)}')">查看</button>`}
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `).join('') : renderEmptyTableRow(8, '暂无画像数据', '选择客户后批量生成画像')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ const pagination = page.querySelector('#pt-pagination');
|
|
|
+ pagination.innerHTML = renderPagination(state.portraits.tablePage, filtered.length, pageSize);
|
|
|
+ pagination.querySelectorAll('button').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ state.portraits.tablePage = Number(btn.dataset.page);
|
|
|
+ saveFilters(PT_FILTERS_KEY, {
|
|
|
+ tablePage: state.portraits.tablePage,
|
|
|
+ keyword: state.portraits.keyword,
|
|
|
+ source: state.portraits.source,
|
|
|
+ tag: state.portraits.tag
|
|
|
+ });
|
|
|
+ loadPortraitsTable(page);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ async function openBatchPortraitSelector(page, updateMode = 'incremental') {
|
|
|
+ const result = await openCustomerSelector({
|
|
|
+ multiple: true,
|
|
|
+ title: updateMode === 'full' ? '选择要全量更新画像的客户' : '选择要增量更新画像的客户',
|
|
|
+ confirmText: updateMode === 'full' ? '全量更新' : '增量更新'
|
|
|
+ });
|
|
|
+
|
|
|
+ const ids = result.externalUserIds || [];
|
|
|
+ if (!ids.length) {
|
|
|
+ toast('未选择客户', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+
|
|
|
+ const jobName = updateMode === 'full' ? 'full-portraits' : 'incremental-portraits';
|
|
|
+ startJob(jobName, '/api/portraits/batch', { externalUserIds: ids, aiMode: 'ai', updateMode, guid: getGuid() }, () => loadPortraitsDashboard(page), 'pt-job-tracker');
|
|
|
+ }
|
|
|
+
|
|
|
+ function openTagModal(page) {
|
|
|
+ const body = `
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>选择客户</label>
|
|
|
+ <button type="button" class="customer-selector-trigger" id="btn-select-tag-customer">点击选择客户</button>
|
|
|
+ <div class="customer-selector-value" id="tag-customer-selected"><span style="color:var(--text-muted);font-size:13px">未选择客户</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>标签(逗号分隔)</label>
|
|
|
+ <input id="tag-input" type="text" placeholder="高意向,重点客户" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>操作</label>
|
|
|
+ <select id="tag-action">
|
|
|
+ <option value="add">添加标签</option>
|
|
|
+ <option value="remove">移除标签</option>
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ openModal('为客户打标签', body, `
|
|
|
+ <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
|
|
|
+ <button class="btn" id="btn-confirm-tag">确定</button>
|
|
|
+ `);
|
|
|
+
|
|
|
+ let selectedCustomer = null;
|
|
|
+ document.getElementById('btn-select-tag-customer').addEventListener('click', async () => {
|
|
|
+ const result = await openCustomerSelector({ multiple: false, selectedIds: selectedCustomer ? [selectedCustomer.externalUserId] : [], title: '选择要打标签的客户' });
|
|
|
+ selectedCustomer = result.customers[0] || null;
|
|
|
+ renderCustomerSelectorValue('tag-customer-selected', selectedCustomer ? [selectedCustomer] : []);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('btn-confirm-tag').addEventListener('click', async () => {
|
|
|
+ const externalUserId = selectedCustomer ? selectedCustomer.externalUserId : '';
|
|
|
+ const tags = document.getElementById('tag-input').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ const action = document.getElementById('tag-action').value;
|
|
|
+ if (!externalUserId || !tags.length) {
|
|
|
+ toast('请选择客户并输入标签', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await api('POST', `/api/tags/${action}`, { externalUserId, tags });
|
|
|
+ toast('标签已更新');
|
|
|
+ closeModal();
|
|
|
+ loadPortraitsDashboard(page);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '更新失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function portraitLabel(key) {
|
|
|
+ const labels = {
|
|
|
+ intent: '意向',
|
|
|
+ budgetRange: '预算',
|
|
|
+ preferredAreas: '区域',
|
|
|
+ houseType: '房型',
|
|
|
+ timeline: '时间计划',
|
|
|
+ keyConcerns: '关注点',
|
|
|
+ urgency: '紧迫度',
|
|
|
+ aiSummary: '综合判断',
|
|
|
+ personaType: '客户类型',
|
|
|
+ fiveW2H: '5W2H',
|
|
|
+ priorityMatrix: '优先级',
|
|
|
+ confidence: '置信度',
|
|
|
+ coreAnxiety: '核心顾虑',
|
|
|
+ decisionMaker: '决策人',
|
|
|
+ loanCapacity: '贷款能力',
|
|
|
+ negotiationStage: '谈判阶段'
|
|
|
+ };
|
|
|
+ return labels[key] || key;
|
|
|
+ }
|
|
|
+
|
|
|
+ function normalizePortraitValue(value) {
|
|
|
+ if (value === undefined || value === null || value === '') return { text: '-', chips: [], note: '' };
|
|
|
+ if (Array.isArray(value)) {
|
|
|
+ return { text: value.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join('、'), chips: value.map(String), note: '' };
|
|
|
+ }
|
|
|
+ if (typeof value !== 'object') return { text: String(value), chips: [], note: '' };
|
|
|
+
|
|
|
+ const chips = [];
|
|
|
+ if (Array.isArray(value.keywords)) chips.push(...value.keywords.map(String));
|
|
|
+ if (Array.isArray(value.evidence)) chips.push(...value.evidence.map(String));
|
|
|
+ if (Array.isArray(value.values)) chips.push(...value.values.map(String));
|
|
|
+
|
|
|
+ const textCandidates = [
|
|
|
+ value.value,
|
|
|
+ value.result,
|
|
|
+ value.summary,
|
|
|
+ value.note,
|
|
|
+ value.inference,
|
|
|
+ value.description,
|
|
|
+ value.label
|
|
|
+ ].filter(v => v !== undefined && v !== null && v !== '');
|
|
|
+
|
|
|
+ if (textCandidates.length) {
|
|
|
+ return {
|
|
|
+ text: textCandidates.map(v => typeof v === 'object' ? JSON.stringify(v) : String(v)).join(';'),
|
|
|
+ chips: [...new Set(chips)],
|
|
|
+ note: value.inferred ? '根据聊天记录推断' : ''
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ text: JSON.stringify(value, null, 2),
|
|
|
+ chips: [...new Set(chips)],
|
|
|
+ note: value.inferred ? '根据聊天记录推断' : ''
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitInsightCard(key, value) {
|
|
|
+ const normalized = normalizePortraitValue(value);
|
|
|
+ const chipHtml = normalized.chips.length
|
|
|
+ ? `<div class="portrait-evidence">${normalized.chips.slice(0, 8).map(chip => `<span>${escapeHtml(chip)}</span>`).join('')}</div>`
|
|
|
+ : '';
|
|
|
+ return `
|
|
|
+ <div class="portrait-insight-card">
|
|
|
+ <div class="portrait-insight-label">${escapeHtml(portraitLabel(key))}</div>
|
|
|
+ <div class="portrait-insight-value">${escapeHtml(normalized.text)}</div>
|
|
|
+ ${normalized.note ? `<div class="portrait-insight-note">${escapeHtml(normalized.note)}</div>` : ''}
|
|
|
+ ${chipHtml}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderPortraitSection(title, keys, portraitFields) {
|
|
|
+ const available = keys.filter(k => portraitFields[k] !== undefined && portraitFields[k] !== null && portraitFields[k] !== '');
|
|
|
+ if (!available.length) return '';
|
|
|
+ return `
|
|
|
+ <section class="portrait-section">
|
|
|
+ <div class="portrait-section-head">
|
|
|
+ <h3>${escapeHtml(title)}</h3>
|
|
|
+ <span>${formatNumber(available.length)} 项</span>
|
|
|
+ </div>
|
|
|
+ <div class="portrait-insight-grid">
|
|
|
+ ${available.map(k => renderPortraitInsightCard(k, portraitFields[k])).join('')}
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ `;
|
|
|
+ }
|
|
|
+
|
|
|
+ window.dashboardPortraitDetail = async (externalUserId) => {
|
|
|
+ if (!externalUserId) return;
|
|
|
+ const cachedPortrait = state.portraits.allPortraits?.[externalUserId];
|
|
|
+ if (cachedPortrait?.canonical) {
|
|
|
+ const customer = state.portraits.allCustomers?.[externalUserId] || {};
|
|
|
+ const fields = cachedPortrait.fields || [];
|
|
|
+ openModal('画像详情', `
|
|
|
+ <div class="portrait-profile">
|
|
|
+ <div class="portrait-hero">
|
|
|
+ <div class="portrait-avatar">${escapeHtml((customer.name || '客').slice(0, 1))}</div>
|
|
|
+ <div class="portrait-identity">
|
|
|
+ <div class="portrait-name">${escapeHtml(customer.name || '未命名客户')}</div>
|
|
|
+ <div class="portrait-subtitle">${escapeHtml(cachedPortrait.displayId || '-')} · 真实企微会话画像</div>
|
|
|
+ <div class="portrait-chip-row">
|
|
|
+ <span class="badge badge-info">Agent 自动沉淀</span>
|
|
|
+ <span class="portrait-soft-chip">${formatNumber(cachedPortrait.messageCount || 0)} 条消息</span>
|
|
|
+ <span class="portrait-soft-chip">${formatNumber(fields.length)} 个画像字段</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="portrait-highlight-grid">
|
|
|
+ ${fields.map(field => `<div class="portrait-insight-card"><span>${escapeHtml(field.label || field.key)}</span><strong>${escapeHtml(field.displayValue ?? '-')}</strong></div>`).join('')}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'portrait-modal');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid(), localOnly: true });
|
|
|
+ const data = res.data || {};
|
|
|
+ const portrait = data.portrait || {};
|
|
|
+ const portraitFields = portrait.portrait || {};
|
|
|
+
|
|
|
+ const localCustomer = Object.values(state.portraits.allCustomers || {}).find(c => c.externalUserId === externalUserId) || {};
|
|
|
+ const name = localCustomer.name || '-';
|
|
|
+ const phone = localCustomer.phone || '-';
|
|
|
+ const tags = localCustomer.tags || [];
|
|
|
+
|
|
|
+ const sourceMap = {
|
|
|
+ keyword: ['badge-warning', '关键词'],
|
|
|
+ agent: ['badge-info', 'AI'],
|
|
|
+ unknown: ['badge-default', '未知']
|
|
|
+ };
|
|
|
+ const [srcCls, srcText] = sourceMap[portrait.source] || sourceMap.unknown;
|
|
|
+
|
|
|
+ const groupMap = {
|
|
|
+ '购房需求': ['intent', 'budgetRange', 'preferredAreas', 'houseType'],
|
|
|
+ '决策状态': ['timeline', 'urgency', 'negotiationStage', 'decisionMaker'],
|
|
|
+ '关注与顾虑': ['keyConcerns', 'coreAnxiety', 'loanCapacity'],
|
|
|
+ '综合分析': ['aiSummary', 'personaType', 'fiveW2H', 'priorityMatrix', 'confidence']
|
|
|
+ };
|
|
|
+
|
|
|
+ const fieldCount = Object.keys(portraitFields).length;
|
|
|
+ const customerInitial = (name && name !== '-' ? name : '客').slice(0, 1);
|
|
|
+ const topFields = ['intent', 'budgetRange', 'preferredAreas', 'timeline', 'keyConcerns']
|
|
|
+ .filter(k => portraitFields[k] !== undefined && portraitFields[k] !== null && portraitFields[k] !== '')
|
|
|
+ .slice(0, 4);
|
|
|
+ const topHighlights = topFields.length
|
|
|
+ ? topFields.map(k => renderPortraitInsightCard(k, portraitFields[k])).join('')
|
|
|
+ : '<div class="portrait-empty-panel">暂无可展示的核心画像字段</div>';
|
|
|
+
|
|
|
+ let sectionsHtml = '';
|
|
|
+ for (const [groupName, keys] of Object.entries(groupMap)) {
|
|
|
+ sectionsHtml += renderPortraitSection(groupName, keys, portraitFields);
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!sectionsHtml) {
|
|
|
+ sectionsHtml = '<div class="portrait-empty-panel">当前画像没有结构化字段。请先同步更多客户对话,再重新生成画像。</div>';
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!detailRoot?.isConnected) return;
|
|
|
+ detailRoot.classList.remove('portrait-modal-loading');
|
|
|
+ detailRoot.innerHTML = `
|
|
|
+ <div class="portrait-profile">
|
|
|
+ <div class="portrait-hero">
|
|
|
+ <div class="portrait-avatar">${escapeHtml(customerInitial)}</div>
|
|
|
+ <div class="portrait-identity">
|
|
|
+ <div class="portrait-name">${escapeHtml(name || '未命名客户')}</div>
|
|
|
+ <div class="portrait-subtitle">${escapeHtml(phone || '-')} · ${escapeHtml(externalUserId)}</div>
|
|
|
+ <div class="portrait-chip-row">
|
|
|
+ <span class="badge ${srcCls}">${escapeHtml(srcText)}</span>
|
|
|
+ <span class="portrait-soft-chip">${formatNumber(portrait.messageCount || 0)} 条消息</span>
|
|
|
+ <span class="portrait-soft-chip">${formatNumber(fieldCount)} 个画像字段</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="portrait-meta">
|
|
|
+ <div>生成时间</div>
|
|
|
+ <strong>${escapeHtml(formatDateTime(portrait.updatedAt))}</strong>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ${tags.length ? `<div class="portrait-tags">${tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join(' ')}</div>` : ''}
|
|
|
+ <div class="portrait-highlight-grid">
|
|
|
+ ${topHighlights}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ${sectionsHtml}
|
|
|
+ ${renderRawToggle(portraitFields)}
|
|
|
+ `;
|
|
|
+ bindRawToggle();
|
|
|
+ } catch (err) {
|
|
|
+ if (detailRoot?.isConnected) {
|
|
|
+ detailRoot.classList.remove('portrait-modal-loading');
|
|
|
+ detailRoot.innerHTML = `<div class="portrait-modal-error"><strong>画像详情加载失败</strong><span>${escapeHtml(err.message || '请稍后重试')}</span></div>`;
|
|
|
+ }
|
|
|
+ toast(err.message || '查询失败', 'error');
|
|
|
+ }
|
|
|
+ };
|
|
|
+ async function renderTransfersPage() {
|
|
|
+ els.pageTitle.textContent = '客户交接';
|
|
|
+ const page = document.createElement('div');
|
|
|
+ page.className = 'page customer-workspace-page';
|
|
|
+
|
|
|
+ page.innerHTML = `
|
|
|
+ <section class="workspace-hero customer-workspace-hero transfer-dashboard-hero">
|
|
|
+ <div>
|
|
|
+ <span>CUSTOMER HANDOVER WORKFLOW</span>
|
|
|
+ <h2>客户交接</h2>
|
|
|
+ <p>先生成交接预览,再执行顾问变更;客户、群聊、欢迎语与执行结果全程留痕。</p>
|
|
|
+ </div>
|
|
|
+ <div class="handover-flow" aria-label="交接流程">
|
|
|
+ <span><b>1</b>准备范围</span><i></i><span><b>2</b>生成预览</span><i></i><span><b>3</b>确认执行</span>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
+ <div class="dashboard-grid customer-dashboard-grid" id="tf-dashboard">
|
|
|
+ <div class="kpi-row" id="tf-kpi-row"></div>
|
|
|
+ <div class="chart-card transfer-progress-card">
|
|
|
+ <h3 class="chart-title"><span>交接流程转化</span><span class="chart-subtitle">从预览准备到成功交接</span></h3>
|
|
|
+ <div class="transfer-stage-board" id="tf-stage-board"></div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-card">
|
|
|
+ <h3 class="chart-title"><span>交接结果分布</span></h3>
|
|
|
+ <div class="chart-container" id="tf-result-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="chart-card full">
|
|
|
+ <h3 class="chart-title"><span>近7日交接执行趋势</span></h3>
|
|
|
+ <div class="chart-container" id="tf-trend-chart"></div>
|
|
|
+ </div>
|
|
|
+ <div class="operation-toolbar">
|
|
|
+ <div class="toolbar-left">
|
|
|
+ <button class="btn" id="btn-new-transfer">新建交接预览</button>
|
|
|
+ </div>
|
|
|
+ <div class="toolbar-right">
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-tf-refresh">刷新</button>
|
|
|
+ <button class="btn btn-sm btn-secondary" id="btn-tf-rebuild">重建索引</button>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="data-table-card">
|
|
|
+ <div class="card-header">
|
|
|
+ <div>
|
|
|
+ <h3 class="card-title">交接包列表</h3>
|
|
|
+ <div class="card-subtitle">预览包与执行记录</div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="filter-bar" style="margin-bottom:16px">
|
|
|
+ <select id="tf-filter-status">
|
|
|
+ <option value="">全部状态</option>
|
|
|
+ <option value="DRAFT" ${state.transfers.status === 'DRAFT' ? 'selected' : ''}>草稿</option>
|
|
|
+ <option value="EXECUTED" ${state.transfers.status === 'EXECUTED' ? 'selected' : ''}>已执行</option>
|
|
|
+ </select>
|
|
|
+ <input id="tf-filter-user" type="text" placeholder="按顾问 userId 搜索" style="width:200px" value="${escapeHtml(state.transfers.user || '')}" />
|
|
|
+ </div>
|
|
|
+ <div class="operation-note" id="tf-source-summary"></div>
|
|
|
+ <div id="tf-transfer-table-wrap"></div>
|
|
|
+ <div id="tf-pagination"></div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ els.content.appendChild(page);
|
|
|
+
|
|
|
+ if (renderTransfersDashboardSnapshot(page)) void loadTransfersDashboard(page, true);
|
|
|
+ else await loadTransfersDashboard(page);
|
|
|
+ dashboardRefreshTimer = setInterval(() => {
|
|
|
+ if (state.page === 'transfers') loadTransfersDashboard(page, true);
|
|
|
+ }, 30000);
|
|
|
+
|
|
|
+ page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page, false, true));
|
|
|
+ page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
|
|
|
+ try {
|
|
|
+ await api('POST', '/api/dashboard/rebuild');
|
|
|
+ toast('索引已重建');
|
|
|
+ loadTransfersDashboard(page, false, true);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '重建失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ page.querySelector('#btn-new-transfer').addEventListener('click', () => openTransferModal(page));
|
|
|
+ page.querySelector('#tf-filter-status').addEventListener('change', () => loadTransfersTable(page));
|
|
|
+ page.querySelector('#tf-filter-user').addEventListener('input', () => loadTransfersTable(page));
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTransfersDashboardSnapshot(page) {
|
|
|
+ const snapshot = state.transfers.dashboardSnapshot;
|
|
|
+ if (!snapshot || !page?.isConnected) return false;
|
|
|
+ const signature = JSON.stringify({
|
|
|
+ summary: snapshot.summary?.transfers || {},
|
|
|
+ previews: (snapshot.transfers?.previews || []).map(item => [item.filePath, item.status, item.createdAt]),
|
|
|
+ executions: (snapshot.transfers?.executions || []).map(item => [item.filePath, item.status, item.executedAt]),
|
|
|
+ candidateIds: (snapshot.candidates || []).map(item => item.externalUserId),
|
|
|
+ groupIds: (snapshot.groups || []).map(item => item.roomId),
|
|
|
+ });
|
|
|
+ if (page.dataset.transferSignature === signature && page.querySelector('#tf-transfer-table-wrap')?.children.length) return true;
|
|
|
+ renderTransfersKpi(page, snapshot.summary, { candidates: snapshot.candidates, groups: snapshot.groups });
|
|
|
+ renderTransfersCharts(page, snapshot.summary, snapshot.transfers);
|
|
|
+ renderTransferSourceSummary(page, snapshot.candidates, snapshot.groups);
|
|
|
+ state.transfers = {
|
|
|
+ ...state.transfers,
|
|
|
+ ...snapshot.transfers,
|
|
|
+ candidates: snapshot.candidates,
|
|
|
+ groups: snapshot.groups,
|
|
|
+ dashboardSnapshot: snapshot,
|
|
|
+ };
|
|
|
+ hideLoading(page.querySelector('#tf-transfer-table-wrap'));
|
|
|
+ loadTransfersTable(page);
|
|
|
+ page.dataset.transferSignature = signature;
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ async function loadTransfersDashboard(page, silent = false, force = false) {
|
|
|
+ if (state.transfers.loadingPromise) return state.transfers.loadingPromise;
|
|
|
+ const tableWrap = page.querySelector('#tf-transfer-table-wrap');
|
|
|
+ if (!silent && !state.transfers.dashboardSnapshot) showLoading(tableWrap, 'table');
|
|
|
+ const run = (async () => {
|
|
|
+ const candidatesPromise = api('GET', `/api/dashboard/customers/search?limit=100&sortBy=nameAsc${force ? '&refresh=1' : ''}`);
|
|
|
+ const [summaryRes, transfersRes, groupsRes] = await Promise.all([
|
|
|
+ api('GET', '/api/dashboard/summary'),
|
|
|
+ api('GET', '/api/dashboard/transfers'),
|
|
|
+ api('GET', '/api/groups/list')
|
|
|
+ ]);
|
|
|
+ const summary = summaryRes.data || {};
|
|
|
+ const transfers = transfersRes.data || {};
|
|
|
+ const groups = groupsRes.data?.groups || [];
|
|
|
+ state.transfers.dashboardSnapshot = {
|
|
|
+ summary,
|
|
|
+ transfers,
|
|
|
+ candidates: state.transfers.candidates || [],
|
|
|
+ groups,
|
|
|
+ };
|
|
|
+ renderTransfersDashboardSnapshot(page);
|
|
|
+
|
|
|
+ const customersRes = await candidatesPromise;
|
|
|
+ state.transfers.dashboardSnapshot = {
|
|
|
+ summary,
|
|
|
+ transfers,
|
|
|
+ candidates: customersRes.data?.customers || [],
|
|
|
+ groups,
|
|
|
+ };
|
|
|
+ renderTransfersDashboardSnapshot(page);
|
|
|
+ })().catch(err => {
|
|
|
+ if (!silent && page?.isConnected) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page, false, true));
|
|
|
+ }).finally(() => { state.transfers.loadingPromise = null; });
|
|
|
+ state.transfers.loadingPromise = run;
|
|
|
+ return run;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTransfersKpi(page, summary, source = {}) {
|
|
|
+ const wrap = page.querySelector('#tf-kpi-row');
|
|
|
+ const t = summary.transfers || {};
|
|
|
+ const kpi = [
|
|
|
+ { label: '可交接客户', value: formatNumber(source.candidates?.length || 0), sub: '已自动读取企微联系人' },
|
|
|
+ { label: '可交接群聊', value: formatNumber(source.groups?.length || 0), sub: '已自动读取群目录' },
|
|
|
+ { label: '交接预览包', value: formatNumber(t.previews) },
|
|
|
+ { label: '已执行交接', value: formatNumber(t.executions) },
|
|
|
+ { label: '交接成功率', value: (t.successRate || 0) + '%', sub: '成功记录 / 总记录' },
|
|
|
+ { label: '近7日交接', value: formatNumber(t.recent7d), sub: '最近7天执行数' }
|
|
|
+ ];
|
|
|
+ wrap.innerHTML = renderKpiRow(kpi);
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTransferSourceSummary(page, candidates, groups) {
|
|
|
+ const wrap = page.querySelector('#tf-source-summary');
|
|
|
+ if (!wrap) return;
|
|
|
+ wrap.innerHTML = `
|
|
|
+ <svg viewBox="0 0 24 24"><path d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20Zm1 15h-2v-6h2v6Zm0-8h-2V7h2v2Z"/></svg>
|
|
|
+ <div><strong>可交接范围已自动同步</strong><span>当前可选 ${formatNumber(candidates.length)} 位企微客户和 ${formatNumber(groups.length)} 个群聊。下方仅记录已生成的交接预览与执行结果,不会自动执行真实交接。</span></div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ function renderTransfersCharts(page, summary, transfers) {
|
|
|
+ const colors = getChartColors();
|
|
|
+ const t = summary.transfers || {};
|
|
|
+ const previews = Number(t.previews || 0);
|
|
|
+ const executions = Number(t.executions || 0);
|
|
|
+ const successful = Math.round(((t.successRate || 0) / 100) * executions);
|
|
|
+ const executionRate = previews ? Math.min(100, Math.round((executions / previews) * 100)) : 0;
|
|
|
+ const successRate = executions ? Math.min(100, Math.round((successful / executions) * 100)) : 0;
|
|
|
+ const overallRate = previews ? Math.min(100, Math.round((successful / previews) * 100)) : 0;
|
|
|
+ const stageBoard = page.querySelector('#tf-stage-board');
|
|
|
+ if (stageBoard) {
|
|
|
+ stageBoard.innerHTML = `
|
|
|
+ <div class="transfer-stage-track">
|
|
|
+ <article class="transfer-stage preview">
|
|
|
+ <div class="transfer-stage-icon"><svg viewBox="0 0 24 24"><path d="M6 2h9l5 5v15H6V2Zm8 2H8v16h10V8h-4V4Zm-4 8h6v2h-6v-2Zm0 4h6v2h-6v-2Z"/></svg></div>
|
|
|
+ <div><span>01 · 准备完成</span><strong>${formatNumber(previews)}</strong><small>份交接预览包</small></div>
|
|
|
+ </article>
|
|
|
+ <div class="transfer-stage-connector"><strong>${executionRate}%</strong><span>进入执行</span><i><b style="width:${executionRate}%"></b></i></div>
|
|
|
+ <article class="transfer-stage execution">
|
|
|
+ <div class="transfer-stage-icon"><svg viewBox="0 0 24 24"><path d="m9 16.17-3.88-3.88L3.7 13.7 9 19l11.3-11.3-1.42-1.42L9 16.17Z"/></svg></div>
|
|
|
+ <div><span>02 · 已确认执行</span><strong>${formatNumber(executions)}</strong><small>次顾问交接</small></div>
|
|
|
+ </article>
|
|
|
+ <div class="transfer-stage-connector"><strong>${successRate}%</strong><span>执行成功</span><i><b style="width:${successRate}%"></b></i></div>
|
|
|
+ <article class="transfer-stage success">
|
|
|
+ <div class="transfer-stage-icon"><svg viewBox="0 0 24 24"><path d="M16 11c1.66 0 3-1.34 3-3s-1.34-3-3-3-3 1.34-3 3 1.34 3 3 3ZM8 11c1.66 0 3-1.34 3-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.5C15 14.17 10.33 13 8 13Zm8 0c-.29 0-.62.02-.97.05A4.2 4.2 0 0 1 17 16.5V19h6v-2.5c0-2.33-4.67-3.5-7-3.5Z"/></svg></div>
|
|
|
+ <div><span>03 · 交接成功</span><strong>${formatNumber(successful)}</strong><small>条成功记录</small></div>
|
|
|
+ </article>
|
|
|
+ </div>
|
|
|
+ <div class="transfer-overall-progress">
|
|
|
+ <div><span>整体交接完成率</span><strong>${overallRate}%</strong></div>
|
|
|
+ <i><b style="width:${overallRate}%"></b></i>
|
|
|
+ <small>${previews ? `共 ${formatNumber(previews)} 份预览包,其中 ${formatNumber(successful)} 份已完成成功交接` : '创建交接预览后,这里将展示阶段转化情况'}</small>
|
|
|
+ </div>`;
|
|
|
+ }
|
|
|
+
|
|
|
+ const executionRecords = transfers.executions || [];
|
|
|
+ const success = executionRecords.reduce((sum, e) => sum + (e.success || 0), 0);
|
|
|
+ const failed = executionRecords.reduce((sum, e) => sum + (e.failed || 0), 0);
|
|
|
+ initEChart('tf-result-chart', {
|
|
|
+ tooltip: { trigger: 'item' },
|
|
|
+ legend: { bottom: 0 },
|
|
|
+ color: [colors.success, colors.error],
|
|
|
+ series: [{
|
|
|
+ type: 'pie',
|
|
|
+ radius: ['40%', '65%'],
|
|
|
+ center: ['50%', '45%'],
|
|
|
+ data: [
|
|
|
+ { name: '成功', value: success },
|
|
|
+ { name: '失败', value: failed }
|
|
|
+ ]
|
|
|
+ }]
|
|
|
+ });
|
|
|
+
|
|
|
+ const days = last7Days();
|
|
|
+ const counts = countByDay(executionRecords, 'executedAt');
|
|
|
+ initEChart('tf-trend-chart', {
|
|
|
+ tooltip: { trigger: 'axis' },
|
|
|
+ xAxis: { type: 'category', data: days.map(d => formatDate(d)) },
|
|
|
+ yAxis: { type: 'value' },
|
|
|
+ series: [{ name: '交接执行', type: 'bar', data: counts, itemStyle: { color: colors.brand }, barWidth: 20, borderRadius: [4, 4, 0, 0] }]
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function loadTransfersTable(page) {
|
|
|
+ const wrap = page.querySelector('#tf-transfer-table-wrap');
|
|
|
+ const transfers = state.transfers || {};
|
|
|
+ const statusFilter = page.querySelector('#tf-filter-status').value;
|
|
|
+ const userFilter = page.querySelector('#tf-filter-user').value.trim().toLowerCase();
|
|
|
+
|
|
|
+ state.transfers.status = statusFilter;
|
|
|
+ state.transfers.user = userFilter;
|
|
|
+ saveFilters(TF_FILTERS_KEY, {
|
|
|
+ tablePage: state.transfers.tablePage,
|
|
|
+ status: statusFilter,
|
|
|
+ user: userFilter
|
|
|
+ });
|
|
|
+
|
|
|
+ let items = [
|
|
|
+ ...(transfers.previews || []).map(p => ({ ...p, type: 'preview' })),
|
|
|
+ ...(transfers.executions || []).map(e => ({ ...e, type: 'execution' }))
|
|
|
+ ];
|
|
|
+
|
|
|
+ let filtered = items.filter(item => {
|
|
|
+ if (statusFilter && item.status !== statusFilter) return false;
|
|
|
+ if (userFilter) {
|
|
|
+ const text = `${item.fromUserId || ''} ${item.toUserId || ''}`.toLowerCase();
|
|
|
+ if (!text.includes(userFilter)) return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ });
|
|
|
+
|
|
|
+ filtered.sort((a, b) => (b.createdAt || b.executedAt || '').localeCompare(a.createdAt || a.executedAt || ''));
|
|
|
+
|
|
|
+ const pageSize = 20;
|
|
|
+ const currentPage = state.transfers.tablePage || 1;
|
|
|
+ const totalPages = Math.max(1, Math.ceil(filtered.length / pageSize));
|
|
|
+ if (currentPage > totalPages) {
|
|
|
+ state.transfers.tablePage = totalPages;
|
|
|
+ saveFilters(TF_FILTERS_KEY, {
|
|
|
+ tablePage: state.transfers.tablePage,
|
|
|
+ status: state.transfers.status,
|
|
|
+ user: state.transfers.user
|
|
|
+ });
|
|
|
+ }
|
|
|
+ const start = (state.transfers.tablePage - 1) * pageSize;
|
|
|
+ const pageItems = filtered.slice(start, start + pageSize);
|
|
|
+
|
|
|
+ wrap.innerHTML = `
|
|
|
+ <div class="table-wrap">
|
|
|
+ <table>
|
|
|
+ <thead>
|
|
|
+ <tr>
|
|
|
+ <th>类型</th>
|
|
|
+ <th>原顾问</th>
|
|
|
+ <th>新顾问</th>
|
|
|
+ <th>记录数</th>
|
|
|
+ <th>状态</th>
|
|
|
+ <th>创建/执行时间</th>
|
|
|
+ <th>操作</th>
|
|
|
+ </tr>
|
|
|
+ </thead>
|
|
|
+ <tbody>
|
|
|
+ ${pageItems.length ? pageItems.map(item => `
|
|
|
+ <tr>
|
|
|
+ <td>${item.type === 'preview' ? '<span class="badge badge-warning">预览</span>' : '<span class="badge badge-info">执行</span>'}</td>
|
|
|
+ <td>${escapeHtml(item.fromUserId || '-')}</td>
|
|
|
+ <td>${escapeHtml(item.toUserId || '-')}</td>
|
|
|
+ <td>${formatNumber(item.itemCount || item.total || 0)}</td>
|
|
|
+ <td>${item.status === 'EXECUTED' ? '<span class="badge badge-success">已执行</span>' : '<span class="badge badge-warning">草稿</span>'}</td>
|
|
|
+ <td>${formatDateTime(item.createdAt || item.executedAt)}</td>
|
|
|
+ <td>
|
|
|
+ ${item.type === 'preview' ? `<button class="btn btn-sm" onclick="window.dashboardExecuteTransfer('${escapeHtml(item.filePath || '')}')">执行</button>` : `<button class="btn btn-sm btn-secondary" onclick="window.dashboardTransferDetail('${escapeHtml(item.filePath || '')}')">详情</button>`}
|
|
|
+ </td>
|
|
|
+ </tr>
|
|
|
+ `).join('') : renderEmptyTableRow(7, '暂无交接记录', '点击新建交接预览')}
|
|
|
+ </tbody>
|
|
|
+ </table>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+
|
|
|
+ const pagination = page.querySelector('#tf-pagination');
|
|
|
+ pagination.innerHTML = renderPagination(state.transfers.tablePage, filtered.length, pageSize);
|
|
|
+ pagination.querySelectorAll('button').forEach(btn => {
|
|
|
+ btn.addEventListener('click', () => {
|
|
|
+ state.transfers.tablePage = Number(btn.dataset.page);
|
|
|
+ saveFilters(TF_FILTERS_KEY, {
|
|
|
+ tablePage: state.transfers.tablePage,
|
|
|
+ status: state.transfers.status,
|
|
|
+ user: state.transfers.user
|
|
|
+ });
|
|
|
+ loadTransfersTable(page);
|
|
|
+ });
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ function openTransferModal(page) {
|
|
|
+ const accountOptions = state.accounts.map(a => `<option value="${escapeHtml(a.userId || a.guid || '')}">${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}</option>`).join('');
|
|
|
+ const body = `
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>原顾问 userId</label>
|
|
|
+ <div style="display:flex;gap:8px">
|
|
|
+ <input id="new-tf-from" type="text" placeholder="fromUserId" style="flex:1" />
|
|
|
+ <select id="quick-tf-from" style="width:140px">
|
|
|
+ <option value="">快速选择</option>
|
|
|
+ ${accountOptions}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>新顾问 userId</label>
|
|
|
+ <div style="display:flex;gap:8px">
|
|
|
+ <input id="new-tf-to" type="text" placeholder="toUserId" style="flex:1" />
|
|
|
+ <select id="quick-tf-to" style="width:140px">
|
|
|
+ <option value="">快速选择</option>
|
|
|
+ ${accountOptions}
|
|
|
+ </select>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>选择要交接的客户(可选)</label>
|
|
|
+ <button type="button" class="customer-selector-trigger" id="btn-select-transfer">点击选择客户</button>
|
|
|
+ <div class="customer-selector-value" id="transfer-customer-selected"><span style="color:var(--text-muted);font-size:13px">未选择客户</span></div>
|
|
|
+ </div>
|
|
|
+ <div class="form-group">
|
|
|
+ <label>已确认群 roomIds(逗号分隔,可选)</label>
|
|
|
+ <input id="new-tf-rooms" type="text" placeholder="roomId1,roomId2" />
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ openModal('新建交接预览', body, `
|
|
|
+ <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
|
|
|
+ <button class="btn" id="btn-confirm-new-transfer">生成预览</button>
|
|
|
+ `);
|
|
|
+
|
|
|
+ const fromInput = document.getElementById('new-tf-from');
|
|
|
+ const toInput = document.getElementById('new-tf-to');
|
|
|
+ document.getElementById('quick-tf-from').addEventListener('change', (e) => { if (e.target.value) fromInput.value = e.target.value; });
|
|
|
+ document.getElementById('quick-tf-to').addEventListener('change', (e) => { if (e.target.value) toInput.value = e.target.value; });
|
|
|
+
|
|
|
+ let selectedCustomers = [];
|
|
|
+ document.getElementById('btn-select-transfer').addEventListener('click', async () => {
|
|
|
+ const result = await openCustomerSelector({ multiple: true, selectedIds: selectedCustomers.map(c => c.externalUserId), title: '选择要交接的客户' });
|
|
|
+ selectedCustomers = result.customers;
|
|
|
+ renderCustomerSelectorValue('transfer-customer-selected', selectedCustomers);
|
|
|
+ });
|
|
|
+
|
|
|
+ document.getElementById('btn-confirm-new-transfer').addEventListener('click', async () => {
|
|
|
+ const fromUserId = fromInput.value.trim();
|
|
|
+ const toUserId = toInput.value.trim();
|
|
|
+ const externalUserIds = selectedCustomers.map(c => c.externalUserId).filter(Boolean);
|
|
|
+ const roomIds = document.getElementById('new-tf-rooms').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
|
|
|
+ if (!fromUserId || !toUserId) {
|
|
|
+ toast('请输入原顾问和新顾问 userId', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ if (!externalUserIds.length && !roomIds.length) {
|
|
|
+ toast('请选择客户或输入 roomIds', 'warning');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ try {
|
|
|
+ await api('POST', '/api/transfers/preview', { fromUserId, toUserId, externalUserIds, roomIds });
|
|
|
+ toast('预览已生成');
|
|
|
+ closeModal();
|
|
|
+ loadTransfersDashboard(page);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '生成失败', 'error');
|
|
|
+ }
|
|
|
+ });
|
|
|
+ }
|
|
|
+
|
|
|
+ window.dashboardExecuteTransfer = (filePath) => {
|
|
|
+ const body = `
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>预览文件路径</label>
|
|
|
+ <input id="exec-tf-file" type="text" value="${escapeHtml(filePath || '')}" placeholder="留空使用最新预览" />
|
|
|
+ </div>
|
|
|
+ <div class="form-group" style="margin-bottom:16px">
|
|
|
+ <label>欢迎语</label>
|
|
|
+ <input id="exec-tf-welcome" type="text" value="您好,我是您的新服务顾问,后续由我为您服务。" />
|
|
|
+ </div>
|
|
|
+ <div class="checkbox-row">
|
|
|
+ <input id="exec-tf-remove" type="checkbox" />
|
|
|
+ <label for="exec-tf-remove">交接后移除原顾问</label>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ openModal('执行交接', body, `
|
|
|
+ <button class="btn btn-secondary" onclick="window.dashboardCloseModal()">取消</button>
|
|
|
+ <button class="btn" id="btn-confirm-exec-transfer">执行</button>
|
|
|
+ `);
|
|
|
+ document.getElementById('btn-confirm-exec-transfer').addEventListener('click', () => {
|
|
|
+ const previewFile = document.getElementById('exec-tf-file').value.trim() || undefined;
|
|
|
+ const welcomeText = document.getElementById('exec-tf-welcome').value.trim();
|
|
|
+ const removeOldBroker = document.getElementById('exec-tf-remove').checked;
|
|
|
+ closeModal();
|
|
|
+ startJob('execute-transfer', '/api/transfers/execute', { guid: getGuid(), previewFile, welcomeText, removeOldBroker }, () => loadTransfersDashboard(document.querySelector('#tf-dashboard')?.closest('.page')));
|
|
|
+ });
|
|
|
+ };
|
|
|
+
|
|
|
+ window.dashboardTransferDetail = async (filePath) => {
|
|
|
+ if (!filePath) return;
|
|
|
+ try {
|
|
|
+ openModal('交接详情', `
|
|
|
+ <div class="detail-panel">${escapeHtml(JSON.stringify({ filePath }, null, 2))}</div>
|
|
|
+ <div style="margin-top:12px">${renderFileLink(filePath, '下载文件')}</div>
|
|
|
+ `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`);
|
|
|
+ } catch (err) {
|
|
|
+ toast(err.message || '查询失败', 'error');
|
|
|
+ }
|
|
|
+ };
|
|
|
+
|
|
|
+
|
|
|
+ // ==================== Init ====================
|
|
|
+ async function checkExistingLogin() {
|
|
|
+ try {
|
|
|
+ const res = await api('GET', '/api/status?fast=true', undefined, 2500);
|
|
|
+ const login = res.data?.login;
|
|
|
+ if (login?.summary?.online && login?.data?.detail) {
|
|
|
+ const detail = login.data.detail;
|
|
|
+ const uid = login.data.uid || login.summary?.uid || '';
|
|
|
+ const guid = detail.guid || '';
|
|
|
+ const existing = state.accounts.find(a => (uid && a.uid === uid) || (guid && a.guid === guid) || a.userId === detail.userId);
|
|
|
+ if (!existing) {
|
|
|
+ addAccount({
|
|
|
+ uid,
|
|
|
+ guid,
|
|
|
+ userId: detail.userId || '',
|
|
|
+ nickname: detail.nickname || detail.userId || '',
|
|
|
+ corpName: detail.corpName || '',
|
|
|
+ corpId: detail.corpId || '',
|
|
|
+ online: true
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ existing.uid = uid || existing.uid;
|
|
|
+ if (guid && (!existing.guid || existing.guid === 'server-managed')) existing.guid = guid;
|
|
|
+ existing.online = true;
|
|
|
+ existing.nickname = existing.nickname || detail.nickname || detail.userId;
|
|
|
+ existing.corpName = existing.corpName || detail.corpName;
|
|
|
+ state.currentAccountId = existing.id;
|
|
|
+ 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);
|
|
|
+ try {
|
|
|
+ const fallback = await api('GET', '/api/agent/status', undefined, 4000);
|
|
|
+ const active = fallback.data?.account || {};
|
|
|
+ if (!active.uid) return;
|
|
|
+ let existing = state.accounts.find(account => account.uid === active.uid || (active.userId && account.userId === active.userId));
|
|
|
+ if (!existing) {
|
|
|
+ addAccount({
|
|
|
+ uid: active.uid,
|
|
|
+ guid: active.guid && active.guid !== 'server-managed' ? active.guid : '',
|
|
|
+ userId: active.userId || '',
|
|
|
+ nickname: active.nickname || active.userId || '',
|
|
|
+ corpName: active.corpName || '',
|
|
|
+ online: Boolean(active.online),
|
|
|
+ });
|
|
|
+ } else {
|
|
|
+ existing.uid = active.uid;
|
|
|
+ if (active.guid && active.guid !== 'server-managed') existing.guid = active.guid;
|
|
|
+ existing.userId = active.userId || existing.userId;
|
|
|
+ existing.nickname = active.nickname || existing.nickname || active.userId;
|
|
|
+ existing.corpName = active.corpName || existing.corpName || '';
|
|
|
+ existing.online = Boolean(active.online);
|
|
|
+ state.currentAccountId = existing.id;
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ }
|
|
|
+ } catch {}
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function hydrateCurrentAccountBinding(options = {}) {
|
|
|
+ const account = currentAccount();
|
|
|
+ if (!account) return false;
|
|
|
+ try {
|
|
|
+ const result = await api('GET', '/api/agent/status', undefined, 2500);
|
|
|
+ const active = result.data?.account || {};
|
|
|
+ if (!account.uid && !active.uid) return false;
|
|
|
+ account.uid = account.uid || active.uid;
|
|
|
+ if (active.guid && active.guid !== 'server-managed' && (!account.guid || account.guid === 'server-managed')) account.guid = active.guid;
|
|
|
+ account.userId = account.userId || active.userId || '';
|
|
|
+ account.nickname = account.nickname || active.nickname || account.userId;
|
|
|
+ account.corpName = account.corpName || active.corpName || '';
|
|
|
+ saveAccounts();
|
|
|
+ updateAccountSwitcher();
|
|
|
+ return await switchAccount(account.id, { silent: true, deferRender: options.deferRender === true });
|
|
|
+ } catch (error) {
|
|
|
+ console.error('恢复当前账号绑定失败', error);
|
|
|
+ return false;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ async function init() {
|
|
|
+ await initializeWorkspaceAccountStorage();
|
|
|
+ setActiveNav(location.hash.slice(1) || 'overview');
|
|
|
+ updateAccountSwitcher();
|
|
|
+ const hadAccountAtStart = Boolean(currentAccount());
|
|
|
+ const accountIdAtStart = state.currentAccountId;
|
|
|
+
|
|
|
+ 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', () => {
|
|
|
+ beginAddAccountLogin();
|
|
|
+ location.hash = '#groups';
|
|
|
+ renderPage();
|
|
|
+ });
|
|
|
+
|
|
|
+ const closeSidebar = () => {
|
|
|
+ els.sidebar.classList.remove('open');
|
|
|
+ els.sidebarBackdrop.classList.remove('active');
|
|
|
+ };
|
|
|
+
|
|
|
+ els.menuBtn.addEventListener('click', () => {
|
|
|
+ const open = els.sidebar.classList.toggle('open');
|
|
|
+ els.sidebarBackdrop.classList.toggle('active', open);
|
|
|
+ });
|
|
|
+ els.sidebarBackdrop.addEventListener('click', closeSidebar);
|
|
|
+
|
|
|
+ window.addEventListener('hashchange', renderPage);
|
|
|
+
|
|
|
+ document.addEventListener('click', e => {
|
|
|
+ const link = e.target.closest('.nav-item');
|
|
|
+ if (link) closeSidebar();
|
|
|
+ });
|
|
|
+
|
|
|
+ // URL 中的当前模块先渲染一次;账号恢复只在后台静默更新绑定,不重放页面路由。
|
|
|
+ void renderPage();
|
|
|
+ try {
|
|
|
+ if (currentAccount()?.uid) {
|
|
|
+ await switchAccount(state.currentAccountId, { silent: true, deferRender: true });
|
|
|
+ }
|
|
|
+ await checkExistingLogin();
|
|
|
+ await hydrateCurrentAccountBinding({ deferRender: true });
|
|
|
+ } finally {
|
|
|
+ state.bootstrapping = false;
|
|
|
+ if ((!hadAccountAtStart && currentAccount()) || accountIdAtStart !== state.currentAccountId) {
|
|
|
+ void renderPage();
|
|
|
+ } else if (state.page === 'agent') {
|
|
|
+ startAgentPageRuntime(els.content.querySelector('.agent-page'));
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ init();
|
|
|
+})();
|