dashboard-nanobot/frontend/src/modules/dashboard/components/SkillMarketInstallModal.tsx

207 lines
8.9 KiB
TypeScript
Raw Normal View History

2026-03-19 08:53:44 +00:00
import { useEffect, useMemo, useState } from 'react';
import axios from 'axios';
2026-03-26 18:09:25 +00:00
import { ChevronLeft, ChevronRight, Hammer, RefreshCw, X } from 'lucide-react';
2026-03-19 08:53:44 +00:00
import { APP_ENDPOINTS } from '../../../config/env';
import type { BotSkillMarketItem } from '../../platform/types';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
2026-03-26 18:09:25 +00:00
import { ProtectedSearchInput } from '../../../components/ProtectedSearchInput';
2026-03-19 08:53:44 +00:00
import {
normalizePlatformPageSize,
readCachedPlatformPageSize,
writeCachedPlatformPageSize,
} from '../../../utils/platformPageSize';
interface SkillMarketInstallModalProps {
isZh: boolean;
open: boolean;
items: BotSkillMarketItem[];
loading: boolean;
installingId: number | null;
onClose: () => void;
onRefresh: () => Promise<void> | void;
onInstall: (item: BotSkillMarketItem) => Promise<void> | void;
formatBytes: (bytes: number) => string;
}
export function SkillMarketInstallModal({
isZh,
open,
items,
loading,
installingId,
onClose,
onRefresh,
onInstall,
formatBytes,
}: SkillMarketInstallModalProps) {
const [search, setSearch] = useState('');
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(() => readCachedPlatformPageSize(10));
useEffect(() => {
if (!open) return;
setSearch('');
setPage(1);
void onRefresh();
void (async () => {
try {
const res = await axios.get<{ page_size?: number }>(`${APP_ENDPOINTS.apiBase}/platform/settings`);
const normalized = normalizePlatformPageSize(res.data?.page_size, readCachedPlatformPageSize(10));
writeCachedPlatformPageSize(normalized);
setPageSize(normalized);
} catch {
setPageSize(readCachedPlatformPageSize(10));
}
})();
}, [open]);
useEffect(() => {
setPage(1);
}, [search, pageSize]);
const filteredItems = useMemo(() => {
const keyword = search.trim().toLowerCase();
if (!keyword) return items;
return items.filter((item) =>
[item.display_name, item.skill_key, item.description, item.zip_filename].some((value) =>
String(value || '').toLowerCase().includes(keyword),
),
);
}, [items, search]);
const pageCount = Math.max(1, Math.ceil(filteredItems.length / pageSize));
const currentPage = Math.min(page, pageCount);
const pagedItems = useMemo(
() => filteredItems.slice((currentPage - 1) * pageSize, currentPage * pageSize),
[currentPage, filteredItems, pageSize],
);
if (!open) return null;
return (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-wide platform-modal skill-market-browser-shell" onClick={(event) => event.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{isZh ? '从市场安装技能' : 'Install From Marketplace'}</h3>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void onRefresh()}
tooltip={isZh ? '刷新市场技能' : 'Refresh marketplace skills'}
aria-label={isZh ? '刷新市场技能' : 'Refresh marketplace skills'}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton className="btn btn-secondary btn-sm icon-btn" onClick={onClose} tooltip={isZh ? '关闭' : 'Close'} aria-label={isZh ? '关闭' : 'Close'}>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className="skill-market-browser-toolbar">
2026-03-26 18:09:25 +00:00
<ProtectedSearchInput
className="platform-searchbar skill-market-search"
value={search}
onChange={setSearch}
onClear={() => setSearch('')}
autoFocus
debounceMs={120}
placeholder={isZh ? '搜索技能、标识或 ZIP 文件名...' : 'Search skills, keys, or ZIP filenames...'}
ariaLabel={isZh ? '搜索技能市场' : 'Search skill marketplace'}
clearTitle={isZh ? '清空搜索' : 'Clear search'}
searchTitle={isZh ? '搜索' : 'Search'}
/>
2026-03-19 08:53:44 +00:00
</div>
<div className="skill-market-browser-grid">
{loading ? (
<div className="ops-empty-inline skill-market-empty-card">{isZh ? '正在读取技能市场...' : 'Loading marketplace skills...'}</div>
) : pagedItems.length === 0 ? (
<div className="ops-empty-inline skill-market-empty-card">
{filteredItems.length === 0
? (isZh ? '没有匹配的技能。' : 'No matching skills found.')
: (isZh ? '当前页没有技能。' : 'No skills on this page.')}
</div>
) : (
pagedItems.map((skill) => {
const isInstalled = Boolean(skill.installed);
const isInstalling = installingId === skill.id;
return (
<article key={skill.id} className={`skill-market-card skill-market-browser-card ${isInstalled ? 'is-active' : ''}`}>
<div className="skill-market-card-top">
<div className="skill-market-card-title-wrap">
<h4>{skill.display_name || skill.skill_key}</h4>
<div className="skill-market-card-key mono">{skill.skill_key}</div>
</div>
<span className={`badge ${isInstalled ? 'badge-ok' : 'badge-unknown'} skill-market-browser-badge`}>
{isInstalled ? (isZh ? '已安装' : 'Installed') : (isZh ? '未安装' : 'Not installed')}
</span>
</div>
<p className="skill-market-card-desc skill-market-browser-desc">{skill.description || (isZh ? '暂无简介。' : 'No description yet.')}</p>
<div className="skill-market-card-meta skill-market-browser-meta">
<span>{isZh ? 'ZIP' : 'ZIP'}: {skill.zip_filename}</span>
<span>{isZh ? '体积' : 'Size'}: {formatBytes(skill.zip_size_bytes)}</span>
<span>{isZh ? '安装次数' : 'Installs'}: {skill.install_count}</span>
</div>
{skill.install_error && !isInstalled ? (
<div className="field-label" style={{ color: 'var(--err)' }}>
{skill.install_error}
</div>
) : null}
<div className="skill-market-card-footer skill-market-browser-footer">
<span className={skill.zip_exists ? 'skill-market-card-status is-ok' : 'skill-market-card-status is-missing'}>
{skill.zip_exists ? (isZh ? '市场包可用' : 'Package ready') : (isZh ? '市场包缺失' : 'Package missing')}
</span>
<button
className="btn btn-primary btn-sm skill-market-install-btn"
disabled={isInstalled || isInstalling || !skill.zip_exists}
onClick={() => void onInstall(skill)}
>
{isInstalling ? <RefreshCw size={14} className="animate-spin" /> : <Hammer size={14} />}
<span style={{ marginLeft: 6 }}>
{isInstalling ? (isZh ? '安装中...' : 'Installing...') : isInstalled ? (isZh ? '已安装' : 'Installed') : (isZh ? '一键安装' : 'Install')}
</span>
</button>
</div>
</article>
);
})
)}
</div>
<div className="skill-market-pager">
<span className="pager-status">
{isZh
? `${currentPage} / ${pageCount} 页,共 ${filteredItems.length} 个技能`
: `Page ${currentPage} / ${pageCount}, ${filteredItems.length} skills`}
</span>
<div className="platform-usage-pager-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage <= 1}
onClick={() => setPage((value) => Math.max(1, value - 1))}
tooltip={isZh ? '上一页' : 'Previous'}
aria-label={isZh ? '上一页' : 'Previous'}
>
<ChevronLeft size={16} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
type="button"
disabled={currentPage >= pageCount}
onClick={() => setPage((value) => Math.min(pageCount, value + 1))}
tooltip={isZh ? '下一页' : 'Next'}
aria-label={isZh ? '下一页' : 'Next'}
>
<ChevronRight size={16} />
</LucentIconButton>
</div>
</div>
</div>
</div>
);
}