[split-upload 3/6] f2fd4fa backup via proxy

This commit is contained in:
Simon
2026-08-11 14:16:50 +08:00
parent e54529d307
commit 8ef4be0fa0
74 changed files with 7945 additions and 5 deletions
@@ -0,0 +1,434 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>工具栏统一设计 v0.4 - WB 专家团队风格</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: #f5f6f8;
color: #1a1a1a;
padding: 32px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
h1 { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; color: #1a1a1a; }
h2 { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; margin: 40px 0 12px; color: #1a1a1a; }
h3 { font-size: 14px; font-weight: 600; margin: 16px 0 8px; color: #333; }
.meta { color: #6b7280; font-size: 12.5px; margin-bottom: 24px; }
/* === 4 按钮统一容器(模拟 InputBar 工具栏) === */
.toolbar {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
background: #ffffff;
border: 1px solid #e8eaed;
border-radius: 12px;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.toolbar-divider {
width: 1px;
height: 22px;
background: #e8eaed;
margin: 0 2px;
}
/* === 统一按钮基础(35×35,圆润) === */
.tool-btn {
width: 35px;
height: 35px;
border: 1px solid #e8eaed;
background: #ffffff;
border-radius: 50%; /* 圆形按钮 — WB 风格 */
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
position: relative;
color: #5f6368;
font-size: 16px;
}
.tool-btn:hover {
background: #f5f6f8;
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.08);
}
.tool-btn:active { transform: translateY(0) scale(0.94); }
.tool-btn svg { width: 18px; height: 18px; }
/* === WB 风格:客服 = 圆形渐变头像 + 白色客服图标 === */
.wb-agent {
border: 1.5px solid transparent;
background-clip: padding-box;
position: relative;
overflow: visible;
}
.wb-agent::before {
content: "";
position: absolute;
inset: 0;
border-radius: 50%;
padding: 1.5px;
background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%);
-webkit-mask: linear-gradient(#fff 0 0) content-box, linear-gradient(#fff 0 0);
-webkit-mask-composite: xor;
mask-composite: exclude;
pointer-events: none;
}
.wb-agent::after {
content: "";
position: absolute;
inset: 1.5px;
border-radius: 50%;
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #db2777 100%);
pointer-events: none;
z-index: 0;
}
.wb-agent .agent-content {
position: relative;
z-index: 1;
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
font-size: 13px;
font-weight: 600;
letter-spacing: 0.02em;
}
.wb-agent .agent-content svg {
width: 18px;
height: 18px;
color: #ffffff;
}
/* 6 态 - 渐变色变化 */
.wb-agent.is-active::after { background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #db2777 100%); }
.wb-agent.is-active::before { background: linear-gradient(135deg, #6366f1 0%, #8b5cf6 50%, #ec4899 100%); }
.wb-agent.is-disabled { opacity: 0.4; cursor: not-allowed; }
.wb-agent.is-disabled:hover { transform: none; box-shadow: none; }
.wb-agent.is-disabled::after { background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%); }
.wb-agent.is-disabled::before { background: linear-gradient(135deg, #d1d5db 0%, #9ca3af 100%); }
.wb-agent.is-urgent {
animation: agentGlow 1.6s ease-in-out infinite;
}
.wb-agent.is-urgent::after { background: linear-gradient(135deg, #ef4444 0%, #dc2626 50%, #b91c1c 100%); }
.wb-agent.is-urgent::before { background: linear-gradient(135deg, #f87171 0%, #ef4444 50%, #dc2626 100%); }
@keyframes agentGlow {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); }
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.18); }
}
.wb-agent.is-waiting::after { background: linear-gradient(135deg, #f59e0b 0%, #d97706 50%, #b45309 100%); }
.wb-agent.is-waiting::before { background: linear-gradient(135deg, #fbbf24 0%, #f59e0b 50%, #d97706 100%); }
.wb-agent.is-end::after { background: linear-gradient(135deg, #10b981 0%, #059669 50%, #047857 100%); }
.wb-agent.is-end::before { background: linear-gradient(135deg, #34d399 0%, #10b981 50%, #059669 100%); }
.wb-agent.is-reopen::after { background: linear-gradient(135deg, #3b82f6 0%, #2563eb 50%, #1d4ed8 100%); }
.wb-agent.is-reopen::before { background: linear-gradient(135deg, #60a5fa 0%, #3b82f6 50%, #2563eb 100%); }
/* === 演示容器 === */
.demo-block {
background: #ffffff;
border: 1px solid #e8eaed;
border-radius: 14px;
padding: 28px;
margin-bottom: 20px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.04);
}
.demo-label { font-size: 11.5px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 14px; font-weight: 500; }
/* === 状态切换 tab === */
.state-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid #e8eaed; }
.state-tab {
padding: 6px 12px;
border: 1px solid #e8eaed;
background: #ffffff;
border-radius: 6px;
cursor: pointer;
font-size: 12.5px;
color: #5f6368;
transition: all 0.15s;
font-family: inherit;
}
.state-tab:hover { background: #f5f6f8; }
.state-tab.active { background: #1a1a1a; color: #fff; border-color: #1a1a1a; }
.state-stage {
min-height: 100px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #fafbfc 0%, #f0f1f5 100%);
border-radius: 10px;
padding: 32px;
}
.state-desc { color: #5f6368; font-size: 12.5px; line-height: 1.7; background: #fafbfc; padding: 12px 14px; border-radius: 6px; border-left: 2px solid #6366f1; margin-top: 12px; }
.state-desc strong { color: #1a1a1a; }
/* === 4 按钮并排 === */
.four-btns {
display: flex;
gap: 6px;
align-items: center;
padding: 16px;
background: linear-gradient(135deg, #fafbfc 0%, #f0f1f5 100%);
border: 1px solid #e8eaed;
border-radius: 12px;
margin: 12px 0;
}
/* === 对比表 === */
.cmp-table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 12.5px; }
.cmp-table th, .cmp-table td { border: 1px solid #e8eaed; padding: 8px 10px; text-align: left; }
.cmp-table th { background: #fafbfc; font-weight: 600; }
.yes { color: #059669; }
.no { color: #dc2626; }
.partial { color: #d97706; }
.palette { display: flex; gap: 6px; flex-wrap: wrap; }
.palette-item { width: 56px; height: 56px; border-radius: 6px; display: flex; align-items: flex-end; padding: 4px; font-size: 9.5px; color: #fff; font-weight: 500; }
.note { background: #fef3c7; border-left: 3px solid #d97706; padding: 12px 16px; border-radius: 4px; font-size: 12.5px; color: #78350f; margin: 16px 0; }
.gradient-bar {
height: 40px;
border-radius: 6px;
display: flex;
align-items: center;
padding: 0 12px;
color: #fff;
font-size: 11.5px;
font-weight: 500;
}
</style>
</head>
<body>
<h1>工具栏统一设计 v0.4 — WB 专家团队风格</h1>
<p class="meta">圆形按钮 + 渐变色客服头像 + 高级感设计 · 2026-08-04 凌晨</p>
<div class="note">
📌 本版核心改动(对比 v0.3 克制拟人):<br>
• 4 按钮改为<strong>圆形</strong>(35×35 圆按钮, WB 企微/钉钉生态风格)<br>
• 客服按钮采用<strong>渐变边框 + 渐变背景 + 白色客服图标</strong>设计(类似企微客服小助手)<br>
• 配色更<strong>大胆</strong>:紫粉渐变(默认)/ 红橙(urgent)/ 绿(end)/ 橙黄(waiting)/ 蓝(reopen)<br>
• 整体感觉<strong>更"专业 SaaS"</strong>,类似企微客服、钉钉智能客服、飞书服务台
</div>
<h2>🎨 配色色板(渐变色为主)</h2>
<div class="demo-block">
<h3>客服 6 态渐变色</h3>
<div style="display: flex; flex-direction: column; gap: 8px;">
<div class="gradient-bar" style="background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 50%, #db2777 100%);">active 紫粉渐变 (默认 · 客服在线)</div>
<div class="gradient-bar" style="background: linear-gradient(135deg, #9ca3af 0%, #6b7280 100%); opacity: 0.6;">disabled 灰度</div>
<div class="gradient-bar" style="background: linear-gradient(135deg, #ef4444 0%, #dc2626 50%, #b91c1c 100%);">urgent 红渐变 (紧急)</div>
<div class="gradient-bar" style="background: linear-gradient(135deg, #f59e0b 0%, #d97706 50%, #b45309 100%);">waiting 橙渐变 (排队)</div>
<div class="gradient-bar" style="background: linear-gradient(135deg, #10b981 0%, #059669 50%, #047857 100%);">end 绿渐变 (已接入)</div>
<div class="gradient-bar" style="background: linear-gradient(135deg, #3b82f6 0%, #2563eb 50%, #1d4ed8 100%);">reopen 蓝渐变 (重新打开)</div>
</div>
</div>
<h2>🎭 4 按钮统一设计(WB 风格 — 圆形)</h2>
<div class="demo-block">
<div class="demo-label">工具栏并排展示(模拟 InputBar 工具栏)</div>
<div class="four-btns">
<!-- 表情 -->
<button class="tool-btn" title="表情">
<svg viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="9" r="6.5" fill="none" stroke="currentColor" stroke-width="1.5"/>
<circle cx="6.5" cy="8" r="0.8" fill="currentColor"/>
<circle cx="11.5" cy="8" r="0.8" fill="currentColor"/>
<path d="M6 11.5 Q9 13.5 12 11.5" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<!-- 文件 -->
<button class="tool-btn" title="文件">
<svg viewBox="0 0 18 18" fill="none">
<path d="M3 2 L11 2 L15 6 L15 16 L3 16 Z" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<path d="M11 2 L11 6 L15 6" fill="#f5f6f8" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
<line x1="6" y1="10" x2="12" y2="10" stroke="currentColor" stroke-width="1.2"/>
<line x1="6" y1="13" x2="10" y2="13" stroke="currentColor" stroke-width="1.2"/>
</svg>
</button>
<div class="toolbar-divider"></div>
<!-- 语音 -->
<button class="tool-btn" title="语音">
<svg viewBox="0 0 18 18" fill="none">
<rect x="6" y="2" width="6" height="9" rx="3" fill="none" stroke="currentColor" stroke-width="1.5"/>
<path d="M3 9 Q3 13 9 13 Q15 13 15 9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="13" x2="9" y2="16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
</button>
<!-- 客服(WB 风格) -->
<button class="tool-btn wb-agent is-active" title="客服在线 · 点击呼叫">
<span class="agent-content">
<svg viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
<path d="M3.5 8 Q3.5 5 5 5 L5 11 Q3.5 11 3.5 8 Z" fill="currentColor" opacity="0.95"/>
<path d="M14.5 8 Q14.5 5 13 5 L13 11 Q14.5 11 14.5 8 Z" fill="currentColor" opacity="0.95"/>
<circle cx="7" cy="8" r="0.6" fill="currentColor"/>
<circle cx="11" cy="8" r="0.6" fill="currentColor"/>
<path d="M7.5 10.2 Q9 11.4 10.5 10.2" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>
</span>
</button>
</div>
<p style="font-size: 12px; color: #5f6368; margin-top: 8px;">
视觉特点: 35×35 圆形按钮 + 客服按钮 1.5px 渐变边框(紫粉) + 渐变背景 + 白色客服图标
</p>
</div>
<h2>🎬 客服按钮 6 态切换</h2>
<div class="demo-block">
<div class="state-tabs">
<button class="state-tab active" data-state="active">active</button>
<button class="state-tab" data-state="disabled">disabled</button>
<button class="state-tab" data-state="urgent">urgent</button>
<button class="state-tab" data-state="waiting">waiting</button>
<button class="state-tab" data-state="end">end</button>
<button class="state-tab" data-state="reopen">reopen</button>
</div>
<div class="state-stage" id="stage"></div>
<div class="state-desc" id="desc"></div>
</div>
<h2>📊 与之前版本对比</h2>
<table class="cmp-table">
<thead>
<tr>
<th>维度</th>
<th>v0.1 方块按钮</th>
<th>v0.2 灵动岛卡通</th>
<th>v0.3 克制拟人</th>
<th>v0.4 WB 渐变 ⭐</th>
</tr>
</thead>
<tbody>
<tr>
<td>按钮形态</td>
<td>方块 8px 圆角</td>
<td>方块 18px 圆角</td>
<td>方块 8px 圆角</td>
<td class="yes"><strong>圆形(35×35)</strong></td>
</tr>
<tr>
<td>4 按钮统一</td>
<td class="no">×</td>
<td class="no">×</td>
<td class="yes">✓ 4 按钮</td>
<td class="yes">✓ 4 按钮(圆形)</td>
</tr>
<tr>
<td>角色设计</td>
<td class="no">emoji</td>
<td class="no">夸张卡通</td>
<td class="partial">克制拟人</td>
<td class="yes"><strong>渐变头像 + 客服图标(企微风)</strong></td>
</tr>
<tr>
<td>配色</td>
<td class="no">单调</td>
<td class="no">卡通色</td>
<td class="partial">中性灰</td>
<td class="yes"><strong>渐变色(紫粉/红/橙/绿/蓝)</strong></td>
</tr>
<tr>
<td>专业感</td>
<td class="partial">★★★</td>
<td class="no"></td>
<td class="yes">★★★★</td>
<td class="yes"><strong>★★★★★(企微/钉钉/飞书级)</strong></td>
</tr>
<tr>
<td>技术实现</td>
<td class="yes">纯 CSS 简单</td>
<td class="partial">CSS + SVG</td>
<td class="partial">CSS + SVG</td>
<td class="partial">CSS + SVG + 渐变(中等)</td>
</tr>
</tbody>
</table>
<h2>❓ 待你拍板</h2>
<ol style="margin-left: 20px; line-height: 1.8;">
<li><strong>WB 风格(圆形 + 渐变)</strong>符合你期望吗?如果"WB 专家团队"指具体产品(如企微、钉钉、飞书的客服),可能需要我再调整</li>
<li><strong>默认渐变色</strong>是紫粉(更年轻/活泼)还是其他(更专业/冷静)?如蓝色、蓝绿、暗色等</li>
<li><strong>客服图标</strong>是戴耳机小人(当前)还是纯文字"客服"两字/品牌 logo 缩写?</li>
<li><strong>圆形 vs 方块</strong>:WB 风格如果太"活泼",我可以做方形 + 渐变(更克制)</li>
</ol>
<p style="margin-top: 32px; padding-top: 16px; border-top: 1px solid #e8eaed; color: #6b7280; font-size: 11.5px;">
v0.4 WB 风格 · 2026-08-04 · Duckula 主理人 · 等待用户拍板
</p>
<script>
const states = {
active: {
cls: 'is-active',
desc: '<strong>active</strong>: 紫粉渐变(蓝→紫→粉)+ 白色客服图标 — 默认"客服在线,等待呼叫"。类似企微客服小助手的"标准态"'
},
disabled: {
cls: 'is-disabled',
desc: '<strong>disabled</strong>: 灰度渐变 + opacity 0.4 — 表达"不可用",无动画'
},
urgent: {
cls: 'is-urgent',
desc: '<strong>urgent</strong>: 红色渐变(浅红→中红→深红)+ 1.6s 发光呼吸(box-shadow 扩散 6px) — 表达"紧急,需关注"'
},
waiting: {
cls: 'is-waiting',
desc: '<strong>waiting</strong>: 橙色渐变(浅黄→橙→深橙) — 表达"排队中"'
},
end: {
cls: 'is-end',
desc: '<strong>end</strong>: 绿色渐变(浅绿→中绿→深绿) — 表达"已接入坐席,通话中"'
},
reopen: {
cls: 'is-reopen',
desc: '<strong>reopen</strong>: 蓝色渐变(浅蓝→中蓝→深蓝) — 表达"会话已关闭,可重新打开"'
}
};
const svgHTML = `<svg viewBox="0 0 18 18" fill="none">
<circle cx="9" cy="8" r="3.5" fill="none" stroke="currentColor" stroke-width="1.4"/>
<path d="M3.5 8 Q3.5 5 5 5 L5 11 Q3.5 11 3.5 8 Z" fill="currentColor" opacity="0.95"/>
<path d="M14.5 8 Q14.5 5 13 5 L13 11 Q14.5 11 14.5 8 Z" fill="currentColor" opacity="0.95"/>
<circle cx="7" cy="8" r="0.6" fill="currentColor"/>
<circle cx="11" cy="8" r="0.6" fill="currentColor"/>
<path d="M7.5 10.2 Q9 11.4 10.5 10.2" fill="none" stroke="currentColor" stroke-width="1.2" stroke-linecap="round"/>
</svg>`;
const stage = document.getElementById('stage');
const descEl = document.getElementById('desc');
const tabs = document.querySelectorAll('.state-tab');
function render(state) {
const s = states[state];
stage.innerHTML = `<button class="tool-btn wb-agent ${s.cls}" style="width: 56px; height: 56px;"><span class="agent-content">${svgHTML}</span></button>`;
descEl.innerHTML = s.desc;
}
tabs.forEach(tab => {
tab.addEventListener('click', () => {
tabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
render(tab.dataset.state);
});
});
render('active');
</script>
</body>
</html>
@@ -0,0 +1,531 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>工具栏统一设计 v1.0 - 消息框内上方 4 按钮</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 50%, #fce7f3 100%);
color: #1a1a1a;
padding: 24px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; color: #1a1a1a; }
h2 { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; margin: 32px 0 12px; color: #1a1a1a; }
h3 { font-size: 14px; font-weight: 600; margin: 12px 0 8px; color: #333; }
.meta { color: #6b7280; font-size: 12.5px; margin-bottom: 20px; }
/* === ChatPanel 整体模拟 === */
.chatpanel-mock {
width: 100%;
max-width: 720px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
overflow: hidden;
}
.chatpanel-mock-header {
padding: 12px 16px;
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
background: rgba(255, 255, 255, 0.5);
font-size: 13px;
color: #6b7280;
display: flex;
align-items: center;
gap: 8px;
}
.chatpanel-mock-header .dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; }
/* === 消息流区域 === */
.chatpanel-mock-messages {
min-height: 280px;
padding: 20px 16px;
background: linear-gradient(180deg, #fafbfc 0%, #f5f7fa 100%);
}
/* === 4 按钮工具栏(消息流上方居中) === */
.glass-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 8px 16px;
margin: 0 auto 16px;
width: fit-content;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.9) inset,
0 -1px 0 rgba(99, 102, 241, 0.08) inset,
0 2px 4px rgba(99, 102, 241, 0.05),
0 8px 16px rgba(99, 102, 241, 0.08),
0 24px 48px rgba(99, 102, 241, 0.05);
position: relative;
}
.glass-toolbar::before {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
background: linear-gradient(180deg, rgba(255, 255, 255, 0.6) 0%, rgba(255, 255, 255, 0.1) 40%, transparent 60%);
pointer-events: none;
}
.glass-toolbar::after {
content: "";
position: absolute;
inset: 0;
border-radius: 20px;
background: radial-gradient(circle at 20% 20%, rgba(99, 102, 241, 0.15) 0%, transparent 50%),
radial-gradient(circle at 80% 80%, rgba(236, 72, 153, 0.12) 0%, transparent 50%);
pointer-events: none;
z-index: 0;
}
/* === 4 按钮(40px · 高级感) === */
.glass-btn {
width: 40px;
height: 40px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
position: relative;
z-index: 2;
box-shadow:
0 1px 0 rgba(255, 255, 255, 0.9) inset,
0 -1px 0 rgba(0, 0, 0, 0.04) inset,
0 1px 2px rgba(0, 0, 0, 0.04),
0 2px 4px rgba(0, 0, 0, 0.04);
overflow: visible;
}
.glass-btn:hover {
background: rgba(255, 255, 255, 0.75);
transform: translateY(-1px) scale(1.06);
box-shadow:
0 1px 0 rgba(255, 255, 255, 1) inset,
0 -1px 0 rgba(0, 0, 0, 0.04) inset,
0 2px 4px rgba(0, 0, 0, 0.06),
0 8px 16px rgba(99, 102, 241, 0.15);
}
.glass-btn:active { transform: translateY(0) scale(0.95); }
.glass-btn svg { width: 22px; height: 22px; display: block; }
/* === 激活态渐变(各按钮专属色) === */
.glass-btn.is-active-emoji { background: linear-gradient(135deg, rgba(251, 191, 36, 0.9) 0%, rgba(245, 158, 11, 0.9) 100%); border-color: rgba(255, 255, 255, 0.4); }
.glass-btn.is-active-emoji::before { content: ""; position: absolute; inset: 1px; border-radius: 50%; background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, transparent 50%); pointer-events: none; }
.glass-btn.is-active-file { background: linear-gradient(135deg, rgba(59, 130, 246, 0.9) 0%, rgba(37, 99, 235, 0.9) 100%); border-color: rgba(255, 255, 255, 0.4); }
.glass-btn.is-active-file::before { content: ""; position: absolute; inset: 1px; border-radius: 50%; background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, transparent 50%); pointer-events: none; }
.glass-btn.is-active-voice { background: linear-gradient(135deg, rgba(139, 92, 246, 0.9) 0%, rgba(124, 58, 237, 0.9) 100%); border-color: rgba(255, 255, 255, 0.4); }
.glass-btn.is-active-voice::before { content: ""; position: absolute; inset: 1px; border-radius: 50%; background: linear-gradient(180deg, rgba(255, 255, 255, 0.35) 0%, transparent 50%); pointer-events: none; }
/* 语音录音中(红色脉冲) */
.glass-btn.is-recording {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.95) 0%, rgba(220, 38, 38, 0.95) 100%);
border-color: rgba(255, 255, 255, 0.4);
animation: recordingPulse 1.5s ease-in-out infinite;
}
@keyframes recordingPulse {
0%, 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0), 0 1px 0 rgba(255, 255, 255, 0.9) inset; }
50% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0.15), 0 1px 0 rgba(255, 255, 255, 0.9) inset; }
}
/* === 坐席按钮(图片头像) === */
.agent-btn {
width: 44px;
height: 44px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
position: relative;
z-index: 2;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
overflow: hidden;
padding: 0;
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%) padding-box;
}
.agent-btn::before {
content: "";
position: absolute;
inset: -2px;
border-radius: 50%;
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%);
z-index: -1;
}
.agent-btn img {
width: 100%;
height: 100%;
border-radius: 50%;
object-fit: cover;
object-position: center 15%;
display: block;
}
.agent-btn:hover {
transform: translateY(-1px) scale(1.05);
}
.agent-btn.is-active { background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%) padding-box; box-shadow: 0 4px 12px rgba(99, 102, 241, 0.3); }
.agent-btn.is-disabled { opacity: 0.4; filter: grayscale(0.7); }
.agent-btn.is-urgent { border: 2.5px solid #ef4444; box-shadow: 0 0 0 0 rgba(239, 68, 68, 0), 0 4px 12px rgba(239, 68, 68, 0.3); animation: recordingPulse 1.5s ease-in-out infinite; }
.agent-btn.is-waiting { border: 2.5px solid #f59e0b; box-shadow: 0 4px 12px rgba(245, 158, 11, 0.3); }
.agent-btn.is-end { border: 2.5px solid #10b981; box-shadow: 0 4px 12px rgba(16, 185, 129, 0.3); }
.agent-btn.is-reopen { border: 2.5px solid #3b82f6; box-shadow: 0 4px 12px rgba(59, 130, 246, 0.3); }
/* === 模拟消息 === */
.mock-msg {
max-width: 70%;
padding: 8px 12px;
border-radius: 12px;
margin-bottom: 8px;
font-size: 13px;
line-height: 1.5;
}
.mock-msg.user {
margin-left: auto;
background: linear-gradient(135deg, #6366f1, #8b5cf6);
color: #fff;
}
.mock-msg.ai {
background: rgba(255, 255, 255, 0.8);
color: #1a1a1a;
border: 1px solid rgba(99, 102, 241, 0.1);
}
.mock-msg .meta { font-size: 10.5px; opacity: 0.7; margin-bottom: 2px; color: #6b7280; }
.mock-msg.user .meta { color: rgba(255, 255, 255, 0.7); }
/* === 演示容器 === */
.demo-block {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 18px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 1px 0 rgba(255, 255, 255, 0.9) inset, 0 8px 16px rgba(99, 102, 241, 0.06);
}
.demo-label { font-size: 11.5px; color: #6b7280; text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 14px; font-weight: 500; }
.state-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 20px; padding-bottom: 16px; border-bottom: 1px solid rgba(99, 102, 241, 0.12); }
.state-tab {
padding: 6px 12px;
border: 1px solid rgba(99, 102, 241, 0.2);
background: rgba(255, 255, 255, 0.6);
border-radius: 8px;
cursor: pointer;
font-size: 12.5px;
color: #4b5563;
transition: all 0.15s;
font-family: inherit;
}
.state-tab:hover { background: rgba(255, 255, 255, 0.8); }
.state-tab.active { background: linear-gradient(135deg, #6366f1, #8b5cf6); color: #fff; border-color: transparent; }
.note { background: rgba(254, 243, 199, 0.6); border-left: 3px solid #d97706; padding: 12px 16px; border-radius: 4px; font-size: 12.5px; color: #78350f; margin: 16px 0; backdrop-filter: blur(10px); }
.state-desc { color: #4b5563; font-size: 12.5px; line-height: 1.7; background: rgba(255, 255, 255, 0.5); padding: 12px 14px; border-radius: 10px; border-left: 2px solid #6366f1; margin-top: 12px; backdrop-filter: blur(10px); }
.state-desc strong { color: #1a1a1a; }
</style>
</head>
<body>
<svg width="0" height="0" style="position: absolute;">
<defs>
<linearGradient id="g-emoji" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fde68a"/><stop offset="60%" stop-color="#fbbf24"/><stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<radialGradient id="g-emoji-blush" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(244, 63, 94, 0.4)"/>
<stop offset="100%" stop-color="rgba(244, 63, 94, 0)"/>
</radialGradient>
<linearGradient id="g-highlight" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.7"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="g-doc-body" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ffffff"/><stop offset="100%" stop-color="#dbeafe"/>
</linearGradient>
<linearGradient id="g-doc-fold" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#c7d2fe"/><stop offset="100%" stop-color="#6366f1"/>
</linearGradient>
<linearGradient id="g-doc-text" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
<linearGradient id="g-mic-body" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#c4b5fd"/><stop offset="50%" stop-color="#8b5cf6"/><stop offset="100%" stop-color="#6d28d9"/>
</linearGradient>
<linearGradient id="g-mic-shine" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.65"/><stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
</defs>
</svg>
<h1>工具栏统一设计 v1.0 — 消息框内上方 4 按钮</h1>
<p class="meta">表情/文件/语音/坐席 · 消息流上方居中 · v0.5 玻璃拟态 · 高级感 SVG + 真实图片头像 · 2026-08-04 凌晨</p>
<div class="note">
📌 本版核心改动(对比 v0.9):<br>
<strong>工具栏位置改了!</strong> — 不再在 InputBar 内部,而是<strong>消息流(消息框)内上方居中</strong>(在 ChatPanel 里,不在 ChatPanel 输入栏里)<br>
<strong>4 按钮加回坐席!</strong> — 表情/文件/语音/坐席(你提醒我别忘)<br>
• 坐席按钮用你提供的 3D 拟人吉祥物图片(沿用 v0.8)<br>
• 3 高级感 SVG 按钮(表情/文件/语音)沿用 v0.9 升级版
</div>
<h2>🎨 ChatPanel 消息框(工具栏在消息流上方)</h2>
<div class="chatpanel-mock">
<div class="chatpanel-mock-header">
<span class="dot"></span>
<span>IT 智能服务台 · 在线 · 客服小王</span>
</div>
<div class="chatpanel-mock-messages">
<!-- 4 按钮工具栏(消息流上方居中) -->
<div class="glass-toolbar" id="toolbar-stage">
<!-- 1. 表情(高级感) -->
<button class="glass-btn" title="表情">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" fill="url(#g-emoji)"/>
<ellipse cx="12" cy="6" rx="6" ry="2.5" fill="url(#g-highlight)"/>
<ellipse cx="6.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="17.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="8.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<ellipse cx="15.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<circle cx="8.9" cy="10.4" r="0.5" fill="white"/>
<circle cx="15.9" cy="10.4" r="0.5" fill="white"/>
<path d="M8 15 Q12 18 16 15" fill="none" stroke="#1e1b4b" stroke-width="1.6" stroke-linecap="round"/>
<path d="M8 15 Q12 17 16 15" fill="rgba(244, 63, 94, 0.2)"/>
</svg>
</button>
<!-- 2. 文件(高级感) -->
<button class="glass-btn" title="文件">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4 2 L14 2 L20 8 L20 20 L4 20 Z" fill="url(#g-doc-body)" stroke="#818cf8" stroke-width="0.8" stroke-linejoin="round"/>
<path d="M14 2 L14 8 L20 8" fill="url(#g-doc-fold)" stroke="#818cf8" stroke-width="0.8" stroke-linejoin="round"/>
<ellipse cx="10" cy="4" rx="5" ry="0.8" fill="url(#g-highlight)"/>
<rect x="7" y="11.5" width="10" height="0.8" rx="0.4" fill="url(#g-doc-text)"/>
<rect x="7" y="14" width="8" height="0.8" rx="0.4" fill="url(#g-doc-text)" opacity="0.7"/>
<rect x="7" y="16.5" width="6" height="0.8" rx="0.4" fill="url(#g-doc-text)" opacity="0.5"/>
</svg>
</button>
<!-- 3. 语音输入(高级感) -->
<button class="glass-btn" title="语音输入">
<svg viewBox="0 0 24 24" fill="none">
<rect x="8" y="2" width="8" height="13" rx="4" fill="url(#g-mic-body)"/>
<rect x="9" y="3" width="2" height="11" rx="1" fill="url(#g-mic-shine)"/>
<line x1="10.5" y1="6" x2="10.5" y2="11" stroke="white" stroke-width="0.3" opacity="0.4"/>
<line x1="12" y1="5" x2="12" y2="12" stroke="white" stroke-width="0.3" opacity="0.4"/>
<line x1="13.5" y1="6" x2="13.5" y2="11" stroke="white" stroke-width="0.3" opacity="0.4"/>
<path d="M3 12 Q3 18 12 18 Q21 18 21 12" fill="none" stroke="url(#g-mic-body)" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="18" x2="12" y2="22" stroke="url(#g-mic-body)" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="22" x2="16" y2="22" stroke="url(#g-mic-body)" stroke-width="2" stroke-linecap="round"/>
</svg>
</button>
<!-- 4. 坐席(真实图片) -->
<button class="agent-btn is-active" title="客服在线 · 点击呼叫">
<img src="./agent-avatar-v0.8.jpg" alt="客服" />
</button>
</div>
<!-- 模拟消息流 -->
<div class="mock-msg user">
<div class="meta">我 10:23</div>
你好,我电脑经常蓝屏
</div>
<div class="mock-msg ai">
<div class="meta">AI 10:23</div>
您好!电脑蓝屏可能由驱动/内存/温度等问题引起。建议先尝试安全模式启动...
</div>
<div class="mock-msg user">
<div class="meta">我 10:25</div>
还有其他办法吗?紧急,需要尽快恢复
</div>
<div style="text-align: center; color: #9ca3af; font-size: 11.5px; margin-top: 12px;">
↓ 在消息流下方继续输入 ↓
</div>
</div>
</div>
<h2>🎬 4 按钮不同状态演示</h2>
<div class="demo-block">
<div class="state-tabs">
<button class="state-tab active" data-state="default">默认</button>
<button class="state-tab" data-state="emoji">表情激活</button>
<button class="state-tab" data-state="file">文件激活</button>
<button class="state-tab" data-state="voice-active">语音激活</button>
<button class="state-tab" data-state="voice-recording">语音录音中</button>
<button class="state-tab" data-state="agent-active">坐席已呼叫</button>
<button class="state-tab" data-state="agent-urgent">坐席紧急</button>
</div>
<div class="chatpanel-mock">
<div class="chatpanel-mock-messages" style="min-height: 100px;">
<div class="glass-toolbar" id="state-stage"></div>
</div>
</div>
<div class="state-desc" id="state-desc"></div>
</div>
<h2>📊 v1.0 vs 之前版本关键差异</h2>
<table style="width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 12.5px;">
<thead>
<tr style="background: rgba(255, 255, 255, 0.6);">
<th style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px; text-align: left;">维度</th>
<th style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px; text-align: left;">v0.9</th>
<th style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px; text-align: left;"><strong>v1.0 终极</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">按钮数</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">3(去掉坐席)</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;"><strong>4(加回坐席)</strong></td>
</tr>
<tr>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">位置</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">InputBar 内部工具栏</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;"><strong>消息框内上方居中(不在 InputBar)</strong></td>
</tr>
<tr>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">3 按钮设计</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">高级感 SVG</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;"><strong>沿用(相同)</strong></td>
</tr>
<tr>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">坐席按钮</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;">✗ 没有</td>
<td style="border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px;"><strong>✓ 图片头像(你提供)</strong></td>
</tr>
</tbody>
</table>
<h2>❓ 待你拍板</h2>
<ol style="margin-left: 20px; line-height: 1.8;">
<li><strong>位置改了</strong>(消息框内上方,不是 InputBar 内)OK 吗?</li>
<li><strong>4 按钮加回坐席</strong>OK 吗?(表情/文件/语音/坐席)</li>
<li><strong>坐席按钮 44px</strong>(比其他 40px 略大,突出头像)OK 吗?</li>
<li><strong>整体设计</strong>可以开始实施了吗?</li>
</ol>
<p style="margin-top: 32px; padding-top: 16px; border-top: 1px solid rgba(99, 102, 241, 0.15); color: #6b7280; font-size: 11.5px;">
v1.0 消息框内上方 4 按钮 · 2026-08-04 · Duckula 主理人 · 等待用户拍板
</p>
<script>
const stateMap = {
'default': {
toolbar: '',
desc: '<strong>默认状态</strong>: 4 按钮全部默认(浅玻璃) — 表情/文件/语音/坐席(图片头像紫粉渐变边框)都未激活'
},
'emoji': {
toolbar: 'emojiActive',
desc: '<strong>表情激活</strong>: 表情按钮变<strong>黄橙渐变</strong> — 表情面板已打开(emoji 网格弹出)'
},
'file': {
toolbar: 'fileActive',
desc: '<strong>文件激活</strong>: 文件按钮变<strong>蓝紫渐变</strong> — 文件选择器已打开(选择图片/文档)'
},
'voice-active': {
toolbar: 'voiceActive',
desc: '<strong>语音激活</strong>: 语音按钮变<strong>紫粉渐变</strong> — 准备录音(用户可点击开始)'
},
'voice-recording': {
toolbar: 'voiceRecording',
desc: '<strong>语音录音中</strong>: 语音按钮变<strong>红色 + 1.5s 发光呼吸</strong> — 正在录音(用户可点击停止)'
},
'agent-active': {
toolbar: 'agentActive',
desc: '<strong>坐席已呼叫</strong>: 坐席按钮<strong>紫粉渐变边框 + 阴影</strong> — 呼叫成功,等待坐席接入'
},
'agent-urgent': {
toolbar: 'agentUrgent',
desc: '<strong>坐席紧急</strong>: 坐席按钮<strong>红色边框 + 1.5s 发光呼吸</strong> — 紧急关键词命中,坐席直接接入'
}
};
const svgEmoji = `<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" fill="url(#g-emoji)"/>
<ellipse cx="12" cy="6" rx="6" ry="2.5" fill="url(#g-highlight)"/>
<ellipse cx="6.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="17.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="8.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<ellipse cx="15.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<circle cx="8.9" cy="10.4" r="0.5" fill="white"/>
<circle cx="15.9" cy="10.4" r="0.5" fill="white"/>
<path d="M8 15 Q12 18 16 15" fill="none" stroke="#1e1b4b" stroke-width="1.6" stroke-linecap="round"/>
<path d="M8 15 Q12 17 16 15" fill="rgba(244, 63, 94, 0.2)"/>
</svg>`;
const svgFile = `<svg viewBox="0 0 24 24" fill="none">
<path d="M4 2 L14 2 L20 8 L20 20 L4 20 Z" fill="url(#g-doc-body)" stroke="#818cf8" stroke-width="0.8" stroke-linejoin="round"/>
<path d="M14 2 L14 8 L20 8" fill="url(#g-doc-fold)" stroke="#818cf8" stroke-width="0.8" stroke-linejoin="round"/>
<ellipse cx="10" cy="4" rx="5" ry="0.8" fill="url(#g-highlight)"/>
<rect x="7" y="11.5" width="10" height="0.8" rx="0.4" fill="url(#g-doc-text)"/>
<rect x="7" y="14" width="8" height="0.8" rx="0.4" fill="url(#g-doc-text)" opacity="0.7"/>
<rect x="7" y="16.5" width="6" height="0.8" rx="0.4" fill="url(#g-doc-text)" opacity="0.5"/>
</svg>`;
const svgVoice = `<svg viewBox="0 0 24 24" fill="none">
<rect x="8" y="2" width="8" height="13" rx="4" fill="url(#g-mic-body)"/>
<rect x="9" y="3" width="2" height="11" rx="1" fill="url(#g-mic-shine)"/>
<line x1="10.5" y1="6" x2="10.5" y2="11" stroke="white" stroke-width="0.3" opacity="0.4"/>
<line x1="12" y1="5" x2="12" y2="12" stroke="white" stroke-width="0.3" opacity="0.4"/>
<line x1="13.5" y1="6" x2="13.5" y2="11" stroke="white" stroke-width="0.3" opacity="0.4"/>
<path d="M3 12 Q3 18 12 18 Q21 18 21 12" fill="none" stroke="url(#g-mic-body)" stroke-width="2" stroke-linecap="round"/>
<line x1="12" y1="18" x2="12" y2="22" stroke="url(#g-mic-body)" stroke-width="2" stroke-linecap="round"/>
<line x1="8" y1="22" x2="16" y2="22" stroke="url(#g-mic-body)" stroke-width="2" stroke-linecap="round"/>
</svg>`;
const imgAgent = `<img src="./agent-avatar-v0.8.jpg" alt="客服" />`;
function renderToolbar(stage, state) {
const emojiClass = state === 'emojiActive' ? 'is-active-emoji' : '';
const fileClass = state === 'fileActive' ? 'is-active-file' : '';
const voiceClass = state === 'voiceActive' ? 'is-active-voice' : (state === 'voiceRecording' ? 'is-recording' : '');
const agentClass = state === 'agentActive' ? 'is-active' : (state === 'agentUrgent' ? 'is-urgent' : 'is-active');
stage.innerHTML = `
<button class="glass-btn ${emojiClass}" title="表情">${svgEmoji}</button>
<button class="glass-btn ${fileClass}" title="文件">${svgFile}</button>
<button class="glass-btn ${voiceClass}" title="语音输入">${svgVoice}</button>
<button class="agent-btn ${agentClass}" title="客服在线 · 点击呼叫">${imgAgent}</button>
`;
}
const toolbarStage = document.getElementById('toolbar-stage');
const stateStage = document.getElementById('state-stage');
const stateDesc = document.getElementById('state-desc');
const stateTabs = document.querySelectorAll('.state-tab');
function renderAll(state) {
const s = stateMap[state];
if (toolbarStage) renderToolbar(toolbarStage, s.toolbar);
if (stateStage) renderToolbar(stateStage, s.toolbar);
if (stateDesc) stateDesc.innerHTML = s.desc;
}
let currentState = 'default';
stateTabs.forEach(tab => {
tab.addEventListener('click', () => {
stateTabs.forEach(t => t.classList.remove('active'));
tab.classList.add('active');
currentState = tab.dataset.state;
renderAll(currentState);
});
});
renderAll('default');
</script>
</body>
</html>
@@ -0,0 +1,458 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title>工具栏统一设计 v1.6 - 圆角长方形 · 中央隆起</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: -apple-system, "PingFang SC", "Microsoft YaHei", sans-serif;
background: linear-gradient(135deg, #e0e7ff 0%, #f3e8ff 50%, #fce7f3 100%);
color: #1a1a1a;
padding: 24px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
h1 { font-size: 22px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 8px; color: #1a1a1a; }
h2 { font-size: 17px; font-weight: 600; letter-spacing: -0.01em; margin: 32px 0 12px; color: #1a1a1a; }
h3 { font-size: 14px; font-weight: 600; margin: 12px 0 8px; color: #333; }
.meta { color: #6b7280; font-size: 12.5px; margin-bottom: 20px; }
/* === ChatPanel 整体 === */
.chatpanel-mock {
width: 100%;
max-width: 720px;
margin: 0 auto;
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 16px;
box-shadow: 0 8px 32px rgba(99, 102, 241, 0.1);
overflow: hidden;
position: relative;
}
.chatpanel-mock-header {
padding: 12px 16px;
border-bottom: 1px solid rgba(99, 102, 241, 0.1);
background: rgba(255, 255, 255, 0.5);
font-size: 13px;
color: #6b7280;
display: flex;
align-items: center;
gap: 8px;
}
.chatpanel-mock-header .dot { width: 8px; height: 8px; border-radius: 50%; background: #10b981; }
/* === InputBar(消息发送框) === */
.inputbar-mock {
background: rgba(255, 255, 255, 0.7);
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
border-top: 1px solid rgba(99, 102, 241, 0.15);
padding: 12px 16px 16px;
position: relative;
}
/* === 普通工具栏(Beforev1.5 宝石嵌入式,带光晕) === */
.glass-toolbar {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 8px 16px;
margin: 0 auto 12px;
width: fit-content;
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(28px) saturate(180%);
-webkit-backdrop-filter: blur(28px) saturate(180%);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 20px;
box-shadow: 0 1px 0 rgba(255,255,255,0.9) inset, 0 2px 4px rgba(99,102,241,0.05), 0 8px 16px rgba(99,102,241,0.08);
position: relative;
}
.glass-btn {
width: 40px; height: 40px;
border: 1px solid rgba(255, 255, 255, 0.6);
background: rgba(255, 255, 255, 0.55);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-radius: 50%;
cursor: pointer;
display: flex; align-items: center; justify-content: center;
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
-webkit-tap-highlight-color: transparent;
position: relative; z-index: 2;
box-shadow: 0 1px 2px rgba(0,0,0,0.04), 0 2px 4px rgba(0,0,0,0.04);
}
.glass-btn:hover { background: rgba(255,255,255,0.75); transform: translateY(-1px) scale(1.06); }
.glass-btn:active { transform: translateY(0) scale(0.95); }
.glass-btn svg { width: 22px; height: 22px; display: block; }
.agent-btn {
width: 44px; height: 44px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
position: relative; z-index: 2;
padding: 0;
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%) padding-box;
}
.agent-btn::before {
content: ""; position: absolute; inset: -2px; border-radius: 50%;
background: linear-gradient(135deg, #6366f1 0%, #a855f7 50%, #ec4899 100%); z-index: -1;
}
.agent-btn img { width: 100%; height: 100%; border-radius: 50%; object-fit: cover; object-position: center 15%; display: block; }
.agent-badge { position: absolute; bottom: 0; right: 0; width: 12px; height: 12px; border-radius: 50%; border: 2px solid white; z-index: 3; box-shadow: 0 1px 2px rgba(0,0,0,0.2); }
.agent-badge.is-online { background: #10b981; }
/* === 圆角长方形 · 中央隆起工具栏(After / v1.6 === */
/* 容器:高度留出中央隆起空间,按钮不溢出 */
.gem-toolbar {
position: relative;
display: flex;
align-items: center;
justify-content: center;
width: fit-content;
height: 84px;
margin: 0 auto 12px;
}
/* 圆角长方形轨道背景(SVG):两端矮、中央因大按钮隆起自然弧线 */
.gem-toolbar-bg {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
z-index: 0;
pointer-events: none;
overflow: visible;
}
/* 按钮行(5 个,坐席居中且更大) */
.gem-row { position: relative; z-index: 2; display: flex; align-items: center; justify-content: center; gap: 14px; }
/* 人工坐席按钮 = 更大,居中,不探出,无光晕 */
.gem-toolbar .agent-btn.gem {
width: 54px;
height: 54px;
margin: 0 6px;
z-index: 3;
box-shadow: none;
}
.gem-toolbar .glass-btn { z-index: 2; }
/* === InputBar 输入框 === */
.inputbar-mock-field {
width: 100%;
min-height: 60px;
background: rgba(255, 255, 255, 0.5);
border: 1px solid rgba(99, 102, 241, 0.12);
border-radius: 12px;
padding: 10px 14px;
font-size: 13px;
color: #9ca3af;
line-height: 1.6;
}
/* === 演示容器 === */
.demo-block {
background: rgba(255, 255, 255, 0.6);
backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.7);
border-radius: 18px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 1px 0 rgba(255,255,255,0.9) inset, 0 8px 16px rgba(99,102,241,0.06);
}
.compare-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.compare-panel { background: rgba(255, 255, 255, 0.5); border: 1px solid rgba(99, 102, 241, 0.15); border-radius: 12px; padding: 16px; }
.compare-panel.before { border-left: 4px solid #f59e0b; }
.compare-panel.after { border-left: 4px solid #10b981; }
.compare-title { font-size: 13px; font-weight: 600; margin-bottom: 12px; }
.compare-panel.before .compare-title { color: #b45309; }
.compare-panel.after .compare-title { color: #047857; }
.note { background: rgba(254, 243, 199, 0.6); border-left: 3px solid #d97706; padding: 12px 16px; border-radius: 4px; font-size: 12.5px; color: #78350f; margin: 16px 0; backdrop-filter: blur(10px); }
.note-success { background: rgba(209, 250, 229, 0.6); border-left: 3px solid #10b981; padding: 12px 16px; border-radius: 4px; font-size: 12.5px; color: #064e3b; margin: 16px 0; backdrop-filter: blur(10px); }
.note-info { background: rgba(219, 234, 254, 0.6); border-left: 3px solid #3b82f6; padding: 12px 16px; border-radius: 4px; font-size: 12.5px; color: #1e3a8a; margin: 16px 0; backdrop-filter: blur(10px); }
table { width: 100%; border-collapse: collapse; margin: 16px 0; font-size: 12.5px; }
thead tr { background: rgba(255, 255, 255, 0.6); }
th { border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px; text-align: left; font-weight: 600; }
td { border: 1px solid rgba(99, 102, 241, 0.15); padding: 8px; }
</style>
</head>
<body>
<svg width="0" height="0" style="position: absolute;">
<defs>
<linearGradient id="g-emoji" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stop-color="#fde68a"/><stop offset="60%" stop-color="#fbbf24"/><stop offset="100%" stop-color="#d97706"/>
</linearGradient>
<radialGradient id="g-emoji-blush" cx="50%" cy="50%" r="50%">
<stop offset="0%" stop-color="rgba(244, 63, 94, 0.4)"/>
<stop offset="100%" stop-color="rgba(244, 63, 94, 0)"/>
</radialGradient>
<linearGradient id="g-highlight" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="white" stop-opacity="0.7"/>
<stop offset="100%" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="g-doc-body" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#ffffff"/><stop offset="100%" stop-color="#dbeafe"/>
</linearGradient>
<linearGradient id="g-doc-fold" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#c7d2fe"/><stop offset="100%" stop-color="#6366f1"/>
</linearGradient>
<linearGradient id="g-doc-text" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#6366f1"/><stop offset="100%" stop-color="#a855f7"/>
</linearGradient>
<linearGradient id="g-mic-body" x1="50%" y1="0%" x2="50%" y2="100%">
<stop offset="0%" stop-color="#c4b5fd"/><stop offset="50%" stop-color="#8b5cf6"/><stop offset="100%" stop-color="#6d28d9"/>
</linearGradient>
<linearGradient id="g-group" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#a5b4fc"/><stop offset="100%" stop-color="#c4b5fd"/>
</linearGradient>
<linearGradient id="g-group-strong" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" stop-color="#6366f1"/><stop offset="50%" stop-color="#a855f7"/><stop offset="100%" stop-color="#ec4899"/>
</linearGradient>
<!-- 圆角长方形轨道渐变 -->
<linearGradient id="g-rail" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#818cf8" stop-opacity="0.5"/>
<stop offset="50%" stop-color="#c4b5fd" stop-opacity="0.9"/>
<stop offset="100%" stop-color="#818cf8" stop-opacity="0.5"/>
</linearGradient>
</defs>
</svg>
<h1>工具栏统一设计 v1.6 — 圆角长方形 · 中央隆起</h1>
<p class="meta">v1.5 修正版:工具栏保持圆角长方形(两端上下矮),中央因「人工坐席」按钮更高而将上下边框撑出自然弧线;坐席按钮不探出、无光晕、工具按钮不溢出 · 2026-08-05</p>
<div class="note-info">
📐 <strong>v1.6 改动说明(修正 v1.5 的 4 点不符合预期)</strong><br>
<strong>工具栏形态</strong>:保持圆角长方形,两端上下高度变窄(约 48px),<u>不是</u>镜片/花瓣形<br>
<strong>坐席按钮居中隆起</strong>:人工坐席按钮(54px)大于其他图标(40px),其高度将工具栏上下边框从两端向中央平滑撑出弧线,按钮<u>不探出</u>轨道<br>
<strong>去除光晕</strong>:去掉 v1.5 的白环 / 紫晕 / 切面高光 / 外发光,坐席按钮仅保留渐变描边<br>
<strong>工具按钮不溢出</strong>:4 个工具按钮(40px)在两端矮轨道上各有约 4px 余量,完全收在轨道内
</div>
<h2>🎯 v1.6 目标对比(Before / After</h2>
<div class="demo-block">
<div class="compare-grid">
<!-- BEFORE: v1.5 宝石嵌入式(带光晕、坐席探出) -->
<div class="compare-panel before">
<div class="compare-title">v1.5(上一版:宝石嵌入式,不符合预期)</div>
<div style="text-align: center; padding: 12px; background: rgba(254, 243, 199, 0.4); border-radius: 10px;">
<div class="glass-toolbar" style="margin-bottom: 8px;">
<button class="glass-btn" title="表情">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" fill="url(#g-emoji)"/>
<ellipse cx="12" cy="6" rx="6" ry="2.5" fill="url(#g-highlight)"/>
<ellipse cx="6.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="17.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="8.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<ellipse cx="15.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<circle cx="8.9" cy="10.4" r="0.5" fill="white"/>
<circle cx="15.9" cy="10.4" r="0.5" fill="white"/>
<path d="M8 15 Q12 18 16 15" fill="none" stroke="#1e1b4b" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</button>
<button class="glass-btn" title="文件">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4 2 L14 2 L20 8 L20 20 L4 20 Z" fill="url(#g-doc-body)" stroke="#818cf8" stroke-width="0.8"/>
<path d="M14 2 L14 8 L20 8" fill="url(#g-doc-fold)" stroke="#818cf8" stroke-width="0.8"/>
<rect x="7" y="11.5" width="10" height="0.8" rx="0.4" fill="url(#g-doc-text)"/>
</svg>
</button>
<button class="agent-btn" title="客服在线" style="box-shadow:0 0 0 3px rgba(255,255,255,0.6),0 0 0 5px rgba(168,85,247,0.35),0 6px 14px rgba(99,102,241,0.4); transform: translateY(-14px);">
<img src="./agent-avatar-v0.8.jpg" alt="客服"/>
<span class="agent-badge is-online"></span>
</button>
<button class="glass-btn" title="语音输入">
<svg viewBox="0 0 24 24" fill="none">
<rect x="8" y="2" width="8" height="13" rx="4" fill="url(#g-mic-body)"/>
<path d="M3 12 Q3 18 12 18 Q21 18 21 12" fill="none" stroke="url(#g-mic-body)" stroke-width="2"/>
<line x1="12" y1="18" x2="12" y2="22" stroke="url(#g-mic-body)" stroke-width="2"/>
</svg>
</button>
<button class="glass-btn" title="群聊">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="8" cy="9" r="2.2" fill="url(#g-group)"/>
<path d="M5 16 Q5 13 8 13 Q11 13 11 16 Z" fill="url(#g-group)"/>
<circle cx="16" cy="9" r="2.2" fill="url(#g-group)"/>
<path d="M13 16 Q13 13 16 13 Q19 13 19 16 Z" fill="url(#g-group)"/>
<circle cx="12" cy="10.5" r="2.8" fill="url(#g-group-strong)"/>
<path d="M8.5 18.5 Q8.5 14 12 14 Q15.5 14 15.5 18.5 Z" fill="url(#g-group-strong)"/>
</svg>
</button>
</div>
<div class="inputbar-mock-field" style="min-height: 36px; padding: 6px 12px; font-size: 12px;">请输入消息...</div>
</div>
<div style="font-size: 12px; color: #92400e; margin-top: 8px;">
⚠️ <strong>问题</strong>: 镜片形 + 光晕 + 坐席探出,不符合预期
</div>
</div>
<!-- AFTER: v1.6 圆角长方形中央隆起 -->
<div class="compare-panel after">
<div class="compare-title">v1.6(目标:圆角长方形 · 中央隆起)</div>
<div style="text-align: center; padding: 12px; background: rgba(209, 250, 229, 0.4); border-radius: 10px;">
<!-- 圆角长方形 · 中央隆起工具栏 -->
<div class="gem-toolbar">
<svg class="gem-toolbar-bg" viewBox="0 0 282 84" preserveAspectRatio="xMidYMid meet">
<!-- 圆角长方形轨道:两端矮、中央因大按钮隆起自然弧线 -->
<path d="M 28 18
L 94 18
C 102 18, 106 11, 114 11
L 168 11
C 176 11, 180 18, 188 18
L 254 18
C 262 18, 268 24, 268 32
L 268 52
C 268 60, 262 66, 254 66
L 188 66
C 180 66, 176 73, 168 73
L 114 73
C 106 73, 102 66, 94 66
L 28 66
C 14 66, 14 60, 14 52
L 14 32
C 14 24, 20 18, 28 18 Z"
fill="url(#g-rail)" stroke="rgba(255,255,255,0.85)" stroke-width="1.2"/>
</svg>
<div class="gem-row">
<button class="glass-btn" title="表情">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="9" fill="url(#g-emoji)"/>
<ellipse cx="12" cy="6" rx="6" ry="2.5" fill="url(#g-highlight)"/>
<ellipse cx="6.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="17.5" cy="14" rx="1.5" ry="1" fill="url(#g-emoji-blush)"/>
<ellipse cx="8.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<ellipse cx="15.5" cy="11" rx="1.4" ry="1.6" fill="#1e1b4b"/>
<circle cx="8.9" cy="10.4" r="0.5" fill="white"/>
<circle cx="15.9" cy="10.4" r="0.5" fill="white"/>
<path d="M8 15 Q12 18 16 15" fill="none" stroke="#1e1b4b" stroke-width="1.6" stroke-linecap="round"/>
</svg>
</button>
<button class="glass-btn" title="文件">
<svg viewBox="0 0 24 24" fill="none">
<path d="M4 2 L14 2 L20 8 L20 20 L4 20 Z" fill="url(#g-doc-body)" stroke="#818cf8" stroke-width="0.8"/>
<path d="M14 2 L14 8 L20 8" fill="url(#g-doc-fold)" stroke="#818cf8" stroke-width="0.8"/>
<rect x="7" y="11.5" width="10" height="0.8" rx="0.4" fill="url(#g-doc-text)"/>
</svg>
</button>
<!-- 人工坐席按钮:更大、居中、不探出、无光晕 -->
<button class="agent-btn gem" title="客服在线">
<img src="./agent-avatar-v0.8.jpg" alt="客服"/>
<span class="agent-badge is-online"></span>
</button>
<button class="glass-btn" title="语音输入">
<svg viewBox="0 0 24 24" fill="none">
<rect x="8" y="2" width="8" height="13" rx="4" fill="url(#g-mic-body)"/>
<path d="M3 12 Q3 18 12 18 Q21 18 21 12" fill="none" stroke="url(#g-mic-body)" stroke-width="2"/>
<line x1="12" y1="18" x2="12" y2="22" stroke="url(#g-mic-body)" stroke-width="2"/>
</svg>
</button>
<button class="glass-btn" title="群聊">
<svg viewBox="0 0 24 24" fill="none">
<circle cx="8" cy="9" r="2.2" fill="url(#g-group)"/>
<path d="M5 16 Q5 13 8 13 Q11 13 11 16 Z" fill="url(#g-group)"/>
<circle cx="16" cy="9" r="2.2" fill="url(#g-group)"/>
<path d="M13 16 Q13 13 16 13 Q19 13 19 16 Z" fill="url(#g-group)"/>
<circle cx="12" cy="10.5" r="2.8" fill="url(#g-group-strong)"/>
<path d="M8.5 18.5 Q8.5 14 12 14 Q15.5 14 15.5 18.5 Z" fill="url(#g-group-strong)"/>
</svg>
</button>
</div>
</div>
<div class="inputbar-mock-field" style="min-height: 36px; padding: 6px 12px; font-size: 12px;">请输入消息...</div>
</div>
<div style="font-size: 12px; color: #065f46; margin-top: 8px;">
<strong>效果</strong>: 圆角长方形两端矮,中央因大坐席按钮自然隆起弧线;坐席不探出、无光晕;工具按钮不溢出
</div>
</div>
</div>
</div>
<h2>📊 v1.5 vs v1.6 改动对比</h2>
<table>
<thead>
<tr>
<th>维度</th>
<th>v1.5(宝石嵌入式)</th>
<th><strong>v1.6(圆角长方形·中央隆起)</strong></th>
</tr>
</thead>
<tbody>
<tr>
<td><strong>工具栏整体形态</strong></td>
<td>镜片/花瓣形(两侧曲线外鼓)</td>
<td><strong>圆角长方形</strong>(两端上下矮,整体仍是矩形轮廓)</td>
</tr>
<tr>
<td><strong>中央隆起成因</strong></td>
<td>整体镜片造型</td>
<td><strong>人工坐席按钮更高(54px &gt; 40px),将上下边框撑出弧线</strong></td>
</tr>
<tr>
<td><strong>坐席按钮是否探出</strong></td>
<td>是(translateY 上探)</td>
<td><strong></strong>(完全收在轨道内,居中)</td>
</tr>
<tr>
<td><strong>光晕/高光</strong></td>
<td>白环 + 紫晕 + 切面高光 + 外发光</td>
<td><strong>全部去除</strong>(仅渐变描边)</td>
</tr>
<tr>
<td><strong>工具按钮溢出</strong></td>
<td>两端略探出轨道</td>
<td><strong>不溢出</strong>(两端各有约 4px 余量)</td>
</tr>
</tbody>
</table>
<h2>🛠️ 需要修改的文件清单</h2>
<table>
<thead>
<tr>
<th>文件</th>
<th>改动</th>
<th>影响</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>src/frontend-h5/src/components/chat/InputBar.vue</code></td>
<td><strong></strong>:工具栏背景 SVG path 改为圆角长方形(两端矮、中央因坐席按钮隆起);坐席按钮尺寸 54px、去除光晕样式、取消 translateY;工具按钮保持 40px 并确保在轨道内不溢出</td>
<td>本文件工具栏局部重构</td>
</tr>
<tr>
<td><code>IntegrationZone.vue</code></td>
<td><strong>不动</strong>v1.3 已删除坐席按钮)</td>
<td></td>
</tr>
<tr>
<td><code>inputBarCallAgentState.ts</code> / <code>integrationZoneLogic.ts</code></td>
<td><strong>不动</strong>helper 保留)</td>
<td></td>
</tr>
<tr>
<td><code>InputBar.test.ts</code></td>
<td>补充圆角长方形轨道 + 坐席居中不溢出结构测试</td>
<td>测试同步更新</td>
</tr>
</tbody>
</table>
<h2>⚠️ 注意事项与待确认点</h2>
<ol style="margin-left: 20px; line-height: 1.8; font-size: 13px;">
<li><strong>两端高度</strong>:当前两端高 48px、中央隆起高 62px(坐席 54px + 上下各 4px)。若想两端更「扁」或中央更「鼓」,调 SVG path 的 y 值</li>
<li><strong>坐席按钮尺寸</strong>:54px(其他 40px)。放大/缩小坐席即同步调整隆起幅度</li>
<li><strong>弧线平滑度</strong>:由 path 控制点(C 102 18,106 11,114 11 等)决定,可调曲度</li>
<li><strong>无光晕</strong>:已去除全部 halo/glow;如后续想要极淡阴影可单独加,默认保持干净</li>
<li><strong>头像占位图</strong><code>./agent-avatar-v0.8.jpg</code> 为占位路径,实际集成时替换</li>
</ol>
<p style="margin-top: 32px; padding-top: 16px; border-top: 1px solid rgba(99, 102, 241, 0.15); color: #6b7280; font-size: 11.5px;">
v1.6 圆角长方形 · 中央隆起 · 2026-08-05 · Duckula 主理人 · 等待用户拍板进入开发
</p>
</body>
</html>
@@ -0,0 +1,507 @@
# PRD — 坐席端会话条目操作菜单(左栏三点菜单 + 右键菜单)
> **REQ编号**: REQ-坐席-012
> **版本**: v1.0
> **状态**: 🟡 待评审 — 交互形态与中栏取舍已由需求方拍板,菜单矩阵与组件边界待技术评审
> **优先级**: P1
> **日期**: 2026-08-09
> **作者**: 许清楚(产品经理)
> **需求来源**: 宋献(IT 支持组组长)口述需求 4 条
> **技术依据**: `docs/02-技术文档/技术架构/技术核查-坐席会话条目操作菜单-v1.0.md`(高见远,2026-08-09
> **配套原型**: `原型-REQ-坐席-000-坐席工作台-v1.7.html`
> **关联**: PRD-REQ-坐席-011(统一工作队列重构)、REQ-坐席-004、REQ-坐席-009、REQ-坐席-010
> **性质**: 增量 PRD。仅描述本次新增/变更,不复述既有功能。
---
## 1. 背景
### 1.1 需求原文与映射
| # | 宋献原始需求 | 本 PRD 对应 |
|---|---|---|
| 1 | 确认现有坐席架构,控件能否在「选中对象-右键调出菜单」模式实现 | §3.1 D-3、§4.3 |
| 2 | 左栏每个会话表单右边增加菜单按钮(三个点),点击弹出接单/置顶/代办/转接 | §4.1、§4.2、§5 |
| 3 | 上述完成后,去除中栏上方「接单/置顶/代办/转接」4 个按钮 | §3.1 D-2、§6 |
| 4 | 先基于最新原型更新细致原型图,与用户确认后再改代码 | §3.1 D-1、§10 |
### 1.2 🔴 前置事实修正(必须在评审开场说明)
需求提出时的前置认知是「V1.0–V1.6 原型中这 4 个按钮丢失了」。经架构师代码核查,**该认知需要修正两处**:
| 认知 | 事实 |
|---|---|
| 「按钮丢失了」 | ❌ **4 个按钮在真实运行代码中从未丢失**。接单 `UserInfoBar.vue:116-124`、置顶 `:127-133`、代办 `:136-142`、转接 `:145-164`,且四者均为**服务端真闭环**(store → API → 后端端点,非 Mock) |
| 「从 v1.5 开始丢失」 | ❌ 丢失只发生在**原型 HTML 侧**,且起始版本是 **v1.4**。v1.0–v1.3 均含 4 按钮(关键词计数实证:接单 11-12 次 / 置顶 3 次 / 代办 1 次 / 转接 2 次),v1.4 起归零 |
**根因**v1.4 起原型**换了血统**。v1.0v1.3 是 24002700 行的全量高保真原型;v1.4 是为论证「B 方案三栏分栏比例」与「排查步骤全屏流程图」而**新画的专题示意图**(961 行),并非 v1.3 的增量演进。v1.5/v1.6 沿此专题线继续演进。
**后果(这是本次必须先做字段回填的原因)**v1.6 的左栏 `.conv-item` 只有「头像/姓名/时间/摘要/未读数」5 个字段,而真实代码 `ConversationItem.vue` 的条目含:
| 元素 | 真实代码 | v1.6 原型 |
|---|---|---|
| 置顶 📌 / 代办 📋 图标 | ✅ `:50` `:52` | ❌ |
| tag-badgeVIP/招手/需介入/情绪/坐席名/待确认) | ✅ 6 类 `:56-82` | ❌ |
| 优先级图标(⛔👥⭐🔁) | ✅ 最多 4 个 `:84-95` | ❌ |
| 紧急度星级 | ✅ 5 星 `:107-114` | ❌ |
| 接手 / 退出 link 按钮 | ✅ `:116-136` | ❌ |
| 处理对象缩略头像 | ✅ `:141-148` | ❌ |
**若直接在 v1.6 上加三点按钮,会得出「条目右侧有大片空槽」的错误结论**——真实条目右侧已被 `.conv-target-avatar` 占满(`section !== 'history'` 时恒显示)。这是 §3.1 D-1 选择 v1.3 而非 v1.6 作为 v1.7 基线的直接原因。
### 1.3 本次真正的新增工作量在哪里
架构核查指出:现有 4 个按钮**几乎没有基于会话归属的可见性控制**——置顶/代办在中栏任何状态下都常驻且不禁用。这在中栏可接受(中栏只显示当前打开的会话,多为自己的);**但一旦下沉到左栏,左栏同时呈现「我的会话 / 同事会话 / 历史会话」三个分区**(`ConversationList.vue:43-76`),菜单项必须新增**归属维度**的可见性规则。
> **本次改造的产品设计核心不是"控件搬家",而是补齐一张此前不存在的「状态 × 归属 × 分区」菜单项可见性矩阵**(§5)。控件本身是低成本工作。
---
## 2. 产品目标
| # | 目标 | 衡量方式 |
|---|---|---|
| G-1 | 把会话的**列表管理动作**(接单/置顶/代办)下沉到动作对象所在处,消除「操作在中栏、效果在左栏」的焦点跳跃 | 置顶/代办操作后无需视线迁移即可看到条目重排 |
| G-2 | 在不牺牲对话过程效率的前提下精简中栏顶栏 | 中栏操作控件由 6 个降至 3 个(转接/摇人/结单),且**转接路径不退化**(保持 3 步) |
| G-3 | 菜单壳一次抽对,避免 PRD-011 Phase 2 与虚拟滚动落地时二次重写 | 新增 `kind`(审批/工单)时只加一个菜单项工厂,不改菜单壳 |
## 2.1 用户故事
| # | 故事 |
|---|---|
| US-1 | 作为坐席,我想在左栏直接对某条会话置顶/代办,这样我不必先打开它、再去中栏操作、再回来看列表变化 |
| US-2 | 作为坐席,我想在扫视排队会话时就地接单,这样我不必"先点开、再接单"两步走 |
| US-3 | 作为熟练坐席,我想右键会话条目直接出菜单,这样我不必先 hover 找到三点按钮 |
| US-4 | 作为坐席,我不想在同事的会话上看到「置顶」——点了会 403 报错,还不给我任何提示 |
| US-5 | 作为坐席,我在中栏读完员工描述判断"这不归我管"时,希望能就地转接,而不是回左栏重新定位这条会话 |
---
## 3. 已锁定决策
### 3.1 需求方已拍板项(D-1 ~ D-6
| # | 决策项 | 结论 | 依据 |
|---|---|---|---|
| **D-1** | 原型基线 | **以 v1.3 为基线复制为 v1.7**(保留高保真左/中/右三栏),再增量叠加 v1.4~v1.6 演进特征(B 方案分栏比例、排查步骤全屏流程图、智能推荐竖排、工具栏重组、Emoji 图标统一)。**不以 v1.6 为基线** | §1.2;架构报告 §1.4 / R-2 |
| **D-2** | 中栏按钮处理 | **删除「接单/置顶/代办」3 个,保留「转接」**。转接与既有的「摇人」「结单」构成语义一致的**对话过程动作组**(转接=交出去 / 摇人=拉进来 / 结单=做完了) | §6;架构报告 §5.1 |
| **D-3** | 交互形态 | **三点按钮为主入口 + 右键为快捷方式,两者并存**。二者共用同一份菜单项定义与 handler | §4.3;架构报告 §2.3 选项 3 |
| **D-4** | 菜单实例架构 | **菜单为单例**,挂在 `ConversationList` 层级,**不挂在条目内部** | §7;架构报告 §2.4-5 |
| **D-5** | 三点按钮落位 | **替换** `.conv-target-avatar`(同栅格位淡入淡出),非覆盖、非并列 | §4.1;架构报告 §4.3 |
| **D-6** | 菜单弹层定位 | `placement="bottom-end"`,宽 140px,视口下缘不足时翻转 `top-end`**DOM 必须 `position:fixed` 或 Teleport 到 body** | §4.2;架构报告 §1.3.4 |
### 3.2 D-2 的完整论证(为什么不是 4 个全删)
需求原文是「去除中栏上方 4 个按钮」。经操作路径推演,**4 个一刀切全删会造成分档不同的退化**:
| 操作 | 当前路径 | 全删后路径 | 退化判定 |
|---|---|---|---|
| 接单 | 中栏 1 步 | 左栏 定位→hover→三点→接单 | ⚪ **不退化,删除是净收益**。接单发生在打开会话**之前/之时**,左栏本就是接单的自然场所(Zendesk / ServiceNow 同构)。且中栏该按钮在 `status !== 'queued'` 时恒为 disabled 的「已接单」,长期占位不可点 |
| 置顶 | 中栏 1 步 | 左栏 4 步 | 🟡 **轻微退化,可接受**。置顶是**列表组织行为**,其效果(条目上浮)只在左栏可见。操作与反馈同处一栏,语义反而更内聚 |
| 代办 | 中栏 1 步 | 左栏 4 步 | 🟡 同上 |
| **转接** | `点转接 → 选坐席 → 确认` = **3 步** | `视线移左栏 → 定位当前条目 → hover → 点三点 → 点转接 → 选坐席 → 确认` = **6+ 步** | 🔴 **显著退化,不接受** |
**转接不可下放的三条理由**
1. **触发时机根本不同**。置顶/代办是坐席**扫视队列时**的列表管理动作;转接是坐席**读完员工描述、判断"这不归我管"那一刻**的对话过程动作——此时注意力与鼠标都在中栏。要求回左栏是**注意力焦点的强制迁移**。
2. **"定位当前条目"本身有成本**。左栏三分区、可滚动,且条目会因置顶/紧急度**动态重排**,当前会话未必在视口内。这一步不是零成本的。
3. **删了转接反而制造不一致**。中栏保留「摇人」「结单」是既定事实。若转接被赶到左栏,结果是**同为对话过程中的协作类动作,入口被劈成两处**——这比"全部在中栏"或"全部在左栏"都差。
**同时,左栏三点菜单仍提供「转接」**。两个入口共用同一 handler 与同一坐席选择 Dialog,符合"高频动作允许多入口"原则,不构成语义分裂。
> **给需求方的备选(如坚持"顶栏太挤"是主要动机)**:可将 3 个被删按钮收进中栏自己的「⋯」总菜单,视觉上顶栏只剩「摇人 / 结单 / ⋯」,功能零退化。此形态**未在 v1.7 中作为主方案呈现**,如需要请在评审时提出。
---
## 4. 交互设计
### 4.1 三点按钮(主入口)
| 项 | 规格 | 依据 |
|---|---|---|
| **位置** | `.conversation-item` 最右侧,**替换** `.conv-target-avatar` 所在栅格位 | D-5 |
| **切换方式** | 同一栅格位内:缩略头像 `opacity:0` 淡出、三点 `opacity:1` 淡入(`transition ≈ 140ms`)。**不改变布局盒模型,无抖动** | 架构报告 R-7 |
| **命中区** | 24 × 24 px;图标 `MoreFilled``@element-plus/icons-vue@^2.3.0` 已装)1416px | D-5 |
| **默认态** | 隐藏(`opacity:0; pointer-events:none` | — |
| **显示触发** | `.conversation-item:hover` `.is-menu-open` 按钮 `:focus-visible` | 保证键盘可达 |
| **菜单打开期间** | 按钮**强制常显**,条目加 `.is-menu-open` 类(背景保持 hover 态),避免"鼠标移出→按钮消失→菜单悬空"的观感断裂 | 架构报告 §2.3 选项 2 风险项 |
| **历史分区** | `section === 'history'` 本无缩略头像,三点直接占该位(可常显淡色 `opacity:.35`hover 加深) | D-5 |
| **事件** | 必须 `@click.stop` —— 根节点 `@click="$emit('click')"``ConversationItem.vue:20`,不阻断会连带切换会话。项目内已有正确先例(`:122` `:133``@click.stop` | 架构报告 §2.4-4 |
**为什么不选"覆盖"或"并列"**
- 覆盖(absolute 盖在缩略头像上):hover 时缩略头像被遮挡,信息丢失且视觉脏;
- 并列(插到缩略头像右侧):再吃掉 ~24px,`.conversation-info` 从 ~132px 压到 ~108px,第一行 6 类 tag + 4 个优先级图标严重挤压。左栏条目内部可用宽度仅 ≈ 228px260 margin 12 padding 20),没有余量。
### 4.2 菜单弹层
| 项 | 规格 |
|---|---|
| 宽度 | 140px(4 项中文两字 + 图标;不得超过侧栏 260px) |
| 定位 | `placement="bottom-end"`(右对齐向下) |
| 翻转 | 视口下缘空间不足时自动翻转 `top-end`;右缘不足时自动贴边 |
| **DOM 归属** | 🔴 **必须 `position:fixed` + 视口坐标,或 Teleport 到 body**。**禁止**以 `position:absolute` 挂在 `.conversation-item` 内部 |
| 层级 | 使用 EP popper 默认 z-index2000+ 自增)或新引入的 `--z-dropdown: 2000` token。**不得沿用 `MessageItem.vue` 的 1000**(低于 EP popper,会被 dropdown/messagebox 盖住) |
| 关闭时机 | 点击菜单外 ∪ Esc ∪ 列表滚动 ∪ 窗口 resize ∪ 切换会话 ∪ 在另一条目再次右键 |
| 互斥 | 全局仅一个菜单可见(单例天然满足,见 §7) |
**为什么 DOM 必须脱离左栏**(这是硬约束,不是优化建议):
```css
.workspace-sidebar { overflow: hidden; } /* global.css:277 */
.conversation-list-scroll { overflow-x: hidden; } /* global.css:359 */
```
双重裁剪导致:① 横向——260px 窄栏内放不下 140px 菜单向右展开,必被裁;② 纵向——列表首尾条目的菜单会被滚动容器裁掉。
### 4.3 右键(快捷方式)
| 项 | 规格 |
|---|---|
| 绑定 | `.conversation-item` 根节点 `@contextmenu.prevent.stop` |
| 定位 | 以鼠标 `clientX/clientY` 为锚点,`position:fixed` |
| 菜单内容 | **与三点按钮完全一致**——由同一个 `computed` 生成的菜单项数组驱动,不允许两套定义 |
| 覆写原生菜单 | 是(`.prevent`)。**仅在会话条目上覆写**,列表空白区、消息区、输入框保留浏览器原生右键 |
| 右键是否需先选中条目 | **否**。右键直接对该条目生效,不改变当前打开的会话(避免误切换丢失输入草稿) |
**为什么两者并存而不是二选一**
- 纯右键**发现性为零**。左栏是坐席每天使用频率最高的区域,且服务台存在**多人轮岗与新人**,不能假设全员知晓。零发现性交互在此位置是产品事故。
- 需求 1(问右键)与需求 2(要三点)**本就是同一菜单的两个触发器**,不是二选一。并存的边际成本仅 0.3–0.5 人日。
### 4.4 反馈与错误处理
| 场景 | 反馈 |
|---|---|
| 操作成功 | 条目即时反映状态变化(📌/📋 图标出现或消失、条目重排)。**不额外弹 toast**——视觉变化本身即反馈 |
| 接单成功 | 条目从「排队区」移入「我的会话」,并自动打开该会话 |
| 转接成功 | 条目移出「我的会话」,`ElMessage.success('已转接给 XXX')` |
| **后端 403 / 失败** | 🔴 必须给明确 `ElMessage.error` 文案。**现状是既有缺陷**`stores/conversation.ts:539-541``console.error`,用户零感知。本次一并修复(P0-7) |
| 无可用坐席 | 「转接」项 disabled + 灰字提示「暂无可用坐席」 |
---
## 5. 菜单项可见性矩阵(核心交付物)
> 按 会话 `status` × 归属(`is_mine` / `is_collaborator` / `can_grab`)× 分区(`section`)分派。
> 与架构报告 §4.4 完全一致,本节为其产品侧收口版本。
| 菜单项 | 显示条件 | 禁用条件 | 动态文案 | 后端权限 | 备注 |
|---|---|---|---|---|---|
| **接单** | `status === 'queued'` | — | 「接单」 | `update:all` | 排队会话专属 |
| **置顶 / 取消置顶** | `is_mine \|\| is_collaborator` | — | `is_pinned` → 「取消置顶」;否则「置顶」 | `update:own` | ⚠️ **非本人会话点击必 403**,前端必须靠显示条件拦住 |
| **代办 / 取消代办** | `is_mine \|\| is_collaborator` | — | `is_todo` → 「取消代办」;否则「代办」 | `update:own` | ⚠️ 同上 |
| **转接** | `status === 'serving' && is_mine` | `availableAgents.length === 0` → 禁用,提示「暂无可用坐席」 | 「转接…」(省略号表示会开 Dialog) | `update:all` | 点击开独立 Dialog,见 Q-3 |
| **接手** | `can_grab && !is_mine && status === 'serving'` | — | 「接手」 | `update:all` | 与第三行 `grab-btn` 同义,双入口,见 Q-4 |
| **退出协作** | `is_collaborator && status === 'serving'` | — | 「退出协作」(danger 样式) | — | 与第三行 `leave-btn` 同义,见 Q-4 |
> ⚠️ **实现细节校准(影响 A-11 双入口一致性验收)**:第三行 `grab-btn` 的显示条件在两个分区**并不相同**——
> `ConversationList.vue:49`(我的会话区)传入 `show-grab = status==='serving' && !is_mine && !is_collaborator`
> `ConversationList.vue:62`(同事会话区)传入 `show-grab = status==='serving' && !is_mine`**少了 `!is_collaborator`**)。
> 条目内再与 `conversation.can_grab` 取交集(`ConversationItem.vue:117`)。
> 菜单工厂 `useConversationMenuItems.ts` **必须复用同一套条件**(建议把该判断上提为一个共享 `computed`,两处引用),
> 否则会出现「第三行有接手 link、菜单里没有」或反之的不一致,直接违反 A-11。
### 5.1 分区实例推演(原型必须逐一演示)
| 实例 | 状态 | 菜单实际渲染项 | 说明 |
|---|---|---|---|
| **A. 排队会话** | `status=queued``is_mine=false` | **接单** | 仅 1 项。置顶/代办不显示(非本人) |
| **B. 我的会话(serving** | `is_mine=true`, `is_pinned=false`, `is_todo=false` | 置顶 / 代办 / 转接… | 3 项。无接单(非 queued)、无接手(非 can_grab |
| **C. 我的会话(已置顶已代办)** | `is_mine=true`, `is_pinned=true`, `is_todo=true` | **取消置顶** / **取消代办** / 转接… | 文案动态翻转 |
| **D. 同事会话** | `is_mine=false`, `can_grab=true`, `status=serving` | **接手** | 仅 1 项。**置顶/代办被显示条件拦住**(点了会 403) |
| **E. 我协作中的会话** | `is_collaborator=true`, `status=serving` | 置顶 / 代办 / **退出协作**(danger) | 无转接(`is_mine=false` |
| **F. 历史会话** | `section=history`, `status=resolved` | **(空)** | 全部条件不满足 → 菜单显示「无可用操作」占位,或三点按钮直接不可点 |
| **G. 转接无可用坐席** | `is_mine=true`, `availableAgents=[]` | 置顶 / 代办 / ~~转接(灰)~~ | 转接项 disabledhover 提示「暂无可用坐席」 |
> **F 的产品决策**:历史会话菜单为空时,**三点按钮仍渲染但呈禁用态**`opacity:.35``cursor:default`,点击无响应),**不弹空菜单**。理由:弹出一个写着"无可用操作"的空菜单是无效交互;但完全不渲染按钮会让历史分区条目右侧出现空洞,与其它分区视觉不齐。
### 5.2 与「结单」的边界
「结单」**不进入**左栏菜单,理由见 Q-5。中栏保留其原有入口。
---
## 6. 中栏改造
### 6.1 变更清单(`UserInfoBar.vue:114-194`
| # | 控件 | 代码位置 | 本次处理 |
|---|---|---|---|
| 1 | 接单 | `:116-124` | ❌ **删除** |
| 2 | 置顶 | `:127-133` | ❌ **删除** |
| 3 | 代办 | `:136-142` | ❌ **删除** |
| 4 | **转接** | `:145-164` | ✅ **保留**D-2 |
| 5 | 摇人 | `:167-174` | ✅ 保留(不在本次范围) |
| 6 | 结单 / 等待确认 | `:177-193` | ✅ 保留(不在本次范围) |
| — | 历史会话开关 chip | `:100-110` | ✅ 保留(在 chips 区,非 actions 区) |
**改造后中栏 actions 区 = 转接 / 摇人 / 结单**,语义收敛为「对话过程动作组」。
### 6.2 转接入口一致性要求
中栏「转接」与左栏菜单「转接」必须:
- 调用同一 store action`transferConv`);
- 打开**同一个坐席选择 Dialog 组件**(Q-3 结论);
- 保留现有的 `ElMessageBox.confirm` 二次确认(`ChatArea.vue:531-547`)。
不得出现"中栏是下拉、左栏是 Dialog"的两套 UI。
---
## 7. 组件抽象边界与 PRD-011 兼容
### 7.1 抽象原则
> **抽「菜单壳」,不抽「业务动作」。**
```
components/common/WorkItemActionMenu.vue 【本次新建】纯 UI 壳,零业务逻辑
职责:单例渲染 / fixed 或 Teleport 定位 / 视口翻转
/ Esc·scroll·resize·outside 关闭 / role=menu + ↑↓ 键盘导航
/ z-index token / 分组分隔线 / danger 项样式
Props : items: MenuItem[] visible: boolean anchor: {x,y} | HTMLElement
Emits : select(action: string) close()
⛔ 不认识 conversation / approval / ticket,不 import 任何 store 与 api
composables/useConversationMenuItems.ts 【本次新建】会话菜单项工厂
入参:conversation + currentAgentId + section('my'|'colleague'|'history')
出参:MenuItem[](已按 §5 矩阵算好 hidden / disabled / label
✅ 承载全部可见性规则,可单测,PRD-011 Phase 2 原样复用
composables/useTodoMenuItems.ts 【PRD-011 Phase 2 新建】任务菜单项工厂
components/conversation/ConversationList.vue 【本次改造】持有单例菜单 + 路由 action 到 store
components/conversation/ConversationItem.vue 【本次改造】仅加三点按钮与 @contextmenu
只 emit('open-menu', {id, x, y}),不含菜单 DOM
```
### 7.2 `MenuItem` 类型契约
```ts
interface MenuItem {
key: string
label: string
icon?: string
danger?: boolean
disabled?: boolean
hidden?: boolean
tooltip?: string
/** 🔴 关键字段:决定容器采用哪种反馈策略 */
execMode: 'direct' | 'redirect' | 'disabled'
}
```
**`execMode` 为什么是必需字段**(对宋献"未来待办条目进入左栏后菜单怎么分派"的直接回答):
| kind | 操作性质 | 执行路径 | 反馈模型 |
|---|---|---|---|
| `conversation` | **服务端真闭环** | store → API → 后端端点 → 刷新列表 | 同步成功/失败 |
| `approval` | **降级跳转** | `<a target="_blank">` 跳企微 → `sys_approval_change` 回调回写 → WS 推送 | 异步最终一致 |
| `ticket` | **当前无对象** | `ITSMService.get_todo_list()` 无条件 `return []` | 无(PRD-011 U-1.2 |
> **必须分派,且不只是文案不同——是执行语义与反馈模型的根本不同。** 会话操作点完即生效;审批操作点完只是跳走,真正的状态变更在企微侧异步回写。**若三类共用一套「点击 → toast 成功」的反馈模型,就会重演 PRD-011 §3.1 定性为 P0 的那个错误**(绿色成功提示 + 外部系统实际未变更)。
### 7.3 与 PRD-011 Phase 2 的衔接检查表
| PRD-011 Phase 2 任务 | 本设计是否兼容 | 说明 |
|---|---|---|
| 定义统一 `ListItem`(含 `kind` 判别式) | ✅ | `MenuItem[]` 由 kind 专属工厂生成,菜单壳不感知 kind |
| 条目组件支持会话态 / 任务态两种渲染 | ✅ | 条目只 `emit('open-menu')`,两态共用同一菜单壳 |
| 左栏 Tab 分层(会话/待办/全部) | ✅ | 「全部」Tab 混排时,容器按 `item.kind` 选工厂 |
| **U-5 虚拟滚动** | ✅ | **单例架构天然兼容**(见下) |
| U-6 待办未读/变更标识 | ➖ | 与菜单无关 |
**D-4 单例架构的强制理由**PRD-011 U-5 已把虚拟滚动挂起。虚拟滚动会在滚动时**回收并复用条目 DOM**——若菜单实例挂在条目内部,reference 元素被销毁会导致菜单错位或残留。单例架构的实现成本与逐条挂载**相同**,却可免除未来上虚拟滚动时的整体返工。这正是宋献「避免三个月后第二次重做」诉求的具体着力点。
---
## 8. 产品经理自决项结论(Q-3 / Q-4 / Q-5
架构报告 §4.4 将三项留给产品决策。以下为结论与论证。
### Q-3 转接的坐席选择:二级菜单 还是 独立 Dialog?
> ### ✅ **结论:独立 Dialog。菜单项文案为「转接…」(带省略号,遵循"点击会开弹窗"的通用约定)。**
| 论据 | 说明 |
|---|---|
| **① 窄栏二级翻转过于脆弱** | 主菜单已 140px、`bottom-end` 右对齐。二级菜单需再向左或向右展开 ~160px。在 260px 侧栏 + 视口右缘的组合下,Popper 需同时做**水平翻转 + 垂直翻转**,边缘 case 组合爆炸,稳定性无法保证 |
| **② 坐席列表承载不下** | 二级菜单需展示 `姓名 (当前负载/最大负载)`,且在线坐席可能十余人,需要**滚动 + 搜索**。二级 popover 里塞搜索框是反模式 |
| **③ 悬停路径问题** | 二级菜单靠 hover 展开时,鼠标从主菜单项斜向移动到二级区域会经过其它菜单项,导致二级意外收起(经典 "diagonal problem"),需额外做安全三角形算法——成本高于直接开 Dialog |
| **④ 与中栏入口天然统一** | 中栏保留的「转接」也需要选坐席。共用同一个 Dialog 组件后,**两个入口 UI 完全一致**,符合 §6.2 的一致性要求。若左栏用二级菜单、中栏用下拉,就是两套 UI |
| **⑤ 转接是有后果的动作** | 转接后会话脱手。现有实现已带 `ElMessageBox.confirm` 二次确认。Dialog 形态与"需要慎重决策"的语义匹配,二级菜单的轻量感反而不合适 |
**Dialog 规格**:宽 380px,标题「转接会话」,含搜索框 + 坐席列表(头像 / 姓名 / `负载/上限`)+ 满载坐席置灰不可选 + 取消按钮。选中坐席后走原有二次确认。
**反方意见记录**:二级菜单少一次弹窗、路径更短。但转接**日频次不高**(非置顶/代办那类高频动作),为低频动作牺牲稳定性不划算。
---
### Q-4 「接手」「退出协作」是否收进三点菜单(收则移除第三行按钮)?
> ### ✅ **结论:收进菜单,但第三行 `grab-btn` / `leave-btn` 保留不动 —— 双入口并存。**
**这与架构建议("本次不动,保持第三行按钮,菜单只放 4 项")有分歧,理由如下:**
| 论据 | 说明 |
|---|---|
| **① 右键路径的完整性** | D-3 已定右键并存。坐席右键条目时,鼠标位置是任意的——若菜单里没有「接手」,坐席仍需**移动鼠标到第三行那个小 link 按钮**才能接手。这让右键快捷方式在最需要它的同事会话场景下失效,自相矛盾 |
| **② 移除第三行按钮才是真损失** | 第三行 grab/leave 是 `v-if` **数据驱动的常显 link**,零 hover 成本、一次点击即达。三点菜单需 hover→点击→点击共 3 步。**移除它是明确的效率退化**,不能为了"避免重复"而做 |
| **③ "同一动作两个入口"不是缺陷** | 这与 §3.2 中栏保留转接是同一条原则:**高频/关键动作允许多入口**,只要共用同一 handler。真正的缺陷是两个入口**行为不一致**,而非入口数量 |
| **④ 变更面反而更小** | 保留第三行按钮 = 不动既有 DOM,只在菜单工厂里多产出两个 MenuItem。移除按钮才需要改 `ConversationItem.vue:116-136` 并回归测试 |
**约束**:两个入口必须 `emit` 同一事件(`grab` / `leave`),走同一 store action。**禁止**在菜单里另写一份逻辑。
**验收要求**:同事会话条目上,第三行「接手」link 与菜单「接手」项必须同时可见、同时消失(受同一 `can_grab` 条件驱动)。
---
### Q-5 「结单」是否进左栏菜单?
> ### ✅ **结论:不进。维持中栏唯一入口。**
| 论据 | 说明 |
|---|---|
| **① 路径错位** | 结单需走摘要确认 Dialog(`ChatArea.vue:448-502`),坐席要在弹窗里确认/编辑会话摘要。**在左栏一个 24px 的三点按钮里触发一个需要阅读与编辑的模态流程**,是明确的路径错位 |
| **② 语义归属** | 结单属「对话过程动作组」(转接/摇人/结单),与置顶/代办的"列表管理"性质不同。§3.2 已确立该分组,结单进左栏会破坏刚刚建立的分组一致性 |
| **③ 结单前需要看对话内容** | 结单意味着"问题已解决",这个判断必须基于中栏的对话内容。坐席不可能在左栏扫视时判断某条会话可以结单——**动作发生地必然是中栏** |
| **④ 误操作成本高** | 结单会触发员工端确认流程(`pending_close` 态)。在密集列表中一个 hover 出现的小菜单里放置这种动作,误触成本过高 |
| **⑤ 用户未提** | 需求原文只提 4 个操作,不做需求扩张 |
---
## 9. 需求池(Requirements Pool
### P0 — Must have
| # | 需求 |
|---|---|
| P0-1 | 新建 `WorkItemActionMenu.vue` 单例菜单壳:fixed/Teleport 定位、视口翻转、Esc / 点击外部 / 滚动 / resize / 切换会话 关闭、z-index ≥ 2000、danger 项样式 |
| P0-2 | 新建 `useConversationMenuItems.ts`:完整实现 §5 矩阵,含动态文案翻转与 disabled 计算,**附单元测试覆盖 §5.1 的 A–G 七个实例** |
| P0-3 | `ConversationItem.vue` 新增三点按钮:§4.1 全部规格(同栅格位淡入淡出、24×24、hover/`.is-menu-open`/`:focus-visible` 显示、`@click.stop` |
| P0-4 | `ConversationItem.vue` 新增 `@contextmenu.prevent.stop`,与三点按钮共用同一菜单项定义 |
| P0-5 | `ConversationList.vue` 持有单例菜单实例,按 action 路由到对应 store action |
| P0-6 | `UserInfoBar.vue` 删除接单/置顶/代办 3 个按钮,**保留转接** |
| P0-7 | 修复既有缺陷:`stores/conversation.ts` 中 4 个操作的失败分支由 `console.error` 改为 `ElMessage.error` 明确文案(403 场景必须有用户可见反馈) |
| P0-8 | 转接坐席选择 Dialog 组件化,中栏与左栏共用 |
### P1 — Should have
| # | 需求 |
|---|---|
| P1-1 | 引入 z-index token`--z-dropdown: 2000` / `--z-modal: 3000` / `--z-toast: 9000`),替换菜单相关硬编码 |
| P1-2 | 菜单 `role="menu"` / 项 `role="menuitem"`,三点按钮用原生 `<button>``el-button`(可 Tab 聚焦、Enter/Space 激活) |
| P1-3 | 历史分区三点按钮禁用态(§5.1 F 的处理) |
| P1-4 | 转接 Dialog 支持坐席姓名搜索 |
### P2 — Nice to have
| # | 需求 |
|---|---|
| P2-1 | 菜单 ↑↓ 方向键导航 + Enter 确认(可挂接既有 `useKeyboardShortcuts.ts` |
| P2-2 | 将 `MessageItem.vue` 的自研右键菜单迁移到 `WorkItemActionMenu`,顺带修复其 5 项缺陷(无边界翻转 / z-index 1000 偏低 / 关闭时机不全 / 无 a11y / 无单例互斥) |
| P2-3 | 中栏按钮删除做成配置开关,支持 2 周内灰度回滚 |
| P2-4 | 为转接注册全局快捷键(如 `Ctrl+Shift+T` |
---
## 10. 验收标准
### 10.1 功能验收
| # | 验收项 | 通过标准 |
|---|---|---|
| A-1 | 三点按钮显隐 | 默认不可见;hover 条目出现且缩略头像淡出;**条目高度与各元素位置零位移** |
| A-2 | 点击不误触 | 点击三点按钮**不切换当前会话**(当前打开的会话保持不变) |
| A-3 | 右键一致性 | 同一条目上,右键菜单项与三点菜单项**逐项完全一致**(项数、顺序、文案、禁用态) |
| A-4 | 菜单不被裁剪 | 左栏**第一条**与**最后一条**会话的菜单完整可见,不被侧栏或滚动容器裁掉;视口下缘不足时向上翻转 |
| A-5 | 矩阵正确性 | §5.1 的 A–G 七个实例逐一验证,菜单项与预期完全一致 |
| A-6 | 403 拦截 | 同事会话菜单中**不出现**置顶/代办项;构造非法请求时前端有明确错误提示 |
| A-7 | 单例互斥 | 打开条目 A 的菜单后,右键条目 B,A 的菜单自动关闭,同屏只有一个菜单 |
| A-8 | 关闭时机 | Esc / 点击外部 / 滚动列表 / 窗口 resize / 切换会话 —— 五种情况菜单均关闭 |
| A-9 | 中栏改造 | 中栏 actions 区仅剩 转接 / 摇人 / 结单;**转接路径仍为 3 步** |
| A-10 | 转接双入口 | 中栏转接与左栏菜单转接打开**同一个 Dialog**,行为一致 |
| A-11 | 接手双入口 | 同事会话条目第三行「接手」link 与菜单「接手」项同时可见/同时消失,点击效果相同 |
| A-12 | 状态即时反映 | 置顶/代办后条目 📌/📋 图标即时出现或消失,列表按新权重重排 |
### 10.2 架构验收(防返工)
| # | 验收项 | 通过标准 |
|---|---|---|
| B-1 | 单例 | 全局 DOM 中 `.work-item-action-menu` 实例数恒 ≤ 1,**不随会话条目数增长** |
| B-2 | 壳零业务 | `WorkItemActionMenu.vue``import` 不含任何 store / api / `Conversation` 类型 |
| B-3 | 工厂可单测 | `useConversationMenuItems.ts` 为纯函数,可脱离组件树单测 |
| B-4 | DOM 归属 | 菜单 DOM 的 `parentElement``body` 或使用 `position: fixed` |
### 10.3 明确不作为验收依据
- ❌ toast 成功提示(沿用 PRD-011 §6 Phase 0 的验收原则)
- ❌ 原型截图(原型是设计依据,不是实现验收标准)
---
## 11. 风险登记
| 编号 | 风险 | 等级 | 缓解 |
|---|---|---|---|
| **R-1** | 转接若被误删将造成 3→6 步路径退化 | 🔴 高 → 🟢 已缓解 | D-2 决定中栏保留转接;若评审推翻,必须同时落地 P2-4 快捷键 |
| **R-2** | v1.7 若沿用 v1.6 低保真基线,菜单落位设计与实现对不上 | 🔴 高 → 🟢 已缓解 | D-1 改以 v1.3 为基线并回填真实字段 |
| **R-3** | z-index 冲突(项目现有 `100/999/1000/1200/3000/9999/999999` 混用,EP popper 默认 2000+ | 🟡 中 | P1-1 引入 token;优先用 EP popover 交由其管理 |
| **R-4** | 菜单可见性与后端权限不一致 → 403。`pin`/`todo``update:own` | 🟡 中 | §5 矩阵前端严格拦截 + P0-7 失败提示 |
| **R-5** | 复制 `MessageItem.vue` 右键实现,带入其 5 项缺陷 | 🟡 中 | 强制走 `WorkItemActionMenu` 新壳,禁止复制粘贴 |
| **R-6** | 未来虚拟滚动导致条目内菜单错位 | 🟡 中 → 🟢 已缓解 | D-4 本次即采用单例架构 |
| **R-7** | 三点按钮与缩略头像争位导致布局抖动 | 🟢 低 | D-5 同栅格位淡入淡出,不改盒模型;A-1 专项验收 |
| **R-8** | 右键在 VDI / 远程桌面 / 触摸屏环境下不可靠 | 🟢 低 | 三点按钮为主入口,右键仅为快捷方式,降级不影响可用性。**需宋献确认坐席实际办公环境(架构 Q-7)** |
| **R-9** | 坐席习惯迁移成本:老坐席习惯在中栏点置顶 | 🟢 低 | 变更公告 + 首次使用时的引导气泡(可选);P2-3 保留灰度回滚 |
---
## 12. 明确不在本次范围
- 中栏「摇人」「结单」的任何改动
- 待办条目(审批/工单)进入左栏 —— 属 PRD-011 Phase 2
- 左栏虚拟滚动 —— 属 PRD-011 U-5(本次仅在架构上预留兼容)
- 会话列表排序权重调整
- 后端任何改动(4 个端点及权限装饰器均已就绪,无需变更)
- 键盘方向键导航(列为 P2-1,不阻塞本次)
---
## 13. 待需求方(宋献)评审确认清单
| # | 待确认项 | 本 PRD 的默认取值 | 若推翻的影响 |
|---|---|---|---|
| **C-1** | 中栏**保留转接**是否接受?(原需求是 4 个全删) | 保留(D-2) | 推翻则转接路径 3→6 步,必须补 P2-4 快捷键,工期 +0.3 人日 |
| **C-2** | 是否接受**右键并存**? | 接受(D-3) | 推翻则去掉 P0-4,省 0.3–0.5 人日,但熟练坐席效率损失 |
| **C-3** | 是否接受 v1.7 **回填左栏真实字段**的额外工作量? | 接受(D-1) | 推翻则原型不可作为开发依据 |
| **C-4** | 坐席实际办公环境是否存在 **VDI / 远程桌面 / 触摸屏**? | 假定为普通 PC 浏览器 | 若有 VDI,右键可靠性下降,需重新评估 C-2 |
| **C-5** | 历史会话条目的三点按钮:**禁用态**还是**完全不渲染**? | 禁用态(§5.1 F) | 视觉偏好项,成本相同 |
| **C-6** | 备选形态:是否需要看「中栏 3 按钮收进中栏自己的『⋯』菜单」的 A/B 对比? | 未在 v1.7 中呈现 | 若需要,v1.7 需追加一版对比图 |
| **C-7** | Q-4 结论(接手/退出**双入口**,不移除第三行按钮)是否认可? | 双入口 | 若要求单入口,需明确保留哪一个 |
---
## 14. 工期估算
> 粗估,用于排序参考,不作为承诺。基于架构报告 §4.1 的分解并按本 PRD 结论调整。
| 阶段 | 内容 | 估时 |
|---|---|---|
| S1 | `WorkItemActionMenu.vue` 单例壳 | 1.0 人日 |
| S2 | `useConversationMenuItems.ts` + 单测(§5 矩阵) | 0.5 人日 |
| S3 | `ConversationItem` 三点按钮 + 右键触发 | 0.5 人日 |
| S4 | `ConversationList` 单例接线 + action 路由 | 0.5 人日 |
| S5 | 中栏删 3 留转接 | 0.2 人日 |
| S6 | 转接 Dialog 组件化(Q-3+ 中栏复用 | 0.5 人日 |
| S7 | P0-7 失败反馈修复 | 0.2 人日 |
| S8 | a11yP1-2+ 键盘导航(P2-1 | 0.3 人日(可 P2 单排) |
| — | **合计** | **3.43.7 人日** |
---
## 15. 变更记录
| 日期 | 版本 | 变更内容 | 变更人 |
|---|---|---|---|
| 2026-08-09 | v1.0 | 创建。基于架构核查报告 v1.0 与宋献口述需求,固化 D-1~D-6 六项决策;产出菜单项可见性矩阵(§5)与七实例推演;给出 Q-3/Q-4/Q-5 三项自决结论(Q-4 与架构建议存在分歧并说明理由);定义 `WorkItemActionMenu` + `useConversationMenuItems` 组件边界与 PRD-011 Phase 2 兼容检查表;登记 R-1~R-9 风险与 C-1~C-7 待确认项 | 许清楚(产品经理) |
</content>
</invoke>