feat(studio): replace notes source upload with multi-tab popup
- Add document-upload-popup.tsx with tabs for B系统/A系统/Y系统/XWT/upload - Add network-actor.tsx for XWT search tab - Add empty.tsx for empty state - Refactor SourcesDialog: left panel becomes single "上传来源" button that opens new popup - Wire DocumentUploadDialogProvider around StudioNotebookDetailPage Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
2c2650ff07
commit
e6d3226c47
4 files changed
+1565
-131
No files matched your search
@@ -0,0 +1,880 @@
|
|||||||
|
"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,
|
||||||
|
Sparkles,
|
||||||
|
Moon,
|
||||||
|
BookOpen,
|
||||||
|
Loader2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
import Empty from "./empty";
|
||||||
|
import { NetworkActor } from "./network-actor";
|
||||||
|
|
||||||
|
// ── 适配层 ─────────────────────────────────────────────────
|
||||||
|
const writeYLog = (..._args: unknown[]) => {};
|
||||||
|
|
||||||
|
function authenticatedFetch(url: string, init?: RequestInit) {
|
||||||
|
const token = getStoredAuth()?.access_token;
|
||||||
|
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";
|
||||||
|
}
|
||||||
|
|
||||||
|
type SearchMode = "A" | "Y" | "knowledge";
|
||||||
|
|
||||||
|
const searchModes = [
|
||||||
|
{ value: "A", label: "A系统", icon: Sparkles },
|
||||||
|
{ value: "Y", label: "Y系统", icon: Moon },
|
||||||
|
{ value: "knowledge", label: "知识库", icon: BookOpen },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
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>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface listDataItem {
|
||||||
|
id?: string | number;
|
||||||
|
name?: string;
|
||||||
|
redisKey?: string;
|
||||||
|
abstractz?: string;
|
||||||
|
checked: boolean;
|
||||||
|
title: string;
|
||||||
|
content: string;
|
||||||
|
url: string;
|
||||||
|
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;
|
||||||
|
}> = ({ children, onClose, searchSpace, onUploadSuccess }) => {
|
||||||
|
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 }}>
|
||||||
|
{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, closeDialog, initialSearchText }) => {
|
||||||
|
const searchSpaceId = String(searchSpace?.id);
|
||||||
|
const backendUrl =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? (import.meta.env.VITE_BACKEND_BASE_URL || "http://localhost:8001")
|
||||||
|
: "http://localhost:8001";
|
||||||
|
const consumerUrl = "http://ch1.b.uat.4.cn/consumer";
|
||||||
|
|
||||||
|
const [searchText, setSearchText] = useState("");
|
||||||
|
const [searchValue, setSearchValue] = useState("");
|
||||||
|
const [activeTab, setActiveTab] = useState<"search" | "Y" | "upload" | "A" | "actor">("search");
|
||||||
|
const [isSearching, setIsSearching] = useState(false);
|
||||||
|
const [importSources, setImportSources] = useState<unknown[]>([]);
|
||||||
|
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 = getStoredAuth()?.access_token ?? "";
|
||||||
|
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) {
|
||||||
|
toast.success("上传成功");
|
||||||
|
handleSuccess();
|
||||||
|
} 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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { imported_count?: number };
|
||||||
|
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
|
||||||
|
setListData([]);
|
||||||
|
handleBatchDelete();
|
||||||
|
} 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([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAllSearch = async (searchMode: SearchMode, text: string) => {
|
||||||
|
if (isSearching || !text.trim()) return;
|
||||||
|
setIsSearching(true);
|
||||||
|
const reqBody = {
|
||||||
|
query: text,
|
||||||
|
search_space_id: searchSpaceId,
|
||||||
|
search_mode: searchMode,
|
||||||
|
};
|
||||||
|
const reqUrl = `${backendUrl}/api/v1/source/new`;
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(reqUrl, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(reqBody),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json() as { result: unknown[] };
|
||||||
|
writeYLog({ optDetail: "笔记检索数据", optType: 3, reqParam: reqBody, url: reqUrl, returnParam: JSON.stringify({ data }), optResult: "0" });
|
||||||
|
const results = data.result as listDataItem[];
|
||||||
|
results.forEach((item) => { item.checked = false; });
|
||||||
|
setImportSources(results);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
writeYLog({ optDetail: "笔记检索数据", optType: 3, reqParam: reqBody, url: reqUrl, returnParam: JSON.stringify({ data: error }), optResult: "1" });
|
||||||
|
} finally {
|
||||||
|
setIsSearching(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
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 = `http://present.a.uat.4.cn/present/domain?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 = `http://present.a.uat.4.cn/present/domain?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 = `http://present.a.uat.4.cn/present/domain?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 = `http://present.a.uat.4.cn/present/domain?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 = `http://present.a.uat.4.cn/present/domain?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 = `http://present.a.uat.4.cn/present/domain?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">
|
||||||
|
<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={`http://ch1.b.uat.4.cn/knowes/fromB1?yUserId=${yUserId}&processType=searchMode&bookChatId=${searchSpaceId}&inputValue=${searchValue}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Y系统信息库 */}
|
||||||
|
{activeTab === "Y" && !isSearching && (
|
||||||
|
<iframe
|
||||||
|
className="w-full h-[calc(100vh-200px)]"
|
||||||
|
src={`http://chlib.b.uat.4.cn/knowlibrary/#/sousou?chatType=new-chat&bookChatId=${searchSpaceId}&inputValue=${searchText}`}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* A系统基因库 */}
|
||||||
|
{activeTab === "A" && (
|
||||||
|
<div className="bg-white rounded-lg">
|
||||||
|
<div className="custom-radio-group pt-[10px] ml-[10px]">
|
||||||
|
{searchTypes.map((option) => (
|
||||||
|
<div
|
||||||
|
key={option.value}
|
||||||
|
className={`custom-radio-btn ${searchType === option.value ? "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={() => void 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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{listData.length === 0 && <Empty />}
|
||||||
|
|
||||||
|
{searchType === "human" && (
|
||||||
|
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||||
|
{listData.map((item, index) => (
|
||||||
|
<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]">
|
||||||
|
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchType === "organization" && (
|
||||||
|
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||||
|
{listData.map((item, index) => (
|
||||||
|
<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]">
|
||||||
|
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchType === "community" && (
|
||||||
|
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||||
|
{listData.map((item, index) => (
|
||||||
|
<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-w-[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]">
|
||||||
|
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchType === "issue" && (
|
||||||
|
<div className="bg-white p-[10px] grid grid-cols-4 gap-[10px]">
|
||||||
|
{listData.map((item, index) => (
|
||||||
|
<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]">
|
||||||
|
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchType === "event" && (
|
||||||
|
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||||
|
{listData.map((item, index) => (
|
||||||
|
<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]">
|
||||||
|
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{searchType === "threat" && (
|
||||||
|
<div className="bg-white p-[10px] grid grid-cols-5 gap-[10px]">
|
||||||
|
{listData.map((item, index) => (
|
||||||
|
<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]">
|
||||||
|
<input type="checkbox" checked={item.checked} onChange={() => listDataCheckedChange(index, !item.checked)} className="w-4 h-4 cursor-pointer accent-primary" />
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
import React from 'react';
|
||||||
|
|
||||||
|
const Empty = ({
|
||||||
|
description = '暂无数据',
|
||||||
|
size = 'default'
|
||||||
|
}: {
|
||||||
|
description?: string;
|
||||||
|
size?: 'default' | 'small';
|
||||||
|
}) => {
|
||||||
|
return (
|
||||||
|
<div className={`empty-container ${size === 'small' ? 'empty-small' : ''}`}>
|
||||||
|
<div className="empty-image">
|
||||||
|
<img src="/icon/empty-list.png" alt="暂无数据" />
|
||||||
|
</div>
|
||||||
|
{description && <p className="empty-description">{description}</p>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Empty;
|
||||||
@@ -0,0 +1,630 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { Loader2, Plus, Search, Trash2 } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { getStoredAuth } from "@/core/auth";
|
||||||
|
|
||||||
|
function authenticatedFetch(url: string, init?: RequestInit) {
|
||||||
|
const token = getStoredAuth()?.access_token;
|
||||||
|
return fetch(url, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
...(init?.headers ?? {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NetworkActorProps {
|
||||||
|
inputValue: string;
|
||||||
|
searchSpaceId: string;
|
||||||
|
onSuccess?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface platformItem {
|
||||||
|
title: string;
|
||||||
|
icon: string;
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface languageItem {
|
||||||
|
title: string;
|
||||||
|
checked: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface PoliticsDirItem {
|
||||||
|
地区: string;
|
||||||
|
语言: string;
|
||||||
|
年龄段: string;
|
||||||
|
性别: string;
|
||||||
|
教育程度: string;
|
||||||
|
关注话题: { 话题: string; 倾向: string; 佐证: string; 风格: string }[];
|
||||||
|
类型: string;
|
||||||
|
对HZZLC: string;
|
||||||
|
对MZZLC: string;
|
||||||
|
对TZZLC: string[];
|
||||||
|
账号描述: string;
|
||||||
|
发文频率: string;
|
||||||
|
活跃时间: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface 用户画像 {
|
||||||
|
地区: string;
|
||||||
|
教育程度: string;
|
||||||
|
关注话题: { 倾向: string; 话题: string; 佐证: string; 风格: string }[];
|
||||||
|
语言: string;
|
||||||
|
年龄段: string;
|
||||||
|
账号描述: string;
|
||||||
|
对TZZLC: string[];
|
||||||
|
对HZZLC: string;
|
||||||
|
对MZZLC: string[];
|
||||||
|
性别: string;
|
||||||
|
类型: string;
|
||||||
|
发文频率: string;
|
||||||
|
活跃时间: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface hx_info {
|
||||||
|
语言: string;
|
||||||
|
用户画像: 用户画像;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserDataItem {
|
||||||
|
comment_count: number;
|
||||||
|
country: string | null;
|
||||||
|
agent_task_id: string | null;
|
||||||
|
education: string | null;
|
||||||
|
groupId: string | null;
|
||||||
|
forward_count: number;
|
||||||
|
political_stand: string;
|
||||||
|
language: string;
|
||||||
|
type: string;
|
||||||
|
concern: string | null;
|
||||||
|
profile_image: string | null;
|
||||||
|
concern_topic: string | null;
|
||||||
|
screen_name: string;
|
||||||
|
school: string | null;
|
||||||
|
is_alive: number;
|
||||||
|
createdTime: string;
|
||||||
|
data_provider: string | null;
|
||||||
|
id: string;
|
||||||
|
text: string | null;
|
||||||
|
group: string | null;
|
||||||
|
read_count: number;
|
||||||
|
group_name: string;
|
||||||
|
area: string | null;
|
||||||
|
image: string | null;
|
||||||
|
updatedTime: string;
|
||||||
|
like_count: number;
|
||||||
|
m_politics_dir: string;
|
||||||
|
camp: string | null;
|
||||||
|
tags: string[];
|
||||||
|
army: string | null;
|
||||||
|
is_sync_a: number;
|
||||||
|
tweets_count: number;
|
||||||
|
groupName: string | null;
|
||||||
|
history_key: string;
|
||||||
|
user_id: string;
|
||||||
|
real_id: string | null;
|
||||||
|
h_politics_dir: string | null;
|
||||||
|
domain: string | null;
|
||||||
|
name: string | null;
|
||||||
|
position: string | null;
|
||||||
|
job: string | null;
|
||||||
|
age_round: string | null;
|
||||||
|
birthday: string | null;
|
||||||
|
is_virtual: number;
|
||||||
|
isMedia: number;
|
||||||
|
hx_info: hx_info;
|
||||||
|
gender: string | null;
|
||||||
|
agentInfo: PoliticsDirItem;
|
||||||
|
profile_url: string | null;
|
||||||
|
tagId: string[];
|
||||||
|
user_name: string;
|
||||||
|
a_update_time: string | null;
|
||||||
|
values: string | null;
|
||||||
|
follows_count: number;
|
||||||
|
title: string | null;
|
||||||
|
is_protected: number;
|
||||||
|
platform: string;
|
||||||
|
importType: string;
|
||||||
|
update_time: string;
|
||||||
|
key: string;
|
||||||
|
introduction: string | null;
|
||||||
|
brief: string | null;
|
||||||
|
creator: string;
|
||||||
|
history_title: string;
|
||||||
|
tweets_ratio: string | null;
|
||||||
|
is_verified: number;
|
||||||
|
t_politics_dir: string[];
|
||||||
|
eventMap: string | null;
|
||||||
|
fans_count: number;
|
||||||
|
a_create_time: string | null;
|
||||||
|
hobbies: string[];
|
||||||
|
location: string | null;
|
||||||
|
average_count: number;
|
||||||
|
ratio: string | null;
|
||||||
|
classification?: string | null;
|
||||||
|
groupDiscussTags?: string[];
|
||||||
|
checked?: boolean;
|
||||||
|
content?: string;
|
||||||
|
url?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NetworkActor({ inputValue, searchSpaceId, onSuccess }: NetworkActorProps) {
|
||||||
|
const [platforms, setPlatforms] = useState<platformItem[]>([
|
||||||
|
{ title: 'X', icon: 'x.png', checked: false },
|
||||||
|
{ title: 'Facebook', icon: 'facebook.png', checked: false },
|
||||||
|
{ title: 'Reddit', icon: 'reddit.png', checked: false },
|
||||||
|
{ title: 'PTT', icon: 'ptt.png', checked: false },
|
||||||
|
{ title: 'YouTube', icon: 'youtube.png', checked: false },
|
||||||
|
{ title: 'Dcard', icon: 'dcard.png', checked: false },
|
||||||
|
{ title: 'Mobile01', icon: 'mobile01.png', checked: false },
|
||||||
|
{ title: 'Threads', icon: 'threads.png', checked: false },
|
||||||
|
{ title: 'Instagram', icon: 'ins.png', checked: false },
|
||||||
|
]);
|
||||||
|
const platformList = useRef<string[]>([]);
|
||||||
|
const [languages, setLanguages] = useState<languageItem[]>([
|
||||||
|
{ title: '繁体中文', checked: false },
|
||||||
|
{ title: '简体中文', checked: false },
|
||||||
|
{ title: '英文', checked: false },
|
||||||
|
{ title: '日文', checked: false },
|
||||||
|
{ title: '其他', checked: false },
|
||||||
|
]);
|
||||||
|
const languageList = useRef<string[]>([]);
|
||||||
|
const [allPlatforms, setAllPlatforms] = useState<boolean>(true);
|
||||||
|
const [allLanguage, setAllLanguage] = useState<boolean>(true);
|
||||||
|
const [sortType, setSortType] = useState<string>('fans_count');
|
||||||
|
const sortTypeValue = useRef('fans_count');
|
||||||
|
const [fansSort, setFansSort] = useState<string>('desc');
|
||||||
|
const sortValue = useRef('desc');
|
||||||
|
const [relevanceSort, setRelevanceSort] = useState<string>('desc');
|
||||||
|
const [userList, setUserList] = useState<UserDataItem[]>([]);
|
||||||
|
const [searchText, setSearchText] = useState<string>(inputValue);
|
||||||
|
const [isSearching, setIsSearching] = useState<boolean>(false);
|
||||||
|
const [searchType, setSearchType] = useState<string>('network');
|
||||||
|
const searchValue = useRef<string>('network');
|
||||||
|
const [selNetCount, setSelNetCount] = useState<number>(0);
|
||||||
|
const [actorCount, setActorCount] = useState<number>(0);
|
||||||
|
const [directionType, setDirectionType] = useState<string>('TW');
|
||||||
|
const directionValue = useRef<string>('TW');
|
||||||
|
const consumerUrl = 'http://ch1.b.uat.4.cn/consumer';
|
||||||
|
const backendUrl = typeof window !== 'undefined'
|
||||||
|
? (import.meta.env.VITE_BACKEND_BASE_URL || 'http://localhost:8001')
|
||||||
|
: 'http://localhost:8001';
|
||||||
|
const [selectedSearchSources, setSelectedSearchSources] = useState<UserDataItem[]>([]);
|
||||||
|
const [isImporting, setIsImporting] = useState(false);
|
||||||
|
|
||||||
|
const searchChange = async () => {
|
||||||
|
setIsSearching(true);
|
||||||
|
if (searchValue.current === 'network') {
|
||||||
|
await agentUserSearch();
|
||||||
|
} else {
|
||||||
|
await agentGroupSearch();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const setDirectionChange = async (type: string) => {
|
||||||
|
setDirectionType(type);
|
||||||
|
directionValue.current = type;
|
||||||
|
await searchChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const searchTypeChange = async (value: string) => {
|
||||||
|
setSearchType(value);
|
||||||
|
searchValue.current = value;
|
||||||
|
await searchChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const allPlatformChange = async () => {
|
||||||
|
setAllPlatforms(true);
|
||||||
|
setPlatforms((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||||
|
platformList.current = [];
|
||||||
|
await searchChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTogglePlatform = async (index: number) => {
|
||||||
|
setPlatforms((prev) => {
|
||||||
|
const newPlatforms = prev.map((item, i) =>
|
||||||
|
i === index ? { ...item, checked: !item.checked } : item
|
||||||
|
);
|
||||||
|
const checkedTitles = newPlatforms.filter((item) => item.checked).map((item) => item.title);
|
||||||
|
platformList.current = checkedTitles;
|
||||||
|
setAllPlatforms(newPlatforms.every((item) => !item.checked));
|
||||||
|
return newPlatforms;
|
||||||
|
});
|
||||||
|
await searchChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const allLanguageChange = async () => {
|
||||||
|
setAllLanguage(true);
|
||||||
|
setLanguages((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||||
|
languageList.current = [];
|
||||||
|
await searchChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleLanguage = async (index: number) => {
|
||||||
|
setLanguages((prev) => {
|
||||||
|
const newItems = prev.map((item, i) =>
|
||||||
|
i === index ? { ...item, checked: !item.checked } : item
|
||||||
|
);
|
||||||
|
const checkedTitles = newItems.filter((item) => item.checked).map((item) => item.title);
|
||||||
|
languageList.current = checkedTitles;
|
||||||
|
setAllLanguage(newItems.every((item) => !item.checked));
|
||||||
|
return newItems;
|
||||||
|
});
|
||||||
|
await searchChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const sortTypeChange = async (type: string) => {
|
||||||
|
setSortType(type);
|
||||||
|
sortTypeValue.current = type;
|
||||||
|
sortValue.current = sortValue.current === 'desc' ? 'asc' : 'desc';
|
||||||
|
if (type === 'fans_count') setFansSort(sortValue.current);
|
||||||
|
else if (type === '_score') setRelevanceSort(sortValue.current);
|
||||||
|
await searchChange();
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentUserSearch = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentUser`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
historyKey: [],
|
||||||
|
historyTitle: '',
|
||||||
|
keyword: searchText,
|
||||||
|
language: languageList.current,
|
||||||
|
page: 1,
|
||||||
|
limit: 12,
|
||||||
|
platform: platformList.current,
|
||||||
|
politicalStand: directionValue.current,
|
||||||
|
sort: sortTypeValue.current,
|
||||||
|
asc: sortValue.current,
|
||||||
|
typeKeyword: 9,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setIsSearching(false);
|
||||||
|
const result: UserDataItem[] = data.data.list;
|
||||||
|
result.forEach((item) => {
|
||||||
|
item.profile_image = item.profile_image
|
||||||
|
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||||
|
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||||
|
item.classification = `${item.political_stand}-${item.history_key}-${item.history_title}`;
|
||||||
|
item.title = item.user_name || item.screen_name || item.user_id;
|
||||||
|
item.content = item.agentInfo['账号描述'];
|
||||||
|
item.url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${item.user_id}&platform=${item.platform}`;
|
||||||
|
});
|
||||||
|
setUserList(result);
|
||||||
|
setSelNetCount(data.data.total);
|
||||||
|
setActorCount(data.data.zs_total);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const agentGroupSearch = async () => {
|
||||||
|
try {
|
||||||
|
const response = await authenticatedFetch(`${consumerUrl}/portrait/agentGroup`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', Authorization: 'Bearer admin' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
historyKey: [],
|
||||||
|
historyTitle: '',
|
||||||
|
keyword: searchText,
|
||||||
|
language: languageList.current,
|
||||||
|
page: 1,
|
||||||
|
limit: 12,
|
||||||
|
platform: platformList.current,
|
||||||
|
politicalStand: directionValue.current,
|
||||||
|
sort: sortTypeValue.current,
|
||||||
|
asc: sortValue.current,
|
||||||
|
typeKeyword: 9,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
setIsSearching(false);
|
||||||
|
const result: UserDataItem[] = data.data.data;
|
||||||
|
result.forEach((item) => {
|
||||||
|
item.hobbies = item.groupDiscussTags || [];
|
||||||
|
item.profile_image = item.profile_image
|
||||||
|
? `http://minio-proxy.b.sit.4.cn/facebook-proxy?url=${item.profile_image}&is_avatar=true`
|
||||||
|
: 'http://ty.b.uat.4.cn/manage/img/default.png';
|
||||||
|
item.classification = `${item.political_stand}`;
|
||||||
|
item.title = item.group_name || item.groupName;
|
||||||
|
if (item.hx_info?.['用户画像']) {
|
||||||
|
item.content = item.hx_info['用户画像']['账号描述'];
|
||||||
|
}
|
||||||
|
item.url = '';
|
||||||
|
});
|
||||||
|
setUserList(result);
|
||||||
|
setActorCount(data.data.total);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Search error:', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const listDataCheckedChange = (index: number, checked: boolean) => {
|
||||||
|
setUserList((prevSources) => {
|
||||||
|
const newSources = prevSources.map((item, aIndex) =>
|
||||||
|
aIndex === index ? { ...item, checked } : item
|
||||||
|
);
|
||||||
|
setSelectedSearchSources(newSources.filter((item) => item.checked));
|
||||||
|
return newSources;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const linkToB2 = (data: UserDataItem) => {
|
||||||
|
const url = `http://ty.b.uat.4.cn/fromB/toDetail?user_id=${data.user_id}&platform=${data.platform}`;
|
||||||
|
window.open(url, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchDelete = () => {
|
||||||
|
setUserList((prev) => prev.map((item) => ({ ...item, checked: false })));
|
||||||
|
setSelectedSearchSources([]);
|
||||||
|
};
|
||||||
|
|
||||||
|
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,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
toast.success(`成功导入 ${data.imported_count || dataList.length} 个来源`);
|
||||||
|
setSearchText('');
|
||||||
|
setUserList([]);
|
||||||
|
handleBatchDelete();
|
||||||
|
onSuccess?.();
|
||||||
|
} else {
|
||||||
|
const errorData = await response.json();
|
||||||
|
toast.error(errorData.detail || '导入失败,请重试');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
toast.error('导入失败,请重试');
|
||||||
|
} finally {
|
||||||
|
setIsImporting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void searchChange();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="bg-white rounded-[5px] p-[10px]">
|
||||||
|
<div className="border border-[#e8e8e8] rounded-[5px] p-[10px]">
|
||||||
|
<div className="mb-[10px] flex">
|
||||||
|
<span className="text-[#3D3D3D]">所属平台:</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="flex items-center gap-[10px] flex-wrap">
|
||||||
|
<span
|
||||||
|
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allPlatforms ? 'platform-active' : ''}`}
|
||||||
|
onClick={() => void allPlatformChange()}
|
||||||
|
>全部</span>
|
||||||
|
{platforms.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||||
|
onClick={() => void handleTogglePlatform(index)}
|
||||||
|
>
|
||||||
|
<img src={`/platform/${item.icon}`} className="w-[24px]" alt="" />
|
||||||
|
<span className="w-[2px] h-[12px] ml-[5px] mr-[5px] bg-[#dbdbdb]"></span>
|
||||||
|
<span className="ml-[5px]">{item.title}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-[10px] flex">
|
||||||
|
<span className="text-[#3D3D3D]">常用语言:</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="flex items-center gap-[10px] flex-wrap">
|
||||||
|
<span
|
||||||
|
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${allLanguage ? 'platform-active' : ''}`}
|
||||||
|
onClick={() => void allLanguageChange()}
|
||||||
|
>全部</span>
|
||||||
|
{languages.map((item, index) => (
|
||||||
|
<span
|
||||||
|
key={index}
|
||||||
|
className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${item.checked ? 'platform-active' : ''}`}
|
||||||
|
onClick={() => void handleToggleLanguage(index)}
|
||||||
|
>
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-[10px] flex">
|
||||||
|
<span className="text-[#3D3D3D]">方向:</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="flex items-center gap-[10px] flex-wrap">
|
||||||
|
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'TW' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('TW')}>W方向</span>
|
||||||
|
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === 'US' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('US')}>U方向</span>
|
||||||
|
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${directionType === '其他' ? 'platform-active' : ''}`} onClick={() => void setDirectionChange('其他')}>其他</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mb-[10px] flex">
|
||||||
|
<span className="text-[#3D3D3D]">类型:</span>
|
||||||
|
<span className="flex-1 min-w-0">
|
||||||
|
<span className="flex items-center gap-[10px] flex-wrap">
|
||||||
|
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'network' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('network')}>网络XWT</span>
|
||||||
|
<span className={`flex items-center cursor-pointer border border-[transparent] pl-[10px] pr-[10px] ${searchType === 'group' ? 'platform-active' : ''}`} onClick={() => void searchTypeChange('group')}>群组XWT</span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-[5px] pt-[15px] mt-[10px]">
|
||||||
|
<div className="flex items-center justify-between pb-[10px] mb-[10px] border-b border-b-[#f1f3f5]">
|
||||||
|
<div className="flex items-center ml-[10px]">
|
||||||
|
<div className="title-bar mr-[8px]"></div>
|
||||||
|
<div className="text-[#454859]">用户表格管理</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<div
|
||||||
|
className={`relative flex items-center cursor-pointer border ${sortType === 'fans_count' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
|
||||||
|
onClick={() => void sortTypeChange('fans_count')}
|
||||||
|
>
|
||||||
|
<span className="leading-[16px]">粉丝数排序</span>
|
||||||
|
<div className="ml-[10px]">
|
||||||
|
<div className={`sort-up ${fansSort === 'asc' ? 'sort-up-active' : ''}`}></div>
|
||||||
|
<div className={`mt-[4px] sort-down ${fansSort === 'desc' ? 'sort-down-active' : ''}`}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`relative flex items-center ml-[10px] cursor-pointer border ${sortType === '_score' ? 'border-[#cf1322]' : ''} p-[7px] pl-[10px] pr-[20px] rounded-[5px]`}
|
||||||
|
onClick={() => void sortTypeChange('_score')}
|
||||||
|
>
|
||||||
|
<span className="leading-[16px]">相关度排序</span>
|
||||||
|
<div className="ml-[10px]">
|
||||||
|
<div className={`sort-up ${relevanceSort === 'asc' ? 'sort-up-active' : ''}`}></div>
|
||||||
|
<div className={`mt-[4px] sort-down ${relevanceSort === 'desc' ? 'sort-down-active' : ''}`}></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{searchType === 'network' && (
|
||||||
|
<>
|
||||||
|
<div className="flex items-center ml-[35px]">
|
||||||
|
<img className="w-[24px] h-[24px]" src="/icon/group-yellow-logo.svg" alt="" />
|
||||||
|
<div className="ml-[5px]">
|
||||||
|
<div className="text-[#454859] leading-[16px]">当前选中网络XWT数</div>
|
||||||
|
<div className="text-[#d97b00] leading-[16px] mt-[5px]">{selNetCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="ml-[5px] mr-[5px] h-[30px] w-[2px] -skew-x-[30deg] bg-[#dbdbdb]"></div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<div className={`flex items-center ${searchType === 'network' ? 'ml-[5px]' : 'ml-[20px]'} mr-[25px]`}>
|
||||||
|
<img className="w-[24px] h-[24px]" src="/icon/group-red-logo.svg" alt="" />
|
||||||
|
<div className="ml-[5px]">
|
||||||
|
<div className="text-[#454859] leading-[16px]">网络XWT总数</div>
|
||||||
|
<div className="text-[#cf1322] leading-[16px] mt-[5px]">{actorCount}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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={() => void 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>
|
||||||
|
)}
|
||||||
|
<div className="p-[10px]">
|
||||||
|
<div className="grid grid-cols-4 gap-[10px] user-list">
|
||||||
|
{userList.map((item, index) => (
|
||||||
|
<div key={index} className="user-item p-[10px] relative">
|
||||||
|
<div className="absolute top-[5px] right-[10px]">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={item.checked ?? false}
|
||||||
|
onChange={() => listDataCheckedChange(index, !item.checked)}
|
||||||
|
className="w-4 h-4 cursor-pointer accent-primary"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mb-[10px] cursor-pointer" onClick={() => linkToB2(item)}>
|
||||||
|
<img src={item.profile_image ?? ''} className="w-[97px] h-[97px] rounded-[5px]" alt="" />
|
||||||
|
<div className="flex-1 min-w-0 pl-[10px]">
|
||||||
|
<div className="truncate mb-[10px] text-[18px]">{item.user_name || item.screen_name || item.user_id || item.group_name || item.groupName}</div>
|
||||||
|
<div className="flex justify-between mb-[10px]">
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img src="/icon/group-yellow-logo.svg" className="w-[16px] h-[16px] mr-[5px]" alt="" />
|
||||||
|
<span>粉丝数:{item.fans_count || '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img src={`/platform/${item.platform}.png`} className="w-[25px] mr-[5px]" alt="" />
|
||||||
|
<span>{item.platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center">
|
||||||
|
<img src="/icon/person-red-logo.svg" className="w-[16px] mr-[5px]" alt="" />
|
||||||
|
<span>实体人:{item.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="cursor-pointer" onClick={() => linkToB2(item)}>
|
||||||
|
<div className="flex items-center mb-[10px]">
|
||||||
|
<div className="w-[70px] flex items-center">
|
||||||
|
<img src="/icon/classification-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||||
|
<span>分类:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="inline-block h-[28px] pl-[8px] pr-[8px] bg-[#fef6f6] text-[#cf1322]">{item.classification}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center mb-[10px]">
|
||||||
|
<div className="w-[70px] flex items-center">
|
||||||
|
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||||
|
<span>标签:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex flex-wrap items-center gap-[5px]">
|
||||||
|
{(item.hobbies ?? []).map((hobbie) => (
|
||||||
|
<div key={hobbie} className="h-[28px] pl-[8px] pr-[8px] bg-[#f6fdfb] text-[#1eb07f]">{hobbie}</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-baseline mb-[10px]">
|
||||||
|
<div className="w-[70px] flex items-center">
|
||||||
|
<img src="/icon/user-green-logo.png" className="w-[16px] mr-[5px]" alt="" />
|
||||||
|
<span>简介:</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="line-clamp-3 text-ellipsis overflow-hidden">
|
||||||
|
{(item.agentInfo && item.agentInfo['账号描述']) || (item.hx_info?.['用户画像']?.['账号描述']) || ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
AlignLeft,
|
AlignLeft,
|
||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from "@/components/ui/dialog";
|
} from "@/components/ui/dialog";
|
||||||
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
import { useNotebookChat, useNotebookSources, useNotebookThreads, useStudioNotebooks } from "@/core/studio/hooks";
|
||||||
import { fetchDocumentByChunk, uploadSourceFiles, uploadSourceText } from "@/core/studio/api";
|
import { fetchDocumentByChunk } from "@/core/studio/api";
|
||||||
import type { DocumentWithChunks } from "@/core/studio/api";
|
import type { DocumentWithChunks } from "@/core/studio/api";
|
||||||
import type { SourceItem } from "@/core/studio/types";
|
import type { SourceItem } from "@/core/studio/types";
|
||||||
import { getStoredAuth } from "@/core/auth";
|
import { getStoredAuth } from "@/core/auth";
|
||||||
@@ -38,6 +38,10 @@ import { MessageList } from "@/components/workspace/messages/message-list";
|
|||||||
import { ThreadContext } from "@/components/workspace/messages/context";
|
import { ThreadContext } from "@/components/workspace/messages/context";
|
||||||
import { ChatBox } from "@/components/workspace/chats/chat-box";
|
import { ChatBox } from "@/components/workspace/chats/chat-box";
|
||||||
import { rehypeCitations } from "@/core/rehype";
|
import { rehypeCitations } from "@/core/rehype";
|
||||||
|
import {
|
||||||
|
DocumentUploadDialogProvider,
|
||||||
|
useDocumentUploadDialog,
|
||||||
|
} from "@/components/assistant-ui/document-upload-popup";
|
||||||
|
|
||||||
// ── 来源卡片图标 ────────────────────────────────────────
|
// ── 来源卡片图标 ────────────────────────────────────────
|
||||||
function SourceIcon({ item }: { item: SourceItem }) {
|
function SourceIcon({ item }: { item: SourceItem }) {
|
||||||
@@ -102,79 +106,31 @@ function SourceCardSkeleton() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const UPLOAD_ACCEPT = ".pdf,.docx,.xlsx,.pptx,.html,.csv,.md,.txt,.jpg,.jpeg,.png,.bmp,.webp,.tiff,.mp3,.mp4,.wav,.webm";
|
|
||||||
|
|
||||||
// ── 来源 Dialog ─────────────────────────────────────────
|
// ── 来源 Dialog ─────────────────────────────────────────
|
||||||
function SourcesDialog({
|
function SourcesDialog({
|
||||||
open,
|
open,
|
||||||
onClose,
|
onClose,
|
||||||
notebookName,
|
notebookName,
|
||||||
notebookId,
|
|
||||||
token,
|
|
||||||
sources,
|
sources,
|
||||||
isLoadingSources,
|
isLoadingSources,
|
||||||
selectedSourceIds,
|
selectedSourceIds,
|
||||||
onToggle,
|
onToggle,
|
||||||
onUploadSuccess,
|
|
||||||
}: {
|
}: {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
notebookName: string;
|
notebookName: string;
|
||||||
notebookId: number;
|
|
||||||
token: string;
|
|
||||||
sources: SourceItem[];
|
sources: SourceItem[];
|
||||||
isLoadingSources: boolean;
|
isLoadingSources: boolean;
|
||||||
selectedSourceIds: number[];
|
selectedSourceIds: number[];
|
||||||
onToggle: (id: number) => void;
|
onToggle: (id: number) => void;
|
||||||
onUploadSuccess: () => void;
|
|
||||||
}) {
|
}) {
|
||||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
const { openDialog: openUploadPopup } = useDocumentUploadDialog();
|
||||||
const [uploading, setUploading] = useState(false);
|
|
||||||
const [uploadError, setUploadError] = useState("");
|
|
||||||
const [panel, setPanel] = useState<"sources" | "text">("sources");
|
|
||||||
const [textContent, setTextContent] = useState("");
|
|
||||||
const [textTitle, setTextTitle] = useState("");
|
|
||||||
const [submittingText, setSubmittingText] = useState(false);
|
|
||||||
const [textError, setTextError] = useState("");
|
|
||||||
|
|
||||||
const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
|
||||||
const files = Array.from(e.target.files ?? []);
|
|
||||||
if (!files.length) return;
|
|
||||||
setUploading(true);
|
|
||||||
setUploadError("");
|
|
||||||
try {
|
|
||||||
await uploadSourceFiles(notebookId, files, token);
|
|
||||||
onUploadSuccess();
|
|
||||||
} catch (err) {
|
|
||||||
setUploadError(err instanceof Error ? err.message : "上传失败");
|
|
||||||
} finally {
|
|
||||||
setUploading(false);
|
|
||||||
if (fileInputRef.current) fileInputRef.current.value = "";
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleTextSubmit = async () => {
|
|
||||||
if (!textContent.trim()) return;
|
|
||||||
setSubmittingText(true);
|
|
||||||
setTextError("");
|
|
||||||
try {
|
|
||||||
await uploadSourceText(notebookId, textContent.trim(), textTitle.trim(), token);
|
|
||||||
setTextContent("");
|
|
||||||
setTextTitle("");
|
|
||||||
setPanel("sources");
|
|
||||||
onUploadSuccess();
|
|
||||||
} catch (err) {
|
|
||||||
setTextError(err instanceof Error ? err.message : "上传失败");
|
|
||||||
} finally {
|
|
||||||
setSubmittingText(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
<Dialog open={open} onOpenChange={(v) => !v && onClose()}>
|
||||||
<DialogContent
|
<DialogContent
|
||||||
showCloseButton={false}
|
showCloseButton={false}
|
||||||
className="flex h-[75vh] w-[900px] max-w-[calc(100vw-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[900px]"
|
className="flex h-[90vh] w-[75vw] max-w-[75vw] sm:max-w-[75vw] flex-col gap-0 overflow-hidden p-0"
|
||||||
>
|
>
|
||||||
<DialogHeader className="flex flex-row items-start justify-between border-b border-border px-6 py-5">
|
<DialogHeader className="flex flex-row items-start justify-between border-b border-border px-6 py-5">
|
||||||
<div>
|
<div>
|
||||||
@@ -194,87 +150,33 @@ function SourcesDialog({
|
|||||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||||
{/* 左侧导航 */}
|
{/* 左侧导航 */}
|
||||||
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
|
<div className="flex w-44 shrink-0 flex-col gap-1 border-r border-border p-3">
|
||||||
<input
|
|
||||||
ref={fileInputRef}
|
|
||||||
type="file"
|
|
||||||
multiple
|
|
||||||
accept={UPLOAD_ACCEPT}
|
|
||||||
className="hidden"
|
|
||||||
onChange={(e) => { void handleFileChange(e); }}
|
|
||||||
/>
|
|
||||||
<button
|
<button
|
||||||
disabled={uploading}
|
onClick={() => openUploadPopup()}
|
||||||
onClick={() => { setPanel("sources"); fileInputRef.current?.click(); }}
|
className="flex w-full flex-col items-center justify-center gap-3 rounded-2xl border-2 border-dashed border-border bg-muted/40 px-4 py-8 text-muted-foreground transition-colors hover:border-primary/50 hover:bg-primary/5 hover:text-primary"
|
||||||
className="flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground disabled:opacity-50"
|
|
||||||
>
|
>
|
||||||
{uploading ? <Loader2 className="size-4 shrink-0 animate-spin" /> : <Plus className="size-4 shrink-0" />}
|
<Plus className="size-8 shrink-0" />
|
||||||
<span className="text-left leading-tight">上传文件</span>
|
<span className="text-sm font-medium leading-tight text-center">上传来源</span>
|
||||||
</button>
|
</button>
|
||||||
<button
|
|
||||||
onClick={() => { setPanel("text"); setTextError(""); }}
|
|
||||||
className={`flex items-center gap-2.5 rounded-xl px-3 py-2.5 text-sm transition-colors hover:bg-muted hover:text-foreground ${panel === "text" ? "bg-muted text-foreground" : "text-muted-foreground"}`}
|
|
||||||
>
|
|
||||||
<AlignLeft className="size-4 shrink-0" />
|
|
||||||
<span className="text-left leading-tight">复制的文字</span>
|
|
||||||
</button>
|
|
||||||
{uploadError && (
|
|
||||||
<p className="px-3 text-xs text-destructive">{uploadError}</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 右侧内容 */}
|
{/* 右侧内容 */}
|
||||||
{panel === "sources" ? (
|
<div className="flex-1 overflow-y-auto p-4">
|
||||||
<div className="flex-1 overflow-y-auto p-4">
|
<div className="grid grid-cols-4 gap-3">
|
||||||
<div className="grid grid-cols-4 gap-3">
|
{isLoadingSources
|
||||||
{isLoadingSources
|
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
||||||
? Array.from({ length: 8 }).map((_, i) => <SourceCardSkeleton key={i} />)
|
: sources.map((src) => (
|
||||||
: sources.map((src) => (
|
<SourceCard
|
||||||
<SourceCard
|
key={src.id}
|
||||||
key={src.id}
|
item={src}
|
||||||
item={src}
|
selected={selectedSourceIds.includes(src.id)}
|
||||||
selected={selectedSourceIds.includes(src.id)}
|
onToggle={onToggle}
|
||||||
onToggle={onToggle}
|
/>
|
||||||
/>
|
))}
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
{!isLoadingSources && sources.length === 0 && (
|
|
||||||
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{!isLoadingSources && sources.length === 0 && (
|
||||||
<div className="flex flex-1 flex-col gap-3 p-5">
|
<p className="text-sm text-muted-foreground">暂无来源,请添加文件。</p>
|
||||||
<input
|
)}
|
||||||
autoFocus
|
</div>
|
||||||
value={textTitle}
|
|
||||||
onChange={(e) => setTextTitle(e.target.value)}
|
|
||||||
placeholder="标题(可选)"
|
|
||||||
className="w-full rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
<textarea
|
|
||||||
value={textContent}
|
|
||||||
onChange={(e) => setTextContent(e.target.value)}
|
|
||||||
placeholder="在此粘贴或输入文字内容…"
|
|
||||||
className="flex-1 resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-primary"
|
|
||||||
/>
|
|
||||||
{textError && <p className="text-xs text-destructive">{textError}</p>}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
onClick={() => setPanel("sources")}
|
|
||||||
className="rounded-lg border border-border px-4 py-2 text-sm text-muted-foreground hover:bg-muted"
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
disabled={!textContent.trim() || submittingText}
|
|
||||||
onClick={() => void handleTextSubmit()}
|
|
||||||
className="flex items-center gap-2 rounded-lg bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{submittingText && <Loader2 className="size-3.5 animate-spin" />}
|
|
||||||
添加来源
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
@@ -378,6 +280,12 @@ export default function StudioNotebookDetailPage() {
|
|||||||
const isChatView = activeThreadId !== null || hasMessages;
|
const isChatView = activeThreadId !== null || hasMessages;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<DocumentUploadDialogProvider
|
||||||
|
searchSpace={{ id: notebook.id }}
|
||||||
|
onUploadSuccess={() => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
||||||
|
}}
|
||||||
|
>
|
||||||
<ThreadContext.Provider value={{ thread }}>
|
<ThreadContext.Provider value={{ thread }}>
|
||||||
<ChatBox threadId={chatThreadId ?? ""}>
|
<ChatBox threadId={chatThreadId ?? ""}>
|
||||||
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
<div className="relative flex h-full flex-col overflow-hidden bg-background">
|
||||||
@@ -667,18 +575,14 @@ export default function StudioNotebookDetailPage() {
|
|||||||
open={sourcesOpen}
|
open={sourcesOpen}
|
||||||
onClose={() => setSourcesOpen(false)}
|
onClose={() => setSourcesOpen(false)}
|
||||||
notebookName={notebook.name}
|
notebookName={notebook.name}
|
||||||
notebookId={notebook.id}
|
|
||||||
token={queryClient.getQueryData<string>(["studio", "token", getStoredAuth()?.email ?? ""]) ?? ""}
|
|
||||||
sources={sources}
|
sources={sources}
|
||||||
isLoadingSources={isLoadingSources}
|
isLoadingSources={isLoadingSources}
|
||||||
selectedSourceIds={selectedSourceIds}
|
selectedSourceIds={selectedSourceIds}
|
||||||
onToggle={handleToggleSource}
|
onToggle={handleToggleSource}
|
||||||
onUploadSuccess={() => {
|
|
||||||
void queryClient.invalidateQueries({ queryKey: ["studio", "sources", notebook.id] });
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ChatBox>
|
</ChatBox>
|
||||||
</ThreadContext.Provider>
|
</ThreadContext.Provider>
|
||||||
|
</DocumentUploadDialogProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user