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:
@@ -18,6 +18,7 @@ from bossai import (
|
|||||||
ExpertStore, ConsultationStore, RatingStore,
|
ExpertStore, ConsultationStore, RatingStore,
|
||||||
DocumentGenerator, RegulationUpdateManager, FactFinder,
|
DocumentGenerator, RegulationUpdateManager, FactFinder,
|
||||||
ChatStore, redact,
|
ChatStore, redact,
|
||||||
|
DailyTopicStore, generate_daily_topics,
|
||||||
)
|
)
|
||||||
|
|
||||||
# ===== 日志脱敏 =====
|
# ===== 日志脱敏 =====
|
||||||
@@ -90,12 +91,50 @@ consultation_store = ConsultationStore()
|
|||||||
rating_store = RatingStore()
|
rating_store = RatingStore()
|
||||||
chat_store = ChatStore()
|
chat_store = ChatStore()
|
||||||
metrics = Metrics()
|
metrics = Metrics()
|
||||||
|
daily_topic_store = DailyTopicStore()
|
||||||
try:
|
try:
|
||||||
kimi = KimiClient()
|
kimi = KimiClient()
|
||||||
except RuntimeError:
|
except RuntimeError:
|
||||||
kimi = None
|
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")
|
@app.middleware("http")
|
||||||
async def metrics_middleware(request: Request, call_next):
|
async def metrics_middleware(request: Request, call_next):
|
||||||
@@ -2959,3 +2998,73 @@ def update_execution_result(matter_id: int, body: dict,
|
|||||||
return {"updated": True}
|
return {"updated": True}
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
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)}
|
||||||
|
|||||||
@@ -3170,6 +3170,211 @@ def build_profile_context(profile: UserProfile) -> str:
|
|||||||
return profile.to_context()
|
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:
|
class CryptoUtil:
|
||||||
|
|||||||
+25
@@ -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 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_user_read ON risk_alerts(user_phone, read_at);
|
||||||
CREATE INDEX IF NOT EXISTS idx_risk_alerts_created ON risk_alerts(created_at DESC);
|
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,生产环境应让专家自行注册)
|
# 专家种子数据(密码统一为 expert123,生产环境应让专家自行注册)
|
||||||
|
|||||||
+2
-1
@@ -17,7 +17,7 @@ import { Settings } from '@/pages/Settings'
|
|||||||
import { Login } from '@/pages/Login'
|
import { Login } from '@/pages/Login'
|
||||||
import { Placeholder } from '@/pages/Placeholder'
|
import { Placeholder } from '@/pages/Placeholder'
|
||||||
import { WorkbenchPendingServices, WorkbenchPending, WorkbenchReplied, WorkbenchUsers } from '@/pages/workbench'
|
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'
|
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/expert-activity" element={<ProtectedRoute allowedRoles={['admin']}><AdminExpertActivity /></ProtectedRoute>} />
|
||||||
<Route path="admin/users" element={<ProtectedRoute allowedRoles={['admin']}><AdminUsers /></ProtectedRoute>} />
|
<Route path="admin/users" element={<ProtectedRoute allowedRoles={['admin']}><AdminUsers /></ProtectedRoute>} />
|
||||||
<Route path="admin/experts" element={<ProtectedRoute allowedRoles={['admin']}><AdminExperts /></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 path="*" element={<Placeholder title="页面未找到" />} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
|
|||||||
@@ -400,6 +400,17 @@ export function ChatPanel({
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDynamicSamples(samples)
|
setDynamicSamples(samples)
|
||||||
}, [samples])
|
}, [samples])
|
||||||
|
|
||||||
|
/** 挂载时取当天热点问题,有则覆盖兜底示例 */
|
||||||
|
useEffect(() => {
|
||||||
|
api.getDailyTopics(scene)
|
||||||
|
.then((res) => {
|
||||||
|
if (res.questions?.length > 0) {
|
||||||
|
setDynamicSamples(res.questions)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(() => {/* 用兜底示例 */})
|
||||||
|
}, [scene])
|
||||||
const [destroying, setDestroying] = useState(false)
|
const [destroying, setDestroying] = useState(false)
|
||||||
/** 恢复历史中(从 ?sid= 断点续接) */
|
/** 恢复历史中(从 ?sid= 断点续接) */
|
||||||
const [restoringHistory, setRestoringHistory] = useState(false)
|
const [restoringHistory, setRestoringHistory] = useState(false)
|
||||||
@@ -1008,7 +1019,10 @@ export function ChatPanel({
|
|||||||
<div className="py-2">
|
<div className="py-2">
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{dynamicSamples.map((s) => (
|
{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}
|
{s}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -240,6 +240,32 @@ export const api = {
|
|||||||
method: 'POST', body: JSON.stringify({ scene, topic, existing }),
|
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 }) =>
|
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) }),
|
request<KnowledgeDoc>('/knowledge', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type { ComponentType } from 'react'
|
|||||||
import {
|
import {
|
||||||
LayoutDashboard, ClipboardList, Scale, TrendingUp, Heart, Users,
|
LayoutDashboard, ClipboardList, Scale, TrendingUp, Heart, Users,
|
||||||
BookOpen, History, User, Settings, ShieldCheck, FileText,
|
BookOpen, History, User, Settings, ShieldCheck, FileText,
|
||||||
BarChart3, Cpu, MessageSquare, UserCog, Briefcase,
|
BarChart3, Cpu, MessageSquare, UserCog, Briefcase, Sparkles,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
export interface NavItem {
|
export interface NavItem {
|
||||||
@@ -69,6 +69,7 @@ export const ADMIN_NAV: NavItem[] = [
|
|||||||
{ path: '/admin/expert-activity', label: '专家沟通', icon: MessageSquare, group: '管理后台' },
|
{ path: '/admin/expert-activity', label: '专家沟通', icon: MessageSquare, group: '管理后台' },
|
||||||
{ path: '/admin/users', label: '用户管理', icon: Users, group: '管理后台' },
|
{ path: '/admin/users', label: '用户管理', icon: Users, group: '管理后台' },
|
||||||
{ path: '/admin/experts', label: '专家管理', icon: UserCog, 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 { AdminExpertActivity } from './AdminExpertActivity'
|
||||||
export { AdminUsers } from './AdminUsers'
|
export { AdminUsers } from './AdminUsers'
|
||||||
export { AdminExperts } from './AdminExperts'
|
export { AdminExperts } from './AdminExperts'
|
||||||
|
export { AdminDailyTopics } from './AdminDailyTopics'
|
||||||
|
|||||||
Reference in New Issue
Block a user