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>
|
|
|
|
|
);
|
|
|
|
|
}
|