Files
MailUI4Agents/client/electron/src/components/AddressInput.tsx
JianFeeeee e6048e9914 fix(webui): 候选菜单改用不透明弹层表面 —— 半透明+背景模糊把背后的表单糊进了列表
用户(2026-09-15,看着抄送/转发两处的候选):「你在选择框的模糊逻辑上用力过猛,
导致只能显示一条信息」。

实测(生产构建 4c2bf26,浏览器里读计算样式,壁纸开):
  菜单 background: rgba(255,255,255,0.5) + backdrop-filter: blur(8px) saturate(1.1)
  而它浮在**表单自己**之上(主题输入框 y=294 正落在菜单 284-510 下面)
⇒ 背后的输入框/标签被糊一遍再从半透明列表底下透上来,只有顶行还读得清。

根因与 2026-09-14 那次「模糊叠模糊」同族:把**控件档**(0.5 透 + 8px 模糊)用在
**弹层**上。控件背后是面板自身,透一点好看;弹层背后是别的内容,透就是叠两份内容。

- 新增 .popup-surface(rgb(var(--c-white)),不取 backdrop-filter;壁纸开时单独压回不透明,
  否则会撞上 html[data-bg='on'] .bg-white 那条接管规则)
- .popup-surface 并入「滚动区不淡」豁免(原来只认 .glass-control,换类会漏掉 → 弹层又被洗白)
- AddressInput 菜单 glass-control → popup-surface;旧断言 nav-merge.test.mjs:109 钉的正是
  那个错的意图(『地址建议菜单』必须用控件档),已改钉新意图

判据:narrow-layout 新增 4 条(表面不透明/不取模糊/壁纸开时也压回/菜单穿的类)+
nav-merge 反向对照。两侧都验:变异①换回 glass-control → 红;变异②改回 0.5 → 红;
复原 → 71 通过 0 失败。生产实测:rgba(...,0.5)+blur → rgb(255,255,255)+无模糊。
2026-09-15 07:46:54 +08:00

266 lines
9.8 KiB
TypeScript
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.

