678 lines
21 KiB
TypeScript
678 lines
21 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useRef, useEffect, useMemo } from 'react'
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from '@/components/ui/dialog'
|
||
import { Button } from '@/components/ui/button'
|
||
import { Input } from '@/components/ui/input'
|
||
import { Label } from '@/components/ui/label'
|
||
import { Textarea } from '@/components/ui/textarea'
|
||
import { Spinner } from '@/components/ui/spinner'
|
||
import { FolderOpen, Trash2, CheckCircle2, XCircle, AlertTriangle } from 'lucide-react'
|
||
import Image from 'next/image'
|
||
import { uploadToCos } from '@/lib/cos-client'
|
||
import { apiFetch } from '@/lib/api'
|
||
|
||
interface BatchImportDialogProps {
|
||
open: boolean
|
||
onOpenChange: (open: boolean) => void
|
||
onSuccess: () => void
|
||
}
|
||
|
||
type ItemStatus = 'pending' | 'uploading' | 'creating' | 'done' | 'error'
|
||
|
||
interface ParsedItem {
|
||
id: string // riddle_id 或文件夹名,仅用于 React key
|
||
folderName: string
|
||
// 本地文件引用
|
||
referenceFile?: File
|
||
riddleFile?: File
|
||
// 预览 URL(本地 blob URL)
|
||
referencePreview?: string
|
||
riddlePreview?: string
|
||
// 从 metadata.json 提取
|
||
anchorText: string
|
||
answer: string
|
||
punchline: string
|
||
hint1: string
|
||
hint2: string
|
||
hint3: string
|
||
// 解析警告/错误信息
|
||
parseError?: string
|
||
// 上传/创建过程状态
|
||
status: ItemStatus
|
||
statusMessage?: string
|
||
}
|
||
|
||
interface FileWithPath extends File {
|
||
webkitRelativePath: string
|
||
}
|
||
|
||
interface RiddleMetadata {
|
||
plan?: {
|
||
riddle_id?: string
|
||
anchor_text?: string
|
||
answer?: string
|
||
hints?: string[]
|
||
homophone_explanation?: string
|
||
riddle?: {
|
||
homophone_explanation?: string
|
||
}
|
||
}
|
||
}
|
||
|
||
interface RiddleConfigItem {
|
||
riddle_id?: string
|
||
answer?: string
|
||
hints?: string[]
|
||
homophone_explanation?: string
|
||
anchor_text?: string
|
||
reference_image?: string
|
||
riddle_image?: string
|
||
}
|
||
|
||
function truncate(s: string | undefined, max: number): string {
|
||
if (!s) return ''
|
||
return s.length > max ? s.slice(0, max) : s
|
||
}
|
||
|
||
const ANSWER_MAX_LENGTH = 8
|
||
|
||
function normalizePath(path: string): string {
|
||
return path.replace(/\\/g, '/').replace(/^\/+/, '').replace(/\/+/g, '/')
|
||
}
|
||
|
||
function joinPath(base: string, path: string): string {
|
||
const cleanPath = normalizePath(path)
|
||
if (!base) return cleanPath
|
||
return normalizePath(`${base}/${cleanPath}`)
|
||
}
|
||
|
||
function getRelativePathWithoutRoot(file: FileWithPath): string {
|
||
const rel = normalizePath(file.webkitRelativePath)
|
||
const [, ...rest] = rel.split('/')
|
||
return rest.join('/')
|
||
}
|
||
|
||
function buildFilePathMap(files: FileList): Map<string, FileWithPath> {
|
||
const fileByPath = new Map<string, FileWithPath>()
|
||
for (let i = 0; i < files.length; i += 1) {
|
||
const file = files[i] as FileWithPath
|
||
const rel = normalizePath(file.webkitRelativePath)
|
||
if (!rel) continue
|
||
fileByPath.set(rel, file)
|
||
const withoutRoot = getRelativePathWithoutRoot(file)
|
||
if (withoutRoot) fileByPath.set(withoutRoot, file)
|
||
}
|
||
return fileByPath
|
||
}
|
||
|
||
function validateItem(item: ParsedItem) {
|
||
if (!item.answer) {
|
||
item.parseError = '未解析到 answer'
|
||
} else if (item.answer.length > ANSWER_MAX_LENGTH) {
|
||
item.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${item.answer.length})`
|
||
} else if (!item.referenceFile || !item.riddleFile) {
|
||
item.parseError = '未匹配到 reference_image 或 riddle_image 对应图片'
|
||
}
|
||
}
|
||
|
||
async function parseConfigJson(
|
||
jsonFile: FileWithPath,
|
||
fileByPath: Map<string, FileWithPath>
|
||
): Promise<ParsedItem[]> {
|
||
const text = await jsonFile.text()
|
||
const json = JSON.parse(text) as unknown
|
||
if (!Array.isArray(json)) return []
|
||
|
||
const jsonPath = normalizePath(jsonFile.webkitRelativePath)
|
||
const jsonDir = jsonPath.split('/').slice(0, -1).join('/')
|
||
const items: ParsedItem[] = []
|
||
|
||
json.forEach((raw, index) => {
|
||
const config = raw as RiddleConfigItem
|
||
if (!config || typeof config !== 'object') return
|
||
|
||
const referencePath = config.reference_image || ''
|
||
const riddlePath = config.riddle_image || ''
|
||
const reference = fileByPath.get(joinPath(jsonDir, referencePath)) || fileByPath.get(normalizePath(referencePath))
|
||
const riddle = fileByPath.get(joinPath(jsonDir, riddlePath)) || fileByPath.get(normalizePath(riddlePath))
|
||
const id = config.riddle_id || `${jsonFile.name}-${index + 1}`
|
||
const folderName = config.riddle_id || referencePath.split('/').slice(-2, -1)[0] || `第 ${index + 1} 关`
|
||
const hints = Array.isArray(config.hints) ? config.hints : []
|
||
|
||
const item: ParsedItem = {
|
||
id,
|
||
folderName,
|
||
referenceFile: reference,
|
||
riddleFile: riddle,
|
||
referencePreview: reference ? URL.createObjectURL(reference) : undefined,
|
||
riddlePreview: riddle ? URL.createObjectURL(riddle) : undefined,
|
||
anchorText: truncate(config.anchor_text, 500),
|
||
answer: (config.answer || '').trim(),
|
||
punchline: (config.homophone_explanation || '').trim(),
|
||
hint1: (hints[0] || '').trim(),
|
||
hint2: (hints[1] || '').trim(),
|
||
hint3: (hints[2] || '').trim(),
|
||
status: 'pending',
|
||
}
|
||
|
||
validateItem(item)
|
||
items.push(item)
|
||
})
|
||
|
||
return items
|
||
}
|
||
|
||
async function parseFolders(files: FileList): Promise<ParsedItem[]> {
|
||
const fileByPath = buildFilePathMap(files)
|
||
const jsonFiles = Array.from(fileByPath.values()).filter(
|
||
(file, index, arr) =>
|
||
file.name.toLowerCase().endsWith('.json') && arr.indexOf(file) === index
|
||
)
|
||
for (const jsonFile of jsonFiles) {
|
||
try {
|
||
const configItems = await parseConfigJson(jsonFile, fileByPath)
|
||
if (configItems.length > 0) {
|
||
return configItems
|
||
}
|
||
} catch {
|
||
// Not the new array-based config. Fall back to legacy per-folder metadata.
|
||
}
|
||
}
|
||
|
||
// 按第一层目录名分组
|
||
const groups = new Map<string, FileWithPath[]>()
|
||
for (let i = 0; i < files.length; i += 1) {
|
||
const f = files[i] as FileWithPath
|
||
const rel = f.webkitRelativePath
|
||
if (!rel) continue
|
||
const parts = rel.split('/')
|
||
if (parts.length < 2) continue
|
||
// 用倒数第二层作为关卡目录名:e.g. riddles/68f1.../metadata.json
|
||
// 但通常用户选的根目录就是 riddles,第一层就是关卡目录
|
||
// 我们取「包含 metadata.json 的那个目录」作为分组 key
|
||
const folderKey = parts.slice(0, -1).join('/')
|
||
if (!groups.has(folderKey)) groups.set(folderKey, [])
|
||
groups.get(folderKey)!.push(f)
|
||
}
|
||
|
||
const items: ParsedItem[] = []
|
||
|
||
for (const [folderKey, groupFiles] of Array.from(groups.entries())) {
|
||
const metadata = groupFiles.find((f) => f.name === 'metadata.json')
|
||
const reference = groupFiles.find((f) => f.name === 'reference.webp')
|
||
const riddle = groupFiles.find((f) => f.name === 'riddle.webp')
|
||
// 只有同时存在 metadata.json + reference.webp + riddle.webp 的目录才视为关卡
|
||
if (!metadata || !reference || !riddle) continue
|
||
|
||
const folderName = folderKey.split('/').pop() || folderKey
|
||
|
||
const item: ParsedItem = {
|
||
id: folderKey,
|
||
folderName,
|
||
referenceFile: reference,
|
||
riddleFile: riddle,
|
||
referencePreview: URL.createObjectURL(reference),
|
||
riddlePreview: URL.createObjectURL(riddle),
|
||
anchorText: '',
|
||
answer: '',
|
||
punchline: '',
|
||
hint1: '',
|
||
hint2: '',
|
||
hint3: '',
|
||
status: 'pending',
|
||
}
|
||
|
||
try {
|
||
const text = await metadata.text()
|
||
const json = JSON.parse(text) as RiddleMetadata
|
||
const plan = json.plan || {}
|
||
item.anchorText = truncate(plan.anchor_text, 500)
|
||
item.answer = (plan.answer || '').trim()
|
||
item.punchline = (
|
||
plan.homophone_explanation ||
|
||
plan.riddle?.homophone_explanation ||
|
||
''
|
||
).trim()
|
||
const hints = Array.isArray(plan.hints) ? plan.hints : []
|
||
item.hint1 = (hints[0] || '').trim()
|
||
item.hint2 = (hints[1] || '').trim()
|
||
item.hint3 = (hints[2] || '').trim()
|
||
|
||
validateItem(item)
|
||
} catch (e) {
|
||
item.parseError = `解析 metadata.json 失败: ${
|
||
e instanceof Error ? e.message : String(e)
|
||
}`
|
||
}
|
||
|
||
items.push(item)
|
||
}
|
||
|
||
// 稳定排序:按文件夹名
|
||
items.sort((a, b) => a.folderName.localeCompare(b.folderName))
|
||
return items
|
||
}
|
||
|
||
export function BatchImportDialog({
|
||
open,
|
||
onOpenChange,
|
||
onSuccess,
|
||
}: BatchImportDialogProps) {
|
||
const folderInputRef = useRef<HTMLInputElement>(null)
|
||
const [items, setItems] = useState<ParsedItem[]>([])
|
||
const [isParsing, setIsParsing] = useState(false)
|
||
const [isRunning, setIsRunning] = useState(false)
|
||
const [globalError, setGlobalError] = useState('')
|
||
|
||
// 关闭后清理预览 URL 并重置
|
||
useEffect(() => {
|
||
if (!open) {
|
||
items.forEach((it) => {
|
||
if (it.referencePreview) URL.revokeObjectURL(it.referencePreview)
|
||
if (it.riddlePreview) URL.revokeObjectURL(it.riddlePreview)
|
||
})
|
||
setItems([])
|
||
setGlobalError('')
|
||
setIsRunning(false)
|
||
setIsParsing(false)
|
||
}
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [open])
|
||
|
||
const stats = useMemo(() => {
|
||
const total = items.length
|
||
const done = items.filter((i) => i.status === 'done').length
|
||
const error = items.filter((i) => i.status === 'error').length
|
||
const invalid = items.filter((i) => i.parseError).length
|
||
return { total, done, error, invalid }
|
||
}, [items])
|
||
|
||
const canSubmit =
|
||
items.length > 0 &&
|
||
!isRunning &&
|
||
items.some((i) => i.status !== 'done' && !i.parseError)
|
||
|
||
const handleSelectFolder = () => {
|
||
folderInputRef.current?.click()
|
||
}
|
||
|
||
const handleFolderChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files
|
||
if (!files || files.length === 0) return
|
||
setIsParsing(true)
|
||
setGlobalError('')
|
||
try {
|
||
const parsed = await parseFolders(files)
|
||
if (parsed.length === 0) {
|
||
setGlobalError(
|
||
'未在所选目录中找到任何有效关卡。请选择包含 riddles.json 和 riddle_images 的目录,或旧格式的 metadata.json、reference.webp、riddle.webp 子目录'
|
||
)
|
||
}
|
||
setItems(parsed)
|
||
} catch (err) {
|
||
setGlobalError(
|
||
err instanceof Error ? err.message : '解析失败'
|
||
)
|
||
} finally {
|
||
setIsParsing(false)
|
||
// 允许重新选择同一目录
|
||
if (folderInputRef.current) folderInputRef.current.value = ''
|
||
}
|
||
}
|
||
|
||
const updateItem = (id: string, patch: Partial<ParsedItem>) => {
|
||
setItems((prev) =>
|
||
prev.map((it) => {
|
||
if (it.id !== id) return it
|
||
const next = { ...it, ...patch }
|
||
// 每次编辑后重新评估答案的校验
|
||
if (patch.answer !== undefined) {
|
||
if (!next.answer) next.parseError = '未填写答案'
|
||
else if (next.answer.length > ANSWER_MAX_LENGTH)
|
||
next.parseError = `答案超过 ${ANSWER_MAX_LENGTH} 字(${next.answer.length})`
|
||
else next.parseError = undefined
|
||
}
|
||
return next
|
||
})
|
||
)
|
||
}
|
||
|
||
const removeItem = (id: string) => {
|
||
setItems((prev) => {
|
||
const target = prev.find((i) => i.id === id)
|
||
if (target) {
|
||
if (target.referencePreview) URL.revokeObjectURL(target.referencePreview)
|
||
if (target.riddlePreview) URL.revokeObjectURL(target.riddlePreview)
|
||
}
|
||
return prev.filter((i) => i.id !== id)
|
||
})
|
||
}
|
||
|
||
const handleRun = async () => {
|
||
setIsRunning(true)
|
||
setGlobalError('')
|
||
|
||
// 使用函数式 setItems 取到最新列表
|
||
const snapshot = items
|
||
for (const it of snapshot) {
|
||
if (it.status === 'done') continue
|
||
if (it.parseError) continue
|
||
if (!it.referenceFile || !it.riddleFile) continue
|
||
|
||
try {
|
||
updateItem(it.id, { status: 'uploading', statusMessage: '上传图片…' })
|
||
|
||
const [image1Url, image2Url] = await Promise.all([
|
||
uploadToCos(it.referenceFile, it.referenceFile.name),
|
||
uploadToCos(it.riddleFile, it.riddleFile.name),
|
||
])
|
||
|
||
updateItem(it.id, { status: 'creating', statusMessage: '创建关卡…' })
|
||
|
||
const payload = {
|
||
image1Url,
|
||
image1Description: it.anchorText || '',
|
||
image2Url,
|
||
image2Description: '',
|
||
answer: it.answer,
|
||
punchline: it.punchline || '',
|
||
hint1: it.hint1 || '',
|
||
hint2: it.hint2 || '',
|
||
hint3: it.hint3 || '',
|
||
}
|
||
|
||
const res = await apiFetch('/api/levels', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(payload),
|
||
})
|
||
if (!res.ok) {
|
||
const err = await res.json().catch(() => ({}))
|
||
throw new Error(err.error || `HTTP ${res.status}`)
|
||
}
|
||
updateItem(it.id, { status: 'done', statusMessage: '已创建' })
|
||
} catch (e) {
|
||
updateItem(it.id, {
|
||
status: 'error',
|
||
statusMessage: e instanceof Error ? e.message : '失败',
|
||
})
|
||
}
|
||
}
|
||
|
||
setIsRunning(false)
|
||
// 触发列表刷新
|
||
onSuccess()
|
||
}
|
||
|
||
return (
|
||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-hidden flex flex-col">
|
||
<DialogHeader>
|
||
<DialogTitle>批量导入关卡</DialogTitle>
|
||
<DialogDescription>
|
||
选择包含 riddles.json 和 riddle_images 的目录;也兼容旧格式的多个关卡子文件夹。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
|
||
{globalError && (
|
||
<div className="p-3 text-sm text-red-600 bg-red-50 border border-red-200 rounded-md">
|
||
{globalError}
|
||
</div>
|
||
)}
|
||
|
||
<div className="flex items-center gap-3">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={handleSelectFolder}
|
||
disabled={isParsing || isRunning}
|
||
>
|
||
<FolderOpen className="h-4 w-4 mr-2" />
|
||
{items.length > 0 ? '重新选择目录' : '选择目录'}
|
||
</Button>
|
||
{isParsing && (
|
||
<span className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Spinner size="sm" /> 解析中…
|
||
</span>
|
||
)}
|
||
{items.length > 0 && (
|
||
<span className="text-sm text-muted-foreground">
|
||
共 {stats.total} 个关卡 · 成功 {stats.done} · 失败 {stats.error}
|
||
{stats.invalid > 0 && ` · 待修正 ${stats.invalid}`}
|
||
</span>
|
||
)}
|
||
|
||
{/* 隐藏的文件夹选择 input:webkitdirectory 让浏览器选择目录 */}
|
||
<input
|
||
ref={folderInputRef}
|
||
type="file"
|
||
className="hidden"
|
||
multiple
|
||
onChange={handleFolderChange}
|
||
{...({
|
||
webkitdirectory: '',
|
||
directory: '',
|
||
} as Record<string, string>)}
|
||
/>
|
||
</div>
|
||
|
||
<div className="flex-1 overflow-y-auto -mx-6 px-6">
|
||
{items.length === 0 ? (
|
||
<div className="text-center py-16 text-muted-foreground text-sm">
|
||
{isParsing ? '解析中…' : '尚未选择目录'}
|
||
</div>
|
||
) : (
|
||
<div className="space-y-4">
|
||
{items.map((it, idx) => (
|
||
<ItemCard
|
||
key={it.id}
|
||
index={idx + 1}
|
||
item={it}
|
||
disabled={isRunning}
|
||
onChange={(patch) => updateItem(it.id, patch)}
|
||
onRemove={() => removeItem(it.id)}
|
||
/>
|
||
))}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<DialogFooter>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => onOpenChange(false)}
|
||
disabled={isRunning}
|
||
>
|
||
{stats.done > 0 ? '关闭' : '取消'}
|
||
</Button>
|
||
<Button type="button" onClick={handleRun} disabled={!canSubmit}>
|
||
{isRunning ? (
|
||
<>
|
||
<Spinner size="sm" className="mr-2" />
|
||
创建中…
|
||
</>
|
||
) : (
|
||
`确认创建 (${items.filter((i) => i.status !== 'done' && !i.parseError).length})`
|
||
)}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
)
|
||
}
|
||
|
||
interface ItemCardProps {
|
||
index: number
|
||
item: ParsedItem
|
||
disabled: boolean
|
||
onChange: (patch: Partial<ParsedItem>) => void
|
||
onRemove: () => void
|
||
}
|
||
|
||
function ItemCard({ index, item, disabled, onChange, onRemove }: ItemCardProps) {
|
||
const statusNode = (() => {
|
||
switch (item.status) {
|
||
case 'uploading':
|
||
case 'creating':
|
||
return (
|
||
<span className="flex items-center gap-1 text-blue-600 text-xs">
|
||
<Spinner size="sm" /> {item.statusMessage}
|
||
</span>
|
||
)
|
||
case 'done':
|
||
return (
|
||
<span className="flex items-center gap-1 text-green-600 text-xs">
|
||
<CheckCircle2 className="h-4 w-4" /> 已创建
|
||
</span>
|
||
)
|
||
case 'error':
|
||
return (
|
||
<span className="flex items-center gap-1 text-red-600 text-xs">
|
||
<XCircle className="h-4 w-4" /> {item.statusMessage}
|
||
</span>
|
||
)
|
||
default:
|
||
if (item.parseError) {
|
||
return (
|
||
<span className="flex items-center gap-1 text-amber-600 text-xs">
|
||
<AlertTriangle className="h-4 w-4" /> {item.parseError}
|
||
</span>
|
||
)
|
||
}
|
||
return (
|
||
<span className="text-muted-foreground text-xs">待创建</span>
|
||
)
|
||
}
|
||
})()
|
||
|
||
return (
|
||
<div className="border rounded-lg p-4 space-y-3 bg-card">
|
||
<div className="flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-3 min-w-0">
|
||
<span className="text-sm font-medium text-muted-foreground">
|
||
#{index}
|
||
</span>
|
||
<span className="text-sm truncate" title={item.folderName}>
|
||
{item.folderName}
|
||
</span>
|
||
{statusNode}
|
||
</div>
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="h-8 w-8 text-red-600 hover:bg-red-50"
|
||
onClick={onRemove}
|
||
disabled={disabled || item.status === 'done'}
|
||
>
|
||
<Trash2 className="h-4 w-4" />
|
||
</Button>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-[auto,1fr] gap-4">
|
||
{/* 左侧:图片预览 */}
|
||
<div className="flex gap-2">
|
||
<div className="space-y-1">
|
||
<div className="relative w-32 h-32 rounded border bg-gray-50 overflow-hidden">
|
||
{item.referencePreview && (
|
||
<Image
|
||
src={item.referencePreview}
|
||
alt="图片1"
|
||
fill
|
||
className="object-contain"
|
||
sizes="128px"
|
||
unoptimized
|
||
/>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-center text-muted-foreground">图1 · reference</p>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<div className="relative w-32 h-32 rounded border bg-gray-50 overflow-hidden">
|
||
{item.riddlePreview && (
|
||
<Image
|
||
src={item.riddlePreview}
|
||
alt="图片2"
|
||
fill
|
||
className="object-contain"
|
||
sizes="128px"
|
||
unoptimized
|
||
/>
|
||
)}
|
||
</div>
|
||
<p className="text-[11px] text-center text-muted-foreground">图2 · riddle</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 右侧:可编辑字段 */}
|
||
<div className="grid grid-cols-2 gap-3">
|
||
<div className="col-span-2 space-y-1">
|
||
<Label className="text-xs">图片1 描述(anchor_text)</Label>
|
||
<Textarea
|
||
value={item.anchorText}
|
||
onChange={(e) => onChange({ anchorText: e.target.value })}
|
||
rows={2}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">答案 *</Label>
|
||
<Input
|
||
value={item.answer}
|
||
onChange={(e) => onChange({ answer: e.target.value })}
|
||
maxLength={ANSWER_MAX_LENGTH}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">谐音梗说明</Label>
|
||
<Input
|
||
value={item.punchline}
|
||
onChange={(e) => onChange({ punchline: e.target.value })}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
|
||
<div className="col-span-2 grid grid-cols-3 gap-2">
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">提示 1</Label>
|
||
<Input
|
||
value={item.hint1}
|
||
onChange={(e) => onChange({ hint1: e.target.value })}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">提示 2</Label>
|
||
<Input
|
||
value={item.hint2}
|
||
onChange={(e) => onChange({ hint2: e.target.value })}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
<div className="space-y-1">
|
||
<Label className="text-xs">提示 3</Label>
|
||
<Input
|
||
value={item.hint3}
|
||
onChange={(e) => onChange({ hint3: e.target.value })}
|
||
disabled={disabled}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|