(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 => `
${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')}
${escapeHtml(a.corpName || '企业微信账号')}
`).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 = `
${escapeHtml(title)}
${bodyHtml}
${actionsHtml}
`;
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 = `${(field.options || []).map(option => `${escapeHtml(option.label)} `).join('')} `;
} else {
const type = ['text', 'datetime-local', 'date', 'number', 'email'].includes(field.type) ? field.type : 'text';
control = ` `;
}
return `${escapeHtml(field.label)}${field.required ? '* ' : ''} ${control}${field.help ? `${escapeHtml(field.help)} ` : ''} `;
}
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 = `
`;
}
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 ``;
}
function renderEmptyTableRow(colspan, title = '暂无数据', desc = '') {
return `
${escapeHtml(title)}
${desc ? `
${escapeHtml(desc)}
` : ''}
`;
}
function renderDataTableCard(title, subtitle, tableHtml, metaHtml = '') {
return `
${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 `
`;
}).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(`${i} `);
}
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 = `
`;
return new Promise((resolve) => {
openModal(title, body, `
取消
${escapeHtml(confirmText)}
`, '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}>`);
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(''); }
html.push(`${markdownInline(unordered[1])} `);
continue;
}
const ordered = line.match(/^\s*\d+[.)]\s+(.+)$/);
if (ordered) {
if (listType !== 'ol') { closeList(); listType = 'ol'; html.push(''); }
html.push(`${markdownInline(ordered[1])} `);
continue;
}
closeList();
if (!line.trim()) continue;
if (line.startsWith('>')) html.push(`${markdownInline(line.replace(/^>\s?/, ''))} `);
else if (/^\|.*\|$/.test(line.trim())) html.push(`${markdownInline(line.trim())}
`);
else html.push(`${markdownInline(line)}
`);
}
if (inCode) html.push(`${escapeHtml(codeLines.join('\n'))} `);
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 = `
UNIFIED QIWEI CAPABILITY HUB 技能中心 把源码版、OpenClaw、Agent Workbench 和花巷房源匹配统一到一个 4320 工作台。
${registry.summary.packageCount || 0} 能力来源
${registry.summary.skillCount || 0} 技能实例
${registry.summary.uniqueCapabilityCount || 0} 去重能力
${packages.map(pkg => `${escapeHtml(pkg.label)} ${pkg.skills.length} ${escapeHtml(skillStatusLabel(pkg.status))} `).join('') || '没有匹配的技能包
'}
${(selectedPackage?.skills || []).map(skill => `
${escapeHtml(skill.title || skill.name)} ${escapeHtml(skillStatusLabel(skill.status))}
${escapeHtml(skill.description || '暂无说明')}
${(skill.tags || []).map(tag => `${escapeHtml(tag)} `).join('')} `).join('') || '
这个包中没有匹配的技能
'}
${selectedSkill ? `${escapeHtml(selectedPackage?.label || '')} ${escapeHtml(selectedSkill.title || selectedSkill.name)} ${escapeHtml(selectedSkill.description || '')}
${selectedSkill.page ? `
打开对应功能页 ` : '
通过 Skill / MCP 调用 '}
${renderMarkdown(selectedSkill.content)}
` : '选择一个技能查看详情
'}
`;
}
async function renderSkillsPage() {
els.pageTitle.textContent = '技能中心';
void refreshAgentAccountHeader();
const page = document.createElement('div');
page.className = 'page skills-page';
page.innerHTML = '正在扫描本地技能包…
';
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 = `${escapeHtml(error.message || '技能包扫描失败')}
`;
}
}
// ==================== Knowledge Library ====================
function flattenKnowledgeNodes(nodes = []) {
return nodes.flatMap(node => [node, ...(node.children ? flattenKnowledgeNodes(node.children) : [])]);
}
function knowledgeIcon(node) {
if (node.type === 'folder') return '▸';
return ({ 'property-dataset': '⌂', '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 `${knowledgeIcon(node)} ${escapeHtml(node.name)} ${node.fileCount || 0} ${(node.children || []).map(child => renderKnowledgeTreeNode(child, depth + 1)).join('')}
`;
}
return `${knowledgeIcon(node)} ${escapeHtml(node.name)} ${escapeHtml(node.kind)} `;
}
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 propertyMeta(property) {
return [property.district, property.layout, property.area ? `${property.area}㎡` : '', property.decoration].filter(Boolean).join(' · ');
}
function renderPropertyDetail(property) {
if (!property) return '选择一套房源 点击左侧房源卡片查看全部字段与数据来源。
';
const rows = [
['区域', property.district], ['户型', property.layout], ['面积', property.area ? `${property.area}㎡` : ''],
['楼层', property.floor], ['朝向', property.orientation], ['装修', property.decoration],
['单价', property.unitPrice ? `${property.unitPrice} 元/㎡` : ''], ['学区', property.isSchoolDistrict ? property.schoolName || '是' : '否'],
['停车', property.parking], ['房龄', property.buildingAge ? `${property.buildingAge} 年` : ''],
['满五唯一', property.isFiveYearOnly ? '是' : '否'], ['议价空间', property.priceDropSpace ? `${property.priceDropSpace} 万` : ''],
].filter(([, value]) => value !== '' && value !== null && value !== undefined);
return `${rows.map(([label, value]) => `
${escapeHtml(label)} ${escapeHtml(value)}
`).join('')}
${property.highlights?.length ? `
房源亮点 ${property.highlights.map(tag => `${escapeHtml(tag)} `).join('')}
` : ''}${property.surrounding ? `
周边描述 ${escapeHtml(property.surrounding)}
` : ''}${property.ownerSituation ? `
业主情况 ${escapeHtml(property.ownerSituation)}
` : ''}
匹配维度 ${Object.entries(property.scores || {}).map(([key, value]) => `
${escapeHtml(({ community: '小区品质', transport: '交通', surrounding: '配套', priceAdvantage: '价格优势' })[key] || key)} ${value || 0}
`).join('')}
`;
}
function renderPropertiesView() {
const data = state.knowledge.properties || { items: [], stats: {}, total: 0, page: 1, pageCount: 1 };
const filters = state.knowledge.filters;
const selected = state.knowledge.selectedProperty;
return `
${data.stats.total || 0} 总房源
${data.stats.averagePrice || 0}万 平均总价
${data.stats.districts?.length || 0} 区域
${data.stats.schoolDistrictCount || 0} 学区房
${(data.items || []).map(property => `
${escapeHtml(property.id)} ${property.isSchoolDistrict ? '学区' : property.decoration || '房源'}
${escapeHtml(property.community)} ${escapeHtml(propertyMeta(property))}
${property.totalPrice || '-'}万 ${(property.highlights || []).slice(0, 3).map(tag => `${escapeHtml(tag)} `).join('')} `).join('') || '
没有符合条件的房源
'}
${renderPropertyDetail(selected)}
`;
}
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
? `${items.map(item => `${escapeHtml(item)} `).join('')} `
: `${escapeHtml(emptyText)}
`;
}
function renderMeetingDetail(meeting) {
if (!meeting) {
return '暂无真实会议 完成官方 CLI 扫码授权并同步后,会议详情会显示在这里;系统不会生成 Mock 会议。
';
}
const analysis = meeting.analysis || {};
const actionItems = analysis.actionItems || [];
const analysisReady = analysis.status === 'completed';
return `
会议描述(官方资料) ${escapeHtml(meeting.description || '官方会议资料未填写描述。')}
会议 ID:${escapeHtml(meeting.id)} · 同步于 ${escapeHtml(formatAgentTime(meeting.syncedAt))}
${analysis.status === 'failed' ? `AI 分析未生成 ${escapeHtml(analysis.error || '模型调用失败,真实会议资料已保留。')}
` : ''}
AI SUMMARY · 需人工复核 ${escapeHtml(analysis.summary || '尚未生成会议摘要')} ${escapeHtml(analysis.analysisBasis || '仅基于官方会议元数据和描述,不包含录音、转写或人工纪要。')}
关键议题 ${renderMeetingBullets(analysis.topics, '资料中暂无可验证的关键议题')}
明确决策 ${renderMeetingBullets(analysis.decisions, '资料中没有可验证的明确决策')}
风险与待确认 ${renderMeetingBullets(analysis.risks, '暂无已识别风险')}
建议动作(非已确认任务) ${renderMeetingBullets(analysis.suggestedActions, '暂无建议动作')}
${actionItems.length ? actionItems.map(item => `${escapeHtml(item.title)} ${escapeHtml(item.evidence || '依据待人工确认')}
${item.owner ? `负责人:${escapeHtml(item.owner)}` : '负责人待确认'}${item.dueDate ? ` · 截止:${escapeHtml(item.dueDate)}` : ' · 截止时间待确认'} ${Math.round(Number(item.confidence || 0) * 100)}% `).join('') : '没有从现有资料中提取出有明确依据的待办。
'}
`;
}
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
? '当前企业无法通过官方 CLI 拉取会议 扫码授权已经成功;请换用 10 人及以下测试企业演示会议,当前企业可继续演示官方文档和待办能力。
'
: renderMeetingDetail(null);
return `
${summary.meetingCount || 0} 累计会议
${summary.analyzedCount || 0} 已分析
${summary.actionItemCount || 0} 待办候选
${summary.lastSyncedAt ? formatAgentTime(summary.lastSyncedAt) : '-'} 最近同步
${meetingReady ? '企业微信官方会议 CLI 已就绪' : capabilityBlocked ? '扫码授权成功,但当前企业未开放会议 CLI' : '企业微信官方 CLI 尚未完成扫码授权'} ${meetingReady ? `当前版本 ${escapeHtml(cli.version || '-')}` : capabilityBlocked ? escapeHtml(cli.capabilityMessage || '当前企业暂不支持授权机器人的会议权限。') : '授权前无法拉取真实会议,页面不会展示演示或 Mock 会议。'}
${!cli.ready && cli.initCommand ? `${escapeHtml(cli.initCommand)}复制初始化命令 ` : ''}
`;
}
function officeCapabilityBanner(cli, label) {
const ready = Boolean(cli?.ready && cli?.available);
return `${ready ? `企业微信官方${label} CLI 已就绪` : `官方${label} CLI 当前不可用`} ${escapeHtml(cli?.message || (ready ? `当前版本 ${cli?.version || '-'}` : '请检查扫码授权和企业权限。'))}
`;
}
function renderDocumentDetail(document) {
if (!document) return '尚未沉淀企微文档 可创建一篇真实企微文档,或粘贴已有文档链接读取到知识库。
';
const analysis = document.analysis || {};
const bullets = (items, empty) => (items || []).length ? `${items.map(item => `${escapeHtml(item)} `).join('')} ` : `${escapeHtml(empty)}
`;
const link = /^https?:\/\//i.test(document.url || '') ? `在企微中打开 ` : '';
return `
${document.readbackStatus === 'pending-permission' ? '创建和写入已成功 机器人暂缺读取授权,当前原文来自本次已确认写入的 Markdown;授权后可重新读取复核。
' : ''}
AI KNOWLEDGE · 需人工复核 ${escapeHtml(analysis.summary || '尚未生成 AI 摘要')} 关键要点 ${bullets(analysis.keyPoints, '暂无')}明确决策 ${bullets(analysis.decisions, '原文中暂无明确决策')}风险 ${bullets(analysis.risks, '暂无')}
${renderMarkdown(document.markdown || '文档为空')} `;
}
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 `
${hub.summary?.documentCount || 0} 已沉淀文档
${hub.summary?.analyzedCount || 0} 已分析
${hub.summary?.lastSyncedAt ? formatAgentTime(hub.summary.lastSyncedAt) : '-'} 最近同步
${officeCapabilityBanner(data.cli, '文档')}
读取已有企微文档 粘贴 doc.weixin.qq.com 链接或 docid 读取到知识库
${docs.map(doc => `${escapeHtml(doc.title)} ${escapeHtml(formatAgentTime(doc.syncedAt))} ${doc.analysis?.status === 'completed' ? 'AI 已分析' : '待分析'} `).join('') || '暂无文档
'} ${renderDocumentDetail(selected)} `;
}
function renderTodoDetail(todo) {
if (!todo) return '选择一条待办 这里只展示当前机器人创建并同步的真实企微待办。
';
return `OFFICIAL TODO · LIVE ${escapeHtml(todo.content)} ${escapeHtml(todo.statusLabel)}
截止时间 ${escapeHtml(todo.endTime || '未设置')}
参与人 ${escapeHtml((todo.followers || []).map(item => item.name || '企微成员').join('、') || '未返回')}
待办 ID ${escapeHtml(todo.id)} ${todo.status !== 0 ? `标记为已完成 ` : ''}官方边界:只能操作由当前机器人创建的待办。
`;
}
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 ``;
}
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 unifiedTaskActions(task) {
if (task.sourceKind === 'candidate') {
return task.promotedTo
? '已转为正式任务 '
: `确认并转为任务 `;
}
const active = ['open', 'in_progress', 'blocked'].includes(task.status);
const buttons = [];
if (task.source === 'customer') {
buttons.push(`转到智能会话 `);
if (active && task.actions?.syncOfficial) buttons.push(`同步企微待办 `);
}
if (active && task.actions?.dismiss) buttons.push(`忽略 `);
if (active && task.actions?.complete) buttons.push(`完成 `);
return buttons.join('') || `${escapeHtml(unifiedTaskStatusLabel(task.status))} `;
}
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 `${escapeHtml(sample?.sourceLabel || source)}(${summary.bySource[source]}) `;
}).join('');
return `
${summary.active || 0} 待处理
${summary.overdue || 0} 已逾期
${summary.completed || 0} 已完成
${summary.candidates || 0} 待确认候选
客户任务不会被原样发给客户 ${escapeHtml(data.policy?.externalCustomerSafety || '')}
AI 候选不会自动成为正式任务 ${escapeHtml(data.policy?.candidateSafety || '')}
新建内部任务 不依赖 CLI,先在本地形成可管理的执行项
普通 高 紧急 低 创建
全部来源 ${sourceOptions}待处理 已逾期 已完成 AI 候选 全部 筛选 当前 ${tasks.length} 条
${tasks.map(task => `
${escapeHtml(task.title)} ${escapeHtml(task.evidence || '暂无补充证据')}
${task.owner ? `负责人:${escapeHtml(task.owner)}` : '负责人待分配'} ${escapeHtml(task.sourceTitle || '')} ${task.confidence !== null ? `可信度 ${Math.round(Number(task.confidence || 0) * 100)}% ` : ''}${unifiedTaskActions(task)}
`).join('') || '
当前筛选下没有任务 可以新建本地内部任务,或切换为“全部 / AI 候选”查看其他来源。
'}
`;
}
function renderKnowledgeFileView(file) {
if (!file) return '选择左侧文件 Markdown、JSON、CSV 和代码文件会在这里可视化预览。
';
const header = ``;
if (['markdown', 'skill-document'].includes(file.kind)) return `${header}${renderMarkdown(file.content)} `;
if (file.kind === 'json') {
const summary = Array.isArray(file.structured)
? `${file.structured.length} 条数组记录`
: `${Object.keys(file.structured || {}).length} 个顶层字段`;
return `${header}${escapeHtml(summary)}
${escapeHtml(JSON.stringify(file.structured ?? file.content, null, 2))} `;
}
return `${header}${escapeHtml(file.content)} `;
}
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 === 'property-dataset'
? renderPropertiesView()
: 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 = `
LOCAL KNOWLEDGE & FILE LIBRARY 知识库 用文件夹方式统一管理规则、技能说明、房源数据和运行输出,点击文件即可可视化查看。
${tree.summary.libraryCount || 0} 目录来源
${tree.summary.fileCount || 0} 可预览文件
${tree.summary.propertyCount || 0} 房源记录
`;
}
async function loadKnowledgeProperties(page) {
const params = new URLSearchParams({ pageSize: '30' });
for (const [key, value] of Object.entries(state.knowledge.filters)) if (String(value || '').trim()) params.set(key, value);
const result = await api('GET', `/api/knowledge/properties?${params}`);
state.knowledge.properties = result.data;
if (state.knowledge.selectedProperty) {
state.knowledge.selectedProperty = result.data.items.find(item => item.id === state.knowledge.selectedProperty.id) || null;
}
renderKnowledgeWorkspace(page);
}
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;
state.knowledge.selectedProperty = 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 if (node.kind === 'property-dataset') await loadKnowledgeProperties(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 = '正在扫描知识库目录…
';
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 (!window.confirm(`确认将以下 AI 候选转为正式内部任务?\n\n${task.title}\n\n依据:${task.evidence || '待补充'}\n\n创建后仍需人工分配负责人并推进。`)) 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 (!window.confirm(`确认将“${task.title}”标记为${action === 'complete' ? '已完成' : '已忽略'}?${officialWarning}`)) 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;
}
const propertyButton = event.target.closest('[data-property-id]');
if (propertyButton) {
try {
const result = await api('GET', `/api/knowledge/properties/${encodeURIComponent(propertyButton.dataset.propertyId)}`);
state.knowledge.selectedProperty = result.data.property;
renderKnowledgeWorkspace(page);
} catch (error) { toast(error.message || '房源读取失败', 'error'); }
}
});
page.addEventListener('submit', async event => {
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;
}
if (event.target.id === 'property-filter-form') {
event.preventDefault();
const form = new FormData(event.target);
for (const key of Object.keys(state.knowledge.filters)) state.knowledge.filters[key] = String(form.get(key) || '').trim();
try { await loadKnowledgeProperties(page); }
catch (error) { toast(error.message || '房源筛选失败', 'error'); }
}
});
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', 'property-data', '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.kind === 'property-dataset') || nodes.find(item => item.type === 'file');
if (defaultNode) await openKnowledgeNode(page, state.knowledge.selectedNodeId || defaultNode.id);
else renderKnowledgeWorkspace(page);
} catch (error) {
page.innerHTML = `${escapeHtml(error.message || '知识库扫描失败')}
`;
}
}
// ==================== 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 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 ? '' : `同步企微待办 `}忽略 完成 `
: `${needsOfficialCompletion ? `同步完成到企微 ` : ''}${escapeHtml(agentTaskStatusLabel(task.status))}${task.resolutionReason === 'profile_condition_resolved' ? ' · 画像条件已补齐' : ''} `;
return `
${escapeHtml(task.title)} ${escapeHtml([task.owner, task.dueAt].filter(Boolean).join(' · ') || '待分配')} ${escapeHtml(task.reason || task.evidence || '')}
${evidenceCount} 条依据 ${escapeHtml(agentOfficialTodoLabel(task))}
${actions}
`;
}
function demandItems(demand = {}) {
const districts = Array.isArray(demand.districts) ? demand.districts.join('/') : (demand.preferredRegion || demand.region || demand.intent_area || '');
const min = demand.budgetMin ?? demand.budget_min;
const max = demand.budgetMax ?? demand.budget_max;
const rooms = demand.rooms || (demand.house_type ? String(demand.house_type).replace(/[^0-9]/g, '') : '');
const values = [
['区域', districts],
['预算', demand.budgetWan ? `${demand.budgetWan}万${demand.budgetType && demand.budgetType !== '待确认' ? `(${demand.budgetType})` : ''}` : (min && max ? `${min}-${max}万` : (max ? `${max}万内` : ''))],
['户型', demand.layout || (rooms ? `${rooms}室` : (demand.house_type || ''))],
['面积', demand.areaMin && demand.areaMax ? `${demand.areaMin}-${demand.areaMax}㎡` : ''],
['装修', demand.decoration],
['用途', demand.purpose || demand.buyerType || demand.purchase_purpose],
['偏好', [demand.floor, demand.orientation].filter(Boolean).join(' / ')],
['学区', demand.schoolRequired ? '需要' : ''],
['时间', demand.timeline],
['紧迫度', demand.urgency],
];
return values.filter(([, value]) => value !== undefined && value !== null && String(value).trim());
}
function renderAgentMessage(message) {
const roleLabel = message.role === 'customer' ? '客户' : message.role === 'human' ? '人工客服' : 'Agent 建议';
const statusLabel = message.status === 'pending' ? '待确认' : message.status === 'sending' ? '发送中' : message.status === 'sent' ? '已发送' : message.status === 'submitted' ? '已提交企微' : '';
return `
${roleLabel} ${formatAgentTime(message.timestamp)}${statusLabel ? ` · ${statusLabel}` : ''}
${escapeHtml(message.content).replace(/\n/g, ' ')}
`;
}
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: '客户' })),
...(scope.groups || []).map(item => ({ ...item, typeLabel: '客户群' })),
];
return `
当前监听范围 ${config.warningMinutes || 15} 分钟提醒 · ${config.urgentMinutes || 60} 分钟紧急
${scopeItems.length ? scopeItems.map(item => `${escapeHtml(item.typeLabel)} ${escapeHtml(item.name)}${item.status === 'awaiting_sync' ? '待同步' : item.status === 'overdue' ? '待回复' : item.status === 'replied' ? '已回复' : item.status === 'no_reply_needed' ? '无需回复' : '监控中'} `).join('') : '尚未配置监听客户或客户群 '}
需要客服处理 ${summary.urgent || 0} 个紧急 · ${summary.warning || 0} 个提醒
${reminders.length ? reminders.slice(0, 8).map(item => `
${item.kind === 'group' ? '群' : '客'} ${escapeHtml(item.name)} ${item.severity === 'urgent' ? '紧急' : '提醒'}
${escapeHtml(item.lastMessagePreview || '收到新消息')}
已等待 ${formatReplyWaiting(item.waitingMinutes)} ${item.kind === 'group' ? '查看群聊' : '去回复'}
`).join('') : '
当前没有超时未回复会话
'}
`;
}
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_task_updated: '客户待办状态已更新',
customer_task_synced_to_official_todo: '客户待办已同步企微',
customer_alert_updated: '客户预警状态已更新',
})[action] || action;
}
function scrollAgentMessagesToLatest(page) {
requestAnimationFrame(() => {
const stream = page.querySelector('.agent-message-stream');
if (stream) stream.scrollTop = stream.scrollHeight;
});
}
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 renderAgentWorkspace(page) {
const status = state.agent.status || {};
const listener = status.listener || {};
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 matches = analysis.matches || [];
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 = `
WECOM · AGENTIC SERVICE
把企微消息变成可执行的客户线索
启动监听后由 AI 自动回复白名单客户;关闭监听后停止 AI,由人工继续回复。
${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'} ${escapeHtml(account.corpName || config.transport || '企微通道')} · ${aiActive ? 'AI 自动回复中' : '当前人工回复'}
${aiActive ? '关闭 AI 监听' : '启动 AI 监听'}
当前处理方式 启停监听即切换 AI / 人工
人工回复
AI 自动回复
安全边界 AI 只回复唯一白名单测试联系人
客户会话 ${conversations.length} 个已归集会话
${conversations.length}
${selected ? `
${(selected.messages || []).map(renderAgentMessage).join('')}
${manualActive ? '人工回复' : 'AI 自动回复'} ${selected.pendingReply ? '等待处理' : (isHistorical ? '历史记录无待发送内容' : aiActive ? 'AI 将自动发送新回复' : 'AI 已停止')}
${escapeHtml(pendingText)}
${manualActive ? '也可以直接在企业微信中人工回复' : '新消息将由 AI 自动回复'} ${manualActive ? '人工发送' : '发送建议'}
` : `智能会话准备就绪 启动监听,然后从白名单企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”
`}
`;
scrollAgentMessagesToLatest(page);
}
function renderAgentWorkspaceV2(page) {
const insightScroll = captureAgentInsightScroll(page);
const status = state.agent.status || {};
const listener = status.listener || {};
const account = status.account || {};
const config = status.config || {};
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 agent = status.agent || {};
const knowledge = status.knowledge || {};
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 sessionGuide = selected?.claudeSession || null;
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 matches = analysis.matches || [];
const pending = selected?.pendingReply || null;
const noReplyNotice = selected?.agentNotice || null;
const activeDraft = pending;
const pendingText = pending?.content || '';
const manualActive = selected?.mode === 'human';
const conversationPaused = selected?.mode === 'paused';
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.innerHTML = `
WECOM · REAL AGENT · HUMAN IN THE LOOP
真实企微消息,由 Agent 思考,人工可随时监管
消息进入后,Agent 自主检索规则库、知识库、画像和房源工具;草稿、依据、置信度和工具轨迹全部留痕。
${escapeHtml(accountName)} · ${account.online ? '在线' : '离线'} ${escapeHtml(account.corpName || config.transport || '企微通道')} · ${listenerRunning ? '真实消息监听中' : '监听已停止'}
补采最近消息
${listenerRunning ? '关闭 AI 监听(转人工)' : '启动 AI 监听'}
全局 Agent 策略 监听与 Agent 策略相互独立,暂停时仍可收消息
待审核
高置信自动
全局暂停
安全边界 仅处理 1 个白名单联系人 · 演示回复已禁用
${renderResponseMonitor(state.agent.responseMonitor || {})}
${selected?.agentError
? `Agent 上游不可用 ${escapeHtml(selected.agentError.message)}。真实消息已保存,系统没有生成或发送 Mock 回复。
`
: noReplyNotice
? `Agent 已处理 ${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。
`
: ''}
真实客户会话 ${conversations.length} 个白名单会话
${conversations.length}
${selected ? `
${sessionGuide?.ready ? 'Claude Code 客户 Session 已识别' : 'Claude Code 客户 Session 等待首次运行'} ${escapeHtml(sessionGuide?.displayName || 'Agent 首次生成草稿后自动创建')} · ${sessionGuide?.ready ? '可安全打开审阅副本' : '不会与其他客户共用上下文'}
${sessionGuide?.ready ? '查看会话与打开方式' : '查看说明'}
${(selected.messages || []).map(renderAgentMessage).join('')}
${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'} ${pending ? `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核' : '可审核发送'}` : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}
${escapeHtml(pendingText)}
${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}
${pending ? `驳回 重新生成 批准并发送 ` : manualActive ? `人工发送 ` : conversationPaused ? '' : `${noReplyNotice ? '重新处理' : '让 Agent 处理'} `}
` : `真实 Agent 会话准备就绪 开始接收,然后从“王刚”的企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”
`}
`;
restoreAgentInsightScroll(page, insightScroll, selected?.id);
scrollAgentMessagesToLatest(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 nextResponseMonitor = responseMonitorRes.data || {};
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),
account: nextStatus.account,
config: nextStatus.config,
conversations: nextConversations,
responseMonitor: nextResponseMonitor,
});
const changed = nextSignature !== state.agent.signature;
state.agent.status = nextStatus;
state.agent.conversations = nextConversations;
state.agent.responseMonitor = nextResponseMonitor;
state.agent.signature = nextSignature;
if (!state.agent.selectedId && state.agent.conversations.length) state.agent.selectedId = state.agent.conversations[0].id;
const waitingForFirstRender = Boolean(page.querySelector('.agent-loading'));
if ((changed || waitingForFirstRender) && state.page === 'agent' && page.isConnected) renderAgentWorkspaceV2(page);
} catch (error) {
if (!silent) toast(error.message || '智能会话加载失败', 'error');
} finally {
state.agent.loading = false;
}
}
function renderAgentPage() {
els.pageTitle.textContent = '智能会话';
state.agent.signature = null;
if (!currentAccount()) {
els.accountName.textContent = '正在检测账号';
els.accountDot.className = 'account-dot offline';
}
const page = document.createElement('div');
page.className = 'page agent-page';
page.innerHTML = '正在读取企微智能会话…
';
els.content.appendChild(page);
page.addEventListener('click', async event => {
const responseMonitorButton = event.target.closest('[data-response-monitor-target]');
if (responseMonitorButton) {
if (responseMonitorButton.dataset.responseMonitorTarget === 'customer') {
state.agent.selectedId = responseMonitorButton.dataset.id;
renderAgentWorkspaceV2(page);
} else {
location.hash = '#groups';
}
return;
}
const conversationButton = event.target.closest('[data-conversation-id]');
if (conversationButton) {
state.agent.selectedId = conversationButton.dataset.conversationId;
renderAgentWorkspaceV2(page);
return;
}
const modeButton = event.target.closest('[data-agent-mode]');
if (modeButton) {
const mode = modeButton.dataset.agentMode;
if (mode === 'auto' && !window.confirm('高置信自动模式会真实发送回复,仅对白名单联系人“王刚”生效。确认开启吗?')) 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' && !window.confirm('该会话的高置信草稿可能自动真实发送。确认开启吗?')) 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 actionButton = event.target.closest('[data-agent-action]');
if (!actionButton) return;
const action = actionButton.dataset.agentAction;
const id = actionButton.dataset.id;
if (action === 'session-guide') {
const conversation = (state.agent.conversations || []).find(item => item.id === id);
openAgentSessionGuide(conversation?.claudeSession || {});
return;
}
actionButton.disabled = true;
try {
let result;
if (action === 'sync-conversations') {
if (!window.confirm('只补采白名单联系人最近的真实消息,不运行 Agent,也不发送任何回复。确认继续吗?')) return;
result = await api('POST', '/api/agent/conversations/sync', {});
const synced = result.data?.conversations || [];
if (synced.length) {
state.agent.conversations = synced;
state.agent.selectedId = synced[0].id;
}
} 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 (!window.confirm('本地待办已经完成。确认现在将绑定的真实企业微信待办也标记为完成?')) 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 && !window.confirm('该待办已绑定企业微信。确认后会同时将真实企微待办标记为完成,是否继续?')) 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' && !window.confirm('启动后,白名单客户的新消息会由 AI 处理;高置信回复可能自动真实发送。人工可随时关闭监听或接管会话。确认启动吗?')) return;
result = await api('POST', `/api/agent/listener/${action === 'start-listener' ? 'start' : 'stop'}`, {});
} else if (action === 'approve') {
const content = page.querySelector('#agent-reply-editor')?.value.trim();
if (!content) throw new Error('请先输入回复内容');
if (!window.confirm('确认审核通过,并真实发送给白名单联系人“王刚”?')) return;
result = await api('POST', `/api/agent/drafts/${actionButton.dataset.draftId}/approve`, { content });
} else if (action === 'reject') {
const reason = window.prompt('驳回原因(可选)', '需要人工调整');
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 === '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('请先输入人工回复内容');
if (!window.confirm('确认由人工真实发送给白名单联系人“王刚”?')) return;
result = await api('POST', `/api/agent/conversations/${id}/manual-send`, { content });
}
if (result) toast(result.assistantMessage || '操作成功');
await loadAgentData(page);
} catch (error) {
toast(error.message || '操作失败', 'error');
} finally {
if (actionButton.isConnected) actionButton.disabled = false;
}
});
loadAgentData(page);
state.agent.timer = setInterval(() => loadAgentData(page, true), 5000);
}
// ==================== 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 = `
请先登录企业微信
登录后才能扫描和管理客户群。如需管理多个账号,可重复登录并切换。
生成登录二维码
`;
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 = `
正在生成二维码…
`;
try {
const res = await api('POST', '/api/login/start', { uid: state.loginUid, persistConfig: false });
if (res.status === 'needs_subscription') {
wrap.innerHTML = `
${escapeHtml(res.assistantMessage || '需要先开通订阅')}
打开订阅与登录流程页
重试 `;
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 = `
打开登录页面 `;
return;
}
if (!qrcodeFile) {
wrap.innerHTML = `
重试 `;
document.getElementById('btn-retry-login').addEventListener('click', startLoginFlow);
return;
}
wrap.innerHTML = `
请用企业微信扫码
重新生成二维码 `;
document.getElementById('btn-retry-login').addEventListener('click', () => {
clearInterval(state.loginTimer);
startLoginFlow();
});
state.loginPhase = 'polling';
pollLoginStatus();
} catch (err) {
wrap.innerHTML = `
${escapeHtml(err.message || '登录启动失败')}
重试 `;
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);
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 = `
手机端显示的 6 位验证码
提交验证码
返回二维码
`;
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;
}
addAccount(account);
const switched = await switchAccount(state.currentAccountId, { silent: true });
if (!switched) return;
state.loginUid = 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 ? ' ' : ''}${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.currentAccountId = null;
renderPage();
setTimeout(() => showVerifyCodeInput(), 100);
return false;
}
state.loginPhase = 'idle';
if (auto) {
toast('账号离线,无法自动恢复,请进入「账号状态」手动登录', 'warning');
} else {
updateRecoverMessage('无法自动恢复,请手动登录');
}
return false;
} catch (err) {
state.loginPhase = 'idle';
if (auto) {
toast('账号离线,自动恢复失败,请进入「账号状态」手动登录', 'warning');
} else {
updateRecoverMessage('自动恢复失败,请手动登录');
}
return false;
}
}
async function recoverLogin() {
state.autoRecoverAttempted = true;
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 ====================
async function renderStatusPage() {
els.pageTitle.textContent = '账号状态';
const page = document.createElement('div');
page.className = 'page';
els.content.appendChild(page);
showLoading(page, 'list');
try {
const res = await api('GET', '/api/status', undefined, 8000);
hideLoading(page);
const login = res.data?.login || {};
const sub = res.data?.subscription || {};
const online = !!login.summary?.online;
const subscribed = !!sub.summary?.subscribed;
const acc = currentAccount() || {};
// 同步已保存账号的在线状态,确保表格显示与当前状态一致
syncAccountOnlineStatus(online, login.data?.detail);
const currentAcc = {
nickname: acc.nickname || login.summary?.nickname || '当前账号',
corpName: acc.corpName || login.data?.detail?.corpName || '',
userId: acc.userId || login.data?.detail?.userId || ''
};
page.innerHTML = `
${escapeHtml(currentAcc.nickname)}
${escapeHtml(currentAcc.corpName || '企业微信账号')}
${online ? '账号在线,可正常操作' : '账号离线,请重新登录'}
${!online ? `
` : ''}
${subscribed ? '订阅有效' : '未开通订阅'}
${escapeHtml(sub.summary?.expireAt ? `到期时间:${sub.summary.expireAt}` : '请检查订阅状态')}
${sub.summary?.seats ? `
席位:${formatNumber(sub.summary.seats)} 个
` : ''}
${subscribed ? '续费/管理' : '开通订阅'}
${res.summary?.authConfigured ? '鉴权已配置' : '鉴权未配置'}
${res.summary?.authConfigured ? '已检测到 Fmode 鉴权 Token' : '请配置 QIWEI_AUTH_TOKEN 或 FMODE_API_KEY'}
${state.accounts.length ? `
账号 企业 状态 操作
${state.accounts.map(a => `
${escapeHtml(a.nickname || a.userId || a.guid)}
${escapeHtml(a.corpName || '-')}
${a.online ? '在线' : '离线'}
${a.id !== state.currentAccountId ? `切换 ` : ''}
删除
`).join('')}
` : `
`}
`;
page.querySelector('#btn-add-account-page').addEventListener('click', () => {
state.loginUid = createDeviceUid();
state.currentAccountId = null;
saveAccounts();
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', () => {
if (confirm('确定删除这个账号吗?')) removeAccount(btn.dataset.id);
});
});
const recoverWrap = page.querySelector('#status-recover-wrap');
if (recoverWrap) {
renderRecoverButton(recoverWrap);
}
} catch (err) {
showError(page, err.message || '加载状态失败', () => renderStatusPage());
}
}
// ==================== Groups 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 = `
WECOM CUSTOMER GROUP OPERATIONS
客户群管理
识别真实客户群、同步历史消息,并集中完成人工确认与群运营准备。
${renderGroupStatsCard(null)}
先识别,再同步 勾选目标群后可批量确认并保存历史消息;未确认的普通群不会进入客户运营流程。
${renderGroupFilters()}
`;
els.content.appendChild(page);
bindGroupSync(page);
bindGroupFilters(page);
await loadGroups();
}
function renderGroupSyncCard() {
return `
`;
}
function renderGroupStatsCard(summary) {
const s = summary || {};
return `
${formatNumber(s.total)} 已扫描群聊
${formatNumber((s.autoConfirmed || 0) + (s.confirmed || 0))} 确认客户群
${formatNumber(s.suggested)} 待人工确认
`;
}
function renderGroupFilters() {
return `
`;
}
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 = `
`;
return;
}
const allSelected = filtered.length > 0 && filtered.every(g => state.selectedGroupIds.has(g.roomId));
card.innerHTML = `
`;
bindGroupTableActions(card, filtered);
}
function bindGroupTableActions(card, filtered) {
const selectAll = card.querySelector('#select-all-groups');
if (selectAll) {
selectAll.addEventListener('change', () => {
filtered.forEach(g => {
if (selectAll.checked) state.selectedGroupIds.add(g.roomId);
else state.selectedGroupIds.delete(g.roomId);
});
renderGroupTable(state.groups);
});
}
card.querySelectorAll('.group-checkbox').forEach(cb => {
cb.addEventListener('change', () => {
const roomId = cb.dataset.room;
if (cb.checked) state.selectedGroupIds.add(roomId);
else state.selectedGroupIds.delete(roomId);
renderGroupTable(state.groups);
});
});
const batchConfirmBtn = card.querySelector('#btn-batch-confirm');
if (batchConfirmBtn) {
batchConfirmBtn.addEventListener('click', () => {
const roomIds = Array.from(state.selectedGroupIds);
if (!roomIds.length) return;
batchConfirmGroups(roomIds);
});
}
const batchSyncBtn = card.querySelector('#btn-batch-sync-msg');
if (batchSyncBtn) {
batchSyncBtn.addEventListener('click', () => {
const roomIds = Array.from(state.selectedGroupIds);
if (!roomIds.length) return;
syncGroupMessages(roomIds, batchSyncBtn);
});
}
card.querySelectorAll('[data-action]').forEach(btn => {
btn.addEventListener('click', () => {
const roomId = btn.dataset.room;
const roomName = btn.dataset.name;
if (btn.dataset.action === 'confirm') confirmGroup(roomId, roomName);
if (btn.dataset.action === 'reject') openRejectModal(roomId, roomName);
if (btn.dataset.action === 'sync-msg') syncGroupMessages([roomId], btn);
if (btn.dataset.action === 'detail') openGroupDetailModal(roomId, roomName);
});
});
}
async function confirmGroup(roomId, roomName) {
const guid = getGuid();
if (!guid) {
toast('请先登录企业微信账号', 'warning');
state.currentAccountId = null;
renderPage();
return;
}
const acc = currentAccount();
if (!acc || !acc.online) {
toast('账号当前离线,请先恢复登录后再确认客户群', 'warning');
location.hash = '#status';
return;
}
try {
const res = await api('POST', '/api/groups/confirm', { roomId });
toast(res.assistantMessage || '已确认为客户群');
loadGroups();
} catch (err) {
toast(err.message || '确认失败', 'error');
}
}
async function batchConfirmGroups(roomIds) {
let success = 0;
let failed = 0;
for (const roomId of roomIds) {
try {
await api('POST', '/api/groups/confirm', { roomId });
success++;
} catch (err) {
failed++;
console.error('确认客户群失败', roomId, err);
}
}
toast(`批量确认完成:成功 ${success} 个${failed ? `,失败 ${failed} 个` : ''}`);
loadGroups();
}
async function loadGroups() {
try {
const [groupsRes, syncRes] = await Promise.all([
api('GET', '/api/groups/list'),
api('GET', '/api/dashboard/groups')
]);
state.groups = groupsRes.data?.groups || [];
state.groupSync = syncRes.data || {};
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 bindGroupSync(page) {
page.querySelector('#btn-sync-groups').addEventListener('click', () => {
const guid = getGuid();
if (!guid) {
toast('请先登录企业微信账号', 'warning');
state.currentAccountId = null;
renderPage();
return;
}
const acc = currentAccount();
if (!acc || !acc.online) {
toast('账号当前离线,请先恢复登录后再扫描群聊', 'warning');
location.hash = '#status';
return;
}
const keywords = document.getElementById('sync-keywords').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
const highConfidence = document.getElementById('sync-high-confidence').value.split(/[,,]/).map(s => s.trim()).filter(Boolean);
const body = {
guid,
scope: document.getElementById('sync-scope').value,
maxPages: Number(document.getElementById('sync-max-pages').value) || 20,
customerKeywords: keywords,
highConfidenceTerms: highConfidence,
autoClassify: document.getElementById('sync-auto-classify').checked,
scanFromMessages: document.getElementById('sync-scope').value === 'messages'
};
const tracker = document.getElementById('sync-job-tracker');
const id = Date.now().toString(36);
tracker.innerHTML = renderJobTracker('sync-groups', id);
startJob('sync-groups', '/api/groups/sync', body, result => {
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('群详情 / 客户信息', `
群名:${escapeHtml(roomName || roomId)}
客户编号(可选)
客户企微 ID(可选)
客户姓名(可选)
${members.length ? `
` : ''}
`, `
取消
保存
`);
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('忽略这个群', `
群名:${escapeHtml(roomName || roomId)}
原因(可选)
`, `
取消
忽略
`);
document.getElementById('btn-do-reject').addEventListener('click', async () => {
const body = { roomId, reason: document.getElementById('reject-reason').value.trim() };
try {
const res = await api('POST', '/api/groups/reject', body);
closeModal();
toast(res.assistantMessage || '已忽略');
loadGroups();
} catch (err) {
toast(err.message || '忽略失败', 'error');
}
});
}
async function syncGroupMessages(roomIds, triggerButton = null) {
const guid = getGuid();
if (!guid) {
toast('请先登录企业微信账号', 'warning');
return;
}
const acc = currentAccount();
if (!acc || !acc.online) {
toast('账号当前离线,请先恢复登录后再同步消息', 'warning');
location.hash = '#status';
return;
}
const body = { guid, roomIds, maxPages: 20, maxMessagesPerRoom: 500 };
const name = 'sync-messages-' + roomIds[0];
if (triggerButton) {
triggerButton.dataset.job = name;
}
startJob(name, '/api/groups/messages/sync', body, result => {
toast(`共同步 ${result.summary?.totalNew || 0} 条消息`);
loadGroups();
});
}
// ==================== 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 `${escapeHtml(label)} `;
}
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 recommendations = selected?.recommendations || [];
const timeline = selected?.timeline || [];
const intelligenceItems = `${openTasks.map(task => `${escapeHtml(task.title)} ${escapeHtml(task.evidence || task.reason || '')}
内部任务 · ${escapeHtml(agentTaskStatusLabel(task.status))} `).join('')}${openAlerts.map(alert => `${escapeHtml(alert.title)} ${escapeHtml(alert.evidence || alert.detail || '')}
客户预警 · ${escapeHtml(alert.severity)} `).join('')}`;
const detail = selected ? `
${selected.completenessScore}% 画像完整度
${selected.evidenceCoverage}% 字段证据覆盖
${selected.messageCount} 已纳入消息
${selected.openTaskCount} 待跟进任务
自动沉淀画像 ${escapeHtml(formatAgentTime(selected.profileUpdatedAt))} 更新 ${(selected.fields || []).map(field => `
${escapeHtml(field.label)} ${escapeHtml(field.displayValue || '-')} ${field.evidence ? field.evidence.source === 'human' ? '人工核对' : '有原话依据' : '历史字段 · 待补依据'}
`).join('') || '
还没有形成结构化画像,后续真实对话会持续补齐。
'}
${selected.tags?.length ? `${selected.tags.map(tag => `${escapeHtml(tag)} `).join('')} ` : ''}
${nextActions.map(action => `
${action.confidence === 'rule' ? '规则建议' : 'AI 建议'} ${escapeHtml(action.title)} ${escapeHtml(action.description)}
${(action.evidence || []).slice(0, 3).map(item => `${escapeHtml(item)} `).join('') || '当前依据待补充 '} 去智能会话处理 `).join('') || '
当前没有新的行动建议。
'}
${recommendations.map(item => `
${[item.property?.district, item.property?.layout, item.property?.area ? `${item.property.area}㎡` : '', item.property?.decoration].filter(Boolean).map(escapeHtml).join(' · ') || '房源快照字段待补充'}
${item.property?.totalPrice || '-'}万 累计推荐 ${item.recommendCount || 1} 次 最近 ${escapeHtml(formatAgentTime(item.lastRecommendedAt))}
${item.feedbackReason ? `${escapeHtml(item.feedbackReason)} ` : ''}`).join('') || '
还没有可验证的房源推荐记录。后续房源工具调用或明确发送的房源方案会自动进入这里。
'}
字段依据 ${evidenceItems.length} 条 ${evidenceItems.map(field => `${escapeHtml(field.label)} ${escapeHtml(field.evidence.text || '人工核对')}
${field.evidence.source === 'human' ? '人工修正记录' : '真实企微消息'} · ${escapeHtml(formatAgentTime(field.evidence.updatedAt))} `).join('') || '现有历史画像没有逐字段原话定位;新消息产生的字段会自动记录证据。
'}
跟进任务与预警 ${openTasks.length + openAlerts.length} 项 ${intelligenceItems || '当前没有待处理任务或预警。
'}
${selected.latestMessage ? `最近纳入主档的消息 ${escapeHtml(selected.latestMessage.content)}
${selected.latestMessage.role === 'customer' ? '客户' : '内部回复'} · ${escapeHtml(formatAgentTime(selected.latestMessage.timestamp))} ` : ''}
${timeline.slice(0, 20).map(event => `
${escapeHtml(formatAgentTime(event.occurredAt))} ${escapeHtml(event.title)} ${escapeHtml(event.description || '')}
${escapeHtml(event.source || '内部主账')}${event.status ? ` · ${escapeHtml(event.status)}` : ''} `).join('') || '
暂无客户事件。
'}
人工核对画像 只修正主档,不会向客户发送消息
保存主档 意向区域 预算/万 户型 购置用途 购置时间 标签
修正依据(可选)
` : '暂无已同步客户 从真实企微会话同步消息后,画像会自动沉淀到这里。
';
return `
${summary.customerCount || 0} 真实会话客户
${summary.profiledCount || 0} 已形成画像
${summary.priorityCount || 0} 重点跟进
${summary.openTaskCount || 0} 待跟进任务
自动沉淀已启用 ${escapeHtml(data.policy?.autoSync || '')} ${escapeHtml(data.policy?.evidence || '')}
`;
}
async function renderCustomerOpsPage() {
const renderToken = state.renderToken;
els.pageTitle.textContent = '客户管理';
void refreshAgentAccountHeader();
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;
const page = document.createElement('div');
page.className = 'page';
page.innerHTML = `
${renderCustomerMasterSection()}
OPTIONAL BATCH OPERATIONS
批量客户运营(可选)
用于手机号或 externalUserId 导入、批量加好友和自动建群;上方真实企微会话客户无需重复导入。
`;
els.content.appendChild(page);
bindCustomerOpsPage(page);
}
function bindCustomerOpsPage(page) {
const textArea = page.querySelector('#co-customer-text');
const fileInput = page.querySelector('#co-customer-file');
const greetingInput = page.querySelector('#co-default-greeting');
const previewText = page.querySelector('#co-preview-text');
const tableWrap = page.querySelector('#co-customer-table-wrap');
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', 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('客户主档已刷新');
const feedbackButton = event.target.closest('[data-recommendation-feedback]');
if (feedbackButton) {
const status = feedbackButton.dataset.feedbackStatus;
let feedbackReason = '';
if (status === 'rejected') {
const answer = window.prompt('请记录不合适原因(价格、区域、楼层、装修或其他)', '');
if (answer === null) return;
feedbackReason = answer.trim();
} else if (status === 'interested') {
const answer = window.prompt('可选:记录客户感兴趣的原因', '');
if (answer === null) return;
feedbackReason = answer.trim();
}
state.customerOps.master.loading = true;
rerenderCustomerMaster();
api('POST', `/api/customers/${encodeURIComponent(state.customerOps.master.selectedId)}/recommendations/${encodeURIComponent(feedbackButton.dataset.recommendationFeedback)}`, { status, feedbackReason })
.then(async result => {
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(); });
}
});
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 = {
preferredRegion: String(form.get('preferredRegion') || '').trim(),
budgetWan: budgetText ? Number(budgetText) : '',
layout: String(form.get('layout') || '').trim(),
intent: String(form.get('intent') || '').trim(),
timeline: String(form.get('timeline') || '').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 = '全部标签 ' + tags.map(t => `${escapeHtml(t)} `).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 `${escapeHtml(text)} `;
};
const groupBadge = (status) => {
if (status === 'CREATED') return '已建群 ';
if (status === 'IN_GROUP') return '群内客户 ';
return '未建群 ';
};
wrap.innerHTML = `
`;
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 = `
粘贴客户列表(每行:手机号 姓名)
或上传 Excel
默认验证消息
`;
openModal('导入客户', body, `
取消
导入
`);
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 `
${iconSvg}
${escapeHtml(title)}
${contentHtml}
`;
}
function renderDetailItem(label, value) {
return `
${escapeHtml(label)}
${value || '-'}
`;
}
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 `${escapeHtml(text)} `;
}
function customerGroupBadge(status) {
if (status === 'CREATED') return '已建群 ';
if (status === 'IN_GROUP') return '群内客户 ';
return '未建群 ';
}
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
? `
${groupLabels.slice(0, 5).map((name, index) => `
${escapeHtml(name)}
${sourceRoomIds[index] && sourceRoomIds[index] !== name ? `
${escapeHtml(sourceRoomIds[index])}
` : ''}
`).join('')}
${groupLabels.length > 5 ? `
还有 ${formatNumber(groupLabels.length - 5)} 个来源群未展示
` : ''}
`
: '暂无来源群记录
';
const basicInfo = `
${renderDetailItem('姓名', escapeHtml(customer.name || '-'))}
${renderDetailItem('手机号', escapeHtml(customer.phone || '-'))}
${renderDetailItem('好友状态', customerFriendBadge(customer.friendRequestStatus))}
${renderDetailItem('群状态', customerGroupBadge(customer.groupStatus))}
${renderDetailItem('画像状态', customer.hasPortrait ? '已生成 ' : '未生成 ')}
${renderDetailItem('最近更新', formatDateTime(customer.updatedAt))}
${tags.length ? `${tags.map(t => `${escapeHtml(t)} `).join(' ')}
` : ''}
externalUserId:${escapeHtml(customer.externalUserId || '-')}
`;
return {
basicInfo,
sourceGroupHtml
};
}
function renderRawToggle(rawData) {
return `
${escapeHtml(JSON.stringify(rawData || {}, null, 2))}
`;
}
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 = '正在加载完整资料...
';
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
? `
${contactList.slice(0, 5).map(c => `
${escapeHtml(c.nickName || c.userName || c.externalUserId || '未知联系人')}
${escapeHtml(c.externalUserId || c.userId || '-')}
`).join('')}
`
: '暂无匹配联系人
';
const roomsHtml = rooms.length
? `
${rooms.slice(0, 8).map(r => `
${escapeHtml(r.roomName || '未命名群')}
${escapeHtml(r.roomId || '-')}
`).join('')}
`
: '暂无相关群
';
const portraitFields = portrait.portrait || {};
const portraitKeys = Object.keys(portraitFields);
const portraitHtml = portraitKeys.length
? `
${portraitKeys.slice(0, 8).map(k => renderPortraitField(k, portraitFields[k])).join('')}
`
: '暂无画像数据
';
const contactsIcon = ' ';
const roomsIcon = ' ';
const portraitIcon = ' ';
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 = '完整资料加载失败
';
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 = ' ';
const roomsIcon = ' ';
openModal('客户详情', `
${renderDetailCard('运营信息', personIcon, basicInfo)}
${renderDetailCard('来源群聊', roomsIcon, sourceGroupHtml)}
加载完整资料
保存修改
`, `关闭 `);
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 = `
CUSTOMER INTELLIGENCE & TAGS
画像与标签
把客户对话沉淀为可追溯的结构化画像,用统一标签支持筛选、运营和后续跟进。
AI 画像 证据可追溯 批量标签
`;
els.content.appendChild(page);
await loadPortraitsDashboard(page);
dashboardRefreshTimer = setInterval(() => {
if (state.page === 'portraits') loadPortraitsDashboard(page, true);
}, 30000);
page.querySelector('#btn-pt-refresh').addEventListener('click', () => loadPortraitsDashboard(page));
page.querySelector('#btn-pt-rebuild').addEventListener('click', async () => {
try {
await api('POST', '/api/dashboard/rebuild');
toast('索引已重建');
loadPortraitsDashboard(page);
} 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-batch').addEventListener('click', () => openBatchPortraitSelector(page));
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');
});
});
}
async function loadPortraitsDashboard(page, silent = false) {
const tableWrap = page.querySelector('#pt-portrait-table-wrap');
if (!silent) showLoading(tableWrap, 'table');
try {
const [summaryRes, portraitsRes, tagsRes, customersRes, customerMasterRes] = await 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 summary = 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 customerMaster) {
const hasProfile = customer.profile && Object.keys(customer.profile).length > 0;
if (!hasProfile) continue;
canonicalPortraits[customer.id] = {
externalUserId: customer.id,
displayId: customer.maskedId,
fields: customer.fields || [],
profile: customer.profile || {},
source: 'agent',
canonical: true,
messageCount: customer.messageCount || 0,
updatedAt: customer.profileUpdatedAt || customer.lastMessageAt,
};
canonicalCustomers[customer.id] = {
externalUserId: customer.id,
name: customer.displayName,
phone: customer.maskedId,
tags: customer.tags || [],
};
}
const portraits = { ...legacyPortraits, ...canonicalPortraits };
const customers = { ...legacyCustomers, ...canonicalCustomers };
const tags = [...new Set([
...(tagsRes.data?.allTags || []),
...customerMaster.flatMap(customer => customer.tags || []),
])];
const portraitItems = Object.values(portraits);
const customerTotal = Math.max(Number(summary.customers?.total || 0), customerMaster.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,
};
state.portraits.allPortraits = portraits;
state.portraits.allCustomers = customers;
hideLoading(tableWrap);
renderPortraitsKpi(page, summary);
renderPortraitsCharts(page, summary, portraits, tags);
renderPortraitsTagFilter(page, tags);
loadPortraitsTable(page);
} catch (err) {
if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadPortraitsDashboard(page));
}
}
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 = '全部标签 ' + tags.map(t => `${escapeHtml(t)} `).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
});
let items = Object.values(portraits).map(p => {
const customer = Object.values(customers).find(c => c.externalUserId === p.externalUserId) || {};
return { ...p, 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 = `
externalUserId
姓名
标签
画像字段
来源
消息数
更新时间
操作
${pageItems.length ? pageItems.map(p => `
${escapeHtml(p.displayId || p.externalUserId || '-')}
${escapeHtml(p.name || '-')}
${p.tags.length ? p.tags.map(t => `${escapeHtml(t)} `).join('') : '-'}
${formatNumber(p.fields?.length || 0)} 个字段
${p.source === 'keyword' ? '关键词 ' : p.source === 'agent' ? 'AI ' : '未知 '}
${formatNumber(p.messageCount)}
${formatDateTime(p.updatedAt)}
查看
`).join('') : renderEmptyTableRow(8, '暂无画像数据', '选择客户后批量生成画像')}
`;
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) {
const result = await openCustomerSelector({
multiple: true,
title: '选择要生成画像的客户',
confirmText: '生成画像'
});
const ids = result.externalUserIds || [];
if (!ids.length) {
toast('未选择客户', 'warning');
return;
}
startJob('batch-portraits', '/api/portraits/batch', { externalUserIds: ids, aiMode: 'ai', guid: getGuid() }, () => loadPortraitsDashboard(page), 'pt-job-tracker');
}
function openTagModal(page) {
const body = `
标签(逗号分隔)
操作
添加标签
移除标签
`;
openModal('为客户打标签', body, `
取消
确定
`);
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
? `${normalized.chips.slice(0, 8).map(chip => `${escapeHtml(chip)} `).join('')}
`
: '';
return `
${escapeHtml(portraitLabel(key))}
${escapeHtml(normalized.text)}
${normalized.note ? `
${escapeHtml(normalized.note)}
` : ''}
${chipHtml}
`;
}
function renderPortraitSection(title, keys, portraitFields) {
const available = keys.filter(k => portraitFields[k] !== undefined && portraitFields[k] !== null && portraitFields[k] !== '');
if (!available.length) return '';
return `
${escapeHtml(title)}
${formatNumber(available.length)} 项
${available.map(k => renderPortraitInsightCard(k, portraitFields[k])).join('')}
`;
}
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('画像详情', `
${escapeHtml((customer.name || '客').slice(0, 1))}
${escapeHtml(customer.name || '未命名客户')}
${escapeHtml(cachedPortrait.displayId || '-')} · 真实企微会话画像
Agent 自动沉淀
${formatNumber(cachedPortrait.messageCount || 0)} 条消息
${formatNumber(fields.length)} 个画像字段
${fields.map(field => `
${escapeHtml(field.label || field.key)} ${escapeHtml(field.displayValue ?? '-')}
`).join('')}
`, `关闭 `, 'portrait-modal');
return;
}
try {
const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
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('')
: '暂无可展示的核心画像字段
';
let sectionsHtml = '';
for (const [groupName, keys] of Object.entries(groupMap)) {
sectionsHtml += renderPortraitSection(groupName, keys, portraitFields);
}
if (!sectionsHtml) {
sectionsHtml = '当前画像没有结构化字段。请先同步更多客户对话,再重新生成画像。
';
}
openModal('画像详情', `
${escapeHtml(customerInitial)}
${escapeHtml(name || '未命名客户')}
${escapeHtml(phone || '-')} · ${escapeHtml(externalUserId)}
${escapeHtml(srcText)}
${formatNumber(portrait.messageCount || 0)} 条消息
${formatNumber(fieldCount)} 个画像字段
${tags.length ? `
${tags.map(t => `${escapeHtml(t)} `).join(' ')}
` : ''}
${topHighlights}
${sectionsHtml}
${renderRawToggle(portraitFields)}
`, `关闭 `, 'portrait-modal');
bindRawToggle();
} catch (err) {
toast(err.message || '查询失败', 'error');
}
};
async function renderTransfersPage() {
els.pageTitle.textContent = '客户交接';
const page = document.createElement('div');
page.className = 'page customer-workspace-page';
page.innerHTML = `
CUSTOMER HANDOVER WORKFLOW
客户交接
先生成交接预览,再执行顾问变更;客户、群聊、欢迎语与执行结果全程留痕。
1 准备范围2 生成预览3 确认执行
`;
els.content.appendChild(page);
await loadTransfersDashboard(page);
dashboardRefreshTimer = setInterval(() => {
if (state.page === 'transfers') loadTransfersDashboard(page, true);
}, 30000);
page.querySelector('#btn-tf-refresh').addEventListener('click', () => loadTransfersDashboard(page));
page.querySelector('#btn-tf-rebuild').addEventListener('click', async () => {
try {
await api('POST', '/api/dashboard/rebuild');
toast('索引已重建');
loadTransfersDashboard(page);
} 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));
}
async function loadTransfersDashboard(page, silent = false) {
const tableWrap = page.querySelector('#tf-transfer-table-wrap');
if (!silent) showLoading(tableWrap, 'table');
try {
const [summaryRes, transfersRes] = await Promise.all([
api('GET', '/api/dashboard/summary'),
api('GET', '/api/dashboard/transfers')
]);
const summary = summaryRes.data || {};
const transfers = transfersRes.data || {};
hideLoading(tableWrap);
renderTransfersKpi(page, summary);
renderTransfersCharts(page, summary, transfers);
state.transfers = { ...state.transfers, ...transfers };
loadTransfersTable(page);
} catch (err) {
if (!silent) showError(tableWrap, err.message || '加载看板失败', () => loadTransfersDashboard(page));
}
}
function renderTransfersKpi(page, summary) {
const wrap = page.querySelector('#tf-kpi-row');
const t = summary.transfers || {};
const kpi = [
{ 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 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 = `
01 · 准备完成 ${formatNumber(previews)} 份交接预览包
${executionRate}% 进入执行
02 · 已确认执行 ${formatNumber(executions)} 次顾问交接
${successRate}% 执行成功
03 · 交接成功 ${formatNumber(successful)} 条成功记录
整体交接完成率 ${overallRate}%
${previews ? `共 ${formatNumber(previews)} 份预览包,其中 ${formatNumber(successful)} 份已完成成功交接` : '创建交接预览后,这里将展示阶段转化情况'}
`;
}
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 = `
类型
原顾问
新顾问
记录数
状态
创建/执行时间
操作
${pageItems.length ? pageItems.map(item => `
${item.type === 'preview' ? '预览 ' : '执行 '}
${escapeHtml(item.fromUserId || '-')}
${escapeHtml(item.toUserId || '-')}
${formatNumber(item.itemCount || item.total || 0)}
${item.status === 'EXECUTED' ? '已执行 ' : '草稿 '}
${formatDateTime(item.createdAt || item.executedAt)}
${item.type === 'preview' ? `执行 ` : `详情 `}
`).join('') : renderEmptyTableRow(7, '暂无交接记录', '点击新建交接预览')}
`;
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 => `${escapeHtml(a.nickname || a.userId || a.guid || '未知账号')} `).join('');
const body = `
已确认群 roomIds(逗号分隔,可选)
`;
openModal('新建交接预览', body, `
取消
生成预览
`);
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 = `
预览文件路径
欢迎语
交接后移除原顾问
`;
openModal('执行交接', body, `
取消
执行
`);
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('交接详情', `
${escapeHtml(JSON.stringify({ filePath }, null, 2))}
${renderFileLink(filePath, '下载文件')}
`, `关闭 `);
} catch (err) {
toast(err.message || '查询失败', 'error');
}
};
// ==================== Init ====================
async function checkExistingLogin() {
try {
const res = await api('GET', '/api/status');
const login = res.data?.login;
if (login?.summary?.online && login?.data?.detail) {
const detail = login.data.detail;
const 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() {
const account = currentAccount();
if (!account) return false;
try {
const result = await api('GET', '/api/agent/status');
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 });
} catch (error) {
console.error('恢复当前账号绑定失败', error);
return false;
}
}
async function init() {
updateAccountSwitcher();
await checkExistingLogin();
await hydrateCurrentAccountBinding();
els.accountBtn.addEventListener('click', e => {
e.stopPropagation();
els.accountDropdown.classList.toggle('open');
});
document.addEventListener('click', e => {
if (!els.accountDropdown.contains(e.target) && e.target !== els.accountBtn) {
els.accountDropdown.classList.remove('open');
}
});
document.getElementById('btn-add-account').addEventListener('click', () => {
state.loginUid = createDeviceUid();
state.currentAccountId = null;
saveAccounts();
location.hash = '#groups';
renderPage();
});
els.menuBtn.addEventListener('click', () => {
els.sidebar.classList.toggle('open');
});
window.addEventListener('hashchange', renderPage);
document.addEventListener('click', e => {
const link = e.target.closest('.nav-item');
if (link) els.sidebar.classList.remove('open');
});
renderPage();
}
init();
})();