feat: 每日热点问题共享库(定时生成+管理员手动更新+点击埋点)
数据库: - daily_topics表:每天每场景一条热点问题记录 - sample_clicks表:记录用户点击示例问题行为 后端: - DailyTopicStore: CRUD + 埋点 + 点击统计 - generate_daily_topics: AI生成4场景各25-30个问题 - 定时任务:每天凌晨3点自动生成(后台线程轮询) - GET /topics/daily: 获取当天热点(公开) - POST /topics/click: 点击埋点 - GET /admin/daily-topics: 管理端查看 - POST /admin/daily-topics: 管理员手动更新 - POST /admin/daily-topics/generate: 手动触发AI生成 - GET /admin/sample-clicks: 点击统计 前端: - ChatPanel挂载时取当天热点,无则用硬编码兜底 - 点击示例问题时记录埋点 - AdminDailyTopics管理页面:查看/编辑/手动生成 - 路由 /admin/daily-topics + 导航配置 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
+2
-1
@@ -17,7 +17,7 @@ import { Settings } from '@/pages/Settings'
|
||||
import { Login } from '@/pages/Login'
|
||||
import { Placeholder } from '@/pages/Placeholder'
|
||||
import { WorkbenchPendingServices, WorkbenchPending, WorkbenchReplied, WorkbenchUsers } from '@/pages/workbench'
|
||||
import { AdminStats, AdminAiUsage, AdminExpertActivity, AdminUsers, AdminExperts } from '@/pages/admin'
|
||||
import { AdminStats, AdminAiUsage, AdminExpertActivity, AdminUsers, AdminExperts, AdminDailyTopics } from '@/pages/admin'
|
||||
import { isLoggedIn, getRole, type Role } from '@/lib/auth'
|
||||
|
||||
/** 路由守卫:未登录跳转登录页,角色不匹配跳转各自首页 */
|
||||
@@ -62,6 +62,7 @@ export default function App() {
|
||||
<Route path="admin/expert-activity" element={<ProtectedRoute allowedRoles={['admin']}><AdminExpertActivity /></ProtectedRoute>} />
|
||||
<Route path="admin/users" element={<ProtectedRoute allowedRoles={['admin']}><AdminUsers /></ProtectedRoute>} />
|
||||
<Route path="admin/experts" element={<ProtectedRoute allowedRoles={['admin']}><AdminExperts /></ProtectedRoute>} />
|
||||
<Route path="admin/daily-topics" element={<ProtectedRoute allowedRoles={['admin']}><AdminDailyTopics /></ProtectedRoute>} />
|
||||
<Route path="*" element={<Placeholder title="页面未找到" />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -400,6 +400,17 @@ export function ChatPanel({
|
||||
useEffect(() => {
|
||||
setDynamicSamples(samples)
|
||||
}, [samples])
|
||||
|
||||
/** 挂载时取当天热点问题,有则覆盖兜底示例 */
|
||||
useEffect(() => {
|
||||
api.getDailyTopics(scene)
|
||||
.then((res) => {
|
||||
if (res.questions?.length > 0) {
|
||||
setDynamicSamples(res.questions)
|
||||
}
|
||||
})
|
||||
.catch(() => {/* 用兜底示例 */})
|
||||
}, [scene])
|
||||
const [destroying, setDestroying] = useState(false)
|
||||
/** 恢复历史中(从 ?sid= 断点续接) */
|
||||
const [restoringHistory, setRestoringHistory] = useState(false)
|
||||
@@ -1008,7 +1019,10 @@ export function ChatPanel({
|
||||
<div className="py-2">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{dynamicSamples.map((s) => (
|
||||
<Button key={s} variant="outline" size="sm" onClick={() => setQuestion(s)} className="text-xs">
|
||||
<Button key={s} variant="outline" size="sm" onClick={() => {
|
||||
setQuestion(s)
|
||||
api.recordSampleClick(scene, s).catch(() => {})
|
||||
}} className="text-xs">
|
||||
{s}
|
||||
</Button>
|
||||
))}
|
||||
|
||||
@@ -240,6 +240,32 @@ export const api = {
|
||||
method: 'POST', body: JSON.stringify({ scene, topic, existing }),
|
||||
}),
|
||||
|
||||
// ===== 每日热点问题 =====
|
||||
getDailyTopics: (scene: Scene) =>
|
||||
request<{ scene: string; questions: string[] }>(`/topics/daily?scene=${scene}`),
|
||||
recordSampleClick: (scene: Scene, question: string) =>
|
||||
request<{ recorded: boolean }>('/topics/click', {
|
||||
method: 'POST', body: JSON.stringify({ scene, question }),
|
||||
}),
|
||||
|
||||
// ===== 管理端:热点问题 =====
|
||||
adminGetDailyTopics: (date?: string) =>
|
||||
request<{ results: Array<{ id: number; date: string; scene: string; questions: string[]; source: string; created_at: string }> }>(
|
||||
`/admin/daily-topics${date ? `?date=${date}` : ''}`,
|
||||
),
|
||||
adminUpdateDailyTopics: (data: { scene: string; questions: string[]; date?: string }) =>
|
||||
request<{ updated: boolean; topic: { id: number; date: string; scene: string; questions: string[]; source: string } }>(
|
||||
'/admin/daily-topics', { method: 'POST', body: JSON.stringify(data) },
|
||||
),
|
||||
adminGenerateDailyTopics: () =>
|
||||
request<{ generated: boolean; results: Record<string, { count: number; source: string; error?: string }> }>(
|
||||
'/admin/daily-topics/generate', { method: 'POST' },
|
||||
),
|
||||
adminSampleClickStats: (days = 14) =>
|
||||
request<{ results: Array<{ scene: string; question: string; clicks: number }> }>(
|
||||
`/admin/sample-clicks?days=${days}`,
|
||||
),
|
||||
|
||||
// ===== 知识库 =====
|
||||
addKnowledge: (data: { title: string; content: string; source?: string; doc_type?: string; region?: string | null; effective_date?: string | null; keywords?: string | null }) =>
|
||||
request<KnowledgeDoc>('/knowledge', { method: 'POST', body: JSON.stringify(data) }),
|
||||
|
||||
@@ -7,7 +7,7 @@ import type { ComponentType } from 'react'
|
||||
import {
|
||||
LayoutDashboard, ClipboardList, Scale, TrendingUp, Heart, Users,
|
||||
BookOpen, History, User, Settings, ShieldCheck, FileText,
|
||||
BarChart3, Cpu, MessageSquare, UserCog, Briefcase,
|
||||
BarChart3, Cpu, MessageSquare, UserCog, Briefcase, Sparkles,
|
||||
} from 'lucide-react'
|
||||
|
||||
export interface NavItem {
|
||||
@@ -69,6 +69,7 @@ export const ADMIN_NAV: NavItem[] = [
|
||||
{ path: '/admin/expert-activity', label: '专家沟通', icon: MessageSquare, group: '管理后台' },
|
||||
{ path: '/admin/users', label: '用户管理', icon: Users, group: '管理后台' },
|
||||
{ path: '/admin/experts', label: '专家管理', icon: UserCog, group: '管理后台' },
|
||||
{ path: '/admin/daily-topics', label: '热点问题', icon: Sparkles, group: '管理后台' },
|
||||
]
|
||||
|
||||
/** 按角色获取导航 */
|
||||
|
||||
@@ -0,0 +1,253 @@
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { toast } from 'sonner'
|
||||
import { Sparkles, Loader2, RefreshCw, Pencil, Calendar } from 'lucide-react'
|
||||
import { PageLayout } from '@/components/common/PageLayout'
|
||||
import { EmptyState } from '@/components/common/EmptyState'
|
||||
import { LoadingState } from '@/components/common/LoadingState'
|
||||
import { Card, CardContent } from '@/components/ui/card'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api'
|
||||
|
||||
/** 场景中文映射 */
|
||||
const SCENE_LABELS: Record<string, string> = {
|
||||
legal: '私人法务', decision: '经营决策', life: '生活助理', emotion: '情绪中心',
|
||||
}
|
||||
|
||||
interface DailyTopic {
|
||||
id: number
|
||||
date: string
|
||||
scene: string
|
||||
questions: string[]
|
||||
source: string
|
||||
created_at: string
|
||||
}
|
||||
|
||||
export function AdminDailyTopics() {
|
||||
const [topics, setTopics] = useState<DailyTopic[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [generating, setGenerating] = useState(false)
|
||||
const [editing, setEditing] = useState<DailyTopic | null>(null)
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.adminGetDailyTopics()
|
||||
setTopics(res.results)
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
/** 手动触发 AI 生成 */
|
||||
const handleGenerate = async () => {
|
||||
setGenerating(true)
|
||||
toast.info('AI 正在生成热点问题,预计 30-60 秒...')
|
||||
try {
|
||||
const res = await api.adminGenerateDailyTopics()
|
||||
const successCount = Object.values(res.results).filter(r => r.count > 0).length
|
||||
toast.success(`生成完成: ${successCount}/4 个场景成功`)
|
||||
load()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '生成失败')
|
||||
} finally {
|
||||
setGenerating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<PageLayout title="热点问题管理" description="每日 AI 自动生成的热点问题,可手动编辑或触发生成">
|
||||
<LoadingState />
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<PageLayout
|
||||
title="热点问题管理"
|
||||
description="每日凌晨3点 AI 自动生成,可手动编辑或触发生成"
|
||||
actions={
|
||||
<Button size="sm" onClick={handleGenerate} disabled={generating}>
|
||||
{generating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI 生成
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
{/* 生成说明 */}
|
||||
<Card className="mb-4 border-blue-200 bg-blue-50">
|
||||
<CardContent className="flex items-center gap-3 py-3">
|
||||
<Calendar className="h-4 w-4 shrink-0 text-blue-600" />
|
||||
<p className="text-xs text-blue-800">
|
||||
每日凌晨3点自动生成当天4个场景的热点问题。点击"AI 生成"可立即触发。编辑后用户端实时生效。
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{topics.length === 0 ? (
|
||||
<EmptyState
|
||||
title="今日暂无热点问题"
|
||||
description="点击右上角'AI 生成'立即生成,或等待凌晨自动生成"
|
||||
icon={Sparkles}
|
||||
/>
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{topics.map((t) => (
|
||||
<Card key={t.id}>
|
||||
<CardContent className="p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="secondary">{SCENE_LABELS[t.scene] || t.scene}</Badge>
|
||||
<Badge variant={t.source === 'ai' ? 'default' : 'outline'}>
|
||||
{t.source === 'ai' ? 'AI 生成' : '管理员'}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{t.questions.length} 个问题 · {t.created_at.slice(0, 16)}
|
||||
</span>
|
||||
</div>
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(t)}>
|
||||
<Pencil className="h-3.5 w-3.5" /> 编辑
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{t.questions.map((q, i) => (
|
||||
<span key={i} className="rounded-md border px-2 py-1 text-xs">
|
||||
{q}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 新增场景(如果某场景缺失) */}
|
||||
{topics.length < 4 && (
|
||||
<Button className="mt-4" variant="outline" onClick={() => setCreating(true)}>
|
||||
<RefreshCw className="h-3.5 w-3.5" /> 手动添加场景
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 编辑弹窗 */}
|
||||
{editing && (
|
||||
<EditTopicsDialog
|
||||
topic={editing}
|
||||
onClose={() => setEditing(null)}
|
||||
onSuccess={() => { load(); setEditing(null) }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 创建弹窗 */}
|
||||
{creating && (
|
||||
<EditTopicsDialog
|
||||
topic={null}
|
||||
onClose={() => setCreating(false)}
|
||||
onSuccess={() => { load(); setCreating(false) }}
|
||||
/>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
/** 编辑/创建热点问题弹窗 */
|
||||
function EditTopicsDialog({
|
||||
topic,
|
||||
onClose,
|
||||
onSuccess,
|
||||
}: {
|
||||
topic: DailyTopic | null
|
||||
onClose: () => void
|
||||
onSuccess: () => void
|
||||
}) {
|
||||
const isCreate = !topic
|
||||
const [scene, setScene] = useState(topic?.scene || 'legal')
|
||||
const [questionsText, setQuestionsText] = useState(
|
||||
topic?.questions.join('\n') || '',
|
||||
)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const handleSave = async () => {
|
||||
const questions = questionsText
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
if (questions.length === 0) {
|
||||
toast.error('至少输入一个问题')
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await api.adminUpdateDailyTopics({ scene, questions })
|
||||
toast.success(isCreate ? '热点问题已添加' : '热点问题已更新')
|
||||
onSuccess()
|
||||
} catch (e) {
|
||||
toast.error(e instanceof Error ? e.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open onOpenChange={(o) => !o && onClose()}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{isCreate ? '添加热点问题' : `编辑 - ${SCENE_LABELS[topic!.scene]}`}</DialogTitle>
|
||||
<DialogDescription>每行一个问题,保存后用户端实时生效</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{isCreate && (
|
||||
<div className="space-y-1.5">
|
||||
<Label>场景</Label>
|
||||
<div className="flex gap-2">
|
||||
{Object.entries(SCENE_LABELS).map(([value, label]) => (
|
||||
<Button
|
||||
key={value}
|
||||
size="sm"
|
||||
variant={scene === value ? 'default' : 'outline'}
|
||||
onClick={() => setScene(value)}
|
||||
>
|
||||
{label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="questions">问题列表(每行一个)</Label>
|
||||
<Textarea
|
||||
id="questions"
|
||||
value={questionsText}
|
||||
onChange={(e) => setQuestionsText(e.target.value)}
|
||||
placeholder={'每行一个问题,例如:\n新公司法下股东出资加速到期,我要补缴吗?\n配偶要求离婚,公司股权怎么分割损失最小?'}
|
||||
className="min-h-[300px] scrollbar-none"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{questionsText.split('\n').filter(s => s.trim()).length} 个问题
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={onClose}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -4,3 +4,4 @@ export { AdminAiUsage } from './AdminAiUsage'
|
||||
export { AdminExpertActivity } from './AdminExpertActivity'
|
||||
export { AdminUsers } from './AdminUsers'
|
||||
export { AdminExperts } from './AdminExperts'
|
||||
export { AdminDailyTopics } from './AdminDailyTopics'
|
||||
|
||||
Reference in New Issue
Block a user