3294 lines
106 KiB
Vue
3294 lines
106 KiB
Vue
<template>
|
||
<div class="auto-reply-page">
|
||
<div class="page-header">
|
||
<div>
|
||
<h2>自动客服</h2>
|
||
<p>当前接管的企微账号将作为自动客服账号处理客户消息</p>
|
||
</div>
|
||
<div class="header-actions">
|
||
<button class="primary-btn" @click="handleStart" :disabled="busy">一键开启</button>
|
||
<button class="ghost-btn" @click="handleDisable" :disabled="busy">关闭</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="status-grid">
|
||
<div class="metric">
|
||
<span>状态</span>
|
||
<strong :class="status.enabled ? 'ok' : 'muted'">{{ status.enabled ? '已开启' : '未开启' }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>知识文件</span>
|
||
<strong>{{ status.knowledgeFileCount || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>知识片段</span>
|
||
<strong>{{ status.knowledgeChunkCount || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>向量片段</span>
|
||
<strong>{{ status.embeddingChunkCount || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>今日回复</span>
|
||
<strong>{{ status.todayReplied || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>今日转人工</span>
|
||
<strong>{{ status.todayHandoff || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>今日忽略</span>
|
||
<strong>{{ status.todayIgnored || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>最近总耗时</span>
|
||
<strong>{{ formatDuration(status.lastTotalDurationMs) }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>内部联系人</span>
|
||
<strong>{{ status.internalContactCount || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>外部联系人</span>
|
||
<strong>{{ status.externalContactCount || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>身份缓存</span>
|
||
<strong :class="status.identityRefreshing ? 'muted' : ''">{{ status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt) }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>协同等待中</span>
|
||
<strong>{{ status.collaborationWaitingCount || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>AI 接管中</span>
|
||
<strong>{{ status.collaborationTakeoverCount || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>今日 AI 补充</span>
|
||
<strong>{{ status.todayCollaborationSupplemented || 0 }}</strong>
|
||
</div>
|
||
<div class="metric">
|
||
<span>今日无人接管</span>
|
||
<strong>{{ status.todayCollaborationTakeovers || 0 }}</strong>
|
||
</div>
|
||
</div>
|
||
<div v-if="message && messageType !== 'error'" class="inline-message" :class="messageType">{{ message }}</div>
|
||
<div v-if="reasonEntries.length" class="reason-row">
|
||
<span v-for="item in reasonEntries" :key="item.reason">{{ reasonLabel(item.reason) }} {{ item.count }}</span>
|
||
</div>
|
||
|
||
<nav class="section-nav" aria-label="自动客服模块导航">
|
||
<button
|
||
v-for="item in sectionNavItems"
|
||
:key="item.id"
|
||
type="button"
|
||
class="section-nav-btn"
|
||
@click="scrollToSection(item.id)"
|
||
>
|
||
{{ item.label }}
|
||
</button>
|
||
</nav>
|
||
|
||
<section id="auto-section-listen" class="auto-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span class="section-index">01</span>
|
||
<h3>监听策略</h3>
|
||
</div>
|
||
<div class="section-meta">
|
||
<span>{{ form.enabled ? '已启用' : '未启用' }}</span>
|
||
<span>{{ form.listen.groupTriggerMode === 'all' ? '群聊全量' : '@ 触发' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="sectionAlerts('listen').length" class="section-alerts">
|
||
<div v-for="alert in sectionAlerts('listen')" :key="alert" class="section-alert">{{ alert }}</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.enabled">
|
||
<span>启用自动客服</span>
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.listen.enablePrivateChat">
|
||
<span>处理私聊</span>
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.listen.enableGroupChat">
|
||
<span>处理群聊</span>
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.listen.ignoreSelfMessage">
|
||
<span>忽略自己发送的消息</span>
|
||
</label>
|
||
<label>
|
||
<span>群聊触发</span>
|
||
<select v-model="form.listen.groupTriggerMode">
|
||
<option value="mention_only">仅 @ 当前账号</option>
|
||
<option value="all">所有群消息</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>去重秒数</span>
|
||
<input type="number" v-model.number="form.listen.deduplicateSeconds" min="30">
|
||
</label>
|
||
<label>
|
||
<span>冷却秒数</span>
|
||
<input type="number" v-model.number="form.replyPolicy.cooldownSeconds" min="0">
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.collaboration.enabled">
|
||
<span>人机协同模式</span>
|
||
</label>
|
||
<label>
|
||
<span>协同等待人工秒数</span>
|
||
<input type="number" v-model.number="form.collaboration.humanWaitSeconds" min="5" max="3600">
|
||
</label>
|
||
<label>
|
||
<span>协同人工回复后判断延迟</span>
|
||
<input type="number" v-model.number="form.collaboration.afterHumanReplyDelaySeconds" min="0" max="120">
|
||
</label>
|
||
<label>
|
||
<span>AI 接管静默退出秒数</span>
|
||
<input type="number" v-model.number="form.collaboration.takeoverIdleExitSeconds" min="30" max="7200">
|
||
</label>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<button class="ghost-btn" @click="saveConfig('listen')" :disabled="busy">保存监听策略</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="auto-section-ai" class="auto-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span class="section-index">02</span>
|
||
<h3>AI 配置</h3>
|
||
</div>
|
||
<div class="section-meta">
|
||
<span>{{ form.ai.provider === 'local' ? '本地模型' : '兼容接口' }}</span>
|
||
<span>文本:{{ form.ai.model || '未配置' }}</span>
|
||
<span>图片:{{ form.ai.visionModel || defaultVisionModel }}</span>
|
||
<span>语音:{{ form.ai.audioModel || '未配置' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="sectionAlerts('ai').length" class="section-alerts">
|
||
<div v-for="alert in sectionAlerts('ai')" :key="alert" class="section-alert">{{ alert }}</div>
|
||
</div>
|
||
<div class="ai-config-groups">
|
||
<div class="config-subsection">
|
||
<div class="config-subsection-header">
|
||
<strong>文本回复</strong>
|
||
<span>用于知识库回答、普通问候和兜底回复</span>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label>
|
||
<span>接口类型</span>
|
||
<select v-model="form.ai.provider">
|
||
<option value="openai_compatible">OpenAI 兼容接口</option>
|
||
<option value="local">本地模型 Ollama</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Base URL</span>
|
||
<input v-model="form.ai.baseUrl" placeholder="https://api.example.com/v1">
|
||
</label>
|
||
<label>
|
||
<span>API Key</span>
|
||
<input v-model="form.ai.apiKey" type="password" placeholder="可为空">
|
||
</label>
|
||
<label>
|
||
<span>文本模型</span>
|
||
<input v-model="form.ai.model" placeholder="qwen-turbo">
|
||
</label>
|
||
<label class="wide">
|
||
<span>AI 身份提示词</span>
|
||
<textarea v-model="form.ai.systemPrompt" rows="3" placeholder="你是一名灵泽万川的智能客服。"></textarea>
|
||
</label>
|
||
<label>
|
||
<span>Max Tokens</span>
|
||
<input type="number" v-model.number="form.ai.maxTokens" min="64" max="4000">
|
||
</label>
|
||
<label>
|
||
<span>Temperature</span>
|
||
<input type="number" step="0.1" v-model.number="form.ai.temperature" min="0" max="2">
|
||
</label>
|
||
<label>
|
||
<span>超时秒数</span>
|
||
<input type="number" v-model.number="form.ai.timeoutSeconds" min="5">
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.ai.enableThinking">
|
||
<span>开启模型思考</span>
|
||
</label>
|
||
<label>
|
||
<span>AI回复的详细程度</span>
|
||
<select v-model="form.ai.replyDetail">
|
||
<option value="concise">简洁</option>
|
||
<option value="medium">中等</option>
|
||
<option value="detailed">详细</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="config-subsection">
|
||
<div class="config-subsection-header">
|
||
<strong>图片识别</strong>
|
||
<span>用于客户发送图片、表情和视频封面时先识别内容;默认复用文本回复的 Base URL / API Key</span>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label>
|
||
<span>图片识别模型</span>
|
||
<input v-model="form.ai.visionModel" placeholder="qwen3-vl-plus">
|
||
</label>
|
||
</div>
|
||
<button type="button" class="advanced-toggle" @click="showVisionAdvanced = !showVisionAdvanced">
|
||
{{ showVisionAdvanced ? '收起高级设置' : '高级设置' }}
|
||
</button>
|
||
<div v-if="showVisionAdvanced" class="form-grid advanced-grid">
|
||
<label>
|
||
<span>图片 Base URL</span>
|
||
<input v-model="form.ai.visionBaseUrl" placeholder="留空复用主 Base URL">
|
||
</label>
|
||
<label>
|
||
<span>图片 API Key</span>
|
||
<input v-model="form.ai.visionApiKey" type="password" placeholder="留空复用主 API Key">
|
||
</label>
|
||
<div v-if="visionApiKeyLooksLikeUrl" class="field-warning">图片 API Key 当前看起来是 URL,保存时会清空并复用主 API Key。</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="config-subsection">
|
||
<div class="config-subsection-header">
|
||
<strong>语音识别</strong>
|
||
<span>用于客户发送语音时转写文本;默认复用文本回复的 Base URL / API Key</span>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label>
|
||
<span>语音模式</span>
|
||
<select v-model="form.ai.audioMode">
|
||
<option value="auto">自动识别</option>
|
||
<option value="openai_audio_chat">OpenAI 兼容音频聊天</option>
|
||
<option value="dashscope_paraformer">百炼 Paraformer</option>
|
||
<option value="local_openai_transcription">本地 OpenAI 转写</option>
|
||
<option value="custom_http">自定义 HTTP</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>语音模型</span>
|
||
<input v-model="form.ai.audioModel" placeholder="qwen3-asr-flash">
|
||
</label>
|
||
</div>
|
||
<button type="button" class="advanced-toggle" @click="showAudioAdvanced = !showAudioAdvanced">
|
||
{{ showAudioAdvanced ? '收起高级设置' : '高级设置' }}
|
||
</button>
|
||
<div v-if="showAudioAdvanced" class="form-grid advanced-grid">
|
||
<label>
|
||
<span>语音 Base URL</span>
|
||
<input v-model="form.ai.audioBaseUrl" placeholder="留空复用主 Base URL">
|
||
</label>
|
||
<label>
|
||
<span>语音 API Key</span>
|
||
<input v-model="form.ai.audioApiKey" type="password" placeholder="留空复用主 API Key">
|
||
</label>
|
||
<div v-if="audioApiKeyLooksLikeUrl" class="field-warning">语音 API Key 当前看起来是 URL,保存时会清空并复用主 API Key。</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<button class="ghost-btn" @click="saveConfig('ai')" :disabled="busy">保存配置</button>
|
||
<button class="ghost-btn" @click="testAI" :disabled="busy">测试 AI</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="auto-section-knowledge" class="auto-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span class="section-index">03</span>
|
||
<h3>知识库</h3>
|
||
</div>
|
||
<div class="section-meta">
|
||
<span>{{ status.knowledgeFileCount || 0 }} 个文件</span>
|
||
<span>{{ status.knowledgeChunkCount || 0 }} 个片段</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="sectionAlerts('knowledge').length" class="section-alerts">
|
||
<div v-for="alert in sectionAlerts('knowledge')" :key="alert" class="section-alert">{{ alert }}</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label class="wide">
|
||
<span>目录</span>
|
||
<input v-model="form.knowledge.directory" placeholder="config/knowledge">
|
||
</label>
|
||
<label>
|
||
<span>TopK</span>
|
||
<input type="number" v-model.number="form.knowledge.topK" min="1">
|
||
</label>
|
||
<label>
|
||
<span>最低分数</span>
|
||
<input type="number" step="0.05" v-model.number="form.knowledge.minScore" min="0" max="1">
|
||
</label>
|
||
<label>
|
||
<span>检索模式</span>
|
||
<select v-model="form.retrieval.retrievalMode">
|
||
<option value="hybrid_rerank">混合检索 + 重排序</option>
|
||
<option value="keyword">仅关键词检索</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>Embedding 模型</span>
|
||
<input v-model="form.retrieval.embeddingModel" placeholder="text-embedding-v4">
|
||
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
|
||
用于文本向量化,例如:text-embedding-v4, text-embedding-v3
|
||
</small>
|
||
</label>
|
||
<label>
|
||
<span>Embedding 维度</span>
|
||
<input type="number" v-model.number="form.retrieval.embeddingDimensions" min="128">
|
||
</label>
|
||
<label>
|
||
<span>Rerank 模型</span>
|
||
<input v-model="form.retrieval.rerankModel" placeholder="qwen3-rerank">
|
||
<small style="color: #666; font-size: 12px; margin-top: 4px; display: block;">
|
||
用于结果重排序,例如:qwen3-rerank, gte-rerank-v2
|
||
</small>
|
||
</label>
|
||
<label>
|
||
<span>召回 TopK</span>
|
||
<input type="number" v-model.number="form.retrieval.recallTopK" min="1">
|
||
</label>
|
||
<label>
|
||
<span>重排 TopK</span>
|
||
<input type="number" v-model.number="form.retrieval.rerankTopK" min="1">
|
||
</label>
|
||
<label>
|
||
<span>最终 TopK</span>
|
||
<input type="number" v-model.number="form.retrieval.finalTopK" min="1">
|
||
</label>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.materials.autoSendEnabled">
|
||
<span>命中素材后直接发送图片/视频/文件</span>
|
||
</label>
|
||
<label>
|
||
<span>素材目录</span>
|
||
<input v-model="form.materials.directory" placeholder="config/materials">
|
||
</label>
|
||
<label>
|
||
<span>素材索引</span>
|
||
<input v-model="form.materials.indexPath" placeholder="config/materials/materials.json">
|
||
</label>
|
||
<label>
|
||
<span>每次最多发送</span>
|
||
<input type="number" v-model.number="form.materials.maxPerReply" min="1" max="5">
|
||
</label>
|
||
<label>
|
||
<span>回复风格</span>
|
||
<select v-model="form.replyStyle">
|
||
<option value="natural_professional">自然专业</option>
|
||
<option value="concise_direct">简洁直接</option>
|
||
<option value="warm_service">热情服务</option>
|
||
</select>
|
||
</label>
|
||
</div>
|
||
<div class="tag-row">
|
||
<span v-for="ext in form.knowledge.supportedExtensions" :key="ext">{{ ext }}</span>
|
||
<span>检索:{{ status.retrievalMode || form.retrieval.retrievalMode }}</span>
|
||
<span>Embedding:{{ status.embeddingModel || form.retrieval.embeddingModel }}</span>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<button class="ghost-btn" @click="saveConfig('knowledge')" :disabled="busy">保存配置</button>
|
||
<button class="ghost-btn" @click="syncMaterials" :disabled="busy">同步素材库</button>
|
||
<button class="ghost-btn" @click="rebuildKnowledge" :disabled="busy">重建索引</button>
|
||
</div>
|
||
<div v-if="status.knowledgeFailedFiles && status.knowledgeFailedFiles.length" class="failed-list">
|
||
<div v-for="item in status.knowledgeFailedFiles" :key="item">{{ item }}</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="auto-section-handoff" class="auto-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span class="section-index">04</span>
|
||
<h3>人工接管</h3>
|
||
</div>
|
||
<div class="section-meta">
|
||
<span>{{ form.handoff.humanUserId ? '已配置同事' : '未配置同事' }}</span>
|
||
<span>{{ form.handoff.sendHumanCardToCustomer ? '客户转人工时发名片' : '不发名片' }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="sectionAlerts('handoff').length" class="section-alerts">
|
||
<div v-for="alert in sectionAlerts('handoff')" :key="alert" class="section-alert">{{ alert }}</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label>
|
||
<span>人工同事</span>
|
||
<div class="handoff-human-combobox">
|
||
<input
|
||
v-model="handoffHumanQuery"
|
||
class="handoff-human-search"
|
||
placeholder="输入姓名、拼音、userID 模糊搜索"
|
||
@focus="startHandoffHumanEditing"
|
||
@input="handleHandoffHumanInput"
|
||
@keydown.enter.prevent="previewFirstHandoffHuman"
|
||
@keydown.esc.prevent="closeHandoffHumanDropdown"
|
||
>
|
||
<div v-if="handoffHumanDropdownOpen" class="handoff-human-dropdown">
|
||
<button
|
||
v-for="item in handoffHumanOptions"
|
||
:key="item.userId"
|
||
type="button"
|
||
class="handoff-human-option"
|
||
:class="{ selected: pendingHandoffHumanId === item.userId }"
|
||
@mousedown.prevent="previewHandoffHuman(item)"
|
||
>
|
||
<strong>{{ identityOptionName(item) }}</strong>
|
||
<span>{{ item.userId }}</span>
|
||
<small>{{ identitySourceLabel(item.source) }}</small>
|
||
</button>
|
||
<div v-if="handoffHumanOptions.length === 0" class="handoff-human-empty">
|
||
{{ handoffHumanSelectHint }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="select-action-row">
|
||
<button type="button" class="mini-btn" @click="confirmHandoffHuman" :disabled="!canConfirmHandoffHuman">确认选择</button>
|
||
<button type="button" class="mini-btn" @click="clearHandoffHuman">清空</button>
|
||
</div>
|
||
<small class="field-hint">{{ selectedHandoffHumanSummary }}</small>
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.handoff.includeKnowledgeHits">
|
||
<span>附带知识库候选</span>
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.handoff.sendHumanCardToCustomer">
|
||
<span>客户要求人工时发送人工名片给客户</span>
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.handoff.sendCustomerCardToHuman">
|
||
<span>同时发送客户名片给人工</span>
|
||
</label>
|
||
<label>
|
||
<span>转人工关键词</span>
|
||
<input v-model="manualTriggerKeywordsText" placeholder="人工、客服、转人工、真人">
|
||
</label>
|
||
<label class="wide">
|
||
<span>客户转人工说明</span>
|
||
<input v-model="form.handoff.customerHandoffNotice">
|
||
</label>
|
||
<label class="wide">
|
||
<span>通知模板</span>
|
||
<textarea v-model="form.handoff.messageTemplate" rows="8" :placeholder="defaultHandoffTemplateHint"></textarea>
|
||
<small class="field-hint">留空时会按私聊/群聊自动使用上面的默认模板;填写后则使用你自定义的模板。</small>
|
||
</label>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<button class="ghost-btn" @click="saveConfig('handoff')" :disabled="busy">保存配置</button>
|
||
<button class="ghost-btn" @click="testHandoff" :disabled="busy">测试私信</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="auto-section-identity" class="auto-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span class="section-index">05</span>
|
||
<h3>身份识别</h3>
|
||
</div>
|
||
<div class="section-meta">
|
||
<span>内部 {{ status.internalContactCount || 0 }}</span>
|
||
<span>外部 {{ status.externalContactCount || 0 }}</span>
|
||
<span>缓存 {{ status.identityInitializing ? '初始化中' : (status.identityRefreshing ? '刷新中' : formatUnixTime(status.identityLastRefreshAt)) }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="sectionAlerts('identity').length" class="section-alerts">
|
||
<div v-for="alert in sectionAlerts('identity')" :key="alert" class="section-alert">{{ alert }}</div>
|
||
</div>
|
||
<div class="form-grid">
|
||
<label>
|
||
<span>未知身份处理</span>
|
||
<select v-model="form.identity.unknownPolicy">
|
||
<option value="customer">按客户处理</option>
|
||
<option value="ignore">忽略未知身份</option>
|
||
<option value="internal">按内部员工处理</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>未知身份转人工</span>
|
||
<select v-model="form.identity.unknownHandoffPolicy">
|
||
<option value="hold">先拦截并核验</option>
|
||
<option value="allow">允许转人工</option>
|
||
</select>
|
||
</label>
|
||
<label>
|
||
<span>刷新间隔分钟</span>
|
||
<input type="number" v-model.number="form.identity.refreshIntervalMinutes" min="5">
|
||
</label>
|
||
<label>
|
||
<span>每页数量</span>
|
||
<input type="number" v-model.number="form.identity.pageSize" min="20" max="500">
|
||
</label>
|
||
<label class="check-row">
|
||
<input type="checkbox" v-model="form.identity.refreshOnStart">
|
||
<span>启动时刷新内外部联系人</span>
|
||
</label>
|
||
<label class="wide">
|
||
<span>内部员工拦截回复</span>
|
||
<input v-model="form.identity.internalNoHandoffReply">
|
||
</label>
|
||
<label class="wide">
|
||
<span>未知身份转人工拦截回复</span>
|
||
<input v-model="form.identity.unknownNoHandoffReply">
|
||
</label>
|
||
<div class="identity-editor wide">
|
||
<div class="identity-editor-header">
|
||
<strong>内部成员同步群</strong>
|
||
<span>选择企业总群后,群成员会作为内部员工自动同步</span>
|
||
</div>
|
||
<div class="identity-picker-row">
|
||
<input v-model="internalGroupQuery" placeholder="搜索总群名称或 conversationId">
|
||
<select v-model="selectedInternalGroupId" :disabled="internalGroupFilteredOptions.length === 0">
|
||
<option value="">{{ groupSelectHint() }}</option>
|
||
<option v-for="item in internalGroupFilteredOptions" :key="item.conversationId" :value="item.conversationId">
|
||
{{ groupOptionLabel(item) }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="ghost-btn" @click="addSelectedInternalGroup" :disabled="!canAddInternalGroup">添加</button>
|
||
</div>
|
||
<div v-if="groupOptions.length === 0" class="field-hint">先点击“刷新群列表”,再选择企业总群;未选择总群时只同步企微联系人列表。</div>
|
||
<div v-if="internalSelectedGroups.length" class="identity-selected-list">
|
||
<div v-for="item in internalSelectedGroups" :key="item.conversationId" class="identity-selected-row group-selected-row">
|
||
<strong>{{ item.name || '未命名群聊' }}</strong>
|
||
<span class="identity-user-id">{{ item.conversationId }}</span>
|
||
<span>{{ item.source || '群列表' }}</span>
|
||
<span></span>
|
||
<button type="button" class="ghost-btn small-btn" @click="removeInternalGroup(item.conversationId)">删除</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="identity-empty">暂无内部成员同步群</div>
|
||
</div>
|
||
<div class="identity-sync-grid wide">
|
||
<div class="identity-sync-panel">
|
||
<div class="identity-editor-header">
|
||
<strong>内部成员</strong>
|
||
<span>{{ identityOptions.internal.length }} 人,来自企微通讯录和内部成员同步群</span>
|
||
</div>
|
||
<input v-model="syncedInternalQuery" class="identity-search-input" placeholder="搜索内部成员名称或 userID">
|
||
<div v-if="internalSyncedContacts.length" class="identity-sync-list">
|
||
<div v-for="item in internalSyncedContacts" :key="item.userId" class="identity-sync-row">
|
||
<strong>{{ item.name || '未命名联系人' }}</strong>
|
||
<span class="identity-user-id">{{ item.userId }}</span>
|
||
<span>{{ identitySourceLabel(item.source) }}</span>
|
||
<span>{{ formatUnixTime(item.lastSeenAt) }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-else class="identity-empty">暂无同步到的内部成员</div>
|
||
</div>
|
||
<div class="identity-sync-panel">
|
||
<div class="identity-editor-header">
|
||
<strong>外部客户</strong>
|
||
<span>{{ identityOptions.external.length }} 人 / {{ externalCustomerGroups.length }} 个账号分组</span>
|
||
</div>
|
||
<input v-model="syncedExternalQuery" class="identity-search-input" placeholder="搜索外部客户名称或 userID">
|
||
<div v-if="externalCustomerGroups.length" class="identity-sync-list external-group-list">
|
||
<details v-for="group in externalCustomerGroups" :key="group.key" class="identity-external-group" open>
|
||
<summary class="identity-external-summary">
|
||
<strong>{{ group.label }}</strong>
|
||
<span>{{ group.count }} 个客户</span>
|
||
</summary>
|
||
<div v-for="item in group.items" :key="externalContactRowKey(item)" class="identity-sync-row">
|
||
<strong>{{ item.name || '未命名联系人' }}</strong>
|
||
<span class="identity-user-id">{{ item.userId }}</span>
|
||
<span>{{ identitySourceLabel(item.source) }}</span>
|
||
<span>{{ formatUnixTime(item.lastSeenAt) }}</span>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div v-else class="identity-empty">暂无同步到的外部客户</div>
|
||
</div>
|
||
</div>
|
||
<details class="identity-advanced wide">
|
||
<summary>高级补充:手动指定联系人 userID</summary>
|
||
<div class="identity-advanced-body">
|
||
<div class="identity-editor">
|
||
<div class="identity-editor-header">
|
||
<strong>手动内部员工</strong>
|
||
<span>仅用于企微接口暂未同步到的特殊联系人</span>
|
||
</div>
|
||
<div class="identity-picker-row">
|
||
<input v-model="internalIdentityQuery" placeholder="搜索内部员工名称或 userID">
|
||
<select v-model="selectedInternalUserId" :disabled="internalFilteredOptions.length === 0">
|
||
<option value="">{{ identitySelectHint('internal') }}</option>
|
||
<option v-for="item in internalFilteredOptions" :key="item.userId" :value="item.userId">
|
||
{{ identityOptionLabel(item) }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="ghost-btn" @click="addSelectedIdentity('internal')" :disabled="!canAddIdentity('internal')">添加</button>
|
||
</div>
|
||
<div v-if="internalSelectedContacts.length" class="identity-selected-list">
|
||
<div v-for="item in internalSelectedContacts" :key="item.userId" class="identity-selected-row">
|
||
<strong>{{ item.name || '未填写名称' }}</strong>
|
||
<span class="identity-user-id">{{ item.userId }}</span>
|
||
<span>{{ identitySourceLabel(item.source) }}</span>
|
||
<input
|
||
v-if="item.needsLabel"
|
||
:value="identityManualLabelDraft('internal', item.userId)"
|
||
placeholder="补充名称"
|
||
@input="setIdentityLabelDraft('internal', item.userId, $event.target.value)"
|
||
@blur="commitIdentityLabelDraft('internal', item.userId)"
|
||
@keydown.enter.prevent="commitIdentityLabelDraft('internal', item.userId)"
|
||
>
|
||
<span v-else></span>
|
||
<button type="button" class="ghost-btn small-btn" @click="removeIdentity('internal', item.userId)">删除</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="identity-empty">暂无手动内部员工</div>
|
||
</div>
|
||
<div class="identity-editor">
|
||
<div class="identity-editor-header">
|
||
<strong>手动外部客户</strong>
|
||
<span>仅用于企微接口暂未同步到的特殊联系人</span>
|
||
</div>
|
||
<div class="identity-picker-row">
|
||
<input v-model="externalIdentityQuery" placeholder="搜索外部客户名称或 userID">
|
||
<select v-model="selectedExternalUserId" :disabled="externalFilteredOptions.length === 0">
|
||
<option value="">{{ identitySelectHint('external') }}</option>
|
||
<option v-for="item in externalFilteredOptions" :key="item.userId" :value="item.userId">
|
||
{{ identityOptionLabel(item) }}
|
||
</option>
|
||
</select>
|
||
<button type="button" class="ghost-btn" @click="addSelectedIdentity('external')" :disabled="!canAddIdentity('external')">添加</button>
|
||
</div>
|
||
<div v-if="externalSelectedContacts.length" class="identity-selected-list">
|
||
<div v-for="item in externalSelectedContacts" :key="item.userId" class="identity-selected-row">
|
||
<strong>{{ item.name || '未填写名称' }}</strong>
|
||
<span class="identity-user-id">{{ item.userId }}</span>
|
||
<span>{{ identitySourceLabel(item.source) }}</span>
|
||
<input
|
||
v-if="item.needsLabel"
|
||
:value="identityManualLabelDraft('external', item.userId)"
|
||
placeholder="补充名称"
|
||
@input="setIdentityLabelDraft('external', item.userId, $event.target.value)"
|
||
@blur="commitIdentityLabelDraft('external', item.userId)"
|
||
@keydown.enter.prevent="commitIdentityLabelDraft('external', item.userId)"
|
||
>
|
||
<span v-else></span>
|
||
<button type="button" class="ghost-btn small-btn" @click="removeIdentity('external', item.userId)">删除</button>
|
||
</div>
|
||
</div>
|
||
<div v-else class="identity-empty">暂无手动外部客户</div>
|
||
</div>
|
||
<label>
|
||
<span>批量粘贴内部员工 ID</span>
|
||
<textarea v-model="internalUserIdsText" rows="3" placeholder="每行一个 user_id,例如 1688855899845302"></textarea>
|
||
</label>
|
||
<label>
|
||
<span>批量粘贴外部客户 ID</span>
|
||
<textarea v-model="externalUserIdsText" rows="3" placeholder="每行一个 user_id"></textarea>
|
||
</label>
|
||
</div>
|
||
</details>
|
||
</div>
|
||
<div class="tag-row">
|
||
<span>内部:{{ status.internalContactCount || 0 }}</span>
|
||
<span>外部:{{ status.externalContactCount || 0 }}</span>
|
||
<span>群列表:{{ status.identityGroupOptionCount || groupOptions.length || 0 }}</span>
|
||
<span>同步群成员:{{ status.internalGroupMemberLastSyncCount || 0 }}</span>
|
||
<span>同步群刷新:{{ formatUnixTime(status.internalGroupMemberLastSyncAt) }}</span>
|
||
<span>上次刷新:{{ formatUnixTime(status.identityLastRefreshAt) }}</span>
|
||
<span>最近响应:{{ status.identityLastResponseType || '-' }} / {{ status.identityLastResponseCount || 0 }}</span>
|
||
<span>查询中:{{ status.identityLookupInFlight || 0 }}</span>
|
||
<span>账号作用域:{{ status.identityScope || '-' }}</span>
|
||
</div>
|
||
<div class="panel-actions">
|
||
<button class="ghost-btn" @click="saveConfig('identity')" :disabled="busy">保存配置</button>
|
||
<button class="ghost-btn" @click="refreshContacts" :disabled="busy || status.identityRefreshing">刷新联系人</button>
|
||
<button class="ghost-btn" @click="refreshGroups" :disabled="busy">刷新群列表</button>
|
||
<button class="ghost-btn" @click="syncInternalGroups" :disabled="busy || status.identityRefreshing || !form.identity.internalGroupConversationIds.length">同步内部成员群</button>
|
||
</div>
|
||
</section>
|
||
|
||
<section id="auto-section-records" class="auto-section">
|
||
<div class="section-header">
|
||
<div class="section-title">
|
||
<span class="section-index">06</span>
|
||
<h3>最近处理</h3>
|
||
</div>
|
||
<div class="section-meta">
|
||
<span>{{ (status.lastMessages || []).length }} 条记录</span>
|
||
<span>{{ recordPageSummary }}</span>
|
||
<span>今日回复 {{ status.todayReplied || 0 }}</span>
|
||
</div>
|
||
</div>
|
||
<div v-if="sectionAlerts('records').length" class="section-alerts">
|
||
<div v-for="alert in sectionAlerts('records')" :key="alert" class="section-alert">{{ alert }}</div>
|
||
</div>
|
||
<div class="record-table">
|
||
<div class="record-row header">
|
||
<span>时间</span>
|
||
<span>动作</span>
|
||
<span>来源</span>
|
||
<span>企微</span>
|
||
<span>客户</span>
|
||
<span>身份</span>
|
||
<span>问题</span>
|
||
<span>原因</span>
|
||
<span>名片</span>
|
||
<span>AI 耗时</span>
|
||
<span>检索分</span>
|
||
<span>来源</span>
|
||
</div>
|
||
<div v-for="record in pagedRecords" :key="record.id" class="record-row">
|
||
<span>{{ record.time }}</span>
|
||
<span>{{ actionLabel(record.action) }}</span>
|
||
<span>{{ record.source }}</span>
|
||
<span class="truncate">{{ formatRecordClient(record) }}</span>
|
||
<span>{{ record.fromNickName || record.fromWxId }}</span>
|
||
<span>{{ identityLabel(record.senderIdentity, record.identitySource) }}</span>
|
||
<span class="truncate">{{ record.question }}</span>
|
||
<span class="truncate">{{ record.reason }}</span>
|
||
<span class="truncate">{{ record.cardStatus || '-' }}</span>
|
||
<span>{{ formatDuration(record.aiDurationMs) }}</span>
|
||
<span>{{ formatScores(record) }}</span>
|
||
<span class="truncate">{{ record.usedKnowledgeSources || '-' }}</span>
|
||
</div>
|
||
<div v-if="!status.lastMessages || status.lastMessages.length === 0" class="empty">暂无处理记录</div>
|
||
</div>
|
||
<div v-if="recordTotalCount > 0" class="record-pagination">
|
||
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(1)" :disabled="recordCurrentPage <= 1">首页</button>
|
||
<button class="ghost-btn small-btn" type="button" @click="prevRecordPage" :disabled="recordCurrentPage <= 1">上一页</button>
|
||
<span>{{ recordPageSummary }},显示 {{ recordStartIndex }}-{{ recordEndIndex }} / {{ recordTotalCount }}</span>
|
||
<button class="ghost-btn small-btn" type="button" @click="nextRecordPage" :disabled="recordCurrentPage >= recordTotalPages">下一页</button>
|
||
<button class="ghost-btn small-btn" type="button" @click="goRecordPage(recordTotalPages)" :disabled="recordCurrentPage >= recordTotalPages">末页</button>
|
||
</div>
|
||
</section>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { computed, onMounted, onUnmounted, reactive, ref } from 'vue'
|
||
import {
|
||
GetAutoReplyConfig,
|
||
SaveAutoReplyConfig,
|
||
SetAutoReplyEnabled,
|
||
GetAutoReplyStatus,
|
||
RebuildKnowledgeIndex,
|
||
SyncAutoReplyMaterials,
|
||
RefreshAutoReplyContacts,
|
||
GetAutoReplyIdentityOptions,
|
||
RefreshAutoReplyGroups,
|
||
GetAutoReplyGroupOptions,
|
||
SyncAutoReplyInternalGroups,
|
||
TestAIConnection,
|
||
TestHumanHandoff,
|
||
SendWxWorkData,
|
||
LogFrontend
|
||
} from '../../wailsjs/go/main/App.js'
|
||
|
||
const busy = ref(false)
|
||
const message = ref('')
|
||
const messageType = ref('success')
|
||
const scopedMessages = ref({})
|
||
const status = ref({})
|
||
const showVisionAdvanced = ref(false)
|
||
const showAudioAdvanced = ref(false)
|
||
const identityOptions = ref({ internal: [], external: [] })
|
||
const groupOptions = ref([])
|
||
const internalIdentityQuery = ref('')
|
||
const externalIdentityQuery = ref('')
|
||
const syncedInternalQuery = ref('')
|
||
const syncedExternalQuery = ref('')
|
||
const internalGroupQuery = ref('')
|
||
const selectedInternalUserId = ref('')
|
||
const selectedExternalUserId = ref('')
|
||
const selectedInternalGroupId = ref('')
|
||
const handoffHumanQuery = ref('')
|
||
const handoffHumanDropdownOpen = ref(false)
|
||
const pendingHandoffHumanId = ref('')
|
||
const handoffHumanEditing = ref(false)
|
||
const identityLabelDrafts = reactive({ internal: {}, external: {} })
|
||
const recordCurrentPage = ref(1)
|
||
const recordPageSize = 20
|
||
const defaultVisionModel = 'qwen3-vl-plus'
|
||
let identityOptionsKey = ''
|
||
let currentIdentityScopeKey = ''
|
||
let timer = null
|
||
|
||
const form = reactive(defaultConfig())
|
||
const sectionNavItems = [
|
||
{ id: 'listen', label: '监听策略' },
|
||
{ id: 'ai', label: 'AI 配置' },
|
||
{ id: 'knowledge', label: '知识库' },
|
||
{ id: 'handoff', label: '人工接管' },
|
||
{ id: 'identity', label: '身份识别' },
|
||
{ id: 'records', label: '最近处理' }
|
||
]
|
||
const sectionScopes = new Set(sectionNavItems.map(item => item.id))
|
||
const scopedMessageTimers = {}
|
||
const defaultHandoffTemplateHint = `客户问题待处理
|
||
|
||
客户:{{customerName}}
|
||
客户ID:{{fromWxId}}
|
||
来源:私聊
|
||
时间:{{messageTime}}
|
||
问题:{{question}}
|
||
原因:{{reason}}
|
||
会话ID:{{conversationId}}
|
||
|
||
群聊问题待处理
|
||
|
||
群聊:{{groupName}}
|
||
客户:{{customerName}}
|
||
客户ID:{{fromWxId}}
|
||
来源:群聊
|
||
时间:{{messageTime}}
|
||
问题:{{question}}
|
||
原因:{{reason}}
|
||
会话ID:{{conversationId}}`
|
||
const reasonEntries = computed(() => {
|
||
return Object.entries(status.value.reasonCounts || {})
|
||
.filter(([, count]) => Number(count) > 0)
|
||
.map(([reason, count]) => ({ reason, count }))
|
||
.slice(0, 8)
|
||
})
|
||
const recordTotalCount = computed(() => (status.value.lastMessages || []).length)
|
||
const recordTotalPages = computed(() => Math.max(1, Math.ceil(recordTotalCount.value / recordPageSize)))
|
||
const recordStartIndex = computed(() => {
|
||
if (recordTotalCount.value <= 0) return 0
|
||
return (recordCurrentPage.value - 1) * recordPageSize + 1
|
||
})
|
||
const recordEndIndex = computed(() => Math.min(recordCurrentPage.value * recordPageSize, recordTotalCount.value))
|
||
const recordPageSummary = computed(() => {
|
||
if (recordTotalCount.value <= 0) return '第 0 / 0 页'
|
||
return `第 ${recordCurrentPage.value} / ${recordTotalPages.value} 页`
|
||
})
|
||
const pagedRecords = computed(() => {
|
||
const records = status.value.lastMessages || []
|
||
const start = (recordCurrentPage.value - 1) * recordPageSize
|
||
return records.slice(start, start + recordPageSize)
|
||
})
|
||
const manualTriggerKeywordsText = computed({
|
||
get() {
|
||
return (form.handoff.manualTriggerKeywords || []).join('、')
|
||
},
|
||
set(value) {
|
||
form.handoff.manualTriggerKeywords = splitKeywordText(value)
|
||
}
|
||
})
|
||
const internalFilteredOptions = computed(() => {
|
||
return filterIdentityOptions(identityOptions.value.internal || [], internalIdentityQuery.value, form.identity.internalUserIds || [])
|
||
})
|
||
const externalFilteredOptions = computed(() => {
|
||
return filterIdentityOptions(identityOptions.value.external || [], externalIdentityQuery.value, form.identity.externalUserIds || [])
|
||
})
|
||
const internalGroupFilteredOptions = computed(() => {
|
||
return filterGroupOptions(groupOptions.value || [], internalGroupQuery.value, form.identity.internalGroupConversationIds || [])
|
||
})
|
||
const internalSyncedContacts = computed(() => {
|
||
return filterSyncedIdentityOptions(identityOptions.value.internal || [], syncedInternalQuery.value)
|
||
})
|
||
const externalCustomerGroups = computed(() => {
|
||
return groupExternalContacts(identityOptions.value.external || [], syncedExternalQuery.value)
|
||
})
|
||
const handoffHumanOptions = computed(() => {
|
||
return filterHandoffHumanOptions(handoffHumanCandidateOptions.value, handoffHumanQuery.value)
|
||
})
|
||
const handoffHumanCandidateOptions = computed(() => {
|
||
return mergeIdentityOptionLists([
|
||
...(identityOptions.value.internal || []),
|
||
...(identityOptions.value.observed || [])
|
||
])
|
||
})
|
||
const handoffHumanSelectHint = computed(() => {
|
||
const allOptions = handoffHumanCandidateOptions.value
|
||
if (!allOptions.length) return '请先刷新联系人'
|
||
return handoffHumanOptions.value.length ? '选择同事' : '未同步到,请刷新联系人或同步内部群'
|
||
})
|
||
const pendingHandoffHuman = computed(() => findHandoffHumanOption(pendingHandoffHumanId.value))
|
||
const canConfirmHandoffHuman = computed(() => {
|
||
const pendingId = String(pendingHandoffHumanId.value || '').trim()
|
||
return pendingId && pendingId !== String(form.handoff.humanUserId || '').trim()
|
||
})
|
||
const selectedHandoffHumanSummary = computed(() => {
|
||
const pendingId = String(pendingHandoffHumanId.value || '').trim()
|
||
if (pendingId) {
|
||
const option = pendingHandoffHuman.value
|
||
const label = option ? identityOptionLabel(option) : pendingId
|
||
return `待确认:${label}`
|
||
}
|
||
const userId = String(form.handoff.humanUserId || '').trim()
|
||
if (!userId) return '选择后系统会自动保存同事账号,私聊会话由后台自动推导'
|
||
const option = findHandoffHumanOption(userId)
|
||
const name = String(option?.name || '').trim()
|
||
return name ? `已选择:${name} / ${userId}` : `已选择:${userId},后台会自动推导私聊会话`
|
||
})
|
||
const internalSelectedGroups = computed(() => selectedInternalGroups())
|
||
const internalSelectedContacts = computed(() => selectedIdentityContacts('internal'))
|
||
const externalSelectedContacts = computed(() => selectedIdentityContacts('external'))
|
||
const visionApiKeyLooksLikeUrl = computed(() => looksLikeUrl(form.ai?.visionApiKey || ''))
|
||
const audioApiKeyLooksLikeUrl = computed(() => looksLikeUrl(form.ai?.audioApiKey || ''))
|
||
const internalUserIdsText = computed({
|
||
get() {
|
||
return (form.identity.internalUserIds || []).join('\n')
|
||
},
|
||
set(value) {
|
||
setIdentityIDs('internal', splitIdText(value))
|
||
}
|
||
})
|
||
const externalUserIdsText = computed({
|
||
get() {
|
||
return (form.identity.externalUserIds || []).join('\n')
|
||
},
|
||
set(value) {
|
||
setIdentityIDs('external', splitIdText(value))
|
||
}
|
||
})
|
||
|
||
function knownSectionScope(scope) {
|
||
const id = String(scope || '').trim()
|
||
return sectionScopes.has(id) ? id : ''
|
||
}
|
||
|
||
function normalizeSectionScope(scope) {
|
||
return knownSectionScope(scope) || 'records'
|
||
}
|
||
|
||
function textIncludesAny(text, keywords) {
|
||
return keywords.some(keyword => text.includes(keyword))
|
||
}
|
||
|
||
function classifyErrorScope(text) {
|
||
const raw = String(text || '').trim()
|
||
if (!raw) return 'records'
|
||
const lower = raw.toLowerCase()
|
||
if (
|
||
textIncludesAny(raw, ['联系人身份', '身份查询', '未知身份拦截回复失败', '内部员工拦截回复失败']) ||
|
||
textIncludesAny(lower, ['identity'])
|
||
) {
|
||
return 'identity'
|
||
}
|
||
if (textIncludesAny(raw, ['AI请求失败', 'AI 请求失败', 'AI 测试失败']) || textIncludesAny(lower, ['ai '])) {
|
||
return 'ai'
|
||
}
|
||
if (textIncludesAny(raw, ['转人工发送失败', '测试私信失败', '人工名片', '客户名片', '客户说明', '人工客服'])) {
|
||
return 'handoff'
|
||
}
|
||
if (
|
||
textIncludesAny(raw, ['知识库', '知识索引', '向量召回', '重排序', '重建失败', '索引', '检索']) ||
|
||
textIncludesAny(lower, ['embedding', 'rerank'])
|
||
) {
|
||
return 'knowledge'
|
||
}
|
||
if (textIncludesAny(raw, ['开启失败', '关闭失败', '保存失败', '加载自动客服配置失败', '重载', '监听', '启动'])) {
|
||
return 'listen'
|
||
}
|
||
return 'records'
|
||
}
|
||
|
||
function classifyStatusErrorScope(text, scope) {
|
||
return knownSectionScope(scope) || classifyErrorScope(text)
|
||
}
|
||
|
||
function addUniqueAlert(alerts, text) {
|
||
const normalized = String(text || '').trim()
|
||
if (!normalized) return
|
||
const index = alerts.findIndex(item => item === normalized || item.includes(normalized) || normalized.includes(item))
|
||
if (index < 0) {
|
||
alerts.push(normalized)
|
||
} else if (normalized.length > alerts[index].length) {
|
||
alerts[index] = normalized
|
||
}
|
||
}
|
||
|
||
function sectionAlerts(sectionId) {
|
||
const scope = normalizeSectionScope(sectionId)
|
||
const alerts = []
|
||
addUniqueAlert(alerts, scopedMessages.value[scope])
|
||
if (scope === 'identity') {
|
||
addUniqueAlert(alerts, status.value.identityRefreshError)
|
||
addUniqueAlert(alerts, status.value.internalGroupMemberSyncError)
|
||
}
|
||
const lastError = String(status.value.lastError || '').trim()
|
||
if (lastError && classifyStatusErrorScope(lastError, status.value.lastErrorScope) === scope) {
|
||
addUniqueAlert(alerts, lastError)
|
||
}
|
||
return alerts
|
||
}
|
||
|
||
function setScopedMessage(scope, text) {
|
||
const target = normalizeSectionScope(scope)
|
||
const normalized = String(text || '').trim()
|
||
if (!normalized) return
|
||
scopedMessages.value = { ...scopedMessages.value, [target]: normalized }
|
||
if (scopedMessageTimers[target]) {
|
||
clearTimeout(scopedMessageTimers[target])
|
||
}
|
||
scopedMessageTimers[target] = setTimeout(() => {
|
||
if (scopedMessages.value[target] !== normalized) return
|
||
const next = { ...scopedMessages.value }
|
||
delete next[target]
|
||
scopedMessages.value = next
|
||
scopedMessageTimers[target] = null
|
||
}, 5000)
|
||
}
|
||
|
||
function defaultConfig() {
|
||
return {
|
||
enabled: false,
|
||
listen: {
|
||
enablePrivateChat: true,
|
||
enableGroupChat: true,
|
||
groupTriggerMode: 'mention_only',
|
||
ignoreSelfMessage: true,
|
||
deduplicateSeconds: 300
|
||
},
|
||
knowledge: {
|
||
directory: 'config/knowledge',
|
||
indexPath: 'config/knowledge/index.json',
|
||
supportedExtensions: ['.md', '.txt', '.csv', '.xlsx', '.docx', '.pdf'],
|
||
topK: 8,
|
||
minScore: 0.40,
|
||
autoRebuildOnStart: false
|
||
},
|
||
retrieval: {
|
||
retrievalMode: 'hybrid_rerank',
|
||
embeddingIndexPath: 'config/knowledge/embedding_index.json',
|
||
embeddingModel: 'text-embedding-v4',
|
||
embeddingDimensions: 512,
|
||
rerankModel: 'qwen3-rerank',
|
||
recallTopK: 50,
|
||
rerankTopK: 30,
|
||
finalTopK: 8
|
||
},
|
||
materials: {
|
||
directory: 'config/materials',
|
||
indexPath: 'config/materials/materials.json',
|
||
autoSendEnabled: true,
|
||
maxPerReply: 2
|
||
},
|
||
collaboration: {
|
||
enabled: false,
|
||
humanWaitSeconds: 180,
|
||
afterHumanReplyDelaySeconds: 3,
|
||
takeoverIdleExitSeconds: 300,
|
||
supplementTarget: 'customer',
|
||
engineerReturnPolicy: 'review'
|
||
},
|
||
replyStyle: 'natural_professional',
|
||
ai: {
|
||
provider: 'openai_compatible',
|
||
baseUrl: '',
|
||
apiKey: '',
|
||
model: 'qwen-turbo',
|
||
systemPrompt: '你是一名企业微信智能客服。',
|
||
visionModel: defaultVisionModel,
|
||
visionBaseUrl: '',
|
||
visionApiKey: '',
|
||
audioProvider: 'auto',
|
||
audioMode: 'openai_audio_chat',
|
||
audioModel: 'qwen3-asr-flash',
|
||
audioBaseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||
audioApiKey: '',
|
||
timeoutSeconds: 20,
|
||
enableThinking: false,
|
||
replyDetail: 'detailed',
|
||
temperature: 0,
|
||
maxTokens: 700
|
||
},
|
||
handoff: {
|
||
humanUserId: '',
|
||
humanConversationId: '',
|
||
messageTemplate: '',
|
||
customerHandoffNotice: '已为您通知人工客服添加您的好友,请稍等。若 2 分钟内仍未收到好友申请,请点击上方名片主动添加人工客服。',
|
||
includeKnowledgeHits: true,
|
||
sendHumanCardToCustomer: true,
|
||
sendCustomerCardToHuman: true,
|
||
cardTriggerMode: 'manual_keywords',
|
||
manualTriggerKeywords: ['人工', '客服', '转人工', '人工客服', '真人', '真人客服'],
|
||
cardKeywords: ['人工', '客服', '转人工', '人工客服', '真人', '真人客服']
|
||
},
|
||
identity: {
|
||
unknownPolicy: 'customer',
|
||
unknownHandoffPolicy: 'hold',
|
||
refreshOnStart: true,
|
||
refreshIntervalMinutes: 30,
|
||
pageSize: 200,
|
||
internalNoHandoffReply: '内部员工消息不触发转人工,如需协助请直接联系对应同事。',
|
||
unknownNoHandoffReply: '正在核验联系人身份,暂不触发转人工。如需协助请直接联系对应同事。',
|
||
internalUserIds: [],
|
||
externalUserIds: [],
|
||
internalGroupConversationIds: [],
|
||
internalGroupConversationIdsByScope: {},
|
||
internalUserLabels: {},
|
||
externalUserLabels: {}
|
||
},
|
||
replyPolicy: {
|
||
unknownAnswerToken: 'NO_ANSWER',
|
||
maxQuestionLength: 1000,
|
||
cooldownSeconds: 3,
|
||
sensitiveKeywords: ['人工', '客服', '投诉', '退款', '退货', '合同', '发票', '赔偿', '价格审批']
|
||
}
|
||
}
|
||
}
|
||
|
||
function splitIdText(value) {
|
||
return String(value || '')
|
||
.split(/[,\n,、;\s]+/)
|
||
.map(item => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function splitKeywordText(value) {
|
||
return String(value || '')
|
||
.split(/[,\n,、;\s]+/)
|
||
.map(item => item.trim())
|
||
.filter(Boolean)
|
||
}
|
||
|
||
function uniqueIds(items) {
|
||
const seen = new Set()
|
||
const result = []
|
||
for (const item of items || []) {
|
||
const id = String(item || '').trim()
|
||
if (!id || seen.has(id)) continue
|
||
seen.add(id)
|
||
result.push(id)
|
||
}
|
||
return result
|
||
}
|
||
|
||
function robotUserIds() {
|
||
return uniqueIds(status.value?.robotUserIds || [])
|
||
}
|
||
|
||
function isRobotUserId(userId) {
|
||
const id = String(userId || '').trim()
|
||
return Boolean(id && robotUserIds().includes(id))
|
||
}
|
||
|
||
function uniqueNonRobotIds(items) {
|
||
return uniqueIds(items).filter(id => !isRobotUserId(id))
|
||
}
|
||
|
||
function sanitizeConfiguredIdentityIDs() {
|
||
form.identity.internalUserIds = uniqueNonRobotIds(form.identity.internalUserIds || [])
|
||
form.identity.externalUserIds = uniqueNonRobotIds(form.identity.externalUserIds || [])
|
||
for (const labels of [form.identity.internalUserLabels, form.identity.externalUserLabels]) {
|
||
if (!labels) continue
|
||
for (const userId of robotUserIds()) {
|
||
delete labels[userId]
|
||
}
|
||
}
|
||
}
|
||
|
||
function internalGroupIDs() {
|
||
return form.identity.internalGroupConversationIds || []
|
||
}
|
||
|
||
function setInternalGroupIDs(ids) {
|
||
const next = uniqueIds(ids)
|
||
form.identity.internalGroupConversationIds = next
|
||
const scope = activeIdentityScope()
|
||
if (scope) {
|
||
if (!form.identity.internalGroupConversationIdsByScope || Array.isArray(form.identity.internalGroupConversationIdsByScope)) {
|
||
form.identity.internalGroupConversationIdsByScope = {}
|
||
}
|
||
form.identity.internalGroupConversationIdsByScope[scope] = next
|
||
}
|
||
}
|
||
|
||
function activeIdentityScope() {
|
||
return String(status.value?.identityScope || '').trim()
|
||
}
|
||
|
||
function syncInternalGroupsForCurrentScope() {
|
||
const scope = activeIdentityScope()
|
||
if (!scope || currentIdentityScopeKey === scope) return
|
||
if (!form.identity.internalGroupConversationIdsByScope || Array.isArray(form.identity.internalGroupConversationIdsByScope)) {
|
||
form.identity.internalGroupConversationIdsByScope = {}
|
||
}
|
||
const scoped = uniqueIds(form.identity.internalGroupConversationIdsByScope[scope] || [])
|
||
const legacy = uniqueIds(form.identity.internalGroupConversationIds || [])
|
||
if (!scoped.length && legacy.length && !form.identity.internalGroupConversationIdsByScope[scope]) {
|
||
form.identity.internalGroupConversationIdsByScope[scope] = legacy
|
||
form.identity.internalGroupConversationIds = legacy
|
||
} else {
|
||
form.identity.internalGroupConversationIds = scoped
|
||
}
|
||
currentIdentityScopeKey = scope
|
||
}
|
||
|
||
function commitInternalGroupsForCurrentScope() {
|
||
const scope = activeIdentityScope()
|
||
if (!scope) return
|
||
if (!form.identity.internalGroupConversationIdsByScope || Array.isArray(form.identity.internalGroupConversationIdsByScope)) {
|
||
form.identity.internalGroupConversationIdsByScope = {}
|
||
}
|
||
form.identity.internalGroupConversationIdsByScope[scope] = uniqueIds(form.identity.internalGroupConversationIds || [])
|
||
}
|
||
|
||
function identityIDs(kind) {
|
||
return kind === 'internal' ? (form.identity.internalUserIds || []) : (form.identity.externalUserIds || [])
|
||
}
|
||
|
||
function identityLabels(kind) {
|
||
ensureIdentityLabelMaps()
|
||
return kind === 'internal' ? form.identity.internalUserLabels : form.identity.externalUserLabels
|
||
}
|
||
|
||
function identityDraftMap(kind) {
|
||
return kind === 'internal' ? identityLabelDrafts.internal : identityLabelDrafts.external
|
||
}
|
||
|
||
function hasIdentityLabelDraft(kind, userId) {
|
||
return Object.prototype.hasOwnProperty.call(identityDraftMap(kind), userId)
|
||
}
|
||
|
||
function identityOptionsFor(kind) {
|
||
return kind === 'internal' ? (identityOptions.value.internal || []) : (identityOptions.value.external || [])
|
||
}
|
||
|
||
function ensureIdentityLabelMaps() {
|
||
if (!form.identity.internalUserLabels || Array.isArray(form.identity.internalUserLabels)) {
|
||
form.identity.internalUserLabels = {}
|
||
}
|
||
if (!form.identity.externalUserLabels || Array.isArray(form.identity.externalUserLabels)) {
|
||
form.identity.externalUserLabels = {}
|
||
}
|
||
}
|
||
|
||
function findIdentityOption(kind, userId) {
|
||
return identityOptionsFor(kind).find(item => String(item.userId || '').trim() === userId)
|
||
}
|
||
|
||
function findHandoffHumanOption(userId) {
|
||
const id = String(userId || '').trim()
|
||
if (!id) return null
|
||
return handoffHumanCandidateOptions.value.find(item => String(item.userId || '').trim() === id) || null
|
||
}
|
||
|
||
function syncHandoffHumanQueryFromSelection(force = false) {
|
||
if (!force && (handoffHumanEditing.value || pendingHandoffHumanId.value)) {
|
||
return
|
||
}
|
||
const userId = String(form.handoff?.humanUserId || '').trim()
|
||
if (!userId) {
|
||
handoffHumanQuery.value = ''
|
||
return
|
||
}
|
||
const option = findHandoffHumanOption(userId)
|
||
handoffHumanQuery.value = option ? identityOptionLabel(option) : userId
|
||
}
|
||
|
||
function identityQuery(kind) {
|
||
return String(kind === 'internal' ? internalIdentityQuery.value : externalIdentityQuery.value || '').trim()
|
||
}
|
||
|
||
function selectedIdentityId(kind) {
|
||
return String(kind === 'internal' ? selectedInternalUserId.value : selectedExternalUserId.value || '').trim()
|
||
}
|
||
|
||
function filteredIdentityOptions(kind) {
|
||
return kind === 'internal' ? internalFilteredOptions.value : externalFilteredOptions.value
|
||
}
|
||
|
||
function pendingIdentityOption(kind) {
|
||
const selectedId = selectedIdentityId(kind)
|
||
if (selectedId) return findIdentityOption(kind, selectedId)
|
||
|
||
const query = identityQuery(kind)
|
||
if (!query) return null
|
||
|
||
const queryLower = query.toLowerCase()
|
||
const exact = identityOptionsFor(kind).find(item => {
|
||
const userId = String(item.userId || '').trim()
|
||
const name = String(item.name || '').trim()
|
||
return userId.toLowerCase() === queryLower || name.toLowerCase() === queryLower
|
||
})
|
||
if (exact) return exact
|
||
|
||
const matches = filteredIdentityOptions(kind)
|
||
return matches.length === 1 ? matches[0] : null
|
||
}
|
||
|
||
function pendingIdentityId(kind) {
|
||
const selectedId = selectedIdentityId(kind)
|
||
if (selectedId) return selectedId
|
||
const option = pendingIdentityOption(kind)
|
||
return String(option?.userId || identityQuery(kind)).trim()
|
||
}
|
||
|
||
function canAddIdentity(kind) {
|
||
const userId = pendingIdentityId(kind)
|
||
return Boolean(userId && !identityIDs(kind).includes(userId))
|
||
}
|
||
|
||
function clearIdentityPicker(kind) {
|
||
if (kind === 'internal') {
|
||
selectedInternalUserId.value = ''
|
||
internalIdentityQuery.value = ''
|
||
} else {
|
||
selectedExternalUserId.value = ''
|
||
externalIdentityQuery.value = ''
|
||
}
|
||
}
|
||
|
||
function setIdentityIDs(kind, ids) {
|
||
const next = uniqueNonRobotIds(ids)
|
||
if (kind === 'internal') {
|
||
form.identity.internalUserIds = next
|
||
} else {
|
||
form.identity.externalUserIds = next
|
||
}
|
||
syncIdentityLabels(kind)
|
||
}
|
||
|
||
function syncIdentityLabels(kind) {
|
||
ensureIdentityLabelMaps()
|
||
const ids = new Set(identityIDs(kind))
|
||
const current = identityLabels(kind)
|
||
const next = {}
|
||
for (const userId of ids) {
|
||
const cached = findIdentityOption(kind, userId)
|
||
const savedLabel = String(current[userId] || '').trim()
|
||
const cachedLabel = hasIdentityLabelDraft(kind, userId) ? '' : String(cached?.name || '').trim()
|
||
const label = savedLabel || cachedLabel
|
||
if (label) next[userId] = label
|
||
}
|
||
if (kind === 'internal') {
|
||
form.identity.internalUserLabels = next
|
||
} else {
|
||
form.identity.externalUserLabels = next
|
||
}
|
||
}
|
||
|
||
function syncAllIdentityLabels() {
|
||
syncIdentityLabels('internal')
|
||
syncIdentityLabels('external')
|
||
}
|
||
|
||
function filterIdentityOptions(options, query, selectedIds) {
|
||
const selected = new Set(uniqueIds(selectedIds))
|
||
const q = String(query || '').trim().toLowerCase()
|
||
return (options || [])
|
||
.filter(item => {
|
||
const userId = String(item.userId || '').trim()
|
||
if (!userId || selected.has(userId) || isRobotUserId(userId)) return false
|
||
if (!q) return true
|
||
const name = String(item.name || '').toLowerCase()
|
||
return userId.toLowerCase().includes(q) || name.includes(q)
|
||
})
|
||
.slice(0, 80)
|
||
}
|
||
|
||
function filterHandoffHumanOptions(options, query) {
|
||
const q = String(query || '').trim().toLowerCase()
|
||
const selectedId = String(form.handoff?.humanUserId || '').trim()
|
||
return (options || [])
|
||
.map(item => ({ item, score: handoffHumanMatchScore(item, q, selectedId) }))
|
||
.filter(entry => entry.score >= 0)
|
||
.sort((a, b) => {
|
||
if (a.score !== b.score) return b.score - a.score
|
||
return identityOptionName(a.item).localeCompare(identityOptionName(b.item), 'zh-Hans-CN')
|
||
})
|
||
.map(entry => entry.item)
|
||
.slice(0, 80)
|
||
}
|
||
|
||
function handoffHumanMatchScore(item, query, selectedId) {
|
||
const userId = String(item.userId || '').trim()
|
||
if (!userId || isRobotUserId(userId)) return -1
|
||
let score = userId === selectedId ? 20 : 0
|
||
if (!query) return score
|
||
const name = String(item.name || '').toLowerCase()
|
||
const source = String(item.source || '').toLowerCase()
|
||
const accountName = String(item.sourceAccountName || '').toLowerCase()
|
||
const accountUserId = String(item.sourceAccountUserId || '').toLowerCase()
|
||
const clientId = String(item.clientId || '').toLowerCase()
|
||
const userIdLower = userId.toLowerCase()
|
||
const fields = [name, userIdLower, source, accountName, accountUserId, clientId].filter(Boolean)
|
||
if (name === query || userIdLower === query) return score + 100
|
||
if (fields.some(field => field.startsWith(query))) return score + 80
|
||
if (fields.some(field => field.includes(query))) return score + 60
|
||
if (fields.some(field => fuzzyIncludes(field, query))) return score + 35
|
||
return -1
|
||
}
|
||
|
||
function fuzzyIncludes(text, query) {
|
||
const target = String(text || '').toLowerCase()
|
||
const pattern = String(query || '').toLowerCase()
|
||
if (!target || !pattern) return false
|
||
let pos = 0
|
||
for (const ch of pattern) {
|
||
pos = target.indexOf(ch, pos)
|
||
if (pos < 0) return false
|
||
pos += ch.length
|
||
}
|
||
return true
|
||
}
|
||
|
||
function mergeIdentityOptionLists(items) {
|
||
const byUserId = new Map()
|
||
for (const item of items || []) {
|
||
const userId = String(item?.userId || '').trim()
|
||
if (!userId || isRobotUserId(userId)) continue
|
||
const current = byUserId.get(userId)
|
||
if (!current || shouldReplaceIdentityOption(current, item)) {
|
||
byUserId.set(userId, item)
|
||
}
|
||
}
|
||
return [...byUserId.values()]
|
||
}
|
||
|
||
function shouldReplaceIdentityOption(current, next) {
|
||
const currentName = String(current?.name || '').trim()
|
||
const nextName = String(next?.name || '').trim()
|
||
if (!currentName && nextName) return true
|
||
const rank = source => ({
|
||
internal_cache: 5,
|
||
internal_group_member: 4,
|
||
configured_human: 4,
|
||
single_info: 3,
|
||
observed_message: 2
|
||
}[String(source || '').trim()] || 1)
|
||
return rank(next?.source) > rank(current?.source)
|
||
}
|
||
|
||
function filterSyncedIdentityOptions(options, query) {
|
||
const q = String(query || '').trim().toLowerCase()
|
||
return (options || [])
|
||
.filter(item => {
|
||
const userId = String(item.userId || '').trim()
|
||
if (!userId || isRobotUserId(userId)) return false
|
||
if (!q) return true
|
||
const name = String(item.name || '').toLowerCase()
|
||
const source = String(item.source || '').toLowerCase()
|
||
const accountName = String(item.sourceAccountName || '').toLowerCase()
|
||
const accountUserId = String(item.sourceAccountUserId || '').toLowerCase()
|
||
const clientId = String(item.clientId || '').toLowerCase()
|
||
return userId.toLowerCase().includes(q) ||
|
||
name.includes(q) ||
|
||
source.includes(q) ||
|
||
accountName.includes(q) ||
|
||
accountUserId.includes(q) ||
|
||
clientId.includes(q)
|
||
})
|
||
.slice(0, 200)
|
||
}
|
||
|
||
function groupExternalContacts(options, query) {
|
||
const groups = new Map()
|
||
for (const item of filterSyncedIdentityOptions(options, query)) {
|
||
const key = sourceAccountKey(item)
|
||
if (!groups.has(key)) {
|
||
groups.set(key, { key, label: sourceAccountLabelFromItem(item), count: 0, items: [] })
|
||
}
|
||
const group = groups.get(key)
|
||
group.items.push(item)
|
||
group.count += 1
|
||
}
|
||
return [...groups.values()]
|
||
.map(group => ({
|
||
...group,
|
||
items: group.items.sort(compareIdentityOptions)
|
||
}))
|
||
.sort((a, b) => {
|
||
if (b.count !== a.count) return b.count - a.count
|
||
return a.label.localeCompare(b.label)
|
||
})
|
||
}
|
||
|
||
function sourceAccountKey(item) {
|
||
const userId = String(item?.sourceAccountUserId || '').trim()
|
||
if (userId) return `user:${userId}`
|
||
const clientId = Number(item?.clientId || 0)
|
||
if (clientId) return `client:${clientId}`
|
||
return 'unknown'
|
||
}
|
||
|
||
function sourceAccountLabelFromItem(item) {
|
||
const name = String(item?.sourceAccountName || '').trim()
|
||
if (name && !/^client\s+\d+$/i.test(name)) return name
|
||
const userId = String(item?.sourceAccountUserId || '').trim()
|
||
if (userId) return userId
|
||
const clientId = Number(item?.clientId || 0)
|
||
if (clientId) return `client ${clientId}`
|
||
const scope = String(item?.scope || '').trim()
|
||
if (scope) return scope
|
||
return '账号待识别'
|
||
}
|
||
|
||
function externalContactRowKey(item) {
|
||
return `${Number(item?.clientId || 0)}:${String(item?.userId || '').trim()}`
|
||
}
|
||
|
||
function compareIdentityOptions(a, b) {
|
||
const leftName = String(a?.name || '').trim().toLowerCase()
|
||
const rightName = String(b?.name || '').trim().toLowerCase()
|
||
if (leftName && rightName && leftName !== rightName) return leftName.localeCompare(rightName)
|
||
if (leftName && !rightName) return -1
|
||
if (!leftName && rightName) return 1
|
||
return String(a?.userId || '').localeCompare(String(b?.userId || ''))
|
||
}
|
||
|
||
function totalGroupCandidates(options) {
|
||
const usable = (options || []).filter(item => {
|
||
const conversationId = String(item.conversationId || '').trim()
|
||
return conversationId && Number(item.memberCount || 0) !== 1
|
||
})
|
||
const groupsByClient = new Map()
|
||
for (const item of usable) {
|
||
const clientKey = String(Number(item.clientId || 0))
|
||
if (!groupsByClient.has(clientKey)) groupsByClient.set(clientKey, [])
|
||
groupsByClient.get(clientKey).push(item)
|
||
}
|
||
const result = []
|
||
for (const items of groupsByClient.values()) {
|
||
const maxMembers = items.reduce((max, item) => Math.max(max, Number(item.memberCount || 0)), 0)
|
||
result.push(...(maxMembers <= 0 ? items : items.filter(item => Number(item.memberCount || 0) === maxMembers)))
|
||
}
|
||
return result
|
||
}
|
||
|
||
function filterGroupOptions(options, query, selectedIds) {
|
||
const selected = new Set(uniqueIds(selectedIds))
|
||
const q = String(query || '').trim().toLowerCase()
|
||
return totalGroupCandidates(options)
|
||
.filter(item => {
|
||
const conversationId = String(item.conversationId || '').trim()
|
||
if (!conversationId || selected.has(conversationId)) return false
|
||
if (!q) return true
|
||
const name = String(item.name || '').toLowerCase()
|
||
return conversationId.toLowerCase().includes(q) || name.includes(q)
|
||
})
|
||
.slice(0, 80)
|
||
}
|
||
|
||
function findGroupOption(conversationId) {
|
||
return (groupOptions.value || []).find(item => String(item.conversationId || '').trim() === conversationId)
|
||
}
|
||
|
||
function selectedInternalGroups() {
|
||
return uniqueIds(internalGroupIDs()).map(conversationId => {
|
||
const cached = findGroupOption(conversationId)
|
||
return {
|
||
conversationId,
|
||
name: String(cached?.name || '').trim(),
|
||
source: String(cached?.source || '').trim() || 'group_list'
|
||
}
|
||
})
|
||
}
|
||
|
||
const canAddInternalGroup = computed(() => {
|
||
const id = String(selectedInternalGroupId.value || internalGroupQuery.value || '').trim()
|
||
return Boolean(id && !internalGroupIDs().includes(id))
|
||
})
|
||
|
||
function addSelectedInternalGroup() {
|
||
const id = String(selectedInternalGroupId.value || internalGroupQuery.value || '').trim()
|
||
if (!id || internalGroupIDs().includes(id)) return
|
||
setInternalGroupIDs([...internalGroupIDs(), id])
|
||
selectedInternalGroupId.value = ''
|
||
internalGroupQuery.value = ''
|
||
}
|
||
|
||
function removeInternalGroup(conversationId) {
|
||
setInternalGroupIDs(internalGroupIDs().filter(item => item !== conversationId))
|
||
}
|
||
|
||
function groupOptionLabel(item) {
|
||
const name = String(item?.name || '').trim() || '未命名群聊'
|
||
return `${name} / ${item.conversationId}`
|
||
}
|
||
|
||
function groupSelectHint() {
|
||
if (!groupOptions.value.length) return '请先刷新群列表'
|
||
return internalGroupFilteredOptions.value.length ? '选择总群' : '没有匹配的群'
|
||
}
|
||
|
||
function selectedIdentityContacts(kind) {
|
||
const labels = identityLabels(kind)
|
||
return uniqueIds(identityIDs(kind)).map(userId => {
|
||
const cached = findIdentityOption(kind, userId)
|
||
const cachedName = String(cached?.name || '').trim()
|
||
const label = String(labels[userId] || '').trim()
|
||
const hasDraft = hasIdentityLabelDraft(kind, userId)
|
||
const name = label || (hasDraft ? '' : cachedName)
|
||
return {
|
||
userId,
|
||
name,
|
||
source: cached?.source || (label ? 'manual_label' : 'manual'),
|
||
needsLabel: hasDraft || !name
|
||
}
|
||
})
|
||
}
|
||
|
||
function identityOptionLabel(item) {
|
||
const name = String(item?.name || '').trim() || '未命名联系人'
|
||
return `${name} / ${item.userId}`
|
||
}
|
||
|
||
function identityOptionName(item) {
|
||
return String(item?.name || '').trim() || String(item?.userId || '').trim() || '未命名联系人'
|
||
}
|
||
|
||
function setHandoffHuman(userId) {
|
||
const id = String(userId || '').trim()
|
||
form.handoff.humanUserId = id
|
||
form.handoff.humanConversationId = ''
|
||
const option = findHandoffHumanOption(id)
|
||
handoffHumanQuery.value = option ? identityOptionLabel(option) : id
|
||
pendingHandoffHumanId.value = ''
|
||
handoffHumanEditing.value = false
|
||
handoffHumanDropdownOpen.value = false
|
||
}
|
||
|
||
function clearHandoffHuman() {
|
||
form.handoff.humanUserId = ''
|
||
form.handoff.humanConversationId = ''
|
||
handoffHumanQuery.value = ''
|
||
pendingHandoffHumanId.value = ''
|
||
handoffHumanEditing.value = false
|
||
handoffHumanDropdownOpen.value = false
|
||
}
|
||
|
||
function startHandoffHumanEditing() {
|
||
handoffHumanEditing.value = true
|
||
handoffHumanDropdownOpen.value = true
|
||
}
|
||
|
||
function closeHandoffHumanDropdown() {
|
||
handoffHumanDropdownOpen.value = false
|
||
}
|
||
|
||
function handleHandoffHumanInput() {
|
||
handoffHumanEditing.value = true
|
||
pendingHandoffHumanId.value = ''
|
||
handoffHumanDropdownOpen.value = true
|
||
}
|
||
|
||
function previewHandoffHuman(item) {
|
||
const id = String(item?.userId || '').trim()
|
||
if (!id) return
|
||
pendingHandoffHumanId.value = id
|
||
handoffHumanQuery.value = identityOptionLabel(item)
|
||
handoffHumanEditing.value = true
|
||
handoffHumanDropdownOpen.value = false
|
||
}
|
||
|
||
function previewFirstHandoffHuman() {
|
||
if (handoffHumanOptions.value.length > 0) {
|
||
previewHandoffHuman(handoffHumanOptions.value[0])
|
||
}
|
||
}
|
||
|
||
function confirmHandoffHuman() {
|
||
const id = String(pendingHandoffHumanId.value || '').trim()
|
||
if (!id) return
|
||
setHandoffHuman(id)
|
||
}
|
||
|
||
function identitySelectHint(kind) {
|
||
const options = kind === 'internal' ? internalFilteredOptions.value : externalFilteredOptions.value
|
||
const allOptions = identityOptionsFor(kind)
|
||
if (!allOptions.length) return '请先刷新联系人'
|
||
return options.length ? '选择联系人' : '没有匹配的联系人'
|
||
}
|
||
|
||
function identitySourceLabel(source) {
|
||
return {
|
||
internal_cache: '企微联系人',
|
||
external_cache: '企微联系人',
|
||
observed_message: '消息观察',
|
||
internal_group_member: '内部成员群',
|
||
single_info: '单人查询',
|
||
manual_label: '手动备注',
|
||
manual: '手动输入'
|
||
}[source] || source || '手动输入'
|
||
}
|
||
|
||
function identityManualLabel(kind, userId) {
|
||
return identityLabels(kind)[userId] || ''
|
||
}
|
||
|
||
function identityManualLabelDraft(kind, userId) {
|
||
const drafts = identityDraftMap(kind)
|
||
if (hasIdentityLabelDraft(kind, userId)) {
|
||
return drafts[userId]
|
||
}
|
||
return identityManualLabel(kind, userId)
|
||
}
|
||
|
||
function setIdentityLabelDraft(kind, userId, value) {
|
||
identityDraftMap(kind)[userId] = String(value || '')
|
||
}
|
||
|
||
function clearIdentityLabelDraft(kind, userId) {
|
||
delete identityDraftMap(kind)[userId]
|
||
}
|
||
|
||
function commitIdentityLabelDraft(kind, userId) {
|
||
const text = String(identityManualLabelDraft(kind, userId) || '').trim()
|
||
setIdentityLabel(kind, userId, text)
|
||
clearIdentityLabelDraft(kind, userId)
|
||
}
|
||
|
||
function commitAllIdentityLabelDrafts() {
|
||
for (const kind of ['internal', 'external']) {
|
||
const ids = new Set(identityIDs(kind))
|
||
for (const userId of Object.keys(identityDraftMap(kind))) {
|
||
if (ids.has(userId)) {
|
||
commitIdentityLabelDraft(kind, userId)
|
||
} else {
|
||
clearIdentityLabelDraft(kind, userId)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
function setIdentityLabel(kind, userId, value) {
|
||
ensureIdentityLabelMaps()
|
||
const labels = { ...identityLabels(kind) }
|
||
const text = String(value || '').trim()
|
||
if (text) {
|
||
labels[userId] = text
|
||
} else {
|
||
delete labels[userId]
|
||
}
|
||
if (kind === 'internal') {
|
||
form.identity.internalUserLabels = labels
|
||
} else {
|
||
form.identity.externalUserLabels = labels
|
||
}
|
||
syncIdentityLabels(kind)
|
||
}
|
||
|
||
function addSelectedIdentity(kind) {
|
||
const userId = pendingIdentityId(kind)
|
||
if (!userId) return
|
||
const option = pendingIdentityOption(kind) || findIdentityOption(kind, userId)
|
||
setIdentityIDs(kind, [...identityIDs(kind), userId])
|
||
if (option?.name) {
|
||
setIdentityLabel(kind, userId, option.name)
|
||
clearIdentityLabelDraft(kind, userId)
|
||
}
|
||
clearIdentityPicker(kind)
|
||
}
|
||
|
||
function removeIdentity(kind, userId) {
|
||
clearIdentityLabelDraft(kind, userId)
|
||
setIdentityIDs(kind, identityIDs(kind).filter(item => item !== userId))
|
||
}
|
||
|
||
function clampRecordPage() {
|
||
if (recordTotalCount.value <= 0) {
|
||
recordCurrentPage.value = 1
|
||
return
|
||
}
|
||
if (recordCurrentPage.value < 1) recordCurrentPage.value = 1
|
||
if (recordCurrentPage.value > recordTotalPages.value) recordCurrentPage.value = recordTotalPages.value
|
||
}
|
||
|
||
function goRecordPage(page) {
|
||
recordCurrentPage.value = Number(page) || 1
|
||
clampRecordPage()
|
||
}
|
||
|
||
function prevRecordPage() {
|
||
goRecordPage(recordCurrentPage.value - 1)
|
||
}
|
||
|
||
function nextRecordPage() {
|
||
goRecordPage(recordCurrentPage.value + 1)
|
||
}
|
||
|
||
function normalizeIdentityOptions(data) {
|
||
return {
|
||
internal: normalizeIdentityOptionList(data?.internal),
|
||
external: normalizeIdentityOptionList(data?.external),
|
||
observed: normalizeIdentityOptionList(data?.observed)
|
||
}
|
||
}
|
||
|
||
function normalizeGroupOptions(items) {
|
||
return (Array.isArray(items) ? items : [])
|
||
.map(item => ({
|
||
conversationId: String(item?.conversationId || '').trim(),
|
||
name: String(item?.name || '').trim(),
|
||
source: String(item?.source || '').trim(),
|
||
clientId: Number(item?.clientId || 0),
|
||
lastSeenAt: Number(item?.lastSeenAt || 0),
|
||
memberCount: Number(item?.memberCount || 0)
|
||
}))
|
||
.filter(item => item.conversationId && item.memberCount !== 1)
|
||
}
|
||
|
||
function normalizeIdentityOptionList(items) {
|
||
return (Array.isArray(items) ? items : [])
|
||
.map(item => ({
|
||
userId: String(item?.userId || '').trim(),
|
||
name: String(item?.name || '').trim(),
|
||
source: String(item?.source || '').trim(),
|
||
clientId: Number(item?.clientId || 0),
|
||
scope: String(item?.scope || '').trim(),
|
||
lastSeenAt: Number(item?.lastSeenAt || 0),
|
||
sourceAccountUserId: String(item?.sourceAccountUserId || '').trim(),
|
||
sourceAccountName: String(item?.sourceAccountName || '').trim()
|
||
}))
|
||
.filter(item => item.userId && !isRobotUserId(item.userId))
|
||
}
|
||
|
||
function cleanupIdentityLabels() {
|
||
commitInternalGroupsForCurrentScope()
|
||
sanitizeConfiguredIdentityIDs()
|
||
form.handoff.humanConversationId = ''
|
||
syncAllIdentityLabels()
|
||
}
|
||
|
||
function normalizeHandoffBeforeSave() {
|
||
if (!form.handoff) return
|
||
form.handoff.manualTriggerKeywords = uniqueIds(form.handoff.manualTriggerKeywords || [])
|
||
if (!form.handoff.manualTriggerKeywords.length) {
|
||
form.handoff.manualTriggerKeywords = uniqueIds(defaultConfig().handoff.manualTriggerKeywords || [])
|
||
}
|
||
form.handoff.cardKeywords = [...form.handoff.manualTriggerKeywords]
|
||
form.handoff.cardTriggerMode = 'manual_keywords'
|
||
}
|
||
|
||
function mergeForm(cfg) {
|
||
const defaults = defaultConfig()
|
||
Object.assign(form, defaults, cfg || {})
|
||
form.listen = Object.assign(defaults.listen, cfg?.listen || {})
|
||
form.knowledge = Object.assign(defaults.knowledge, cfg?.knowledge || {})
|
||
form.retrieval = Object.assign(defaults.retrieval, cfg?.retrieval || {})
|
||
form.materials = Object.assign(defaults.materials, cfg?.materials || {})
|
||
form.collaboration = Object.assign(defaults.collaboration, cfg?.collaboration || {})
|
||
form.ai = Object.assign(defaults.ai, cfg?.ai || {})
|
||
form.replyStyle = cfg?.replyStyle || defaults.replyStyle || 'natural_professional'
|
||
normalizeVisionModelConfig()
|
||
form.handoff = Object.assign(defaults.handoff, cfg?.handoff || {})
|
||
if (!Array.isArray(form.handoff.manualTriggerKeywords) || form.handoff.manualTriggerKeywords.length === 0) {
|
||
form.handoff.manualTriggerKeywords = uniqueIds([
|
||
...(defaults.handoff.manualTriggerKeywords || []),
|
||
...(form.handoff.cardKeywords || [])
|
||
])
|
||
}
|
||
form.handoff.manualTriggerKeywords = uniqueIds([
|
||
...(form.handoff.manualTriggerKeywords || []),
|
||
...(form.handoff.cardKeywords || [])
|
||
])
|
||
form.handoff.cardKeywords = [...form.handoff.manualTriggerKeywords]
|
||
form.handoff.cardTriggerMode = 'manual_keywords'
|
||
syncHandoffHumanQueryFromSelection(true)
|
||
form.identity = Object.assign(defaults.identity, cfg?.identity || {})
|
||
form.replyPolicy = Object.assign(defaults.replyPolicy, cfg?.replyPolicy || {})
|
||
ensureIdentityLabelMaps()
|
||
form.identity.internalUserIds = uniqueNonRobotIds(form.identity.internalUserIds || [])
|
||
form.identity.externalUserIds = uniqueNonRobotIds(form.identity.externalUserIds || [])
|
||
form.identity.internalGroupConversationIds = uniqueIds(form.identity.internalGroupConversationIds || [])
|
||
if (!form.identity.internalGroupConversationIdsByScope || Array.isArray(form.identity.internalGroupConversationIdsByScope)) {
|
||
form.identity.internalGroupConversationIdsByScope = {}
|
||
}
|
||
currentIdentityScopeKey = ''
|
||
syncAllIdentityLabels()
|
||
}
|
||
|
||
function looksLikeUrl(value) {
|
||
return /^https?:\/\//i.test(String(value || '').trim())
|
||
}
|
||
|
||
function isVisionCapableModelName(model) {
|
||
const name = String(model || '').trim().toLowerCase()
|
||
return name.includes('vl') || name.includes('vision') || name.includes('qvq') || name.includes('omni')
|
||
}
|
||
|
||
function isLikelyTextOnlyQwenModel(model) {
|
||
const name = String(model || '').trim().toLowerCase()
|
||
if (!name || isVisionCapableModelName(name)) return false
|
||
if (['qwen-turbo', 'qwen-plus', 'qwen-max', 'qwen-long'].includes(name)) return true
|
||
return name.startsWith('qwen') &&
|
||
['turbo', 'plus', 'max', 'long', 'coder', 'math', 'instruct'].some(token => name.includes(token))
|
||
}
|
||
|
||
function normalizeVisionModelConfig() {
|
||
if (!form.ai) return
|
||
const visionModel = String(form.ai.visionModel || '').trim()
|
||
const textModel = String(form.ai.model || '').trim()
|
||
if (!visionModel ||
|
||
(visionModel.toLowerCase() === textModel.toLowerCase() && !isVisionCapableModelName(visionModel)) ||
|
||
isLikelyTextOnlyQwenModel(visionModel)) {
|
||
form.ai.visionModel = defaultVisionModel
|
||
}
|
||
}
|
||
|
||
function normalizeAIConfigBeforeSave() {
|
||
if (!form.ai) return
|
||
if (!String(form.ai.systemPrompt || '').trim()) form.ai.systemPrompt = '你是一名企业微信智能客服。'
|
||
normalizeVisionModelConfig()
|
||
if (looksLikeUrl(form.ai.visionApiKey)) {
|
||
form.ai.visionApiKey = ''
|
||
notify('图片 API Key 误填为 URL,已清空并复用主 API Key', 'warn', 'ai')
|
||
}
|
||
if (!form.ai.audioProvider) form.ai.audioProvider = 'auto'
|
||
if (!form.ai.audioMode) form.ai.audioMode = 'openai_audio_chat'
|
||
if (!form.ai.audioModel) form.ai.audioModel = 'qwen3-asr-flash'
|
||
if (!form.ai.audioBaseUrl) form.ai.audioBaseUrl = 'https://dashscope.aliyuncs.com/compatible-mode/v1'
|
||
if (!['concise', 'medium', 'detailed'].includes(form.ai.replyDetail)) form.ai.replyDetail = 'detailed'
|
||
if (looksLikeUrl(form.ai.audioApiKey)) {
|
||
form.ai.audioApiKey = ''
|
||
notify('语音 API Key 误填为 URL,已清空并复用主 API Key', 'warn', 'ai')
|
||
}
|
||
}
|
||
|
||
function validateModelConfig() {
|
||
if (!form.retrieval) return null
|
||
|
||
const embeddingModel = String(form.retrieval.embeddingModel || '').trim().toLowerCase()
|
||
const rerankModel = String(form.retrieval.rerankModel || '').trim().toLowerCase()
|
||
|
||
// 检测 Embedding 模型字段是否填写了 Rerank 模型
|
||
if (embeddingModel && (embeddingModel.includes('rerank') || embeddingModel.includes('gte-rerank') || embeddingModel.includes('bge-rerank'))) {
|
||
return `配置错误:Embedding 模型字段不能填写 Rerank 模型(${form.retrieval.embeddingModel})。请使用 text-embedding-v4 或 text-embedding-v3 等 Embedding 模型。`
|
||
}
|
||
|
||
// 检测 Rerank 模型字段是否填写了 Embedding 模型
|
||
if (rerankModel && (rerankModel.includes('embedding') || rerankModel.includes('text-embedding'))) {
|
||
return `配置错误:Rerank 模型字段不能填写 Embedding 模型(${form.retrieval.rerankModel})。请使用 qwen3-rerank 或 gte-rerank-v2 等 Rerank 模型。`
|
||
}
|
||
|
||
return null
|
||
}
|
||
|
||
async function loadConfig() {
|
||
try {
|
||
const cfg = await GetAutoReplyConfig()
|
||
mergeForm(cfg)
|
||
} catch (err) {
|
||
notify(`加载自动客服配置失败: ${err.message || err}`, 'error', 'listen')
|
||
}
|
||
}
|
||
|
||
async function loadStatus() {
|
||
try {
|
||
const result = await GetAutoReplyStatus()
|
||
if (result?.success && result.data) {
|
||
status.value = result.data
|
||
clampRecordPage()
|
||
syncInternalGroupsForCurrentScope()
|
||
sanitizeConfiguredIdentityIDs()
|
||
const nextKey = [
|
||
result.data.internalContactCount || 0,
|
||
result.data.externalContactCount || 0,
|
||
result.data.identityGroupOptionCount || 0,
|
||
result.data.internalGroupMemberLastSyncAt || 0,
|
||
result.data.identityLastResponseAt || 0,
|
||
result.data.identityLastRefreshAt || 0
|
||
].join(':')
|
||
if (nextKey !== identityOptionsKey) {
|
||
identityOptionsKey = nextKey
|
||
await loadIdentityOptions(true)
|
||
await loadGroupOptions(true)
|
||
}
|
||
}
|
||
} catch (err) {
|
||
LogFrontend('warn', `加载自动客服状态失败: ${err.message || err}`)
|
||
}
|
||
}
|
||
|
||
async function loadIdentityOptions(silent = true) {
|
||
try {
|
||
const result = await GetAutoReplyIdentityOptions()
|
||
if (result?.success && result.data) {
|
||
identityOptions.value = normalizeIdentityOptions(result.data)
|
||
syncHandoffHumanQueryFromSelection()
|
||
syncAllIdentityLabels()
|
||
} else if (!silent) {
|
||
notify(`加载联系人下拉失败: ${result?.message || ''}`, 'error', 'identity')
|
||
}
|
||
} catch (err) {
|
||
LogFrontend('warn', `加载联系人下拉失败: ${err.message || err}`)
|
||
if (!silent) notify(`加载联系人下拉失败: ${err.message || err}`, 'error', 'identity')
|
||
}
|
||
}
|
||
|
||
async function loadGroupOptions(silent = true) {
|
||
try {
|
||
const result = await GetAutoReplyGroupOptions()
|
||
if (result?.success && result.data) {
|
||
groupOptions.value = normalizeGroupOptions(result.data)
|
||
} else if (!silent) {
|
||
notify(`加载群列表失败: ${result?.message || ''}`, 'error', 'identity')
|
||
}
|
||
} catch (err) {
|
||
LogFrontend('warn', `加载群列表失败: ${err.message || err}`)
|
||
if (!silent) notify(`加载群列表失败: ${err.message || err}`, 'error', 'identity')
|
||
}
|
||
}
|
||
|
||
async function saveConfig(scope = 'listen', silentSuccess = false) {
|
||
const ownsBusy = !busy.value
|
||
if (ownsBusy) busy.value = true
|
||
try {
|
||
commitAllIdentityLabelDrafts()
|
||
cleanupIdentityLabels()
|
||
normalizeHandoffBeforeSave()
|
||
normalizeAIConfigBeforeSave()
|
||
|
||
// 验证模型配置
|
||
const validationError = validateModelConfig()
|
||
if (validationError) {
|
||
notify(validationError, 'error', scope)
|
||
return false
|
||
}
|
||
|
||
const result = await SaveAutoReplyConfig(JSON.stringify(form))
|
||
const ok = Array.isArray(result) ? result[0] : Boolean(result)
|
||
const msg = Array.isArray(result) ? result[1] : ''
|
||
if (!ok) {
|
||
notify(`保存失败: ${msg || '未知错误'}`, 'error', scope)
|
||
await loadStatus()
|
||
return false
|
||
}
|
||
if (!silentSuccess) {
|
||
notify('自动客服配置已保存', 'success')
|
||
}
|
||
await loadStatus()
|
||
return true
|
||
} catch (err) {
|
||
notify(`保存失败: ${err.message || err}`, 'error', scope)
|
||
return false
|
||
} finally {
|
||
if (ownsBusy) busy.value = false
|
||
}
|
||
}
|
||
|
||
async function handleStart() {
|
||
busy.value = true
|
||
try {
|
||
form.enabled = true
|
||
commitAllIdentityLabelDrafts()
|
||
cleanupIdentityLabels()
|
||
normalizeHandoffBeforeSave()
|
||
await SaveAutoReplyConfig(JSON.stringify(form))
|
||
await SetAutoReplyEnabled(true)
|
||
await SendWxWorkData('0', JSON.stringify({ type: 10000, data: {} }))
|
||
notify('自动客服已开启,正在监听当前接管账号', 'success')
|
||
await loadStatus()
|
||
} catch (err) {
|
||
notify(`开启失败: ${err.message || err}`, 'error', 'listen')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function handleDisable() {
|
||
busy.value = true
|
||
try {
|
||
form.enabled = false
|
||
commitAllIdentityLabelDrafts()
|
||
cleanupIdentityLabels()
|
||
normalizeHandoffBeforeSave()
|
||
await SaveAutoReplyConfig(JSON.stringify(form))
|
||
await SetAutoReplyEnabled(false)
|
||
notify('自动客服已关闭', 'success')
|
||
await loadStatus()
|
||
} catch (err) {
|
||
notify(`关闭失败: ${err.message || err}`, 'error', 'listen')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function rebuildKnowledge() {
|
||
busy.value = true
|
||
try {
|
||
if (!(await saveConfig('knowledge', true))) return
|
||
const result = await RebuildKnowledgeIndex()
|
||
notify(result?.success ? '知识库索引已重建' : `重建失败: ${result?.message || ''}`, result?.success ? 'success' : 'error', 'knowledge')
|
||
await loadStatus()
|
||
} catch (err) {
|
||
notify(`重建失败: ${err.message || err}`, 'error', 'knowledge')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function syncMaterials() {
|
||
busy.value = true
|
||
try {
|
||
if (!(await saveConfig('knowledge', true))) return
|
||
const result = await SyncAutoReplyMaterials()
|
||
const data = result?.data || {}
|
||
const summary = `新增 ${data.added || 0} 个,删除 ${data.removed || 0} 个,共 ${data.total || 0} 个`
|
||
notify(result?.success ? `素材库已同步:${summary}` : `同步素材库失败: ${result?.message || ''}`, result?.success ? 'success' : 'error', 'knowledge')
|
||
await loadStatus()
|
||
} catch (err) {
|
||
notify(`同步素材库失败: ${err.message || err}`, 'error', 'knowledge')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function refreshContacts() {
|
||
busy.value = true
|
||
try {
|
||
if (!(await saveConfig('identity', true))) return
|
||
const result = await RefreshAutoReplyContacts()
|
||
notify(result?.success ? '联系人身份缓存已开始刷新' : `刷新联系人失败: ${result?.message || ''}`, result?.success ? 'success' : 'error', 'identity')
|
||
await loadStatus()
|
||
await loadIdentityOptions(true)
|
||
setTimeout(() => loadIdentityOptions(true), 2000)
|
||
setTimeout(() => loadIdentityOptions(true), 6000)
|
||
} catch (err) {
|
||
notify(`刷新联系人失败: ${err.message || err}`, 'error', 'identity')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function refreshGroups() {
|
||
busy.value = true
|
||
try {
|
||
if (!(await saveConfig('identity', true))) return
|
||
const result = await RefreshAutoReplyGroups()
|
||
notify(result?.success ? '群列表已开始刷新' : `刷新群列表失败: ${result?.message || ''}`, result?.success ? 'success' : 'error', 'identity')
|
||
await loadStatus()
|
||
await loadGroupOptions(true)
|
||
setTimeout(() => loadGroupOptions(true), 2000)
|
||
setTimeout(() => loadGroupOptions(true), 6000)
|
||
} catch (err) {
|
||
notify(`刷新群列表失败: ${err.message || err}`, 'error', 'identity')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function syncInternalGroups() {
|
||
busy.value = true
|
||
try {
|
||
if (!(await saveConfig('identity', true))) return
|
||
const result = await SyncAutoReplyInternalGroups()
|
||
notify(result?.success ? '内部总群成员已开始同步' : `同步总群成员失败: ${result?.message || ''}`, result?.success ? 'success' : 'error', 'identity')
|
||
await loadStatus()
|
||
await loadIdentityOptions(true)
|
||
setTimeout(() => loadIdentityOptions(true), 2000)
|
||
setTimeout(() => loadIdentityOptions(true), 6000)
|
||
} catch (err) {
|
||
notify(`同步总群成员失败: ${err.message || err}`, 'error', 'identity')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function testAI() {
|
||
busy.value = true
|
||
try {
|
||
if (!(await saveConfig('ai', true))) return
|
||
const result = await TestAIConnection()
|
||
const summary = result?.data?.rawSummary || result?.data?.answer || ''
|
||
const duration = formatDuration(result?.data?.durationMs)
|
||
notify(result?.success ? `AI 连接正常(${duration}):${summary}` : `AI 测试失败(${duration}): ${result?.message || ''}`, result?.success ? 'success' : 'error', 'ai')
|
||
} catch (err) {
|
||
notify(`AI 测试失败: ${err.message || err}`, 'error', 'ai')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
async function testHandoff() {
|
||
busy.value = true
|
||
try {
|
||
if (!(await saveConfig('handoff', true))) return
|
||
const result = await TestHumanHandoff()
|
||
notify(result?.success ? '测试私信已发送' : `测试私信失败: ${result?.message || ''}`, result?.success ? 'success' : 'error', 'handoff')
|
||
} catch (err) {
|
||
notify(`测试私信失败: ${err.message || err}`, 'error', 'handoff')
|
||
} finally {
|
||
busy.value = false
|
||
}
|
||
}
|
||
|
||
function notify(text, type = 'success', scope = '') {
|
||
const normalized = String(text || '').trim()
|
||
if (!normalized) return
|
||
if (type === 'error') {
|
||
setScopedMessage(scope || classifyErrorScope(normalized), normalized)
|
||
return
|
||
}
|
||
message.value = normalized
|
||
messageType.value = type
|
||
setTimeout(() => {
|
||
if (message.value === normalized) message.value = ''
|
||
}, 5000)
|
||
}
|
||
|
||
function actionLabel(action) {
|
||
return {
|
||
replied: '已回复',
|
||
handoff: '转人工',
|
||
ignored: '已忽略',
|
||
failed: '失败'
|
||
}[action] || action
|
||
}
|
||
|
||
function reasonLabel(reason) {
|
||
return {
|
||
self_message: '自己消息',
|
||
handoff_conversation: '人工会话',
|
||
missing_group_name: '群名兜底',
|
||
greeting_replied: '问候回复',
|
||
internal_employee_no_handoff: '内部不转人工',
|
||
identity_unknown_as_customer: '未知按客户',
|
||
identity_unknown_no_handoff: '未知不转人工',
|
||
identity_lookup_started: '身份查询已发起',
|
||
identity_lookup_failed: '身份查询失败',
|
||
human_card_sent: '人工名片已发',
|
||
customer_card_sent: '客户名片已发',
|
||
customer_notice_sent: '客户说明已发',
|
||
human_card_failed: '人工名片失败',
|
||
customer_card_failed: '客户名片失败',
|
||
customer_notice_failed: '客户说明失败',
|
||
duplicate: '重复消息',
|
||
cooldown: '冷却中'
|
||
}[reason] || reason
|
||
}
|
||
|
||
function formatDuration(value) {
|
||
const ms = Number(value || 0)
|
||
if (!Number.isFinite(ms) || ms <= 0) return '-'
|
||
if (ms < 1000) return `${ms}ms`
|
||
return `${(ms / 1000).toFixed(1)}s`
|
||
}
|
||
|
||
function formatUnixTime(value) {
|
||
const seconds = Number(value || 0)
|
||
if (!Number.isFinite(seconds) || seconds <= 0) return '-'
|
||
const date = new Date(seconds * 1000)
|
||
const h = String(date.getHours()).padStart(2, '0')
|
||
const m = String(date.getMinutes()).padStart(2, '0')
|
||
return `${h}:${m}`
|
||
}
|
||
|
||
function identityLabel(identity, source) {
|
||
if (identity === 'internal') return '内部'
|
||
if (identity === 'external') return '客户'
|
||
if (source === 'identity_unknown_as_customer') return '未知/客户'
|
||
if (source === 'identity_unknown_ignored') return '未知/忽略'
|
||
if (source === 'manual_internal') return '手动内部'
|
||
if (source === 'manual_external') return '手动客户'
|
||
if (source === 'single_info') return identity === 'internal' ? '内部/单人' : '客户/单人'
|
||
return identity || '-'
|
||
}
|
||
|
||
function formatRecordClient(record) {
|
||
const clientId = record?.clientId ? `client ${record.clientId}` : ''
|
||
const userId = String(record?.userId || record?.robotId || '').trim()
|
||
if (clientId && userId) return `${clientId} / ${userId}`
|
||
return clientId || userId || '-'
|
||
}
|
||
|
||
function formatScores(record) {
|
||
const parts = []
|
||
if (Number(record.keywordScore || 0) > 0) parts.push(`K ${Number(record.keywordScore).toFixed(3)}`)
|
||
if (Number(record.vectorScore || 0) > 0) parts.push(`V ${Number(record.vectorScore).toFixed(3)}`)
|
||
if (Number(record.rerankScore || 0) > 0) parts.push(`R ${Number(record.rerankScore).toFixed(3)}`)
|
||
return parts.length ? parts.join(' / ') : '-'
|
||
}
|
||
|
||
function scrollToSection(id) {
|
||
const target = document.getElementById(`auto-section-${id}`)
|
||
if (target) {
|
||
target.scrollIntoView({ behavior: 'smooth', block: 'start' })
|
||
}
|
||
}
|
||
|
||
onMounted(async () => {
|
||
await loadConfig()
|
||
await loadStatus()
|
||
await loadIdentityOptions(true)
|
||
await loadGroupOptions(true)
|
||
timer = setInterval(async () => {
|
||
await loadStatus()
|
||
await loadIdentityOptions(true)
|
||
}, 5000)
|
||
})
|
||
|
||
onUnmounted(() => {
|
||
if (timer) clearInterval(timer)
|
||
for (const scopedTimer of Object.values(scopedMessageTimers)) {
|
||
if (scopedTimer) clearTimeout(scopedTimer)
|
||
}
|
||
})
|
||
</script>
|
||
|
||
<style scoped>
|
||
.auto-reply-page {
|
||
color: #18282d;
|
||
padding: 18px;
|
||
background: #f5f7f8;
|
||
}
|
||
|
||
.page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 16px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.page-header h2 {
|
||
margin: 0 0 6px;
|
||
color: #18282d;
|
||
font-size: 24px;
|
||
}
|
||
|
||
.page-header p {
|
||
margin: 0;
|
||
color: #667085;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.header-actions,
|
||
.panel-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
|
||
.primary-btn,
|
||
.ghost-btn {
|
||
min-height: 36px;
|
||
border-radius: 6px;
|
||
border: 1px solid #16706d;
|
||
padding: 0 16px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.primary-btn {
|
||
background: #16706d;
|
||
color: #fff;
|
||
}
|
||
|
||
.ghost-btn {
|
||
background: #fff;
|
||
color: #16706d;
|
||
}
|
||
|
||
button:disabled {
|
||
opacity: 0.55;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.status-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||
gap: 12px;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.metric {
|
||
background: #fff;
|
||
border: 1px solid #dfe5e8;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.metric {
|
||
padding: 14px;
|
||
}
|
||
|
||
.metric span {
|
||
display: block;
|
||
color: #667085;
|
||
font-size: 12px;
|
||
margin-bottom: 8px;
|
||
}
|
||
|
||
.metric strong {
|
||
font-size: 22px;
|
||
}
|
||
|
||
.metric .ok {
|
||
color: #16706d;
|
||
}
|
||
|
||
.metric .muted {
|
||
color: #8a94a6;
|
||
}
|
||
|
||
.section-nav {
|
||
position: sticky;
|
||
top: 0;
|
||
z-index: 8;
|
||
display: flex;
|
||
gap: 8px;
|
||
overflow-x: auto;
|
||
margin: 0 0 22px;
|
||
padding: 10px;
|
||
border: 1px solid #d7e1e4;
|
||
border-radius: 8px;
|
||
background: rgba(245, 247, 248, 0.96);
|
||
box-shadow: 0 8px 18px rgba(15, 31, 35, 0.06);
|
||
}
|
||
|
||
.section-nav-btn {
|
||
flex: 0 0 auto;
|
||
min-height: 34px;
|
||
border: 1px solid #c9d9dc;
|
||
border-radius: 6px;
|
||
padding: 0 13px;
|
||
background: #fff;
|
||
color: #1d4e52;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.section-nav-btn:hover {
|
||
border-color: #16706d;
|
||
background: #eef9f7;
|
||
}
|
||
|
||
.auto-section {
|
||
position: relative;
|
||
margin: 0 0 26px;
|
||
padding: 0 18px 18px;
|
||
border: 1px solid #d7e1e4;
|
||
border-left: 5px solid #16706d;
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
box-shadow: 0 12px 26px rgba(15, 31, 35, 0.05);
|
||
scroll-margin-top: 76px;
|
||
}
|
||
|
||
.section-header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 16px;
|
||
margin: 0 -18px 16px;
|
||
padding: 15px 18px;
|
||
border-bottom: 1px solid #e5ecef;
|
||
border-radius: 8px 8px 0 0;
|
||
background: #f9fbfc;
|
||
}
|
||
|
||
.section-title {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
min-width: 0;
|
||
}
|
||
|
||
.section-title h3 {
|
||
color: #18282d;
|
||
font-size: 17px;
|
||
margin: 0;
|
||
line-height: 1.35;
|
||
}
|
||
|
||
.section-index {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
width: 30px;
|
||
height: 30px;
|
||
border-radius: 6px;
|
||
color: #fff;
|
||
background: #16706d;
|
||
font-size: 13px;
|
||
font-weight: 800;
|
||
}
|
||
|
||
.section-meta {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
}
|
||
|
||
.section-meta span {
|
||
border: 1px solid #d7e1e4;
|
||
border-radius: 6px;
|
||
padding: 4px 8px;
|
||
color: #4d5f66;
|
||
background: #fff;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.section-alerts {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin: -4px 0 16px;
|
||
}
|
||
|
||
.section-alert {
|
||
border-radius: 4px;
|
||
padding: 10px 12px;
|
||
background: #fdecec;
|
||
color: #9b1c1c;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
overflow-wrap: anywhere;
|
||
}
|
||
|
||
.form-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
|
||
gap: 14px 16px;
|
||
margin-bottom: 14px;
|
||
}
|
||
|
||
.ai-config-groups {
|
||
display: grid;
|
||
gap: 14px;
|
||
}
|
||
|
||
.config-subsection {
|
||
border: 1px solid #dce7ea;
|
||
border-radius: 6px;
|
||
padding: 14px;
|
||
background: #fbfcfd;
|
||
}
|
||
|
||
.config-subsection .form-grid {
|
||
margin-bottom: 0;
|
||
}
|
||
|
||
.advanced-grid {
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.config-subsection-header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.config-subsection-header strong {
|
||
color: #18282d;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.config-subsection-header span {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.advanced-toggle {
|
||
margin-top: 12px;
|
||
min-height: 32px;
|
||
border: 1px solid #c9d9dc;
|
||
border-radius: 6px;
|
||
padding: 0 12px;
|
||
background: #fff;
|
||
color: #1d4e52;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.advanced-toggle:hover {
|
||
border-color: #16706d;
|
||
background: #eef9f7;
|
||
}
|
||
|
||
.form-grid label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
color: #475467;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.form-grid input,
|
||
.form-grid select,
|
||
.form-grid textarea {
|
||
border: 1px solid #cfd8dc;
|
||
border-radius: 6px;
|
||
min-height: 36px;
|
||
padding: 8px 10px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.select-action-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
}
|
||
|
||
.handoff-human-combobox {
|
||
position: relative;
|
||
}
|
||
|
||
.handoff-human-search {
|
||
width: 100%;
|
||
}
|
||
|
||
.handoff-human-dropdown {
|
||
position: absolute;
|
||
z-index: 20;
|
||
top: calc(100% + 4px);
|
||
left: 0;
|
||
right: 0;
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
border: 1px solid #cfd8dc;
|
||
border-radius: 6px;
|
||
background: #fff;
|
||
box-shadow: 0 12px 28px rgba(16, 24, 40, 0.16);
|
||
}
|
||
|
||
.handoff-human-option {
|
||
width: 100%;
|
||
border: 0;
|
||
border-bottom: 1px solid #eef2f4;
|
||
padding: 9px 10px;
|
||
background: transparent;
|
||
color: #1f2f35;
|
||
text-align: left;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.handoff-human-option:hover,
|
||
.handoff-human-option.selected {
|
||
background: #eef9f7;
|
||
}
|
||
|
||
.handoff-human-option strong,
|
||
.handoff-human-option span,
|
||
.handoff-human-option small {
|
||
display: block;
|
||
}
|
||
|
||
.handoff-human-option span,
|
||
.handoff-human-option small,
|
||
.handoff-human-empty {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.handoff-human-empty {
|
||
padding: 10px;
|
||
}
|
||
|
||
.select-action-row select {
|
||
flex: 1;
|
||
min-width: 0;
|
||
}
|
||
|
||
.mini-btn {
|
||
min-height: 36px;
|
||
border: 1px solid #cfd8dc;
|
||
border-radius: 6px;
|
||
padding: 0 12px;
|
||
background: #fff;
|
||
color: #1d4e52;
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.mini-btn:hover {
|
||
border-color: #16706d;
|
||
background: #eef9f7;
|
||
}
|
||
|
||
.form-grid textarea {
|
||
min-height: 110px;
|
||
resize: vertical;
|
||
}
|
||
|
||
.wide {
|
||
grid-column: 1 / -1;
|
||
}
|
||
|
||
.check-row {
|
||
flex-direction: row !important;
|
||
align-items: center;
|
||
}
|
||
|
||
.check-row input {
|
||
width: 16px;
|
||
min-height: 16px;
|
||
}
|
||
|
||
.field-hint {
|
||
color: #5f6f82;
|
||
font-size: 12px;
|
||
line-height: 1.5;
|
||
}
|
||
|
||
.field-warning {
|
||
grid-column: 1 / -1;
|
||
border: 1px solid #f5c26b;
|
||
border-radius: 4px;
|
||
padding: 9px 10px;
|
||
color: #7a4b00;
|
||
background: #fff7e6;
|
||
font-size: 13px;
|
||
line-height: 1.45;
|
||
}
|
||
|
||
.identity-editor {
|
||
border: 1px solid #d7e1e4;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
background: #f8fafb;
|
||
}
|
||
|
||
.identity-editor-header {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: 10px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.identity-editor-header strong {
|
||
color: #18282d;
|
||
font-size: 14px;
|
||
}
|
||
|
||
.identity-editor-header span,
|
||
.identity-empty {
|
||
color: #667085;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.identity-picker-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(180px, 1fr) minmax(220px, 1.4fr) auto;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.identity-picker-row input,
|
||
.identity-picker-row select {
|
||
border: 1px solid #cfd8dc;
|
||
border-radius: 6px;
|
||
min-height: 36px;
|
||
padding: 8px 10px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
}
|
||
|
||
.identity-selected-list {
|
||
display: grid;
|
||
gap: 8px;
|
||
margin-top: 10px;
|
||
}
|
||
|
||
.identity-selected-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(120px, 1fr) minmax(170px, 1.2fr) 90px minmax(140px, 1fr) auto;
|
||
gap: 8px;
|
||
align-items: center;
|
||
border: 1px solid #e6ecef;
|
||
border-radius: 6px;
|
||
padding: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.identity-selected-row strong {
|
||
color: #18282d;
|
||
}
|
||
|
||
.identity-selected-row span,
|
||
.identity-user-id {
|
||
color: #5f6f82;
|
||
font-size: 12px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.identity-selected-row input {
|
||
border: 1px solid #cfd8dc;
|
||
border-radius: 6px;
|
||
min-height: 32px;
|
||
padding: 6px 8px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.identity-sync-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 12px;
|
||
}
|
||
|
||
.identity-sync-panel {
|
||
border: 1px solid #d7e1e4;
|
||
border-radius: 6px;
|
||
padding: 12px;
|
||
background: #fff;
|
||
min-width: 0;
|
||
}
|
||
|
||
.identity-search-input {
|
||
border: 1px solid #cfd8dc;
|
||
border-radius: 6px;
|
||
min-height: 36px;
|
||
padding: 8px 10px;
|
||
font-size: 13px;
|
||
font-family: inherit;
|
||
width: 100%;
|
||
}
|
||
|
||
.identity-sync-list {
|
||
display: grid;
|
||
gap: 6px;
|
||
margin-top: 10px;
|
||
max-height: 360px;
|
||
overflow: auto;
|
||
}
|
||
|
||
.external-group-list {
|
||
gap: 10px;
|
||
}
|
||
|
||
.identity-external-group {
|
||
border: 1px solid rgba(72, 214, 218, 0.22);
|
||
border-radius: 6px;
|
||
padding: 8px;
|
||
background: rgba(6, 18, 22, 0.96);
|
||
box-shadow: inset 0 0 0 1px rgba(72, 214, 218, 0.06);
|
||
}
|
||
|
||
.identity-external-summary {
|
||
cursor: pointer;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
gap: 10px;
|
||
color: #effbfd;
|
||
font-size: 13px;
|
||
list-style-position: inside;
|
||
align-items: center;
|
||
padding: 2px 0 6px;
|
||
}
|
||
|
||
.identity-external-summary::-webkit-details-marker {
|
||
display: none;
|
||
}
|
||
|
||
.identity-external-summary span {
|
||
color: #8fb7bf;
|
||
font-size: 12px;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.identity-external-group .identity-sync-row {
|
||
border-bottom-color: rgba(72, 214, 218, 0.12);
|
||
}
|
||
|
||
.identity-external-group .identity-sync-row strong {
|
||
color: #f2feff;
|
||
}
|
||
|
||
.identity-external-group .identity-sync-row span {
|
||
color: #9eb9c0;
|
||
}
|
||
|
||
.identity-sync-row {
|
||
display: grid;
|
||
grid-template-columns: minmax(110px, 1fr) minmax(150px, 1.2fr) 86px 52px;
|
||
gap: 8px;
|
||
align-items: center;
|
||
border-bottom: 1px solid #edf2f4;
|
||
padding: 7px 0;
|
||
min-width: 0;
|
||
}
|
||
|
||
.identity-sync-row strong,
|
||
.identity-sync-row span {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.identity-sync-row strong {
|
||
color: #18282d;
|
||
}
|
||
|
||
.identity-sync-row span {
|
||
color: #5f6f82;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.identity-advanced {
|
||
border: 1px solid #d7e1e4;
|
||
border-radius: 6px;
|
||
padding: 10px 12px;
|
||
background: #f8fafb;
|
||
}
|
||
|
||
.identity-advanced summary {
|
||
cursor: pointer;
|
||
color: #18343a;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.identity-advanced-body {
|
||
display: grid;
|
||
gap: 12px;
|
||
margin-top: 12px;
|
||
}
|
||
|
||
.identity-advanced-body label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 6px;
|
||
}
|
||
|
||
.small-btn {
|
||
min-height: 30px;
|
||
padding: 0 10px;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.tag-row {
|
||
display: flex;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
margin: 0 0 14px;
|
||
}
|
||
|
||
.tag-row span {
|
||
border: 1px solid #d7e1e4;
|
||
border-radius: 6px;
|
||
padding: 4px 8px;
|
||
color: #44515f;
|
||
background: #f8fafb;
|
||
}
|
||
|
||
.inline-message {
|
||
border-radius: 4px;
|
||
padding: 10px 12px;
|
||
margin-bottom: 12px;
|
||
background: #eaf7ee;
|
||
color: #17663a;
|
||
}
|
||
|
||
.inline-message.error {
|
||
background: #fdecec;
|
||
color: #9b1c1c;
|
||
}
|
||
|
||
.reason-row {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 8px;
|
||
margin: 0 0 12px;
|
||
}
|
||
|
||
.reason-row span {
|
||
border: 1px solid #d7e1e4;
|
||
border-radius: 6px;
|
||
padding: 4px 8px;
|
||
color: #44515f;
|
||
background: #f8fafb;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.failed-list {
|
||
color: #9b1c1c;
|
||
font-size: 12px;
|
||
line-height: 1.6;
|
||
}
|
||
|
||
.record-table {
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.record-pagination {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: flex-end;
|
||
gap: 8px;
|
||
flex-wrap: wrap;
|
||
padding-top: 12px;
|
||
color: #667085;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.record-row {
|
||
display: grid;
|
||
grid-template-columns: 150px 80px 70px 150px 130px 90px minmax(180px, 1fr) minmax(180px, 1fr) minmax(150px, 1fr) 80px 150px minmax(180px, 1fr);
|
||
gap: 10px;
|
||
min-width: 1740px;
|
||
padding: 9px 0;
|
||
border-bottom: 1px solid #eef1f4;
|
||
font-size: 13px;
|
||
}
|
||
|
||
.record-row.header {
|
||
color: #667085;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.truncate {
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.empty {
|
||
color: #667085;
|
||
padding: 20px 0;
|
||
}
|
||
|
||
@media (max-width: 760px) {
|
||
.auto-reply-page {
|
||
padding: 12px;
|
||
}
|
||
|
||
.section-nav {
|
||
margin-bottom: 18px;
|
||
padding: 8px;
|
||
}
|
||
|
||
.auto-section {
|
||
padding: 0 12px 14px;
|
||
scroll-margin-top: 70px;
|
||
}
|
||
|
||
.section-header {
|
||
align-items: flex-start;
|
||
flex-direction: column;
|
||
margin: 0 -12px 14px;
|
||
padding: 13px 12px;
|
||
}
|
||
|
||
.section-meta {
|
||
justify-content: flex-start;
|
||
}
|
||
|
||
.identity-picker-row,
|
||
.identity-selected-row,
|
||
.identity-sync-grid,
|
||
.identity-sync-row {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
}
|
||
|
||
/* Command center skin */
|
||
.auto-reply-page {
|
||
color: var(--cmd-text);
|
||
border-color: var(--cmd-line);
|
||
background: transparent;
|
||
box-shadow: none;
|
||
}
|
||
|
||
.page-header h2,
|
||
.section-title h3,
|
||
.config-subsection-header strong,
|
||
.identity-editor-header strong,
|
||
.identity-sync-row strong,
|
||
.identity-selected-row strong,
|
||
.identity-advanced summary {
|
||
color: var(--cmd-text);
|
||
}
|
||
|
||
.page-header p,
|
||
.metric span,
|
||
.section-meta span,
|
||
.config-subsection-header span,
|
||
.form-grid label,
|
||
.field-hint,
|
||
.identity-editor-header span,
|
||
.identity-empty,
|
||
.identity-sync-row span,
|
||
.identity-selected-row span,
|
||
.identity-user-id,
|
||
.empty {
|
||
color: var(--cmd-text-soft);
|
||
}
|
||
|
||
.metric,
|
||
.auto-section,
|
||
.config-subsection,
|
||
.identity-editor,
|
||
.identity-sync-panel,
|
||
.identity-advanced,
|
||
.section-nav,
|
||
.identity-selected-row {
|
||
border-color: var(--cmd-line);
|
||
background: var(--cmd-panel);
|
||
box-shadow: var(--cmd-shadow);
|
||
}
|
||
|
||
.metric {
|
||
position: relative;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.metric::before,
|
||
.auto-section::before {
|
||
content: "";
|
||
position: absolute;
|
||
inset: 0 0 auto;
|
||
height: 2px;
|
||
background: linear-gradient(90deg, var(--cmd-cyan), transparent);
|
||
}
|
||
|
||
.metric strong {
|
||
color: var(--cmd-text);
|
||
text-shadow: 0 0 18px rgba(72, 240, 220, 0.12);
|
||
}
|
||
|
||
.metric .ok {
|
||
color: var(--cmd-green);
|
||
}
|
||
|
||
.section-nav {
|
||
background: rgba(7, 18, 23, 0.96);
|
||
backdrop-filter: blur(12px);
|
||
}
|
||
|
||
.section-nav-btn {
|
||
color: var(--cmd-text-soft);
|
||
border-color: rgba(72, 240, 220, 0.24);
|
||
background: rgba(6, 17, 22, 0.92);
|
||
}
|
||
|
||
.section-nav-btn:hover {
|
||
color: var(--cmd-cyan);
|
||
border-color: rgba(72, 240, 220, 0.68);
|
||
background: rgba(72, 240, 220, 0.1);
|
||
}
|
||
|
||
.auto-section {
|
||
border-left-color: var(--cmd-cyan);
|
||
background: rgba(8, 20, 26, 0.92);
|
||
}
|
||
|
||
.section-header {
|
||
border-bottom-color: rgba(72, 240, 220, 0.16);
|
||
background: linear-gradient(90deg, rgba(72, 240, 220, 0.1), rgba(99, 168, 255, 0.045));
|
||
}
|
||
|
||
.section-index {
|
||
color: #031316;
|
||
background: linear-gradient(135deg, var(--cmd-cyan), var(--cmd-blue));
|
||
box-shadow: 0 0 18px rgba(72, 240, 220, 0.22);
|
||
}
|
||
|
||
.section-meta span,
|
||
.tag-row span,
|
||
.reason-row span {
|
||
border-color: rgba(72, 240, 220, 0.2);
|
||
color: var(--cmd-text-soft);
|
||
background: rgba(7, 18, 23, 0.82);
|
||
}
|
||
|
||
.section-alert,
|
||
.field-warning,
|
||
.failed-list {
|
||
color: var(--cmd-red);
|
||
}
|
||
|
||
.section-alert {
|
||
background: rgba(255, 107, 125, 0.1);
|
||
border: 1px solid rgba(255, 107, 125, 0.28);
|
||
}
|
||
|
||
.field-warning {
|
||
border-color: rgba(255, 209, 102, 0.34);
|
||
color: var(--cmd-amber);
|
||
background: rgba(255, 209, 102, 0.1);
|
||
}
|
||
|
||
.config-subsection,
|
||
.identity-editor,
|
||
.identity-sync-panel,
|
||
.identity-advanced,
|
||
.identity-selected-row {
|
||
background: rgba(7, 18, 23, 0.78);
|
||
}
|
||
|
||
.form-grid input,
|
||
.form-grid select,
|
||
.form-grid textarea,
|
||
.identity-picker-row input,
|
||
.identity-picker-row select,
|
||
.identity-selected-row input,
|
||
.identity-search-input {
|
||
color: var(--cmd-text);
|
||
border-color: rgba(72, 240, 220, 0.24);
|
||
background: rgba(5, 15, 20, 0.94);
|
||
}
|
||
|
||
.handoff-human-dropdown {
|
||
border-color: rgba(72, 240, 220, 0.28);
|
||
background: rgba(5, 15, 20, 0.98);
|
||
box-shadow: 0 16px 34px rgba(0, 0, 0, 0.38);
|
||
}
|
||
|
||
.handoff-human-option {
|
||
color: var(--cmd-text);
|
||
border-bottom-color: rgba(72, 240, 220, 0.12);
|
||
}
|
||
|
||
.handoff-human-option:hover,
|
||
.handoff-human-option.selected {
|
||
background: rgba(72, 240, 220, 0.12);
|
||
}
|
||
|
||
.handoff-human-option span,
|
||
.handoff-human-option small,
|
||
.handoff-human-empty {
|
||
color: var(--cmd-text-soft);
|
||
}
|
||
|
||
.form-grid input:focus,
|
||
.form-grid select:focus,
|
||
.form-grid textarea:focus,
|
||
.identity-picker-row input:focus,
|
||
.identity-picker-row select:focus,
|
||
.identity-selected-row input:focus,
|
||
.identity-search-input:focus {
|
||
outline: none;
|
||
border-color: rgba(72, 240, 220, 0.68);
|
||
box-shadow: 0 0 18px rgba(72, 240, 220, 0.12);
|
||
}
|
||
|
||
.identity-sync-row,
|
||
.record-row {
|
||
border-bottom-color: rgba(72, 240, 220, 0.14);
|
||
}
|
||
|
||
.record-row.header {
|
||
color: var(--cmd-text);
|
||
}
|
||
|
||
.inline-message {
|
||
color: var(--cmd-green);
|
||
border: 1px solid rgba(93, 242, 167, 0.34);
|
||
background: rgba(93, 242, 167, 0.1);
|
||
}
|
||
|
||
.inline-message.error {
|
||
color: var(--cmd-red);
|
||
border-color: rgba(255, 107, 125, 0.38);
|
||
background: rgba(255, 107, 125, 0.1);
|
||
}
|
||
|
||
.primary-btn {
|
||
color: #031316;
|
||
border-color: rgba(72, 240, 220, 0.82);
|
||
background: linear-gradient(135deg, var(--cmd-cyan), var(--cmd-blue));
|
||
box-shadow: 0 0 20px rgba(72, 240, 220, 0.16);
|
||
}
|
||
|
||
.ghost-btn,
|
||
.mini-btn,
|
||
.advanced-toggle {
|
||
color: var(--cmd-cyan);
|
||
border-color: rgba(72, 240, 220, 0.38);
|
||
background: rgba(9, 23, 29, 0.84);
|
||
}
|
||
</style>
|