FAQSearchDropdown.tsx 6.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210
  1. "use client";
  2. import { useCallback, useEffect, useRef, useState } from "react";
  3. import type { FAQQuickResult } from "@/features/agent/services/faqApi";
  4. import { quickSearchFAQs } from "@/features/agent/services/faqApi";
  5. import { Loader2, Search } from "lucide-react";
  6. import { useI18n } from "@/lib/i18n/provider";
  7. interface FAQSearchDropdownProps {
  8. open: boolean;
  9. onClose: () => void;
  10. onSelect: (faq: FAQQuickResult) => void;
  11. }
  12. export function FAQSearchDropdown({
  13. open,
  14. onClose,
  15. onSelect,
  16. }: FAQSearchDropdownProps) {
  17. const { t } = useI18n();
  18. const inputRef = useRef<HTMLInputElement>(null);
  19. const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  20. const containerRef = useRef<HTMLDivElement>(null);
  21. const [query, setQuery] = useState("");
  22. const [results, setResults] = useState<FAQQuickResult[]>([]);
  23. const [loading, setLoading] = useState(false);
  24. const [error, setError] = useState<string | null>(null);
  25. const [selectedIndex, setSelectedIndex] = useState(-1);
  26. useEffect(() => {
  27. if (open) {
  28. setQuery("");
  29. setResults([]);
  30. setError(null);
  31. setSelectedIndex(-1);
  32. setTimeout(() => inputRef.current?.focus(), 30);
  33. }
  34. }, [open]);
  35. useEffect(() => {
  36. return () => {
  37. if (debounceRef.current) clearTimeout(debounceRef.current);
  38. };
  39. }, []);
  40. const doSearch = useCallback(async (q: string) => {
  41. if (!q.trim()) {
  42. setResults([]);
  43. setError(null);
  44. setSelectedIndex(-1);
  45. return;
  46. }
  47. setLoading(true);
  48. setError(null);
  49. try {
  50. const data = await quickSearchFAQs(q.trim(), 10);
  51. setResults(data);
  52. setSelectedIndex(data.length > 0 ? 0 : -1);
  53. } catch (err) {
  54. setError((err as Error).message || "搜索失败");
  55. setResults([]);
  56. setSelectedIndex(-1);
  57. } finally {
  58. setLoading(false);
  59. }
  60. }, []);
  61. const handleInputChange = useCallback(
  62. (value: string) => {
  63. setQuery(value);
  64. if (debounceRef.current) clearTimeout(debounceRef.current);
  65. debounceRef.current = setTimeout(() => doSearch(value), 300);
  66. },
  67. [doSearch]
  68. );
  69. const handleSelect = useCallback(
  70. (faq: FAQQuickResult) => {
  71. onSelect(faq);
  72. },
  73. [onSelect]
  74. );
  75. const handleKeyDown = useCallback(
  76. (event: React.KeyboardEvent<HTMLInputElement>) => {
  77. switch (event.key) {
  78. case "ArrowDown":
  79. event.preventDefault();
  80. setSelectedIndex((prev) =>
  81. results.length === 0 ? -1 : Math.min(results.length - 1, prev + 1)
  82. );
  83. break;
  84. case "ArrowUp":
  85. event.preventDefault();
  86. setSelectedIndex((prev) => Math.max(0, prev - 1));
  87. break;
  88. case "Enter":
  89. if (selectedIndex >= 0 && selectedIndex < results.length) {
  90. event.preventDefault();
  91. handleSelect(results[selectedIndex]);
  92. }
  93. break;
  94. case "Escape":
  95. event.preventDefault();
  96. onClose();
  97. break;
  98. }
  99. },
  100. [results, selectedIndex, handleSelect, onClose]
  101. );
  102. useEffect(() => {
  103. if (!open) return;
  104. const handleClickOutside = (event: MouseEvent) => {
  105. if (
  106. containerRef.current &&
  107. !containerRef.current.contains(event.target as Node)
  108. ) {
  109. onClose();
  110. }
  111. };
  112. const timer = setTimeout(() => {
  113. document.addEventListener("mousedown", handleClickOutside);
  114. }, 100);
  115. return () => {
  116. clearTimeout(timer);
  117. document.removeEventListener("mousedown", handleClickOutside);
  118. };
  119. }, [open, onClose]);
  120. if (!open) return null;
  121. return (
  122. <div
  123. ref={containerRef}
  124. className="absolute bottom-full left-4 right-4 mb-2 bg-popover border border-border rounded-xl shadow-xl z-50 max-h-[380px] flex flex-col overflow-hidden"
  125. >
  126. <div className="flex items-center gap-2 px-3 py-3 border-b border-border/50 flex-shrink-0">
  127. <Search className="w-4 h-4 text-muted-foreground flex-shrink-0" />
  128. <input
  129. ref={inputRef}
  130. type="text"
  131. value={query}
  132. onChange={(e) => handleInputChange(e.target.value)}
  133. onKeyDown={handleKeyDown}
  134. placeholder={t("agent.faqs.quickSearch.placeholder")}
  135. className="flex-1 bg-transparent border-none outline-none text-sm placeholder:text-muted-foreground"
  136. />
  137. <kbd
  138. className="text-[10px] px-1.5 py-0.5 rounded bg-muted border border-border text-muted-foreground cursor-pointer flex-shrink-0"
  139. onClick={onClose}
  140. >
  141. Esc
  142. </kbd>
  143. </div>
  144. <div className="overflow-y-auto flex-1 min-h-[60px]">
  145. {loading && (
  146. <div className="flex items-center justify-center gap-2 py-8 text-sm text-muted-foreground">
  147. <Loader2 className="w-4 h-4 animate-spin" />
  148. {t("agent.faqs.quickSearch.searching")}
  149. </div>
  150. )}
  151. {error && (
  152. <div className="text-center py-6 text-sm text-destructive px-4">
  153. {error}
  154. </div>
  155. )}
  156. {!loading && !error && query.trim() && results.length === 0 && (
  157. <div className="text-center py-6 text-sm text-muted-foreground">
  158. {t("agent.faqs.quickSearch.noResults")}
  159. </div>
  160. )}
  161. {!loading && results.length > 0 && (
  162. <div className="py-1">
  163. {results.map((faq, index) => (
  164. <button
  165. key={faq.id}
  166. type="button"
  167. onClick={() => handleSelect(faq)}
  168. className={`w-full text-left px-4 py-2.5 transition-colors cursor-pointer ${
  169. index === selectedIndex
  170. ? "bg-accent text-accent-foreground"
  171. : "hover:bg-accent/60"
  172. }`}
  173. >
  174. <div className="font-medium text-sm line-clamp-1">
  175. {faq.question}
  176. </div>
  177. <div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">
  178. {faq.answer}
  179. </div>
  180. </button>
  181. ))}
  182. </div>
  183. )}
  184. {!loading && !query.trim() && (
  185. <div className="text-center py-6 text-sm text-muted-foreground">
  186. {t("agent.faqs.quickSearch.startTyping")}
  187. </div>
  188. )}
  189. </div>
  190. </div>
  191. );
  192. }