|
|
@@ -42,7 +42,7 @@
|
|
|
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: [], selectedId: null, timer: null, loading: false, signature: null },
|
|
|
+ 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: '' }) }
|
|
|
@@ -62,8 +62,11 @@
|
|
|
accountList: document.getElementById('account-list')
|
|
|
};
|
|
|
|
|
|
- function api(method, path, body) {
|
|
|
+ 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);
|
|
|
@@ -72,6 +75,11 @@
|
|
|
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);
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -81,7 +89,7 @@
|
|
|
|
|
|
function getGuid() {
|
|
|
const acc = currentAccount();
|
|
|
- return acc ? (acc.guid || acc.userId || '') : '';
|
|
|
+ return acc ? ((acc.guid && acc.guid !== 'server-managed' ? acc.guid : '') || acc.userId || '') : '';
|
|
|
}
|
|
|
|
|
|
function saveAccounts() {
|
|
|
@@ -120,7 +128,7 @@
|
|
|
try {
|
|
|
const result = await api('POST', '/api/accounts/switch', {
|
|
|
uid: account.uid,
|
|
|
- guid: account.guid,
|
|
|
+ guid: account.guid === 'server-managed' ? '' : account.guid,
|
|
|
userId: account.userId,
|
|
|
nickname: account.nickname,
|
|
|
corpName: account.corpName,
|
|
|
@@ -1054,6 +1062,7 @@
|
|
|
|
|
|
function renderPage() {
|
|
|
state.renderToken += 1;
|
|
|
+ closeAllModals();
|
|
|
const page = location.hash.slice(1) || 'agent';
|
|
|
const loggedIn = !!currentAccount();
|
|
|
els.nav.classList.toggle('nav-locked', !loggedIn);
|
|
|
@@ -1916,6 +1925,57 @@
|
|
|
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 `
|
|
|
+ <section class="response-monitor ${summary.urgent ? 'has-urgent' : ''}">
|
|
|
+ <header class="response-monitor-header">
|
|
|
+ <div>
|
|
|
+ <span>RESPONSE SLA MONITOR</span>
|
|
|
+ <strong>客服回复提醒</strong>
|
|
|
+ <small>最后一条是客户消息且尚未回复时计时;仅本地提醒,不自动外发</small>
|
|
|
+ </div>
|
|
|
+ <div class="response-monitor-kpis">
|
|
|
+ <span><b>${summary.monitoredCustomers || 0}</b> 个客户</span>
|
|
|
+ <span><b>${summary.monitoredGroups || 0}</b> 个客户群</span>
|
|
|
+ <span class="${summary.overdue ? 'danger' : ''}"><b>${summary.overdue || 0}</b> 个超时</span>
|
|
|
+ </div>
|
|
|
+ </header>
|
|
|
+ <div class="response-monitor-body">
|
|
|
+ <div class="response-monitor-scope">
|
|
|
+ <div class="response-monitor-section-title"><strong>当前监听范围</strong><span>${config.warningMinutes || 15} 分钟提醒 · ${config.urgentMinutes || 60} 分钟紧急</span></div>
|
|
|
+ <div class="response-scope-list">
|
|
|
+ ${scopeItems.length ? scopeItems.map(item => `<span class="response-scope-chip ${escapeHtml(item.status || '')}"><i>${escapeHtml(item.typeLabel)}</i>${escapeHtml(item.name)}<em>${item.status === 'awaiting_sync' ? '待同步' : item.status === 'overdue' ? '待回复' : item.status === 'replied' ? '已回复' : item.status === 'no_reply_needed' ? '无需回复' : '监控中'}</em></span>`).join('') : '<span class="response-monitor-empty">尚未配置监听客户或客户群</span>'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="response-reminder-list">
|
|
|
+ <div class="response-monitor-section-title"><strong>需要客服处理</strong><span>${summary.urgent || 0} 个紧急 · ${summary.warning || 0} 个提醒</span></div>
|
|
|
+ ${reminders.length ? reminders.slice(0, 8).map(item => `
|
|
|
+ <article class="response-reminder ${escapeHtml(item.severity)}">
|
|
|
+ <div><i>${item.kind === 'group' ? '群' : '客'}</i><strong>${escapeHtml(item.name)}</strong><em>${item.severity === 'urgent' ? '紧急' : '提醒'}</em></div>
|
|
|
+ <p>${escapeHtml(item.lastMessagePreview || '收到新消息')}</p>
|
|
|
+ <footer><span>已等待 ${formatReplyWaiting(item.waitingMinutes)}</span><button class="btn btn-sm" data-response-monitor-target="${escapeHtml(item.kind)}" data-id="${escapeHtml(item.conversationId || item.id)}">${item.kind === 'group' ? '查看群聊' : '去回复'}</button></footer>
|
|
|
+ </article>
|
|
|
+ `).join('') : '<div class="response-monitor-empty success">当前没有超时未回复会话</div>'}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </section>`;
|
|
|
+ }
|
|
|
+
|
|
|
function agentAuditLabel(action) {
|
|
|
return ({
|
|
|
message_received: '收到真实企微消息',
|
|
|
@@ -1928,6 +1988,7 @@
|
|
|
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',
|
|
|
@@ -1942,6 +2003,32 @@
|
|
|
})[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 || {};
|
|
|
@@ -2046,9 +2133,11 @@
|
|
|
</aside>
|
|
|
</section>
|
|
|
`;
|
|
|
+ scrollAgentMessagesToLatest(page);
|
|
|
}
|
|
|
|
|
|
function renderAgentWorkspaceV2(page) {
|
|
|
+ const insightScroll = captureAgentInsightScroll(page);
|
|
|
const status = state.agent.status || {};
|
|
|
const listener = status.listener || {};
|
|
|
const account = status.account || {};
|
|
|
@@ -2073,6 +2162,7 @@
|
|
|
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';
|
|
|
@@ -2111,7 +2201,13 @@
|
|
|
<div class="agent-guard"><span>安全边界</span>仅处理 1 个白名单联系人 · 演示回复已禁用</div>
|
|
|
</section>
|
|
|
|
|
|
- ${selected?.agentError ? `<div class="agent-runtime-alert"><strong>Agent 上游不可用</strong><span>${escapeHtml(selected.agentError.message)}。真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>` : ''}
|
|
|
+ ${renderResponseMonitor(state.agent.responseMonitor || {})}
|
|
|
+
|
|
|
+ ${selected?.agentError
|
|
|
+ ? `<div class="agent-runtime-alert"><strong>Agent 上游不可用</strong><span>${escapeHtml(selected.agentError.message)}。真实消息已保存,系统没有生成或发送 Mock 回复。</span></div>`
|
|
|
+ : noReplyNotice
|
|
|
+ ? `<div class="agent-runtime-alert notice"><strong>Agent 已处理</strong><span>${escapeHtml(noReplyNotice.message)},系统没有向客户发送消息。</span></div>`
|
|
|
+ : ''}
|
|
|
|
|
|
<section class="agent-workspace">
|
|
|
<aside class="agent-conversation-list">
|
|
|
@@ -2146,19 +2242,19 @@
|
|
|
</div>
|
|
|
<div class="agent-message-stream">${(selected.messages || []).map(renderAgentMessage).join('')}</div>
|
|
|
<div class="agent-composer ${manualActive ? 'manual' : ''}">
|
|
|
- <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : 'Agent 处理区'}</strong><span>${pending ? `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核' : '可审核发送'}` : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : '没有待审核草稿'}</span></div>
|
|
|
- <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
|
|
|
+ <div class="agent-composer-label"><strong>${pending ? 'Agent 待审核草稿' : manualActive ? '人工回复' : conversationPaused ? '会话已暂停' : noReplyNotice ? 'Agent 已处理' : 'Agent 处理区'}</strong><span>${pending ? `置信度 ${Math.round((pending.confidence || 0) * 100)}% · ${pending.requiresHuman ? '必须人工审核' : '可审核发送'}` : manualActive ? 'Agent 不会生成或发送回复' : conversationPaused ? '消息保留,Agent 不处理' : noReplyNotice ? '最新消息无需回复' : '没有待审核草稿'}</span></div>
|
|
|
+ <textarea id="agent-reply-editor" placeholder="${manualActive ? '输入人工回复内容…' : pending ? '可先编辑 Agent 草稿…' : noReplyNotice ? '最新客户消息无需回复' : '点击下方按钮,让 Agent 处理最近一条消息'}" ${conversationPaused ? 'disabled' : ''}>${escapeHtml(pendingText)}</textarea>
|
|
|
<div class="agent-composer-actions">
|
|
|
<span>${pending ? escapeHtml(pending.reason || '请核对内容与依据后再发送') : `模型:${escapeHtml(agent.model || '未配置')} · ${agent.configured ? '已配置' : '未配置'}`}</span>
|
|
|
<div class="agent-review-actions">
|
|
|
- ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">让 Agent 处理</button>`}
|
|
|
+ ${pending ? `<button class="btn btn-secondary" data-agent-action="reject" data-id="${selected.id}" data-draft-id="${pending.id}">驳回</button><button class="btn btn-secondary" data-agent-action="regenerate" data-id="${selected.id}" data-draft-id="${pending.id}">重新生成</button><button class="btn" data-agent-action="approve" data-id="${selected.id}" data-draft-id="${pending.id}">批准并发送</button>` : manualActive ? `<button class="btn" data-agent-action="manual-send" data-id="${selected.id}">人工发送</button>` : conversationPaused ? '' : `<button class="btn" data-agent-action="generate" data-id="${selected.id}">${noReplyNotice ? '重新处理' : '让 Agent 处理'}</button>`}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
` : `<div class="agent-empty large"><strong>真实 Agent 会话准备就绪</strong><span>开始接收,然后从“王刚”的企微发送:“我想在新北区买婚房,预算150万,三室,最好精装”</span></div>`}
|
|
|
</main>
|
|
|
|
|
|
- <aside class="agent-insight-panel">
|
|
|
+ <aside class="agent-insight-panel" data-conversation-id="${escapeHtml(selected?.id || '')}">
|
|
|
${selected ? `
|
|
|
<div class="agent-insight-section">
|
|
|
<div class="agent-section-title"><span>01</span><div><strong>意图判断</strong><small>Agent 结构化理解</small></div></div>
|
|
|
@@ -2197,18 +2293,22 @@
|
|
|
</aside>
|
|
|
</section>
|
|
|
`;
|
|
|
+ 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] = await Promise.all([
|
|
|
+ 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) {
|
|
|
@@ -2242,10 +2342,12 @@
|
|
|
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'));
|
|
|
@@ -2270,6 +2372,16 @@
|
|
|
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;
|
|
|
@@ -2735,7 +2847,7 @@
|
|
|
showLoading(page, 'list');
|
|
|
|
|
|
try {
|
|
|
- const res = await api('GET', '/api/status');
|
|
|
+ const res = await api('GET', '/api/status', undefined, 8000);
|
|
|
hideLoading(page);
|
|
|
const login = res.data?.login || {};
|
|
|
const sub = res.data?.subscription || {};
|
|
|
@@ -3485,7 +3597,13 @@
|
|
|
|
|
|
page.innerHTML = `
|
|
|
<div id="customer-master-root">${renderCustomerMasterSection()}</div>
|
|
|
- <div id="customer-master-root">${renderCustomerMasterSection()}</div>
|
|
|
+ <section class="workspace-hero customer-workspace-hero" style="margin-top:20px">
|
|
|
+ <div>
|
|
|
+ <span>OPTIONAL BATCH OPERATIONS</span>
|
|
|
+ <h2>批量客户运营(可选)</h2>
|
|
|
+ <p>用于手机号或 externalUserId 导入、批量加好友和自动建群;上方真实企微会话客户无需重复导入。</p>
|
|
|
+ </div>
|
|
|
+ </section>
|
|
|
<div class="dashboard-grid" id="co-dashboard">
|
|
|
<div class="kpi-row" id="co-kpi-row"></div>
|
|
|
<div class="chart-card">
|
|
|
@@ -3722,7 +3840,7 @@
|
|
|
const c = summary.customers || {};
|
|
|
const friendStatus = c.friendStatusCounts || {};
|
|
|
const kpi = [
|
|
|
- { label: '本地客户总数', value: formatNumber(c.total) },
|
|
|
+ { 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: '已创建客户群' },
|
|
|
@@ -4351,16 +4469,63 @@
|
|
|
const tableWrap = page.querySelector('#pt-portrait-table-wrap');
|
|
|
if (!silent) showLoading(tableWrap, 'table');
|
|
|
try {
|
|
|
- const [summaryRes, portraitsRes, tagsRes, customersRes] = await Promise.all([
|
|
|
+ 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/dashboard/customers'),
|
|
|
+ api('GET', '/api/customers')
|
|
|
]);
|
|
|
const summary = summaryRes.data || {};
|
|
|
- const portraits = portraitsRes.data?.portraits || {};
|
|
|
- const tags = tagsRes.data?.allTags || [];
|
|
|
- const customers = customersRes.data?.customers || {};
|
|
|
+ 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;
|
|
|
@@ -4562,7 +4727,7 @@
|
|
|
<tbody>
|
|
|
${pageItems.length ? pageItems.map(p => `
|
|
|
<tr>
|
|
|
- <td style="font-family:monospace;font-size:12px">${escapeHtml(p.externalUserId || '-')}</td>
|
|
|
+ <td style="font-family:monospace;font-size:12px">${escapeHtml(p.displayId || p.externalUserId || '-')}</td>
|
|
|
<td>${escapeHtml(p.name || '-')}</td>
|
|
|
<td>${p.tags.length ? p.tags.map(t => `<span class="tag">${escapeHtml(t)}</span>`).join('') : '-'}</td>
|
|
|
<td>${formatNumber(p.fields?.length || 0)} 个字段</td>
|
|
|
@@ -4753,6 +4918,31 @@
|
|
|
|
|
|
window.dashboardPortraitDetail = async (externalUserId) => {
|
|
|
if (!externalUserId) return;
|
|
|
+ const cachedPortrait = state.portraits.allPortraits?.[externalUserId];
|
|
|
+ if (cachedPortrait?.canonical) {
|
|
|
+ const customer = state.portraits.allCustomers?.[externalUserId] || {};
|
|
|
+ const fields = cachedPortrait.fields || [];
|
|
|
+ openModal('画像详情', `
|
|
|
+ <div class="portrait-profile">
|
|
|
+ <div class="portrait-hero">
|
|
|
+ <div class="portrait-avatar">${escapeHtml((customer.name || '客').slice(0, 1))}</div>
|
|
|
+ <div class="portrait-identity">
|
|
|
+ <div class="portrait-name">${escapeHtml(customer.name || '未命名客户')}</div>
|
|
|
+ <div class="portrait-subtitle">${escapeHtml(cachedPortrait.displayId || '-')} · 真实企微会话画像</div>
|
|
|
+ <div class="portrait-chip-row">
|
|
|
+ <span class="badge badge-info">Agent 自动沉淀</span>
|
|
|
+ <span class="portrait-soft-chip">${formatNumber(cachedPortrait.messageCount || 0)} 条消息</span>
|
|
|
+ <span class="portrait-soft-chip">${formatNumber(fields.length)} 个画像字段</span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div class="portrait-highlight-grid">
|
|
|
+ ${fields.map(field => `<div class="portrait-insight-card"><span>${escapeHtml(field.label || field.key)}</span><strong>${escapeHtml(field.displayValue ?? '-')}</strong></div>`).join('')}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `, `<button class="btn btn-secondary" onclick="window.dashboardCloseModal()">关闭</button>`, 'portrait-modal');
|
|
|
+ return;
|
|
|
+ }
|
|
|
try {
|
|
|
const res = await api('POST', '/api/customer-ops/customer-profile', { externalUserId, guid: getGuid() });
|
|
|
const data = res.data || {};
|
|
|
@@ -5219,8 +5409,8 @@
|
|
|
if (login?.summary?.online && login?.data?.detail) {
|
|
|
const detail = login.data.detail;
|
|
|
const uid = login.data.uid || login.summary?.uid || '';
|
|
|
- const guid = detail.guid || 'server-managed';
|
|
|
- const existing = state.accounts.find(a => (uid && a.uid === uid) || a.guid === guid || a.userId === detail.userId);
|
|
|
+ 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,
|
|
|
@@ -5232,11 +5422,12 @@
|
|
|
online: true
|
|
|
});
|
|
|
} else {
|
|
|
- existing.uid = existing.uid || uid;
|
|
|
- existing.guid = existing.guid || guid;
|
|
|
+ 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) {
|
|
|
@@ -5264,6 +5455,32 @@
|
|
|
}
|
|
|
} 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 {}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
@@ -5271,18 +5488,16 @@
|
|
|
const account = currentAccount();
|
|
|
if (!account) return false;
|
|
|
try {
|
|
|
- if (!account.uid) {
|
|
|
- const result = await api('GET', '/api/agent/status');
|
|
|
- const active = result.data?.account || {};
|
|
|
- if (!active.uid) return false;
|
|
|
- account.uid = active.uid;
|
|
|
- account.guid = 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();
|
|
|
- }
|
|
|
+ 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);
|
|
|
@@ -5292,7 +5507,6 @@
|
|
|
|
|
|
async function init() {
|
|
|
updateAccountSwitcher();
|
|
|
- if (currentAccount()?.uid) await switchAccount(state.currentAccountId, { silent: true });
|
|
|
await checkExistingLogin();
|
|
|
await hydrateCurrentAccountBinding();
|
|
|
|