243 lines
9.6 KiB
TypeScript
243 lines
9.6 KiB
TypeScript
import { cn } from '@/lib/utils';
|
||
import type { WritingStatus } from '../../types/ai-writing';
|
||
|
||
interface WritingSkeletonCardProps {
|
||
status: WritingStatus;
|
||
/** 素材收集阶段的实时进度文案(仅 researching 阶段使用) */
|
||
researchProgress?: string;
|
||
/** 用户自带大纲时为 true:大纲阶段文案显示「整理你的大纲」而非「规划大纲」。 */
|
||
organizing?: boolean;
|
||
}
|
||
|
||
// ── 基础骨架元素 ──────────────────────────────────────────────────────────────
|
||
|
||
function SkeletonLine({ className }: { className?: string }) {
|
||
return (
|
||
<div className={cn('h-3 rounded-md bg-muted animate-pulse', className)} />
|
||
);
|
||
}
|
||
|
||
function ThinkingDots({ color = 'bg-blue-400' }: { color?: string }) {
|
||
return (
|
||
<span className="inline-flex gap-1 items-center">
|
||
{[0, 1, 2].map((i) => (
|
||
<span
|
||
key={i}
|
||
className={cn('size-1.5 rounded-full animate-bounce', color)}
|
||
style={{ animationDelay: `${i * 150}ms` }}
|
||
/>
|
||
))}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
// ── 素材收集骨架屏 ────────────────────────────────────────────────────────────
|
||
|
||
function MaterialsSkeleton({ researchProgress }: { researchProgress?: string }) {
|
||
return (
|
||
<div className="space-y-3 p-4">
|
||
{/* 标题行:展示素材收集专家的实时进度 */}
|
||
<div className="flex items-center gap-2 text-xs text-blue-500 font-medium">
|
||
<ThinkingDots color="bg-blue-400" />
|
||
<span>{researchProgress || '素材收集专家搜索中…'}</span>
|
||
</div>
|
||
|
||
{/* 关键词标签占位 */}
|
||
<div className="flex gap-1.5 flex-wrap">
|
||
{[60, 80, 50].map((w, i) => (
|
||
<div
|
||
key={i}
|
||
className="h-5 rounded-full bg-blue-100/60 dark:bg-blue-900/30 animate-pulse"
|
||
style={{ width: `${w}px` }}
|
||
/>
|
||
))}
|
||
</div>
|
||
|
||
{/* 素材卡片占位 */}
|
||
{[0, 1, 2, 3].map((i) => (
|
||
<div key={i} className="rounded-lg border bg-muted/30 p-3 space-y-2">
|
||
{/* 标题 */}
|
||
<div className="flex items-start gap-2">
|
||
<SkeletonLine className={cn('flex-1 h-3.5', i % 3 === 0 ? 'w-4/5' : i % 3 === 1 ? 'w-3/4' : 'w-2/3')} />
|
||
</div>
|
||
{/* 来源 + 时间 + 相关度 */}
|
||
<div className="flex items-center gap-2">
|
||
<SkeletonLine className="w-20 h-2" />
|
||
<SkeletonLine className="w-16 h-2" />
|
||
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-blue-200/60 dark:bg-blue-700/40 animate-pulse"
|
||
style={{ width: `${55 + i * 8}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
{/* 摘要行 */}
|
||
<SkeletonLine className="w-full h-2" />
|
||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-5/6' : 'w-4/5')} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 大纲规划骨架屏 ────────────────────────────────────────────────────────────
|
||
|
||
function OutlineSkeleton({ organizing }: { organizing?: boolean } = {}) {
|
||
return (
|
||
<div className="space-y-3 p-4">
|
||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||
<ThinkingDots color="bg-purple-400" />
|
||
<span>{organizing ? '作家整理你的大纲中…' : '作家规划大纲中…'}</span>
|
||
</div>
|
||
|
||
{/* 文章标题占位 */}
|
||
<SkeletonLine className="w-3/5 h-4 mx-auto" />
|
||
|
||
{/* 章节列表 */}
|
||
{[0, 1, 2, 3].map((i) => (
|
||
<div key={i} className="flex items-start gap-2 pl-1">
|
||
<div className="mt-1 size-2 shrink-0 rounded-sm bg-purple-200/70 dark:bg-purple-800/40 animate-pulse" />
|
||
<div className="flex-1 space-y-1.5">
|
||
<SkeletonLine className={cn('h-3', i % 2 === 0 ? 'w-2/5' : 'w-1/2')} />
|
||
<SkeletonLine className="w-3/5 h-2" />
|
||
{i % 2 === 0 && <SkeletonLine className="w-2/5 h-2" />}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 正文写作骨架屏 ────────────────────────────────────────────────────────────
|
||
|
||
function DraftSkeleton() {
|
||
return (
|
||
<div className="space-y-4 p-4">
|
||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||
<ThinkingDots color="bg-purple-400" />
|
||
<span>作家写作中…</span>
|
||
</div>
|
||
|
||
{/* 标题占位 */}
|
||
<SkeletonLine className="w-2/5 h-5 mx-auto" />
|
||
|
||
{/* 章节内容 */}
|
||
{[0, 1, 2].map((i) => (
|
||
<div key={i} className="space-y-1.5">
|
||
<SkeletonLine className="w-1/4 h-3.5" />
|
||
<SkeletonLine className="w-full h-2" />
|
||
<SkeletonLine className="w-full h-2" />
|
||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-11/12')} />
|
||
<SkeletonLine className="w-full h-2" />
|
||
<SkeletonLine className="w-3/4 h-2" />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 编辑审核骨架屏 ────────────────────────────────────────────────────────────
|
||
|
||
function ReviewSkeleton() {
|
||
return (
|
||
<div className="space-y-3 p-4">
|
||
<div className="flex items-center gap-2 text-xs text-orange-500 font-medium">
|
||
<ThinkingDots color="bg-orange-400" />
|
||
<span>编辑审核中…</span>
|
||
</div>
|
||
|
||
{/* 综合评分占位 */}
|
||
<div className="flex items-center justify-between">
|
||
<SkeletonLine className="w-24 h-3" />
|
||
<SkeletonLine className="w-12 h-4" />
|
||
</div>
|
||
|
||
{/* 分项评分条 */}
|
||
{['事实准确度', '逻辑结构', '语言质量'].map((label, i) => (
|
||
<div key={label} className="space-y-1">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-xs text-muted-foreground">{label}</span>
|
||
<SkeletonLine className="w-8 h-3" />
|
||
</div>
|
||
<div className="h-1.5 rounded-full bg-muted overflow-hidden">
|
||
<div
|
||
className="h-full rounded-full bg-orange-200/60 dark:bg-orange-700/40 animate-pulse"
|
||
style={{ width: `${55 + i * 10}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
{/* 意见条目占位 */}
|
||
<div className="space-y-2 pt-1 border-t">
|
||
<SkeletonLine className="w-full h-2" />
|
||
<SkeletonLine className="w-5/6 h-2" />
|
||
<SkeletonLine className="w-full h-2" />
|
||
<SkeletonLine className="w-4/5 h-2" />
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 按意见修改骨架屏 ──────────────────────────────────────────────────────────
|
||
|
||
function RevisingSkeleton() {
|
||
return (
|
||
<div className="space-y-4 p-4">
|
||
<div className="flex items-center gap-2 text-xs text-purple-500 font-medium">
|
||
<ThinkingDots color="bg-purple-400" />
|
||
<span>作家按意见修改中…</span>
|
||
</div>
|
||
<SkeletonLine className="w-2/5 h-5 mx-auto" />
|
||
{[0, 1, 2].map((i) => (
|
||
<div key={i} className="space-y-1.5">
|
||
<SkeletonLine className="w-1/4 h-3.5" />
|
||
<SkeletonLine className="w-full h-2" />
|
||
<SkeletonLine className={cn('h-2', i % 2 === 0 ? 'w-4/5' : 'w-full')} />
|
||
</div>
|
||
))}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// ── 状态映射 ──────────────────────────────────────────────────────────────────
|
||
|
||
// researching / awaiting_material_confirm 由 WritingSkeletonCard 单独处理(需透传进度文案)
|
||
const SKELETON_MAP: Partial<Record<WritingStatus, React.FC>> = {
|
||
// 实际运行状态
|
||
writing_outline: OutlineSkeleton,
|
||
writing_draft: DraftSkeleton,
|
||
reviewing: ReviewSkeleton,
|
||
revising: RevisingSkeleton,
|
||
// 过渡等待状态(用户提交后、后端事件到达前的空白期)
|
||
awaiting_outline_confirm: OutlineSkeleton, // 过渡到写作
|
||
awaiting_draft_confirm: DraftSkeleton, // 提交修改意见或编辑审核后
|
||
awaiting_review_confirm: RevisingSkeleton, // 采纳编辑意见后
|
||
};
|
||
|
||
export function WritingSkeletonCard({ status, researchProgress, organizing }: WritingSkeletonCardProps) {
|
||
// 素材收集阶段单独渲染,以便透传实时进度文案
|
||
if (status === 'analyzing_sample' || status === 'researching' || status === 'awaiting_material_confirm') {
|
||
return (
|
||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||
<MaterialsSkeleton researchProgress={status === 'analyzing_sample' ? '正在分析样文风格…' : researchProgress} />
|
||
</div>
|
||
);
|
||
}
|
||
// 大纲阶段单独渲染,以便透传「整理 / 规划」文案(用户自带大纲走「整理」)
|
||
if (status === 'writing_outline' || status === 'awaiting_outline_confirm') {
|
||
return (
|
||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||
<OutlineSkeleton organizing={organizing} />
|
||
</div>
|
||
);
|
||
}
|
||
const Skeleton = SKELETON_MAP[status];
|
||
if (!Skeleton) return null;
|
||
return (
|
||
<div className="rounded-xl border bg-card shadow-sm overflow-hidden">
|
||
<Skeleton />
|
||
</div>
|
||
);
|
||
}
|