Files
socweb/frontend/components/gemini/ExtensionsPanel.tsx
T
gongzhiyongandClaude Sonnet 4.6 04d8fbb740 refactor: 重组项目结构,前端收拢至 frontend/,新增 backend/ 目录
- 前端文件移入 frontend/ 子目录
- 新建 backend/ 目录(待开发)
- 新增 CLAUDE.md、claudehd.md、EXTERNAL_SERVICES.md

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 18:01:04 +08:00

322 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { cn } from "@/lib/utils";
import {
X,
Ticket,
ShoppingCart,
Cloud,
Check,
Loader2,
Eye,
EyeOff,
AlertCircle,
} from "lucide-react";
interface Extension {
id: string;
name: string;
description: string;
icon: React.ElementType;
connected: boolean;
apiKey: string;
}
interface ExtensionsPanelProps {
isOpen: boolean;
onClose: () => void;
extensions: Extension[];
onUpdateExtension: (id: string, apiKey: string, connected: boolean) => void;
}
export function ExtensionsPanel({
isOpen,
onClose,
extensions,
onUpdateExtension,
}: ExtensionsPanelProps) {
const [editingId, setEditingId] = useState<string | null>(null);
const [tempKey, setTempKey] = useState("");
const [showKey, setShowKey] = useState<Record<string, boolean>>({});
const [connecting, setConnecting] = useState<string | null>(null);
const handleConnect = async (ext: Extension) => {
if (!tempKey.trim()) return;
setConnecting(ext.id);
// 模拟API验证
await new Promise((r) => setTimeout(r, 1500));
onUpdateExtension(ext.id, tempKey, true);
setConnecting(null);
setEditingId(null);
setTempKey("");
};
const handleDisconnect = (ext: Extension) => {
onUpdateExtension(ext.id, "", false);
};
const handleEdit = (ext: Extension) => {
setEditingId(ext.id);
setTempKey(ext.apiKey);
};
const handleCancel = () => {
setEditingId(null);
setTempKey("");
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={onClose}
/>
{/* Panel */}
<div className="relative w-full max-w-lg bg-[#1e1e1e] border border-[#3a3a3a] rounded-2xl shadow-2xl overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-[#3a3a3a]">
<h2 className="text-lg font-semibold text-[#e3e3e3]">扩展程序</h2>
<button
onClick={onClose}
className="p-1.5 rounded-full hover:bg-[#2a2a2a] text-[#9aa0a6] hover:text-[#e3e3e3] transition-colors"
>
<X size={20} />
</button>
</div>
{/* Content */}
<div className="p-4 space-y-3 max-h-[60vh] overflow-y-auto">
{extensions.map((ext) => {
const Icon = ext.icon;
const isEditing = editingId === ext.id;
const isConnecting = connecting === ext.id;
return (
<div
key={ext.id}
className={cn(
"rounded-xl border transition-all duration-200",
ext.connected
? "bg-[#1a2a1a] border-green-800/50"
: "bg-[#2a2a2a] border-[#3a3a3a]"
)}
>
{/* Extension header */}
<div className="flex items-center gap-3 p-4">
<div
className={cn(
"w-10 h-10 rounded-lg flex items-center justify-center",
ext.connected ? "bg-green-900/50" : "bg-[#3a3a3a]"
)}
>
<Icon
size={20}
className={ext.connected ? "text-green-400" : "text-[#9aa0a6]"}
/>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<h3 className="font-medium text-[#e3e3e3]">{ext.name}</h3>
{ext.connected && (
<span className="flex items-center gap-1 text-xs text-green-400">
<Check size={12} />
已连接
</span>
)}
</div>
<p className="text-sm text-[#9aa0a6] truncate">
{ext.description}
</p>
</div>
{!isEditing && (
<button
onClick={() =>
ext.connected ? handleDisconnect(ext) : handleEdit(ext)
}
className={cn(
"px-3 py-1.5 text-sm font-medium rounded-lg transition-colors",
ext.connected
? "text-red-400 hover:bg-red-900/30"
: "text-[#4285f4] hover:bg-[#4285f4]/10"
)}
>
{ext.connected ? "断开" : "连接"}
</button>
)}
</div>
{/* API Key input */}
{isEditing && (
<div className="px-4 pb-4 space-y-3">
<div className="space-y-1.5">
<label className="text-xs text-[#9aa0a6] flex items-center gap-1">
<AlertCircle size={12} />
API Key(密钥将安全存储)
</label>
<div className="relative">
<input
type={showKey[ext.id] ? "text" : "password"}
value={tempKey}
onChange={(e) => setTempKey(e.target.value)}
placeholder="请输入 API Key"
className="w-full px-3 py-2 pr-10 bg-[#1e1e1e] border border-[#3a3a3a] rounded-lg text-[#e3e3e3] placeholder:text-[#5f6368] focus:outline-none focus:border-[#4285f4] text-sm"
/>
<button
type="button"
onClick={() =>
setShowKey((prev) => ({
...prev,
[ext.id]: !prev[ext.id],
}))
}
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-[#9aa0a6] hover:text-[#e3e3e3]"
>
{showKey[ext.id] ? (
<EyeOff size={16} />
) : (
<Eye size={16} />
)}
</button>
</div>
</div>
<div className="flex gap-2">
<button
onClick={handleCancel}
className="flex-1 px-3 py-2 text-sm text-[#9aa0a6] hover:text-[#e3e3e3] hover:bg-[#3a3a3a] rounded-lg transition-colors"
>
取消
</button>
<button
onClick={() => handleConnect(ext)}
disabled={!tempKey.trim() || isConnecting}
className={cn(
"flex-1 px-3 py-2 text-sm font-medium rounded-lg transition-colors flex items-center justify-center gap-2",
tempKey.trim() && !isConnecting
? "bg-[#4285f4] text-white hover:bg-[#3b78e7]"
: "bg-[#3a3a3a] text-[#5f6368] cursor-not-allowed"
)}
>
{isConnecting ? (
<>
<Loader2 size={14} className="animate-spin" />
验证中...
</>
) : (
"连接"
)}
</button>
</div>
</div>
)}
{/* Connected info */}
{ext.connected && !isEditing && (
<div className="px-4 pb-4">
<div className="flex items-center gap-2 text-xs text-[#9aa0a6]">
<span>API Key:</span>
<code className="px-2 py-0.5 bg-[#2a2a2a] rounded text-[#9aa0a6]">
{ext.apiKey.slice(0, 8)}****{ext.apiKey.slice(-4)}
</code>
<button
onClick={() => handleEdit(ext)}
className="text-[#4285f4] hover:underline ml-auto"
>
修改
</button>
</div>
</div>
)}
</div>
);
})}
</div>
{/* Footer */}
<div className="px-6 py-3 border-t border-[#3a3a3a] bg-[#1a1a1a]">
<p className="text-xs text-[#5f6368] text-center">
连接扩展后,可在对话中查看相关系统数据摘要
</p>
</div>
</div>
</div>
);
}
// 工单摘要组件 - 显示在对话区域
interface TicketSummaryProps {
tickets: {
id: string;
title: string;
status: "pending" | "processing" | "resolved";
priority: "P0" | "P1" | "P2" | "P3";
createdAt: string;
}[];
}
export function TicketSummary({ tickets }: TicketSummaryProps) {
const statusMap = {
pending: { label: "待处理", color: "text-yellow-400 bg-yellow-900/30" },
processing: { label: "处理中", color: "text-blue-400 bg-blue-900/30" },
resolved: { label: "已解决", color: "text-green-400 bg-green-900/30" },
};
const priorityColors = {
P0: "text-red-400",
P1: "text-orange-400",
P2: "text-yellow-400",
P3: "text-[#9aa0a6]",
};
if (tickets.length === 0) return null;
return (
<div className="mx-auto max-w-3xl px-4 mb-4">
<div className="bg-[#1e1e1e] border border-[#3a3a3a] rounded-xl overflow-hidden">
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-[#3a3a3a] bg-[#252525]">
<Ticket size={16} className="text-[#4285f4]" />
<span className="text-sm font-medium text-[#e3e3e3]">工单摘要</span>
<span className="text-xs text-[#9aa0a6]">({tickets.length} 条待关注)</span>
</div>
<div className="divide-y divide-[#2a2a2a]">
{tickets.slice(0, 5).map((ticket) => (
<div
key={ticket.id}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-[#2a2a2a] transition-colors cursor-pointer"
>
<span className={cn("text-xs font-mono", priorityColors[ticket.priority])}>
{ticket.priority}
</span>
<span className="flex-1 text-sm text-[#e3e3e3] truncate">
{ticket.title}
</span>
<span
className={cn(
"text-xs px-2 py-0.5 rounded-full",
statusMap[ticket.status].color
)}
>
{statusMap[ticket.status].label}
</span>
<span className="text-xs text-[#5f6368]">{ticket.createdAt}</span>
</div>
))}
</div>
{tickets.length > 5 && (
<div className="px-4 py-2 text-center border-t border-[#2a2a2a]">
<button className="text-xs text-[#4285f4] hover:underline">
查看全部 {tickets.length} 条工单
</button>
</div>
)}
</div>
</div>
);
}