码桶

发现社区成员的开源项目

奶狗 /

ng-webot

公开
main
ng-webot/web/src/pages/Market.tsx
Market.tsx22.2 KB
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { useNavigate, useLocation } from 'react-router-dom';
import { useAuth } from '@/lib/auth';
import { toast } from 'sonner';
import { api } from '@/lib/api';
import {
  Search, Package, Sparkles, Loader2,
  ChevronLeft, ChevronRight,
  Settings, X, BookOpen, FileText, Upload,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { PluginSettingsEditor } from '@/components/PluginConfigPanel';
import { DocsModal } from '@/components/DocsModal';

/* ==================== 类型 ==================== */

interface PluginItem {
  id: string; name: string; version: string; author: string;
  category: string; description: string; usage?: string;
  builtin?: boolean; configurable?: boolean;
  settingsSchema?: any[]; customConfig?: any;
  installed: boolean; enabled: boolean;
  force_enabled?: boolean;
  is_free?: boolean; access?: number; price?: number;
  downloads?: number;
}

interface ListingItem {
  id: number; market_id: string; name: string; version: string;
  description: string; category: string; price: number;
  downloads: number; seller_name: string; seller_url?: string;
  bought?: boolean; status?: string;
  seller_user_id?: number;
}

interface Pagination {
  total: number; page: number; pageSize: number; totalPages: number;
}

type TabKey = 'official' | 'self';

/* ==================== 工具 ==================== */

const CAT_COLORS: Record<string, string> = {
  'AI': 'from-indigo-500 to-violet-500',
  '工具': 'from-blue-500 to-cyan-500',
  '娱乐': 'from-orange-400 to-pink-500',
  '信息获取': 'from-green-400 to-emerald-600',
  '推送': 'from-sky-400 to-indigo-500',
  '社交': 'from-pink-400 to-rose-500',
  '智能家居': 'from-amber-400 to-orange-600',
  '办公': 'from-slate-500 to-slate-700',
  '管理': 'from-rose-400 to-red-500',
  'RSS': 'from-teal-400 to-cyan-600',
  '其他': 'from-gray-400 to-gray-600',
};
const CAT_BG: Record<string, string> = {
  'AI': 'bg-indigo-50 text-indigo-700',
  '工具': 'bg-blue-50 text-blue-700',
  '娱乐': 'bg-orange-50 text-orange-700',
  '信息获取': 'bg-green-50 text-green-700',
  '推送': 'bg-sky-50 text-sky-700',
  '社交': 'bg-pink-50 text-pink-700',
  '智能家居': 'bg-amber-50 text-amber-700',
  '办公': 'bg-slate-100 text-slate-700',
  '管理': 'bg-rose-50 text-rose-700',
  'RSS': 'bg-teal-50 text-teal-700',
  '其他': 'bg-gray-100 text-gray-600',
};

function catColor(cat: string) { return CAT_COLORS[cat] || CAT_COLORS['其他']; }
function catBadge(cat: string) { return CAT_BG[cat] || CAT_BG['其他']; }

function useDebounce<T>(value: T, delay: number): T {
  const [debounced, setDebounced] = useState(value);
  useEffect(() => {
    const t = setTimeout(() => setDebounced(value), delay);
    return () => clearTimeout(t);
  }, [value, delay]);
  return debounced;
}

/* ==================== 分页器组件 ==================== */

function Paginator({ pg, onChange }: { pg: Pagination | null; onChange: (p: number) => void }) {
  if (!pg || pg.totalPages <= 1) return null;
  const pages: (number | string)[] = [];
  const maxShow = 5;
  let start = Math.max(1, pg.page - Math.floor(maxShow / 2));
  let end = Math.min(pg.totalPages, start + maxShow - 1);
  if (end - start + 1 < maxShow) start = Math.max(1, end - maxShow + 1);
  if (start > 1) { pages.push(1); if (start > 2) pages.push('...'); }
  for (let i = start; i <= end; i++) pages.push(i);
  if (end < pg.totalPages) { if (end < pg.totalPages - 1) pages.push('...'); pages.push(pg.totalPages); }

  return (
    <div className="flex items-center justify-center gap-1 mt-8">
      <button
        disabled={pg.page <= 1}
        onClick={() => onChange(pg.page - 1)}
        className="h-9 w-9 flex items-center justify-center rounded-lg border hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
      >
        <ChevronLeft size={16} />
      </button>
      {pages.map((p, i) =>
        typeof p === 'string'
          ? <span key={`dot-${i}`} className="w-9 text-center text-muted-foreground">...</span>
          : (
            <button
              key={p}
              onClick={() => onChange(p)}
              className={`h-9 w-9 rounded-lg text-sm font-medium transition-all ${
                p === pg.page
                  ? 'bg-primary text-primary-foreground shadow-sm'
                  : 'border hover:bg-muted text-muted-foreground'
              }`}
            >
              {p}
            </button>
          )
      )}
      <button
        disabled={pg.page >= pg.totalPages}
        onClick={() => onChange(pg.page + 1)}
        className="h-9 w-9 flex items-center justify-center rounded-lg border hover:bg-muted disabled:opacity-30 disabled:cursor-not-allowed transition-colors"
      >
        <ChevronRight size={16} />
      </button>
    </div>
  );
}

/* ==================== 主组件 ==================== */

export default function Market() {
  const { me, bots, refresh } = useAuth();
  const navigate = useNavigate();
  const location = useLocation();
  const bot = bots[0]

  const [doc, setDoc] = useState<{ file: string; title: string } | null>(null);
  const initTab = (location.state && (location.state as any).tab) || 'official';
  const [tab, setTab] = useState<TabKey>((['official', 'self'].includes(initTab) ? initTab : 'official') as TabKey);

  const [plugins, setPlugins] = useState<PluginItem[]>([]);
  const [myPlugins, setMyPlugins] = useState<ListingItem[]>([]);

  const [pagination, setPagination] = useState<Pagination | null>(null);

  const [loading, setLoading] = useState(false);
  const [search, setSearch] = useState('');
  const [category, setCategory] = useState<string>('全部');
  const debouncedSearch = useDebounce(search, 300);

  const botId = bots?.[0]?.id || 0;

  const categories = useMemo(() => {
    const set = new Set<string>();
    plugins.forEach(p => { if (p.category) set.add(p.category); });
    return ['全部', ...Array.from(set).sort()];
  }, [plugins]);

  const filteredPlugins = useMemo(() =>
    category === '全部' ? plugins : plugins.filter(p => p.category === category),
    [plugins, category]
  );

  // ==================== API 调用 ====================

  const currentPage = useRef(1);

  const loadOfficial = useCallback(async (p = 1, s = '') => {
    if (!botId) return;
    const body: any = { bot_id: botId, page: p, pageSize: 12 };
    if (s) body.search = s;
    const d = await api.post('/api/market/list', body);
    if (d.ok) {
      setPlugins(d.plugins);
      setPagination({ total: d.total, page: d.page, pageSize: d.pageSize, totalPages: d.totalPages });
    }
  }, [botId]);

  const loadSelfPlugins = useCallback(async (p = 1, s = '') => {
    const body: any = { page: p, pageSize: 12 };
    if (s) body.search = s;
    const d = await api.post('/api/market/my-self-plugins', body);
    if (d.ok) {
      setMyPlugins(d.listings || []);
      setPagination({ total: d.total, page: d.page, pageSize: d.pageSize, totalPages: d.totalPages });
    }
  }, []);

  const loadTab = useCallback(async (t: TabKey, p: number, s: string) => {
    setLoading(true);
    currentPage.current = p;
    if (t === 'official') await loadOfficial(p, s);
    else await loadSelfPlugins(p, s);
    setLoading(false);
  }, [loadOfficial, loadSelfPlugins]);

  useEffect(() => {
    setSearch('');
    setCategory('全部');
  }, [tab]);

  useEffect(() => { loadTab(tab, 1, debouncedSearch); }, [tab, debouncedSearch, loadTab]);

  const goPage = (p: number) => loadTab(tab, p, debouncedSearch);

  // ==================== 操作 ====================

  const handleInstall = async (plugin: PluginItem) => {
    if (!botId) { toast.error('请先绑定机器人'); return; }
    try {
      const d = await api.post('/api/market/redeem', { bot_id: botId, market_id: plugin.id });
      if (d.ok) { toast.success(d.msg); refresh(); loadTab(tab, currentPage.current, debouncedSearch); }
      else toast.error(d.msg);
    } catch (e: any) { toast.error(e.message); }
  };

  const handleToggle = async (plugin: PluginItem, enable: boolean) => {
    if (!botId) { toast.error('请先绑定机器人'); return; }
    try {
      const d = await api.post('/api/market/toggle', { bot_id: botId, market_id: plugin.id, enabled: enable ? 1 : 0 });
      if (d.ok) { toast.success(enable ? `${plugin.name} 已启用` : `${plugin.name} 已停用`); refresh(); loadTab(tab, currentPage.current, debouncedSearch); }
      else toast.error(d.msg);
    } catch (e: any) { toast.error(e.message); }
  };

  const handleSelfRemove = async (listing: ListingItem) => {
    if (!confirm(`确定删除自用插件「${listing.name}」?`)) return;
    try {
      const d = await api.post('/api/market/listing-remove', { listing_id: listing.id });
      if (d.ok) { toast.success(`已删除 ${listing.name}`); loadTab('self', currentPage.current, debouncedSearch); }
      else toast.error(d.msg);
    } catch (e: any) { toast.error(e.message); }
  };

  // ==================== 渲染 ====================

  const emptyMsg = debouncedSearch ? `未找到与「${debouncedSearch}」相关的插件` : '暂无插件';

  if (!me) {
    return (
      <div className="flex min-h-[60vh] items-center justify-center text-sm text-muted-foreground">
        请先登录
      </div>
    );
  }

  return (
    <div className="min-h-[calc(100vh-3.5rem)]">
      {/* ========== 头部 ========== */}
      <div className="border-b bg-card">
        <div className="max-w-6xl mx-auto px-4 py-8 sm:py-10">
          <div className="flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
            <div>
              <h1 className="text-2xl sm:text-3xl font-bold tracking-tight">插件市场</h1>
              <p className="text-muted-foreground max-w-xl text-sm">
                发现、安装和管理你的智能插件,一键接入 AI 对话,赋能你的 bot。
              </p>
            </div>
            <div className="flex gap-2">
              <Button variant="outline" size="sm" onClick={() => setDoc({ file: '插件开发指南.md', title: '插件开发文档' })}>
                <BookOpen size={14} className="mr-1.5" /> 插件开发文档
              </Button>
              <Button variant="outline" size="sm" onClick={() => setDoc({ file: '安装使用说明.md', title: '安装使用说明' })}>
                <FileText size={14} className="mr-1.5" /> 安装使用说明
              </Button>
              <Button size="sm" onClick={() => navigate('/market/upload')}>
                <Upload size={14} className="mr-1.5" /> 上传插件
              </Button>
            </div>
          </div>
        </div>
      </div>

      <div className="max-w-6xl mx-auto px-4 pb-16">
        {/* ========== Tab 切换 + 搜索 ========== */}
        <div className="pt-5 pb-5">
          <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
            {/* Tab 切换 */}
            <div className="flex gap-1 p-1 rounded-lg bg-muted border w-fit">
              {([
                { key: 'official' as TabKey, label: '系统插件', icon: Sparkles },
                { key: 'self' as TabKey, label: '用户插件', icon: Package },
              ]).map(({ key, label, icon: I }) => (
                <button
                  key={key}
                  onClick={() => setTab(key)}
                  className={`flex items-center gap-2 px-3.5 py-2 rounded-md text-sm font-medium transition-all
                    ${tab === key
                      ? 'bg-background text-foreground shadow-sm'
                      : 'text-muted-foreground hover:text-foreground'}
                  `}
                >
                  <I size={14} /> {label}
                </button>
              ))}
            </div>

            {/* 搜索 */}
            <div className="relative w-full sm:w-64">
              <Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-muted-foreground" />
              <Input
                placeholder="搜索插件..."
                value={search}
                onChange={e => setSearch(e.target.value)}
                className="pl-9 h-9 text-sm"
              />
              {search && (
                <button
                  onClick={() => setSearch('')}
                  className="absolute right-2.5 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
                >
                  <X size={14} />
                </button>
              )}
            </div>
          </div>
        </div>

        {/* 我的自用插件统计 */}
        {tab === 'self' && !loading && (
          <div className="flex gap-4 mb-4 text-sm">
            <span className="text-muted-foreground">自用插件 <span className="text-foreground font-semibold">{myPlugins.length}</span> 个(仅你自己可用,不进入市场)</span>
          </div>
        )}

        {/* ========== 分类筛选(仅官方) ========== */}
        {tab === 'official' && categories.length > 2 && (
          <div className="flex flex-wrap gap-2 mb-6">
            {categories.map(c => (
              <button
                key={c}
                onClick={() => setCategory(c)}
                className={`px-3 py-1.5 rounded-full text-xs font-medium border transition-all
                  ${category === c
                    ? 'bg-primary text-primary-foreground border-primary'
                    : 'bg-background text-muted-foreground border-border hover:text-foreground hover:border-foreground/20'}
                `}
              >
                {c}
              </button>
            ))}
          </div>
        )}

        {/* ========== 加载状态 ========== */}
        {loading && (
          <div className="flex items-center justify-center py-24">
            <Loader2 size={24} className="animate-spin text-muted-foreground" />
          </div>
        )}

        {/* ========== 系统插件 ========== */}
        {!loading && tab === 'official' && (
          <>
            {filteredPlugins.length === 0 ? (
              <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
                <Package size={28} className="mb-3 opacity-40" />
                <p className="text-sm">{emptyMsg}</p>
              </div>
            ) : (
              <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
                {filteredPlugins.map(p => (
                  <OfficialCard
                    key={p.id}
                    plugin={p}
                    botId={botId}
                    onInstall={handleInstall}
                    onToggle={handleToggle}
                  />
                ))}
              </div>
            )}
            <Paginator pg={pagination} onChange={goPage} />
          </>
        )}

        {/* ========== 用户插件(自用) ========== */}
        {!loading && tab === 'self' && (
          <>
            {myPlugins.length === 0 ? (
              <div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
                <Package size={28} className="mb-3 opacity-40" />
                <p className="text-sm">{emptyMsg}</p>
              </div>
            ) : (
              <div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
                {myPlugins.map(l => (
                  <SelfCard key={l.id} listing={l} onRemove={handleSelfRemove} />
                ))}
              </div>
            )}
            <Paginator pg={pagination} onChange={goPage} />
          </>
        )}
      </div>

      <DocsModal
        open={!!doc}
        onOpenChange={(v: boolean) => { if (!v) setDoc(null) }}
        file={doc?.file || ''}
        title={doc?.title || ''}
      />
    </div>
  );
}

/* ==================== 系统插件卡片 ==================== */

function OfficialCard({ plugin: p, botId, onInstall, onToggle }: {
  plugin: PluginItem; botId: number; onInstall: (p: PluginItem) => void; onToggle: (p: PluginItem, enable: boolean) => void;
}) {
  const [showDetail, setShowDetail] = useState(false);
  const [showSettings, setShowSettings] = useState(false);
  const accessLabel = '免费';
  const hasConfig = p.configurable && ((Array.isArray(p.settingsSchema) && p.settingsSchema.length > 0) || (p.customConfig && Object.keys(p.customConfig).length > 0));

  return (
    <div className="rounded-xl border bg-card shadow-sm overflow-hidden transition-all duration-200 hover:shadow-md">
      <div className="p-4">
        <div className="flex items-start justify-between gap-2 mb-2">
          <div className="min-w-0">
            <h3 className="font-semibold text-sm leading-tight truncate">{p.name}</h3>
            <p className="text-[11px] text-muted-foreground mt-0.5">v{p.version} · {p.author} · {p.category}</p>
          </div>
          <div className="flex items-center gap-1 shrink-0">
            {p.builtin && <Badge variant="secondary" className="text-[10px] h-4 px-1.5">内置</Badge>}
            <Badge variant={accessLabel === '免费' ? 'success' : 'secondary'} className="text-[10px] h-4 px-1.5">{accessLabel}</Badge>
          </div>
        </div>

        <p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 mb-3">
          {p.description || '暂无描述'}
        </p>

        <div className="flex items-center justify-between pt-2.5 border-t">
          <button
            onClick={() => setShowDetail(!showDetail)}
            className="text-[11px] text-muted-foreground hover:text-foreground transition-colors"
          >
            {showDetail ? '收起详情' : '查看详情'}
          </button>

          {!botId ? (
            <span className="text-[11px] text-muted-foreground">请先绑定机器人</span>
          ) : p.installed ? (
            <div className="flex items-center gap-1.5">
              {hasConfig && (
                <Button
                  variant="outline"
                  size="sm"
                  className="h-7 text-[11px] px-2.5"
                  onClick={() => setShowSettings(true)}
                >
                  <Settings size={11} /> 设置
                </Button>
              )}
              {p.force_enabled ? (
                <Button variant="secondary" size="sm" className="h-7 text-[11px] px-2.5 cursor-default" disabled>
                  系统基础
                </Button>
              ) : (
                <Button
                  variant={p.enabled ? 'default' : 'outline'}
                  size="sm"
                  className="h-7 text-[11px] px-2.5"
                  onClick={() => onToggle(p, !p.enabled)}
                >
                  {p.enabled ? '运行中' : '已停用'}
                </Button>
              )}
            </div>
          ) : (
            <Button
              size="sm"
              className="h-7 text-[11px] px-2.5"
              onClick={() => onInstall(p)}
            >
              获取
            </Button>
          )}
        </div>
      </div>

      {showDetail && (
        <div className="px-4 pb-4 border-t bg-muted/30">
          <div className="pt-3 space-y-2 text-xs text-muted-foreground">
            {p.usage && (
              <div className="p-2.5 rounded-lg bg-background border">
                <p className="text-muted-foreground mb-1 font-medium">使用方法</p>
                <p className="whitespace-pre-wrap text-foreground/80 leading-relaxed">{p.usage}</p>
              </div>
            )}
            <div className="flex gap-4 pt-0.5">
              <span>v{p.version}</span>
              {p.configurable && (
                <span className="flex items-center gap-1"><Settings size={10} /> 可配置</span>
              )}
            </div>
          </div>
        </div>
      )}

      <Dialog open={showSettings} onOpenChange={setShowSettings}>
        <DialogContent>
          <DialogHeader>
            <DialogTitle>自定义设置 · {p.name}</DialogTitle>
          </DialogHeader>
          <PluginSettingsEditor plugin={p as any} botId={botId} />
        </DialogContent>
      </Dialog>
    </div>
  );
}

function StatusBadge({ status }: { status: string }) {
  if (status === 'approved') return <Badge variant="success" className="text-[10px] h-4 px-1.5">已上架</Badge>;
  if (status === 'pending') return <Badge variant="warning" className="text-[10px] h-4 px-1.5">审核中</Badge>;
  return <Badge variant="secondary" className="text-[10px] h-4 px-1.5">已下架</Badge>;
}

function SelfCard({ listing: l, onRemove }: {
  listing: ListingItem; onRemove: (l: ListingItem) => void;
}) {
  const status = l.status || 'private';

  return (
    <div className="rounded-xl border bg-card shadow-sm overflow-hidden transition-all duration-200 hover:shadow-md">
      <div className="p-4">
        <div className="flex items-start justify-between gap-2 mb-2">
          <div className="min-w-0">
            <h3 className="font-semibold text-sm leading-tight truncate">{l.name}</h3>
            <p className="text-[11px] text-muted-foreground mt-0.5">v{l.version} · 自用 · {l.category}</p>
          </div>
          <StatusBadge status={status} />
        </div>

        <p className="text-xs text-muted-foreground leading-relaxed line-clamp-2 mb-3">
          {l.description || '暂无描述'}
        </p>

        <div className="flex items-center justify-between pt-2.5 border-t">
          <span className="text-[11px] text-muted-foreground">{l.downloads || 0} 下载</span>
          <div className="flex gap-1.5">
            <Button
              variant="outline"
              size="sm"
              className="h-7 text-[11px] px-2.5 text-red-600 hover:text-red-700"
              onClick={() => onRemove(l)}
            >
              删除
            </Button>
          </div>
        </div>
      </div>
    </div>
  );
}