|
|
/**
|
|
|
* MotherShip 登录后一级:地址键入联想下拉(Axel suggest)
|
|
|
*/
|
|
|
"use client";
|
|
|
|
|
|
import { useEffect, useId, useRef, useState } from "react";
|
|
|
import { CaretRight, MagnifyingGlass, X } from "@phosphor-icons/react";
|
|
|
import { hostSuggestMothershipAddress } from "@/lib/frontend/api-client";
|
|
|
import type { MothershipAddressCandidate } from "@/lib/frontend/types";
|
|
|
import { LoadingSpinner } from "@/components/ui/loading-spinner";
|
|
|
|
|
|
const DEBOUNCE_MS = 300;
|
|
|
const MIN_QUERY_LEN = 3;
|
|
|
|
|
|
export interface MothershipAddressSuggestFieldProps {
|
|
|
label: string;
|
|
|
required?: boolean;
|
|
|
placeholder?: string;
|
|
|
disabled?: boolean;
|
|
|
/** 必填未满足时高亮边框 */
|
|
|
invalid?: boolean;
|
|
|
testId?: string;
|
|
|
customerId: string;
|
|
|
apiBaseUrl?: string;
|
|
|
serviceToken?: string;
|
|
|
value: string;
|
|
|
confirmed: MothershipAddressCandidate | null;
|
|
|
onQueryChange: (query: string) => void;
|
|
|
onConfirm: (candidate: MothershipAddressCandidate | null) => void;
|
|
|
}
|
|
|
|
|
|
function splitDisplay(label: string): { primary: string; secondary: string } {
|
|
|
const parts = label.split(",").map((p) => p.trim()).filter(Boolean);
|
|
|
if (parts.length <= 1) {
|
|
|
return { primary: label, secondary: "" };
|
|
|
}
|
|
|
return {
|
|
|
primary: parts[0]!,
|
|
|
secondary: parts.slice(1).join(", "),
|
|
|
};
|
|
|
}
|
|
|
|
|
|
export function MothershipAddressSuggestField({
|
|
|
label,
|
|
|
required,
|
|
|
placeholder = "按邮编、地址或公司名搜索",
|
|
|
disabled,
|
|
|
invalid,
|
|
|
testId,
|
|
|
customerId,
|
|
|
apiBaseUrl = "",
|
|
|
serviceToken,
|
|
|
value,
|
|
|
confirmed,
|
|
|
onQueryChange,
|
|
|
onConfirm,
|
|
|
}: MothershipAddressSuggestFieldProps) {
|
|
|
const listId = useId();
|
|
|
const wrapRef = useRef<HTMLDivElement>(null);
|
|
|
const [open, setOpen] = useState(false);
|
|
|
const [loading, setLoading] = useState(false);
|
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
const [candidates, setCandidates] = useState<MothershipAddressCandidate[]>(
|
|
|
[],
|
|
|
);
|
|
|
const reqSeq = useRef(0);
|
|
|
|
|
|
useEffect(() => {
|
|
|
const onDoc = (e: MouseEvent) => {
|
|
|
if (!wrapRef.current?.contains(e.target as Node)) {
|
|
|
setOpen(false);
|
|
|
}
|
|
|
};
|
|
|
document.addEventListener("mousedown", onDoc);
|
|
|
return () => document.removeEventListener("mousedown", onDoc);
|
|
|
}, []);
|
|
|
|
|
|
useEffect(() => {
|
|
|
const q = value.trim();
|
|
|
if (q.length < MIN_QUERY_LEN) {
|
|
|
setCandidates([]);
|
|
|
setError(null);
|
|
|
setLoading(false);
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
// 已确认且文案未变:不重复请求
|
|
|
if (confirmed && confirmed.display_label === value.trim()) {
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
const seq = ++reqSeq.current;
|
|
|
setLoading(true);
|
|
|
setError(null);
|
|
|
const timer = window.setTimeout(() => {
|
|
|
void (async () => {
|
|
|
try {
|
|
|
const res = await hostSuggestMothershipAddress(
|
|
|
apiBaseUrl,
|
|
|
serviceToken,
|
|
|
customerId,
|
|
|
q,
|
|
|
);
|
|
|
if (seq !== reqSeq.current) return;
|
|
|
if (res.code !== 0 || !res.data) {
|
|
|
setCandidates([]);
|
|
|
setError(res.message || "地址联想失败");
|
|
|
return;
|
|
|
}
|
|
|
setCandidates(
|
|
|
res.data.candidates.filter(
|
|
|
(c) =>
|
|
|
c.selectable !== false &&
|
|
|
Boolean(c.option_id?.trim()) &&
|
|
|
Boolean(
|
|
|
(c.display_label || c.formatted_address || "").trim(),
|
|
|
),
|
|
|
),
|
|
|
);
|
|
|
setOpen(true);
|
|
|
} catch (err) {
|
|
|
if (seq !== reqSeq.current) return;
|
|
|
setCandidates([]);
|
|
|
setError(err instanceof Error ? err.message : "地址联想失败");
|
|
|
} finally {
|
|
|
if (seq === reqSeq.current) {
|
|
|
setLoading(false);
|
|
|
}
|
|
|
}
|
|
|
})();
|
|
|
}, DEBOUNCE_MS);
|
|
|
|
|
|
return () => window.clearTimeout(timer);
|
|
|
}, [value, apiBaseUrl, serviceToken, customerId, confirmed]);
|
|
|
|
|
|
const handleInput = (next: string) => {
|
|
|
onQueryChange(next);
|
|
|
if (confirmed) {
|
|
|
onConfirm(null);
|
|
|
}
|
|
|
setOpen(true);
|
|
|
};
|
|
|
|
|
|
const handleClear = () => {
|
|
|
onQueryChange("");
|
|
|
onConfirm(null);
|
|
|
setCandidates([]);
|
|
|
setError(null);
|
|
|
setOpen(false);
|
|
|
};
|
|
|
|
|
|
const handlePick = (item: MothershipAddressCandidate) => {
|
|
|
onQueryChange(item.display_label || item.formatted_address);
|
|
|
onConfirm(item);
|
|
|
setOpen(false);
|
|
|
setError(null);
|
|
|
};
|
|
|
|
|
|
return (
|
|
|
<div ref={wrapRef} className="relative space-y-1.5">
|
|
|
<label className="block text-sm font-medium text-[#4B5563]">
|
|
|
{label.trimEnd()}
|
|
|
{required ? (
|
|
|
<span className="ml-0.5 text-[#EF4444]" aria-hidden>
|
|
|
*
|
|
|
</span>
|
|
|
) : null}
|
|
|
</label>
|
|
|
<div className="relative">
|
|
|
<MagnifyingGlass
|
|
|
size={18}
|
|
|
className="pointer-events-none absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled"
|
|
|
/>
|
|
|
<input
|
|
|
data-testid={testId}
|
|
|
disabled={disabled}
|
|
|
value={value}
|
|
|
onChange={(e) => handleInput(e.target.value)}
|
|
|
onFocus={() => {
|
|
|
if (candidates.length > 0 || value.trim().length >= MIN_QUERY_LEN) {
|
|
|
setOpen(true);
|
|
|
}
|
|
|
}}
|
|
|
placeholder={placeholder}
|
|
|
role="combobox"
|
|
|
aria-expanded={open}
|
|
|
aria-controls={listId}
|
|
|
aria-autocomplete="list"
|
|
|
aria-invalid={invalid || undefined}
|
|
|
className={[
|
|
|
"h-10 w-full rounded-md border bg-surface pl-10 pr-16 text-sm focus:outline-none focus:ring-2 disabled:opacity-60",
|
|
|
invalid
|
|
|
? "border-[#EF4444] focus:border-[#EF4444] focus:ring-[#EF4444]/20"
|
|
|
: "border-border focus:border-[#1890FF] focus:ring-[#1890FF]/20",
|
|
|
].join(" ")}
|
|
|
/>
|
|
|
<div className="absolute right-2 top-1/2 flex -translate-y-1/2 items-center gap-1">
|
|
|
{loading && (
|
|
|
<LoadingSpinner size={16} className="text-text-secondary" />
|
|
|
)}
|
|
|
{value && !disabled && (
|
|
|
<button
|
|
|
type="button"
|
|
|
aria-label="清空地址"
|
|
|
onClick={handleClear}
|
|
|
className="rounded p-1 text-text-secondary hover:bg-bg hover:text-text-primary"
|
|
|
>
|
|
|
<X size={14} weight="bold" />
|
|
|
</button>
|
|
|
)}
|
|
|
</div>
|
|
|
</div>
|
|
|
|
|
|
{confirmed && (
|
|
|
<p className="text-xs text-success">
|
|
|
已选择 MotherShip 地址:{confirmed.display_label}
|
|
|
</p>
|
|
|
)}
|
|
|
{error && <p className="text-xs text-error">{error}</p>}
|
|
|
|
|
|
{open && !disabled && value.trim().length >= MIN_QUERY_LEN && (
|
|
|
<div
|
|
|
id={listId}
|
|
|
role="listbox"
|
|
|
className="absolute z-30 mt-1 max-h-72 w-full overflow-auto rounded-md border border-border bg-surface shadow-modal"
|
|
|
>
|
|
|
<button
|
|
|
type="button"
|
|
|
role="option"
|
|
|
disabled
|
|
|
title="请从搜索结果中选择经 MotherShip 校验的地址"
|
|
|
className="flex w-full cursor-not-allowed items-start gap-2 border-b border-border bg-bg/80 px-3 py-2.5 text-left opacity-70"
|
|
|
>
|
|
|
<MagnifyingGlass
|
|
|
size={16}
|
|
|
className="mt-0.5 shrink-0 text-text-disabled"
|
|
|
/>
|
|
|
<span>
|
|
|
<span className="block text-sm text-text-secondary">
|
|
|
使用我输入的地址
|
|
|
</span>
|
|
|
<span className="block text-xs text-text-disabled">{value}</span>
|
|
|
<span className="mt-0.5 block text-[11px] text-warning">
|
|
|
须从下方搜索结果点选,方可继续
|
|
|
</span>
|
|
|
</span>
|
|
|
</button>
|
|
|
|
|
|
<p className="px-3 py-1.5 text-[11px] font-medium uppercase tracking-wide text-text-disabled">
|
|
|
搜索结果
|
|
|
</p>
|
|
|
|
|
|
{loading && candidates.length === 0 && (
|
|
|
<p className="px-3 py-3 text-sm text-text-secondary">正在搜索…</p>
|
|
|
)}
|
|
|
{!loading && candidates.length === 0 && !error && (
|
|
|
<p className="px-3 py-3 text-sm text-text-secondary">
|
|
|
未找到匹配地址,请调整关键词
|
|
|
</p>
|
|
|
)}
|
|
|
|
|
|
{candidates.map((item) => {
|
|
|
const { primary, secondary } = splitDisplay(
|
|
|
item.display_label || item.formatted_address,
|
|
|
);
|
|
|
return (
|
|
|
<button
|
|
|
key={item.option_id}
|
|
|
type="button"
|
|
|
role="option"
|
|
|
onClick={() => handlePick(item)}
|
|
|
className="flex w-full items-center gap-2 px-3 py-2.5 text-left hover:bg-bg"
|
|
|
>
|
|
|
<MagnifyingGlass
|
|
|
size={16}
|
|
|
className="shrink-0 text-text-disabled"
|
|
|
/>
|
|
|
<span className="min-w-0 flex-1">
|
|
|
<span className="block truncate text-sm text-text-primary">
|
|
|
{primary}
|
|
|
</span>
|
|
|
{secondary ? (
|
|
|
<span className="block truncate text-xs text-text-secondary">
|
|
|
{secondary}
|
|
|
</span>
|
|
|
) : null}
|
|
|
</span>
|
|
|
<CaretRight size={14} className="shrink-0 text-text-disabled" />
|
|
|
</button>
|
|
|
);
|
|
|
})}
|
|
|
</div>
|
|
|
)}
|
|
|
</div>
|
|
|
);
|
|
|
}
|