287 lines
9.2 KiB
TypeScript
287 lines
9.2 KiB
TypeScript
'use client'
|
|
|
|
import { useState, useEffect } from 'react'
|
|
import { useTranslations } from 'next-intl'
|
|
import { Button } from '@/components/ui/button'
|
|
import { LoadingSpinner } from '@/components/ui/loading-spinner'
|
|
import {
|
|
History,
|
|
Clock,
|
|
GitBranch,
|
|
RotateCcw,
|
|
Eye,
|
|
GitCompare,
|
|
ChevronDown,
|
|
ChevronRight
|
|
} from 'lucide-react'
|
|
|
|
interface PromptVersion {
|
|
id: string
|
|
version: number
|
|
content: string
|
|
changelog: string
|
|
createdAt: string
|
|
}
|
|
|
|
interface VersionHistoryProps {
|
|
promptId: string
|
|
userId: string
|
|
onVersionRestore?: (version: PromptVersion) => void
|
|
onVersionSelect?: (version: PromptVersion) => void
|
|
}
|
|
|
|
export function VersionHistory({
|
|
promptId,
|
|
userId,
|
|
onVersionRestore,
|
|
onVersionSelect
|
|
}: VersionHistoryProps) {
|
|
const t = useTranslations('studio')
|
|
const [versions, setVersions] = useState<PromptVersion[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [expandedVersions, setExpandedVersions] = useState<Set<string>>(new Set())
|
|
const [selectedVersions, setSelectedVersions] = useState<string[]>([])
|
|
const [restoring, setRestoring] = useState<string | null>(null)
|
|
|
|
useEffect(() => {
|
|
fetchVersions()
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [promptId, userId])
|
|
|
|
const fetchVersions = async () => {
|
|
try {
|
|
setLoading(true)
|
|
const response = await fetch(`/api/prompts/${promptId}/versions?userId=${userId}`)
|
|
if (response.ok) {
|
|
const data = await response.json()
|
|
setVersions(data)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error fetching versions:', error)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
const handleRestoreVersion = async (version: PromptVersion) => {
|
|
if (restoring) return
|
|
|
|
try {
|
|
setRestoring(version.id)
|
|
const response = await fetch(
|
|
`/api/prompts/${promptId}/versions/${version.id}/restore`,
|
|
{
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ userId })
|
|
}
|
|
)
|
|
|
|
if (response.ok) {
|
|
await fetchVersions() // 刷新版本列表
|
|
onVersionRestore?.(version)
|
|
}
|
|
} catch (error) {
|
|
console.error('Error restoring version:', error)
|
|
} finally {
|
|
setRestoring(null)
|
|
}
|
|
}
|
|
|
|
const toggleVersionExpansion = (versionId: string) => {
|
|
const newExpanded = new Set(expandedVersions)
|
|
if (newExpanded.has(versionId)) {
|
|
newExpanded.delete(versionId)
|
|
} else {
|
|
newExpanded.add(versionId)
|
|
}
|
|
setExpandedVersions(newExpanded)
|
|
}
|
|
|
|
const handleVersionSelect = (versionId: string) => {
|
|
if (selectedVersions.includes(versionId)) {
|
|
setSelectedVersions(prev => prev.filter(id => id !== versionId))
|
|
} else if (selectedVersions.length < 2) {
|
|
setSelectedVersions(prev => [...prev, versionId])
|
|
} else {
|
|
// 替换第一个选中的版本
|
|
setSelectedVersions([selectedVersions[1], versionId])
|
|
}
|
|
}
|
|
|
|
const handleCompareVersions = () => {
|
|
if (selectedVersions.length === 2) {
|
|
const [fromId, toId] = selectedVersions
|
|
window.open(
|
|
`/studio/${promptId}/compare?from=${fromId}&to=${toId}`,
|
|
'_blank'
|
|
)
|
|
}
|
|
}
|
|
|
|
const formatDate = (dateString: string) => {
|
|
return new Intl.DateTimeFormat('default', {
|
|
year: 'numeric',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
hour: '2-digit',
|
|
minute: '2-digit'
|
|
}).format(new Date(dateString))
|
|
}
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="flex items-center justify-center p-8">
|
|
<LoadingSpinner size="lg" />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
{/* Header */}
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-2">
|
|
<History className="h-5 w-5 text-muted-foreground" />
|
|
<h3 className="font-semibold text-foreground">{t('versionHistory')}</h3>
|
|
<span className="text-sm text-muted-foreground">
|
|
({versions.length} versions)
|
|
</span>
|
|
</div>
|
|
|
|
{selectedVersions.length === 2 && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={handleCompareVersions}
|
|
className="flex items-center space-x-1"
|
|
>
|
|
<GitCompare className="h-4 w-4" />
|
|
<span>Compare</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
|
|
{/* Version List */}
|
|
<div className="space-y-2">
|
|
{versions.map((version, index) => {
|
|
const isExpanded = expandedVersions.has(version.id)
|
|
const isSelected = selectedVersions.includes(version.id)
|
|
const isLatest = index === 0
|
|
|
|
return (
|
|
<div
|
|
key={version.id}
|
|
className={`border border-border rounded-lg transition-colors ${
|
|
isSelected ? 'ring-2 ring-primary' : ''
|
|
}`}
|
|
>
|
|
<div className="p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center space-x-3">
|
|
<input
|
|
type="checkbox"
|
|
checked={isSelected}
|
|
onChange={() => handleVersionSelect(version.id)}
|
|
className="rounded"
|
|
disabled={selectedVersions.length >= 2 && !isSelected}
|
|
/>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<GitBranch className="h-4 w-4 text-muted-foreground" />
|
|
<span className="font-medium text-foreground">
|
|
Version {version.version}
|
|
</span>
|
|
{isLatest && (
|
|
<span className="inline-flex items-center px-2 py-1 text-xs font-medium bg-primary/10 text-primary rounded-full">
|
|
Current
|
|
</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center space-x-2">
|
|
<div className="flex items-center space-x-1 text-sm text-muted-foreground">
|
|
<Clock className="h-3 w-3" />
|
|
<span className="hidden sm:inline">{formatDate(version.createdAt)}</span>
|
|
<span className="sm:hidden">{new Date(version.createdAt).toLocaleDateString()}</span>
|
|
</div>
|
|
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
onClick={() => toggleVersionExpansion(version.id)}
|
|
className="h-8 w-8 p-0"
|
|
>
|
|
{isExpanded ? (
|
|
<ChevronDown className="h-4 w-4" />
|
|
) : (
|
|
<ChevronRight className="h-4 w-4" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Changelog */}
|
|
<div className="mt-2">
|
|
<p className="text-sm text-muted-foreground">
|
|
{version.changelog}
|
|
</p>
|
|
</div>
|
|
|
|
{/* Expanded Content */}
|
|
{isExpanded && (
|
|
<div className="mt-4 pt-4 border-t border-border">
|
|
<div className="bg-muted rounded-lg p-3">
|
|
<pre className="text-sm text-foreground whitespace-pre-wrap font-mono">
|
|
{version.content.substring(0, 200)}
|
|
{version.content.length > 200 && '...'}
|
|
</pre>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between mt-3">
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => onVersionSelect?.(version)}
|
|
className="flex items-center space-x-1"
|
|
>
|
|
<Eye className="h-3 w-3" />
|
|
<span>View Full</span>
|
|
</Button>
|
|
|
|
{!isLatest && (
|
|
<Button
|
|
variant="outline"
|
|
size="sm"
|
|
onClick={() => handleRestoreVersion(version)}
|
|
disabled={restoring === version.id}
|
|
className="flex items-center space-x-1"
|
|
>
|
|
{restoring === version.id ? (
|
|
<LoadingSpinner size="sm" />
|
|
) : (
|
|
<RotateCcw className="h-3 w-3" />
|
|
)}
|
|
<span>Restore</span>
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{versions.length === 0 && (
|
|
<div className="text-center py-8">
|
|
<History className="h-12 w-12 text-muted-foreground mx-auto mb-4" />
|
|
<p className="text-muted-foreground">No version history available</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|