|
|
@@ -0,0 +1,167 @@
|
|
|
+import { useState, useEffect, useRef, useCallback } from "react";
|
|
|
+import { ErrorBoundary } from "./error";
|
|
|
+import styles from "./mask.module.scss";
|
|
|
+import { useNavigate } from "react-router-dom";
|
|
|
+import { IconButton } from "./button";
|
|
|
+import CloseIcon from "../icons/close.svg";
|
|
|
+import EyeIcon from "../icons/eye.svg";
|
|
|
+import Locale from "../locales";
|
|
|
+import { Path } from "../constant";
|
|
|
+
|
|
|
+import { useChatStore } from "../store";
|
|
|
+
|
|
|
+type Item = {
|
|
|
+ id: number;
|
|
|
+ name: string;
|
|
|
+ content: string;
|
|
|
+};
|
|
|
+export function SearchChatPage() {
|
|
|
+ const navigate = useNavigate();
|
|
|
+
|
|
|
+ const chatStore = useChatStore();
|
|
|
+
|
|
|
+ const sessions = chatStore.sessions;
|
|
|
+ const selectSession = chatStore.selectSession;
|
|
|
+
|
|
|
+ const [searchResults, setSearchResults] = useState<Item[]>([]);
|
|
|
+
|
|
|
+ const previousValueRef = useRef<string>("");
|
|
|
+ const searchInputRef = useRef<HTMLInputElement>(null);
|
|
|
+ const doSearch = useCallback((text: string) => {
|
|
|
+ const lowerCaseText = text.toLowerCase();
|
|
|
+ const results: Item[] = [];
|
|
|
+
|
|
|
+ sessions.forEach((session, index) => {
|
|
|
+ const fullTextContents: string[] = [];
|
|
|
+
|
|
|
+ session.messages.forEach((message) => {
|
|
|
+ const content = message.content as string;
|
|
|
+ if (!content.toLowerCase || content === "") return;
|
|
|
+ const lowerCaseContent = content.toLowerCase();
|
|
|
+
|
|
|
+ // full text search
|
|
|
+ let pos = lowerCaseContent.indexOf(lowerCaseText);
|
|
|
+ while (pos !== -1) {
|
|
|
+ const start = Math.max(0, pos - 35);
|
|
|
+ const end = Math.min(content.length, pos + lowerCaseText.length + 35);
|
|
|
+ fullTextContents.push(content.substring(start, end));
|
|
|
+ pos = lowerCaseContent.indexOf(
|
|
|
+ lowerCaseText,
|
|
|
+ pos + lowerCaseText.length,
|
|
|
+ );
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (fullTextContents.length > 0) {
|
|
|
+ results.push({
|
|
|
+ id: index,
|
|
|
+ name: session.topic,
|
|
|
+ content: fullTextContents.join("... "), // concat content with...
|
|
|
+ });
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ // sort by length of matching content
|
|
|
+ results.sort((a, b) => b.content.length - a.content.length);
|
|
|
+
|
|
|
+ return results;
|
|
|
+ }, []);
|
|
|
+
|
|
|
+ useEffect(() => {
|
|
|
+ const intervalId = setInterval(() => {
|
|
|
+ if (searchInputRef.current) {
|
|
|
+ const currentValue = searchInputRef.current.value;
|
|
|
+ if (currentValue !== previousValueRef.current) {
|
|
|
+ if (currentValue.length > 0) {
|
|
|
+ const result = doSearch(currentValue);
|
|
|
+ setSearchResults(result);
|
|
|
+ }
|
|
|
+ previousValueRef.current = currentValue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }, 1000);
|
|
|
+
|
|
|
+ // Cleanup the interval on component unmount
|
|
|
+ return () => clearInterval(intervalId);
|
|
|
+ }, [doSearch]);
|
|
|
+
|
|
|
+ return (
|
|
|
+ <ErrorBoundary>
|
|
|
+ <div className={styles["mask-page"]}>
|
|
|
+ {/* header */}
|
|
|
+ <div className="window-header">
|
|
|
+ <div className="window-header-title">
|
|
|
+ <div className="window-header-main-title">
|
|
|
+ {Locale.SearchChat.Page.Title}
|
|
|
+ </div>
|
|
|
+ <div className="window-header-submai-title">
|
|
|
+ {Locale.SearchChat.Page.SubTitle(searchResults.length)}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className="window-actions">
|
|
|
+ <div className="window-action-button">
|
|
|
+ <IconButton
|
|
|
+ icon={<CloseIcon />}
|
|
|
+ bordered
|
|
|
+ onClick={() => navigate(-1)}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div className={styles["mask-page-body"]}>
|
|
|
+ <div className={styles["mask-filter"]}>
|
|
|
+ {/**搜索输入框 */}
|
|
|
+ <input
|
|
|
+ type="text"
|
|
|
+ className={styles["search-bar"]}
|
|
|
+ placeholder={Locale.SearchChat.Page.Search}
|
|
|
+ autoFocus
|
|
|
+ ref={searchInputRef}
|
|
|
+ onKeyDown={(e) => {
|
|
|
+ if (e.key === "Enter") {
|
|
|
+ e.preventDefault();
|
|
|
+ const searchText = e.currentTarget.value;
|
|
|
+ if (searchText.length > 0) {
|
|
|
+ const result = doSearch(searchText);
|
|
|
+ setSearchResults(result);
|
|
|
+ }
|
|
|
+ }
|
|
|
+ }}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div>
|
|
|
+ {searchResults.map((item) => (
|
|
|
+ <div
|
|
|
+ className={styles["mask-item"]}
|
|
|
+ key={item.id}
|
|
|
+ onClick={() => {
|
|
|
+ navigate(Path.Chat);
|
|
|
+ selectSession(item.id);
|
|
|
+ }}
|
|
|
+ style={{ cursor: "pointer" }}
|
|
|
+ >
|
|
|
+ {/** 搜索匹配的文本 */}
|
|
|
+ <div className={styles["mask-header"]}>
|
|
|
+ <div className={styles["mask-title"]}>
|
|
|
+ <div className={styles["mask-name"]}>{item.name}</div>
|
|
|
+ {item.content.slice(0, 70)}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ {/** 操作按钮 */}
|
|
|
+ <div className={styles["mask-actions"]}>
|
|
|
+ <IconButton
|
|
|
+ icon={<EyeIcon />}
|
|
|
+ text={Locale.SearchChat.Item.View}
|
|
|
+ />
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ ))}
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </ErrorBoundary>
|
|
|
+ );
|
|
|
+}
|