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
+109
View File
@@ -18,6 +18,7 @@ from bossai import (
ExpertStore, ConsultationStore, RatingStore,
DocumentGenerator, RegulationUpdateManager, FactFinder,
ChatStore, redact,
DailyTopicStore, generate_daily_topics,
)
# ===== 日志脱敏 =====
@@ -90,12 +91,50 @@ consultation_store = ConsultationStore()
rating_store = RatingStore()
chat_store = ChatStore()
metrics = Metrics()
daily_topic_store = DailyTopicStore()
try:
kimi = KimiClient()
except RuntimeError:
kimi = None
# ===== 每日热点问题定时任务(每天凌晨3点) =====
def _daily_topics_scheduler():
"""后台线程:每天凌晨3点自动生成热点问题。
使用轮询方式实现,避免引入 APScheduler 依赖。
AI 不可用时跳过,保留已有数据。
"""
import threading
from datetime import datetime, time as dt_time
def run():
while True:
now = datetime.now()
# 计算到下一个凌晨3点的秒数
target = datetime.combine(now.date(), dt_time(3, 0))
if now >= target:
# 今天3点已过,等明天
from datetime import timedelta
target = target + timedelta(days=1)
wait_seconds = (target - now).total_seconds()
threading.Event().wait(timeout=wait_seconds)
# 执行生成
if kimi:
try:
logging.info("开始生成每日热点问题...")
results = generate_daily_topics(kimi, daily_topic_store)
logging.info(f"每日热点问题生成完成: {results}")
except Exception as e:
logging.warning(f"每日热点问题生成失败: {type(e).__name__}: {e}")
thread = threading.Thread(target=run, daemon=True, name="daily-topics-scheduler")
thread.start()
_daily_topics_scheduler()
# ===== 请求计时 + 日志脱敏中间件 =====
@app.middleware("http")
async def metrics_middleware(request: Request, call_next):
@@ -2959,3 +2998,73 @@ def update_execution_result(matter_id: int, body: dict,
return {"updated": True}
finally:
conn.close()
# ===== 每日热点问题库 =====
@app.get("/topics/daily")
def get_daily_topics(scene: str):
"""获取当天热点问题(公开接口,无需登录)。
无当天数据时返回空数组,前端用硬编码兜底。
"""
if scene not in DailyTopicStore.SCENES:
raise HTTPException(400, f"场景应为 {DailyTopicStore.SCENES} 之一")
return {"scene": scene, "questions": daily_topic_store.get_today(scene)}
@app.post("/topics/click")
def record_sample_click(body: dict, authorization: str | None = Header(default=None)):
"""记录用户点击示例问题(埋点)。"""
scene = body.get("scene", "")
question = body.get("question", "")
if scene not in DailyTopicStore.SCENES or not question:
raise HTTPException(400, "缺少 scene 或 question")
phone = get_current_phone(authorization)
daily_topic_store.record_click(scene, question, phone)
return {"recorded": True}
# ===== 管理端:热点问题管理 =====
@app.get("/admin/daily-topics")
def admin_get_daily_topics(date: str | None = None):
"""管理端:获取指定日期(默认今天)所有场景的热点问题。"""
from datetime import date as date_cls
target = date or str(date_cls.today())
return {"results": daily_topic_store.get_by_date(target)}
class DailyTopicUpdateRequest(BaseModel):
"""管理员手动更新热点问题。"""
scene: str
questions: list[str]
date: str | None = None # 指定日期,默认今天
@app.post("/admin/daily-topics")
def admin_update_daily_topics(req: DailyTopicUpdateRequest):
"""管理端:手动更新某场景的热点问题。"""
if req.scene not in DailyTopicStore.SCENES:
raise HTTPException(400, f"场景应为 {DailyTopicStore.SCENES} 之一")
if not req.questions:
raise HTTPException(400, "问题列表不能为空")
result = daily_topic_store.upsert(
req.scene, req.questions, source="admin", date_str=req.date,
)
return {"updated": True, "topic": result}
@app.post("/admin/daily-topics/generate")
def admin_generate_daily_topics():
"""管理端:手动触发 AI 生成当天热点问题(4个场景)。"""
if not kimi:
raise HTTPException(503, "AI 服务不可用,请检查 KIMI_API_KEY 配置")
results = generate_daily_topics(kimi, daily_topic_store)
return {"generated": True, "results": results}
@app.get("/admin/sample-clicks")
def admin_sample_click_stats(days: int = 14):
"""管理端:获取示例问题点击统计。"""
return {"results": daily_topic_store.click_stats(days)}
+205
View File
@@ -3170,6 +3170,211 @@ def build_profile_context(profile: UserProfile) -> str:
return profile.to_context()
# ===== 每日热点问题库 =====
class DailyTopicStore:
"""每日热点问题库:每天 AI 生成或管理员手动更新。
每个场景(legal/decision/life/emotion)每天一条记录,
questions 为 JSONB 数组,前端取当天问题展示给用户。
"""
SCENES = ["legal", "decision", "life", "emotion"]
def __init__(self, dsn: str | None = None):
self.dsn = dsn or os.getenv(
"DATABASE_URL", "host=localhost port=5432 dbname=mcp user=postgres"
)
def _conn(self):
return psycopg2.connect(self.dsn)
def get_today(self, scene: str) -> list[str]:
"""获取当天热点问题,无记录返回空列表。"""
from datetime import date
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT questions FROM daily_topics "
"WHERE date = %s AND scene = %s",
(date.today(), scene),
)
r = cur.fetchone()
return r[0] if r else []
def get_by_date(self, date_str: str) -> list[dict]:
"""获取指定日期所有场景的热点问题(管理端用)。"""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT id, date, scene, questions, source, created_at "
"FROM daily_topics WHERE date = %s ORDER BY scene",
(date_str,),
)
rows = cur.fetchall()
return [
{"id": r[0], "date": str(r[1]), "scene": r[2],
"questions": r[3], "source": r[4], "created_at": str(r[5])}
for r in rows
]
def upsert(self, scene: str, questions: list[str],
source: str = "ai", date_str: str | None = None) -> dict:
"""插入或更新当天(或指定日期)的热点问题。
Args:
scene: 场景 legal/decision/life/emotion
questions: 问题列表
source: ai(自动生成) / admin(管理员手动)
date_str: 指定日期(YYYY-MM-DD),默认今天
Returns:
更新后的记录 dict
"""
from datetime import date
import json
target_date = date_str if date_str else str(date.today())
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO daily_topics (date, scene, questions, source) "
"VALUES (%s, %s, %s, %s) "
"ON CONFLICT (date, scene) DO UPDATE "
"SET questions = EXCLUDED.questions, source = EXCLUDED.source, "
"created_at = now() "
"RETURNING id, date, scene, questions, source, created_at",
(target_date, scene, json.dumps(questions), source),
)
r = cur.fetchone()
return {"id": r[0], "date": str(r[1]), "scene": r[2],
"questions": r[3], "source": r[4], "created_at": str(r[5])}
def record_click(self, scene: str, question: str,
user_phone: str | None = None) -> bool:
"""记录用户点击示例问题(埋点)。"""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"INSERT INTO sample_clicks (user_phone, scene, question) "
"VALUES (%s, %s, %s)",
(user_phone, scene, question),
)
return cur.rowcount > 0
def click_stats(self, days: int = 14) -> list[dict]:
"""获取近 N 天示例问题点击统计(管理端用)。"""
with self._conn() as conn:
with conn.cursor() as cur:
cur.execute(
"SELECT scene, question, COUNT(*) as clicks "
"FROM sample_clicks "
"WHERE clicked_at >= now() - interval '%s days' "
"GROUP BY scene, question ORDER BY clicks DESC LIMIT 100",
(days,),
)
rows = cur.fetchall()
return [
{"scene": r[0], "question": r[1], "clicks": r[2]}
for r in rows
]
def generate_daily_topics(kimi_client, store: DailyTopicStore) -> dict:
"""AI 生成当天 4 个场景的热点问题并存库。
每个场景生成 20-30 个问题,聚焦老板焦虑和关注点。
AI 不可用时跳过(保留已有数据)。
Returns:
各场景生成结果 {scene: {count, source}}
"""
from datetime import datetime
scene_desc = {
"legal": "法律/法务问题,聚焦中小企业老板的焦虑:劳动用工风险、合同纠纷、债权债务、股东纠纷、税务风险、诉讼仲裁、婚姻财产分割、股权分配、知识产权、行政处罚等",
"decision": "经营决策问题,聚焦中小企业老板的焦虑:扩张收缩、项目承接、定价策略、招人裁员、融资合伙、业务转型、现金流管理、竞争应对、投资决策等",
"life": "个人生活问题,聚焦中小企业老板的焦虑:家庭与事业平衡、子女教育规划、父母养老、健康风险、睡眠压力、时间管理、财富传承等",
"emotion": "情绪与心理问题,聚焦中小企业老板的焦虑:创业压力、合伙人冲突、孤独感、决策焦虑、拖延症、自我怀疑、危机应对等",
}
# 获取当前日期用于 prompt
today_str = datetime.now().strftime("%Y年%m月%d日")
# 获取当前热点事件提示(简单版:用日期季节推断)
month = datetime.now().month
season_hints = {
1: "年初:年度规划、去年总结、新政策生效、春节前后用工",
2: "春节后:招工难、复工、年度目标分解",
3: "两会政策、年报截止、个税汇算",
4: "Q1复盘、社保基数调整预告、春招",
5: "劳动节、用工合规检查、年中冲刺",
6: "年中复盘、半年报、高温补贴、用电高峰",
7: "下半年规划、社保基数调整、学生毕业季",
8: "秋招准备、Q3冲刺、税务自查",
9: "Q3复盘、中秋、国庆备货、年度预算",
10: "国庆后冲刺、年终规划、个税专项附加扣除",
11: "年底冲刺、年终奖规划、来年预算、欠薪风险",
12: "年终总结、欠薪清查、税务清算、年度复盘、春节前用工",
}
season = season_hints.get(month, "")
results = {}
for scene, desc in scene_desc.items():
prompt = f"""你是左右(BossAi)的内容编辑,负责为中小企业老板生成今日热点问题。
当前日期:{today_str}
季节性背景:{season}
场景:{desc}
请生成25-30个该场景下老板最焦虑、最关注的热点问题。
要求:
1. 问题必须从老板/雇主视角出发(BossAi 用户是企业老板)
2. 结合当前季节性和时事热点(如政策变化、节日前后、经济形势)
3. 问题要具体、有场景感、能直接开问,不要空泛
4. 涵盖该场景下不同方向,不要集中在某一类
5. 问题长度 10-30 字
6. 返回JSON数组格式:["问题1","问题2",...]
7. 只返回JSON,不要其他文字"""
try:
answer = kimi_client.chat(
[{"role": "user", "content": prompt}],
temperature=0.8, timeout=90,
)
answer = answer.strip()
if answer.startswith("```"):
answer = answer.split("\n", 1)[1] if "\n" in answer else answer[3:]
if answer.endswith("```"):
answer = answer[:-3]
answer = answer.strip().replace("\u201c", '"').replace("\u201d", '"')
start = answer.find("[")
end = answer.rfind("]")
if start == -1 or end == -1:
results[scene] = {"count": 0, "source": "ai", "error": "JSON解析失败"}
continue
questions = json.loads(answer[start:end + 1])
# 过滤和去重
valid = []
seen = set()
for q in questions:
q = str(q).strip()
if q and q not in seen and len(q) <= 50:
valid.append(q)
seen.add(q)
if len(valid) < 10:
results[scene] = {"count": 0, "source": "ai", "error": "有效问题不足10个"}
continue
store.upsert(scene, valid[:30], source="ai")
results[scene] = {"count": len(valid[:30]), "source": "ai"}
except Exception as e:
import logging
logging.warning(f"generate_daily_topics[{scene}] 失败: {type(e).__name__}: {e}")
results[scene] = {"count": 0, "source": "ai", "error": str(e)}
return results
# ===== 对话历史落库 =====
class CryptoUtil:
+25
View File
@@ -249,6 +249,31 @@ CREATE TABLE IF NOT EXISTS risk_alerts (
CREATE INDEX IF NOT EXISTS idx_risk_alerts_user ON risk_alerts(user_phone);
CREATE INDEX IF NOT EXISTS idx_risk_alerts_user_read ON risk_alerts(user_phone, read_at);
CREATE INDEX IF NOT EXISTS idx_risk_alerts_created ON risk_alerts(created_at DESC);
-- 每日热点问题库:每天凌晨 AI 生成,管理员可手动更新
CREATE TABLE IF NOT EXISTS daily_topics (
id SERIAL PRIMARY KEY,
date DATE NOT NULL, -- 生效日期
scene TEXT NOT NULL, -- legal/decision/life/emotion
questions JSONB NOT NULL, -- 问题数组 ["问题1","问题2",...]
source TEXT NOT NULL DEFAULT 'ai', -- ai(自动生成) / admin(管理员手动)
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- 唯一约束:同一天同一场景只有一条记录(更新时覆盖)
CREATE UNIQUE INDEX IF NOT EXISTS idx_daily_topics_date_scene ON daily_topics(date, scene);
CREATE INDEX IF NOT EXISTS idx_daily_topics_date ON daily_topics(date DESC);
-- 示例问题点击埋点:记录用户点击哪些示例问题
CREATE TABLE IF NOT EXISTS sample_clicks (
id SERIAL PRIMARY KEY,
user_phone TEXT,
scene TEXT NOT NULL, -- legal/decision/life/emotion
question TEXT NOT NULL, -- 被点击的示例问题
clicked_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_sample_clicks_scene ON sample_clicks(scene);
CREATE INDEX IF NOT EXISTS idx_sample_clicks_clicked ON sample_clicks(clicked_at DESC);
"""
# 专家种子数据(密码统一为 expert123,生产环境应让专家自行注册)
+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'