finished version
This commit is contained in:
parent
4180300ab1
commit
93f38ab664
@ -132,13 +132,12 @@ Required environment variables:
|
|||||||
- [ ] Published
|
- [ ] Published
|
||||||
- [ ] View Count
|
- [ ] View Count
|
||||||
- [ ] Prompt Debugger
|
- [ ] Prompt Debugger
|
||||||
- [ ] Main Version
|
|
||||||
|
|
||||||
- [x] Prompt Version Controll
|
- [x] Prompt Version Controll
|
||||||
- [x] Generate a new version when save
|
- [x] Generate a new version when save
|
||||||
- [x] Save last [LIMIT] versions
|
- [x] Save last [LIMIT] versions
|
||||||
- [ ] [LIMIT] can setting in user profile
|
- [ ] [LIMIT] can setting in use profile
|
||||||
- [ ] [LIMIT] max is by Subscribe
|
- [ ] [LIMIT] max is by Subscribe
|
||||||
|
- [ ] Delete Version Button
|
||||||
- [x] Prompt Debugger run
|
- [x] Prompt Debugger run
|
||||||
- [x] Select AI Model
|
- [x] Select AI Model
|
||||||
- [x] Input Prompt Content
|
- [x] Input Prompt Content
|
||||||
|
@ -14,14 +14,17 @@ datasource db {
|
|||||||
}
|
}
|
||||||
|
|
||||||
model User {
|
model User {
|
||||||
id String @id // 使用Supabase用户ID,不再自动生成
|
id String @id // 使用Supabase用户ID,不再自动生成
|
||||||
email String @unique
|
email String @unique
|
||||||
username String? @unique // 允许为空,因为有些用户可能没有设置用户名
|
username String? @unique // 允许为空,因为有些用户可能没有设置用户名
|
||||||
avatar String?
|
avatar String?
|
||||||
bio String?
|
bio String?
|
||||||
language String @default("en")
|
language String @default("en")
|
||||||
createdAt DateTime @default(now())
|
versionLimit Int @default(3) // 版本数量限制,可在用户配置中设置
|
||||||
updatedAt DateTime @updatedAt
|
subscribePlan String @default("free") // 订阅计划: "free", "pro"
|
||||||
|
maxVersionLimit Int @default(3) // 基于订阅的最大版本限制
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
|
||||||
prompts Prompt[]
|
prompts Prompt[]
|
||||||
|
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getVersionsToDelete } from '@/lib/subscription'
|
||||||
|
|
||||||
interface RouteParams {
|
interface RouteParams {
|
||||||
params: Promise<{ id: string }>
|
params: Promise<{ id: string }>
|
||||||
@ -67,10 +68,13 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
|||||||
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
|
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证 prompt 是否存在且属于用户
|
// 验证 prompt 是否存在且属于用户,并获取用户信息
|
||||||
const existingPrompt = await prisma.prompt.findFirst({
|
const existingPrompt = await prisma.prompt.findFirst({
|
||||||
where: { id, userId },
|
where: { id, userId },
|
||||||
include: { versions: { orderBy: { version: 'desc' }, take: 1 } }
|
include: {
|
||||||
|
versions: { orderBy: { version: 'desc' } },
|
||||||
|
user: true
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (!existingPrompt) {
|
if (!existingPrompt) {
|
||||||
@ -96,6 +100,9 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
|||||||
|
|
||||||
if (content && currentVersion && content !== currentVersion.content) {
|
if (content && currentVersion && content !== currentVersion.content) {
|
||||||
shouldCreateVersion = true
|
shouldCreateVersion = true
|
||||||
|
} else if (content && !currentVersion) {
|
||||||
|
// 如果没有版本历史,创建第一个版本
|
||||||
|
shouldCreateVersion = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新 prompt
|
// 更新 prompt
|
||||||
@ -118,8 +125,27 @@ export async function PUT(request: NextRequest, { params }: RouteParams) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
// 如果内容有变化,创建新版本
|
// 如果内容有变化,创建新版本并处理版本限制
|
||||||
if (shouldCreateVersion && content) {
|
if (shouldCreateVersion && content) {
|
||||||
|
const user = existingPrompt.user
|
||||||
|
const currentVersionCount = existingPrompt.versions.length
|
||||||
|
|
||||||
|
// 检查是否需要删除旧版本以遵守版本限制
|
||||||
|
const versionsToDelete = getVersionsToDelete(currentVersionCount, user)
|
||||||
|
|
||||||
|
if (versionsToDelete > 0) {
|
||||||
|
// 删除最旧的版本
|
||||||
|
const oldestVersions = existingPrompt.versions
|
||||||
|
.slice(-versionsToDelete)
|
||||||
|
.map(v => v.id)
|
||||||
|
|
||||||
|
await prisma.promptVersion.deleteMany({
|
||||||
|
where: {
|
||||||
|
id: { in: oldestVersions }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const nextVersion = (currentVersion?.version || 0) + 1
|
const nextVersion = (currentVersion?.version || 0) + 1
|
||||||
await prisma.promptVersion.create({
|
await prisma.promptVersion.create({
|
||||||
data: {
|
data: {
|
||||||
|
124
src/app/api/users/profile/route.ts
Normal file
124
src/app/api/users/profile/route.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server'
|
||||||
|
import { prisma } from '@/lib/prisma'
|
||||||
|
import { getMaxVersionLimit } from '@/lib/subscription'
|
||||||
|
|
||||||
|
// GET /api/users/profile - 获取用户配置
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(request.url)
|
||||||
|
const userId = searchParams.get('userId')
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
bio: true,
|
||||||
|
language: true,
|
||||||
|
versionLimit: true,
|
||||||
|
subscribePlan: true,
|
||||||
|
maxVersionLimit: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(user)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user profile:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to fetch user profile' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// PUT /api/users/profile - 更新用户配置
|
||||||
|
export async function PUT(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const body = await request.json()
|
||||||
|
const {
|
||||||
|
userId,
|
||||||
|
username,
|
||||||
|
avatar,
|
||||||
|
bio,
|
||||||
|
language,
|
||||||
|
versionLimit
|
||||||
|
} = body
|
||||||
|
|
||||||
|
if (!userId) {
|
||||||
|
return NextResponse.json({ error: 'User ID is required' }, { status: 401 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户当前信息以验证版本限制
|
||||||
|
const currentUser = await prisma.user.findUnique({
|
||||||
|
where: { id: userId },
|
||||||
|
select: { subscribePlan: true, maxVersionLimit: true }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!currentUser) {
|
||||||
|
return NextResponse.json({ error: 'User not found' }, { status: 404 })
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证版本限制不能超过订阅计划的最大限制
|
||||||
|
let finalVersionLimit = versionLimit
|
||||||
|
if (versionLimit !== undefined) {
|
||||||
|
const maxVersionLimit = getMaxVersionLimit(currentUser.subscribePlan)
|
||||||
|
if (versionLimit > maxVersionLimit) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
error: 'Version limit exceeds subscription plan maximum',
|
||||||
|
maxAllowed: maxVersionLimit
|
||||||
|
},
|
||||||
|
{ status: 400 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
finalVersionLimit = Math.max(1, Math.min(versionLimit, maxVersionLimit))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateData: Record<string, unknown> = {}
|
||||||
|
if (username !== undefined) updateData.username = username
|
||||||
|
if (avatar !== undefined) updateData.avatar = avatar
|
||||||
|
if (bio !== undefined) updateData.bio = bio
|
||||||
|
if (language !== undefined) updateData.language = language
|
||||||
|
if (finalVersionLimit !== undefined) updateData.versionLimit = finalVersionLimit
|
||||||
|
|
||||||
|
const updatedUser = await prisma.user.update({
|
||||||
|
where: { id: userId },
|
||||||
|
data: updateData,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
email: true,
|
||||||
|
username: true,
|
||||||
|
avatar: true,
|
||||||
|
bio: true,
|
||||||
|
language: true,
|
||||||
|
versionLimit: true,
|
||||||
|
subscribePlan: true,
|
||||||
|
maxVersionLimit: true,
|
||||||
|
createdAt: true,
|
||||||
|
updatedAt: true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return NextResponse.json(updatedUser)
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error updating user profile:', error)
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: 'Failed to update user profile' },
|
||||||
|
{ status: 500 }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
@ -19,8 +19,13 @@ interface UserProfile {
|
|||||||
email: string
|
email: string
|
||||||
username?: string
|
username?: string
|
||||||
bio?: string
|
bio?: string
|
||||||
avatar_url?: string
|
avatar?: string
|
||||||
language?: 'en' | 'zh'
|
language?: 'en' | 'zh'
|
||||||
|
versionLimit: number
|
||||||
|
subscribePlan: string
|
||||||
|
maxVersionLimit: number
|
||||||
|
createdAt?: string
|
||||||
|
updatedAt?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function ProfilePage() {
|
export default function ProfilePage() {
|
||||||
@ -33,7 +38,8 @@ export default function ProfilePage() {
|
|||||||
username: false,
|
username: false,
|
||||||
email: false,
|
email: false,
|
||||||
bio: false,
|
bio: false,
|
||||||
password: false
|
password: false,
|
||||||
|
versionLimit: false
|
||||||
})
|
})
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
username: '',
|
username: '',
|
||||||
@ -42,7 +48,8 @@ export default function ProfilePage() {
|
|||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
language: 'en' as 'en' | 'zh'
|
language: 'en' as 'en' | 'zh',
|
||||||
|
versionLimit: 3
|
||||||
})
|
})
|
||||||
const [showPasswords, setShowPasswords] = useState({
|
const [showPasswords, setShowPasswords] = useState({
|
||||||
current: false,
|
current: false,
|
||||||
@ -58,22 +65,18 @@ export default function ProfilePage() {
|
|||||||
const supabase = createClient()
|
const supabase = createClient()
|
||||||
|
|
||||||
const loadProfile = useCallback(async () => {
|
const loadProfile = useCallback(async () => {
|
||||||
|
if (!user) return
|
||||||
|
|
||||||
setProfileLoading(true)
|
setProfileLoading(true)
|
||||||
try {
|
try {
|
||||||
// Get user metadata and profile data
|
// Get profile data from our API
|
||||||
const { data: { user: userData }, error: userError } = await supabase.auth.getUser()
|
const response = await fetch(`/api/users/profile?userId=${user.id}`)
|
||||||
|
if (!response.ok) {
|
||||||
if (userError) throw userError
|
throw new Error('Failed to fetch profile')
|
||||||
|
|
||||||
const profileData: UserProfile = {
|
|
||||||
id: userData?.id || '',
|
|
||||||
email: userData?.email || '',
|
|
||||||
username: userData?.user_metadata?.username || userData?.user_metadata?.full_name || '',
|
|
||||||
bio: userData?.user_metadata?.bio || '',
|
|
||||||
avatar_url: userData?.user_metadata?.avatar_url || '',
|
|
||||||
language: userData?.user_metadata?.language || 'en'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const profileData: UserProfile = await response.json()
|
||||||
|
|
||||||
setProfile(profileData)
|
setProfile(profileData)
|
||||||
setFormData({
|
setFormData({
|
||||||
username: profileData.username || '',
|
username: profileData.username || '',
|
||||||
@ -82,7 +85,8 @@ export default function ProfilePage() {
|
|||||||
currentPassword: '',
|
currentPassword: '',
|
||||||
newPassword: '',
|
newPassword: '',
|
||||||
confirmPassword: '',
|
confirmPassword: '',
|
||||||
language: profileData.language || 'en'
|
language: profileData.language || 'en',
|
||||||
|
versionLimit: profileData.versionLimit
|
||||||
})
|
})
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Error loading profile:', error)
|
console.error('Error loading profile:', error)
|
||||||
@ -90,14 +94,14 @@ export default function ProfilePage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setProfileLoading(false)
|
setProfileLoading(false)
|
||||||
}
|
}
|
||||||
}, [supabase, t])
|
}, [user, t])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
loadProfile()
|
loadProfile()
|
||||||
}, [user, loadProfile])
|
}, [user, loadProfile])
|
||||||
|
|
||||||
const updateProfile = async (field: string, value: string) => {
|
const updateProfile = async (field: string, value: string | number) => {
|
||||||
if (!user) return
|
if (!user) return
|
||||||
|
|
||||||
setFieldLoading(prev => ({ ...prev, [field]: true }))
|
setFieldLoading(prev => ({ ...prev, [field]: true }))
|
||||||
@ -105,18 +109,26 @@ export default function ProfilePage() {
|
|||||||
setSaveStatus({ type: null, message: '' })
|
setSaveStatus({ type: null, message: '' })
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const updates: Record<string, string> = {}
|
|
||||||
|
|
||||||
if (field === 'email') {
|
if (field === 'email') {
|
||||||
const { error } = await supabase.auth.updateUser({ email: value })
|
const { error } = await supabase.auth.updateUser({ email: value as string })
|
||||||
if (error) throw error
|
if (error) throw error
|
||||||
setSaveStatus({ type: 'success', message: t('checkEmailToConfirm') })
|
setSaveStatus({ type: 'success', message: t('checkEmailToConfirm') })
|
||||||
} else {
|
} else {
|
||||||
updates[field] = value
|
// Use our API for other fields
|
||||||
const { error } = await supabase.auth.updateUser({
|
const updateData: Record<string, unknown> = { userId: user.id }
|
||||||
data: updates
|
updateData[field] = value
|
||||||
|
|
||||||
|
const response = await fetch('/api/users/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(updateData)
|
||||||
})
|
})
|
||||||
if (error) throw error
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to update profile')
|
||||||
|
}
|
||||||
|
|
||||||
setSaveStatus({ type: 'success', message: `${field} ${t('updatedSuccessfully')}` })
|
setSaveStatus({ type: 'success', message: `${field} ${t('updatedSuccessfully')}` })
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -192,11 +204,20 @@ export default function ProfilePage() {
|
|||||||
reader.onload = async (e) => {
|
reader.onload = async (e) => {
|
||||||
const dataUrl = e.target?.result as string
|
const dataUrl = e.target?.result as string
|
||||||
|
|
||||||
const { error } = await supabase.auth.updateUser({
|
// Update avatar using our API
|
||||||
data: { avatar_url: dataUrl }
|
const response = await fetch('/api/users/profile', {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
userId: user.id,
|
||||||
|
avatar: dataUrl
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
if (error) throw error
|
if (!response.ok) {
|
||||||
|
const errorData = await response.json()
|
||||||
|
throw new Error(errorData.error || 'Failed to update avatar')
|
||||||
|
}
|
||||||
|
|
||||||
await loadProfile()
|
await loadProfile()
|
||||||
setSaveStatus({ type: 'success', message: t('avatarUpdatedSuccessfully') })
|
setSaveStatus({ type: 'success', message: t('avatarUpdatedSuccessfully') })
|
||||||
@ -265,7 +286,7 @@ export default function ProfilePage() {
|
|||||||
<div className="flex flex-col items-center">
|
<div className="flex flex-col items-center">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Avatar
|
<Avatar
|
||||||
src={profile?.avatar_url}
|
src={profile?.avatar}
|
||||||
alt="Profile Avatar"
|
alt="Profile Avatar"
|
||||||
size={96}
|
size={96}
|
||||||
className="w-24 h-24"
|
className="w-24 h-24"
|
||||||
@ -622,6 +643,96 @@ export default function ProfilePage() {
|
|||||||
</div>
|
</div>
|
||||||
</LoadingOverlay>
|
</LoadingOverlay>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Version Limit Settings */}
|
||||||
|
{profileLoading ? (
|
||||||
|
<FormFieldSkeleton />
|
||||||
|
) : (
|
||||||
|
<LoadingOverlay isLoading={fieldLoading.versionLimit}>
|
||||||
|
<div className="bg-card p-6 rounded-lg border border-border">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-foreground">Version Limit</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
Maximum number of versions to keep for each prompt
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{!isEditing.versionLimit && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsEditing(prev => ({ ...prev, versionLimit: true }))}
|
||||||
|
disabled={isLoading || avatarUploading}
|
||||||
|
>
|
||||||
|
{tCommon('edit')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isEditing.versionLimit ? (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center space-x-4">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max={profile?.maxVersionLimit || 3}
|
||||||
|
value={formData.versionLimit}
|
||||||
|
onChange={(e) => setFormData(prev => ({
|
||||||
|
...prev,
|
||||||
|
versionLimit: Math.max(1, Math.min(parseInt(e.target.value) || 1, profile?.maxVersionLimit || 3))
|
||||||
|
}))}
|
||||||
|
className="w-24"
|
||||||
|
disabled={fieldLoading.versionLimit}
|
||||||
|
/>
|
||||||
|
<span className="text-sm text-muted-foreground">
|
||||||
|
versions (max: {profile?.maxVersionLimit || 3} for {profile?.subscribePlan || 'free'} plan)
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground mt-2">
|
||||||
|
When this limit is reached, oldest versions will be automatically deleted when new versions are created.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => updateProfile('versionLimit', formData.versionLimit)}
|
||||||
|
disabled={isLoading || fieldLoading.versionLimit}
|
||||||
|
>
|
||||||
|
{fieldLoading.versionLimit ? (
|
||||||
|
<LoadingSpinner size="sm" className="mr-2" />
|
||||||
|
) : (
|
||||||
|
<Save className="w-4 h-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{tCommon('save')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => {
|
||||||
|
setIsEditing(prev => ({ ...prev, versionLimit: false }))
|
||||||
|
setFormData(prev => ({ ...prev, versionLimit: profile?.versionLimit || 3 }))
|
||||||
|
}}
|
||||||
|
disabled={fieldLoading.versionLimit}
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<p className="text-foreground">
|
||||||
|
Current limit: <span className="font-medium">{profile?.versionLimit || 3} versions</span>
|
||||||
|
</p>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
<p>Subscription: <span className="capitalize">{profile?.subscribePlan || 'free'}</span></p>
|
||||||
|
<p>Maximum allowed: {profile?.maxVersionLimit || 3} versions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</LoadingOverlay>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,6 +1,6 @@
|
|||||||
'use client'
|
'use client'
|
||||||
|
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState, useRef } from 'react'
|
||||||
import { useTranslations } from 'next-intl'
|
import { useTranslations } from 'next-intl'
|
||||||
import { useAuth } from '@/hooks/useAuth'
|
import { useAuth } from '@/hooks/useAuth'
|
||||||
import { useRouter } from 'next/navigation'
|
import { useRouter } from 'next/navigation'
|
||||||
@ -10,15 +10,12 @@ import { Input } from '@/components/ui/input'
|
|||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
import { LoadingSpinner } from '@/components/ui/loading-spinner'
|
import { LoadingSpinner } from '@/components/ui/loading-spinner'
|
||||||
|
import { VersionTimeline, VersionTimelineRef } from '@/components/studio/VersionTimeline'
|
||||||
import {
|
import {
|
||||||
Play,
|
Play,
|
||||||
Save,
|
Save,
|
||||||
Copy,
|
Copy,
|
||||||
Settings,
|
Settings,
|
||||||
Plus,
|
|
||||||
Search,
|
|
||||||
Filter,
|
|
||||||
MoreHorizontal,
|
|
||||||
Zap,
|
Zap,
|
||||||
History,
|
History,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@ -43,6 +40,14 @@ interface PromptData {
|
|||||||
usage?: number
|
usage?: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface PromptVersion {
|
||||||
|
id: string
|
||||||
|
version: number
|
||||||
|
content: string
|
||||||
|
changelog: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
export default function PromptPage({ params }: PromptPageProps) {
|
export default function PromptPage({ params }: PromptPageProps) {
|
||||||
const [promptId, setPromptId] = useState<string>('')
|
const [promptId, setPromptId] = useState<string>('')
|
||||||
|
|
||||||
@ -62,6 +67,11 @@ export default function PromptPage({ params }: PromptPageProps) {
|
|||||||
const [isRunning, setIsRunning] = useState(false)
|
const [isRunning, setIsRunning] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [currentVersion, setCurrentVersion] = useState<PromptVersion | null>(null)
|
||||||
|
const [originalContent, setOriginalContent] = useState('')
|
||||||
|
const [originalTitle, setOriginalTitle] = useState('')
|
||||||
|
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)
|
||||||
|
const versionTimelineRef = useRef<VersionTimelineRef>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!loading && !user) {
|
if (!loading && !user) {
|
||||||
@ -87,6 +97,17 @@ export default function PromptPage({ params }: PromptPageProps) {
|
|||||||
setPrompt(processedData)
|
setPrompt(processedData)
|
||||||
setPromptContent(data.content)
|
setPromptContent(data.content)
|
||||||
setPromptTitle(data.name)
|
setPromptTitle(data.name)
|
||||||
|
setOriginalContent(data.content)
|
||||||
|
setOriginalTitle(data.name)
|
||||||
|
|
||||||
|
// 获取当前版本信息
|
||||||
|
const versionResponse = await fetch(`/api/prompts/${promptId}/versions?userId=${user.id}&limit=1`)
|
||||||
|
if (versionResponse.ok) {
|
||||||
|
const versionData = await versionResponse.json()
|
||||||
|
if (versionData.length > 0) {
|
||||||
|
setCurrentVersion(versionData[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
router.push('/studio')
|
router.push('/studio')
|
||||||
}
|
}
|
||||||
@ -101,6 +122,12 @@ export default function PromptPage({ params }: PromptPageProps) {
|
|||||||
fetchPrompt()
|
fetchPrompt()
|
||||||
}, [user, loading, router, promptId])
|
}, [user, loading, router, promptId])
|
||||||
|
|
||||||
|
// 检测未保存的更改
|
||||||
|
useEffect(() => {
|
||||||
|
const hasChanges = promptContent !== originalContent || promptTitle !== originalTitle
|
||||||
|
setHasUnsavedChanges(hasChanges)
|
||||||
|
}, [promptContent, promptTitle, originalContent, originalTitle])
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const handleRunTest = async () => {
|
const handleRunTest = async () => {
|
||||||
@ -151,7 +178,22 @@ export default function PromptPage({ params }: PromptPageProps) {
|
|||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const updatedPrompt = await response.json()
|
const updatedPrompt = await response.json()
|
||||||
setPrompt(updatedPrompt)
|
setPrompt(updatedPrompt)
|
||||||
// Show success message
|
setOriginalContent(promptContent)
|
||||||
|
setOriginalTitle(promptTitle)
|
||||||
|
|
||||||
|
// 获取新的版本信息
|
||||||
|
const versionResponse = await fetch(`/api/prompts/${promptId}/versions?userId=${user.id}&limit=1`)
|
||||||
|
if (versionResponse.ok) {
|
||||||
|
const versionData = await versionResponse.json()
|
||||||
|
if (versionData.length > 0) {
|
||||||
|
setCurrentVersion(versionData[0])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新版本历史列表
|
||||||
|
if (versionTimelineRef.current) {
|
||||||
|
await versionTimelineRef.current.refreshVersions()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to save prompt:', error)
|
console.error('Failed to save prompt:', error)
|
||||||
@ -165,6 +207,45 @@ export default function PromptPage({ params }: PromptPageProps) {
|
|||||||
// Show success message
|
// Show success message
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleVersionSelect = (version: PromptVersion) => {
|
||||||
|
setPromptContent(version.content)
|
||||||
|
setOriginalContent(version.content)
|
||||||
|
setCurrentVersion(version)
|
||||||
|
setTestResult('') // 清除测试结果
|
||||||
|
|
||||||
|
// 重置保存状态
|
||||||
|
setHasUnsavedChanges(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVersionRestore = async (version: PromptVersion) => {
|
||||||
|
if (!user || !promptId) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/prompts/${promptId}/versions/${version.id}/restore`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ userId: user.id })
|
||||||
|
})
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const updatedPrompt = await response.json()
|
||||||
|
setPrompt(updatedPrompt)
|
||||||
|
setPromptContent(version.content)
|
||||||
|
setOriginalContent(version.content)
|
||||||
|
setCurrentVersion(version)
|
||||||
|
setTestResult('')
|
||||||
|
|
||||||
|
// 刷新版本历史列表
|
||||||
|
if (versionTimelineRef.current) {
|
||||||
|
await versionTimelineRef.current.refreshVersions()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restoring version:', error)
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (loading || isLoading) {
|
if (loading || isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen flex items-center justify-center">
|
<div className="min-h-screen flex items-center justify-center">
|
||||||
@ -230,101 +311,18 @@ export default function PromptPage({ params }: PromptPageProps) {
|
|||||||
|
|
||||||
<div className="max-w-7xl mx-auto p-4">
|
<div className="max-w-7xl mx-auto p-4">
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||||
{/* Left Sidebar - Prompt List */}
|
{/* Left Sidebar - Version Timeline */}
|
||||||
<div className="lg:col-span-1">
|
<div className="lg:col-span-1">
|
||||||
<div className="bg-card rounded-lg border border-border overflow-hidden">
|
<div className="bg-card rounded-lg border border-border p-4">
|
||||||
<div className="p-4 border-b border-border">
|
<VersionTimeline
|
||||||
<div className="flex items-center justify-between mb-3">
|
ref={versionTimelineRef}
|
||||||
<h2 className="font-semibold text-foreground">{t('myPrompts')}</h2>
|
promptId={promptId}
|
||||||
<Button size="sm" variant="outline" className="h-7 w-7 p-0">
|
userId={user.id}
|
||||||
<Plus className="h-3 w-3" />
|
currentVersion={currentVersion}
|
||||||
</Button>
|
hasUnsavedChanges={hasUnsavedChanges}
|
||||||
</div>
|
onVersionSelect={handleVersionSelect}
|
||||||
|
onVersionRestore={handleVersionRestore}
|
||||||
{/* Search */}
|
/>
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder={t('searchPrompts')}
|
|
||||||
className="pl-9 h-8"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<div className="p-4 border-b border-border">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<Button variant="ghost" size="sm" className="h-7 px-2 text-xs">
|
|
||||||
<Filter className="h-3 w-3 mr-1" />
|
|
||||||
{t('filter')}
|
|
||||||
</Button>
|
|
||||||
<span className="text-muted-foreground">3 prompts</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Prompt List */}
|
|
||||||
<div className="max-h-96 overflow-y-auto">
|
|
||||||
{/* Active Prompt */}
|
|
||||||
<div className="p-3 border-b border-border bg-muted/50">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-primary mt-2 flex-shrink-0"></div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-foreground text-sm truncate">{prompt?.name || 'Loading...'}</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{prompt?.description || ''}</p>
|
|
||||||
<div className="flex items-center mt-2 space-x-2">
|
|
||||||
{prompt?.tags?.slice(0, 1).map((tag) => (
|
|
||||||
<span key={typeof tag === 'string' ? tag : (tag as { name: string })?.name || ''} className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-primary/10 text-primary rounded">
|
|
||||||
{typeof tag === 'string' ? tag : (tag as { name: string })?.name || ''}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
{prompt?.tags && prompt.tags.length > 1 && (
|
|
||||||
<span className="text-xs text-muted-foreground">+{prompt.tags.length - 1}</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
|
|
||||||
<MoreHorizontal className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Other Prompts */}
|
|
||||||
<div className="p-3 border-b border-border hover:bg-muted/30 cursor-pointer group">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-muted-foreground/30 mt-2 flex-shrink-0"></div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-foreground text-sm truncate">Code Review Assistant</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">Help review code for best practices and potential issues</p>
|
|
||||||
<div className="flex items-center mt-2 space-x-2">
|
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground rounded">
|
|
||||||
development
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
|
|
||||||
<MoreHorizontal className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="p-3 hover:bg-muted/30 cursor-pointer group">
|
|
||||||
<div className="flex items-start space-x-3">
|
|
||||||
<div className="w-2 h-2 rounded-full bg-muted-foreground/30 mt-2 flex-shrink-0"></div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<h3 className="font-medium text-foreground text-sm truncate">Content Summarizer</h3>
|
|
||||||
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">Summarize long articles into key points</p>
|
|
||||||
<div className="flex items-center mt-2 space-x-2">
|
|
||||||
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-muted text-muted-foreground rounded">
|
|
||||||
content
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Button variant="ghost" size="sm" className="h-6 w-6 p-0 opacity-0 group-hover:opacity-100">
|
|
||||||
<MoreHorizontal className="h-3 w-3" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Prompt Info */}
|
{/* Prompt Info */}
|
||||||
@ -408,9 +406,18 @@ export default function PromptPage({ params }: PromptPageProps) {
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex items-center space-x-2 text-sm text-muted-foreground">
|
<div className="flex items-center space-x-2 text-sm">
|
||||||
<Clock className="h-4 w-4" />
|
{hasUnsavedChanges ? (
|
||||||
<span>Auto-save enabled</span>
|
<>
|
||||||
|
<div className="w-2 h-2 rounded-full bg-orange-500 animate-pulse"></div>
|
||||||
|
<span className="text-orange-600 dark:text-orange-400">Unsaved changes</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span className="text-muted-foreground">All changes saved</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
364
src/components/studio/VersionTimeline.tsx
Normal file
364
src/components/studio/VersionTimeline.tsx
Normal file
@ -0,0 +1,364 @@
|
|||||||
|
'use client'
|
||||||
|
|
||||||
|
import { useState, useEffect, useImperativeHandle, forwardRef, useCallback } from 'react'
|
||||||
|
import { useTranslations } from 'next-intl'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { LoadingSpinner } from '@/components/ui/loading-spinner'
|
||||||
|
import {
|
||||||
|
History,
|
||||||
|
Eye,
|
||||||
|
RotateCcw,
|
||||||
|
AlertTriangle
|
||||||
|
} from 'lucide-react'
|
||||||
|
|
||||||
|
interface PromptVersion {
|
||||||
|
id: string
|
||||||
|
version: number
|
||||||
|
content: string
|
||||||
|
changelog: string
|
||||||
|
createdAt: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface VersionTimelineProps {
|
||||||
|
promptId: string
|
||||||
|
userId: string
|
||||||
|
currentVersion?: PromptVersion | null
|
||||||
|
hasUnsavedChanges?: boolean
|
||||||
|
onVersionSelect: (version: PromptVersion) => void
|
||||||
|
onVersionRestore: (version: PromptVersion) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UserLimits {
|
||||||
|
versionLimit: number
|
||||||
|
maxVersionLimit: number
|
||||||
|
subscribePlan: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VersionTimelineRef {
|
||||||
|
refreshVersions: () => Promise<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const VersionTimeline = forwardRef<VersionTimelineRef, VersionTimelineProps>(({
|
||||||
|
promptId,
|
||||||
|
userId,
|
||||||
|
currentVersion,
|
||||||
|
hasUnsavedChanges = false,
|
||||||
|
onVersionSelect,
|
||||||
|
onVersionRestore
|
||||||
|
}, ref) => {
|
||||||
|
const t = useTranslations('studio')
|
||||||
|
const [versions, setVersions] = useState<PromptVersion[]>([])
|
||||||
|
const [loading, setLoading] = useState(true)
|
||||||
|
const [selectedVersionId, setSelectedVersionId] = useState<string | null>(null)
|
||||||
|
const [restoring, setRestoring] = useState<string | null>(null)
|
||||||
|
const [showWarning, setShowWarning] = useState(false)
|
||||||
|
const [pendingVersionId, setPendingVersionId] = useState<string | null>(null)
|
||||||
|
const [userLimits, setUserLimits] = useState<UserLimits | null>(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
fetchVersions()
|
||||||
|
fetchUserLimits()
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [promptId, userId])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentVersion) {
|
||||||
|
setSelectedVersionId(currentVersion.id)
|
||||||
|
}
|
||||||
|
}, [currentVersion])
|
||||||
|
|
||||||
|
const fetchVersions = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true)
|
||||||
|
const response = await fetch(`/api/prompts/${promptId}/versions?userId=${userId}`)
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json()
|
||||||
|
setVersions(data)
|
||||||
|
if (data.length > 0 && !selectedVersionId) {
|
||||||
|
setSelectedVersionId(data[0].id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching versions:', error)
|
||||||
|
} finally {
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}, [promptId, userId, selectedVersionId])
|
||||||
|
|
||||||
|
// 暴露刷新函数给父组件
|
||||||
|
useImperativeHandle(ref, () => ({
|
||||||
|
refreshVersions: fetchVersions
|
||||||
|
}), [fetchVersions])
|
||||||
|
|
||||||
|
const fetchUserLimits = async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(`/api/users/profile?userId=${userId}`)
|
||||||
|
if (response.ok) {
|
||||||
|
const userData = await response.json()
|
||||||
|
setUserLimits({
|
||||||
|
versionLimit: userData.versionLimit,
|
||||||
|
maxVersionLimit: userData.maxVersionLimit,
|
||||||
|
subscribePlan: userData.subscribePlan
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching user limits:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleVersionSelect = (version: PromptVersion) => {
|
||||||
|
if (hasUnsavedChanges && version.id !== selectedVersionId) {
|
||||||
|
setShowWarning(true)
|
||||||
|
setPendingVersionId(version.id)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setSelectedVersionId(version.id)
|
||||||
|
onVersionSelect(version)
|
||||||
|
}
|
||||||
|
|
||||||
|
const confirmVersionSwitch = () => {
|
||||||
|
if (pendingVersionId) {
|
||||||
|
const version = versions.find(v => v.id === pendingVersionId)
|
||||||
|
if (version) {
|
||||||
|
setSelectedVersionId(version.id)
|
||||||
|
onVersionSelect(version)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setShowWarning(false)
|
||||||
|
setPendingVersionId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelVersionSwitch = () => {
|
||||||
|
setShowWarning(false)
|
||||||
|
setPendingVersionId(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRestoreVersion = async (version: PromptVersion) => {
|
||||||
|
if (restoring) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
setRestoring(version.id)
|
||||||
|
await onVersionRestore(version)
|
||||||
|
await fetchVersions()
|
||||||
|
setSelectedVersionId(version.id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error restoring version:', error)
|
||||||
|
} finally {
|
||||||
|
setRestoring(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatDate = (dateString: string) => {
|
||||||
|
const date = new Date(dateString)
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - date.getTime()
|
||||||
|
const hours = Math.floor(diff / (1000 * 60 * 60))
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (days === 0) {
|
||||||
|
if (hours === 0) {
|
||||||
|
const minutes = Math.floor(diff / (1000 * 60))
|
||||||
|
return minutes <= 0 ? 'Just now' : `${minutes}m ago`
|
||||||
|
}
|
||||||
|
return `${hours}h ago`
|
||||||
|
} else if (days < 7) {
|
||||||
|
return `${days}d ago`
|
||||||
|
} else {
|
||||||
|
return new Intl.DateTimeFormat('default', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric'
|
||||||
|
}).format(date)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<History className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<h3 className="font-medium text-foreground">{t('versionHistory')}</h3>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
({versions.length})
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{userLimits && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span>Limit: {userLimits.versionLimit}/{userLimits.maxVersionLimit}</span>
|
||||||
|
<span className="capitalize">{userLimits.subscribePlan}</span>
|
||||||
|
</div>
|
||||||
|
<div className="w-full bg-muted rounded-full h-1 mt-1">
|
||||||
|
<div
|
||||||
|
className={`h-1 rounded-full transition-all ${
|
||||||
|
versions.length >= userLimits.versionLimit ? 'bg-orange-500' : 'bg-primary'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(100, (versions.length / userLimits.versionLimit) * 100)}%` }}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Timeline */}
|
||||||
|
<div className="relative">
|
||||||
|
{/* Timeline line */}
|
||||||
|
<div className="absolute left-4 top-0 bottom-0 w-px bg-border"></div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
{versions.map((version, index) => {
|
||||||
|
const isSelected = selectedVersionId === version.id
|
||||||
|
const isLatest = index === 0
|
||||||
|
const isCurrentVersion = currentVersion?.id === version.id
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={version.id}
|
||||||
|
className={`relative pl-10 pr-2 py-2 rounded-lg cursor-pointer transition-all hover:bg-muted/50 ${
|
||||||
|
isSelected ? 'bg-muted' : ''
|
||||||
|
}`}
|
||||||
|
onClick={() => handleVersionSelect(version)}
|
||||||
|
>
|
||||||
|
{/* Timeline dot */}
|
||||||
|
<div className={`absolute left-2.5 top-4 w-3 h-3 rounded-full border-2 border-background ${
|
||||||
|
isCurrentVersion && hasUnsavedChanges
|
||||||
|
? 'bg-orange-500'
|
||||||
|
: isSelected
|
||||||
|
? 'bg-primary'
|
||||||
|
: isLatest
|
||||||
|
? 'bg-green-500'
|
||||||
|
: 'bg-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
{isCurrentVersion && hasUnsavedChanges && (
|
||||||
|
<div className="absolute inset-0 animate-pulse bg-orange-500 rounded-full"></div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<span className={`text-sm font-medium ${
|
||||||
|
isSelected ? 'text-foreground' : 'text-muted-foreground'
|
||||||
|
}`}>
|
||||||
|
v{version.version}
|
||||||
|
</span>
|
||||||
|
{isLatest && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300 rounded-full">
|
||||||
|
Latest
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isCurrentVersion && hasUnsavedChanges && (
|
||||||
|
<span className="inline-flex items-center px-1.5 py-0.5 text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-300 rounded-full">
|
||||||
|
Modified
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{formatDate(version.createdAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{version.changelog}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
{/* Action buttons - only show on hover or when selected */}
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center space-x-1 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onVersionSelect(version)
|
||||||
|
}}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
<Eye className="h-3 w-3 mr-1" />
|
||||||
|
View
|
||||||
|
</Button>
|
||||||
|
{!isLatest && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
handleRestoreVersion(version)
|
||||||
|
}}
|
||||||
|
disabled={restoring === version.id}
|
||||||
|
className="h-6 px-2 text-xs"
|
||||||
|
>
|
||||||
|
{restoring === version.id ? (
|
||||||
|
<LoadingSpinner size="sm" />
|
||||||
|
) : (
|
||||||
|
<RotateCcw className="h-3 w-3 mr-1" />
|
||||||
|
)}
|
||||||
|
Restore
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{versions.length === 0 && (
|
||||||
|
<div className="text-center py-8">
|
||||||
|
<History className="h-8 w-8 text-muted-foreground mx-auto mb-2" />
|
||||||
|
<p className="text-sm text-muted-foreground">No versions yet</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Modal */}
|
||||||
|
{showWarning && (
|
||||||
|
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
|
||||||
|
<div className="bg-background border border-border rounded-lg p-6 max-w-md mx-4">
|
||||||
|
<div className="flex items-start space-x-3">
|
||||||
|
<AlertTriangle className="h-5 w-5 text-orange-500 mt-0.5" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold text-foreground mb-2">
|
||||||
|
Unsaved Changes
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm text-muted-foreground mb-4">
|
||||||
|
You have unsaved changes that will be lost if you switch versions. Are you sure you want to continue?
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={cancelVersionSwitch}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={confirmVersionSwitch}
|
||||||
|
>
|
||||||
|
Switch Version
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
VersionTimeline.displayName = 'VersionTimeline'
|
@ -3,7 +3,7 @@ import { cn } from '@/lib/utils'
|
|||||||
|
|
||||||
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
variant?: 'default' | 'outline' | 'ghost' | 'destructive'
|
variant?: 'default' | 'outline' | 'ghost' | 'destructive'
|
||||||
size?: 'sm' | 'md' | 'lg'
|
size?: 'xs' | 'sm' | 'md' | 'lg'
|
||||||
}
|
}
|
||||||
|
|
||||||
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
@ -18,6 +18,7 @@ const Button = forwardRef<HTMLButtonElement, ButtonProps>(
|
|||||||
}
|
}
|
||||||
|
|
||||||
const sizes = {
|
const sizes = {
|
||||||
|
xs: 'h-6 px-2 text-xs',
|
||||||
sm: 'h-8 px-3 text-sm',
|
sm: 'h-8 px-3 text-sm',
|
||||||
md: 'h-10 px-4 text-sm',
|
md: 'h-10 px-4 text-sm',
|
||||||
lg: 'h-12 px-6 text-base'
|
lg: 'h-12 px-6 text-base'
|
||||||
|
69
src/lib/subscription.ts
Normal file
69
src/lib/subscription.ts
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
// 订阅计划配置
|
||||||
|
export const SUBSCRIPTION_PLANS = {
|
||||||
|
free: {
|
||||||
|
name: 'Free',
|
||||||
|
maxVersionLimit: 3,
|
||||||
|
promptLimit: 20,
|
||||||
|
credit: 5
|
||||||
|
},
|
||||||
|
pro: {
|
||||||
|
name: 'Pro',
|
||||||
|
maxVersionLimit: 10,
|
||||||
|
promptLimit: 500,
|
||||||
|
credit: 20
|
||||||
|
}
|
||||||
|
} as const
|
||||||
|
|
||||||
|
export type SubscriptionPlan = keyof typeof SUBSCRIPTION_PLANS
|
||||||
|
|
||||||
|
// 获取用户的版本限制
|
||||||
|
export function getUserVersionLimit(user: {
|
||||||
|
versionLimit: number
|
||||||
|
subscribePlan: string
|
||||||
|
maxVersionLimit: number
|
||||||
|
}): number {
|
||||||
|
const plan = user.subscribePlan as SubscriptionPlan
|
||||||
|
const planConfig = SUBSCRIPTION_PLANS[plan]
|
||||||
|
|
||||||
|
if (!planConfig) {
|
||||||
|
// 如果订阅计划无效,使用免费计划的限制
|
||||||
|
return Math.min(user.versionLimit, SUBSCRIPTION_PLANS.free.maxVersionLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 用户设置的限制不能超过订阅计划的最大限制
|
||||||
|
return Math.min(user.versionLimit, planConfig.maxVersionLimit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户的最大版本限制(基于订阅)
|
||||||
|
export function getMaxVersionLimit(subscribePlan: string): number {
|
||||||
|
const plan = subscribePlan as SubscriptionPlan
|
||||||
|
const planConfig = SUBSCRIPTION_PLANS[plan]
|
||||||
|
|
||||||
|
return planConfig?.maxVersionLimit || SUBSCRIPTION_PLANS.free.maxVersionLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户是否可以创建新版本
|
||||||
|
export function canCreateNewVersion(
|
||||||
|
currentVersionCount: number,
|
||||||
|
user: {
|
||||||
|
versionLimit: number
|
||||||
|
subscribePlan: string
|
||||||
|
maxVersionLimit: number
|
||||||
|
}
|
||||||
|
): boolean {
|
||||||
|
const versionLimit = getUserVersionLimit(user)
|
||||||
|
return currentVersionCount < versionLimit
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算需要删除的版本数量
|
||||||
|
export function getVersionsToDelete(
|
||||||
|
currentVersionCount: number,
|
||||||
|
user: {
|
||||||
|
versionLimit: number
|
||||||
|
subscribePlan: string
|
||||||
|
maxVersionLimit: number
|
||||||
|
}
|
||||||
|
): number {
|
||||||
|
const versionLimit = getUserVersionLimit(user)
|
||||||
|
return Math.max(0, currentVersionCount - versionLimit + 1) // +1 因为要创建新版本
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user