(function () { 'use strict'; const API_BASE = ''; const ACCOUNTS_KEY = 'qiwei-accounts'; const CURRENT_ACCOUNT_KEY = 'qiwei-current-account'; 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'; 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)); } const state = { page: 'agent', accounts: JSON.parse(localStorage.getItem(ACCOUNTS_KEY) || '[]'), currentAccountId: localStorage.getItem(CURRENT_ACCOUNT_KEY) || null, status: null, groups: [], groupSync: {}, groupFilters: loadFilters(GROUP_FILTERS_KEY, { keyword: '', status: '', showCustomerOnly: false }), groupKeywords: null, jobs: {}, renderToken: 0, toastId: 0, modal: null, loginTimer: null, loginPhase: 'idle', loginUid: 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: [], lastTransferPreview: null, ...loadFilters(PT_FILTERS_KEY, { tablePage: 1, keyword: '', source: '', tag: '' }) }, agent: { status: null, conversations: [], responseMonitor: null, selectedId: null, timer: null, loading: false, signature: null }, skills: { registry: null, selectedPackageId: null, selectedSkillId: null, keyword: '' }, knowledge: { tree: null, selectedNodeId: null, file: null, properties: null, selectedProperty: null, expandedFolderIds: new Set(), foldersInitialized: false, filters: { q: '', district: '', layout: '', decoration: '', maxPrice: '' }, 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, ...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'), 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 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 isGroupSynced(roomId) { return !!(state.groupSync[roomId] && state.groupSync[roomId].lastSyncAt); } function addAccount(account) { 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 }); state.currentAccountId = existing.id; } else { account.id = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 6)}`; state.accounts.push(account); state.currentAccountId = account.id; } saveAccounts(); updateAccountSwitcher(); } 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 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); state.agent.status = null; state.agent.conversations = []; state.agent.selectedId = null; state.agent.signature = null; saveAccounts(); updateAccountSwitcher(); 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 => ` `).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 = `${escapeHtml(message)} `; 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 `${escapeHtml(text)}`; } const modalStack = []; function openModal(title, bodyHtml, actionsHtml, modalClass = '') { closeModal(); const previousFocus = document.activeElement; const overlay = document.createElement('div'); overlay.className = 'modal-overlay'; overlay.innerHTML = ``; document.body.appendChild(overlay); 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 (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 = ``; } else if (field.type === 'select') { control = ``; } else { const type = ['text', 'datetime-local', 'date', 'number', 'email'].includes(field.type) ? field.type : 'text'; control = ``; } return ``; } function showBusinessModal(options = {}) { closeModal(); const previousFocus = document.activeElement; const overlay = document.createElement('div'); const titleId = `business-modal-title-${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 = ``; 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()); }); } 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 会话', `
${ready ? '已检测到客户专属 Session' : 'Session 等待首次运行'}${escapeHtml(guide.guidance || '')}
会话名称
${escapeHtml(guide.displayName || '首次运行后生成')}
打开位置
${escapeHtml(guide.openLocation || 'Fmode Studio → 当前企微项目 → 终端')}
${escapeHtml(guide.openCommand || 'npm run agent:session:list')}

打开时会自动 fork 审阅副本。你可以查看历史、模型思考和工具记录,也可以继续追问;生产客户 Session 不会被修改。

`, `` ); 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 = ` 执行中…`; } 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 `
${escapeHtml(label)}
执行中…
`; } // ==================== 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 = '
'; } else if (type === 'list') { container.innerHTML = `
`; } else if (type === 'table') { container.innerHTML = `
`; } } function hideLoading(container) { if (!container) return; container.classList.remove('loading-overlay'); } function showError(container, message, onRetry) { if (!container) return; container.innerHTML = `
加载失败
${escapeHtml(message || '请稍后重试')}
${onRetry ? '' : ''}
`; if (onRetry) { container.querySelector('#btn-retry-loading').addEventListener('click', onRetry); } } function renderEmptyChart(container, title = '暂无数据') { if (!container) return; container.innerHTML = `
${escapeHtml(title)}
`; } 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) => `
${escapeHtml(item.label)}
${escapeHtml(String(item.value))}
${item.sub ? `
${escapeHtml(item.sub)}
` : ''} ${item.trend ? `
${item.trend > 0 ? '↑' : item.trend < 0 ? '↓' : ''} ${Math.abs(item.trend)}%
` : ''}
`).join(''); } function renderChartCard(id, title, subtitle, heightClass = '') { return `

