Files
qiweimanager-master/frontend/src/components/AutoReply.vue

3294 lines
106 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>