feat: 每日热点问题共享库(定时生成+管理员手动更新+点击埋点)
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

数据库:
- 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:
selfrelease
2026-08-31 17:04:33 +08:00
co-authored by Devin
parent 35cc6e54ea
commit a7ef530841
9 changed files with 638 additions and 3 deletions
+2 -1
View File
@@ -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>
+15 -1
View File
@@ -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>
))}
+26
View File
@@ -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) }),
+2 -1
View File
@@ -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: '管理后台' },
]
/** 按角色获取导航 */
+253
View File
@@ -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>
)
}
+1
View File
@@ -4,3 +4,4 @@ export { AdminAiUsage } from './AdminAiUsage'
export { AdminExpertActivity } from './AdminExpertActivity'
export { AdminUsers } from './AdminUsers'
export { AdminExperts } from './AdminExperts'
export { AdminDailyTopics } from './AdminDailyTopics'