import { useEffect, useMemo, useRef, useState } from 'react';
import * as api from '../api/client';
import type { SessionCandidate } from '../types';
/**
* 三段式地址输入:name -> @path -> .session
* 每段都向 /contacts/suggest 询问候选,未命中时也允许自由输入。
* 值本身始终是完整字符串 name@path.session。
*
* session 段的候选带标题与来源标记:一个工作区下可能有十几条会话,
* 光看 brisk-harbor / witty-planet 这类随机短名分不出哪条在谈什么。
*/
export default function AddressInput({
value,
onChange,
placeholder,
allowMultiple = false,
autoFocus = false
}: {
value: string;
onChange: (v: string) => void;
placeholder?: string;
/** 抄送场景:允许逗号分隔多个地址,补全只作用于最后一段 */
allowMultiple?: boolean;
autoFocus?: boolean;
}) {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<string[]>([]);
// session 段的富候选,与 items 同序。其他段为空数组。
const [meta, setMeta] = useState<SessionCandidate[]>([]);
const [kind, setKind] = useState<'name' | 'path' | 'session'>('name');
const [active, setActive] = useState(0);
const [menuLayout, setMenuLayout] = useState({ flip: false, maxHeight: 288 });
const boxRef = useRef<HTMLDivElement>(null);
// 当前正在编辑的那一段(多地址时取最后一段)
const { head, editing } = useMemo(() => {
if (!allowMultiple) return { head: '', editing: value };
const idx = Math.max(value.lastIndexOf(','), value.lastIndexOf(';'));
if (idx < 0) return { head: '', editing: value };
return { head: value.slice(0, idx + 1), editing: value.slice(idx + 1).trimStart() };
}, [value, allowMultiple]);
// 把编辑段拆成 name / path / session 三部分
const parts = useMemo(() => parseParts(editing), [editing]);
useEffect(() => {
let cancelled = false;
const run = async () => {
try {
// 决定问哪一层:还没写 @ -> 问 name;写了 @ 没写 . -> 问 path;写了 . -> 问 session
const res = parts.hasDot
? await api.suggestAddress(parts.name, parts.path)
: parts.hasAt
? await api.suggestAddress(parts.name)
: await api.suggestAddress();
if (cancelled) return;
const frag = parts.hasDot ? parts.session : parts.hasAt ? parts.path : parts.name;
const lower = frag.toLowerCase();
const all = res.suggestions || [];
const cands = res.candidates || [];
// 过滤时保持 suggestions 与 candidates 同序:candidates 是按下标对应的,
// 分别过滤两个数组会让标题错位到别的别名上。
const keep: number[] = [];
all.forEach((s, i) => {
const c = cands[i];
// 标题也参与匹配:想找「缓存选型」那条会话时,人记得的是标题而不是随机短名
const hay = c?.title ? `${s} ${c.title}`.toLowerCase() : s.toLowerCase();
if (hay.includes(lower)) keep.push(i);
});
setKind(res.kind);
setItems(keep.map(i => all[i]));
setMeta(cands.length ? keep.map(i => cands[i]).filter(Boolean) : []);
setActive(0);
} catch {
if (!cancelled) {
setItems([]);
setMeta([]);
}
}
};
const t = setTimeout(run, 120);
return () => {
cancelled = true;
clearTimeout(t);
};
}, [parts.name, parts.path, parts.session, parts.hasAt, parts.hasDot]);
useEffect(() => {
const onDocClick = (e: MouseEvent) => {
if (boxRef.current && !boxRef.current.contains(e.target as Node)) setOpen(false);
};
document.addEventListener('mousedown', onDocClick);
return () => document.removeEventListener('mousedown', onDocClick);
}, []);
// 补全菜单根据 Visual Viewport 剩余空间向上翻转;软键盘出现后也实时重算。
useEffect(() => {
if (!open || items.length === 0) return;
const update = () => {
const box = boxRef.current;
if (!box) return;
const rect = box.getBoundingClientRect();
const viewport = window.visualViewport;
const top = viewport?.offsetTop ?? 0;
const bottom = top + (viewport?.height ?? window.innerHeight);
const above = Math.max(0, rect.top - top - 8);
const below = Math.max(0, bottom - rect.bottom - 8);
const flip = below < Math.min(240, above) && above > below;
setMenuLayout({ flip, maxHeight: Math.max(96, Math.min(288, flip ? above : below)) });
};
update();
window.addEventListener('resize', update, { passive: true });
window.addEventListener('scroll', update, { passive: true, capture: true });
window.visualViewport?.addEventListener('resize', update, { passive: true });
window.visualViewport?.addEventListener('scroll', update, { passive: true });
return () => {
window.removeEventListener('resize', update);
window.removeEventListener('scroll', update, { capture: true });
window.visualViewport?.removeEventListener('resize', update);
window.visualViewport?.removeEventListener('scroll', update);
};
}, [open, items.length]);
/** 选中一个候选后拼回完整地址 */
const apply = (choice: string) => {
let next: string;
if (kind === 'name') {
next = `${choice}@`;
} else if (kind === 'path') {
next = `${parts.name}@${choice}.`;
} else {
next = `${parts.name}@${parts.path}.${choice}`;
}
onChange(allowMultiple ? `${head}${head ? ' ' : ''}${next}` : next);
// name/path 选完仍停留在补全态,继续下一段
setOpen(kind !== 'session');
};
const onKeyDown = (e: React.KeyboardEvent<HTMLInputElement>) => {
if (!open || items.length === 0) return;
if (e.key === 'ArrowDown') {
e.preventDefault();
setActive(i => (i + 1) % items.length);
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setActive(i => (i - 1 + items.length) % items.length);
} else if (e.key === 'Enter' || e.key === 'Tab') {
e.preventDefault();
apply(items[active]);
} else if (e.key === 'Escape') {
setOpen(false);
}
};
const hint =
kind === 'name'
? 'Agent 名'
: kind === 'path'
? '工作区路径'
: '会话别名(new 为新建)';
return (
<div ref={boxRef} className="relative">
<input
value={value}
autoFocus={autoFocus}
onChange={e => {
onChange(e.target.value);
setOpen(true);
}}
onFocus={() => setOpen(true)}
onKeyDown={onKeyDown}
placeholder={placeholder}
spellCheck={false}
className="w-full text-sm font-mono border border-gray-300 rounded-md px-2.5 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
/>
{open && items.length > 0 && (
<div
className={`absolute z-20 w-full overflow-y-auto popup-surface border border-gray-200 rounded-lg shadow-2 ${
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
style={{ maxHeight: menuLayout.maxHeight }}
>
<div className="px-2.5 py-1 text-3xs text-gray-400 border-b border-gray-100">
{hint}
</div>
{items.map((s, i) => {
const c = meta[i];
return (
<button
key={s}
onMouseDown={e => {
e.preventDefault();
apply(s);
}}
onMouseEnter={() => setActive(i)}
className={`w-full text-left px-2.5 py-1.5 ${
i === active ? 'bg-blue-50' : 'hover:bg-gray-50'
}`}
>
<div className="flex items-center gap-1.5">
<span
className={`text-sm font-mono truncate ${
i === active ? 'text-blue-700' : 'text-gray-700'
}`}
>
{s}
</span>
<div className="flex-1" />
{/* 平台侧会话本侧还没有邮件线索:标出来,让人知道这一封是「接入」
一条已经在跑的会话,而不是继续一条已有的邮件往来 */}
{c?.source === 'platform' && (
<span
className="shrink-0 px-1 py-0.5 rounded bg-blue-100 text-blue-700 text-3xs"
title="平台侧已有的会话,本站还没有对应的邮件往来"
>
平台
</span>
)}
{c?.source === 'new' && (
<span className="shrink-0 text-3xs text-gray-400 font-sans">新建会话</span>
)}
{(c?.unread ?? 0) > 0 && (
<span className="shrink-0 px-1 py-0.5 rounded bg-red-600 text-white text-3xs">
{c!.unread}
</span>
)}
</div>
{c?.title && c.source !== 'new' && (
<p className="text-3xs text-gray-400 truncate mt-0.5">{c.title}</p>
)}
</button>
);
})}
</div>
)}
</div>
);
}
/** 把 name@path.session 拆段;path 内允许 . 与 /,按最后一个 . 切 */
function parseParts(s: string) {
const at = s.indexOf('@');
if (at < 0) {
return { name: s, path: '', session: '', hasAt: false, hasDot: false };
}
const name = s.slice(0, at);
const rest = s.slice(at + 1);
const dot = rest.lastIndexOf('.');
if (dot < 0) {
return { name, path: rest, session: '', hasAt: true, hasDot: false };
}
return {
name,
path: rest.slice(0, dot),
session: rest.slice(dot + 1),
hasAt: true,
hasDot: true
};
}