You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
chajia/components/mothership/mothership-address-suggest-...

325 lines
10 KiB

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

/**
* 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;
}
// 已确认且文案未变:不重复请求
const confirmedText = (
confirmed?.display_label ||
confirmed?.formatted_address ||
""
).trim();
if (confirmed && confirmedText && confirmedText === 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) => {
const label = (item.display_label || item.formatted_address || "").trim();
onQueryChange(label);
onConfirm({
...item,
display_label: label,
formatted_address: (item.formatted_address || label).trim(),
});
setOpen(false);
setError(null);
setCandidates([]);
};
const confirmedLabel = (
confirmed?.display_label ||
confirmed?.formatted_address ||
""
).trim();
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"
: confirmed
? "border-[#16A34A] focus:border-[#16A34A] focus:ring-[#16A34A]/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 && confirmedLabel ? (
<p className="text-xs font-medium text-[#16A34A]">
已确认联想地址:{confirmedLabel}
</p>
) : null}
{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="请从搜索结果中选择经校验的地址"
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"
onMouseDown={(e) => {
// 避免 mousedown 关层 / input blur 抢在 click 前,导致点选无反应
e.preventDefault();
e.stopPropagation();
handlePick(item);
}}
onClick={(e) => {
e.preventDefault();
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>
);
}