From a7ef530841423dd473aab3aa419e09097c341aa5 Mon Sep 17 00:00:00 2001 From: selfrelease Date: Mon, 31 Aug 2026 17:04:33 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=AF=8F=E6=97=A5=E7=83=AD=E7=82=B9?= =?UTF-8?q?=E9=97=AE=E9=A2=98=E5=85=B1=E4=BA=AB=E5=BA=93(=E5=AE=9A?= =?UTF-8?q?=E6=97=B6=E7=94=9F=E6=88=90+=E7=AE=A1=E7=90=86=E5=91=98?= =?UTF-8?q?=E6=89=8B=E5=8A=A8=E6=9B=B4=E6=96=B0+=E7=82=B9=E5=87=BB?= =?UTF-8?q?=E5=9F=8B=E7=82=B9)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据库: - 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> --- api.py | 109 ++++++++++ bossai.py | 205 ++++++++++++++++++ db_init.py | 25 +++ web2/src/App.tsx | 3 +- web2/src/components/common/ChatPanel.tsx | 16 +- web2/src/lib/api.ts | 26 +++ web2/src/lib/nav-config.ts | 3 +- web2/src/pages/admin/AdminDailyTopics.tsx | 253 ++++++++++++++++++++++ web2/src/pages/admin/index.ts | 1 + 9 files changed, 638 insertions(+), 3 deletions(-) create mode 100644 web2/src/pages/admin/AdminDailyTopics.tsx diff --git a/api.py b/api.py index 12c32ab..11926a5 100644 --- a/api.py +++ b/api.py @@ -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)} diff --git a/bossai.py b/bossai.py index 0f2788c..5cf7a06 100644 --- a/bossai.py +++ b/bossai.py @@ -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: diff --git a/db_init.py b/db_init.py index 3557c08..2d50ad1 100644 --- a/db_init.py +++ b/db_init.py @@ -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,生产环境应让专家自行注册) diff --git a/web2/src/App.tsx b/web2/src/App.tsx index 95fae9f..de6f1aa 100644 --- a/web2/src/App.tsx +++ b/web2/src/App.tsx @@ -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() { } /> } /> } /> + } /> } /> diff --git a/web2/src/components/common/ChatPanel.tsx b/web2/src/components/common/ChatPanel.tsx index 4d95ce9..964c0f9 100644 --- a/web2/src/components/common/ChatPanel.tsx +++ b/web2/src/components/common/ChatPanel.tsx @@ -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({
{dynamicSamples.map((s) => ( - ))} diff --git a/web2/src/lib/api.ts b/web2/src/lib/api.ts index 48506b0..646e360 100644 --- a/web2/src/lib/api.ts +++ b/web2/src/lib/api.ts @@ -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 }>( + '/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('/knowledge', { method: 'POST', body: JSON.stringify(data) }), diff --git a/web2/src/lib/nav-config.ts b/web2/src/lib/nav-config.ts index e33a70a..e65197d 100644 --- a/web2/src/lib/nav-config.ts +++ b/web2/src/lib/nav-config.ts @@ -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: '管理后台' }, ] /** 按角色获取导航 */ diff --git a/web2/src/pages/admin/AdminDailyTopics.tsx b/web2/src/pages/admin/AdminDailyTopics.tsx new file mode 100644 index 0000000..879f04b --- /dev/null +++ b/web2/src/pages/admin/AdminDailyTopics.tsx @@ -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 = { + 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([]) + const [loading, setLoading] = useState(true) + const [generating, setGenerating] = useState(false) + const [editing, setEditing] = useState(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 ( + + + + ) + } + + return ( + + {generating ? : } + AI 生成 + + } + > + {/* 生成说明 */} + + + +

+ 每日凌晨3点自动生成当天4个场景的热点问题。点击"AI 生成"可立即触发。编辑后用户端实时生效。 +

+
+
+ + {topics.length === 0 ? ( + + ) : ( +
+ {topics.map((t) => ( + + +
+
+ {SCENE_LABELS[t.scene] || t.scene} + + {t.source === 'ai' ? 'AI 生成' : '管理员'} + + + {t.questions.length} 个问题 · {t.created_at.slice(0, 16)} + +
+ +
+
+ {t.questions.map((q, i) => ( + + {q} + + ))} +
+
+
+ ))} +
+ )} + + {/* 新增场景(如果某场景缺失) */} + {topics.length < 4 && ( + + )} + + {/* 编辑弹窗 */} + {editing && ( + setEditing(null)} + onSuccess={() => { load(); setEditing(null) }} + /> + )} + + {/* 创建弹窗 */} + {creating && ( + setCreating(false)} + onSuccess={() => { load(); setCreating(false) }} + /> + )} +
+ ) +} + +/** 编辑/创建热点问题弹窗 */ +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 ( + !o && onClose()}> + + + {isCreate ? '添加热点问题' : `编辑 - ${SCENE_LABELS[topic!.scene]}`} + 每行一个问题,保存后用户端实时生效 + + +
+ {isCreate && ( +
+ +
+ {Object.entries(SCENE_LABELS).map(([value, label]) => ( + + ))} +
+
+ )} + +
+ +