mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-08-08 16:38:01 +08:00
1200 lines
42 KiB
TypeScript
1200 lines
42 KiB
TypeScript
import { BulkOperateBar } from '@/components/bulk-operate-bar';
|
|
import { CardContainer } from '@/components/card-container';
|
|
import { EmptyCardType } from '@/components/empty/constant';
|
|
import { EmptyAppCard } from '@/components/empty/empty';
|
|
import ListFilterBar from '@/components/list-filter-bar';
|
|
import SvgIcon from '@/components/svg-icon';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent, CardFooter } from '@/components/ui/card';
|
|
import { Checkbox } from '@/components/ui/checkbox';
|
|
import { SearchInput } from '@/components/ui/input';
|
|
import { Segmented } from '@/components/ui/segmented';
|
|
import { Spin } from '@/components/ui/spin';
|
|
import {
|
|
Table,
|
|
TableBody,
|
|
TableCell,
|
|
TableHead,
|
|
TableHeader,
|
|
TableRow,
|
|
} from '@/components/ui/table';
|
|
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from '@/components/ui/tooltip';
|
|
import { Routes } from '@/routes';
|
|
import fileManagerService from '@/services/file-manager-service';
|
|
import { formatFileSize } from '@/utils/common-util';
|
|
import { formatDate } from '@/utils/date';
|
|
import {
|
|
Eye,
|
|
FolderOpen,
|
|
LayoutGrid,
|
|
List,
|
|
Pencil,
|
|
Plus,
|
|
RefreshCw,
|
|
Settings,
|
|
Trash2,
|
|
} from 'lucide-react';
|
|
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { useLocation, useNavigate } from 'react-router';
|
|
import { CreateSpaceDialog } from './components/create-space-dialog';
|
|
import DeleteSelectedSpacesDialog from './components/delete-selected-spaces-dialog';
|
|
import DeleteSpaceDialog from './components/delete-space-dialog';
|
|
import RenameSpaceDialog from './components/rename-space-dialog';
|
|
import SearchConfigModal from './components/search-config-modal';
|
|
import SkillCard from './components/skill-card';
|
|
import SkillDetail from './components/skill-detail';
|
|
import UploadModal from './components/upload-modal';
|
|
import { useSkills, useSkillSearchConfig } from './hooks';
|
|
import type { Skill } from './types';
|
|
|
|
// Format relative time
|
|
const formatRelative = (timestamp: number): string => {
|
|
let normalized = timestamp;
|
|
if (normalized > 1e17) normalized = normalized / 1e6;
|
|
else if (normalized > 1e14) normalized = normalized / 1e3;
|
|
else if (normalized > 0 && normalized < 1e11) normalized = normalized * 1e3;
|
|
|
|
const diff = Date.now() - normalized;
|
|
if (diff < 0) return 'just now';
|
|
|
|
const minutes = Math.floor(diff / 60000);
|
|
if (minutes < 1) return 'just now';
|
|
if (minutes < 60) return `${minutes}m ago`;
|
|
|
|
const hours = Math.floor(minutes / 60);
|
|
if (hours < 24) return `${hours}h ago`;
|
|
|
|
const days = Math.floor(hours / 24);
|
|
if (days < 30) return `${days}d ago`;
|
|
|
|
const months = Math.floor(days / 30);
|
|
if (months < 12) return `${months}mo ago`;
|
|
|
|
const years = Math.floor(months / 12);
|
|
return `${years}y ago`;
|
|
};
|
|
|
|
const SkillsPage: React.FC = () => {
|
|
const { t } = useTranslation();
|
|
const { pathname } = useLocation();
|
|
const navigate = useNavigate();
|
|
const [spaces, setSpaces] = useState<Array<{ id: string; name: string }>>([]);
|
|
const [spaceInput, setSpaceInput] = useState('');
|
|
const [selectedSpaceId, setSelectedSpaceId] = useState<string>('');
|
|
const [selectedSpaceName, setSelectedSpaceName] = useState<string>('');
|
|
const [spaceLoading, setSpaceLoading] = useState(false);
|
|
const [spaceSearchString, setSpaceSearchString] = useState('');
|
|
|
|
const {
|
|
skills,
|
|
filteredSkills,
|
|
loading,
|
|
searchQuery,
|
|
setSearchQuery,
|
|
fetchSpaces,
|
|
createSpace,
|
|
deleteSpace,
|
|
updateSpace,
|
|
fetchSkills,
|
|
uploadSkill,
|
|
deleteSkill,
|
|
getSkillFileContent,
|
|
getSkillVersionFiles,
|
|
getSkillDetails,
|
|
} = useSkills();
|
|
|
|
const {
|
|
config,
|
|
configLoading,
|
|
saveConfig,
|
|
fetchConfig,
|
|
reindex,
|
|
searchSkills,
|
|
} = useSkillSearchConfig(selectedSpaceId);
|
|
|
|
const [viewMode, setViewMode] = useState<'grid' | 'list'>('grid');
|
|
const [spaceViewMode, setSpaceViewMode] = useState<'grid' | 'list'>('grid');
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
const [selectedSkill, setSelectedSkill] = useState<Skill | null>(null);
|
|
const [uploadModalOpen, setUploadModalOpen] = useState(false);
|
|
const [configModalOpen, setConfigModalOpen] = useState(false);
|
|
const [createSpaceModalOpen, setCreateSpaceModalOpen] = useState(false);
|
|
const [deleteSpaceModalOpen, setDeleteSpaceModalOpen] = useState(false);
|
|
const [spaceToDelete, setSpaceToDelete] = useState<{
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
const [renameSpaceModalOpen, setRenameSpaceModalOpen] = useState(false);
|
|
const [spaceToRename, setSpaceToRename] = useState<{
|
|
id: string;
|
|
name: string;
|
|
} | null>(null);
|
|
const [renameSpaceInput, setRenameSpaceInput] = useState('');
|
|
const [rowSelection, setRowSelection] = useState<Record<string, boolean>>({});
|
|
const [spaceDetails, setSpaceDetails] = useState<
|
|
Record<string, { size: number; createTime: number }>
|
|
>({});
|
|
const [deleteSpacesModalOpen, setDeleteSpacesModalOpen] = useState(false);
|
|
const [searchResults, setSearchResults] = useState<Skill[]>([]);
|
|
const [isSearching, setIsSearching] = useState(false);
|
|
const [hasSearched, setHasSearched] = useState(false);
|
|
const [skillDetailLoading, setSkillDetailLoading] = useState(false);
|
|
|
|
// Pagination and sorting state
|
|
const [currentPage, setCurrentPage] = useState(1);
|
|
const [pageSize] = useState(20);
|
|
const [totalSkills, setTotalSkills] = useState(0);
|
|
const [sortBy] = useState<'name' | 'update_time' | 'create_time'>(
|
|
'update_time',
|
|
);
|
|
const [sortOrder, setSortOrder] = useState<'asc' | 'desc'>('desc');
|
|
|
|
// Selection state derived values (must be declared before any functions that use them)
|
|
const selectedSpaceCount = useMemo(
|
|
() => Object.keys(rowSelection).length,
|
|
[rowSelection],
|
|
);
|
|
const selectedSpaceIds = useMemo(
|
|
() => Object.keys(rowSelection),
|
|
[rowSelection],
|
|
);
|
|
const hasSelectedSpaces = selectedSpaceCount > 0;
|
|
|
|
const clearModalLocks = useCallback(() => {
|
|
setDetailOpen(false);
|
|
setUploadModalOpen(false);
|
|
setConfigModalOpen(false);
|
|
setSelectedSkill(null);
|
|
document.body.style.removeProperty('pointer-events');
|
|
document.body.style.removeProperty('overflow');
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
clearModalLocks();
|
|
}, [pathname, clearModalLocks]);
|
|
|
|
useEffect(() => {
|
|
return () => {
|
|
document.body.style.removeProperty('pointer-events');
|
|
document.body.style.removeProperty('overflow');
|
|
};
|
|
}, []);
|
|
|
|
const loadSpaces = useCallback(async () => {
|
|
setSpaceLoading(true);
|
|
setRowSelection({}); // Clear selection when loading new data
|
|
try {
|
|
const nextSpaces = await fetchSpaces();
|
|
setSpaces(nextSpaces);
|
|
// Fetch folder details for each space
|
|
const details: Record<string, { size: number; createTime: number }> = {};
|
|
for (const space of nextSpaces) {
|
|
if (space.folder_id) {
|
|
try {
|
|
const { data } = await fileManagerService.listFile({
|
|
parent_id: space.folder_id,
|
|
});
|
|
if (data.code === 0) {
|
|
const files = data.data?.files || [];
|
|
const totalSize = files.reduce(
|
|
(sum: number, f: any) => sum + (f.size || 0),
|
|
0,
|
|
);
|
|
details[space.id] = {
|
|
size: totalSize,
|
|
createTime: space.create_time || Date.now(),
|
|
};
|
|
}
|
|
} catch (e) {
|
|
console.warn('Failed to fetch space folder details:', e);
|
|
}
|
|
}
|
|
}
|
|
setSpaceDetails(details);
|
|
} finally {
|
|
setSpaceLoading(false);
|
|
}
|
|
}, [fetchSpaces]);
|
|
|
|
useEffect(() => {
|
|
loadSpaces();
|
|
// oxlint-disable-next-line react/exhaustive-deps
|
|
}, []);
|
|
|
|
// Function to load skills with pagination and sorting
|
|
const loadSkills = useCallback(async () => {
|
|
const result = await fetchSkills(
|
|
selectedSpaceName,
|
|
selectedSpaceId,
|
|
currentPage,
|
|
pageSize,
|
|
sortBy,
|
|
sortOrder,
|
|
);
|
|
setTotalSkills(result.total);
|
|
}, [
|
|
fetchSkills,
|
|
selectedSpaceName,
|
|
selectedSpaceId,
|
|
currentPage,
|
|
pageSize,
|
|
sortBy,
|
|
sortOrder,
|
|
]);
|
|
|
|
// Load skills when space changes or pagination/sorting changes
|
|
useEffect(() => {
|
|
if (!selectedSpaceId || !selectedSpaceName) return;
|
|
// Clear search results when switching spaces
|
|
setSearchResults([]);
|
|
setHasSearched(false);
|
|
setSearchQuery('');
|
|
setCurrentPage(1);
|
|
fetchConfig(undefined, selectedSpaceId);
|
|
// Use search API with pagination and sorting
|
|
loadSkills();
|
|
// oxlint-disable-next-line react/exhaustive-deps
|
|
}, [selectedSpaceId, selectedSpaceName]);
|
|
|
|
// Load skills when pagination or sorting changes
|
|
useEffect(() => {
|
|
if (!selectedSpaceId || !selectedSpaceName || hasSearched) return;
|
|
loadSkills();
|
|
// oxlint-disable-next-line react/exhaustive-deps
|
|
}, [currentPage, sortBy, sortOrder]);
|
|
|
|
const handleViewSkill = useCallback(
|
|
async (skill: Skill) => {
|
|
// If skill already has versions, use it directly
|
|
if (skill.versions && skill.versions.length > 0) {
|
|
setSelectedSkill(skill);
|
|
setDetailOpen(true);
|
|
return;
|
|
}
|
|
|
|
// Try to enrich skill data with versions from existing skills list
|
|
if (!(skill as any)._folderId || !skill.versions) {
|
|
const existingSkill = filteredSkills.find((s) => s.id === skill.id);
|
|
if (existingSkill) {
|
|
if ((existingSkill as any)._folderId) {
|
|
skill = {
|
|
...skill,
|
|
_folderId: (existingSkill as any)._folderId,
|
|
};
|
|
}
|
|
if (existingSkill.versions && existingSkill.versions.length > 0) {
|
|
skill = {
|
|
...skill,
|
|
versions: existingSkill.versions,
|
|
files: existingSkill.files,
|
|
};
|
|
}
|
|
}
|
|
}
|
|
|
|
// If still no versions but has folderId, fetch from file system
|
|
if (
|
|
(!skill.versions || skill.versions.length === 0) &&
|
|
(skill as any)._folderId
|
|
) {
|
|
setSkillDetailLoading(true);
|
|
try {
|
|
const detailedSkill = await getSkillDetails(
|
|
(skill as any)._folderId,
|
|
skill.name,
|
|
);
|
|
if (detailedSkill) {
|
|
skill = {
|
|
...skill,
|
|
versions: detailedSkill.versions,
|
|
files: detailedSkill.files,
|
|
metadata: {
|
|
...skill.metadata,
|
|
...detailedSkill.metadata,
|
|
},
|
|
};
|
|
}
|
|
} catch (error) {
|
|
console.warn('Failed to fetch skill details:', error);
|
|
} finally {
|
|
setSkillDetailLoading(false);
|
|
}
|
|
}
|
|
|
|
if (!(skill as any)._folderId) {
|
|
console.warn(
|
|
`[Skill Search] Skill "${skill.name}" has no folder_id. ` +
|
|
'Please reindex skills to fix this issue.',
|
|
);
|
|
}
|
|
|
|
setSelectedSkill(skill);
|
|
setDetailOpen(true);
|
|
},
|
|
[filteredSkills, getSkillDetails],
|
|
);
|
|
|
|
const handleCloseDetail = useCallback(() => {
|
|
setDetailOpen(false);
|
|
setSelectedSkill(null);
|
|
}, []);
|
|
|
|
const handleUpload = useCallback(
|
|
async (name: string, version: string, files: File[]) => {
|
|
// Pass space name (for file system), space ID (for indexing), and embd_id (for indexing)
|
|
return await uploadSkill(
|
|
name,
|
|
version,
|
|
files,
|
|
selectedSpaceName,
|
|
selectedSpaceId,
|
|
config?.embd_id,
|
|
);
|
|
},
|
|
[uploadSkill, selectedSpaceName, selectedSpaceId, config?.embd_id],
|
|
);
|
|
|
|
const handleDelete = useCallback(
|
|
async (skillId: string, skillName: string, folderId?: string) => {
|
|
// Pass both space ID (for index), space name (for file system), and folderId (for search results)
|
|
const success = await deleteSkill(
|
|
skillId,
|
|
skillName,
|
|
selectedSpaceId,
|
|
selectedSpaceName,
|
|
folderId,
|
|
);
|
|
// If delete succeeded and we have search results, remove the skill from searchResults
|
|
if (success) {
|
|
setSearchResults((prev) => prev.filter((s) => s.id !== skillId));
|
|
}
|
|
},
|
|
[deleteSkill, selectedSpaceId, selectedSpaceName],
|
|
);
|
|
|
|
const handleCreateHub = useCallback(async () => {
|
|
const nextHubName = spaceInput.trim();
|
|
if (!nextHubName) return;
|
|
const newHub = await createSpace(nextHubName);
|
|
if (!newHub) return;
|
|
setSpaceInput('');
|
|
setCreateSpaceModalOpen(false);
|
|
await loadSpaces();
|
|
// Select the newly created space
|
|
setSelectedSpaceId(newHub.id);
|
|
setSelectedSpaceName(newHub.name);
|
|
}, [spaceInput, createSpace, loadSpaces]);
|
|
|
|
const handleDeleteHub = useCallback(async () => {
|
|
if (!spaceToDelete) return;
|
|
const success = await deleteSpace(spaceToDelete.id);
|
|
if (success) {
|
|
setDeleteSpaceModalOpen(false);
|
|
setSpaceToDelete(null);
|
|
await loadSpaces();
|
|
}
|
|
}, [spaceToDelete, deleteSpace, loadSpaces]);
|
|
|
|
const openDeleteSpaceModal = useCallback(
|
|
(space: { id: string; name: string }, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setSpaceToDelete(space);
|
|
setDeleteSpaceModalOpen(true);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const openRenameSpaceModal = useCallback(
|
|
(space: { id: string; name: string }, e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
setSpaceToRename(space);
|
|
setRenameSpaceInput(space.name);
|
|
setRenameSpaceModalOpen(true);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleRenameHub = useCallback(async () => {
|
|
if (!spaceToRename || !renameSpaceInput.trim()) return;
|
|
const success = await updateSpace(
|
|
spaceToRename.id,
|
|
renameSpaceInput.trim(),
|
|
);
|
|
if (success) {
|
|
setRenameSpaceModalOpen(false);
|
|
setSpaceToRename(null);
|
|
setRenameSpaceInput('');
|
|
await loadSpaces();
|
|
// Update selected space name if it's the current space
|
|
if (selectedSpaceId === spaceToRename.id) {
|
|
setSelectedSpaceName(renameSpaceInput.trim());
|
|
}
|
|
}
|
|
}, [
|
|
spaceToRename,
|
|
renameSpaceInput,
|
|
updateSpace,
|
|
loadSpaces,
|
|
selectedSpaceId,
|
|
]);
|
|
|
|
const handleDeleteSelectedHubs = useCallback(async () => {
|
|
for (const hubId of selectedSpaceIds) {
|
|
await deleteSpace(hubId);
|
|
}
|
|
setDeleteSpacesModalOpen(false);
|
|
setRowSelection({});
|
|
await loadSpaces();
|
|
}, [selectedSpaceIds, deleteSpace, loadSpaces]);
|
|
|
|
const handleOpenDeleteSelectedModal = useCallback(() => {
|
|
setDeleteSpacesModalOpen(true);
|
|
}, []);
|
|
|
|
const handleSearch = useCallback(
|
|
async (query: string) => {
|
|
setSearchQuery(query);
|
|
if (!query.trim()) {
|
|
setSearchResults([]);
|
|
setHasSearched(false);
|
|
return;
|
|
}
|
|
setIsSearching(true);
|
|
setHasSearched(true);
|
|
try {
|
|
const results = await searchSkills(query, 1, 20);
|
|
if (results?.skills) {
|
|
const localSkillMap = new Map(skills.map((s) => [s.id, s]));
|
|
const localSkillNameMap = new Map(
|
|
skills.map((s) => [s.name.toLowerCase(), s]),
|
|
);
|
|
const mergedResults = results.skills.map((skill) => {
|
|
const localSkill =
|
|
localSkillMap.get(skill.id) ||
|
|
localSkillNameMap.get(skill.name.toLowerCase());
|
|
if (!localSkill) return skill;
|
|
return {
|
|
...skill,
|
|
created_at: localSkill.created_at,
|
|
updated_at: localSkill.updated_at,
|
|
_folderId:
|
|
(skill as any)._folderId || (localSkill as any)._folderId,
|
|
versions: localSkill.versions,
|
|
files: localSkill.files,
|
|
};
|
|
});
|
|
setSearchResults(mergedResults);
|
|
} else {
|
|
setSearchResults([]);
|
|
}
|
|
} catch (error) {
|
|
console.error('Search error:', error);
|
|
setSearchResults([]);
|
|
} finally {
|
|
setIsSearching(false);
|
|
}
|
|
},
|
|
[searchSkills, setSearchQuery, skills],
|
|
);
|
|
|
|
const handleSearchInputChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
const value = e.target.value;
|
|
setSearchQuery(value);
|
|
if (!value.trim()) {
|
|
setSearchResults([]);
|
|
setHasSearched(false);
|
|
}
|
|
},
|
|
[setSearchQuery],
|
|
);
|
|
|
|
const handleSearchKeyDown = useCallback(
|
|
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
|
if (e.key === 'Enter') {
|
|
handleSearch(searchQuery);
|
|
}
|
|
},
|
|
[handleSearch, searchQuery],
|
|
);
|
|
|
|
const handleHubSearchChange = useCallback(
|
|
(e: React.ChangeEvent<HTMLInputElement>) => {
|
|
setSpaceSearchString(e.target.value);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const filteredSpaces = useMemo(() => {
|
|
if (!spaceSearchString.trim()) return spaces;
|
|
return spaces.filter((space) =>
|
|
space.name.toLowerCase().includes(spaceSearchString.toLowerCase()),
|
|
);
|
|
}, [spaces, spaceSearchString]);
|
|
|
|
const displayedSkills = useMemo(() => {
|
|
// Server-side sorting is already applied via API, no need to sort here
|
|
return hasSearched ? searchResults : filteredSkills;
|
|
}, [hasSearched, searchResults, filteredSkills]);
|
|
|
|
const isLoading = loading || isSearching || configLoading;
|
|
|
|
// Space list breadcrumb: root / skills
|
|
const hubListBreadcrumb = (
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="text-text-secondary cursor-pointer hover:text-text-primary"
|
|
onClick={() => navigate(Routes.Files)}
|
|
>
|
|
root
|
|
</span>
|
|
<span className="text-text-secondary">/</span>
|
|
<span>{t('skills.title')}</span>
|
|
</div>
|
|
);
|
|
|
|
// Skills list breadcrumb: root / skills / {hubName}
|
|
const skillsListBreadcrumb = (
|
|
<div className="flex items-center gap-2">
|
|
<span
|
|
className="text-text-secondary cursor-pointer hover:text-text-primary"
|
|
onClick={() => navigate(Routes.Files)}
|
|
>
|
|
root
|
|
</span>
|
|
<span className="text-text-secondary">/</span>
|
|
<span
|
|
className="text-text-secondary cursor-pointer hover:text-text-primary"
|
|
onClick={() => {
|
|
setSelectedSpaceId('');
|
|
setSelectedSpaceName('');
|
|
setSearchResults([]);
|
|
setHasSearched(false);
|
|
setSearchQuery('');
|
|
fetchSkills(''); // Clear skills data
|
|
}}
|
|
>
|
|
{t('skills.title')}
|
|
</span>
|
|
<span className="text-text-secondary">/</span>
|
|
<span>{selectedSpaceName}</span>
|
|
</div>
|
|
);
|
|
|
|
// Space list page (no space selected)
|
|
if (!selectedSpaceId) {
|
|
return (
|
|
<>
|
|
<article
|
|
className="size-full flex flex-col"
|
|
data-testid="skill-space-list"
|
|
>
|
|
<header className="px-5 pt-8 mb-4">
|
|
<ListFilterBar
|
|
leftPanel={hubListBreadcrumb}
|
|
searchString={spaceSearchString}
|
|
onSearchChange={handleHubSearchChange}
|
|
showFilter={false}
|
|
icon="file"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<Segmented
|
|
value={spaceViewMode}
|
|
onChange={(v) => setSpaceViewMode(v as 'grid' | 'list')}
|
|
options={[
|
|
{ value: 'grid', label: <LayoutGrid className="size-4" /> },
|
|
{ value: 'list', label: <List className="size-4" /> },
|
|
]}
|
|
/>
|
|
<Button onClick={() => setCreateSpaceModalOpen(true)}>
|
|
<Plus className="size-[1em]" />
|
|
{t('skills.createSpace') || 'Create Skill Space'}
|
|
</Button>
|
|
</div>
|
|
</ListFilterBar>
|
|
|
|
{hasSelectedSpaces && spaceViewMode === 'list' && (
|
|
<BulkOperateBar
|
|
className="mt-4"
|
|
count={selectedSpaceCount}
|
|
unit={t('skills.space') || 'spaces'}
|
|
list={[
|
|
{
|
|
id: 'delete',
|
|
label: t('common.delete'),
|
|
icon: <Trash2 className="size-4" />,
|
|
onClick: handleOpenDeleteSelectedModal,
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
</header>
|
|
|
|
<div className="flex-1 px-5 flex flex-col overflow-hidden">
|
|
{spaceLoading ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Spin size="large" />
|
|
</div>
|
|
) : filteredSpaces.length ? (
|
|
spaceViewMode === 'grid' ? (
|
|
<CardContainer className="flex-1 overflow-auto">
|
|
{filteredSpaces.map((space) => (
|
|
<Card
|
|
key={space.id}
|
|
className="group flex flex-col cursor-pointer bg-bg-card hover:border-accent-primary hover:shadow-md transition-all"
|
|
onClick={() => {
|
|
setSelectedSpaceId(space.id);
|
|
setSelectedSpaceName(space.name);
|
|
}}
|
|
>
|
|
<CardContent className="flex-1 flex flex-col p-4">
|
|
<div className="flex items-start justify-between mb-2">
|
|
<div className="flex-1 min-w-0 flex items-center gap-2">
|
|
<SvgIcon
|
|
name="home-icon/skill-space"
|
|
width={20}
|
|
height={20}
|
|
/>
|
|
<h3 className="font-semibold text-lg truncate">
|
|
{space.name}
|
|
</h3>
|
|
</div>
|
|
<div className="flex opacity-0 group-hover:opacity-100 transition-opacity">
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-text-secondary hover:text-accent-primary"
|
|
onClick={(e: React.MouseEvent) =>
|
|
openRenameSpaceModal(space, e)
|
|
}
|
|
>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-text-secondary hover:text-red-500"
|
|
onClick={(e: React.MouseEvent) =>
|
|
openDeleteSpaceModal(space, e)
|
|
}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</CardContent>
|
|
<CardFooter className="pt-0 pb-4 px-4">
|
|
<span className="text-accent-primary text-sm">
|
|
{t('skills.enterSpace') || 'Enter'} →
|
|
</span>
|
|
</CardFooter>
|
|
</Card>
|
|
))}
|
|
</CardContainer>
|
|
) : (
|
|
<div className="flex-1 overflow-auto border border-border rounded-lg">
|
|
<Table>
|
|
<TableHeader className="bg-bg-title sticky top-0">
|
|
<TableRow>
|
|
<TableHead className="w-[50px] text-center">
|
|
<Checkbox
|
|
checked={
|
|
filteredSpaces.length > 0 &&
|
|
filteredSpaces.every(
|
|
(space) => rowSelection[space.id],
|
|
)
|
|
}
|
|
onCheckedChange={(checked) => {
|
|
const newSelection = { ...rowSelection };
|
|
filteredSpaces.forEach((space) => {
|
|
if (checked) {
|
|
newSelection[space.id] = true;
|
|
} else {
|
|
delete newSelection[space.id];
|
|
}
|
|
});
|
|
setRowSelection(newSelection);
|
|
}}
|
|
/>
|
|
</TableHead>
|
|
<TableHead className="w-[20vw]">
|
|
{t('skills.spaceName') || 'Name'}
|
|
</TableHead>
|
|
<TableHead className="w-[160px]">
|
|
{t('fileManager.uploadDate') || 'Upload Date'}
|
|
</TableHead>
|
|
<TableHead className="w-[96px]">
|
|
{t('fileManager.size') || 'Size'}
|
|
</TableHead>
|
|
<TableHead className="w-[96px] text-right">
|
|
{t('common.action') || 'Action'}
|
|
</TableHead>
|
|
</TableRow>
|
|
</TableHeader>
|
|
<TableBody>
|
|
{filteredSpaces.map((space) => (
|
|
<TableRow
|
|
key={space.id}
|
|
className="hover:bg-bg-secondary/50 cursor-pointer transition-colors"
|
|
onClick={() => {
|
|
setSelectedSpaceId(space.id);
|
|
setSelectedSpaceName(space.name);
|
|
}}
|
|
>
|
|
<TableCell
|
|
className="text-center"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Checkbox
|
|
checked={!!rowSelection[space.id]}
|
|
onCheckedChange={(checked) => {
|
|
setRowSelection((prev) => {
|
|
const newSelection = { ...prev };
|
|
if (checked) {
|
|
newSelection[space.id] = true;
|
|
} else {
|
|
delete newSelection[space.id];
|
|
}
|
|
return newSelection;
|
|
});
|
|
}}
|
|
/>
|
|
</TableCell>
|
|
<TableCell>
|
|
<div className="flex items-center gap-2 overflow-hidden">
|
|
<SvgIcon
|
|
name="home-icon/skill-space"
|
|
width={16}
|
|
height={16}
|
|
/>
|
|
<span className="font-medium truncate">
|
|
{space.name}
|
|
</span>
|
|
</div>
|
|
</TableCell>
|
|
<TableCell className="text-sm text-text-secondary">
|
|
{spaceDetails[space.id]?.createTime
|
|
? formatDate(spaceDetails[space.id].createTime)
|
|
: '-'}
|
|
</TableCell>
|
|
<TableCell className="text-sm text-text-secondary">
|
|
{spaceDetails[space.id]?.size !== undefined
|
|
? formatFileSize(spaceDetails[space.id].size)
|
|
: '-'}
|
|
</TableCell>
|
|
<TableCell
|
|
className="text-right"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-text-secondary hover:text-accent-primary"
|
|
onClick={(e: React.MouseEvent) =>
|
|
openRenameSpaceModal(space, e)
|
|
}
|
|
>
|
|
<Pencil className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-text-secondary hover:text-red-500"
|
|
onClick={(e: React.MouseEvent) =>
|
|
openDeleteSpaceModal(space, e)
|
|
}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</TableCell>
|
|
</TableRow>
|
|
))}
|
|
</TableBody>
|
|
</Table>
|
|
</div>
|
|
)
|
|
) : (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
{spaceSearchString ? (
|
|
<EmptyAppCard
|
|
showIcon
|
|
size="large"
|
|
className="w-[480px] p-14"
|
|
isSearch
|
|
type={EmptyCardType.Skills}
|
|
/>
|
|
) : (
|
|
<EmptyAppCard
|
|
showIcon
|
|
size="large"
|
|
className="w-[480px] p-14"
|
|
type={EmptyCardType.Skills}
|
|
onClick={() => setCreateSpaceModalOpen(true)}
|
|
/>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</article>
|
|
|
|
{/* Create Space Modal */}
|
|
<CreateSpaceDialog
|
|
open={createSpaceModalOpen}
|
|
onOpenChange={setCreateSpaceModalOpen}
|
|
spaceInput={spaceInput}
|
|
onSpaceInputChange={setSpaceInput}
|
|
onCreate={handleCreateHub}
|
|
/>
|
|
|
|
{/* Delete Space Modal */}
|
|
<DeleteSpaceDialog
|
|
open={deleteSpaceModalOpen}
|
|
onOpenChange={(open) => {
|
|
setDeleteSpaceModalOpen(open);
|
|
if (!open) setSpaceToDelete(null);
|
|
}}
|
|
spaceToDelete={spaceToDelete}
|
|
onDelete={handleDeleteHub}
|
|
/>
|
|
|
|
{/* Rename Space Modal */}
|
|
<RenameSpaceDialog
|
|
open={renameSpaceModalOpen}
|
|
onOpenChange={(open) => {
|
|
setRenameSpaceModalOpen(open);
|
|
if (!open) {
|
|
setSpaceToRename(null);
|
|
setRenameSpaceInput('');
|
|
}
|
|
}}
|
|
spaceToRename={spaceToRename}
|
|
renameSpaceInput={renameSpaceInput}
|
|
onRenameInputChange={setRenameSpaceInput}
|
|
onRename={handleRenameHub}
|
|
/>
|
|
|
|
{/* Delete Selected Spaces Modal */}
|
|
<DeleteSelectedSpacesDialog
|
|
open={deleteSpacesModalOpen}
|
|
onOpenChange={setDeleteSpacesModalOpen}
|
|
selectedCount={selectedSpaceCount}
|
|
onDelete={handleDeleteSelectedHubs}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
// Inside a space (skills list page)
|
|
return (
|
|
<article className="size-full flex flex-col" data-testid="skills-list">
|
|
<header className="px-5 pt-8 mb-4">
|
|
<ListFilterBar
|
|
leftPanel={skillsListBreadcrumb}
|
|
showFilter={false}
|
|
showSearch={false}
|
|
icon="file"
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
{/* Search skills */}
|
|
<SearchInput
|
|
placeholder={t('skills.searchPlaceholder') || 'Search skills...'}
|
|
value={searchQuery}
|
|
onChange={handleSearchInputChange}
|
|
onKeyDown={handleSearchKeyDown}
|
|
className="w-[200px]"
|
|
rootClassName="relative"
|
|
/>
|
|
{/* Sort order toggle */}
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setSortOrder(sortOrder === 'asc' ? 'desc' : 'asc')}
|
|
title={
|
|
sortOrder === 'asc'
|
|
? t('skills.sortDesc') || 'Sort Descending'
|
|
: t('skills.sortAsc') || 'Sort Ascending'
|
|
}
|
|
>
|
|
{sortOrder === 'asc' ? (
|
|
<svg
|
|
className="size-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M12 5v14M5 12l7-7 7 7" />
|
|
</svg>
|
|
) : (
|
|
<svg
|
|
className="size-4"
|
|
viewBox="0 0 24 24"
|
|
fill="none"
|
|
stroke="currentColor"
|
|
strokeWidth="2"
|
|
>
|
|
<path d="M12 19V5M5 12l7 7 7-7" />
|
|
</svg>
|
|
)}
|
|
</Button>
|
|
|
|
{/* Grid/List toggle */}
|
|
<Segmented
|
|
value={viewMode}
|
|
onChange={(v) => setViewMode(v as 'grid' | 'list')}
|
|
options={[
|
|
{ value: 'grid', label: <LayoutGrid className="size-4" /> },
|
|
{ value: 'list', label: <List className="size-4" /> },
|
|
]}
|
|
/>
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => setConfigModalOpen(true)}
|
|
disabled={loading}
|
|
>
|
|
<Settings className="size-4" />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('skills.configureSearch')}</TooltipContent>
|
|
</Tooltip>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<Button
|
|
variant="outline"
|
|
size="icon"
|
|
onClick={() => loadSkills()}
|
|
disabled={loading}
|
|
>
|
|
<RefreshCw className={loading ? 'animate-spin' : ''} />
|
|
</Button>
|
|
</TooltipTrigger>
|
|
<TooltipContent>{t('common.refresh')}</TooltipContent>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
<Button onClick={() => setUploadModalOpen(true)}>
|
|
<Plus className="mr-2" />
|
|
{t('skills.addSkill') || 'Add Skill'}
|
|
</Button>
|
|
</div>
|
|
</ListFilterBar>
|
|
</header>
|
|
|
|
<div className="flex-1 px-5 flex flex-col overflow-hidden">
|
|
{/* Skills List */}
|
|
{isLoading ? (
|
|
<div className="flex-1 flex items-center justify-center">
|
|
<Spin size="large" />
|
|
</div>
|
|
) : displayedSkills.length === 0 ? (
|
|
<div className="flex-1 flex flex-col items-center justify-center text-text-secondary">
|
|
<FolderOpen className="size-16 mb-4 opacity-50" />
|
|
{hasSearched ? (
|
|
<p>
|
|
{t('skills.noSearchResults') || 'No search results'}
|
|
: "{searchQuery}"
|
|
</p>
|
|
) : searchQuery ? (
|
|
<p>
|
|
{t('skills.noSearchResults') || 'No search results'}
|
|
: "{searchQuery}"
|
|
</p>
|
|
) : (
|
|
<div className="text-center">
|
|
<p className="mb-2">{t('skills.noSkills')}</p>
|
|
<button
|
|
className="text-accent-primary hover:underline"
|
|
onClick={() => setUploadModalOpen(true)}
|
|
>
|
|
{t('skills.addSkill') || 'Add Skill'}
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : viewMode === 'grid' ? (
|
|
<CardContainer className="flex-1 overflow-auto">
|
|
{displayedSkills.map((skill) => (
|
|
<SkillCard
|
|
key={skill.id}
|
|
skill={skill}
|
|
onView={handleViewSkill}
|
|
onDelete={handleDelete}
|
|
formatRelative={formatRelative}
|
|
/>
|
|
))}
|
|
</CardContainer>
|
|
) : (
|
|
<div className="flex-1 overflow-auto border border-border rounded-lg">
|
|
<table className="w-full" style={{ tableLayout: 'fixed' }}>
|
|
<colgroup>
|
|
<col style={{ width: 'auto' }} />
|
|
<col style={{ width: '120px' }} />
|
|
<col style={{ width: '96px' }} />
|
|
</colgroup>
|
|
<thead className="bg-bg-title sticky top-0">
|
|
<tr>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-text-title">
|
|
{t('skills.skillName') || 'Name'}
|
|
</th>
|
|
<th className="px-4 py-3 text-left text-sm font-medium text-text-title">
|
|
{t('skills.version') || 'Version'}
|
|
</th>
|
|
<th className="px-4 py-3 text-right text-sm font-medium text-text-title">
|
|
{t('common.action') || 'Action'}
|
|
</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-border">
|
|
{displayedSkills.map((skill) => (
|
|
<tr
|
|
key={skill.id}
|
|
className="hover:bg-bg-secondary/50 cursor-pointer transition-colors"
|
|
onClick={() => handleViewSkill(skill)}
|
|
>
|
|
<td className="px-4 py-3">
|
|
<div className="flex items-center gap-2 overflow-hidden">
|
|
<SvgIcon
|
|
name="home-icon/skill-folder"
|
|
width={16}
|
|
height={16}
|
|
/>
|
|
<span className="font-medium truncate">
|
|
{skill.name}
|
|
</span>
|
|
</div>
|
|
{skill.description && (
|
|
<p className="text-text-secondary text-xs mt-1 truncate">
|
|
{skill.description}
|
|
</p>
|
|
)}
|
|
</td>
|
|
<td className="px-4 py-3 text-sm text-text-secondary">
|
|
{skill.metadata?.version || '-'}
|
|
</td>
|
|
<td
|
|
className="px-4 py-3 text-right"
|
|
onClick={(e) => e.stopPropagation()}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-text-secondary hover:text-accent-primary"
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
handleViewSkill(skill);
|
|
}}
|
|
>
|
|
<Eye className="size-4" />
|
|
</Button>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="h-8 w-8 text-text-secondary hover:text-red-500"
|
|
onClick={(e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
handleDelete(
|
|
skill.id,
|
|
skill.name,
|
|
(skill as any)._folderId,
|
|
);
|
|
}}
|
|
>
|
|
<Trash2 className="size-4" />
|
|
</Button>
|
|
</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
|
|
{/* Pagination */}
|
|
{!hasSearched && totalSkills > 0 && (
|
|
<div className="flex items-center justify-between py-4 border-t border-border mt-4">
|
|
<div className="text-sm text-text-secondary">
|
|
{t('skills.totalSkills', { total: totalSkills })}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={currentPage <= 1 || loading}
|
|
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
|
>
|
|
{t('common.previous')}
|
|
</Button>
|
|
<span className="text-sm text-text-secondary px-2">
|
|
{t('skills.pageInfo', {
|
|
current: currentPage,
|
|
total: Math.ceil(totalSkills / pageSize),
|
|
})}
|
|
</span>
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
disabled={
|
|
currentPage >= Math.ceil(totalSkills / pageSize) || loading
|
|
}
|
|
onClick={() => setCurrentPage((p) => p + 1)}
|
|
>
|
|
{t('common.next')}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Skill Detail Drawer */}
|
|
{detailOpen && selectedSkill && (
|
|
<SkillDetail
|
|
skill={selectedSkill}
|
|
open={detailOpen}
|
|
onClose={handleCloseDetail}
|
|
getFileContent={getSkillFileContent}
|
|
getVersionFiles={getSkillVersionFiles}
|
|
/>
|
|
)}
|
|
|
|
{/* Skill Detail Loading Overlay */}
|
|
{skillDetailLoading && (
|
|
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/20">
|
|
<Spin size="large" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Upload Modal */}
|
|
{uploadModalOpen && (
|
|
<UploadModal
|
|
open={uploadModalOpen}
|
|
onCancel={() => setUploadModalOpen(false)}
|
|
onUpload={handleUpload}
|
|
/>
|
|
)}
|
|
|
|
{/* Search Config Modal */}
|
|
{configModalOpen && (
|
|
<SearchConfigModal
|
|
open={configModalOpen}
|
|
onOpenChange={setConfigModalOpen}
|
|
config={config || undefined}
|
|
onSave={saveConfig}
|
|
onReindex={reindex}
|
|
loading={configLoading}
|
|
/>
|
|
)}
|
|
</article>
|
|
);
|
|
};
|
|
|
|
export default SkillsPage;
|