- Add fetchTicketSummary() and TicketSummaryData type to lib/api.ts - Change TicketSummary props from tickets[] array to summary object with total, by_status, and by_priority fields - Update GeminiChat to fetch summary data instead of ticket list - TicketSummary now renders status and priority breakdowns from the summary endpoint instead of computing from raw ticket data Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
322 lines
12 KiB
TypeScript
322 lines
12 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-[var(--gem-surface)] border border-[var(--gem-border)] rounded-2xl shadow-2xl overflow-hidden">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between px-6 py-4 border-b border-[var(--gem-border)]">
|
|
<h2 className="text-lg font-semibold text-[var(--gem-text)]">扩展程序</h2>
|
|
<button
|
|
onClick={onClose}
|
|
className="p-1.5 rounded-full hover:bg-[var(--gem-surface-2)] text-[var(--gem-text-muted)] hover:text-[var(--gem-text)] 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-[var(--gem-ext-connected-bg)] border-green-800/50"
|
|
: "bg-[var(--gem-surface-2)] border-[var(--gem-border)]"
|
|
)}
|
|
>
|
|
{/* 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-[var(--gem-border)]"
|
|
)}
|
|
>
|
|
<Icon
|
|
size={20}
|
|
className={ext.connected ? "text-green-400" : "text-[var(--gem-text-muted)]"}
|
|
/>
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h3 className="font-medium text-[var(--gem-text)]">{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-[var(--gem-text-muted)] 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-[var(--gem-blue)] hover:bg-[var(--gem-blue)]/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-[var(--gem-text-muted)] 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-[var(--gem-surface)] border border-[var(--gem-border)] rounded-lg text-[var(--gem-text)] placeholder:text-[var(--gem-text-placeholder)] focus:outline-none focus:border-[var(--gem-blue)] 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-[var(--gem-text-muted)] hover:text-[var(--gem-text)]"
|
|
>
|
|
{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-[var(--gem-text-muted)] hover:text-[var(--gem-text)] hover:bg-[var(--gem-border)] 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-[var(--gem-blue)] text-white hover:bg-[var(--gem-blue-hover)]"
|
|
: "bg-[var(--gem-border)] text-[var(--gem-text-placeholder)] 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-[var(--gem-text-muted)]">
|
|
<span>API Key:</span>
|
|
<code className="px-2 py-0.5 bg-[var(--gem-surface-2)] rounded text-[var(--gem-text-muted)]">
|
|
{ext.apiKey.slice(0, 8)}****{ext.apiKey.slice(-4)}
|
|
</code>
|
|
<button
|
|
onClick={() => handleEdit(ext)}
|
|
className="text-[var(--gem-blue)] hover:underline ml-auto"
|
|
>
|
|
修改
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-6 py-3 border-t border-[var(--gem-border)] bg-[var(--gem-bg)]">
|
|
<p className="text-xs text-[var(--gem-text-placeholder)] text-center">
|
|
连接扩展后,可在对话中查看相关系统数据摘要
|
|
</p>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
// 工单摘要组件 - 显示在对话区域
|
|
interface TicketSummaryProps {
|
|
summary: {
|
|
total: number;
|
|
by_status: { pending: number; processing: number; resolved: number };
|
|
by_priority: { P0: number; P1: number; P2: number; P3: number };
|
|
};
|
|
}
|
|
|
|
export function TicketSummary({ summary }: TicketSummaryProps) {
|
|
const statusEntries: { key: keyof typeof summary.by_status; label: string; color: string }[] = [
|
|
{ key: "pending", label: "待处理", color: "text-yellow-400 bg-yellow-900/30" },
|
|
{ key: "processing", label: "处理中", color: "text-blue-400 bg-blue-900/30" },
|
|
{ key: "resolved", label: "已解决", color: "text-green-400 bg-green-900/30" },
|
|
];
|
|
|
|
const priorityEntries: { key: keyof typeof summary.by_priority; color: string }[] = [
|
|
{ key: "P0", color: "text-red-400" },
|
|
{ key: "P1", color: "text-orange-400" },
|
|
{ key: "P2", color: "text-yellow-400" },
|
|
{ key: "P3", color: "text-[var(--gem-text-muted)]" },
|
|
];
|
|
|
|
if (summary.total === 0) return null;
|
|
|
|
return (
|
|
<div className="mx-auto max-w-3xl px-4 mb-4">
|
|
<div className="bg-[var(--gem-surface)] border border-[var(--gem-border)] rounded-xl overflow-hidden">
|
|
<div className="flex items-center gap-2 px-4 py-2.5 border-b border-[var(--gem-border)] bg-[var(--gem-surface-hover)]">
|
|
<Ticket size={16} className="text-[var(--gem-blue)]" />
|
|
<span className="text-sm font-medium text-[var(--gem-text)]">工单摘要</span>
|
|
<span className="text-xs text-[var(--gem-text-muted)]">({summary.total} 条待关注)</span>
|
|
</div>
|
|
<div className="divide-y divide-[var(--gem-surface-2)]">
|
|
{statusEntries.map(({ key, label, color }) => (
|
|
<div
|
|
key={key}
|
|
className="flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--gem-surface-2)] transition-colors cursor-pointer"
|
|
>
|
|
<span
|
|
className={cn(
|
|
"text-xs px-2 py-0.5 rounded-full",
|
|
color
|
|
)}
|
|
>
|
|
{label}
|
|
</span>
|
|
<span className="flex-1 text-sm text-[var(--gem-text)]">
|
|
{summary.by_status[key]} 条
|
|
</span>
|
|
</div>
|
|
))}
|
|
{priorityEntries.map(({ key, color }) => (
|
|
<div
|
|
key={key}
|
|
className="flex items-center gap-3 px-4 py-2.5 hover:bg-[var(--gem-surface-2)] transition-colors cursor-pointer"
|
|
>
|
|
<span className={cn("text-xs font-mono", color)}>
|
|
{key}
|
|
</span>
|
|
<span className="flex-1 text-sm text-[var(--gem-text)]">
|
|
{summary.by_priority[key]} 条
|
|
</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|