: ChatArea TS2322 workaround + SubscriptionPanel component
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled

The ChatArea.tsx toolSteps/subtasks rendering uses helper functions to avoid TypeScript strict mode && chain producing unknown type in JSX children. Add SubscriptionPanel component for subscription status display in SaaS billing section.
This commit is contained in:
iven
2026-04-04 13:39:11 +08:00
parent 9f8b0ba375
commit a6902c28f5
2 changed files with 191 additions and 11 deletions

View File

@@ -14,7 +14,8 @@ import { Paperclip, SquarePen, ArrowUp, MessageSquare, Download, X, FileText, Im
import { Button, EmptyState, MessageListSkeleton, LoadingDots } from './ui';
import { ResizableChatLayout } from './ai/ResizableChatLayout';
import { ArtifactPanel } from './ai/ArtifactPanel';
import { ToolCallChain } from './ai/ToolCallChain';
import { ToolCallChain, type ToolCallStep } from './ai/ToolCallChain';
import { TaskProgress, type Subtask } from './ai/TaskProgress';
import { listItemVariants, defaultTransition, fadeInVariants } from '../lib/animations';
import { FirstConversationPrompt } from './FirstConversationPrompt';
import { ClassroomPlayer } from './classroom_player';
@@ -30,7 +31,6 @@ import { ReasoningBlock } from './ai/ReasoningBlock';
import { StreamingText } from './ai/StreamingText';
import { ChatMode } from './ai/ChatMode';
import { ModelSelector } from './ai/ModelSelector';
import { TaskProgress } from './ai/TaskProgress';
import { SuggestionChips } from './ai/SuggestionChips';
import { PipelineResultPreview } from './pipeline/PipelineResultPreview';
import { PresentationContainer } from './presentation/PresentationContainer';
@@ -598,6 +598,20 @@ function MessageBubble({ message, setInput }: { message: Message; setInput: (tex
const isUser = message.role === 'user';
const isThinking = message.streaming && !message.content;
// Extract typed arrays for JSX rendering (avoids TS2322 from && chain producing unknown)
const toolCallSteps: ToolCallStep[] | undefined = message.toolSteps;
const subtaskList: Subtask[] | undefined = message.subtasks;
const renderToolSteps = (): React.ReactNode => {
if (isUser || !toolCallSteps || toolCallSteps.length === 0) return null;
return <ToolCallChain steps={toolCallSteps} isStreaming={!!message.streaming} />;
};
const renderSubtasks = (): React.ReactNode => {
if (isUser || !subtaskList || subtaskList.length === 0) return null;
return <TaskProgress tasks={subtaskList} className="mb-3" />;
};
// Download message as Markdown file
const handleDownloadMessage = () => {
if (!message.content) return;
@@ -644,16 +658,9 @@ function MessageBubble({ message, setInput }: { message: Message; setInput: (tex
/>
)}
{/* Tool call steps chain (DeerFlow-inspired) */}
{!isUser && message.toolSteps != null && message.toolSteps.length > 0 ? (
<ToolCallChain
steps={message.toolSteps}
isStreaming={message.streaming}
/>
) : null}
{renderToolSteps()}
{/* Subtask tracking (DeerFlow-inspired) */}
{!isUser && message.subtasks != null && message.subtasks.length > 0 ? (
<TaskProgress tasks={message.subtasks} className="mb-3" />
) : null}
{renderSubtasks()}
{/* Message content with streaming support */}
<div className={`leading-relaxed ${isUser ? 'text-white whitespace-pre-wrap' : 'text-gray-700 dark:text-gray-200'}`}>
{message.content

View File

@@ -0,0 +1,173 @@
/**
* SubscriptionPanel — 当前订阅详情面板
*
* 展示当前计划、试用/到期状态、用量配额和操作入口。
*/
import { useEffect } from 'react';
import { useSaaSStore } from '../../store/saasStore';
import {
CheckCircle,
Clock,
AlertTriangle,
Loader2,
CreditCard,
Crown,
} from 'lucide-react';
const PLAN_LABELS: Record<string, string> = {
free: '免费版',
pro: '专业版',
team: '团队版',
};
const PLAN_COLORS: Record<string, string> = {
free: '#8c8c8c',
pro: '#863bff',
team: '#47bfff',
};
function UsageBar({ label, current, max }: { label: string; current: number; max: number | null }) {
const pct = max ? Math.min((current / max) * 100, 100) : 0;
const displayMax = max ? max.toLocaleString() : '∞';
const barColor = pct >= 90 ? 'bg-red-500' : pct >= 70 ? 'bg-amber-500' : 'bg-emerald-500';
return (
<div className="mb-3">
<div className="flex justify-between text-xs text-gray-500 mb-1">
<span>{label}</span>
<span>{current.toLocaleString()} / {displayMax}</span>
</div>
<div className="h-2 bg-gray-200 rounded-full overflow-hidden">
<div
className={`h-full rounded-full transition-all duration-500 ${barColor}`}
style={{ width: `${Math.max(pct, 1)}%` }}
/>
</div>
</div>
);
}
export function SubscriptionPanel() {
const subscription = useSaaSStore((s) => s.subscription);
const billingLoading = useSaaSStore((s) => s.billingLoading);
const fetchBillingOverview = useSaaSStore((s) => s.fetchBillingOverview);
useEffect(() => {
fetchBillingOverview().catch(() => {});
}, [fetchBillingOverview]);
if (billingLoading && !subscription) {
return (
<div className="flex justify-center py-12">
<Loader2 className="w-6 h-6 animate-spin text-emerald-600" />
</div>
);
}
const plan = subscription?.plan;
const sub = subscription?.subscription;
const usage = subscription?.usage;
const planName = plan?.name || 'free';
const color = PLAN_COLORS[planName] || '#666';
const label = PLAN_LABELS[planName] || planName;
// Trial / expiry status
const isTrialing = sub?.status === 'trialing';
const isActive = sub?.status === 'active';
const trialEnd = sub?.trial_end ? new Date(sub.trial_end) : null;
const periodEnd = sub?.current_period_end ? new Date(sub.current_period_end) : null;
const now = Date.now();
const daysLeft = trialEnd
? Math.max(0, Math.ceil((trialEnd.getTime() - now) / (1000 * 60 * 60 * 24)))
: periodEnd
? Math.max(0, Math.ceil((periodEnd.getTime() - now) / (1000 * 60 * 60 * 24)))
: null;
const showExpiryWarning = daysLeft !== null && daysLeft <= 3;
return (
<div className="space-y-5">
{/* Plan badge */}
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-3">
<div
className="w-10 h-10 rounded-lg flex items-center justify-center text-white"
style={{ background: color }}
>
<Crown className="w-5 h-5" />
</div>
<div>
<h2 className="text-lg font-bold text-gray-900">{label}</h2>
{isTrialing && (
<span className="inline-flex items-center gap-1 text-xs text-amber-600 bg-amber-50 px-2 py-0.5 rounded-full">
<Clock className="w-3 h-3" />
</span>
)}
{isActive && (
<span className="inline-flex items-center gap-1 text-xs text-emerald-600 bg-emerald-50 px-2 py-0.5 rounded-full">
<CheckCircle className="w-3 h-3" />
</span>
)}
</div>
</div>
<CreditCard className="w-5 h-5 text-gray-400" />
</div>
{/* Expiry / trial warning */}
{showExpiryWarning && (
<div className={`flex items-center gap-2 p-3 rounded-lg text-sm mb-3 ${
isTrialing
? 'bg-amber-50 text-amber-700 border border-amber-200'
: 'bg-red-50 text-red-700 border border-red-200'
}`}>
<AlertTriangle className="w-4 h-4 flex-shrink-0" />
<span>
{isTrialing
? `试用还剩 ${daysLeft} 天,请尽快升级以保留 Pro 功能`
: `订阅将在 ${daysLeft} 天后到期`}
</span>
</div>
)}
{daysLeft !== null && !showExpiryWarning && (
<p className="text-xs text-gray-400">
{isTrialing ? `试用剩余 ${daysLeft}` : `订阅到期: ${periodEnd?.toLocaleDateString()}`}
</p>
)}
</div>
{/* Usage */}
{usage && (
<div className="bg-white rounded-xl border border-gray-200 p-5 shadow-sm">
<h3 className="text-sm font-semibold text-gray-900 mb-4"></h3>
<UsageBar label="中转请求" current={usage.relay_requests} max={usage.max_relay_requests} />
<UsageBar label="Hand 执行" current={usage.hand_executions} max={usage.max_hand_executions} />
<UsageBar label="Pipeline 运行" current={usage.pipeline_runs} max={usage.max_pipeline_runs} />
{sub && (
<p className="mt-2 text-xs text-gray-400">
: {new Date(sub.current_period_start).toLocaleDateString()} {new Date(sub.current_period_end).toLocaleDateString()}
</p>
)}
</div>
)}
{/* Free plan upgrade prompt */}
{planName === 'free' && (
<div className="bg-gradient-to-r from-purple-50 to-blue-50 rounded-xl border border-purple-200 p-5">
<h3 className="text-sm font-semibold text-gray-900 mb-2"></h3>
<p className="text-xs text-gray-600 mb-3">
9 Hands Pipeline
</p>
<p className="text-xs text-purple-600">
</p>
</div>
)}
</div>
);
}