ux: 热点问题管理改为列表式编辑,支持逐条增删改+批量粘贴
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

This commit is contained in:
selfrelease
2026-09-01 10:00:03 +08:00
parent 06f046de1e
commit 944ba177bb
+67 -26
View File
@@ -1,6 +1,6 @@
import { useEffect, useState, useCallback } from 'react' import { useEffect, useState, useCallback } from 'react'
import { toast } from 'sonner' import { toast } from 'sonner'
import { Sparkles, Loader2, RefreshCw, Pencil, Calendar } from 'lucide-react' import { Sparkles, Loader2, RefreshCw, Pencil, Calendar, Plus, X } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout' import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState' import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState' import { LoadingState } from '@/components/common/LoadingState'
@@ -8,7 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Label } from '@/components/ui/label' import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea' import { Input } from '@/components/ui/input'
import { import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog' } from '@/components/ui/dialog'
@@ -160,7 +160,7 @@ export function AdminDailyTopics() {
) )
} }
/** 编辑/创建热点问题弹窗 */ /** 编辑/创建热点问题弹窗:列表式编辑,每条可单独修改/删除 */
function EditTopicsDialog({ function EditTopicsDialog({
topic, topic,
onClose, onClose,
@@ -172,23 +172,43 @@ function EditTopicsDialog({
}) { }) {
const isCreate = !topic const isCreate = !topic
const [scene, setScene] = useState(topic?.scene || 'legal') const [scene, setScene] = useState(topic?.scene || 'legal')
const [questionsText, setQuestionsText] = useState( const [questions, setQuestions] = useState<string[]>(topic?.questions || [''])
topic?.questions.join('\n') || '',
)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
/** 更新某行问题 */
const handleUpdate = (index: number, value: string) => {
setQuestions(prev => prev.map((q, i) => i === index ? value : q))
}
/** 删除某行问题 */
const handleDelete = (index: number) => {
setQuestions(prev => prev.filter((_, i) => i !== index))
}
/** 添加空行 */
const handleAdd = () => {
setQuestions(prev => [...prev, ''])
}
/** 批量粘贴:从剪贴板按行解析 */
const handlePaste = (e: React.ClipboardEvent) => {
const text = e.clipboardData.getData('text')
if (text.includes('\n') && text.trim().length > 50) {
e.preventDefault()
const lines = text.split('\n').map(s => s.trim()).filter(Boolean)
setQuestions(prev => [...prev.filter(s => s.trim()), ...lines])
}
}
const handleSave = async () => { const handleSave = async () => {
const questions = questionsText const valid = questions.map(s => s.trim()).filter(Boolean)
.split('\n') if (valid.length === 0) {
.map((s) => s.trim())
.filter(Boolean)
if (questions.length === 0) {
toast.error('至少输入一个问题') toast.error('至少输入一个问题')
return return
} }
setSaving(true) setSaving(true)
try { try {
await api.adminUpdateDailyTopics({ scene, questions }) await api.adminUpdateDailyTopics({ scene, questions: valid })
toast.success(isCreate ? '热点问题已添加' : '热点问题已更新') toast.success(isCreate ? '热点问题已添加' : '热点问题已更新')
onSuccess() onSuccess()
} catch (e) { } catch (e) {
@@ -200,17 +220,17 @@ function EditTopicsDialog({
return ( return (
<Dialog open onOpenChange={(o) => !o && onClose()}> <Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg"> <DialogContent className="max-w-2xl">
<DialogHeader> <DialogHeader>
<DialogTitle>{isCreate ? '添加热点问题' : `编辑 - ${SCENE_LABELS[topic!.scene]}`}</DialogTitle> <DialogTitle>{isCreate ? '添加热点问题' : `编辑 - ${SCENE_LABELS[topic!.scene]}`}</DialogTitle>
<DialogDescription>每行一个问题,保存后用户端实时生效</DialogDescription> <DialogDescription>保存后用户端实时生效。可逐条编辑,支持批量粘贴。</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4"> <div className="space-y-4">
{isCreate && ( {isCreate && (
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label>场景</Label> <Label>场景</Label>
<div className="flex gap-2"> <div className="flex flex-wrap gap-2">
{Object.entries(SCENE_LABELS).map(([value, label]) => ( {Object.entries(SCENE_LABELS).map(([value, label]) => (
<Button <Button
key={value} key={value}
@@ -226,17 +246,38 @@ function EditTopicsDialog({
)} )}
<div className="space-y-1.5"> <div className="space-y-1.5">
<Label htmlFor="questions">问题列表(每行一个)</Label> <div className="flex items-center justify-between">
<Textarea <Label>问题列表({questions.filter(s => s.trim()).length} 条)</Label>
id="questions" <Button size="sm" variant="ghost" onClick={handleAdd} className="h-7 text-xs">
value={questionsText} <Plus className="mr-1 h-3.5 w-3.5" /> 添加
onChange={(e) => setQuestionsText(e.target.value)} </Button>
placeholder={'每行一个问题,例如:\n新公司法下股东出资加速到期,我要补缴吗?\n配偶要求离婚,公司股权怎么分割损失最小?'} </div>
className="min-h-[300px] scrollbar-none" <div
/> className="max-h-[50vh] space-y-2 overflow-y-auto scrollbar-none rounded-md border p-2"
<p className="text-xs text-muted-foreground"> onPaste={handlePaste}
{questionsText.split('\n').filter(s => s.trim()).length} 个问题 >
</p> {questions.map((q, i) => (
<div key={i} className="flex items-start gap-2">
<span className="mt-2 shrink-0 text-xs tabular-nums text-muted-foreground">{i + 1}</span>
<Input
value={q}
onChange={(e) => handleUpdate(i, e.target.value)}
placeholder="输入问题..."
className="h-8 text-sm"
/>
<button
onClick={() => handleDelete(i)}
className="mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-red-50 hover:text-red-500"
aria-label="删除"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
))}
{questions.length === 0 && (
<p className="py-4 text-center text-xs text-muted-foreground">暂无问题,点击"添加"或粘贴</p>
)}
</div>
</div> </div>
</div> </div>