Files
MemeStudio/components/levels/level-dialog.tsx
2026-05-01 10:04:16 +08:00

344 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState, useEffect, useMemo } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Spinner } from '@/components/ui/spinner'
import { Level, LevelFormData } from '@/types'
import { ImageUploader } from './image-uploader'
interface LevelDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
level?: Level | null
/** 当前关卡总数(含正在编辑的这条) */
totalCount: number
/** 编辑时当前行的 0-based index创建时传 null */
currentIndex: number | null
onSubmit: (data: LevelFormData) => Promise<void>
}
type FormState = Omit<LevelFormData, 'position'> & {
/** 位置字段以字符串保存,便于处理"空输入"状态;提交时解析为数字 */
position: string
}
const defaultFormState: FormState = {
image1Url: '',
image1Description: '',
image2Url: '',
image2Description: '',
answer: '',
punchline: '',
hint1: '',
hint2: '',
hint3: '',
position: '',
}
export function LevelDialog({
open,
onOpenChange,
level,
totalCount,
currentIndex,
onSubmit,
}: LevelDialogProps) {
const [formData, setFormData] = useState<FormState>(defaultFormState)
const [isLoading, setIsLoading] = useState(false)
const [error, setError] = useState('')
const isEdit = !!level
// 位置的合法范围与默认值
const { minPos, maxPos, defaultPos } = useMemo(() => {
if (isEdit) {
const min = 1
const max = Math.max(totalCount, 1)
const def = typeof currentIndex === 'number' ? currentIndex + 1 : max
return { minPos: min, maxPos: max, defaultPos: def }
}
const max = totalCount + 1
return { minPos: 1, maxPos: max, defaultPos: max }
}, [isEdit, totalCount, currentIndex])
// Reset form when dialog opens/closes or level changes
useEffect(() => {
if (open) {
if (level) {
setFormData({
image1Url: level.image1Url,
image1Description: level.image1Description || '',
image2Url: level.image2Url,
image2Description: level.image2Description || '',
answer: level.answer,
punchline: level.punchline || '',
hint1: level.hint1 || '',
hint2: level.hint2 || '',
hint3: level.hint3 || '',
position: String(defaultPos),
})
} else {
setFormData({ ...defaultFormState, position: String(defaultPos) })
}
setError('')
}
}, [open, level, defaultPos])
const handleImage1Upload = (url: string) => {
setFormData((prev) => ({ ...prev, image1Url: url }))
}
const handleImage2Upload = (url: string) => {
setFormData((prev) => ({ ...prev, image2Url: url }))
}
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
setError('')
if (!formData.image1Url) {
setError('请上传图片1')
return
}
if (!formData.image2Url) {
setError('请上传图片2')
return
}
if (!formData.answer.trim()) {
setError('请输入答案')
return
}
if (formData.answer.trim().length > 4) {
setError('答案最多4个字')
return
}
// 位置:留空视为"不改位置"(编辑)/ "追加末尾"(创建)
let position: number | undefined
const raw = formData.position.trim()
if (raw !== '') {
const n = Number(raw)
if (!Number.isInteger(n) || n < minPos || n > maxPos) {
setError(`位置必须是 ${minPos}-${maxPos} 之间的整数`)
return
}
// 编辑场景:值没变则不上传 position避免后端无谓重算 sortKey
if (isEdit && typeof currentIndex === 'number' && n === currentIndex + 1) {
position = undefined
} else {
position = n
}
}
const payload: LevelFormData = {
image1Url: formData.image1Url,
image1Description: formData.image1Description,
image2Url: formData.image2Url,
image2Description: formData.image2Description,
answer: formData.answer,
punchline: formData.punchline,
hint1: formData.hint1,
hint2: formData.hint2,
hint3: formData.hint3,
...(position !== undefined ? { position } : {}),
}
setIsLoading(true)
try {
await onSubmit(payload)
onOpenChange(false)
} catch (err) {
setError(err instanceof Error ? err.message : '操作失败,请稍后重试')
} finally {
setIsLoading(false)
}
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>{level ? '编辑关卡' : '添加关卡'}</DialogTitle>
<DialogDescription>
{level ? '修改关卡信息' : '创建新的关卡'}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
{error}
</div>
)}
{/* 双图上传区域 */}
<div className="grid grid-cols-2 gap-4">
{/* 图片1 */}
<div className="space-y-2">
<Label>1 *</Label>
<ImageUploader
value={formData.image1Url}
onChange={handleImage1Upload}
/>
<Input
value={formData.image1Description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, image1Description: e.target.value }))
}
placeholder="图片1描述 (可选)"
/>
</div>
{/* 图片2 */}
<div className="space-y-2">
<Label>2 *</Label>
<ImageUploader
value={formData.image2Url}
onChange={handleImage2Upload}
/>
<Input
value={formData.image2Description}
onChange={(e) =>
setFormData((prev) => ({ ...prev, image2Description: e.target.value }))
}
placeholder="图片2描述 (可选)"
/>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="answer"> *</Label>
<Input
id="answer"
value={formData.answer}
onChange={(e) =>
setFormData((prev) => ({ ...prev, answer: e.target.value }))
}
placeholder="请输入答案最多8个字"
maxLength={8}
required
/>
<p className="text-xs text-muted-foreground text-right">
{formData.answer.length}/8
</p>
</div>
<div className="space-y-2">
<Label htmlFor="position">
<span className="text-muted-foreground font-normal">*</span>
</Label>
<div className="flex items-center gap-3">
<Input
id="position"
type="number"
min={minPos}
max={maxPos}
step={1}
inputMode="numeric"
value={formData.position}
onChange={(e) =>
setFormData((prev) => ({ ...prev, position: e.target.value }))
}
className="w-32 font-mono tabular-nums"
placeholder={String(defaultPos)}
/>
<p className="text-xs text-muted-foreground">
<span className="font-mono">{minPos}</span> {' '}
<span className="font-mono">{maxPos}</span>
{isEdit && typeof currentIndex === 'number' && (
<>
{' · 当前第 '}
<span className="font-mono">{currentIndex + 1}</span>
</>
)}
{!isEdit && ' · 留空则追加到末尾'}
</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="punchline"> ()</Label>
<Input
id="punchline"
value={formData.punchline}
onChange={(e) =>
setFormData((prev) => ({ ...prev, punchline: e.target.value }))
}
placeholder="请输入谐音梗说明"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hint1">1 ()</Label>
<Input
id="hint1"
value={formData.hint1}
onChange={(e) =>
setFormData((prev) => ({ ...prev, hint1: e.target.value }))
}
placeholder="请输入提示1"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hint2">2 ()</Label>
<Input
id="hint2"
value={formData.hint2}
onChange={(e) =>
setFormData((prev) => ({ ...prev, hint2: e.target.value }))
}
placeholder="请输入提示2"
/>
</div>
<div className="space-y-2">
<Label htmlFor="hint3">3 ()</Label>
<Input
id="hint3"
value={formData.hint3}
onChange={(e) =>
setFormData((prev) => ({ ...prev, hint3: e.target.value }))
}
placeholder="请输入提示3"
/>
</div>
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
disabled={isLoading}
>
</Button>
<Button type="submit" disabled={isLoading}>
{isLoading ? (
<>
<Spinner size="sm" className="mr-2" />
...
</>
) : (
'保存'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
)
}