mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-01 04:47:53 +08:00
fce368f177
F037 license banner shifts all admin content down by its height, but page scroll containers hardcode viewport-relative heights (calc(100vh - Npx)) that ignore it, so their bottoms fell off-screen and could not be scrolled to when the banner was shown. - LicenseBanner publishes its rendered height as the CSS var --license-banner-h on :root (0px when hidden, so all subtractions are no-ops without the banner) - sweep every calc(100vh - Npx) page/scroll height to subtract the var - MainLayout sidebar nav max-height subtracts the var too - SystemPage 组织同步/角色管理: root lacked a fill+scroll container (or used a viewport calc miscalibrated for the nested TabsList) -> switch to h-full + internal overflow-y-auto so they fill their flex parent, banner-agnostic - InputFormItem: in edit mode, switching the upload file type to an image-capable type no longer backfilled the image variable name (the new-item auto-fill effect is skipped when editing); backfill image_file / file_path on file-type change
738 lines
35 KiB
TypeScript
738 lines
35 KiB
TypeScript
import { useNavigate } from "react-router-dom";
|
|
import { Button } from "../../components/bs-ui/button";
|
|
import { Input, SearchInput } from "../../components/bs-ui/input";
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow
|
|
} from "../../components/bs-ui/table";
|
|
|
|
import { BookIcon } from "@/components/bs-icons/knowledge";
|
|
import { LoadIcon, LoadingIcon } from "@/components/bs-icons/loading";
|
|
import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm";
|
|
import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog";
|
|
import { Dialog, DialogClose, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/bs-ui/dialog";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@/components/bs-ui/select";
|
|
import { toast, useToast } from "@/components/bs-ui/toast/use-toast";
|
|
import { QuestionTooltip } from "@/components/bs-ui/tooltip";
|
|
import Tip from "@/components/bs-ui/tooltip/tip";
|
|
import { getKnowledgeModelConfig } from "@/controllers/API/finetune";
|
|
import { CircleAlert, Copy, Ellipsis, LoaderCircle, Settings, Shield, Trash2 } from "lucide-react";
|
|
import { useContext, useEffect, useRef, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
import { Textarea } from "../../components/bs-ui/input";
|
|
import LoadMore from "../../components/bs-comp/loadMore";
|
|
import { userContext } from "../../contexts/userContext";
|
|
import { copyLibDatabase, createFileLib, deleteFileLib, readFileLibDatabase, updateKnowledge } from "../../controllers/API";
|
|
import { captureAndAlertRequestErrorHoc } from "../../controllers/request";
|
|
import { useInfiniteCursorTable } from "../../util/hook";
|
|
import { useModel } from "../ModelPage/manage";
|
|
import { ModelSelect } from "../ModelPage/manage/tabs/WorkbenchModel";
|
|
|
|
// Knowledge base status
|
|
const enum KnowledgeBaseStatus {
|
|
Unpublished = 0,
|
|
Published = 1, // Document knowledge base build success status
|
|
Copying = 2,
|
|
Rebuilding = 3, // Document knowledge base rebuilding status
|
|
Failed = 4 // Document knowledge base rebuild failed status
|
|
}
|
|
|
|
const KB_MANAGE_PERMISSION_IDS = [
|
|
'manage_kb_owner',
|
|
'manage_kb_manager',
|
|
'manage_kb_viewer',
|
|
]
|
|
|
|
function CreateModal({ datalist, open, onOpenChange, onLoadEnd, mode = 'create', currentLib = null }) {
|
|
const { t } = useTranslation('knowledge')
|
|
const navigate = useNavigate()
|
|
|
|
const nameRef = useRef(null)
|
|
const descRef = useRef(null)
|
|
const [modelId, setModelId] = useState('')
|
|
|
|
const [isSubmitting, setIsSubmitting] = useState(false)
|
|
const [isModelChanged, setIsModelChanged] = useState(false)
|
|
|
|
const { embeddings, isLoading } = useModel()
|
|
// Unified handling of model data fetching
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
|
|
const fetchModelData = async () => {
|
|
try {
|
|
if (mode === 'create') {
|
|
const config = await getKnowledgeModelConfig();
|
|
setModelId(config.embedding_model_id);
|
|
} else {
|
|
setModelId(currentLib.model);
|
|
}
|
|
|
|
if (mode === 'edit' && currentLib) {
|
|
// Use setTimeout to ensure DOM has been rendered
|
|
setTimeout(() => {
|
|
if (nameRef.current) nameRef.current.value = currentLib.name || '';
|
|
if (descRef.current) descRef.current.value = currentLib.description || '';
|
|
}, 0);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load model data:', error);
|
|
toast({
|
|
variant: "error",
|
|
description: t('loadModelError')
|
|
});
|
|
}
|
|
};
|
|
|
|
fetchModelData();
|
|
}, [open, mode, currentLib]);
|
|
|
|
useEffect(() => {
|
|
// Clear all internal state when modal closes
|
|
if (!open) {
|
|
setModelId('');
|
|
setIsSubmitting(false);
|
|
setIsModelChanged(false);
|
|
setError({ name: false, desc: false });
|
|
}
|
|
}, [open]);
|
|
|
|
const { toast } = useToast()
|
|
const [error, setError] = useState({ name: false, desc: false })
|
|
|
|
const handleCreate = async (e, isImport = false) => {
|
|
const name = nameRef.current.value || ''; // Name (default empty string to avoid null)
|
|
let desc = descRef.current.value || ''; // Description (default empty string)
|
|
|
|
// 1. Define the fixed text part of the default description (excluding name)
|
|
const defaultDescPrefix = t('defaultDescPrefix');
|
|
const defaultDescSuffix = t('defaultDescSuffix');
|
|
// Fixed text total length = prefix length + suffix length
|
|
const fixedTextLength = defaultDescPrefix.length + defaultDescSuffix.length;
|
|
// Maximum name length allowed = 200 - fixed text length (ensure name + fixed text ≤ 200)
|
|
const maxNameLengthForDefaultDesc = 200 - fixedTextLength;
|
|
|
|
// 2. When description is not entered, generate default description (strictly control total length ≤ 200)
|
|
if (!desc) {
|
|
// Case 1: Name length ≤ maximum allowable length → directly concatenate to generate default description
|
|
if (name.length <= maxNameLengthForDefaultDesc) {
|
|
desc = `${defaultDescPrefix}${name}${defaultDescSuffix}`;
|
|
}
|
|
// Case 2: Name length > maximum allowable length → truncate name then concatenate
|
|
else {
|
|
desc = '';
|
|
}
|
|
}
|
|
|
|
// 3. Original validation logic (only for user-entered descriptions, default description already ensures ≤ 200)
|
|
if (!name) {
|
|
handleError(t('lib.enterLibraryName', { ns: 'bs' }));
|
|
return;
|
|
}
|
|
if (name.length > 200) {
|
|
handleError(t('nameExceedsLimit'));
|
|
return;
|
|
}
|
|
|
|
|
|
// Fix: Name duplication validation logic
|
|
// In edit mode and name unchanged, skip duplication check
|
|
const isEditMode = mode === 'edit' && currentLib;
|
|
const nameUnchanged = isEditMode && name === currentLib.name;
|
|
|
|
if (!nameUnchanged && datalist.find(data => data.name === name && (!currentLib || data.id !== currentLib.id))) {
|
|
handleError(t('lib.nameExists', { ns: 'bs' }));
|
|
return;
|
|
}
|
|
|
|
if (descRef.current.value && desc.length > 200) {
|
|
handleError(t('lib.descriptionLimit', { ns: 'bs' }));
|
|
return;
|
|
}
|
|
|
|
if (mode === 'create' && !modelId) {
|
|
handleError(t('lib.selectModel', { ns: 'bs' }));
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true)
|
|
if (mode === 'create') {
|
|
await captureAndAlertRequestErrorHoc(createFileLib({
|
|
name,
|
|
description: desc,
|
|
model: modelId,
|
|
type: 0
|
|
}).then(res => {
|
|
window.libname = [name, desc]
|
|
navigate(isImport
|
|
? `/filelib/upload/${res.id}`
|
|
: `/filelib/${res.id}`
|
|
);
|
|
onOpenChange(false);
|
|
})).finally(() => {
|
|
setIsSubmitting(false)
|
|
})
|
|
} else {
|
|
const data = {
|
|
"model_id": modelId,
|
|
"model_type": "embedding",
|
|
"knowledge_id": currentLib.id,
|
|
"knowledge_name": name,
|
|
"description": desc
|
|
}
|
|
await captureAndAlertRequestErrorHoc(updateKnowledge(data).then(res => {
|
|
toast({
|
|
variant: "success",
|
|
description: t('updateSuccess')
|
|
})
|
|
onOpenChange(false);
|
|
onLoadEnd()
|
|
}).catch(error => {
|
|
toast({ variant: "error", description: error || t('updateFailed') });
|
|
onOpenChange(false);
|
|
})).finally(() => {
|
|
setIsSubmitting(false)
|
|
})
|
|
}
|
|
}
|
|
|
|
const handleError = (message) => {
|
|
toast({
|
|
variant: 'error',
|
|
description: message
|
|
});
|
|
}
|
|
|
|
return <Dialog open={open} onOpenChange={onOpenChange}>
|
|
<DialogContent className="sm:max-w-[625px]">
|
|
<DialogHeader>
|
|
<DialogTitle>{mode === 'create' ? t('lib.createLibrary', { ns: 'bs' }) : t('knowledgeBaseSettings')}</DialogTitle>
|
|
</DialogHeader>
|
|
<div className="flex flex-col gap-4 py-2">
|
|
{mode === 'edit' && currentLib && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-48">
|
|
<label className="bisheng-label text-sm text-gray-500">{t('lib.knowledgeBaseId', { ns: 'bs' })}</label>
|
|
<div className="text-sm">{currentLib.id}</div>
|
|
</div>
|
|
<div className="flex items-center gap-48">
|
|
<label className="bisheng-label text-sm text-gray-500">{t('createTime', { ns: 'bs' })}</label>
|
|
<div className="text-sm">
|
|
{currentLib.create_time.replace('T', ' ')}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
<div className="">
|
|
<label htmlFor="name" className="bisheng-label">{t('system.libraryName', { ns: 'bs' })}</label>
|
|
<span className="text-red-500">*</span>
|
|
<Input
|
|
name="name"
|
|
ref={nameRef}
|
|
defaultValue={mode === 'edit' && currentLib ? currentLib.name : ''}
|
|
placeholder={t('lib.enterLibraryName', { ns: 'bs' })}
|
|
className={`col-span-3 ${error.name && 'border-red-400'}`}
|
|
/>
|
|
</div>
|
|
<div className="">
|
|
<label htmlFor="desc" className="bisheng-label">{t('lib.desc', { ns: 'bs' })}</label>
|
|
<Textarea
|
|
id="desc"
|
|
ref={descRef}
|
|
defaultValue={mode === 'edit' && currentLib ? currentLib.description : ''}
|
|
placeholder={t('enterKnowledgeBaseDescription')}
|
|
rows={8}
|
|
className={`col-span-3 ${error.desc && 'border-red-400'}`}
|
|
/>
|
|
</div>
|
|
<div className="">
|
|
<label htmlFor="model" className="bisheng-label">{t('lib.embeddingModelSelection', { ns: 'bs' })}</label>
|
|
{isLoading ? (
|
|
<div className="flex items-center gap-2 p-3 border rounded-md bg-gray-50">
|
|
<LoadIcon className="w-4 h-4 animate-spin" />
|
|
<span className="text-sm text-gray-600">{t('loadingModelList')}</span>
|
|
</div>
|
|
) : embeddings.length > 0 ? (
|
|
<ModelSelect
|
|
key={`model-select-${modelId}`}
|
|
label=""
|
|
close
|
|
value={modelId}
|
|
options={embeddings}
|
|
onChange={(modelId) => {
|
|
setModelId(modelId);
|
|
if (mode === 'edit') setIsModelChanged(true);
|
|
}}
|
|
/>
|
|
) : (
|
|
<div className="p-3 border rounded-md bg-gray-50 text-sm text-gray-600">
|
|
{t('noAvailableModels')}
|
|
</div>
|
|
)}
|
|
{mode === 'edit' && isModelChanged && (
|
|
<p className="text-red-500 text-sm mt-1 flex items-center gap-1">
|
|
<CircleAlert className="w-4 h-4" color="#ef4444" />
|
|
{t('embeddingModelChangeWarning')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<DialogClose>
|
|
<Button variant="outline" className="px-8 h-8">{t('cancel')}</Button>
|
|
</DialogClose>
|
|
{mode === 'create' ? (
|
|
<>
|
|
<Button
|
|
variant="outline"
|
|
className="px-8 h-8 flex"
|
|
onClick={(e) => handleCreate(e, false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting && <LoadIcon className="mr-1" />}
|
|
{t('finishCreate')}
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
className="px-8 h-8 flex"
|
|
onClick={(e) => handleCreate(e, true)}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting && <LoadIcon className="mr-1" />}
|
|
{t('createImport', { ns: 'bs' })}
|
|
</Button>
|
|
</>
|
|
) : (
|
|
<Button
|
|
type="submit"
|
|
className="px-8 h-8 flex"
|
|
onClick={(e) => handleCreate(e, false)}
|
|
disabled={isSubmitting}
|
|
>
|
|
{isSubmitting && <LoadIcon className="mr-1" />}
|
|
{t('confirm')}
|
|
</Button>
|
|
)}
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
}
|
|
|
|
const doing = {} // Record knowledge bases being copied
|
|
export default function KnowledgeFile() {
|
|
const [open, setOpen] = useState(false);
|
|
const { user } = useContext(userContext);
|
|
const { message } = useToast()
|
|
const navigate = useNavigate()
|
|
const [settingsOpen, setSettingsOpen] = useState(false);
|
|
const [currentSettingLib, setCurrentSettingLib] = useState(null);
|
|
const [copyLoadingId, setCopyLoadingId] = useState<string | null>(null);
|
|
// New: Control Select dropdown state to avoid occasional popup issues
|
|
const [selectOpenId, setSelectOpenId] = useState<string | null>(null);
|
|
const [modalKey, setModalKey] = useState(0); // New: Used to force re-render of modal
|
|
// Permission management state
|
|
const [permDialogOpen, setPermDialogOpen] = useState(false);
|
|
const [permTarget, setPermTarget] = useState<{ id: string; name: string } | null>(null);
|
|
|
|
// F027: cursor-based infinite scroll; no `total` / `page` anymore.
|
|
const { data: datalist, loading, hasMore, search, reload, loadMore } = useInfiniteCursorTable(
|
|
{ cancelLoadingWhenReload: true },
|
|
(param) =>
|
|
readFileLibDatabase({ cursor: param.cursor, pageSize: param.pageSize, name: param.keyword, permissionId: 'view_kb' }),
|
|
)
|
|
|
|
// Permission levels for badge display
|
|
// 列表已由后端 get_knowledge 按 ReBAC 过滤;勿再用批量 check 二次过滤,否则与 FGA/缓存短暂不同步时会出现「接口有数据但表格空白」。
|
|
const visibleLibs = datalist;
|
|
const hasListPermission = (el: any, permissionId: string) =>
|
|
Array.isArray(el.permission_ids) && el.permission_ids.includes(permissionId);
|
|
const canEdit = (el: any) =>
|
|
hasListPermission(el, 'edit_kb');
|
|
const canDelete = (el: any) =>
|
|
hasListPermission(el, 'delete_kb');
|
|
// PRD 3.3.3:「创建」「复制」与 ReBAC 编辑权解耦,由 WEB_MENU `create_knowledge` 控制(对齐「创建应用」+ 列表「复制」)
|
|
const canCreateLibrary =
|
|
user.role === 'admin' ||
|
|
Boolean(user.is_department_admin) ||
|
|
(user.web_menu || []).includes('create_knowledge');
|
|
const canReadRow = (el: any) =>
|
|
hasListPermission(el, 'view_kb');
|
|
/** 与 apps.tsx 一致:create_knowledge 菜单 + 对目标库具备使用/可见(can_read) */
|
|
const canUseCopy = (el: any) => canCreateLibrary && canReadRow(el);
|
|
const canManageKb = (el: any) =>
|
|
KB_MANAGE_PERMISSION_IDS.some((permissionId) => hasListPermission(el, permissionId));
|
|
const isLibraryBusy = (el: any) =>
|
|
[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state);
|
|
const canCopy = (el: any) =>
|
|
canUseCopy(el) && el.state === KnowledgeBaseStatus.Published;
|
|
const hasRowActions = (el: any) =>
|
|
canManageKb(el) || canCopy(el) || canEdit(el) || canDelete(el);
|
|
const showOperationsColumn = visibleLibs.some((el: any) => isLibraryBusy(el) || hasRowActions(el));
|
|
|
|
// Enable polling during copying
|
|
useEffect(() => {
|
|
const todos = datalist.reduce((prev, curr) => {
|
|
if (curr.state === KnowledgeBaseStatus.Copying) {
|
|
prev.push({ id: curr.id, name: curr.name })
|
|
}
|
|
return prev
|
|
}, [])
|
|
|
|
todos.map(todo => {
|
|
if (doing[todo.id]) {
|
|
const lib = datalist.find(item => item.id === todo.id);
|
|
if (lib && lib.state !== KnowledgeBaseStatus.Copying) {
|
|
message({
|
|
variant: 'success',
|
|
description: t('copyCompleted', { name: todo.name })
|
|
})
|
|
delete doing[todo.id]
|
|
}
|
|
}
|
|
})
|
|
|
|
let timer = null
|
|
if (todos.length > 0) {
|
|
timer = setTimeout(() => {
|
|
reload()
|
|
}, 5000);
|
|
}
|
|
|
|
return () => {
|
|
clearTimeout(timer)
|
|
}
|
|
}, [datalist])
|
|
|
|
const handleDelete = (id) => {
|
|
bsConfirm({
|
|
title: t('prompt'),
|
|
desc: t('lib.confirmDeleteLibrary', { ns: 'bs' }),
|
|
onOk(next) {
|
|
captureAndAlertRequestErrorHoc(deleteFileLib(id).then(res => {
|
|
reload();
|
|
}));
|
|
next()
|
|
},
|
|
})
|
|
}
|
|
|
|
const handleOpenSettings = (lib) => {
|
|
console.log("=== handleOpenSettings execution started ===");
|
|
console.log("Clicked lib ID:", lib.id);
|
|
// 1. Deep copy: Completely break reference association with original lib (solving nested property reference issues)
|
|
const newCurrentLib = JSON.parse(JSON.stringify(lib));
|
|
// 2. Inject unique identifier: Ensure currentSettingLib reference is absolutely unique even if data is identical
|
|
newCurrentLib.__updateKey = Date.now(); // Generate different timestamp for each click
|
|
|
|
setCurrentSettingLib(newCurrentLib); // Now passing a completely new object reference
|
|
setSettingsOpen(true);
|
|
setModalKey(prev => prev + 1); // Keep modalKey to ensure modal re-mounts
|
|
console.log("handleOpenSettings called with lib:", newCurrentLib); // Verify print
|
|
};
|
|
|
|
const handleSettingsClose = (isOpen) => {
|
|
console.log("handleSettingsClose called with isOpen:", isOpen);
|
|
setSettingsOpen(isOpen);
|
|
if (!isOpen) {
|
|
setCurrentSettingLib(null);
|
|
setSelectOpenId(null);
|
|
console.log("Settings modal closed and state cleared");
|
|
}
|
|
};
|
|
|
|
// Cache the active tab hint before entering detail; the parent's
|
|
// ``defaultValue`` reads ``LibPage.type`` to restore the file/qa tab.
|
|
// F027: ``page`` is now an opaque cursor token, not restorable, so we
|
|
// only persist the type marker.
|
|
const handleCachePage = () => {
|
|
window.LibPage = { type: 'file' }
|
|
}
|
|
|
|
useEffect(() => {
|
|
// F027: ``useInfiniteCursorTable`` auto-loads page 1 on mount; we no
|
|
// longer try to restore a specific page on return from detail. Just
|
|
// drop the marker so subsequent navigations get a fresh first page.
|
|
delete window.LibPage;
|
|
}, [])
|
|
|
|
const { t, i18n } = useTranslation('knowledge');
|
|
useEffect(() => {
|
|
i18n.loadNamespaces('knowledge');
|
|
}, [i18n]);
|
|
|
|
// Copy knowledge base
|
|
const handleCopy = async (elem) => {
|
|
const newName = `${elem.name}${t('copySuffix')}`;
|
|
if (newName.length > 200) {
|
|
toast({
|
|
title: t('operationFailed'),
|
|
variant: 'error',
|
|
description: t('copyNameExceedsLimit')
|
|
});
|
|
|
|
// Reset all related states
|
|
setSelectOpenId(null);
|
|
setCopyLoadingId(null);
|
|
|
|
// Force re-render of Select component
|
|
setModalKey(prev => prev + 1);
|
|
return;
|
|
}
|
|
|
|
setCopyLoadingId(elem.id);
|
|
doing[elem.id] = true;
|
|
|
|
try {
|
|
await captureAndAlertRequestErrorHoc(copyLibDatabase(elem.id, newName));
|
|
reload();
|
|
} catch (error) {
|
|
message({
|
|
variant: 'error',
|
|
description: t('copyFailed')
|
|
});
|
|
} finally {
|
|
setCopyLoadingId(null);
|
|
setSelectOpenId(null);
|
|
// Ensure Select component resets
|
|
setModalKey(prev => prev + 1);
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
console.log("settingsOpen state changed:", settingsOpen);
|
|
console.log("currentSettingLib:", currentSettingLib);
|
|
console.log("modalKey:", modalKey);
|
|
}, [settingsOpen, currentSettingLib, modalKey]);
|
|
|
|
return (
|
|
<div className="relative">
|
|
{loading && <div className="absolute w-full h-full top-0 left-0 flex justify-center items-center z-10 bg-[rgba(255,255,255,0.6)] dark:bg-blur-shared">
|
|
<LoadingIcon />
|
|
</div>}
|
|
<div className="h-[calc(100vh-128px-var(--license-banner-h,0px))] overflow-y-auto pb-20">
|
|
<div className="flex justify-end gap-4 items-center absolute right-0 top-[-44px]">
|
|
<SearchInput placeholder={t('lib.searchPlaceholder', { ns: 'bs' })} onChange={(e) => search(e.target.value)} />
|
|
{canCreateLibrary && <Button className="px-8 text-[#FFFFFF]" onClick={() => setOpen(true)}>{t('create', { ns: 'bs' })}</Button>}
|
|
</div>
|
|
<Table noScroll>
|
|
<TableHeader>
|
|
<TableRow>
|
|
<TableHead>{t('lib.libraryName', { ns: 'bs' })}</TableHead>
|
|
<TableHead>{t('updateTime')}</TableHead>
|
|
<TableHead>{t('lib.createUser', { ns: 'bs' })}</TableHead>
|
|
{showOperationsColumn && (
|
|
<TableHead className="text-right">{t('operations')}</TableHead>
|
|
)}
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{visibleLibs.map((el: any) => (
|
|
<TableRow
|
|
key={el.id}
|
|
className=""
|
|
onClick={() => {
|
|
if (!canReadRow(el)) return;
|
|
if ([KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state)) return;
|
|
window.libname = [el.name, el.description];
|
|
navigate(`/filelib/${el.id}`);
|
|
handleCachePage();
|
|
}}
|
|
>
|
|
<TableCell
|
|
className="font-medium max-w-[200px]"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<div className="flex items-center justify-center size-12 text-white rounded-[4px] w-[40px] h-[40px]">
|
|
<BookIcon className="text-primary size-10" />
|
|
</div>
|
|
<div>
|
|
<div className="truncate max-w-[500px] w-[264px] text-[14px] font-medium pt-2">
|
|
{el.name}
|
|
</div>
|
|
<Tip
|
|
side="top"
|
|
content={el.description?.length > 30 ? el.description : ''}
|
|
>
|
|
<div className="truncate max-w-[500px] text-[12px] text-[#5A5A5A] pt-1">
|
|
{el.description || ''}
|
|
</div>
|
|
</Tip>
|
|
</div>
|
|
</div>
|
|
</TableCell>
|
|
|
|
<TableCell
|
|
className="text-[#5A5A5A]"
|
|
>
|
|
{el.update_time.replace('T', ' ')}
|
|
</TableCell>
|
|
|
|
<TableCell
|
|
className="max-w-[300px] break-all"
|
|
>
|
|
<div className="truncate-multiline text-[#5A5A5A]">{el.user_name || '--'}</div>
|
|
</TableCell>
|
|
|
|
{showOperationsColumn && <TableCell className="text-right">
|
|
<div className="flex items-center justify-end gap-2">
|
|
{(isLibraryBusy(el) || hasRowActions(el)) && <Select
|
|
key={`${el.id}-${modalKey}`}
|
|
open={selectOpenId === el.id}
|
|
onOpenChange={(isOpen) => {
|
|
if (isLibraryBusy(el) || !hasRowActions(el)) return;
|
|
if (copyLoadingId !== el.id) {
|
|
setSelectOpenId(isOpen ? el.id : null);
|
|
} else if (!isOpen) {
|
|
// If in copying state and about to close, allow closing
|
|
setSelectOpenId(null);
|
|
}
|
|
}}
|
|
onValueChange={(selectedValue) => {
|
|
setSelectOpenId(null);
|
|
console.log("Selected value:", selectedValue, "for lib:", el.id);
|
|
|
|
switch (selectedValue) {
|
|
case 'permission':
|
|
setPermTarget({ id: String(el.id), name: el.name });
|
|
setPermDialogOpen(true);
|
|
setModalKey(prev => prev + 1);
|
|
break;
|
|
case 'copy':
|
|
canCopy(el) && handleCopy(el);
|
|
break;
|
|
case 'set':
|
|
canEdit(el) && handleOpenSettings(el);
|
|
break;
|
|
case 'delete':
|
|
canDelete(el) && handleDelete(el.id);
|
|
break;
|
|
}
|
|
}}
|
|
>
|
|
<SelectTrigger
|
|
showIcon={false}
|
|
disabled={copyLoadingId === el.id}
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
className="size-10 px-2 bg-transparent border-none shadow-none hover:bg-gray-300 flex items-center justify-center duration-200 relative"
|
|
>
|
|
{[KnowledgeBaseStatus.Copying, KnowledgeBaseStatus.Unpublished].includes(el.state) ? (
|
|
<>
|
|
<LoaderCircle className="animate-spin" />
|
|
<div className="absolute -top-8 left-1/2 transform -translate-x-1/2 bg-white text-gray-800 text-xs px-2 py-1 rounded whitespace-nowrap border border-gray-300 shadow-sm">
|
|
{t('copying')}
|
|
</div>
|
|
</>
|
|
) : (
|
|
hasRowActions(el) && <Ellipsis size={24} color="#a69ba2" strokeWidth={1.75} />
|
|
)}
|
|
</SelectTrigger>
|
|
{hasRowActions(el) && <SelectContent
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
}}
|
|
className="z-50 overflow-visible"
|
|
>
|
|
{canManageKb(el) && (
|
|
<SelectItem showIcon={false} value="permission">
|
|
<div className="flex gap-2 items-center">
|
|
<Shield className="w-4 h-4" />
|
|
{t('managePermission', { ns: 'permission' })}
|
|
</div>
|
|
</SelectItem>
|
|
)}
|
|
{canCopy(el) && (
|
|
<SelectItem
|
|
showIcon={false}
|
|
value="copy"
|
|
disabled={copyLoadingId === el.id}
|
|
>
|
|
<div className="flex gap-2 items-center" >
|
|
<Copy className="w-4 h-4" />
|
|
{t('lib.copy', { ns: 'bs' })}
|
|
</div>
|
|
</SelectItem>
|
|
)}
|
|
{canEdit(el) && (
|
|
<SelectItem
|
|
value="set"
|
|
showIcon={false}
|
|
>
|
|
<div className="flex gap-2 items-center">
|
|
<Settings className="w-4 h-4" />
|
|
{t('settings')}
|
|
</div>
|
|
</SelectItem>
|
|
)}
|
|
{canDelete(el) && (
|
|
<SelectItem
|
|
value="delete"
|
|
showIcon={false}
|
|
>
|
|
<div className="flex gap-2 items-center">
|
|
<Trash2 className="w-4 h-4" />
|
|
{t('delete')}
|
|
</div>
|
|
</SelectItem>
|
|
)}
|
|
</SelectContent>}
|
|
</Select>}
|
|
</div>
|
|
</TableCell>}
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
{/* F027: infinite-scroll trigger lives INSIDE the
|
|
`overflow-y-auto` scroll container so IntersectionObserver
|
|
tracks in-container scroll. Outside-container placement
|
|
keeps the footer always-visible and stalls pagination. */}
|
|
{hasMore && <LoadMore onScrollLoad={loadMore} />}
|
|
</div>
|
|
<div className="bisheng-table-footer px-6 bg-background-login">
|
|
<div className="flex items-center gap-2">
|
|
<p className="desc">{t('lib.libraryCollection', { ns: 'bs' })}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Create modal */}
|
|
<CreateModal
|
|
datalist={datalist}
|
|
open={open}
|
|
onOpenChange={setOpen}
|
|
onLoadEnd={() => { }}
|
|
mode="create"
|
|
/>
|
|
|
|
{/* Edit (Settings) modal - using key to force re-render */}
|
|
{settingsOpen && (
|
|
<CreateModal
|
|
key={`settings-modal-${modalKey}`}
|
|
datalist={datalist}
|
|
open={settingsOpen}
|
|
onOpenChange={handleSettingsClose}
|
|
onLoadEnd={reload}
|
|
mode="edit"
|
|
currentLib={currentSettingLib}
|
|
/>
|
|
)}
|
|
|
|
{/* Permission management dialog */}
|
|
{permTarget && (
|
|
<PermissionDialog
|
|
open={permDialogOpen}
|
|
onOpenChange={setPermDialogOpen}
|
|
resourceType="knowledge_library"
|
|
resourceId={permTarget.id}
|
|
resourceName={permTarget.name}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|