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-...

297 lines
9.1 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;
}
// 已确认且文案未变:不重复请求
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>
);
}