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