Files
MailUI4Agents/client/electron/src/components/AddressInput.tsx

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 bg-white border border-gray-200 rounded-md shadow-lg ${
menuLayout.flip ? 'bottom-full mb-1' : 'top-full mt-1'
}`}
style={{ maxHeight: menuLayout.maxHeight }}
>
<div className="px-2.5 py-1 text-[10px] 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-[9px]"
title="平台侧已有的会话,本站还没有对应的邮件往来"
>
</span>
)}
{c?.source === 'new' && (
<span className="shrink-0 text-[10px] 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-[9px]">
{c!.unread}
</span>
)}
</div>
{c?.title && c.source !== 'new' && (
<p className="text-[10px] 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
};
}