duoqi-admin/src/components/settings/PushTemplateTab.tsx
Wang Zhuoxuan 0a31f8634e feat: 实现 Phase 3 — UGC 审核、举报处理、运营配置、多管理员
Phase 3a - UGC 审核队列:
- 题目列表添加来源 Tab 切换(全部/官方/用户投稿)
- UGC 审核对话框,支持通过/拒绝并填写备注
- 添加来源列和审核操作入口

Phase 3b - 举报处理:
- 举报列表页面,支持搜索和筛选
- 举报详情对话框,支持驳回/采纳处理
- 5 种举报原因和 4 种处理状态

Phase 3c - 运营配置:
- 设置页面使用 Tabs 布局
- 活动配置:XP 加成、时间范围、状态管理
- 推送文案:模板管理、变量支持、测试发送
- 通用设置:应用级配置项管理

Phase 3d - 多管理员支持:
- 用户名密码登录(替换 Token 登录)
- 管理员管理页面:创建、删除、重置密码
- 角色区分:admin(管理员)/ moderator(审核员)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-08 15:38:07 +08:00

353 lines
11 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.

import { useState, useEffect, useCallback } from "react"
import { Plus, Pencil, Trash2, Send, FileText } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table"
import { Badge } from "@/components/ui/badge"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { Textarea } from "@/components/ui/textarea"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import {
fetchPushTemplates,
createPushTemplate,
updatePushTemplate,
deletePushTemplate,
sendTestPush,
} from "@/lib/api/settings-api"
import { PUSH_TRIGGER_LABELS } from "@/lib/constants"
import type { PushTemplate, PushTriggerType, PushTemplateFormData } from "@/types/settings"
const triggerBadgeColors: Record<PushTriggerType, "default" | "secondary" | "outline"> = {
streak: "default",
achievement: "secondary",
event: "outline",
manual: "default",
}
export function PushTemplateTab() {
const [templates, setTemplates] = useState<PushTemplate[]>([])
const [loading, setLoading] = useState(true)
const [dialogOpen, setDialogOpen] = useState(false)
const [deleteOpen, setDeleteOpen] = useState(false)
const [editingTemplate, setEditingTemplate] = useState<PushTemplate | null>(null)
const [deletingTemplate, setDeletingTemplate] = useState<PushTemplate | null>(null)
const [submitting, setSubmitting] = useState(false)
const [testSending, setTestSending] = useState(false)
// 表单状态
const [formData, setFormData] = useState<PushTemplateFormData>({
name: "",
title: "",
body: "",
triggerType: "manual",
})
const loadTemplates = useCallback(async () => {
setLoading(true)
try {
const res = await fetchPushTemplates()
setTemplates(res.data)
} catch {
setTemplates([])
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
loadTemplates()
}, [loadTemplates])
function openCreateDialog() {
setEditingTemplate(null)
setFormData({
name: "",
title: "",
body: "",
triggerType: "manual",
})
setDialogOpen(true)
}
function openEditDialog(template: PushTemplate) {
setEditingTemplate(template)
setFormData({
name: template.name,
title: template.title,
body: template.body,
triggerType: template.triggerType,
})
setDialogOpen(true)
}
function openDeleteDialog(template: PushTemplate) {
setDeletingTemplate(template)
setDeleteOpen(true)
}
async function handleSubmit() {
setSubmitting(true)
try {
if (editingTemplate) {
await updatePushTemplate(editingTemplate.id, formData)
} else {
await createPushTemplate(formData)
}
setDialogOpen(false)
await loadTemplates()
} finally {
setSubmitting(false)
}
}
async function handleDelete() {
if (!deletingTemplate) return
await deletePushTemplate(deletingTemplate.id)
setDeleteOpen(false)
setDeletingTemplate(null)
await loadTemplates()
}
async function handleSendTest(template: PushTemplate) {
setTestSending(true)
try {
await sendTestPush(template.id)
// TODO: 显示成功提示
} catch {
// TODO: 显示错误提示
} finally {
setTestSending(false)
}
}
return (
<>
<div className="flex items-center justify-between">
<div>
<h2 className="text-lg font-semibold"></h2>
<p className="text-sm text-muted-foreground">
</p>
</div>
<Button onClick={openCreateDialog}>
<Plus className="size-4 mr-1" />
</Button>
</div>
{/* 可用变量说明 */}
<div className="rounded-lg border bg-muted/30 p-4 text-sm">
<h3 className="font-medium mb-2"></h3>
<p className="text-muted-foreground">
使 <code className="px-1 py-0.5 rounded bg-background">{"{{variable}}"}</code>
</p>
<ul className="mt-2 space-y-1 text-muted-foreground">
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{userName}}"}</code> - </li>
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{streakDays}}"}</code> - </li>
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{achievementName}}"}</code> - </li>
<li> <code className="px-1 py-0.5 rounded bg-background">{"{{eventName}}"}</code> - </li>
</ul>
</div>
<div className="rounded-lg border">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{loading ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
...
</TableCell>
</TableRow>
) : templates.length === 0 ? (
<TableRow>
<TableCell colSpan={5} className="h-24 text-center text-muted-foreground">
</TableCell>
</TableRow>
) : (
templates.map((template) => (
<TableRow key={template.id}>
<TableCell className="font-medium flex items-center gap-2">
<FileText className="size-4 text-muted-foreground" />
{template.name}
</TableCell>
<TableCell>
<Badge variant={triggerBadgeColors[template.triggerType]}>
{PUSH_TRIGGER_LABELS[template.triggerType]}
</Badge>
</TableCell>
<TableCell className="text-sm">{template.title}</TableCell>
<TableCell className="text-muted-foreground text-sm max-w-xs">
<span className="line-clamp-1">{template.body}</span>
</TableCell>
<TableCell>
<div className="flex gap-2">
<Button
variant="outline"
size="icon-xs"
onClick={() => handleSendTest(template)}
disabled={testSending}
title="发送测试推送"
>
<Send className="size-3.5" />
</Button>
<Button variant="ghost" size="icon-xs" onClick={() => openEditDialog(template)}>
<Pencil className="size-3.5" />
</Button>
<Button
variant="ghost"
size="icon-xs"
className="text-destructive"
onClick={() => openDeleteDialog(template)}
>
<Trash2 className="size-3.5" />
</Button>
</div>
</TableCell>
</TableRow>
))
)}
</TableBody>
</Table>
</div>
{/* 创建/编辑对话框 */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editingTemplate ? "编辑模板" : "新建模板"}</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<div>
<Label htmlFor="tpl-name"></Label>
<Input
id="tpl-name"
value={formData.name}
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
placeholder="例如:连胜提醒"
/>
</div>
<div>
<Label htmlFor="tpl-trigger"></Label>
<Select
value={formData.triggerType}
onValueChange={(val) => setFormData({ ...formData, triggerType: val as PushTriggerType })}
>
<SelectTrigger id="tpl-trigger">
<SelectValue />
</SelectTrigger>
<SelectContent>
{Object.entries(PUSH_TRIGGER_LABELS).map(([value, label]) => (
<SelectItem key={value} value={value}>
{label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div>
<Label htmlFor="tpl-title"></Label>
<Input
id="tpl-title"
value={formData.title}
onChange={(e) => setFormData({ ...formData, title: e.target.value })}
placeholder="例如:恭喜获得成就!"
/>
</div>
<div>
<Label htmlFor="tpl-body"></Label>
<Textarea
id="tpl-body"
value={formData.body}
onChange={(e) => setFormData({ ...formData, body: e.target.value })}
placeholder='例如:{{userName}},你已连续签到 {{streakDays}} 天!'
rows={4}
/>
<p className="text-xs text-muted-foreground mt-1">
使 {`{{variable}}`}
</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting}>
</Button>
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? "保存中..." : "保存"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
"{deletingTemplate?.name}"
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-destructive text-white hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
)
}