${escapeHtml(title)} ${subtitle ? `${escapeHtml(subtitle)}` : ''}

`; } function renderOperationToolbar(leftHtml, rightHtml) { return `
${leftHtml}
${rightHtml}
`; } function renderEmptyTableRow(colspan, title = '暂无数据', desc = '') { return `
${escapeHtml(title)}
${desc ? `
${escapeHtml(desc)}
` : ''}
`; } function renderDataTableCard(title, subtitle, tableHtml, metaHtml = '') { return `

${escapeHtml(title)}

${subtitle ? `
${escapeHtml(subtitle)}
` : ''}
${metaHtml ? `
${metaHtml}
` : ''} ${tableHtml}
`; } function renderPagination(current, total, pageSize, onPageChange) { const totalPages = Math.max(1, Math.ceil(total / pageSize)); return ``; } async function openCustomerSelector(options = {}) { const { multiple = true, selectedIds = [], title = '选择客户', confirmText = '确定' } = options; const selected = new Set(selectedIds.filter(Boolean)); 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 = '
加载中…
'; 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) { if (c.externalUserId) loadedCustomers.set(c.externalUserId, c); } renderList(); renderPagination(); } catch (err) { 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 = '
无匹配客户
'; return; } listEl.innerHTML = items.map(c => { const isSelected = selected.has(c.externalUserId); const portraitBadge = c.hasPortrait ? '已画像' : ''; return `
${escapeHtml(c.name || '未命名客户')}${portraitBadge}
${escapeHtml(c.phone || '-')}${c.externalUserId ? ` · ${escapeHtml(c.externalUserId)}` : ''}
`; }).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(``); } wrap.innerHTML = ` ${pages.join('')} ${currentPage} / ${totalPages} 页 `; 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 = `
已选 0 / 共 0 位客户
加载中…
`; return new Promise((resolve) => { openModal(title, body, ` `, '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(c.externalUserId)); const externalUserIds = selectedCustomers.map(c => 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 = '未选择客户'; return; } container.innerHTML = customers.map(c => ` ${escapeHtml(c.name || '未命名')} ${c.phone ? `(${escapeHtml(c.phone)})` : ''} `).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')); const active = document.querySelector(`.nav-item[data-page="${page}"]`); if (active) active.classList.add('active'); } function renderPage() { state.renderToken += 1; closeAllModals(); const page = location.hash.slice(1) || 'agent'; 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 && !['agent', 'skills', 'knowledge', 'customer-ops'].includes(page)) { renderLoginPage(); return; } if (page === 'agent') renderAgentPage(); else if (page === 'skills') renderSkillsPage(); else if (page === 'knowledge') renderKnowledgePage(); else if (page === 'status') renderStatusPage(); else if (page === 'customer-ops') renderCustomerOpsPage(); else if (page === 'portraits') renderPortraitsPage(); else if (page === 'transfers') renderTransfersPage(); else renderGroupsPage(); } // ==================== Unified Skill Center ==================== function markdownInline(value) { return escapeHtml(value) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1'); } 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 = ''; }; for (const line of lines) { if (line.trim().startsWith('```')) { closeList(); if (inCode) { html.push(`
${escapeHtml(codeLines.join('\n'))}
`); 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(`${markdownInline(heading[2])}`); continue; } const unordered = line.match(/^\s*[-*]\s+(.+)$/); if (unordered) { if (listType !== 'ul') { closeList(); listType = 'ul'; html.push('