1158 lines
47 KiB
TypeScript
1158 lines
47 KiB
TypeScript
"use client";
|
||
|
||
import {
|
||
createContext,
|
||
type FC,
|
||
type ReactNode,
|
||
useCallback,
|
||
useContext,
|
||
useEffect,
|
||
useRef,
|
||
useState,
|
||
} from "react";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogTitle,
|
||
} from "@/components/ui/dialog";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import {
|
||
Search,
|
||
Loader2,
|
||
Plus,
|
||
Trash2,
|
||
} from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import { toast } from "sonner";
|
||
import { getStoredAuth } from "@/core/auth";
|
||
import { readRuntimeEnv } from "@/env";
|
||
import { queryClient } from "@/components/query-client-provider";
|
||
import { Checkbox } from "@/components/ui/checkbox";
|
||
import Empty from "./empty";
|
||
import { NetworkActor } from "./network-actor";
|
||
|
||
// ── 适配层 ─────────────────────────────────────────────────
|
||
|
||
// 本组件只对接「我的笔记/空间」后端,用不到本系统(DeerFlow)token。
|
||
// notebook token 由 fetchStudioToken(getTokenByEmail) 获取,缓存在 react-query
|
||
// 的 ["studio","token",email] 下(见 core/studio/hooks)。
|
||
function getNotebookToken(): string | undefined {
|
||
const email = getStoredAuth()?.email ?? "";
|
||
return queryClient.getQueryData<string>(["studio", "token", email]);
|
||
}
|
||
|
||
// 上传到笔记后端时传 useNotebookToken=true 注入我的笔记 token;
|
||
// 其余请求不注入本系统 token,调用方自行在 init.headers 里带 Authorization。
|
||
export function authenticatedFetch(url: string, init?: RequestInit, useNotebookToken = false): Promise<Response> {
|
||
const token = useNotebookToken ? getNotebookToken() : undefined;
|
||
return fetch(url, {
|
||
...init,
|
||
headers: {
|
||
...(init?.headers ?? {}),
|
||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||
},
|
||
});
|
||
}
|
||
|
||
function getYUserId(): string {
|
||
if (typeof window === "undefined") return "1157761008018653184";
|
||
try {
|
||
const raw = localStorage.getItem("deerflow.external_user_info");
|
||
if (raw) {
|
||
const obj = JSON.parse(raw) as { yUserId?: string };
|
||
if (obj?.yUserId) return obj.yUserId;
|
||
}
|
||
} catch { }
|
||
return "1157761008018653184";
|
||
}
|
||
|
||
|
||
|
||
const searchTypes = [
|
||
{ value: "human", label: "人物基因库" },
|
||
{ value: "organization", label: "组织基因库" },
|
||
{ value: "community", label: "群体基因库" },
|
||
{ value: "issue", label: "议题基因库" },
|
||
{ value: "event", label: "事件基因库" },
|
||
{ value: "threat", label: "风险与机会" },
|
||
] as const;
|
||
|
||
// ── Context ────────────────────────────────────────────────
|
||
interface DocumentUploadDialogContextType {
|
||
openDialog: (initialSearchText?: string) => void;
|
||
closeDialog: (
|
||
mode?: "search",
|
||
data?: { query: string; mode: string }
|
||
) => Promise<void>;
|
||
// 上传成功回调,返回上传的来源信息
|
||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||
}
|
||
|
||
// ── 任务轮询类型 ─────────────────────────────────────────────
|
||
interface TaskSource {
|
||
task_id: string;
|
||
title: string;
|
||
status: 'processing' | 'success' | 'error';
|
||
source_type: string;
|
||
}
|
||
|
||
interface UploadResponse {
|
||
status: string;
|
||
uploaded: number;
|
||
skipped: number;
|
||
sources: TaskSource[];
|
||
}
|
||
|
||
interface listDataItem {
|
||
id?: string | number;
|
||
name?: string;
|
||
redisKey?: string;
|
||
abstractz?: string;
|
||
checked: boolean;
|
||
title: string;
|
||
content: string;
|
||
url: string;
|
||
// 上传文件轮询相关字段
|
||
task_id?: string;
|
||
processing_status?: 'processing' | 'success' | 'error';
|
||
is_uploaded_file?: boolean;
|
||
country?: string;
|
||
dateOfBirth?: string;
|
||
occupationData?: string;
|
||
party?: string;
|
||
sexOrGender?: string;
|
||
imagePath?: string;
|
||
mongoId?: string;
|
||
countryData?: string;
|
||
countrySingle?: string;
|
||
subtype?: string;
|
||
text?: string;
|
||
twoLevel?: string;
|
||
account_ids?: unknown[];
|
||
community_description?: string;
|
||
community_name?: string;
|
||
community_type?: string;
|
||
create_time?: string;
|
||
creator?: string;
|
||
groups?: string;
|
||
labels?: string;
|
||
nations?: string;
|
||
params?: string;
|
||
platforms?: string;
|
||
real_total?: number;
|
||
total?: number;
|
||
update_time?: string;
|
||
virtual_total?: number;
|
||
direction?: string;
|
||
hot_num?: number;
|
||
issue_hot?: number;
|
||
issue_name?: string;
|
||
issue_type?: string;
|
||
news_num?: number;
|
||
post_num?: number;
|
||
topic?: string;
|
||
abstract?: string;
|
||
allwords?: string[];
|
||
category?: string;
|
||
controversy_num?: number;
|
||
date?: string;
|
||
discriminant_reason?: string;
|
||
discriminant_result?: string;
|
||
doc_author?: string;
|
||
doc_ids?: string[];
|
||
emotion_info?: string;
|
||
first_publish_time?: string;
|
||
grade?: string;
|
||
human_id_list?: string;
|
||
human_list?: string[];
|
||
if_artificial?: number;
|
||
if_follow?: number;
|
||
if_shehua?: number;
|
||
if_show?: number;
|
||
image_path?: string;
|
||
import_num?: number;
|
||
insert_time?: string;
|
||
jw?: string;
|
||
keywords?: string[];
|
||
location?: string[];
|
||
location_list?: unknown[];
|
||
location_type?: number;
|
||
longitude?: number;
|
||
media_board?: string;
|
||
media_type?: string;
|
||
nowords?: unknown[];
|
||
onewords?: unknown[];
|
||
organization_id_list?: string;
|
||
organization_list?: unknown[];
|
||
raw_id?: string;
|
||
report_num?: number;
|
||
site_name?: string;
|
||
source_type?: string;
|
||
sub_category?: string;
|
||
subject?: string;
|
||
super_category?: string;
|
||
surge_num?: number;
|
||
tone?: string;
|
||
topic_id?: string;
|
||
total_num?: number;
|
||
type?: string;
|
||
userid?: string;
|
||
wx_sub_score?: string;
|
||
wx_total_score?: string;
|
||
}
|
||
|
||
const DocumentUploadDialogContext =
|
||
createContext<DocumentUploadDialogContextType | null>(null);
|
||
|
||
export const useDocumentUploadDialog = () => {
|
||
const context = useContext(DocumentUploadDialogContext);
|
||
if (!context) {
|
||
throw new Error(
|
||
"useDocumentUploadDialog must be used within DocumentUploadDialogProvider"
|
||
);
|
||
}
|
||
return context;
|
||
};
|
||
|
||
// ── Provider ───────────────────────────────────────────────
|
||
export const DocumentUploadDialogProvider: FC<{
|
||
children: ReactNode;
|
||
onClose?: (
|
||
mode?: "search",
|
||
data?: { query: string; mode: string }
|
||
) => void | Promise<void>;
|
||
searchSpace: { id: number | string;[key: string]: unknown };
|
||
onUploadSuccess?: () => void;
|
||
onUploadComplete?: (sources: Array<{ id?: number; title: string; task_id?: string; status: string }>) => void;
|
||
}> = ({ children, onClose, searchSpace, onUploadSuccess, onUploadComplete }) => {
|
||
const [isOpen, setIsOpen] = useState(false);
|
||
const [initialSearchText, setInitialSearchText] = useState("");
|
||
const isClosingRef = useRef(false);
|
||
|
||
const openDialog = useCallback((initialSearchText?: string) => {
|
||
if (isClosingRef.current) return;
|
||
setInitialSearchText(initialSearchText || "");
|
||
setIsOpen(true);
|
||
}, []);
|
||
|
||
const closeDialog = useCallback(
|
||
async (mode?: "search", data?: { query: string; mode: string }) => {
|
||
isClosingRef.current = true;
|
||
if (onClose) {
|
||
try {
|
||
await onClose(mode, data);
|
||
} catch (error) {
|
||
console.error("Error in Provider onClose:", error);
|
||
}
|
||
}
|
||
setIsOpen(false);
|
||
setTimeout(() => {
|
||
isClosingRef.current = false;
|
||
}, 300);
|
||
},
|
||
[onClose]
|
||
);
|
||
|
||
const handleOpenChange = useCallback(
|
||
(open: boolean) => {
|
||
if (!open) {
|
||
if (!isClosingRef.current) {
|
||
void closeDialog("search", { query: "", mode: "" });
|
||
void closeDialog();
|
||
}
|
||
} else {
|
||
if (!isClosingRef.current) {
|
||
setIsOpen(true);
|
||
}
|
||
}
|
||
},
|
||
[closeDialog]
|
||
);
|
||
|
||
return (
|
||
<DocumentUploadDialogContext.Provider value={{ openDialog, closeDialog, onUploadComplete }}>
|
||
{children}
|
||
<DocumentUploadPopupContent
|
||
isOpen={isOpen}
|
||
onOpenChange={handleOpenChange}
|
||
searchSpace={searchSpace}
|
||
onUploadSuccess={onUploadSuccess}
|
||
closeDialog={closeDialog}
|
||
initialSearchText={initialSearchText}
|
||
/>
|
||
</DocumentUploadDialogContext.Provider>
|
||
);
|
||
};
|
||
|
||
// ── Popup content ──────────────────────────────────────────
|
||
const UPLOAD_ACCEPT =
|
||
".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
|
||
|
||
const DocumentUploadPopupContent: FC<{
|
||
isOpen: boolean;
|
||
onOpenChange: (open: boolean) => void;
|
||
searchSpace: { id: number | string;[key: string]: unknown };
|
||
onUploadSuccess?: () => void;
|
||
closeDialog: (
|
||
mode?: "search",
|
||
data?: { query: string; mode: string }
|
||
) => Promise<void>;
|
||
initialSearchText?: string;
|
||
}> = ({ isOpen, onOpenChange, searchSpace, onUploadSuccess, initialSearchText }) => {
|
||
const { onUploadComplete } = useContext(DocumentUploadDialogContext) || {};
|
||
const searchSpaceId = String(searchSpace?.id);
|
||
|
||
// 从环境变量获取配置
|
||
const backendUrl = readRuntimeEnv("VITE_AIPI_BACKENP_URL") || "http://localhost:8001";
|
||
const consumerUrl = readRuntimeEnv("VITE_AIPI_CONSUMER_URL") || "http://ch1.b.uat.4.cn/consumer";
|
||
const presentUrl = readRuntimeEnv("VITE_AIPI_PRESENT_URL") || "http://present.a.uat.4.cn/present/domain";
|
||
const knowesUrl = readRuntimeEnv("VITE_AIPI_KNOWES_URL") || "http://ch1.b.uat.4.cn/knowes";
|
||
const knowledgeUrl = readRuntimeEnv("VITE_AIPI_KNOWLEDGE_URL") || "http://chlib.b.uat.4.cn/knowlibrary";
|
||
|
||
const [searchText, setSearchText] = useState("");
|
||
const [searchValue, setSearchValue] = useState("");
|
||
const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search");
|
||
const [isSearching, setIsSearching] = useState(false);
|
||
const [isImporting, setIsImporting] = useState(false);
|
||
const [listData, setListData] = useState<listDataItem[]>([]);
|
||
const [searchType, setSearchType] = useState("human");
|
||
const [selectedSearchSources, setSelectedSearchSources] = useState<listDataItem[]>([]);
|
||
// upload tab state
|
||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||
const [uploading, setUploading] = useState(false);
|
||
|
||
useEffect(() => {
|
||
if (isOpen && initialSearchText) {
|
||
setSearchText(initialSearchText);
|
||
setSearchValue(initialSearchText);
|
||
}
|
||
}, [isOpen, initialSearchText]);
|
||
|
||
if (!searchSpaceId) return null;
|
||
|
||
const handleSuccess = () => {
|
||
onUploadSuccess?.();
|
||
onOpenChange(false);
|
||
};
|
||
|
||
const searchActiveChange = async (type: "search" | "Y" | "upload" | "A" | "actor") => {
|
||
setActiveTab(type);
|
||
if (type === "A") {
|
||
await searchAChange(searchType);
|
||
}
|
||
};
|
||
|
||
const handleFileUpload = async (files: File[]) => {
|
||
if (!files.length) return;
|
||
setUploading(true);
|
||
try {
|
||
const token = getNotebookToken() ?? "";
|
||
const formData = new FormData();
|
||
formData.append("search_space_id", searchSpaceId);
|
||
for (const file of files) {
|
||
formData.append("files", file);
|
||
}
|
||
const res = await fetch(`${backendUrl}/api/v1/documents/fileupload`, {
|
||
method: "POST",
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
body: formData,
|
||
});
|
||
if (res.ok) {
|
||
const data = await res.json() as UploadResponse;
|
||
// 调用上传完成回调,传递来源信息
|
||
if (data.sources && data.sources.length > 0) {
|
||
onUploadComplete?.(data.sources.map(s => ({
|
||
id: undefined, // 后端返回的数据中暂时没有 id
|
||
title: s.title,
|
||
task_id: s.task_id,
|
||
status: s.status,
|
||
})));
|
||
}
|
||
toast.success(`上传成功 ${data.uploaded || files.length} 个文件`);
|
||
// 只关闭上传弹窗
|
||
onOpenChange(false);
|
||
} else {
|
||
toast.error("上传失败,请重试");
|
||
}
|
||
} catch {
|
||
toast.error("上传失败,请重试");
|
||
} finally {
|
||
setUploading(false);
|
||
if (fileInputRef.current) fileInputRef.current.value = "";
|
||
}
|
||
};
|
||
|
||
const handleImport = async () => {
|
||
if (selectedSearchSources.length === 0) {
|
||
toast.warning("没有可导入的来源");
|
||
return;
|
||
}
|
||
setIsImporting(true);
|
||
const dataList = selectedSearchSources.map((item) => ({
|
||
title: item.title,
|
||
content: item.content,
|
||
url: item.url,
|
||
}));
|
||
try {
|
||
const response = await authenticatedFetch(`${backendUrl}/api/v1/source/import`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({
|
||
search_space_id: Number(searchSpaceId),
|
||
source_list: dataList,
|
||
}),
|
||
}, true);
|
||
if (response.ok) {
|
||
const data = await response.json() as { imported_count?: number };
|
||
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
|
||
onUploadComplete?.(
|
||
selectedSearchSources.map((s) => ({
|
||
id: undefined,
|
||
title: s.title,
|
||
task_id: undefined,
|
||
status: "success" as const,
|
||
}))
|
||
);
|
||
onOpenChange(false);
|
||
} else {
|
||
const errorData = await response.json() as { detail?: string };
|
||
toast.error(errorData.detail || "导入失败,请重试");
|
||
}
|
||
} catch {
|
||
toast.error("导入失败,请重试");
|
||
} finally {
|
||
setIsImporting(false);
|
||
}
|
||
};
|
||
|
||
const listDataCheckedChange = (index: number, checked: boolean) => {
|
||
setListData((prevSources) => {
|
||
const newSources = prevSources.map((item, aIndex) =>
|
||
aIndex === index ? { ...item, checked } : item
|
||
);
|
||
setSelectedSearchSources(newSources.filter((item) => item.checked));
|
||
return newSources;
|
||
});
|
||
};
|
||
|
||
const handleBatchDelete = () => {
|
||
setListData((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||
setSelectedSearchSources([]);
|
||
};
|
||
|
||
|
||
|
||
function urlSafeBtoa(str: string): string {
|
||
const encoder = new TextEncoder();
|
||
const uint8Array = encoder.encode(str);
|
||
const binaryString = Array.prototype.map
|
||
.call(uint8Array, (byte: number) => String.fromCharCode(byte))
|
||
.join("");
|
||
return btoa(binaryString).replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_");
|
||
}
|
||
|
||
const linkToA = (url: string) => { window.open(url); };
|
||
|
||
const queryTopicTargetHumanList = async () => {
|
||
setIsSearching(true);
|
||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryTopicTargetHumanList`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json() as { data: listDataItem[] };
|
||
setIsSearching(false);
|
||
const result = data.data;
|
||
result.forEach((item) => {
|
||
item.checked = false;
|
||
item.title = item.name || "";
|
||
item.content = item.abstractz || "";
|
||
const redirect = urlSafeBtoa(`/gene/#/human/basic?mid=${item.id}`);
|
||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||
});
|
||
setListData(result);
|
||
}
|
||
setIsSearching(false);
|
||
};
|
||
|
||
const organizationSearch = async () => {
|
||
setIsSearching(true);
|
||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunity`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||
body: JSON.stringify({ name: searchText, page: 1, size: 50 }),
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json() as { data: listDataItem[] };
|
||
setIsSearching(false);
|
||
const result = data.data;
|
||
result.forEach((item) => {
|
||
item.checked = false;
|
||
item.title = item.name || "";
|
||
item.content = item.abstractz || "";
|
||
const redirect = urlSafeBtoa(`/gene/#/organization/basicAttr?mid=${item.mongoId}`);
|
||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||
});
|
||
setListData(result);
|
||
}
|
||
setIsSearching(false);
|
||
};
|
||
|
||
const queryCommunityList = async () => {
|
||
setIsSearching(true);
|
||
const params = { keyword: searchText, page: 1, size: 50 };
|
||
const queryString = Object.entries(params).map(([k, v]) => `${k}=${v}`).join("&");
|
||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryCommunityList?${queryString}`, {
|
||
method: "GET",
|
||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json() as { data: listDataItem[] };
|
||
setIsSearching(false);
|
||
const result = data.data;
|
||
result.forEach((item) => {
|
||
item.checked = false;
|
||
item.title = item.community_name || "";
|
||
item.content = item.community_description || "";
|
||
const redirect = urlSafeBtoa(`/gene/#/commune/basicCommune?cid=${item.id}`);
|
||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||
});
|
||
setListData(result);
|
||
}
|
||
setIsSearching(false);
|
||
};
|
||
|
||
const issueSearch = async () => {
|
||
setIsSearching(true);
|
||
const response = await authenticatedFetch(`${consumerUrl}/issue/list?keyword=${searchText}`, {
|
||
method: "GET",
|
||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json() as { data: listDataItem[] };
|
||
setIsSearching(false);
|
||
const result = data.data;
|
||
result.forEach((item) => {
|
||
item.checked = false;
|
||
item.title = item.issue_name || "";
|
||
item.content = item.issue_type || "";
|
||
const redirect = encodeURIComponent(`/gene/#/issue/zzyyIssue?mid=${item.id}&event_type=issue`);
|
||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||
});
|
||
setListData(result);
|
||
}
|
||
setIsSearching(false);
|
||
};
|
||
|
||
const eventSearch = async () => {
|
||
setIsSearching(true);
|
||
const response = await authenticatedFetch(`${consumerUrl}/event/queryEvents`, {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||
body: JSON.stringify({ word: searchText, page: 1, size: 50, type: "hot", timeRange: "" }),
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json() as { data: listDataItem[] };
|
||
setIsSearching(false);
|
||
const result = data.data;
|
||
result.forEach((item) => {
|
||
item.checked = false;
|
||
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
|
||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||
});
|
||
setListData(result);
|
||
}
|
||
setIsSearching(false);
|
||
};
|
||
|
||
const topicQuery = async () => {
|
||
setIsSearching(true);
|
||
let result: listDataItem[] = [];
|
||
for (const discriminant of ["威胁", "ZJ机"]) {
|
||
const queryString = `keyword=${searchText}&discriminant=${discriminant}`;
|
||
const response = await authenticatedFetch(`${consumerUrl}/topicQuery/queryEventFromA?${queryString}`, {
|
||
method: "GET",
|
||
headers: { "Content-Type": "application/json", Authorization: "Bearer admin" },
|
||
});
|
||
if (response.ok) {
|
||
const data = await response.json() as { data: listDataItem[] };
|
||
const results = data.data;
|
||
results.forEach((item) => {
|
||
item.checked = false;
|
||
const redirect = urlSafeBtoa(`/gene/#/event/basicEvent?event_type=hot&mid=${item.id}`);
|
||
item.url = `${presentUrl}?inredirect=${redirect}`;
|
||
});
|
||
result = [...result, ...results];
|
||
}
|
||
}
|
||
setIsSearching(false);
|
||
setListData(result);
|
||
};
|
||
|
||
const searchAChange = async (type: string) => {
|
||
if (type === "human") await queryTopicTargetHumanList();
|
||
else if (type === "organization") await organizationSearch();
|
||
else if (type === "community") await queryCommunityList();
|
||
else if (type === "issue") await issueSearch();
|
||
else if (type === "event") await eventSearch();
|
||
else if (type === "threat") await topicQuery();
|
||
};
|
||
|
||
const searchTypeChange = (value: string) => {
|
||
setSearchType(value);
|
||
setListData([]);
|
||
void searchAChange(value);
|
||
};
|
||
|
||
const searchDataChange = () => {
|
||
setIsSearching(true);
|
||
setSearchValue(searchText);
|
||
setTimeout(() => setIsSearching(false), 500);
|
||
if (activeTab === "A") void searchAChange(searchType);
|
||
};
|
||
|
||
const yUserId = getYUserId();
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||
<DialogContent
|
||
className="w-[90vw] max-w-[90vw] sm:max-w-[90vw] h-[90vh] flex flex-col p-0 gap-0 overflow-hidden border border-border bg-muted text-foreground [&>button]:right-3 [&>button]:top-4 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button]:z-[100] [&>button_svg]:size-4"
|
||
onPointerDownOutside={(e) => e.preventDefault()}
|
||
onInteractOutside={(e) => e.preventDefault()}
|
||
>
|
||
<DialogTitle className="sr-only">添加来源</DialogTitle>
|
||
<DialogDescription className="sr-only">从外部系统搜索并导入来源</DialogDescription>
|
||
<div className="flex-1 min-h-0 relative overflow-hidden">
|
||
<div className="h-full overflow-y-auto">
|
||
<div className="px-3 sm:px-12 pt-12 sm:pt-24 pb-6 sm:pb-16">
|
||
{/* 搜索框 */}
|
||
<div className="relative rounded-lg border border-neutral-200 bg-white mb-[10px] transition-colors hover:border-neutral-300">
|
||
<Textarea
|
||
placeholder="搜索资料..."
|
||
value={searchText}
|
||
onChange={(e) => setSearchText(e.target.value)}
|
||
onKeyDown={(e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
searchDataChange();
|
||
}
|
||
}}
|
||
className="max-h-[160px] resize-none border-0 bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
|
||
rows={1}
|
||
/>
|
||
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none">
|
||
<Search className="h-4 w-4" strokeWidth={1.5} />
|
||
</div>
|
||
<Button
|
||
size="sm"
|
||
onClick={searchDataChange}
|
||
disabled={isSearching || !searchText.trim()}
|
||
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
|
||
>
|
||
{isSearching ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <span>搜索</span>}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Tab 标签页 */}
|
||
<div className="border-b border-gray-200 rounded-t-xl px-4 bg-gray-50">
|
||
<div className="flex flex-nowrap overflow-x-auto">
|
||
{(
|
||
[
|
||
{ key: "search" as const, label: "B系统(知识库)" },
|
||
{ key: "A" as const, label: "A系统(基因库)" },
|
||
{ key: "Y" as const, label: "Y系统(信息库)" },
|
||
{ key: "actor" as const, label: "B系统(XWT检索)" },
|
||
{ key: "upload" as const, label: "上传" },
|
||
] as const
|
||
).map(({ key, label }) => (
|
||
<button
|
||
key={key}
|
||
onClick={() => void searchActiveChange(key)}
|
||
className={`relative pb-3 px-6 pt-4 text-sm font-medium transition-colors ${activeTab === key ? "text-gray-900" : "text-gray-400 hover:text-gray-600"
|
||
}`}
|
||
>
|
||
{label}
|
||
{activeTab === key && (
|
||
<span className="absolute bottom-0 left-0 right-0 h-0.5 bg-gray-900 rounded-full" />
|
||
)}
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="my-5">
|
||
{/* B系统知识库 */}
|
||
{activeTab === "search" && !isSearching && (
|
||
<iframe
|
||
className="w-full h-[calc(100vh-200px)]"
|
||
src={`${knowesUrl}/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
|
||
/>
|
||
)}
|
||
|
||
{/* Y系统信息库 */}
|
||
{activeTab === "Y" && !isSearching && (
|
||
<iframe
|
||
className="w-full h-[calc(100vh-200px)]"
|
||
src={`${knowledgeUrl}/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
|
||
/>
|
||
)}
|
||
|
||
{/* A系统基因库 */}
|
||
{activeTab === "A" && (
|
||
<div className="bg-white rounded-lg">
|
||
{false && (
|
||
<div className="relative pt-[10px]">
|
||
<div className="relative rounded-lg border border-neutral-200 bg-white dark-theme-input ml-[10px] mr-[10px] mb-[10px] transition-colors hover:border-neutral-300">
|
||
<Textarea
|
||
placeholder="搜索资料..."
|
||
value={searchText}
|
||
onChange={(e) => setSearchText(e.target.value)}
|
||
onKeyDown={async (e) => {
|
||
if (e.key === "Enter" && !e.shiftKey) {
|
||
e.preventDefault();
|
||
await searchAChange(searchType);
|
||
}
|
||
}}
|
||
className=" max-h-[160px] resize-none border-0 dark-theme-input bg-transparent px-10 py-3 pr-20 text-sm text-neutral-700 placeholder:text-neutral-400 focus-visible:ring-0 focus-visible:outline-none"
|
||
rows={1}
|
||
/>
|
||
|
||
{/* 左侧搜索图标 */}
|
||
<div className="absolute left-3 top-3 text-neutral-400 pointer-events-none dark-theme-search">
|
||
<Search className="h-4 w-4" strokeWidth={1.5} />
|
||
</div>
|
||
|
||
{/* 右侧搜索按钮 */}
|
||
<Button
|
||
size="sm"
|
||
onClick={async () =>
|
||
await searchAChange(searchType)
|
||
}
|
||
disabled={isSearching || !searchText.trim()}
|
||
className="absolute right-2 top-2 h-7 rounded-md bg-neutral-900 px-3 text-xs font-medium text-white dark-theme-search transition-colors hover:bg-neutral-800 disabled:pointer-events-none disabled:opacity-40"
|
||
>
|
||
{isSearching ? (
|
||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||
) : (
|
||
<span>搜索</span>
|
||
)}
|
||
</Button>
|
||
|
||
{/* 左下角模式选择 */}
|
||
<div className="absolute left-2 bottom-1.5 flex items-center">
|
||
{/* <Select value={searchType} onValueChange={(value) => searchTypeChange(value)}>
|
||
<SelectTrigger className="h-6 w-auto min-w-[80px] gap-1.5 rounded border border-neutral-200 bg-white px-2 py-0 pr-1.5 text-[11px] font-medium text-neutral-600 hover:bg-neutral-50 data-[state=open]:bg-white">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent align="start" className="min-w-[120px] rounded-md border border-neutral-200 bg-white p-0.5 shadow-sm">
|
||
{searchTypes.map((mode) => {
|
||
return (
|
||
<SelectItem
|
||
key={mode.value}
|
||
value={mode.value}
|
||
className="rounded px-2 py-1.5 text-[11px] font-medium data-[highlighted]:bg-neutral-100"
|
||
>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="text-neutral-700">{mode.label}</span>
|
||
</div>
|
||
</SelectItem>
|
||
);
|
||
})}
|
||
</SelectContent>
|
||
</Select> */}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div className="custom-radio-group pt-[10px] ml-[10px]">
|
||
{" "}
|
||
{/* 外层容器,模拟 nz-radio-group */}
|
||
{searchTypes.map((option) => {
|
||
const isActive = searchType === option.value;
|
||
|
||
return (
|
||
<div
|
||
key={option.value}
|
||
className={`custom-radio-btn ${isActive ? "active" : ""
|
||
}`}
|
||
onClick={() => searchTypeChange(option.value)}
|
||
>
|
||
{option.label}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
{/* 批量操作栏 - 当有选中项时显示 */}
|
||
{selectedSearchSources.length > 0 && (
|
||
<div className="flex items-center justify-between px-4 py-2 bg-gray-50 border-b border-gray-100">
|
||
<span className="text-sm text-gray-600">
|
||
已选中 {selectedSearchSources.length} 项
|
||
</span>
|
||
<div className="flex gap-2">
|
||
<Button
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={handleBatchDelete}
|
||
className="h-8 px-3 text-sm"
|
||
>
|
||
<Trash2 className="h-3.5 w-3.5 mr-1" />
|
||
删除选中
|
||
</Button>
|
||
<Button
|
||
size="sm"
|
||
onClick={() => handleImport()}
|
||
disabled={isImporting}
|
||
className="h-8 px-3 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200"
|
||
>
|
||
{isImporting ? (
|
||
<Loader2 className="h-3.5 w-3.5 mr-1 animate-spin" />
|
||
) : (
|
||
<Plus className="h-3.5 w-3.5 mr-1" />
|
||
)}
|
||
导入选中
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 如果没有选中项,显示普通导入按钮 */}
|
||
{isImporting && (
|
||
<div className="px-4 py-2 border-b border-gray-100">
|
||
<Button
|
||
onClick={() => handleImport()}
|
||
disabled={isImporting}
|
||
className="w-full h-8 text-sm bg-blue-50 hover:bg-blue-100 text-blue-600 border border-blue-200 disabled:opacity-50 disabled:cursor-not-allowed"
|
||
>
|
||
{isImporting ? (
|
||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||
) : (
|
||
<Plus className="h-4 w-4 mr-1" />
|
||
)}
|
||
{isImporting ? "导入中..." : "导入全部"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
{isSearching ? (
|
||
<div className="flex items-center justify-center py-16">
|
||
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||
</div>
|
||
) : (
|
||
listData.length === 0 && <Empty />
|
||
)}
|
||
{searchType === "human" && (
|
||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||
{listData.map((item: listDataItem, index: number) => {
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||
>
|
||
<div className="w-[50px] mr-[10px]">
|
||
<img
|
||
src={item.imagePath || ""}
|
||
className="w-[50px] h-[50px]"
|
||
alt=""
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p
|
||
className="truncate hover:underline cursor-pointer"
|
||
onClick={() => linkToA(item.url)}
|
||
>
|
||
{item.name}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
职业:{item.occupationData}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
属地:{item.country}
|
||
</p>
|
||
</div>
|
||
<div className="absolute top-[5px] right-[10px]">
|
||
<Checkbox
|
||
checked={item.checked}
|
||
onCheckedChange={() =>
|
||
listDataCheckedChange(index, !item.checked)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{searchType === "organization" && (
|
||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||
{listData.map((item: listDataItem, index: number) => {
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="relative flex items-center shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||
>
|
||
<div className="w-[50px] mr-[10px]">
|
||
<img
|
||
src={item.imagePath || ""}
|
||
className="w-[50px] h-[50px]"
|
||
alt=""
|
||
/>
|
||
</div>
|
||
<div className="flex-1 min-w-0">
|
||
<p
|
||
className="truncate hover:underline cursor-pointer"
|
||
onClick={() => linkToA(item.url)}
|
||
>
|
||
{item.name}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
组织类别:{item.subtype}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
简介:{item.text}
|
||
</p>
|
||
</div>
|
||
<div className="absolute top-[5px] right-[10px]">
|
||
<Checkbox
|
||
checked={item.checked}
|
||
onCheckedChange={() =>
|
||
listDataCheckedChange(index, !item.checked)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{searchType === "community" && (
|
||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||
{listData.map((item: listDataItem, index: number) => {
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||
>
|
||
<div>
|
||
<div
|
||
className="truncate mb-[5px] hover:underline cursor-pointer"
|
||
onClick={() => linkToA(item.url)}
|
||
>
|
||
{item.title}
|
||
</div>
|
||
<div className="flex items-center text-[#909090] mb-[5px]">
|
||
{item.nations && (
|
||
<span className="truncate max-width-[200px] inline-block border border-[#cf1322] rounded px-1 text-[#cf1322]">
|
||
{item.nations}
|
||
</span>
|
||
)}
|
||
{item.labels && (
|
||
<span className="inline-block border border-[#bbb] rounded px-1 ml-[10px]">
|
||
{item.labels}
|
||
</span>
|
||
)}
|
||
</div>
|
||
<div className="flex justify-between mb-[5px]">
|
||
<span className="text-[#909090]">
|
||
群体规模:
|
||
<span className="text-[#cf1322]">
|
||
{item.total}
|
||
</span>
|
||
</span>
|
||
<span className="text-[#909090]">
|
||
对应实体数:
|
||
<span className="text-[#cf1322]">
|
||
{item.real_total}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<span className="text-[#909090]">
|
||
涉及平台:
|
||
<span className="text-[#222222]">
|
||
{item.platforms}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="absolute top-[5px] right-[10px]">
|
||
<Checkbox
|
||
checked={item.checked}
|
||
onCheckedChange={() =>
|
||
listDataCheckedChange(index, !item.checked)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{searchType === "issue" && (
|
||
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||
{listData.map((item: listDataItem, index: number) => {
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||
>
|
||
<div>
|
||
<div className="truncate mb-[5px]">
|
||
{item.title}
|
||
</div>
|
||
<div className="flex justify-between mb-[5px]">
|
||
<span className="text-[#909090]">
|
||
方向:
|
||
<span className="text-[#222222]">
|
||
{item.direction}
|
||
</span>
|
||
</span>
|
||
<span className="text-[#909090]">
|
||
类别:
|
||
<span className="text-[#222222]">
|
||
{item.issue_type}
|
||
</span>
|
||
</span>
|
||
</div>
|
||
</div>
|
||
<div className="absolute top-[5px] right-[10px]">
|
||
<Checkbox
|
||
checked={item.checked}
|
||
onCheckedChange={() =>
|
||
listDataCheckedChange(index, !item.checked)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{searchType === "event" && (
|
||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||
{listData.map((item: listDataItem, index: number) => {
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||
>
|
||
<div className="flex-1 min-w-0">
|
||
<p
|
||
className="truncate hover:underline cursor-pointer"
|
||
onClick={() => linkToA(item.url)}
|
||
>
|
||
{item.title}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
首发新闻:{item.site_name}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
事件时间:{item.first_publish_time}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
事件时间:{item.keywords?.join("、")}
|
||
</p>
|
||
</div>
|
||
<div className="absolute top-[5px] right-[10px]">
|
||
<Checkbox
|
||
checked={item.checked}
|
||
onCheckedChange={() =>
|
||
listDataCheckedChange(index, !item.checked)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
{searchType === "threat" && (
|
||
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||
{listData.map((item: listDataItem, index: number) => {
|
||
return (
|
||
<div
|
||
key={index}
|
||
className="relative shadow-[0px_0px_2px_1px_#ededed] mb-[10px] p-[10px] rounded-[5px]"
|
||
>
|
||
<div className="flex-1 min-w-0 pr-[25px]">
|
||
<p
|
||
className="truncate hover:underline cursor-pointer"
|
||
onClick={() => linkToA(item.url)}
|
||
>
|
||
{item.title}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
发布平台:{item.site_name || "暂无数据"}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
事件时间:
|
||
{item.first_publish_time || "暂无数据"}
|
||
</p>
|
||
<p className="truncate text-[#909090]">
|
||
关键词:
|
||
{item.keywords?.join("、") || "暂无数据"}
|
||
</p>
|
||
</div>
|
||
<div className="absolute top-[5px] right-[10px]">
|
||
<Checkbox
|
||
checked={item.checked}
|
||
onCheckedChange={() =>
|
||
listDataCheckedChange(index, !item.checked)
|
||
}
|
||
/>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
|
||
{/* B系统XWT检索 */}
|
||
{activeTab === "actor" && !isSearching && (
|
||
<NetworkActor
|
||
inputValue={searchText}
|
||
searchSpaceId={searchSpaceId}
|
||
onSuccess={handleSuccess}
|
||
/>
|
||
)}
|
||
|
||
{/* 上传 */}
|
||
{activeTab === "upload" && (
|
||
<div className="flex flex-col items-center justify-center gap-4 py-10">
|
||
<input
|
||
ref={fileInputRef}
|
||
type="file"
|
||
multiple
|
||
accept={UPLOAD_ACCEPT}
|
||
className="hidden"
|
||
onChange={(e) => {
|
||
const files = Array.from(e.target.files ?? []);
|
||
if (files.length) void handleFileUpload(files);
|
||
}}
|
||
/>
|
||
<Button
|
||
onClick={() => fileInputRef.current?.click()}
|
||
disabled={uploading}
|
||
className="px-6"
|
||
>
|
||
{uploading ? (
|
||
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />上传中...</>
|
||
) : (
|
||
<><Plus className="h-4 w-4 mr-2" />选择文件上传</>
|
||
)}
|
||
</Button>
|
||
<p className="text-xs text-muted-foreground">支持 PDF、Word、Excel、PPT、HTML、CSV、Markdown、TXT 及常见图片格式</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div className="absolute bottom-0 left-0 right-0 h-2 sm:h-7 bg-gradient-to-t from-muted via-muted/80 to-transparent pointer-events-none z-10" />
|
||
</div>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|