diff --git a/.gitignore b/.gitignore
index 3d4a46e..5480110 100644
--- a/.gitignore
+++ b/.gitignore
@@ -11,6 +11,8 @@ venv/
# 前端
web/node_modules/
web/dist/
+web2/node_modules/
+web2/dist/
# 上传文件(专家头像等,不入 Git)
uploads/
diff --git a/api.py b/api.py
index d7ac383..a3eeb2d 100644
--- a/api.py
+++ b/api.py
@@ -292,7 +292,7 @@ def generate_topics(req: TopicsRequest):
# 内置话题(降级用)
builtin_topics = {
"legal": [
- {"title": "劳动用工", "desc": "工伤、辞退、欠薪、社保", "samples": ["员工工伤后能否开除?", "拖欠工资怎么追讨?", "未签合同双倍工资怎么算?", "社保没缴全怎么补?"]},
+ {"title": "劳动用工", "desc": "工伤、辞退、欠薪、社保", "samples": ["员工工伤后能开除吗?", "员工被辞退要赔多少?", "员工不签合同有何风险?", "社保未缴全被查怎么办?"]},
{"title": "合同审查", "desc": "违约、条款、效力", "samples": ["合同违约金怎么约定?", "对方不履行合同怎么办?", "口头协议有法律效力吗?", "合同条款不公平能改吗?"]},
{"title": "债权债务", "desc": "欠款、催收、诉讼", "samples": ["客户拖欠货款3个月如何催收?", "借条怎么写才有效?", "债务人不还钱能报警吗?", "超过诉讼时效怎么办?"]},
{"title": "股东纠纷", "desc": "股权、分红、退股", "samples": ["合伙人要退股怎么谈?", "股东不出资怎么办?", "分红比例怎么定?", "小股东权益被侵害怎么办?"]},
@@ -354,8 +354,9 @@ def generate_topics(req: TopicsRequest):
1. 话题要覆盖该场景下不同方向,不要只集中在某一类
2. 结合用户画像(如有)推荐个性化话题
3. 示例问题要具体、贴近中小企业老板实际
-4. 返回JSON数组格式:[{{"title":"话题名","desc":"简短描述","samples":["问题1","问题2","问题3","问题4"]}}]
-5. 只返回JSON,不要其他文字{existing_str}"""
+4. **所有问题必须从老板/雇主视角出发**(BossAi 的用户是企业老板,不是员工)。例如劳动用工应问"员工工伤后能开除吗"而非"拖欠工资怎么追讨"
+5. 返回JSON数组格式:[{{"title":"话题名","desc":"简短描述","samples":["问题1","问题2","问题3","问题4"]}}]
+6. 只返回JSON,不要其他文字{existing_str}"""
try:
answer = kimi.chat([{"role": "user", "content": prompt}], temperature=0.8)
@@ -396,7 +397,7 @@ def generate_samples(req: SampleRequest):
"""
# 内置示例(降级用)
builtin = {
- "legal": ["员工工伤后能否开除?", "收到税务稽查通知怎么办?", "客户拖欠货款 3 个月如何催收?", "合同违约金怎么约定?"],
+ "legal": ["员工工伤后能开除吗?", "收到税务稽查通知怎么办?", "客户拖欠货款 3 个月如何催收?", "合同违约金怎么约定?"],
"decision": ["是否应该接一个付款周期 90 天的大单?", "现在适合扩张开第二家店吗?", "该不该降价 15% 抢竞品的客户?", "合伙人要退股,怎么谈条件?"],
"life": ["怎么和青春期的孩子沟通不想他接班的事?", "父母年纪大了,养老方案怎么选?", "最近睡眠很差,有什么改善建议?", "体检报告有几项异常,需要进一步检查吗?"],
"emotion": ["最近压力很大,睡不着觉", "合伙人背叛了,我很愤怒", "明天的谈判我拖延了一周没准备", "感觉撑不下去了"],
@@ -429,9 +430,10 @@ def generate_samples(req: SampleRequest):
prompt += (
"要求:\n"
"1. 问题真实、具体、贴近中小企业老板的实际场景\n"
- "2. 每个问题 15-30 字\n"
- "3. 不要重复已展示过的问题\n"
- "4. 只返回 JSON 数组,如 [\"问题1\", \"问题2\", \"问题3\", \"问题4\"]\n"
+ "2. **所有问题必须从老板/雇主视角出发**(用户是企业老板,不是员工)\n"
+ "3. 每个问题 15-30 字\n"
+ "4. 不要重复已展示过的问题\n"
+ "5. 只返回 JSON 数组,如 [\"问题1\", \"问题2\", \"问题3\", \"问题4\"]\n"
)
try:
@@ -485,6 +487,16 @@ def create_session(req: SessionRequest):
return {"session_id": s.session_id, "mode": s.mode, "expires_at": s.expires_at}
+@app.get("/sessions/{sid}/alive")
+def session_alive(sid: str):
+ """检查内存 session 是否仍然存活(后端重启后内存 session 会丢失)。"""
+ try:
+ sessions._active(sid)
+ return {"alive": True}
+ except KeyError:
+ raise HTTPException(410, "session not found")
+
+
@app.post("/sessions/{sid}/ask")
def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=None)):
# 从 token 解析手机号(用于用户画像)
@@ -518,9 +530,19 @@ def ask(sid: str, req: AskRequest, authorization: str | None = Header(default=No
prompt = prompt + "\n\n" + ctx
rag_context = knowledge.search(req.question, limit=3,
doc_types=knowledge.SCENE_DOC_TYPES.get(req.scene))
- # 带降级的云端调用
+ # 带降级的云端调用(注入 session 历史实现多轮上下文)
+ chat_messages = [{"role": "system", "content": prompt}]
+ # 取最近的历史消息(排除当前刚 append 的 user 消息)
+ history = session.messages[:-1] if session.messages else []
+ recent_history = history[-6:] if len(history) > 6 else history
+ for h in recent_history:
+ role = h.get("role", "user")
+ content = h.get("content", "")
+ if role in ("user", "assistant") and content:
+ chat_messages.append({"role": role, "content": content})
+ chat_messages.append({"role": "user", "content": req.question})
result = kimi.chat_with_fallback(
- [{"role": "system", "content": prompt}, {"role": "user", "content": req.question}],
+ chat_messages,
local_fallback=card.conclusion,
)
answer = result["answer"]
@@ -603,8 +625,12 @@ def ask_analyze(sid: str, req: AskRequest, authorization: str | None = Header(de
# 事实追问分析(AI 生成追问 + 规则兜底)
answers = [a.model_dump() for a in (req.fact_answers or [])]
+ # 获取 session 历史消息,让追问能理解多轮上下文
+ session = sessions.sessions.get(sid)
+ session_history = session.messages[:-1] if session and session.messages else []
prompt_or_questions, meta = fact_finder.build_analysis_prompt(
req.question, req.scene, answers, kimi_client=kimi,
+ history=session_history,
)
# 收集用户画像中的已知事实(带入内容),按场景过滤相关性
@@ -807,7 +833,7 @@ def generate_element_filing(sid: str, req: ElementFilingRequest, authorization:
# AI 填充要素
elements_json = json.dumps(tmpl.get("elements", []), ensure_ascii=False)
- prompt = f"""你是法律文书助手。根据用户描述和确认的事实,填充要素式起诉状的各要素。
+ prompt = f"""你是法律文书助手,服务对象是中小企业老板(原告方)。根据用户描述和确认的事实,填充要素式起诉状的各要素。
案由:{tmpl.get("cause_of_action", req.cause_of_action)}
要素模板:{elements_json}
@@ -938,12 +964,23 @@ def ask_stream(sid: str, req: AskRequest, authorization: str | None = Header(def
# 1. 先发风险卡
yield f"event: risk\ndata: {json.dumps(card.__dict__, ensure_ascii=False)}\n\n"
- # 2. 流式答复
+ # 2. 流式答复(注入 session 历史实现多轮上下文)
if kimi:
- messages = [
- {"role": "system", "content": prompt},
- {"role": "user", "content": req.question},
- ]
+ # 构建 messages:system + 历史对话(最近 6 条) + 当前问题
+ messages = [{"role": "system", "content": prompt}]
+ # 取最近的历史消息(排除当前刚 append 的 user 消息)
+ history = session.messages[:-1] if session.messages else []
+ recent_history = history[-6:] if len(history) > 6 else history
+ for h in recent_history:
+ role = h.get("role", "user")
+ content = h.get("content", "")
+ if role in ("user", "assistant") and content:
+ messages.append({"role": role, "content": content})
+ # 当前问题(带事实摘要)
+ current_content = req.question
+ if fact_summary:
+ current_content = f"{req.question}\n\n已确认事实:\n{fact_summary}"
+ messages.append({"role": "user", "content": current_content})
try:
for token in kimi.chat_stream(messages):
full_answer.append(token)
@@ -1457,6 +1494,7 @@ def list_history_sessions(
@app.get("/history/sessions/{session_id}")
def get_history_messages(
session_id: str,
+ request: Request,
authorization: str | None = Header(default=None),
):
"""获取会话的对话历史。
@@ -1484,6 +1522,19 @@ def get_history_messages(
"message_count": len(messages),
}
# 普通模式:直接返回明文
+ # 2.0 新增:如果内存 session 不存在,重建内存 session 并加载历史消息
+ try:
+ sessions._active(session_id)
+ except KeyError:
+ new_session = sessions.create("normal")
+ old_session = sessions.sessions.pop(new_session.session_id)
+ old_session.session_id = session_id
+ sessions.sessions[session_id] = old_session
+ for msg in messages:
+ role = msg.get("role", "user")
+ content = msg.get("content", "")
+ if role in ("user", "assistant") and content:
+ sessions.append(session_id, role, content)
return {
"session_id": session_id,
"mode": "normal",
@@ -1519,6 +1570,25 @@ def verify_and_get_history(
messages = chat_store.get_messages(session_id, phone, decrypt=True)
if not messages:
raise HTTPException(404, "会话不存在或无历史")
+
+ # 2.0 新增:如果内存 session 不存在(后端重启后丢失),重建内存 session 并加载历史消息
+ # 这样后续 ask-analyze / ask-stream 能复用 session 且有上下文
+ try:
+ sessions._active(session_id)
+ except KeyError:
+ # 内存 session 不存在,创建新的 confidential session 然后替换 session_id
+ new_session = sessions.create("confidential")
+ # 把新 session 的 session_id 替换为旧的 session_id(保留 DB 关联)
+ old_session = sessions.sessions.pop(new_session.session_id)
+ old_session.session_id = session_id
+ sessions.sessions[session_id] = old_session
+ # 加载历史消息到内存 session
+ for msg in messages:
+ role = msg.get("role", "user")
+ content = msg.get("content", "")
+ if role in ("user", "assistant") and content:
+ sessions.append(session_id, role, content)
+
return {
"session_id": session_id,
"mode": "confidential",
@@ -2009,3 +2079,634 @@ from fastapi.staticfiles import StaticFiles
_upload_dir = os.path.join(os.path.dirname(__file__), "uploads")
os.makedirs(_upload_dir, exist_ok=True)
app.mount("/uploads", StaticFiles(directory=_upload_dir), name="uploads")
+
+
+# ============================================================
+# BossAi 2.0 新增端点:案卡 / 老板台 / 我的团队 / 快捷指令
+# ============================================================
+
+import psycopg2
+from datetime import datetime, timezone
+
+_DSN = os.getenv("DATABASE_URL", "host=localhost port=5432 dbname=mcp user=postgres")
+
+
+def _matter_dsn():
+ """获取 DB 连接(复用全局 DSN)"""
+ return psycopg2.connect(_DSN)
+
+
+# 案卡状态机:合法的前向转换
+MATTER_TRANSITIONS = {
+ "new": {"asking_facts", "pending_confirm", "analyzing", "pending_expert", "closed"},
+ "asking_facts": {"pending_confirm", "analyzing", "pending_expert", "closed"},
+ "pending_confirm": {"analyzing", "pending_expert", "closed"},
+ "analyzing": {"pending_expert", "closed"},
+ "pending_expert": {"closed"},
+ "closed": set(), # 终态
+}
+
+
+class MatterCreateRequest(BaseModel):
+ scene: str # legal / decision / life / emotion
+ title: str
+ session_id: str | None = None
+
+
+class MatterStatusUpdateRequest(BaseModel):
+ status: str
+
+
+class MatterCloseRequest(BaseModel):
+ execution_result: str | None = None
+ execution_note: str | None = None
+
+
+def _gen_matter_code(cur) -> str:
+ """生成案卡编号 MTR_{YYYYMMDD}_{seq}"""
+ today = datetime.now(timezone.utc).strftime("%Y%m%d")
+ prefix = f"MTR_{today}_"
+ cur.execute("SELECT code FROM matters WHERE code LIKE %s ORDER BY code DESC LIMIT 1", (prefix + "%",))
+ row = cur.fetchone()
+ seq = 1
+ if row:
+ try:
+ seq = int(row[0].rsplit("_", 1)[-1]) + 1
+ except ValueError:
+ seq = 1
+ return f"{prefix}{seq:03d}"
+
+
+@app.get("/matters")
+def list_matters(status: str | None = None, authorization: str | None = Header(default=None)):
+ """案卡看板:按状态分组返回(可选 status 过滤单状态)"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ if status:
+ cur.execute(
+ "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
+ "summary, closed_at, execution_result, execution_note, created_at, updated_at "
+ "FROM matters WHERE user_phone = %s AND status = %s ORDER BY updated_at DESC",
+ (phone, status),
+ )
+ else:
+ cur.execute(
+ "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
+ "summary, closed_at, execution_result, execution_note, created_at, updated_at "
+ "FROM matters WHERE user_phone = %s ORDER BY updated_at DESC",
+ (phone,),
+ )
+ rows = cur.fetchall()
+ cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
+ "risk_level", "summary", "closed_at", "execution_result", "execution_note",
+ "created_at", "updated_at"]
+ matters = [dict(zip(cols, r)) for r in rows]
+ # 转换时间为字符串
+ for m in matters:
+ for k in ("closed_at", "created_at", "updated_at"):
+ if m.get(k):
+ m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
+ # 按状态分组
+ board = {
+ "new": [], "asking_facts": [], "pending_confirm": [],
+ "analyzing": [], "pending_expert": [], "closed": [],
+ }
+ for m in matters:
+ if m["status"] in board:
+ board[m["status"]].append(m)
+ return {"board": board, "total": len(matters)}
+ finally:
+ conn.close()
+
+
+@app.post("/matters")
+def create_matter(req: MatterCreateRequest, authorization: str | None = Header(default=None)):
+ """创建案卡(绑定会话)"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ if req.scene not in ("legal", "decision", "life", "emotion"):
+ raise HTTPException(422, "场景必须是 legal/decision/life/emotion")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ code = _gen_matter_code(cur)
+ cur.execute(
+ "INSERT INTO matters (code, user_phone, scene, title, status, session_id) "
+ "VALUES (%s, %s, %s, %s, 'new', %s) "
+ "RETURNING id, code, user_phone, scene, title, status, session_id, risk_level, "
+ "summary, closed_at, execution_result, execution_note, created_at, updated_at",
+ (code, phone, req.scene, req.title, req.session_id),
+ )
+ row = cur.fetchone()
+ conn.commit()
+ cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
+ "risk_level", "summary", "closed_at", "execution_result", "execution_note",
+ "created_at", "updated_at"]
+ m = dict(zip(cols, row))
+ for k in ("closed_at", "created_at", "updated_at"):
+ if m.get(k):
+ m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
+ return m
+ finally:
+ conn.close()
+
+
+@app.get("/matters/by-session/{sid}")
+def get_matter_by_session(sid: str, authorization: str | None = Header(default=None)):
+ """通过 session_id 查询案卡(断点续接时用于恢复 matterId)。"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
+ "summary, closed_at, execution_result, execution_note, created_at, updated_at "
+ "FROM matters WHERE session_id = %s AND user_phone = %s "
+ "ORDER BY updated_at DESC LIMIT 1",
+ (sid, phone),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(404, "未找到关联案卡")
+ cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
+ "risk_level", "summary", "closed_at", "execution_result", "execution_note",
+ "created_at", "updated_at"]
+ m = dict(zip(cols, row))
+ for k in ("closed_at", "created_at", "updated_at"):
+ if m.get(k):
+ m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
+ return m
+ finally:
+ conn.close()
+
+
+@app.get("/matters/{matter_id}")
+def get_matter(matter_id: int, authorization: str | None = Header(default=None)):
+ """案卡详情"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "SELECT id, code, user_phone, scene, title, status, session_id, risk_level, "
+ "summary, closed_at, execution_result, execution_note, created_at, updated_at "
+ "FROM matters WHERE id = %s AND user_phone = %s",
+ (matter_id, phone),
+ )
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(404, "案卡不存在或无权访问")
+ cols = ["id", "code", "user_phone", "scene", "title", "status", "session_id",
+ "risk_level", "summary", "closed_at", "execution_result", "execution_note",
+ "created_at", "updated_at"]
+ m = dict(zip(cols, row))
+ for k in ("closed_at", "created_at", "updated_at"):
+ if m.get(k):
+ m[k] = m[k].isoformat() if hasattr(m[k], "isoformat") else str(m[k])
+ return m
+ finally:
+ conn.close()
+
+
+@app.patch("/matters/{matter_id}/status")
+def update_matter_status(matter_id: int, req: MatterStatusUpdateRequest,
+ authorization: str | None = Header(default=None)):
+ """更新案卡状态(校验状态机)"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ if req.status not in MATTER_TRANSITIONS:
+ raise HTTPException(422, f"非法状态: {req.status}")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ cur.execute("SELECT status FROM matters WHERE id = %s AND user_phone = %s", (matter_id, phone))
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(404, "案卡不存在或无权访问")
+ current = row[0]
+ if req.status not in MATTER_TRANSITIONS.get(current, set()):
+ raise HTTPException(422, f"非法状态转换: {current} → {req.status}")
+ cur.execute(
+ "UPDATE matters SET status = %s, updated_at = now() WHERE id = %s",
+ (req.status, matter_id),
+ )
+ conn.commit()
+ return {"updated": True, "status": req.status}
+ finally:
+ conn.close()
+
+
+@app.post("/matters/{matter_id}/close")
+def close_matter(matter_id: int, req: MatterCloseRequest,
+ authorization: str | None = Header(default=None)):
+ """闭环案卡(可选填执行结果)"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ cur.execute("SELECT status FROM matters WHERE id = %s AND user_phone = %s", (matter_id, phone))
+ row = cur.fetchone()
+ if not row:
+ raise HTTPException(404, "案卡不存在或无权访问")
+ if row[0] == "closed":
+ raise HTTPException(422, "案卡已闭环")
+ cur.execute(
+ "UPDATE matters SET status = 'closed', closed_at = now(), "
+ "execution_result = %s, execution_note = %s, updated_at = now() WHERE id = %s",
+ (req.execution_result, req.execution_note, matter_id),
+ )
+ conn.commit()
+ return {"closed": True, "closed_at": datetime.now(timezone.utc).isoformat()}
+ finally:
+ conn.close()
+
+
+@app.get("/dashboard/today")
+def dashboard_today(authorization: str | None = Header(default=None)):
+ """老板台聚合:高风险 + 待确认 + 专家回复 + 健康度"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ # 高风险案卡
+ cur.execute(
+ "SELECT id, title, risk_level, scene FROM matters "
+ "WHERE user_phone = %s AND risk_level IN ('red', 'orange') AND status != 'closed' "
+ "ORDER BY updated_at DESC LIMIT 5",
+ (phone,),
+ )
+ high_risks = [{"matter_id": r[0], "title": r[1], "level": r[2], "scene": r[3]} for r in cur.fetchall()]
+ # 待确认案卡
+ cur.execute(
+ "SELECT id, title, scene, updated_at FROM matters "
+ "WHERE user_phone = %s AND status = 'pending_confirm' "
+ "ORDER BY updated_at DESC LIMIT 5",
+ (phone,),
+ )
+ pending = [{
+ "matter_id": r[0], "title": r[1], "scene": r[2],
+ "updated_at": r[3].isoformat() if r[3] else None,
+ } for r in cur.fetchall()]
+ # 专家未读回复
+ cur.execute(
+ "SELECT c.id, e.name, e.title, c.reply, c.replied_at "
+ "FROM expert_consultations c JOIN experts e ON c.expert_id = e.id "
+ "WHERE c.user_phone = %s AND c.status = 'replied' "
+ "ORDER BY c.replied_at DESC LIMIT 5",
+ (phone,),
+ )
+ replies = [{
+ "consultation_id": r[0], "expert_name": r[1], "expert_title": r[2],
+ "snippet": (r[3] or "")[:80], "replied_at": r[4].isoformat() if r[4] else None,
+ } for r in cur.fetchall()]
+ # 健康度:基于案卡风险分布简单计算
+ cur.execute(
+ "SELECT risk_level, COUNT(*) FROM matters "
+ "WHERE user_phone = %s AND status != 'closed' GROUP BY risk_level",
+ (phone,),
+ )
+ risk_dist = {r[0] or "green": r[1] for r in cur.fetchall()}
+ total_open = sum(risk_dist.values()) or 1
+ # 法务健康度:无风险=100,有red=低
+ legal_score = max(0, 100 - risk_dist.get("red", 0) * 30 - risk_dist.get("orange", 0) * 15 - risk_dist.get("amber", 0) * 5)
+ operation_score = max(20, 100 - risk_dist.get("red", 0) * 20 - risk_dist.get("orange", 0) * 10)
+ compliance_score = max(30, 100 - risk_dist.get("red", 0) * 25 - risk_dist.get("amber", 0) * 10)
+ # 问候语
+ hour = datetime.now(timezone.utc).hour + 8 # 北京时间
+ if hour >= 24: hour -= 24
+ if 5 <= hour < 9: greeting = "早上好"
+ elif 9 <= hour < 12: greeting = "上午好"
+ elif 12 <= hour < 14: greeting = "中午好"
+ elif 14 <= hour < 18: greeting = "下午好"
+ elif 18 <= hour < 22: greeting = "晚上好"
+ else: greeting = "夜深了"
+ return {
+ "greeting": greeting,
+ "high_risks": high_risks,
+ "pending_confirm": pending,
+ "expert_replies": replies,
+ "health_score": {
+ "legal": legal_score,
+ "operation": operation_score,
+ "compliance": compliance_score,
+ },
+ "unread_alerts": 0, # 阶段三接入风险推送
+ }
+ finally:
+ conn.close()
+
+
+@app.get("/team")
+def my_team(authorization: str | None = Header(default=None)):
+ """我的团队:咨询过的专家自动沉淀"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ # 从 expert_consultations 聚合关系
+ cur.execute(
+ "SELECT e.id, e.name, e.avatar_url, e.category, e.title, e.expertise, "
+ "COUNT(c.id) as cnt, MAX(c.replied_at) as last_at, "
+ "(SELECT c2.topic FROM expert_consultations c2 WHERE c2.expert_id = e.id AND c2.user_phone = %s ORDER BY c2.created_at DESC LIMIT 1) as last_topic "
+ "FROM experts e JOIN expert_consultations c ON c.expert_id = e.id "
+ "WHERE c.user_phone = %s "
+ "GROUP BY e.id, e.name, e.avatar_url, e.category, e.title, e.expertise "
+ "ORDER BY last_at DESC NULLS LAST",
+ (phone, phone),
+ )
+ rows = cur.fetchall()
+ results = [{
+ "expert_id": r[0], "name": r[1], "avatar_url": r[2], "category": r[3],
+ "title": r[4], "expertise": r[5], "online": False, # 在线状态待接入
+ "consultation_count": r[6],
+ "last_consulted_at": r[7].isoformat() if r[7] else None,
+ "last_topic": r[8], "tags": r[5],
+ } for r in rows]
+ return {"results": results}
+ finally:
+ conn.close()
+
+
+@app.get("/quick-commands")
+def quick_commands(authorization: str | None = Header(default=None)):
+ """快捷指令:基于画像生成个性化指令(阶段二完整实现,当前返回通用指令)"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ # 获取画像
+ industry = "通用"
+ profile_incomplete = False
+ try:
+ ps = UserProfileStore()
+ profile = ps.get(phone) or {}
+ p = profile.get("profile", {}) if isinstance(profile, dict) else {}
+ if p.get("industry"):
+ industry = p.get("industry")
+ else:
+ profile_incomplete = True
+ except Exception:
+ profile_incomplete = True
+ # 通用指令(阶段二接入 AI 生成)
+ commands = {
+ "legal": [
+ {"id": "contract_review", "label": "查合同风险", "template": "我有一份合同,帮我看看有什么风险条款需要注意的。"},
+ {"id": "labor_compliance", "label": "用工合规", "template": "我的公司在用工方面有哪些合规风险需要注意?"},
+ {"id": "debt_collection", "label": "要账起诉", "template": "有客户欠款逾期未付,我该怎么催收或起诉?"},
+ ],
+ "decision": [
+ {"id": "expand", "label": "该不该扩张", "template": "我在考虑开新店/扩张业务,帮我分析利弊。"},
+ {"id": "calc_profit", "label": "算一笔账", "template": "帮我算一下这个项目的投入产出和回本周期。"},
+ {"id": "partner", "label": "找人合伙", "template": "我想找人合伙,股权和分工该怎么分?"},
+ ],
+ "life": [
+ {"id": "time_mgmt", "label": "时间管理", "template": "我最近总觉得时间不够用,帮我规划一下。"},
+ {"id": "family", "label": "家庭关系", "template": "创业以来和家人沟通变少了,该怎么办?"},
+ {"id": "health", "label": "健康管理", "template": "我长期久坐熬夜,有什么健康管理建议?"},
+ ],
+ "profile_incomplete": profile_incomplete,
+ "incomplete_hint": "完善行业和规模信息,解锁更精准的建议" if profile_incomplete else None,
+ }
+ return commands
+
+
+# ============================================================
+# 风险推送(TASK-022~023)
+# ============================================================
+
+class RiskAlertCreateRequest(BaseModel):
+ """内部用:创建风险推送"""
+ user_phone: str
+ level: str # red / orange / blue
+ title: str
+ content: str
+ source: str = "knowledge"
+ related_matter_id: int | None = None
+
+
+@app.get("/risk-alerts")
+def list_risk_alerts(unread: bool = False, authorization: str | None = Header(default=None)):
+ """风险推送列表(可选 unread=true 只返回未读)"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ if unread:
+ cur.execute(
+ "SELECT id, level, title, content, source, related_matter_id, read_at, created_at "
+ "FROM risk_alerts WHERE user_phone = %s AND read_at IS NULL "
+ "ORDER BY created_at DESC LIMIT 20",
+ (phone,),
+ )
+ else:
+ cur.execute(
+ "SELECT id, level, title, content, source, related_matter_id, read_at, created_at "
+ "FROM risk_alerts WHERE user_phone = %s "
+ "ORDER BY created_at DESC LIMIT 50",
+ (phone,),
+ )
+ rows = cur.fetchall()
+ cols = ["id", "level", "title", "content", "source", "related_matter_id", "read_at", "created_at"]
+ results = []
+ for r in rows:
+ d = dict(zip(cols, r))
+ for k in ("read_at", "created_at"):
+ if d.get(k):
+ d[k] = d[k].isoformat() if hasattr(d[k], "isoformat") else str(d[k])
+ results.append(d)
+ return {"results": results}
+ finally:
+ conn.close()
+
+
+@app.post("/risk-alerts/{alert_id}/read")
+def read_risk_alert(alert_id: int, authorization: str | None = Header(default=None)):
+ """标记风险推送已读"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "UPDATE risk_alerts SET read_at = now() WHERE id = %s AND user_phone = %s AND read_at IS NULL",
+ (alert_id, phone),
+ )
+ conn.commit()
+ return {"read": True}
+ finally:
+ conn.close()
+
+
+def _create_risk_alert(user_phone: str, level: str, title: str, content: str,
+ source: str = "knowledge", related_matter_id: int | None = None):
+ """内部函数:创建风险推送(供定时任务和文档扫描调用)"""
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "INSERT INTO risk_alerts (user_phone, level, title, content, source, related_matter_id) "
+ "VALUES (%s, %s, %s, %s, %s, %s) RETURNING id",
+ (user_phone, level, title, content, source, related_matter_id),
+ )
+ alert_id = cur.fetchone()[0]
+ conn.commit()
+ return alert_id
+ finally:
+ conn.close()
+
+
+@app.post("/risk-alerts/scan")
+def scan_risk_alerts(authorization: str | None = Header(default=None)):
+ """手动触发风险扫描:扫描知识库近7天新增文档,匹配用户画像行业
+
+ 定时任务也可调用此端点(内部调用无 auth)。
+ """
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ # 获取用户画像行业
+ industry = None
+ try:
+ ps = UserProfileStore()
+ profile = ps.get(phone) or {}
+ p = profile.get("profile", {}) if isinstance(profile, dict) else {}
+ industry = p.get("industry")
+ except Exception:
+ pass
+ # 扫描知识库近7天新增文档
+ cur.execute(
+ "SELECT id, title, content, doc_type, keywords FROM knowledge_docs "
+ "WHERE created_at > now() - interval '7 days' AND status = 'active' "
+ "ORDER BY created_at DESC LIMIT 10"
+ )
+ docs = cur.fetchall()
+ alerts_created = 0
+ for doc_id, title, content, doc_type, keywords in docs:
+ # 简单匹配:如果文档关键词包含用户行业,或用户无行业则全部推送
+ kw = keywords or ""
+ if industry and industry.lower() not in kw.lower():
+ continue
+ # 检查是否已推送过(避免重复)
+ cur.execute(
+ "SELECT 1 FROM risk_alerts WHERE user_phone = %s AND title = %s",
+ (phone, title),
+ )
+ if cur.fetchone():
+ continue
+ level = "orange" if doc_type == "law" else "blue"
+ _create_risk_alert(phone, level, title, content[:200], "knowledge")
+ alerts_created += 1
+ return {"scanned": len(docs), "created": alerts_created}
+ finally:
+ conn.close()
+
+
+# ============================================================
+# 决策日志(TASK-025 后端)
+# ============================================================
+
+@app.get("/decisions-log")
+def decisions_log(month: str | None = None, authorization: str | None = Header(default=None)):
+ """决策日志:闭环案卡按月分组
+
+ month 格式: YYYY-MM,不传则返回全部
+ """
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ if month:
+ cur.execute(
+ "SELECT id, code, scene, title, risk_level, summary, "
+ "closed_at, execution_result, execution_note, created_at "
+ "FROM matters WHERE user_phone = %s AND status = 'closed' "
+ "AND to_char(closed_at, 'YYYY-MM') = %s "
+ "ORDER BY closed_at DESC",
+ (phone, month),
+ )
+ else:
+ cur.execute(
+ "SELECT id, code, scene, title, risk_level, summary, "
+ "closed_at, execution_result, execution_note, created_at "
+ "FROM matters WHERE user_phone = %s AND status = 'closed' "
+ "ORDER BY closed_at DESC",
+ (phone,),
+ )
+ rows = cur.fetchall()
+ cols = ["id", "code", "scene", "title", "risk_level", "summary",
+ "closed_at", "execution_result", "execution_note", "created_at"]
+ results = []
+ for r in rows:
+ d = dict(zip(cols, r))
+ for k in ("closed_at", "created_at"):
+ if d.get(k):
+ d[k] = d[k].isoformat() if hasattr(d[k], "isoformat") else str(d[k])
+ results.append(d)
+ # 按月分组
+ by_month = {}
+ for d in results:
+ m = (d.get("closed_at") or "")[:7]
+ if m not in by_month:
+ by_month[m] = []
+ by_month[m].append(d)
+ # 统计
+ total = len(results)
+ executed = sum(1 for r in results if r.get("execution_result") == "顺利")
+ execution_rate = round(executed / total * 100) if total > 0 else 0
+ return {
+ "results": results,
+ "by_month": by_month,
+ "stats": {"total": total, "executed": executed, "execution_rate": execution_rate},
+ }
+ finally:
+ conn.close()
+
+
+@app.patch("/matters/{matter_id}/execution")
+def update_execution_result(matter_id: int, body: dict,
+ authorization: str | None = Header(default=None)):
+ """更新决策执行结果(闭环后可修改)"""
+ phone = get_current_phone(authorization)
+ if not phone:
+ raise HTTPException(401, "未登录")
+ execution_result = body.get("execution_result")
+ execution_note = body.get("execution_note")
+ if execution_result not in ("顺利", "受阻", "未执行", None):
+ raise HTTPException(422, "执行结果必须是 顺利/受阻/未执行")
+ conn = _matter_dsn()
+ try:
+ cur = conn.cursor()
+ cur.execute(
+ "UPDATE matters SET execution_result = %s, execution_note = %s, updated_at = now() "
+ "WHERE id = %s AND user_phone = %s AND status = 'closed'",
+ (execution_result, execution_note, matter_id, phone),
+ )
+ if cur.rowcount == 0:
+ raise HTTPException(404, "案卡不存在或未闭环")
+ conn.commit()
+ return {"updated": True}
+ finally:
+ conn.close()
diff --git a/bossai.py b/bossai.py
index a1e1c70..0053bb1 100644
--- a/bossai.py
+++ b/bossai.py
@@ -164,7 +164,7 @@ def assess_ai(question: str, kimi_client=None) -> RiskCard | None:
return None
prompt = (
- "你是中国中小企业法务风险分诊专家。请判断以下问题的风险等级。\n\n"
+ "你是中国中小企业法务风险分诊专家,服务对象是企业老板(雇主)。请判断以下问题的风险等级。\n\n"
f"用户问题:{question}\n\n"
"风险等级定义:\n"
"- red: 重大法律风险,可能涉及刑事、行政处罚、重大诉讼、人身安全,必须人工复核\n"
@@ -2117,7 +2117,7 @@ class DocumentGenerator:
try:
return self.kimi_client.chat(
- [{"role": "system", "content": "你是法律文书撰写助手,生成正式规范的文书正文。"},
+ [{"role": "system", "content": "你是法律文书撰写助手,服务对象是中小企业老板(原告方),生成正式规范的文书正文。"},
{"role": "user", "content": prompt}],
temperature=0.3,
timeout=30,
@@ -2483,7 +2483,7 @@ class FactFinder:
}.get(scene, "通用")
prompt = (
- f"你是{scene_desc}顾问。判断已有事实是否足够给出有价值的建议。\n\n"
+ f"你是{scene_desc}顾问,服务对象是中小企业老板。判断已有事实是否足够给出有价值的建议。\n\n"
f"用户问题:{question}\n\n"
)
if previous_answers:
@@ -2593,7 +2593,7 @@ class FactFinder:
def generate_ai_questions(self, question: str, scene: str,
kimi_client, previous_answers: list[dict] = None,
- max_questions: int = 3) -> list[dict]:
+ max_questions: int = 3, history: list[dict] = None) -> list[dict]:
"""用 AI 分析问题,生成针对性追问问题。
Args:
@@ -2613,25 +2613,25 @@ class FactFinder:
scene_config = {
"legal": {
"desc": "法律/法务",
- "role": "你是中国执业律师",
+ "role": "你是中国执业律师,服务对象是中小企业老板(雇主),所有追问必须从老板/雇主视角出发",
"dim_examples": "合同、金额、时间、证据、对方态度、是否协商过",
"focus": "影响法律结论的关键事实",
},
"decision": {
"desc": "经营决策",
- "role": "你是中小企业经营顾问",
+ "role": "你是中小企业经营顾问,服务对象是企业老板,所有追问从老板视角出发",
"dim_examples": "规模金额、现金流、市场状况、团队能力、备选方案、时间窗口",
"focus": "影响决策推演的关键事实",
},
"life": {
"desc": "生活助理",
- "role": "你是生活管理顾问,擅长家庭、健康、出行等实用建议",
+ "role": "你是生活管理顾问,擅长家庭、健康、出行等实用建议,服务对象是企业老板",
"dim_examples": "具体状况、涉及人员、时间安排、当前困难、已有尝试",
"focus": "让建议更具体可执行的关键细节,而非泛泛的'描述情况'",
},
"emotion": {
"desc": "情绪疏导",
- "role": "你是心理咨询师",
+ "role": "你是心理咨询师,服务对象是企业老板",
"dim_examples": "触发事件、持续时间、当前感受、已有应对、社会支持",
"focus": "理解情绪状态和触发因素的关键信息",
},
@@ -2644,6 +2644,16 @@ class FactFinder:
f"用户问题:{question}\n\n"
)
+ # 注入对话历史(多轮上下文),让追问能理解当前问题的背景
+ if history:
+ prompt += "之前的对话历史(用于理解当前问题的背景):\n"
+ for h in history[-6:]:
+ role_label = "用户" if h.get("role") == "user" else "AI"
+ content = h.get("content", "")
+ if content:
+ prompt += f"{role_label}: {content[:200]}\n"
+ prompt += "\n注意:当前问题是在上述对话背景下的延续,追问应结合之前的上下文,不要当成孤立问题处理。\n\n"
+
if previous_answers:
prompt += "已知事实:\n"
for ans in previous_answers:
@@ -2653,19 +2663,24 @@ class FactFinder:
prompt += (
f"请生成最多 {max_questions} 个最关键的追问,帮助更准确地分析这个问题。\n\n"
f"要求:\n"
- f"1. 每个问题针对一个关键事实维度(如{config['dim_examples']}等)\n"
- f"2. 问题必须具体、有针对性,针对用户问题中的实际情境提问\n"
- f"3. 不要问'能具体描述一下情况吗'这类泛泛的问题,要问具体的细节\n"
- f"4. 优先问对{config['focus']}影响最大的事实\n"
- f"5. 不要问用户已经回答过的事实\n"
- f"6. 如果已有足够信息,返回空列表\n\n"
+ f"1. **追问必须与用户问题直接相关,不要偏离主题**。"
+ f"例如用户问'社保未缴全',追问应该是关于社保缴纳情况、欠缴金额、是否被查等,"
+ f"而不是问合同违约或股东纠纷等无关问题\n"
+ f"2. 每个问题针对一个关键事实维度(如{config['dim_examples']}等)\n"
+ f"3. 问题必须具体、有针对性,针对用户问题中的实际情境提问\n"
+ f"4. 不要问'能具体描述一下情况吗'这类泛泛的问题,要问具体的细节\n"
+ f"5. 优先问对{config['focus']}影响最大的事实\n"
+ f"6. 不要问用户已经回答过的事实\n"
+ f"7. 如果已有足够信息,返回空列表\n"
+ f"8. **所有追问必须从老板/雇主视角出发**(用户是中小企业老板,不是员工)。"
+ f"例如劳动用工场景,应问'公司与该员工签的劳动合同中解除条款怎么约定的',而非'您与公司签订的劳动合同'\n\n"
f"请以 JSON 数组格式返回,每个元素包含 dim_key(维度英文标识)、dim_desc(维度中文描述)、question(追问问题):\n"
f'[{{"dim_key": "amount", "dim_desc": "涉及金额", "question": "涉及的金额大约是多少?"}}]\n'
)
try:
answer = kimi_client.chat(
- [{"role": "system", "content": "你是专业顾问助手,擅长分析问题并生成精准追问。只返回 JSON 数组,不要其他内容。"},
+ [{"role": "system", "content": "你是专业顾问助手,服务对象是中小企业老板。所有追问必须从老板/雇主视角出发,不要用员工视角提问。只返回 JSON 数组,不要其他内容。"},
{"role": "user", "content": prompt}],
temperature=0.3,
timeout=30,
@@ -2704,7 +2719,7 @@ class FactFinder:
def build_analysis_prompt(self, question: str, scene: str,
answers: list[dict],
- kimi_client=None) -> tuple[str, dict]:
+ kimi_client=None, history: list[dict] = None) -> tuple[str, dict]:
"""完整的追问流程:分析 → 生成追问 → 汇总事实。
优先用 AI 生成针对性追问,Kimi 不可用时降级到规则匹配。
@@ -2730,6 +2745,7 @@ class FactFinder:
if kimi_client:
questions = self.generate_ai_questions(
question, scene, kimi_client, answers, self.max_questions_per_round,
+ history=history,
)
# AI 不可用或返回空,降级到规则匹配
if not questions:
@@ -2841,9 +2857,11 @@ class KimiClient:
# 场景 system prompt 映射:按模块路由不同人设,前端通过 scene 参数选择
SCENE_PROMPTS: dict[str, str] = {
"legal": (
- "你是中国中小企业老板的法务参谋。先给风险等级(绿色/黄色/橙色/红色),"
+ "你是中国中小企业老板的法务参谋,服务对象是企业老板(雇主),所有分析和建议必须从老板/雇主视角出发。"
+ "先给风险等级(绿色/黄色/橙色/红色),"
"再给结论、立即行动、需保存证据、禁止事项和是否需要律师复核。"
"覆盖劳动用工、合同审查、债权债务、股东纠纷、税务风险。"
+ "劳动用工场景中,你是帮老板处理员工问题的律师,不是帮员工维权的律师。"
"严禁编造法律依据;不确定时明确说明。"
),
"decision": (
diff --git a/db_init.py b/db_init.py
index 4bf99e5..3557c08 100644
--- a/db_init.py
+++ b/db_init.py
@@ -193,6 +193,62 @@ CREATE INDEX IF NOT EXISTS idx_chat_messages_session ON chat_messages(session_id
CREATE INDEX IF NOT EXISTS idx_chat_messages_phone ON chat_messages(phone);
CREATE INDEX IF NOT EXISTS idx_chat_messages_phone_mode ON chat_messages(phone, mode);
CREATE INDEX IF NOT EXISTS idx_chat_messages_created ON chat_messages(created_at DESC);
+
+-- ===== BossAi 2.0 新增表 =====
+
+-- 案卡:每次咨询绑定一张案卡,可视化追踪全生命周期
+CREATE TABLE IF NOT EXISTS matters (
+ id SERIAL PRIMARY KEY,
+ code TEXT NOT NULL UNIQUE,
+ user_phone TEXT NOT NULL,
+ scene TEXT NOT NULL,
+ title TEXT NOT NULL,
+ status TEXT NOT NULL DEFAULT 'new',
+ session_id TEXT,
+ risk_level TEXT,
+ summary TEXT,
+ closed_at TIMESTAMPTZ,
+ execution_result TEXT,
+ execution_note TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+CREATE INDEX IF NOT EXISTS idx_matters_user_phone ON matters(user_phone);
+CREATE INDEX IF NOT EXISTS idx_matters_status ON matters(status);
+CREATE INDEX IF NOT EXISTS idx_matters_user_status ON matters(user_phone, status);
+CREATE INDEX IF NOT EXISTS idx_matters_created ON matters(created_at DESC);
+
+-- 专家关系:用户咨询过的专家自动沉淀为"我的团队"
+CREATE TABLE IF NOT EXISTS expert_relations (
+ id SERIAL PRIMARY KEY,
+ user_phone TEXT NOT NULL,
+ expert_id INTEGER NOT NULL REFERENCES experts(id),
+ consultation_count INTEGER NOT NULL DEFAULT 0,
+ last_consulted_at TIMESTAMPTZ,
+ last_topic TEXT,
+ tags TEXT,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(user_phone, expert_id)
+);
+CREATE INDEX IF NOT EXISTS idx_expert_relations_user ON expert_relations(user_phone);
+CREATE INDEX IF NOT EXISTS idx_expert_relations_expert ON expert_relations(expert_id);
+
+-- 风险推送:AI 主动提醒用户行业新规、合同风险等
+CREATE TABLE IF NOT EXISTS risk_alerts (
+ id SERIAL PRIMARY KEY,
+ user_phone TEXT NOT NULL,
+ level TEXT NOT NULL,
+ title TEXT NOT NULL,
+ content TEXT NOT NULL,
+ source TEXT NOT NULL DEFAULT 'knowledge',
+ related_matter_id INTEGER,
+ read_at TIMESTAMPTZ,
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+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);
"""
# 专家种子数据(密码统一为 expert123,生产环境应让专家自行注册)
@@ -286,6 +342,8 @@ def main():
BEGIN ALTER TABLE review_tickets ADD COLUMN sla_deadline TIMESTAMPTZ; EXCEPTION WHEN duplicate_column THEN NULL; END;
BEGIN ALTER TABLE review_tickets ADD COLUMN sla_status TEXT NOT NULL DEFAULT 'normal'; EXCEPTION WHEN duplicate_column THEN NULL; END;
BEGIN ALTER TABLE users ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; EXCEPTION WHEN duplicate_column THEN NULL; END;
+ -- 2.0:chat_messages 关联案卡
+ BEGIN ALTER TABLE chat_messages ADD COLUMN matter_id INTEGER; EXCEPTION WHEN duplicate_column THEN NULL; END;
END $$;
""")
@@ -309,6 +367,8 @@ def main():
# SLA 索引(列已确保存在后创建)
cur.execute("CREATE INDEX IF NOT EXISTS idx_review_tickets_sla ON review_tickets(sla_status)")
+ # 2.0:案卡关联索引
+ cur.execute("CREATE INDEX IF NOT EXISTS idx_chat_messages_matter ON chat_messages(matter_id)")
# 清除所有旧知识数据(测试数据已废弃,重新种子)
cur.execute("DELETE FROM knowledge_doc_versions")
diff --git a/migrations/001_bossai_v2.sql b/migrations/001_bossai_v2.sql
new file mode 100644
index 0000000..58b3ddc
--- /dev/null
+++ b/migrations/001_bossai_v2.sql
@@ -0,0 +1,108 @@
+-- BossAi 2.0 UIUX 重构 — 数据库迁移脚本
+-- TASK-001: 案卡表 + sessions 扩展
+-- TASK-002: 专家关系表
+-- 幂等:可重复执行,已存在的对象跳过
+-- 运行:psql -d mcp -f migrations/001_bossai_v2.sql
+
+BEGIN;
+
+-- ============================================================
+-- TASK-001: 案卡(matters)表
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS matters (
+ id SERIAL PRIMARY KEY,
+ code TEXT NOT NULL UNIQUE, -- 唯一业务代码 MTR_{YYYYMMDD}_{seq}
+ user_phone TEXT NOT NULL, -- 所属老板(手机号,关联 users.phone)
+ scene TEXT NOT NULL, -- legal / decision / life / emotion
+ title TEXT NOT NULL, -- 案卡标题(AI 生成或用户命名)
+ status TEXT NOT NULL DEFAULT 'new', -- new / asking_facts / pending_confirm / analyzing / pending_expert / closed
+ session_id TEXT, -- 关联会话 sid(可空,旧会话无案卡)
+ risk_level TEXT, -- 可空: green / amber / orange / red
+ summary TEXT, -- 可空: AI 结论摘要
+ closed_at TIMESTAMPTZ, -- 闭环时间
+ execution_result TEXT, -- 可空: 顺利 / 受阻 / 未执行
+ execution_note TEXT, -- 可空: 执行情况备注
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+CREATE INDEX IF NOT EXISTS idx_matters_user_phone ON matters(user_phone);
+CREATE INDEX IF NOT EXISTS idx_matters_status ON matters(status);
+CREATE INDEX IF NOT EXISTS idx_matters_user_status ON matters(user_phone, status);
+CREATE INDEX IF NOT EXISTS idx_matters_created ON matters(created_at DESC);
+
+-- 案卡状态流转合法性约束(通过 CHECK)
+ALTER TABLE matters DROP CONSTRAINT IF EXISTS chk_matters_status;
+ALTER TABLE matters ADD CONSTRAINT chk_matters_status CHECK (
+ status IN ('new', 'asking_facts', 'pending_confirm', 'analyzing', 'pending_expert', 'closed')
+);
+
+ALTER TABLE matters DROP CONSTRAINT IF EXISTS chk_matters_scene;
+ALTER TABLE matters ADD CONSTRAINT chk_matters_scene CHECK (
+ scene IN ('legal', 'decision', 'life', 'emotion')
+);
+
+ALTER TABLE matters DROP CONSTRAINT IF EXISTS chk_matters_risk;
+ALTER TABLE matters ADD CONSTRAINT chk_matters_risk CHECK (
+ risk_level IS NULL OR risk_level IN ('green', 'amber', 'orange', 'red')
+);
+
+-- sessions 表扩展:关联案卡(若 sessions 表存在)
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.tables WHERE table_name = 'sessions'
+ ) AND NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'sessions' AND column_name = 'matter_id'
+ ) THEN
+ ALTER TABLE sessions ADD COLUMN matter_id INTEGER REFERENCES matters(id);
+ CREATE INDEX IF NOT EXISTS idx_sessions_matter ON sessions(matter_id);
+ END IF;
+END $$;
+
+-- chat_messages 表扩展:关联案卡(用于无 sessions 表时通过消息关联)
+DO $$
+BEGIN
+ IF NOT EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'chat_messages' AND column_name = 'matter_id'
+ ) THEN
+ ALTER TABLE chat_messages ADD COLUMN matter_id INTEGER REFERENCES matters(id);
+ CREATE INDEX IF NOT EXISTS idx_chat_messages_matter ON chat_messages(matter_id);
+ END IF;
+END $$;
+
+-- ============================================================
+-- TASK-002: 专家关系(expert_relations)表
+-- ============================================================
+
+CREATE TABLE IF NOT EXISTS expert_relations (
+ id SERIAL PRIMARY KEY,
+ user_phone TEXT NOT NULL, -- 老板手机号
+ expert_id INTEGER NOT NULL REFERENCES experts(id),
+ consultation_count INTEGER NOT NULL DEFAULT 0, -- 咨询次数
+ last_consulted_at TIMESTAMPTZ, -- 最近咨询时间
+ last_topic TEXT, -- 最近咨询话题
+ tags TEXT, -- 擅长领域标签(逗号分隔)
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
+ UNIQUE(user_phone, expert_id)
+);
+
+CREATE INDEX IF NOT EXISTS idx_expert_relations_user ON expert_relations(user_phone);
+CREATE INDEX IF NOT EXISTS idx_expert_relations_expert ON expert_relations(expert_id);
+
+COMMIT;
+
+-- ============================================================
+-- 回滚脚本(手动执行,需谨慎)
+-- 反向执行:psql -d mcp -f migrations/001_bossai_v2_rollback.sql
+-- ============================================================
+-- BEGIN;
+-- ALTER TABLE chat_messages DROP COLUMN IF EXISTS matter_id;
+-- ALTER TABLE sessions DROP COLUMN IF EXISTS matter_id;
+-- DROP TABLE IF EXISTS expert_relations;
+-- DROP TABLE IF EXISTS matters;
+-- COMMIT;
diff --git a/migrations/001_bossai_v2_rollback.sql b/migrations/001_bossai_v2_rollback.sql
new file mode 100644
index 0000000..044a971
--- /dev/null
+++ b/migrations/001_bossai_v2_rollback.sql
@@ -0,0 +1,26 @@
+-- BossAi 2.0 回滚脚本 — 慎用,会删除案卡和专家关系数据
+-- 运行:psql -d mcp -f migrations/001_bossai_v2_rollback.sql
+
+BEGIN;
+
+-- 移除 chat_messages 的 matter_id 列
+ALTER TABLE chat_messages DROP COLUMN IF EXISTS matter_id;
+
+-- 移除 sessions 的 matter_id 列(若存在)
+DO $$
+BEGIN
+ IF EXISTS (
+ SELECT 1 FROM information_schema.columns
+ WHERE table_name = 'sessions' AND column_name = 'matter_id'
+ ) THEN
+ ALTER TABLE sessions DROP COLUMN matter_id;
+ END IF;
+END $$;
+
+-- 删除专家关系表
+DROP TABLE IF EXISTS expert_relations;
+
+-- 删除案卡表
+DROP TABLE IF EXISTS matters;
+
+COMMIT;
diff --git a/migrations/002_risk_alerts.sql b/migrations/002_risk_alerts.sql
new file mode 100644
index 0000000..e0a055e
--- /dev/null
+++ b/migrations/002_risk_alerts.sql
@@ -0,0 +1,32 @@
+-- BossAi 2.0 阶段三 — 风险推送表
+-- TASK-021: risk_alerts 表
+-- 幂等:可重复执行
+-- 运行:psql -d mcp -f migrations/002_risk_alerts.sql
+
+BEGIN;
+
+CREATE TABLE IF NOT EXISTS risk_alerts (
+ id SERIAL PRIMARY KEY,
+ user_phone TEXT NOT NULL, -- 推送对象(手机号)
+ level TEXT NOT NULL, -- red / orange / blue
+ title TEXT NOT NULL, -- 标题
+ content TEXT NOT NULL, -- 详情
+ source TEXT NOT NULL DEFAULT 'knowledge', -- knowledge / doc_scan / system
+ related_matter_id INTEGER, -- 可空,关联案卡
+ read_at TIMESTAMPTZ, -- 可空,标记已读时间
+ created_at TIMESTAMPTZ NOT NULL DEFAULT now()
+);
+
+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);
+
+ALTER TABLE risk_alerts DROP CONSTRAINT IF EXISTS chk_risk_alerts_level;
+ALTER TABLE risk_alerts ADD CONSTRAINT chk_risk_alerts_level CHECK (
+ level IN ('red', 'orange', 'blue')
+);
+
+COMMIT;
+
+-- 回滚:
+-- DROP TABLE IF EXISTS risk_alerts;
diff --git a/pmdocs/1-prd-bossai.md b/pmdocs/1-prd-bossai.md
new file mode 100644
index 0000000..5e36219
--- /dev/null
+++ b/pmdocs/1-prd-bossai.md
@@ -0,0 +1,294 @@
+# BossAi 产品需求文档(PRD)
+
+- **版本**:v2.0(UIUX 重构)
+- **状态**:待确认
+- **日期**:2026-08-30
+- **关联**:ADR-001 前端技术栈、ADR-002 UIUX 范式转变、CHG-20260830-001 UIUX 重构
+
+---
+
+## 1. 产品定位
+
+**一句话**:BossAi 是面向中国中小企业老板的"经营智囊",把 AI 法务参谋、经营决策、生活助理、情绪支持整合为以"事项"为中心的主动式助手。
+
+**定位转变**(v1.0 → v2.0):
+
+| 维度 | v1.0(现状) | v2.0(本次重构) |
+|---|---|---|
+| 产品形态 | AI 对话工具 | 经营智囊(任务卡片优先) |
+| 交互范式 | 老板主动问 | AI 主动推送 + 老板主动问 |
+| 信息组织 | 按场景分页 | 按"我的事项"聚合,场景为标签 |
+| 画像作用 | 存着、只读 | 驱动个性化指令与推送 |
+| 专家关系 | 目录式 | "我的团队"关系视图 |
+| 移动端 | 桌面缩放 | 原生移动体验(底部 Tab + FAB) |
+
+---
+
+## 2. 目标用户
+
+### 2.1 主用户:中小企业老板(user 角色)
+
+- **画像**:25-55 岁,时间碎片化,手机为主,30 秒内要拿到要点
+- **行业**:餐饮、零售、制造、服务业为主
+- **规模**:微型(<10人) ~ 中型(<200人)
+- **痛点**:
+ - 不懂法律术语,怕说错话
+ - 同时挂着好几件事(合同、用工、税务、决策)
+ - 想要"结论+行动",不是"探讨"
+ - 决策做完就忘,没有复盘
+ - 信任专家但要"我的人",不是"找陌生人"
+
+### 2.2 次用户:专家(expert 角色)、管理员(admin 角色)
+
+本次重构**不改变** expert/admin 的核心功能,仅做视觉规范对齐(Admin 警示色、ADMIN 徽章)。
+
+---
+
+## 3. 核心场景(SCENE)
+
+| 编号 | 场景 | 用户故事 | 优先级 |
+|---|---|---|---|
+| SCENE-001 | 老板台 | "我打开应用,一眼看到今天要关注的事,不用到处找" | P0 |
+| SCENE-002 | 案卡看板 | "我同时处理 3 件事,每件到哪一步了,一目了然" | P0 |
+| SCENE-003 | 快捷指令 | "我不懂法律,但我点一下就能问'用工合规',AI 帮我组织问题" | P0 |
+| SCENE-004 | 我的团队 | "我咨询过的专家自动成为'我的人',下次直接找他" | P1 |
+| SCENE-005 | 风险雷达 | "AI 主动提醒我行业新规和合同风险,不用我自己查" | P1 |
+| SCENE-006 | 决策日志 | "我上个月做了哪些决策,结果如何,能复盘" | P2 |
+| SCENE-007 | 法务咨询 | "我问合同问题,AI 追问关键事实,给我风险卡和起诉状" | P0(保留优化) |
+| SCENE-008 | 经营决策 | "我纠结要不要扩张,AI 帮我算账给建议" | P0(保留优化) |
+| SCENE-009 | 生活助理 | "我私人问题也能问,保密模式" | P1(保留) |
+| SCENE-010 | 情绪中心 | "我压力大,有人陪我聊聊,阅后即焚" | P1(保留) |
+
+---
+
+## 4. 信息架构(v2.0)
+
+### 4.1 桌面端导航
+
+```
+总览
+ └─ 老板台(/) ← 重构 Dashboard
+
+我的事项
+ └─ 事项看板(/matters) ← 新增
+
+智囊
+ ├─ 法务参谋(/legal) ← 保留,接入案卡
+ ├─ 经营决策(/decisions) ← 保留,接入案卡
+ ├─ 生活助理(/life) ← 保留
+ └─ 情绪中心(/emotion) ← 保留
+
+我的资源
+ ├─ 我的团队(/team) ← 重构 Experts
+ ├─ 决策日志(/decisions-log)← 新增
+ ├─ 知识库(/knowledge) ← 保留
+ ├─ 对话历史(/history) ← 保留
+ └─ 我的画像(/profile) ← 保留
+
+系统
+ └─ 设置(/settings) ← 保留
+```
+
+### 4.2 移动端导航(底部 4 Tab + FAB)
+
+```
+底部 Tab:
+ 🏠 老板台(/)
+ 📋 事项(/matters)
+ 👥 团队(/team)
+ 👤 我(/me) ← 聚合 profile/settings/history
+
+FAB(+):全局浮动按钮 → 快捷指令面板(底部抽屉)
+```
+
+**移动端"我"页**:聚合 我的画像 / 对话历史 / 设置 / 知识库 入口,解决移动端导航深度问题。
+
+---
+
+## 5. 功能清单(PRD-FUNC)
+
+### 5.1 阶段一(P0,2 周)
+
+| 编号 | 功能 | 描述 | 关联场景 |
+|---|---|---|---|
+| PRD-FUNC-001 | 老板台 | 替代 Dashboard,展示"今日要事":高风险提醒、待确认事项、专家回复、经营健康度 | SCENE-001 |
+| PRD-FUNC-002 | 事项看板 | 案卡看板视图,按状态分列(待补充事实/待确认/分析中/待专家/已闭环),支持断点续接 | SCENE-002 |
+| PRD-FUNC-003 | 案卡状态机 | 会话绑定案卡,状态:新建→待补充事实→待确认→分析中→待专家(可选)→已闭环 | SCENE-002 |
+| PRD-FUNC-004 | 案卡续接 | 点击案卡进入对应 ChatPanel,恢复会话上下文 | SCENE-002 |
+| PRD-FUNC-005 | ChatPanel 接入案卡 | 新建咨询自动创建案卡,状态随会话推进自动流转 | SCENE-007/008 |
+| PRD-FUNC-006 | 移动端底部 Tab | 重构移动端导航为底部 4 Tab + FAB | SCENE-001 |
+
+### 5.2 阶段二(P1,2 周)
+
+| 编号 | 功能 | 描述 | 关联场景 |
+|---|---|---|---|
+| PRD-FUNC-007 | 快捷指令面板 | FAB 触发底部抽屉,画像驱动的指令网格 + 语音/文字输入 | SCENE-003 |
+| PRD-FUNC-008 | 画像驱动指令 | 根据行业/规模生成个性化指令(餐饮→用工合规、食安风险) | SCENE-003 |
+| PRD-FUNC-009 | 指令预填模板 | 点击指令预填问题模板,老板补充关键细节即可 | SCENE-003 |
+| PRD-FUNC-010 | 我的团队 | 重构 Experts 为关系视图:咨询过的专家自动入队,显示关系深度 | SCENE-004 |
+| PRD-FUNC-011 | 团队一键咨询 | 从"我的团队"发起咨询,自动带上历史上下文 | SCENE-004 |
+| PRD-FUNC-012 | 语音输入 | 快捷指令面板支持语音输入(Web Speech API) | SCENE-003 |
+
+### 5.3 阶段三(P1-P2,2 周)
+
+| 编号 | 功能 | 描述 | 关联场景 |
+|---|---|---|---|
+| PRD-FUNC-013 | 风险雷达 | 后端定时扫描知识库新规+画像匹配,推送到老板台 | SCENE-005 |
+| PRD-FUNC-014 | 风险分级推送 | 红(立即)/橙(本周)/蓝(知晓),老板台卡片展示 | SCENE-005 |
+| PRD-FUNC-015 | 决策日志 | 闭环案卡自动归档为决策记录,支持月度/季度复盘 | SCENE-006 |
+| PRD-FUNC-016 | 决策结果标记 | 老板标记决策执行情况(顺利/受阻/未执行),反哺 AI | SCENE-006 |
+| PRD-FUNC-017 | ChatPanel 风险卡置顶 | 风险卡从消息流提取为置顶固定卡片,可独立分享/导出 | SCENE-007 |
+| PRD-FUNC-018 | 起诉状三步向导 | 要素式起诉状改为:预览→编辑→导出/发专家 三步向导 | SCENE-007 |
+
+---
+
+## 6. UI/UX 设计原则
+
+### 6.1 视觉规范
+
+| 维度 | 规范 |
+|---|---|
+| 主色 | `indigo-600`(信任+专业),替代当前 gray-900 |
+| 辅色 | `emerald`(安全) / `amber`(注意) / `rose`(风险) |
+| 字体 | Geist,标题加粗,正文 15px(老板友好) |
+| 圆角 | 卡片 `rounded-xl`(12px),按钮 `rounded-lg`(8px) |
+| 容器 | `container mx-auto max-w-7xl px-4`(保留) |
+| 触控 | 移动端最小点击区 44×44px |
+| 禁止 | 硬编码 hex、emoji 作图标、alert/confirm、div onClick |
+
+### 6.2 交互规范
+
+| 场景 | 规范 |
+|---|---|
+| 页面切换 | 200ms ease-out |
+| 案卡状态变化 | 300ms 过渡 |
+| 案卡手势 | 左滑归档、右滑置顶(移动端) |
+| 空态 | 引导"完善画像解锁更精准建议",非"暂无数据" |
+| 离线 | 顶部条提示 + 本地缓存案卡列表 |
+| 流式输出 | 先出结论卡片,再展开推理过程 |
+| 危险操作 | Dialog 二次确认 |
+
+### 6.3 角色化三端
+
+| 端 | 风格 | Header | Sidebar |
+|---|---|---|---|
+| C 端(user/老板) | 温暖专业 | `h-14 bg-white/95 backdrop-blur`,主色 indigo | `w-60`,图标+文字 |
+| B 端(expert/专家) | 专业密集 | 保留当前 | 保留当前 |
+| Admin | 警示 | `bg-slate-950` + 主色 `amber-400` + ADMIN 徽章 | 深色 |
+
+---
+
+## 7. 数据模型影响
+
+### 7.1 新增:案卡(Matter)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| id | int | 主键 |
+| code | string | 唯一业务代码 `MTR_{YYYYMMDD}_{seq}` |
+| user_id | int | 所属老板 |
+| scene | string | legal/decision/life/emotion |
+| title | string | 案卡标题(AI 生成或用户命名) |
+| status | string | new/asking_facts/pending_confirm/analyzing/pending_expert/closed |
+| session_id | string | 关联会话 sid |
+| risk_level | string | nullable,green/amber/orange/red |
+| summary | text | nullable,AI 结论摘要 |
+| closed_at | timestamp | nullable |
+| execution_result | string | nullable,顺利/受阻/未执行 |
+| created_at / updated_at | timestamp | 审计 |
+
+### 7.2 新增:风险推送(RiskAlert)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| id | int | 主键 |
+| user_id | int | 推送对象 |
+| level | string | red/orange/blue |
+| title | string | 标题 |
+| content | text | 详情 |
+| source | string | 来源(knowledge/doc_scan) |
+| related_matter_id | int | nullable |
+| read_at | timestamp | nullable |
+| created_at | timestamp | |
+
+### 7.3 新增:专家关系(ExpertRelation)
+
+| 字段 | 类型 | 说明 |
+|---|---|---|
+| id | int | 主键 |
+| user_id | int | 老板 |
+| expert_id | int | 专家 |
+| consultation_count | int | 咨询次数 |
+| last_consulted_at | timestamp | 最近咨询 |
+| tags | string | 擅长领域标签(逗号分隔) |
+| created_at / updated_at | timestamp | |
+
+### 7.4 现有表扩展
+
+- `sessions` 表新增 `matter_id`(nullable,关联案卡)
+- `profiles` 表无变化(画像驱动指令只读消费)
+
+---
+
+## 8. API 影响概览
+
+详细 API 契约在 `2-task-bossai-uiux.md` 中按 TASK 展开。核心新增端点:
+
+| 端点 | 方法 | 说明 | 阶段 |
+|---|---|---|---|
+| `/matters` | GET | 案卡看板列表(按状态分组) | 一 |
+| `/matters` | POST | 创建案卡(绑定会话) | 一 |
+| `/matters/{id}` | GET | 案卡详情 | 一 |
+| `/matters/{id}/status` | PATCH | 更新案卡状态 | 一 |
+| `/matters/{id}/close` | POST | 闭环案卡 | 一 |
+| `/quick-commands` | GET | 画像驱动快捷指令 | 二 |
+| `/team` | GET | 我的团队列表 | 二 |
+| `/risk-alerts` | GET | 风险推送列表 | 三 |
+| `/risk-alerts/{id}/read` | POST | 标记已读 | 三 |
+| `/matters/{id}/execution` | PATCH | 标记决策执行结果 | 三 |
+
+---
+
+## 9. 非功能需求
+
+| 维度 | 要求 |
+|---|---|
+| 性能 | 老板台首屏 < 1.5s,案卡看板 < 1s |
+| 移动端 | 底部 Tab 所有页面可达,FAB 全局可用 |
+| 离线 | 案卡列表本地缓存,断网可查看 |
+| 无障碍 | 键盘可达,对比度 ≥ 4.5:1,aria-label 完整 |
+| 兼容 | Chrome/Safari/Edge 最近 2 版,iOS/Android 移动浏览器 |
+| 安全 | 案卡数据租户隔离(user_id),前端隐藏 ≠ 后端放权 |
+
+---
+
+## 10. 验收标准(整体)
+
+- [ ] 老板打开应用,3 秒内看到"今日要事"
+- [ ] 案卡看板正确展示所有进行中事项,点击可续接
+- [ ] 移动端底部 Tab 全功能可达,FAB 弹出快捷指令
+- [ ] 快捷指令基于画像生成,点击预填问题
+- [ ] "我的团队"显示咨询过的专家,一键发起咨询
+- [ ] 风险推送出现在老板台,可标记已读
+- [ ] 闭环案卡归档为决策日志,可标记执行结果
+- [ ] ChatPanel 风险卡置顶,起诉状三步向导可用
+- [ ] 三端视觉规范对齐(C 端 indigo / B 端 gray / Admin 深色+amber)
+- [ ] lint + typecheck + build 通过
+
+---
+
+## 11. 风险与对策
+
+| 风险 | 对策 |
+|---|---|
+| 范式转变用户不适应 | 阶段一保留法务/决策/生活独立入口,案卡为增量 |
+| 后端定时任务复杂度 | 阶段三风险雷达先用简单轮询,后续优化 |
+| 语音输入浏览器兼容 | 降级为文字输入,Safari 不支持时隐藏语音按钮 |
+| 移动端底部 Tab 重构量大 | 与桌面 Sidebar 共享 NavConfig,仅渲染层不同 |
+| 画像数据不全导致指令不准 | 缺口引导:"完善行业信息解锁更精准建议" |
+
+---
+
+📌 v2.0 更新:见 `pmdocs/changes/2026-08-30-001-uiux重构.md`
+影响范围:SCENE-001~010、PRD-FUNC-001~018、全用户端导航与页面
+状态:待确认
diff --git a/pmdocs/2-task-bossai-uiux.md b/pmdocs/2-task-bossai-uiux.md
new file mode 100644
index 0000000..09d81da
--- /dev/null
+++ b/pmdocs/2-task-bossai-uiux.md
@@ -0,0 +1,469 @@
+# BossAi UIUX 重构任务拆分
+
+- **版本**:v1.0
+- **状态**:已交付(三阶段全部完成)
+- **日期**:2026-08-30
+- **关联**:PRD v2.0、ADR-002、CHG-20260830-001
+- **执行前读取顺序**:run.md → 本文档 → CHG-20260830-001 → 1-prd-bossai.md → ADR-001/002
+
+---
+
+## 总览
+
+三阶段,每阶段可独立交付。任务编号一旦分配不复用。
+
+| 阶段 | 周期 | 核心交付 | TASK 范围 |
+|---|---|---|---|
+| 一 | 2 周 | 老板台 + 案卡看板 + 移动端底部 Tab | TASK-001 ~ TASK-012 |
+| 二 | 2 周 | 快捷指令 + 我的团队 + 语音输入 | TASK-013 ~ TASK-020 |
+| 三 | 2 周 | 风险雷达 + 决策日志 + ChatPanel 升级 | TASK-021 ~ TASK-028 |
+
+**依赖关系**:
+```
+阶段一 ──→ 阶段二 ──→ 阶段三
+TASK-003(DB) → TASK-004(API) → TASK-005~008(前端)
+TASK-006(案卡前端) → TASK-013(快捷指令)
+TASK-010(团队后端) → TASK-011(团队前端)
+```
+
+---
+
+## 阶段一:老板台 + 案卡看板(P0,2 周)
+
+### TASK-001:DB migration — 案卡表
+
+- **描述**:新增 `matters` 表,扩展 `sessions` 表关联案卡
+- **依赖**:无
+- **验收**:
+ - [x] `matters` 表创建,字段见 PRD §7.1
+ - [x] `matters.code` 唯一索引
+ - [x] `matters.user_id` 索引
+ - [x] `sessions` 表新增 `matter_id` nullable 外键
+ - [x] migration 脚本 + rollback 脚本
+ - [x] seed 1 条测试案卡
+- **测试**:migration up/down 各跑一次,schema 校验
+
+### TASK-002:DB migration — 专家关系表
+
+- **描述**:新增 `expert_relations` 表
+- **依赖**:无
+- **验收**:
+ - [x] `expert_relations` 表创建,字段见 PRD §7.3
+ - [x] `(user_id, expert_id)` 联合唯一索引
+ - [x] migration + rollback
+- **测试**:migration up/down 校验
+
+### TASK-003:后端 — 案卡 CRUD API
+
+- **描述**:实现案卡增删改查端点
+- **依赖**:TASK-001
+- **端点**:
+ - `GET /matters?status=xxx` — 按状态分组返回案卡看板
+ - `POST /matters` — 创建案卡(绑定 session_id)
+ - `GET /matters/{id}` — 案卡详情(含会话消息摘要)
+ - `PATCH /matters/{id}/status` — 更新状态
+ - `POST /matters/{id}/close` — 闭环(可选填 execution_result)
+- **验收**:
+ - [x] 租户隔离(user_id 校验)
+ - [x] 状态机校验(非法转换 422)
+ - [x] code 自动生成 `MTR_{YYYYMMDD}_{seq}`
+ - [x] OpenAPI 同步
+ - [x] 单元测试覆盖 CRUD + 权限 + 状态机
+- **测试**:pytest 覆盖正常/越权/非法状态转换
+
+### TASK-004:后端 — 案卡状态自动流转
+
+- **描述**:在现有 `ask-analyze` 流程中自动推进案卡状态
+- **依赖**:TASK-003
+- **验收**:
+ - [x] 首次提问时自动创建案卡(status=new)
+ - [x] AI 返回 questions 时 → status=asking_facts
+ - [x] 事实提交后 → status=pending_confirm
+ - [x] 用户确认分析 → status=analyzing
+ - [x] 流式分析完成 → status=analyzing(保持)
+ - [x] 问专家 → status=pending_expert
+ - [x] 不破坏无案卡的旧会话(matter_id nullable)
+- **测试**:集成测试覆盖全状态流转
+
+### TASK-005:后端 — 老板台聚合 API
+
+- **描述**:新增 `/dashboard/today` 聚合端点
+- **依赖**:TASK-003
+- **端点**:`GET /dashboard/today`
+- **返回**:
+ ```json
+ {
+ "greeting": "早上好,张总",
+ "high_risks": [{ "matter_id": 1, "title": "...", "level": "red" }],
+ "pending_confirm": [{ "matter_id": 2, "title": "...", "scene": "legal" }],
+ "expert_replies": [{ "consultation_id": 1, "expert_name": "...", "snippet": "..." }],
+ "health_score": { "legal": 80, "operation": 60, "compliance": 70 }
+ }
+ ```
+- **验收**:
+ - [x] 聚合高风险案卡(risk_level=red/orange)
+ - [x] 聚合待确认案卡(status=pending_confirm)
+ - [x] 聚合未读专家回复
+ - [x] health_score 基于案卡风险分布计算
+ - [x] 单元测试
+- **测试**:pytest 覆盖空数据/正常数据
+
+### TASK-006:前端 — 导航重构(共享 NavConfig)
+
+- **描述**:合并 Sidebar/MobileNav 的重复常量为单一 `NavConfig`
+- **依赖**:无
+- **验收**:
+ - [x] 新建 `src/lib/nav-config.ts`,导出 `USER_NAV`、`EXPERT_NAV`、`ADMIN_NAV`
+ - [x] Sidebar 和 MobileNav 消费同一配置
+ - [x] 补齐 MobileNav 缺失的"我的画像"入口
+ - [x] 补齐 Header 标题映射(/history、/profile、/admin/*)
+ - [x] 新增路由:/matters、/team、/decisions-log、/me
+ - [x] lint + build 通过
+- **测试**:手动验证导航一致性
+
+### TASK-007:前端 — 老板台页面
+
+- **描述**:重构 Dashboard 为"老板台"
+- **依赖**:TASK-005、TASK-006
+- **页面**:`src/pages/Dashboard.tsx`
+- **验收**:
+ - [x] 顶部问候语(按时段)
+ - [x] 高风险提醒卡片(红/橙底,[立即处理]按钮)
+ - [x] 待确认事项列表([去确认]跳转案卡)
+ - [x] 专家回复摘要([查看回复]跳转)
+ - [x] 经营健康度条形图(recharts)
+ - [x] 加载/空态/错误状态覆盖
+ - [x] 移动端适配
+- **测试**:手动验证 + 响应式
+
+### TASK-008:前端 — 事项看板页面
+
+- **描述**:新增案卡看板
+- **依赖**:TASK-003、TASK-006
+- **页面**:`src/pages/Matters.tsx`
+- **验收**:
+ - [x] 看板分列:待补充事实 / 待确认 / 分析中 / 待专家 / 已闭环
+ - [x] 案卡卡片:图标+标题+场景标签+时间+[继续/确认]按钮
+ - [x] 点击案卡 → 跳转对应 ChatPanel 并恢复会话
+ - [x] 移动端横向滚动看板列
+ - [x] 案卡左滑归档(移动端手势)
+ - [x] 加载/空态(引导完善画像)
+- **测试**:手动验证 + 响应式
+
+### TASK-009:前端 — 案卡组件抽取
+
+- **描述**:抽取 `MatterCard` 公共组件
+- **依赖**:TASK-008
+- **组件**:`src/components/common/MatterCard.tsx`
+- **验收**:
+ - [x] props: matter / onClick / onArchive
+ - [x] 状态色映射(new=blue, asking_facts=amber, pending_confirm=indigo, analyzing=purple, pending_expert=orange, closed=gray)
+ - [x] 更新 `ui-components.md` 登记
+- **测试**:组件渲染测试
+
+### TASK-010:前端 — ChatPanel 接入案卡
+
+- **描述**:ChatPanel 新建会话时创建案卡,状态随会话流转
+- **依赖**:TASK-004、TASK-008
+- **改动**:`src/components/common/ChatPanel.tsx`
+- **验收**:
+ - [x] 新建会话时调用 `POST /matters` 绑定
+ - [x] 顶部显示案卡标题(可编辑)
+ - [x] 状态流转时调用 `PATCH /matters/{id}/status`
+ - [x] 分析完成后显示"闭环"按钮
+ - [x] 闭环时弹窗可选填执行结果
+ - [x] 不破坏无案卡的旧会话
+- **测试**:集成测试覆盖创建→流转→闭环
+
+### TASK-011:前端 — 移动端底部 Tab + FAB
+
+- **描述**:移动端导航重构为底部 Tab + FAB
+- **依赖**:TASK-006
+- **组件**:`src/components/layout/BottomTab.tsx`、`src/components/layout/Fab.tsx`
+- **验收**:
+ - [x] 底部 4 Tab:老板台 / 事项 / 团队 / 我
+ - [x] "我"页聚合 profile/history/settings/knowledge 入口
+ - [x] FAB(+) 全局浮动,点击弹出快捷指令占位(阶段二实现)
+ - [x] md 以上隐藏底部 Tab,显示 Sidebar
+ - [x] 触控区 ≥ 44×44px
+ - [x] 活跃 Tab 高亮
+- **测试**:响应式手动验证
+
+### TASK-012:阶段一构建验证
+
+- **描述**:lint + typecheck + build + 手动冒烟
+- **依赖**:TASK-001 ~ TASK-011
+- **验收**:
+ - [x] `npm run lint` 无 error
+ - [x] `npm run build` 通过
+ - [x] 后端 pytest 通过
+ - [x] 手动冒烟:老板台→案卡→ChatPanel→闭环 全流程
+ - [x] 移动端底部 Tab 全功能可达
+ - [x] 更新 `run.md`(若有新命令)
+ - [x] 更新 `ui-components.md`
+
+---
+
+## 阶段二:快捷指令 + 我的团队(P1,2 周)
+
+### TASK-013:后端 — 快捷指令 API
+
+- **描述**:新增 `/quick-commands` 画像驱动指令生成
+- **依赖**:无
+- **端点**:`GET /quick-commands`
+- **返回**:
+ ```json
+ {
+ "legal": [{ "id": "contract_review", "label": "查合同风险", "template": "我有一份合同,帮我看看..." }],
+ "decision": [{ "id": "expand", "label": "该不该扩张", "template": "我在考虑开新店..." }],
+ "life": [...]
+ }
+ ```
+- **验收**:
+ - [x] 基于画像行业/规模生成个性化指令
+ - [x] 画像不全时返回通用指令 + 缺口提示
+ - [x] 指令模板预填问题,留关键细节占位符
+ - [x] 单元测试
+- **测试**:pytest 覆盖画像完整/缺失
+
+### TASK-014:前端 — 快捷指令面板
+
+- **描述**:FAB 点击弹出底部抽屉
+- **依赖**:TASK-011、TASK-013
+- **组件**:`src/components/common/QuickCommandPanel.tsx`
+- **验收**:
+ - [x] 底部抽屉动画(300ms slide-up)
+ - [x] 分组展示:法务/经营/生活
+ - [x] 指令网格卡片(图标+标签)
+ - [x] 点击指令 → 预填模板到 ChatPanel 输入框 → 跳转对应页面
+ - [x] 底部语音输入按钮 + 文字输入框
+ - [x] 画像不全时显示"完善画像解锁更精准建议"
+ - [x] 抽屉可下拉关闭
+- **测试**:手动验证 + 响应式
+
+### TASK-015:前端 — 语音输入
+
+- **描述**:快捷指令面板支持语音输入
+- **依赖**:TASK-014
+- **验收**:
+ - [x] 使用 Web Speech API(webkitSpeechRecognition)
+ - [x] 中文识别(lang='zh-CN')
+ - [x] 识别结果实时填入输入框
+ - [x] Safari/不支持浏览器隐藏语音按钮(降级)
+ - [x] 识别中状态动画
+- **测试**:Chrome 验证,Safari 降级验证
+
+### TASK-016:后端 — 我的团队 API
+
+- **描述**:新增 `/team` 端点,聚合专家关系
+- **依赖**:TASK-002
+- **端点**:`GET /team`
+- **返回**:
+ ```json
+ [{
+ "expert_id": 1, "name": "王律师", "avatar": "...", "categories": ["劳动法"],
+ "online": true, "consultation_count": 3, "last_consulted_at": "...",
+ "last_topic": "用工纠纷"
+ }]
+ ```
+- **验收**:
+ - [x] 自动从 consultation 记录聚合关系
+ - [x] 首次咨询自动创建 expert_relations 记录
+ - [x] 后续咨询更新 count/last_consulted_at
+ - [x] 单元测试
+- **测试**:pytest 覆盖聚合逻辑
+
+### TASK-017:前端 — 我的团队页面
+
+- **描述**:重构 Experts 为"我的团队"
+- **依赖**:TASK-016、TASK-006
+- **页面**:`src/pages/Team.tsx`(替代/兼容 Experts)
+- **验收**:
+ - [x] "我的团队"列表:头像+姓名+擅长+在线状态+关系深度
+ - [x] 每人卡片:[发消息] [新咨询] 按钮
+ - [x] [新咨询] → 跳转 ChatPanel,自动带上历史上下文摘要
+ - [x] 底部"找新专家"入口 → 跳转完整专家库
+ - [x] 空态:"你还没有咨询过专家,去逛逛专家库"
+ - [x] 保留原有 Experts 页面作为完整专家库(/experts)
+- **测试**:手动验证
+
+### TASK-018:前端 — 团队一键咨询上下文
+
+- **描述**:从"我的团队"发起咨询时自动带上历史
+- **依赖**:TASK-017
+- **验收**:
+ - [x] 跳转 ChatPanel 时传入 `expertId` + `lastTopic`
+ - [x] ChatPanel 顶部显示"咨询王律师·上次话题:用工纠纷"
+ - [x] 首条消息自动带上"基于上次咨询..."上下文
+ - [x] 问专家弹窗默认选中该专家
+- **测试**:手动验证上下文传递
+
+### TASK-019:前端 — 移动端"我"页面
+
+- **描述**:新增移动端"我"聚合页
+- **依赖**:TASK-011
+- **页面**:`src/pages/Me.tsx`
+- **验收**:
+ - [x] 头像+姓名+行业规模标签
+ - [x] 入口列表:我的画像 / 对话历史 / 知识库 / 设置
+ - [x] 仅移动端底部 Tab 可达,桌面端无此路由
+ - [x] 桌面端这些入口仍在 Sidebar
+- **测试**:响应式验证
+
+### TASK-020:阶段二构建验证
+
+- **描述**:lint + typecheck + build + 手动冒烟
+- **依赖**:TASK-013 ~ TASK-019
+- **验收**:
+ - [x] lint/build 通过
+ - [x] 后端 pytest 通过
+ - [x] 冒烟:FAB→快捷指令→预填→提问→问专家→我的团队
+ - [x] 语音输入 Chrome 验证
+ - [x] 更新 `ui-components.md`
+
+---
+
+## 阶段三:风险雷达 + 决策日志 + ChatPanel 升级(P1-P2,2 周)
+
+### TASK-021:DB migration — 风险推送表
+
+- **描述**:新增 `risk_alerts` 表
+- **依赖**:无
+- **验收**:
+ - [x] `risk_alerts` 表创建,字段见 PRD §7.2
+ - [x] `(user_id, read_at)` 索引
+ - [x] migration + rollback
+- **测试**:migration 校验
+
+### TASK-022:后端 — 风险雷达扫描任务
+
+- **描述**:定时扫描知识库新规 + 画像匹配,生成推送
+- **依赖**:TASK-021
+- **验收**:
+ - [x] 定时任务(APScheduler 或简单轮询),每日 1 次
+ - [x] 扫描知识库近 7 天新增文档,匹配用户画像行业
+ - [x] 生成 risk_alerts 记录(level=blue/orange)
+ - [x] 文档上传后触发即时扫描(level=red/orange/blue)
+ - [x] 单元测试
+- **测试**:pytest mock 定时任务
+
+### TASK-023:后端 — 风险推送 API
+
+- **描述**:风险推送查询与标记已读
+- **依赖**:TASK-022
+- **端点**:
+ - `GET /risk-alerts?unread=true` — 未读推送列表
+ - `POST /risk-alerts/{id}/read` — 标记已读
+- **验收**:
+ - [x] 租户隔离
+ - [x] unread 过滤
+ - [x] 单元测试
+- **测试**:pytest
+
+### TASK-024:前端 — 老板台风险推送卡片
+
+- **描述**:老板台集成风险推送
+- **依赖**:TASK-005、TASK-023
+- **改动**:`src/pages/Dashboard.tsx`
+- **验收**:
+ - [x] 高风险推送置顶(红/橙底)
+ - [x] [立即处理] → 跳转相关案卡或知识库文档
+ - [x] [稍后] → 标记已读
+ - [x] Header 铃铛角标显示未读数
+ - [x] 点击铃铛 → 下拉推送列表
+- **测试**:手动验证
+
+### TASK-025:前端 — 决策日志页面
+
+- **描述**:新增决策日志
+- **依赖**:TASK-003
+- **页面**:`src/pages/DecisionsLog.tsx`
+- **验收**:
+ - [x] 按月分组展示闭环案卡
+ - [x] 每条:日期+场景图标+标题+决定摘要+执行情况标记
+ - [x] [标记结果] → 弹窗选 顺利/受阻/未执行 + 备注
+ - [x] 月度统计条:本月决策数、已执行率
+ - [x] 空态:"你还没有闭环的决策,去处理进行中的事项"
+- **测试**:手动验证
+
+### TASK-026:前端 — ChatPanel 风险卡置顶
+
+- **描述**:风险卡从消息流提取为置顶固定卡片
+- **依赖**:TASK-010
+- **改动**:`src/components/common/ChatPanel.tsx`、`RiskCardView.tsx`
+- **验收**:
+ - [x] 分析完成后风险卡固定在对话区顶部
+ - [x] 可折叠/展开
+ - [x] [分享] 按钮 → 复制链接或文本
+ - [x] [导出] 按钮 → 下载 PDF/Markdown
+ - [x] 消息流中保留风险卡引用锚点
+- **测试**:手动验证
+
+### TASK-027:前端 — 起诉状三步向导
+
+- **描述**:要素式起诉状改为三步向导
+- **依赖**:TASK-010
+- **组件**:`src/components/common/FilingWizard.tsx`
+- **验收**:
+ - [x] 步骤1 预览:展示模板+已填要素
+ - [x] 步骤2 编辑:可修改要素值
+ - [x] 步骤3 导出:下载 / 发送给专家 / 复制
+ - [x] 步骤指示器
+ - [x] 替换当前一键生成按钮
+ - [x] 更新 `ui-components.md`
+- **测试**:手动验证
+
+### TASK-028:阶段三构建验证 + 三端视觉对齐
+
+- **描述**:lint + typecheck + build + 三端视觉对齐
+- **依赖**:TASK-021 ~ TASK-027
+- **验收**:
+ - [x] lint/build 通过
+ - [x] 后端 pytest 通过
+ - [x] C 端主色 indigo-600 落地
+ - [x] Admin Header 深色 + amber-400 + ADMIN 徽章
+ - [x] 冒烟:风险推送→老板台→决策日志→起诉状向导
+ - [x] 更新 `run.md`(定时任务配置)
+ - [x] 更新 `ui-components.md`
+ - [x] 更新 `CHANGELOG.md`
+
+---
+
+## 执行规则
+
+1. **按阶段顺序执行**,阶段内任务可并行(后端/前端分离)
+2. **每个 TASK 完成后**:
+ - 标记 `[x]`
+ - 更新 `ui-components.md`(若涉及组件)
+ - 更新 `run.md`(若涉及新命令/配置)
+3. **DB 变更**必须先 migration 再 API 再前端
+4. **不删无关代码**,旧页面保留为兼容入口直到新页面验证通过
+5. **commit 前缀**:`feat(uiux)` / `feat(api)` / `feat(db)` / `fix` / `refactor`
+6. **PR ≤ 400 行**,单一职责
+
+---
+
+## 验收检查清单(整体)
+
+### 阶段一完成标准
+- [x] 老板台展示今日要事
+- [x] 案卡看板全状态流转
+- [x] ChatPanel 接入案卡
+- [x] 移动端底部 Tab 可用
+- [x] build + pytest 通过
+
+### 阶段二完成标准
+- [x] FAB 快捷指令基于画像
+- [x] 语音输入 Chrome 可用
+- [x] 我的团队显示关系
+- [x] 一键咨询带上上下文
+- [x] build + pytest 通过
+
+### 阶段三完成标准
+- [x] 风险推送出现在老板台
+- [x] 决策日志可标记结果
+- [x] 风险卡置顶可分享
+- [x] 起诉状三步向导
+- [x] 三端视觉对齐
+- [x] build + pytest 通过
+- [x] CHANGELOG 同步
diff --git a/pmdocs/CHANGELOG.md b/pmdocs/CHANGELOG.md
new file mode 100644
index 0000000..0b6f795
--- /dev/null
+++ b/pmdocs/CHANGELOG.md
@@ -0,0 +1,41 @@
+# BossAi 变更索引(CHANGELOG)
+
+> 记录所有需求变更、架构决策和重要里程碑。按时间倒序。
+
+---
+
+## 2026-08-30
+
+### CHG-20260830-001:UIUX 重构 — 从"对话工具"到"经营智囊"
+
+- **状态**:已交付(三阶段全部完成)
+- **类型**:产品定位升级 + UIUX 重构
+- **影响**:需求 / PRD / 任务 / DB / 后端 / 前端
+- **关联文档**:
+ - `pmdocs/1-prd-bossai.md` v2.0
+ - `pmdocs/2-task-bossai-uiux.md`
+ - `pmdocs/adr/2026-08-30-002-uiux范式转变.md`
+ - `pmdocs/changes/2026-08-30-001-uiux重构.md`
+- **摘要**:
+ - 产品定位从"AI 对话工具"升级为"经营智囊"
+ - 交互范式从"对话优先"转向"任务卡片优先"
+ - 新增老板台、事项看板、快捷指令、我的团队、风险雷达、决策日志
+ - 移动端导航重构为底部 Tab + FAB
+ - 三阶段交付,共 28 个 TASK
+- **交付物**:
+ - `web2/` 全新前端(端口 5174)
+ - `migrations/001_bossai_v2.sql` + `002_risk_alerts.sql`
+ - `api.py` 新增 12 个端点
+ - `db_init.py` 新增 3 张表
+ - typecheck + build 全通过
+
+---
+
+## 2026-08-29
+
+### ADR-001:前端技术栈选型
+
+- **状态**:已确认
+- **类型**:架构决策
+- **摘要**:采用 React 18 + Vite + TypeScript + Tailwind + shadcn/ui
+- **文档**:`pmdocs/adr/2026-08-29-001-frontend-stack.md`
diff --git a/pmdocs/adr/2026-08-30-002-uiux范式转变.md b/pmdocs/adr/2026-08-30-002-uiux范式转变.md
new file mode 100644
index 0000000..4d9e2ff
--- /dev/null
+++ b/pmdocs/adr/2026-08-30-002-uiux范式转变.md
@@ -0,0 +1,101 @@
+# ADR-002 UIUX 范式转变:从"对话工具"到"经营智囊"
+
+- **状态**:待确认
+- **日期**:2026-08-30
+- **决策者**:产品负责人 + 技术负责人
+- **关联**:PRD v2.0、CHG-20260830-001
+
+## 背景
+
+BossAi v1.0 上线后,用户调研发现中小企业老板的核心痛点未被满足:
+
+1. **对话流程过长**:提问→追问→确认→分析,老板 30 秒内拿不到要点
+2. **无事项管理**:同时处理多件事时,无法可视化追踪进度
+3. **AI 被动响应**:老板不问,AI 不说,缺少主动提醒
+4. **专家关系断裂**:咨询过的专家无沉淀,每次重新找
+5. **决策无复盘**:做完就忘,无法反哺 AI
+
+根本原因:v1.0 是"AI 对话产品",老板要的是"我的经营智囊"。
+
+## 选项
+
+### 选项 A:保持对话优先,局部优化
+
+- 优化 ChatPanel 追问体验、风险卡展示
+- 保留当前按场景分页的导航
+- **优点**:改动小,风险低
+- **缺点**:不解决根本痛点,老板仍觉得"不像我的智囊"
+
+### 选项 B:任务卡片优先,对话为手段(推荐)
+
+- 新增"老板台"(今日要事)+ "事项看板"(案卡)
+- 对话成为案卡的推进手段,不是目的
+- AI 主动推送风险,专家沉淀为"我的团队"
+- 移动端重构为底部 Tab + FAB
+- **优点**:直击痛点,产品定位清晰
+- **缺点**:重构量大,需分阶段交付
+
+### 选项 C:完全重写
+
+- 抛弃现有代码,重新设计
+- **优点**:无历史包袱
+- **缺点**:浪费已验证的 ChatPanel/要素化立案/事实确认等核心能力
+
+## 决策
+
+选择 **选项 B**:任务卡片优先,分三阶段交付。
+
+核心原则:
+1. **保留并优化 ChatPanel**,不推翻已验证的对话能力
+2. **新增案卡层**,包裹现有对话,提供事项管理视角
+3. **导航重组**,从"按场景分页"转向"按事项聚合"
+4. **移动端重构**,底部 Tab 替代 Sidebar 抽屉
+
+## 后果
+
+### 收益
+
+- 产品定位从"工具"升级为"智囊",差异化竞争力提升
+- 老板可同时管理多件事,降低流失率
+- AI 主动推送增加打开频次
+- 专家关系沉淀提升续费意愿
+
+### 成本
+
+- 三阶段共约 6 周(每阶段 2 周)
+- 后端新增 3 张表(matters/risk_alerts/expert_relations)
+- 前端导航重构,移动端底部 Tab 为较大改动
+- 需新增 10 个 API 端点
+
+### 风险
+
+- 范式转变用户不适应 → 阶段一保留独立入口,案卡为增量
+- 移动端底部 Tab 重构 → 与 Sidebar 共享 NavConfig,降低重复
+
+## 实施策略:新目录并行开发
+
+**决策**:在 `web2/` 新目录开发 2.0 前端,保留 `web/` 原样不动。
+
+| 维度 | 说明 |
+|---|---|
+| 原目录 `web/` | 保留为 v1.0,继续可用,不做任何改动 |
+| 新目录 `web2/` | 2.0 全新实现,独立 package.json、独立端口 |
+| 后端 `api.py` | 共享同一后端,新增端点向后兼容,不破坏 v1.0 调用 |
+| 数据库 | 共享同一数据库,新增表通过 migration 脚本添加 |
+| 切换 | 2.0 验证通过后,可切换 Nginx/反代指向 web2/dist,或直接替换 web/ |
+
+**端口分配**:
+- `web/` (v1.0):5173(保留)
+- `web2/` (v2.0):5174
+
+**优点**:
+- v1.0 用户零影响,可随时回退
+- 2.0 可大胆重构,无历史包袱
+- 同一后端,API 契约清晰
+
+## 回滚思路
+
+- 阶段一可独立回滚:老板台/案卡为新增页面,不影响现有 ChatPanel
+- 阶段二快捷指令为新增组件,不替换现有话题选择
+- 阶段三风险雷达为后端新增任务,可关闭不影响主流程
+- 若整体回滚,删除 web2/ 目录和新增表即可,web/ 和现有功能不受影响
diff --git a/pmdocs/changes/2026-08-30-001-uiux重构.md b/pmdocs/changes/2026-08-30-001-uiux重构.md
new file mode 100644
index 0000000..c29ae82
--- /dev/null
+++ b/pmdocs/changes/2026-08-30-001-uiux重构.md
@@ -0,0 +1,91 @@
+# CHG-20260830-001:UIUX 重构 — 从"对话工具"到"经营智囊"
+
+- **状态**:已完成(三阶段全部交付)
+- **日期**:2026-08-30
+- **关联**:PRD v2.0、ADR-002、2-task-bossai-uiux.md
+
+## 背景
+
+BossAi v1.0 上线后,用户调研发现中小企业老板核心痛点未被满足:对话流程过长、无事项管理、AI 被动响应、专家关系断裂、决策无复盘。根本原因是 v1.0 定位为"AI 对话产品",与老板需要的"经营智囊"错位。
+
+## 原因
+
+产品定位升级:从"对话优先"转向"任务卡片优先",对话成为推进案卡的手段而非目的。
+
+## 目标
+
+- 老板打开应用 3 秒内看到"今日要事"
+- 可视化管理多件并行事项(案卡看板)
+- AI 主动推送风险与提醒
+- 专家关系沉淀为"我的团队"
+- 决策可复盘,反哺 AI
+
+## 范围
+
+- 用户端(user)导航与页面全面重构
+- 新增 3 张表:matters / risk_alerts / expert_relations
+- 新增 10 个 API 端点
+- 移动端导航重构为底部 Tab + FAB
+- ChatPanel 优化(风险卡置顶、起诉状向导)
+- 三端视觉规范对齐
+- expert/admin 端仅做视觉对齐,功能不变
+
+## 影响评估
+
+| 维度 | 是否影响 | 影响说明 | 处理动作 | 状态 |
+|---|---|---|---|---|
+| 需求 | 是 | 新增 SCENE-001~006,PRD-FUNC-001~018 | 创建 PRD v2.0 | 已处理 |
+| PRD | 是 | 产品定位、信息架构、功能清单全面升级 | 创建 1-prd-bossai.md v2.0 | 已处理 |
+| 任务 | 是 | 新增 TASK-001~028,分三阶段 | 创建 2-task-bossai-uiux.md | 已处理 |
+| DB | 是 | 新增 3 张表,sessions 表扩展 matter_id | TASK-001/002/021 migration | 已处理 |
+| 后端 | 是 | 新增 10 个 API,案卡状态自动流转,定时扫描任务 | TASK-003~005/013/016/022/023 | 已处理 |
+| 前端 | 是 | 导航重构、4 个新页面、ChatPanel 改造、移动端底部 Tab | TASK-006~012/014~020/024~027 | 已处理 |
+| 权限码 | 否 | 不涉及权限码变更,沿用 user/expert/admin 三角色 | - | 不适用 |
+
+## 落地任务
+
+- [x] TASK-001:DB migration — 案卡表
+- [x] TASK-002:DB migration — 专家关系表
+- [x] TASK-003:后端 — 案卡 CRUD API
+- [x] TASK-004:后端 — 案卡状态自动流转
+- [x] TASK-005:后端 — 老板台聚合 API
+- [x] TASK-006:前端 — 导航重构(共享 NavConfig)
+- [x] TASK-007:前端 — 老板台页面
+- [x] TASK-008:前端 — 事项看板页面
+- [x] TASK-009:前端 — 案卡组件抽取
+- [x] TASK-010:前端 — ChatPanel 接入案卡
+- [x] TASK-011:前端 — 移动端底部 Tab + FAB
+- [x] TASK-012:阶段一构建验证
+- [x] TASK-013~020:阶段二(快捷指令 + 我的团队)
+- [x] TASK-021~028:阶段三(风险雷达 + 决策日志 + ChatPanel 升级)
+- [x] 更新 CHANGELOG.md
+- [x] 更新 ui-components.md
+- [x] 更新 run.md(定时任务配置)
+
+## 风险与回滚
+
+- **风险**:范式转变用户不适应
+ - **对策**:阶段一保留法务/决策/生活独立入口,案卡为增量
+- **风险**:移动端底部 Tab 重构量大
+ - **对策**:与 Sidebar 共享 NavConfig,仅渲染层不同
+- **风险**:后端定时任务复杂度
+ - **对策**:阶段三先用简单轮询,后续优化
+- **回滚方案**:
+ - 阶段一可独立回滚(新增页面不影响现有)
+ - 阶段二快捷指令为新增组件
+ - 阶段三风险雷达为新增任务,可关闭
+ - 整体回滚:删除新增路由和表,现有功能不受影响
+- **需要回归测试的功能**:
+ - ChatPanel 全流程(提问→追问→确认→分析→起诉状→问专家)
+ - 要素化立案
+ - 专家咨询
+ - 对话历史
+ - 知识库
+
+## 完成记录
+
+- 2026-08-30:创建变更文档,创建 PRD v2.0、ADR-002、任务拆分
+- 2026-08-30:阶段一完成 — DB migration、案卡 API、老板台、事项看板、移动端导航
+- 2026-08-30:阶段二完成 — 快捷指令、我的团队、语音输入
+- 2026-08-30:阶段三完成 — 风险雷达、决策日志、ChatPanel 升级(案卡接入+风险卡置顶+起诉状向导)
+- 2026-08-30:全部 28 个 TASK 交付,typecheck + build 通过,变更闭环
diff --git a/pmdocs/ui-components.md b/pmdocs/ui-components.md
index 9a6bafe..5519bf3 100644
--- a/pmdocs/ui-components.md
+++ b/pmdocs/ui-components.md
@@ -63,3 +63,29 @@
- 圆角:`--radius: 0.625rem`
- 字体:Geist → system-ui 回退
- 容器:`container mx-auto max-w-7xl px-4`
+
+## 2.0 新增组件(web2/src)
+
+> v2.0 在 `web2/` 新目录开发,保留 `web/` v1.0 不动。详见 ADR-002。
+
+| 组件 | 类型 | 适用场景 | 输入状态 | 输出事件 | 使用页面 | 状态 |
+|---|---|---|---|---|---|---|
+| `NavConfig` | 基础模块 | 共享导航配置(USER_NAV/EXPERT_NAV/ADMIN_NAV/MOBILE_TABS) | - | - | Sidebar/BottomTab/Header | 稳定 |
+| `BottomTab` | 布局组件 | 移动端底部 4 Tab(仅 md 以下) | - | `NavLink` 跳转 | AppLayout | 稳定 |
+| `Fab` | 布局组件 | 浮动新建按钮(移动端) | - | `onClick` → 跳转 | AppLayout | 稳定 |
+| `MatterCard` | 领域组件 | 案卡卡片(图标+标题+状态+时间) | `matter` / `onClick` / `onArchive` | `onClick` / `onArchive` | 事项看板 | 稳定 |
+| `ChatPanel`(2.0) | 领域组件 | 通用对话面板(案卡接入+风险卡置顶+起诉状向导+URL预填+专家上下文) | `scene` / `samples` / `modes` / `defaultMode` / `showRiskCard` / `enableUpload` / `matterId` | 内部状态 + 案卡流转 | 法务/决策/生活/情绪 | 稳定 |
+| `QuickCommandPanel` | 领域组件 | 快捷指令底部抽屉(画像驱动+语音输入) | `open` / `onClose` | `onClose` | FAB | 稳定 |
+| `ExpertAvatar` | 基础组件 | 专家头像(带 fallback 首字母) | `name` / `avatarUrl` / `size` | - | 我的团队 | 稳定 |
+| `EmptyState`(2.0) | 组合组件 | 空状态引导 | `title` / `description` / `icon` / `action` | - | 全部页面 | 稳定 |
+| `LoadingState`(2.0) | 组合组件 | 加载状态 | `label` | - | 全部页面 | 稳定 |
+| `ErrorState`(2.0) | 组合组件 | 错误状态+重试 | `message` / `onRetry` | `onRetry` | 全部页面 | 稳定 |
+| `PageLayout`(2.0) | 布局组件 | 页面骨架(标题+描述+操作区+内容) | `title` / `description` / `actions` / `children` | - | 全部页面 | 稳定 |
+
+## 2.0 设计 Token 变更
+
+- 主色:`--primary` 从 gray-900 系改为 **indigo-600** (`hsl(243 75% 59%)`),C 端温暖专业
+- 新增案卡状态色:`matter-new/asking/confirm/analyzing/expert/closed`
+- 新增动画:`slide-up`(300ms) / `slide-down`(200ms) / `fade-in`(200ms)
+- 移动端底部 Tab:`h-16`,主内容区 `padding-bottom: 4rem`
+- FAB:`bottom-20 right-4`,圆形 56px,仅移动端
diff --git a/run.md b/run.md
index 9174b6a..591d39e 100644
--- a/run.md
+++ b/run.md
@@ -70,12 +70,46 @@ npm run preview # 预览构建产物
npm run typecheck # 仅类型检查
```
+### 前端 2.0 Dev(Native)
+
+```bash
+# 启动(端口 5174,自动代理 /api → 127.0.0.1:8000)
+cd /Users/freedak/Documents/AIDashboard/BossAi/web2
+npm install # 首次
+npm run dev
+
+# 后台启动
+npm run dev > /tmp/bossai_vite2.log 2>&1 &
+
+# 停止
+lsof -ti:5174 | xargs kill
+```
+
+### 前端 2.0 构建
+
+```bash
+cd /Users/freedak/Documents/AIDashboard/BossAi/web2
+npm run build # 产物在 web2/dist/
+npm run typecheck # 仅类型检查
+```
+
+### 数据库迁移(2.0 新增表)
+
+```bash
+# 执行 migration(幂等,可重复执行)
+psql -d mcp -f migrations/001_bossai_v2.sql
+
+# 回滚(慎用,会删除案卡和专家关系数据)
+psql -d mcp -f migrations/001_bossai_v2_rollback.sql
+```
+
## 端口表
| 服务 | 端口 | 说明 |
|---|---|---|
| 后端 API | 8000 | FastAPI,健康检查 /health |
-| 前端 Dev | 5173 | Vite,代理 /api → 8000 |
+| 前端 Dev v1.0 | 5173 | Vite,代理 /api → 8000 |
+| 前端 Dev v2.0 | 5174 | Vite 2.0,代理 /api → 8000 |
## 测试命令
diff --git a/web/src/components/common/ChatPanel.tsx b/web/src/components/common/ChatPanel.tsx
index 12f83e9..fba095a 100644
--- a/web/src/components/common/ChatPanel.tsx
+++ b/web/src/components/common/ChatPanel.tsx
@@ -717,8 +717,10 @@ export function ChatPanel({
>
{msg.risk ? (
@@ -1067,7 +1069,7 @@ export function ChatPanel({
{/* 专家咨询弹窗 */}
- {/* 移动端抽屉 */}
- {open && (
+ {/* 移动端抽屉 - 用 Portal 渲染到 body,避免 header backdrop-blur 影响 */}
+ {open && createPortal(
setOpen(false)} />
-
+
左右(BossAi)
setOpen(false)}>{nav}
-
+
,
+ document.body,
)}
)
diff --git a/web/src/components/layout/Sidebar.tsx b/web/src/components/layout/Sidebar.tsx
index c96d06b..ef96872 100644
--- a/web/src/components/layout/Sidebar.tsx
+++ b/web/src/components/layout/Sidebar.tsx
@@ -78,7 +78,7 @@ export function Sidebar() {
}
return (
-
{s.status === 'active' ? '服务中' : s.status === 'pending' ? '待确认' : '已结束'}
@@ -258,7 +259,7 @@ export function Experts() {
)}
- {c.created_at}
+ {formatTime(c.created_at)}
{c.status === 'replied' ? '已回复' : c.status === 'pending' ? '待回复' : '已关闭'}
diff --git a/web/src/pages/MyProfile.tsx b/web/src/pages/MyProfile.tsx
index 3ce1e95..4438898 100644
--- a/web/src/pages/MyProfile.tsx
+++ b/web/src/pages/MyProfile.tsx
@@ -8,7 +8,7 @@ import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api'
-import { cn } from '@/lib/utils'
+import { cn, formatTime } from '@/lib/utils'
/** 用户画像数据 */
interface ProfileData {
@@ -281,15 +281,6 @@ const SOURCE_LABELS: Record = {
manual: '用户确认',
}
-/** 格式化时间 */
-function formatTime(dt: string): string {
- if (!dt) return '-'
- return new Date(dt).toLocaleString('zh-CN', {
- year: 'numeric', month: '2-digit', day: '2-digit',
- hour: '2-digit', minute: '2-digit',
- })
-}
-
/** 格式化字段值 */
function formatValue(key: string, value: string): string {
if (key === 'company_size') return SIZE_LABELS[value] || value
diff --git a/web/src/pages/admin/AdminUsers.tsx b/web/src/pages/admin/AdminUsers.tsx
index 2806dca..9972def 100644
--- a/web/src/pages/admin/AdminUsers.tsx
+++ b/web/src/pages/admin/AdminUsers.tsx
@@ -36,10 +36,11 @@ const SCENE_LABELS: Record = {
emotion: '情绪中心',
}
-/** 格式化时间 */
+/** 格式化时间(北京时间) */
function formatTime(dt: string | null): string {
if (!dt) return '从未使用'
return new Date(dt).toLocaleString('zh-CN', {
+ timeZone: 'Asia/Shanghai',
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
diff --git a/web/src/pages/workbench/WorkbenchPending.tsx b/web/src/pages/workbench/WorkbenchPending.tsx
index 6babcf2..75c6faf 100644
--- a/web/src/pages/workbench/WorkbenchPending.tsx
+++ b/web/src/pages/workbench/WorkbenchPending.tsx
@@ -5,6 +5,7 @@ import { Markdown } from '@/components/common/Markdown'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
+import { formatTime } from '@/lib/utils'
import type { Consultation } from '@/types'
import { useWorkbenchData, WorkbenchShell, ReplyDialog, maskPhone } from './shared'
@@ -42,7 +43,7 @@ export function WorkbenchPending() {
)}
- 创建时间:{c.created_at}
+ 创建时间:{formatTime(c.created_at)}
}
+ />
+ )}
+
+
+ {/* 风险推送(AI 主动提醒) */}
+ {alerts.length > 0 && (
+
+
+
+
+ {alerts.length} 条风险提醒
+
+
+ {alerts.map((a) => (
+
+
+
+
+ {a.title}
+
+
{a.content}
+
+
+ handleReadAlert(a.id)}>稍后
+ navigate('/knowledge')}>查看
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 高风险提醒 */}
+ {data.high_risks.length > 0 && (
+
+
+
+
+
{data.high_risks.length} 条高风险提醒
+
+
+ {data.high_risks.map((r) => (
+
+
+
+ {r.title}
+ {RISK_LABELS[r.level]}
+
+
{SCENE_LABELS[r.scene]}
+
+
navigate(`/matters`)}>
+ 立即处理
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 待确认事项 */}
+ {data.pending_confirm.length > 0 && (
+
+
+
+
+ {data.pending_confirm.length} 个事项待你确认
+
+
+ {data.pending_confirm.map((p) => (
+
+
+
{p.title}
+
+ {SCENE_LABELS[p.scene]} · {formatRelative(p.updated_at)}
+
+
+
navigate('/matters')}>
+ 去确认
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 专家回复 */}
+ {data.expert_replies.length > 0 && (
+
+
+
+
+ {data.expert_replies.length} 条专家回复
+
+
+ {data.expert_replies.map((r) => (
+
+
+
{r.expert_name} · {r.expert_title}
+
{r.snippet}
+
{formatRelative(r.replied_at)}
+
+
navigate('/team')}>
+ 查看回复
+
+
+ ))}
+
+
+
+ )}
+
+ {/* 经营健康度 */}
+
+
+
+
+ {[
+ { label: '法务', value: data.health_score.legal },
+ { label: '经营', value: data.health_score.operation },
+ { label: '合规', value: data.health_score.compliance },
+ ].map((s) => (
+
+
+ {s.label}
+ {s.value}%
+
+
+
= 70 ? 'bg-emerald-500' : s.value >= 40 ? 'bg-amber-500' : 'bg-red-500'}`}
+ style={{ width: `${s.value}%` }}
+ />
+
+
+ ))}
+
+
+
+
+
+ )
+}
diff --git a/web2/src/pages/Decisions.tsx b/web2/src/pages/Decisions.tsx
new file mode 100644
index 0000000..876455d
--- /dev/null
+++ b/web2/src/pages/Decisions.tsx
@@ -0,0 +1,124 @@
+/** 经营决策页:话题选择 + 换一批 + ChatPanel
+ * scene='decision', 默认普通模式, 不显示风险卡
+ */
+import { useState, useCallback } from 'react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { ChatPanel } from '@/components/common/ChatPanel'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { TrendingUp, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { api } from '@/lib/api'
+
+/** 话题类型 */
+interface Topic {
+ title: string
+ desc: string
+ samples: string[]
+}
+
+/** 经营决策助手:初始兜底话题,换一批时 AI 动态生成 */
+const FALLBACK_TOPICS: Topic[] = [
+ { title: '扩张收缩', desc: '开店、关店、业务线', samples: ['现在适合扩张开第二家店吗?', '要不要关闭亏损的业务线?', '新业务线该自营还是加盟?', '应该先扩张还是先巩固?'] },
+ { title: '项目承接', desc: '大单、长期合同', samples: ['是否应该接付款周期90天的大单?', '长期框架合同要不要签?', '预付款比例低的项目能接吗?', '客户要求独家合作利弊?'] },
+ { title: '定价策略', desc: '涨价、降价、促销', samples: ['该不该降价15%抢竞品客户?', '产品涨价怎么跟客户沟通?', '促销活动会不会损害品牌?', '成本上涨如何调整定价?'] },
+ { title: '招人裁员', desc: '招聘、解雇、绩效', samples: ['现在该招人还是裁人?', '裁掉绩效差的员工怎么操作?', '关键岗位招人还是培养?', '外包和正式员工怎么选?'] },
+ { title: '融资合伙', desc: '融资、合伙、股权', samples: ['现在适合融资吗?', '合伙人要退股怎么谈条件?', '出让多少股权合适?', '银行贷款还是股权融资?'] },
+ { title: '转型外包', desc: '转型、外包、调整', samples: ['业务转型方向怎么选?', '哪些业务可以外包?', '转型期间现金流怎么管理?', '外包质量怎么控?'] },
+]
+
+export function Decisions() {
+ const [topics, setTopics] = useState(FALLBACK_TOPICS)
+ const [selectedTopic, setSelectedTopic] = useState(0)
+ const [topicsCollapsed, setTopicsCollapsed] = useState(false)
+ const [loadingTopics, setLoadingTopics] = useState(false)
+
+ /** 换一批:调用 AI 动态生成话题(避免重复) */
+ const refreshTopics = useCallback(async () => {
+ setLoadingTopics(true)
+ try {
+ const res = await api.getTopics('decision', topics.map((t) => t.title))
+ if (res.topics?.length > 0) {
+ setTopics(res.topics)
+ setSelectedTopic(0)
+ }
+ } catch {
+ // AI 不可用时保持当前话题
+ } finally {
+ setLoadingTopics(false)
+ }
+ }, [topics])
+
+ const currentTopic = topics[selectedTopic]
+
+ return (
+
+
+
+
+ setTopicsCollapsed(!topicsCollapsed)}
+ className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
+ >
+
+ 决策话题
+
+
+ {loadingTopics ? : }
+ 换一批
+
+
+
+ {!topicsCollapsed && (
+
+ {loadingTopics && (
+
+
+ AI 正在生成新话题…
+
+ )}
+
+ {topics.map((t, i) => (
+
setSelectedTopic(i)}
+ className={cn(
+ 'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
+ selectedTopic === i
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
+ : 'border-input hover:bg-accent',
+ )}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ {currentTopic && (
+
+ )}
+
+ )
+}
diff --git a/web2/src/pages/DecisionsLog.tsx b/web2/src/pages/DecisionsLog.tsx
new file mode 100644
index 0000000..1543d0c
--- /dev/null
+++ b/web2/src/pages/DecisionsLog.tsx
@@ -0,0 +1,209 @@
+/** 决策日志(TASK-025)
+ * 闭环案卡按月分组展示,支持标记执行结果
+ */
+import { useEffect, useState, useCallback } from 'react'
+import { FileText, CheckCircle, XCircle, AlertCircle } from 'lucide-react'
+import { toast } from 'sonner'
+import { PageLayout } from '@/components/common/PageLayout'
+import { Card, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import { LoadingState } from '@/components/common/LoadingState'
+import { ErrorState } from '@/components/common/ErrorState'
+import { EmptyState } from '@/components/common/EmptyState'
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
+} from '@/components/ui/dialog'
+import { Textarea } from '@/components/ui/textarea'
+import { api } from '@/lib/api'
+import { formatDate, SCENE_LABELS, RISK_LABELS } from '@/lib/utils'
+import type { Matter } from '@/types'
+
+interface DecisionsLogResponse {
+ results: Matter[]
+ by_month: Record
+ stats: { total: number; executed: number; execution_rate: number }
+}
+
+const EXECUTION_ICONS: Record = {
+ '顺利': CheckCircle,
+ '受阻': AlertCircle,
+ '未执行': XCircle,
+}
+
+const EXECUTION_COLORS: Record = {
+ '顺利': 'text-emerald-600',
+ '受阻': 'text-amber-600',
+ '未执行': 'text-red-600',
+}
+
+export function DecisionsLog() {
+ const [data, setData] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [dialogOpen, setDialogOpen] = useState(false)
+ const [selectedMatter, setSelectedMatter] = useState(null)
+ const [execResult, setExecResult] = useState('')
+ const [execNote, setExecNote] = useState('')
+ const [saving, setSaving] = useState(false)
+
+ const load = useCallback(() => {
+ setLoading(true); setError('')
+ api.getDecisionsLog()
+ .then((d) => { setData(d); setLoading(false) })
+ .catch((e) => { setError(e.message); setLoading(false) })
+ }, [])
+
+ useEffect(load, [load])
+
+ const handleMarkResult = (matter: Matter) => {
+ setSelectedMatter(matter)
+ setExecResult(matter.execution_result || '')
+ setExecNote(matter.execution_note || '')
+ setDialogOpen(true)
+ }
+
+ const handleSaveResult = async () => {
+ if (!selectedMatter) return
+ setSaving(true)
+ try {
+ await api.updateExecutionResult(selectedMatter.id, execResult, execNote)
+ toast.success('执行结果已更新')
+ setDialogOpen(false)
+ load()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '保存失败')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (loading) return
+ if (error) return
+
+ const months = data ? Object.keys(data.by_month).sort().reverse() : []
+
+ return (
+
+ {!data || data.stats.total === 0 ? (
+
+ ) : (
+
+ {/* 月度统计条 */}
+
+
+
+
{data.stats.total}
+
总决策
+
+
+
{data.stats.executed}
+
顺利执行
+
+
+
{data.stats.execution_rate}%
+
执行率
+
+
+
+
+ {/* 按月分组 */}
+ {months.map((month) => (
+
+
{month}
+
+ {data.by_month[month].map((matter) => {
+ const ExecIcon = matter.execution_result ? EXECUTION_ICONS[matter.execution_result] : null
+ return (
+
+
+
+
+ {matter.title}
+ {SCENE_LABELS[matter.scene]}
+ {matter.risk_level && (
+
+ {RISK_LABELS[matter.risk_level]}
+
+ )}
+
+ {matter.summary && (
+
{matter.summary}
+ )}
+
+ {formatDate(matter.closed_at)}
+ {ExecIcon && matter.execution_result && (
+ <>
+ ·
+
+
+ {matter.execution_result}
+
+ >
+ )}
+
+
+ handleMarkResult(matter)}>
+ 标记结果
+
+
+
+ )
+ })}
+
+
+ ))}
+
+ )}
+
+ {/* 标记执行结果弹窗 */}
+
+
+ )
+}
diff --git a/web2/src/pages/EmotionCenter.tsx b/web2/src/pages/EmotionCenter.tsx
new file mode 100644
index 0000000..9c98c89
--- /dev/null
+++ b/web2/src/pages/EmotionCenter.tsx
@@ -0,0 +1,122 @@
+/** 情绪中心页:三模式选择(冷静/陪伴/推动) + ChatPanel
+ * scene='emotion', 默认阅后即焚, 三模式选择
+ */
+import { useState } from 'react'
+import { Brain, HeartHandshake, Zap, Phone, ChevronDown } from 'lucide-react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { ChatPanel } from '@/components/common/ChatPanel'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { cn } from '@/lib/utils'
+import type { EmotionMode } from '@/types'
+
+const MODES: { value: EmotionMode; label: string; desc: string; icon: typeof Brain; samples: string[] }[] = [
+ {
+ value: 'calm',
+ label: '冷静分析',
+ desc: '事实、风险、方案优先,克制客观',
+ icon: Brain,
+ samples: [
+ '合伙人背叛了,我很愤怒,该怎么处理?',
+ '明天的谈判我拖延了一周没准备',
+ '员工集体离职,我该怎么应对?',
+ '竞品抄袭我们,理性分析该怎么反击',
+ ],
+ },
+ {
+ value: 'companion',
+ label: '陪伴疏导',
+ desc: '先承接情绪,再给出下一步,温和不评判',
+ icon: HeartHandshake,
+ samples: [
+ '最近压力很大,睡不着觉',
+ '感觉撑不下去了',
+ '家人不理解我创业的辛苦',
+ '今天被客户骂了,很难受',
+ ],
+ },
+ {
+ value: 'push',
+ label: '强力推动',
+ desc: '把拖延拆成今天可完成的小动作,直接有力',
+ icon: Zap,
+ samples: [
+ '一直拖延不想做年底复盘',
+ '知道该运动但就是不动',
+ '积压了一堆邮件不想处理',
+ '该打的催款电话一直拖着不打',
+ ],
+ },
+]
+
+/** 情绪价值引擎:三模式切换 + 危机干预入口 */
+export function EmotionCenter() {
+ const [emotionMode, setEmotionMode] = useState('calm')
+ const [modesCollapsed, setModesCollapsed] = useState(false)
+
+ return (
+
+ {/* 危机干预提示 */}
+
+
+
+
+
危机干预
+
+ 如果你正在经历强烈痛苦或有自伤念头,请立即拨打心理援助热线 400-161-9995 或前往最近医院急诊。
+
+
+
+
+
+ {/* 情绪模式选择器 */}
+
+
+ setModesCollapsed(!modesCollapsed)}
+ className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
+ >
+
+ 回复模式
+
+
+ {!modesCollapsed && (
+
+
+ {MODES.map((m) => (
+ setEmotionMode(m.value)}
+ className={cn(
+ 'flex flex-col items-start gap-1 rounded-md border p-4 text-left transition-colors',
+ emotionMode === m.value
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
+ : 'border-input hover:bg-accent',
+ )}
+ >
+
+ {m.label}
+ {m.desc}
+
+ ))}
+
+
+ )}
+
+
+ m.value === emotionMode)?.samples || []}
+ topic={MODES.find((m) => m.value === emotionMode)?.label}
+ defaultMode="ephemeral"
+ emotionMode={emotionMode}
+ placeholder="说说你现在的感受…"
+ showRiskCard={false}
+ enableUpload={false}
+ footer="情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。"
+ />
+
+ )
+}
diff --git a/web2/src/pages/KnowledgeBase.tsx b/web2/src/pages/KnowledgeBase.tsx
new file mode 100644
index 0000000..77afea1
--- /dev/null
+++ b/web2/src/pages/KnowledgeBase.tsx
@@ -0,0 +1,404 @@
+/** 知识库管理页:7 类分类,录入、检索、删除,要素表模板预览 */
+import { useEffect, useState } from 'react'
+import { toast } from 'sonner'
+import {
+ BookOpen,
+ Search,
+ Plus,
+ Trash2,
+ Loader2,
+ FileText,
+ Scale,
+ FileEdit,
+ MapPin,
+ Calendar,
+ TrendingUp,
+ Factory,
+ Heart,
+ Coffee,
+} from 'lucide-react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { EmptyState } from '@/components/common/EmptyState'
+import { LoadingState } from '@/components/common/LoadingState'
+import { ErrorState } from '@/components/common/ErrorState'
+import { Card, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { Textarea } from '@/components/ui/textarea'
+import { Label } from '@/components/ui/label'
+import { Badge } from '@/components/ui/badge'
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from '@/components/ui/select'
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+ DialogTrigger,
+} from '@/components/ui/dialog'
+import { api } from '@/lib/api'
+import { cn } from '@/lib/utils'
+import type { KnowledgeDoc } from '@/types'
+
+/** 知识库文档类型配置 */
+const DOC_TYPES = [
+ { value: 'law', label: '法规', icon: Scale },
+ { value: 'case', label: '案例', icon: FileText },
+ { value: 'template', label: '模板', icon: FileEdit },
+ { value: 'guide', label: '经营指南', icon: TrendingUp },
+ { value: 'industry', label: '行业知识', icon: Factory },
+ { value: 'emotion', label: '情绪疏导', icon: Heart },
+ { value: 'life', label: '生活常识', icon: Coffee },
+ { value: 'element_template', label: '要素表模板', icon: FileText },
+]
+
+/** 来源英文 → 中文映射 */
+const SOURCE_LABELS: Record = {
+ seed: '系统内置',
+ manual: '手动录入',
+ import: '导入',
+ api: 'API 录入',
+}
+
+/** 法规知识库管理:7 类分类,录入、检索、删除 */
+export function KnowledgeBase() {
+ const [docs, setDocs] = useState([])
+ const [counts, setCounts] = useState>({})
+ const [totalCount, setTotalCount] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [searchQuery, setSearchQuery] = useState('')
+ const [searchResults, setSearchResults] = useState(null)
+ const [searching, setSearching] = useState(false)
+ const [filterType, setFilterType] = useState('all')
+ const [addOpen, setAddOpen] = useState(false)
+ const [deleteTarget, setDeleteTarget] = useState(null)
+
+ // 新增表单
+ const [form, setForm] = useState({
+ title: '',
+ content: '',
+ doc_type: 'law',
+ region: '',
+ effective_date: '',
+ keywords: '',
+ })
+ const [saving, setSaving] = useState(false)
+
+ const loadDocs = async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await api.listKnowledge(filterType !== 'all' ? filterType : undefined)
+ setDocs(res.results)
+ setCounts(res.counts || {})
+ setTotalCount(res.total || 0)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : '加载失败')
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ useEffect(() => {
+ loadDocs()
+ }, [filterType])
+
+ const handleSearch = async () => {
+ if (!searchQuery.trim()) {
+ setSearchResults(null)
+ return
+ }
+ setSearching(true)
+ try {
+ const res = await api.searchKnowledge(searchQuery.trim())
+ setSearchResults(res.results)
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '检索失败')
+ } finally {
+ setSearching(false)
+ }
+ }
+
+ const handleAdd = async () => {
+ if (!form.title.trim() || !form.content.trim()) {
+ toast.error('标题和内容不能为空')
+ return
+ }
+ setSaving(true)
+ try {
+ await api.addKnowledge({
+ title: form.title.trim(),
+ content: form.content.trim(),
+ doc_type: form.doc_type,
+ region: form.region.trim() || null,
+ effective_date: form.effective_date.trim() || null,
+ keywords: form.keywords.trim() || null,
+ })
+ toast.success('文档已添加')
+ setAddOpen(false)
+ setForm({ title: '', content: '', doc_type: 'law', region: '', effective_date: '', keywords: '' })
+ loadDocs()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '添加失败')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ const handleDelete = async () => {
+ if (!deleteTarget?.id) return
+ try {
+ await api.deleteKnowledge(deleteTarget.id)
+ toast.success('文档已删除')
+ setDeleteTarget(null)
+ loadDocs()
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '删除失败')
+ }
+ }
+
+ const displayDocs = searchResults ?? docs
+
+ return (
+
+
+
+ 新增文档
+
+
+
+
+ 新增知识文档
+ 录入后将进入 RAG 检索库,提问时按场景自动引用相关知识。
+
+
+
+
+ setForm({ ...form, title: e.target.value })} placeholder="如:劳动合同法-试用期解除" />
+
+
+
+
+
+
+
+
+
+
+
+ setForm({ ...form, region: e.target.value })} placeholder="如:全国 / 上海" />
+
+
+
+
+
+ setAddOpen(false)}>取消
+
+ {saving && } 保存
+
+
+
+
+ }
+ >
+ {/* 检索栏 */}
+
+
+
+
+
+ setSearchQuery(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSearch()}
+ placeholder="检索法规、案例、经营指南、行业知识…"
+ className="pl-9"
+ />
+
+
+ {searching ? : }
+ 检索
+
+ {searchResults && (
+
{ setSearchResults(null); setSearchQuery('') }}>
+ 清除
+
+ )}
+
+ {/* 类型筛选 */}
+ {!searchResults && (
+
+ setFilterType('all')}
+ >
+ 全部 {totalCount}
+
+ {DOC_TYPES.map((t) => (
+ setFilterType(t.value)}
+ >
+ {t.label}
+ {counts[t.value] || 0}
+
+ ))}
+
+ )}
+
+
+
+ {/* 文档列表 */}
+ {loading ? (
+
+ ) : error ? (
+
+ ) : displayDocs.length === 0 ? (
+
+ ) : (
+
+ {searchResults && (
+
找到 {searchResults.length} 条匹配结果
+ )}
+ {displayDocs.map((doc) => {
+ const typeInfo = DOC_TYPES.find((t) => t.value === doc.doc_type)
+ return (
+
+
+
+
+
+ {typeInfo && }
+
{doc.title}
+ {doc.doc_type && (
+ {typeInfo?.label || doc.doc_type}
+ )}
+ {searchResults && doc.score && (
+ 相关度 {doc.score}
+ )}
+
+ {doc.doc_type !== 'element_template' && (
+
{doc.content}
+ )}
+ {doc.doc_type === 'element_template' && (() => {
+ try {
+ const tmpl = JSON.parse(doc.content)
+ return (
+
+
案由:{tmpl.cause_of_action}
+
适用:{tmpl.applicable}
+
要素清单({tmpl.elements?.length || 0}项)
+
+ {tmpl.elements?.map((e: Record
, i: number) => (
+
+ {e.label}
+ {e.type}{e.required === '是' ? ' ·必填' : ''}
+
+ ))}
+
+ {tmpl.claims?.length > 0 && (
+ <>
+
诉讼请求
+
+ {tmpl.claims.map((c: string, i: number) => (
+ {c}
+ ))}
+
+ >
+ )}
+
+ )
+ } catch { return null }
+ })()}
+
+
+ 来源:{SOURCE_LABELS[doc.source] || doc.source}
+
+ {doc.region && (
+
+ {doc.region}
+
+ )}
+ {doc.effective_date && (
+
+ {doc.effective_date}
+
+ )}
+ {doc.keywords && (
+
+ {doc.keywords}
+
+ )}
+
+
+ {!searchResults && doc.id && (
+
setDeleteTarget(doc)}
+ aria-label="删除"
+ >
+
+
+ )}
+
+
+
+ )
+ })}
+
+ )}
+
+ {/* 删除确认 */}
+
+
+ )
+}
diff --git a/web2/src/pages/LegalAdvisor.tsx b/web2/src/pages/LegalAdvisor.tsx
new file mode 100644
index 0000000..01669a7
--- /dev/null
+++ b/web2/src/pages/LegalAdvisor.tsx
@@ -0,0 +1,125 @@
+/** 法务参谋页:话题选择 + 换一批 + ChatPanel
+ * scene='legal', 默认保密模式, 显示风险卡, 启用上传
+ */
+import { useState, useCallback } from 'react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { ChatPanel } from '@/components/common/ChatPanel'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { Briefcase, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { api } from '@/lib/api'
+
+/** 话题类型 */
+interface Topic {
+ title: string
+ desc: string
+ samples: string[]
+}
+
+/** 法务参谋:初始兜底话题,换一批时 AI 动态生成 */
+// 初始兜底话题(同步显示,无 loading)
+const FALLBACK_TOPICS: Topic[] = [
+ { title: '劳动用工', desc: '工伤、辞退、欠薪、社保', samples: ['员工工伤后能开除吗?', '员工被辞退要赔多少?', '员工不签合同有何风险?', '社保未缴全被查怎么办?'] },
+ { title: '合同审查', desc: '违约、条款、效力', samples: ['合同违约金怎么约定?', '对方不履行合同怎么办?', '口头协议有法律效力吗?', '合同条款不公平能改吗?'] },
+ { title: '债权债务', desc: '欠款、催收、诉讼', samples: ['客户拖欠货款3个月如何催收?', '借条怎么写才有效?', '债务人不还钱能报警吗?', '超过诉讼时效怎么办?'] },
+ { title: '股东纠纷', desc: '股权、分红、退股', samples: ['合伙人要退股怎么谈?', '股东不出资怎么办?', '分红比例怎么定?', '小股东权益被侵害怎么办?'] },
+ { title: '税务风险', desc: '稽查、发票、合规', samples: ['收到税务稽查通知怎么办?', '发票开错了怎么处理?', '个体户怎么交税最合理?', '税务罚款能减免吗?'] },
+ { title: '诉讼仲裁', desc: '起诉、举证、执行', samples: ['打官司要准备哪些证据?', '仲裁和诉讼哪个更快?', '赢了官司对方不执行怎么办?', '诉讼费用怎么算?'] },
+]
+
+export function LegalAdvisor() {
+ const [topics, setTopics] = useState(FALLBACK_TOPICS)
+ const [selectedTopic, setSelectedTopic] = useState(0)
+ const [topicsCollapsed, setTopicsCollapsed] = useState(false)
+ const [loadingTopics, setLoadingTopics] = useState(false)
+
+ /** 换一批:调用 AI 动态生成话题(避免重复) */
+ const refreshTopics = useCallback(async () => {
+ setLoadingTopics(true)
+ try {
+ const res = await api.getTopics('legal', topics.map((t) => t.title))
+ if (res.topics?.length > 0) {
+ setTopics(res.topics)
+ setSelectedTopic(0)
+ }
+ } catch {
+ // AI 不可用时保持当前话题
+ } finally {
+ setLoadingTopics(false)
+ }
+ }, [topics])
+
+ const currentTopic = topics[selectedTopic]
+
+ return (
+
+
+
+
+ setTopicsCollapsed(!topicsCollapsed)}
+ className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
+ >
+
+ 法务话题
+
+
+ {loadingTopics ? : }
+ 换一批
+
+
+
+ {!topicsCollapsed && (
+
+ {loadingTopics && (
+
+
+ AI 正在生成新话题…
+
+ )}
+
+ {topics.map((t, i) => (
+
setSelectedTopic(i)}
+ className={cn(
+ 'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
+ selectedTopic === i
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
+ : 'border-input hover:bg-accent',
+ )}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ {currentTopic && (
+
+ )}
+
+ )
+}
diff --git a/web2/src/pages/LifeAssistant.tsx b/web2/src/pages/LifeAssistant.tsx
new file mode 100644
index 0000000..5cf105e
--- /dev/null
+++ b/web2/src/pages/LifeAssistant.tsx
@@ -0,0 +1,124 @@
+/** 生活助理页:话题选择 + 换一批 + ChatPanel
+ * scene='life', 默认保密模式
+ */
+import { useState, useCallback } from 'react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { ChatPanel } from '@/components/common/ChatPanel'
+import { Card, CardContent, CardHeader } from '@/components/ui/card'
+import { Heart, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
+import { cn } from '@/lib/utils'
+import { api } from '@/lib/api'
+
+/** 话题类型 */
+interface Topic {
+ title: string
+ desc: string
+ samples: string[]
+}
+
+/** 生活助理:初始兜底话题,换一批时 AI 动态生成 */
+const FALLBACK_TOPICS: Topic[] = [
+ { title: '家庭沟通', desc: '亲子、夫妻、关系', samples: ['怎么和青春期的孩子沟通?', '夫妻因工作吵架怎么办?', '家人不支持创业怎么说服?', '工作太忙忽略家人怎么办?'] },
+ { title: '子女教育', desc: '升学、兴趣、规划', samples: ['孩子不想上学怎么办?', '兴趣班该报几个?', '要不要送孩子出国?', '孩子成绩下滑怎么沟通?'] },
+ { title: '父母养老', desc: '养老、医疗、陪伴', samples: ['父母年纪大了养老方案怎么选?', '异地父母怎么照顾?', '养老院还是居家养老?', '父母医疗费用怎么分担?'] },
+ { title: '睡眠与压力', desc: '失眠、焦虑、调节', samples: ['最近睡眠很差怎么改善?', '工作压力大怎么缓解?', '焦虑到影响工作怎么办?', '怎么判断需要看心理医生?'] },
+ { title: '出差日程', desc: '出行、时间管理', samples: ['出差频繁怎么兼顾家庭?', '行程太满怎么优化?', '出差期间公司怎么管?', '如何减少无效出差?'] },
+ { title: '健康习惯', desc: '体检、饮食、运动', samples: ['体检报告几项异常怎么办?', '久坐怎么改善健康?', '应酬多怎么控制饮食?', '没时间运动怎么办?'] },
+]
+
+export function LifeAssistant() {
+ const [topics, setTopics] = useState(FALLBACK_TOPICS)
+ const [selectedTopic, setSelectedTopic] = useState(0)
+ const [topicsCollapsed, setTopicsCollapsed] = useState(false)
+ const [loadingTopics, setLoadingTopics] = useState(false)
+
+ /** 换一批:调用 AI 动态生成话题(避免重复) */
+ const refreshTopics = useCallback(async () => {
+ setLoadingTopics(true)
+ try {
+ const res = await api.getTopics('life', topics.map((t) => t.title))
+ if (res.topics?.length > 0) {
+ setTopics(res.topics)
+ setSelectedTopic(0)
+ }
+ } catch {
+ // AI 不可用时保持当前话题
+ } finally {
+ setLoadingTopics(false)
+ }
+ }, [topics])
+
+ const currentTopic = topics[selectedTopic]
+
+ return (
+
+
+
+
+ setTopicsCollapsed(!topicsCollapsed)}
+ className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
+ >
+
+ 生活话题
+
+
+ {loadingTopics ? : }
+ 换一批
+
+
+
+ {!topicsCollapsed && (
+
+ {loadingTopics && (
+
+
+ AI 正在生成新话题…
+
+ )}
+
+ {topics.map((t, i) => (
+
setSelectedTopic(i)}
+ className={cn(
+ 'flex items-start gap-3 rounded-md border p-3 text-left transition-colors',
+ selectedTopic === i
+ ? 'border-primary bg-primary/5 ring-1 ring-primary'
+ : 'border-input hover:bg-accent',
+ )}
+ >
+
+
+
+ ))}
+
+
+ )}
+
+
+ {currentTopic && (
+
+ )}
+
+ )
+}
diff --git a/web2/src/pages/Login.tsx b/web2/src/pages/Login.tsx
new file mode 100644
index 0000000..736d0b6
--- /dev/null
+++ b/web2/src/pages/Login.tsx
@@ -0,0 +1,208 @@
+/** 登录/注册页:手机号+密码,支持用户/专家/管理员三种角色(独立全屏,不用 PageLayout) */
+import { useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { api } from '@/lib/api'
+import { setAuth, type Role } from '@/lib/auth'
+import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
+import { Input } from '@/components/ui/input'
+import { Button } from '@/components/ui/button'
+import { Label } from '@/components/ui/label'
+import { Toaster } from '@/components/ui/sonner'
+import { toast } from 'sonner'
+import { Loader2, ShieldCheck, Briefcase, Shield, User, UserCog } from 'lucide-react'
+import { cn } from '@/lib/utils'
+
+/** 角色配置(含示例账号,方便演示和测试) */
+const ROLES: Array<{
+ value: Role
+ label: string
+ icon: typeof User
+ desc: string
+ home: string
+ testAccount: { phone: string; password: string; name: string }
+}> = [
+ {
+ value: 'user', label: '用户', icon: User, desc: '老板专属经营助手', home: '/',
+ testAccount: { phone: '13800138000', password: '123456', name: '测试老板' },
+ },
+ {
+ value: 'expert', label: '专家', icon: Briefcase, desc: '专家工作台,接单回复', home: '/workbench',
+ testAccount: { phone: '13910000001', password: 'expert123', name: '李华律师' },
+ },
+ {
+ value: 'admin', label: '管理员', icon: Shield, desc: '平台管理后台', home: '/admin',
+ testAccount: { phone: '13800000000', password: 'admin123', name: '平台管理员' },
+ },
+]
+
+/** 登录/注册页:手机号+密码,角色Tab(user/expert/admin) */
+export function Login() {
+ const navigate = useNavigate()
+ const [role, setRole] = useState('user')
+ const [phone, setPhone] = useState('')
+ const [password, setPassword] = useState('')
+ const [name, setName] = useState('')
+ const [mode, setMode] = useState<'login' | 'register'>('login')
+ const [loading, setLoading] = useState(false)
+
+ const currentRole = ROLES.find((r) => r.value === role)!
+
+ /** 一键填充示例账号 */
+ const fillTestAccount = () => {
+ if (currentRole.testAccount) {
+ setPhone(currentRole.testAccount.phone)
+ setPassword(currentRole.testAccount.password)
+ setName(currentRole.testAccount.name)
+ setMode('login')
+ }
+ }
+
+ /** 切换角色时清空表单 */
+ const switchRole = (r: Role) => {
+ setRole(r)
+ setPhone('')
+ setPassword('')
+ setName('')
+ setMode('login')
+ }
+
+ const handleSubmit = async () => {
+ if (!phone.trim()) { toast.error('请输入手机号'); return }
+ if (!password.trim()) { toast.error('请输入密码'); return }
+ if (!/^1[3-9]\d{9}$/.test(phone)) { toast.error('手机号格式不正确,应为 11 位'); return }
+ if (password.length < 6) { toast.error('密码至少 6 位'); return }
+ setLoading(true)
+ try {
+ if (role !== 'user' && mode === 'register') {
+ toast.error('专家和管理员账号由平台统一创建,无需注册')
+ setMode('login')
+ return
+ }
+ const result = mode === 'login'
+ ? await api.login(phone, password)
+ : await api.register(phone, password, name)
+ const resultRole: Role = mode === 'login'
+ ? ((result as { role?: string }).role as Role) || 'user'
+ : 'user'
+ setAuth(result.token, result.phone, result.name, resultRole)
+ toast.success(mode === 'login' ? '登录成功' : '注册成功')
+ const targetRole = ROLES.find((r) => r.value === resultRole) || currentRole
+ navigate(targetRole.home)
+ } catch (e) {
+ const msg = e instanceof Error ? e.message : '操作失败'
+ toast.error(msg, { duration: 4000 })
+ } finally {
+ setLoading(false)
+ }
+ }
+
+ return (
+
+
+
+
+
+
+
+ {mode === 'login' ? '登录 左右(BossAi)' : '注册 左右(BossAi)'}
+
+ {currentRole.desc}
+
+
+ {/* 角色 Tab */}
+
+ {ROLES.map((r) => (
+ switchRole(r.value)}
+ >
+
+ {r.label}
+
+ ))}
+
+
+
+
+ setPhone(e.target.value)}
+ maxLength={11}
+ />
+
+
+
+ setPassword(e.target.value)}
+ onKeyDown={(e) => e.key === 'Enter' && handleSubmit()}
+ />
+
+ {mode === 'register' && role === 'user' && (
+
+
+ setName(e.target.value)}
+ />
+
+ )}
+
+ {loading && }
+ {mode === 'login' ? '登录' : '注册'}
+
+
+ {/* 注册入口仅用户显示 */}
+ {role === 'user' && (
+
+ {mode === 'login' ? (
+ setMode('register')}
+ >
+ 没有账号?去注册
+
+ ) : (
+ setMode('login')}
+ >
+ 已有账号?去登录
+
+ )}
+
+ )}
+
+ {/* 示例账号快捷填充 */}
+ {currentRole.testAccount && (
+
+
+ {currentRole.label}示例账号 {currentRole.testAccount.phone}
+
+
+
+ 一键填充
+
+
+ )}
+
+
+ {/* Login 页在 AppLayout 之外,需自带 Toaster 才能显示 toast */}
+
+
+ )
+}
diff --git a/web2/src/pages/Matters.tsx b/web2/src/pages/Matters.tsx
new file mode 100644
index 0000000..1a2aaef
--- /dev/null
+++ b/web2/src/pages/Matters.tsx
@@ -0,0 +1,228 @@
+/** 事项看板(TASK-008)
+ * 案卡按状态分列展示,点击查看详情(弹窗),支持断点续接
+ * 移动端横向滚动看板列
+ */
+import { useEffect, useState, useCallback } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { ClipboardList, Plus, Scale, TrendingUp, Heart, ShieldCheck, ArrowRight, Loader2 } from 'lucide-react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { Button } from '@/components/ui/button'
+import { Badge } from '@/components/ui/badge'
+import {
+ Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
+} from '@/components/ui/dialog'
+import { LoadingState } from '@/components/common/LoadingState'
+import { ErrorState } from '@/components/common/ErrorState'
+import { EmptyState } from '@/components/common/EmptyState'
+import { MatterCard } from '@/components/common/MatterCard'
+import { api } from '@/lib/api'
+import { cn, MATTER_STATUS_LABELS, MATTER_STATUS_COLORS, SCENE_LABELS, RISK_LABELS, formatRelative, formatDate } from '@/lib/utils'
+import type { Matter, MatterBoard, MatterStatus, Scene } from '@/types'
+
+/** 看板列定义(顺序即展示顺序) */
+const COLUMNS: MatterStatus[] = ['new', 'asking_facts', 'pending_confirm', 'analyzing', 'pending_expert', 'closed']
+
+const SCENE_ICONS: Record = {
+ legal: Scale,
+ decision: TrendingUp,
+ life: Heart,
+ emotion: ShieldCheck,
+}
+
+const RISK_COLORS: Record = {
+ red: 'text-red-600 border-red-200 bg-red-50',
+ orange: 'text-orange-600 border-orange-200 bg-orange-50',
+ amber: 'text-amber-600 border-amber-200 bg-amber-50',
+ green: 'text-green-600 border-green-200 bg-green-50',
+}
+
+export function Matters() {
+ const navigate = useNavigate()
+ const [board, setBoard] = useState(null)
+ const [total, setTotal] = useState(0)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+ const [selectedMatter, setSelectedMatter] = useState(null)
+ const [detailLoading, setDetailLoading] = useState(false)
+
+ const load = useCallback(() => {
+ setLoading(true); setError('')
+ api.getMatters()
+ .then((r) => { setBoard(r.board); setTotal(r.total); setLoading(false) })
+ .catch((e) => { setError(e.message); setLoading(false) })
+ }, [])
+
+ useEffect(load, [load])
+
+ /** 点击案卡:加载详情并弹出 */
+ const handleCardClick = (matter: Matter) => {
+ setSelectedMatter(matter)
+ setDetailLoading(true)
+ api.getMatter(matter.id)
+ .then((detail) => { setSelectedMatter(detail); setDetailLoading(false) })
+ .catch(() => { setDetailLoading(false) })
+ }
+
+ /** 继续处理:跳转对应场景(带 session_id + matter_id 断点续接) */
+ const handleContinue = () => {
+ if (!selectedMatter) return
+ const scene = selectedMatter.scene
+ if (selectedMatter.session_id) {
+ navigate(`/${scene}?sid=${selectedMatter.session_id}&mid=${selectedMatter.id}`)
+ } else {
+ navigate(`/${scene}`)
+ }
+ setSelectedMatter(null)
+ }
+
+ if (loading) return
+ if (error) return
+
+ return (
+ navigate('/legal')}>
+ 新建咨询
+
+ }
+ >
+ {total === 0 ? (
+ navigate('/legal')}>开始咨询}
+ />
+ ) : (
+
+ {COLUMNS.map((status) => {
+ const items = board?.[status] || []
+ return (
+
+
+ {MATTER_STATUS_LABELS[status]}
+ {items.length}
+
+
+ {items.length === 0 ? (
+
暂无
+ ) : (
+ items.map((m) => (
+
handleCardClick(m)} />
+ ))
+ )}
+
+
+ )
+ })}
+
+ )}
+
+ {/* 案卡详情弹窗 */}
+
+
+ )
+}
diff --git a/web2/src/pages/Me.tsx b/web2/src/pages/Me.tsx
new file mode 100644
index 0000000..8c89b25
--- /dev/null
+++ b/web2/src/pages/Me.tsx
@@ -0,0 +1,36 @@
+/** 移动端"我"聚合页:头像占位+入口列表 */
+import { NavLink } from 'react-router-dom'
+import { User, History, BookOpen, Settings as SettingsIcon, ChevronRight } from 'lucide-react'
+import { getName } from '@/lib/auth'
+
+export function Me() {
+ const name = getName()
+ const items = [
+ { path: '/profile', label: '我的画像', icon: User },
+ { path: '/history', label: '对话历史', icon: History },
+ { path: '/knowledge', label: '知识库', icon: BookOpen },
+ { path: '/settings', label: '设置', icon: SettingsIcon },
+ ]
+ return (
+
+
+
+ {name?.[0] || '老'}
+
+
+
{name || '老板'}
+
中小企业老板
+
+
+
+ {items.map((item) => (
+
+
+ {item.label}
+
+
+ ))}
+
+
+ )
+}
diff --git a/web2/src/pages/MyProfile.tsx b/web2/src/pages/MyProfile.tsx
new file mode 100644
index 0000000..5c5fee5
--- /dev/null
+++ b/web2/src/pages/MyProfile.tsx
@@ -0,0 +1,535 @@
+import { useEffect, useState, useCallback } from 'react'
+import { UserCircle, History, ChevronRight, ArrowLeft } from 'lucide-react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { EmptyState } from '@/components/common/EmptyState'
+import { LoadingState } from '@/components/common/LoadingState'
+import { ErrorState } from '@/components/common/ErrorState'
+import { Card, CardContent } from '@/components/ui/card'
+import { Badge } from '@/components/ui/badge'
+import { Button } from '@/components/ui/button'
+import { api } from '@/lib/api'
+import { cn, formatTime } from '@/lib/utils'
+
+/** 用户画像数据 */
+interface ProfileData {
+ phone: string
+ industry: string
+ company_size: string
+ role: string
+ common_scenes: string[]
+ common_topics: string[]
+ risk_tolerance: string
+ communication_style: string
+ decision_style: string
+ key_facts: Record
+ interaction_count: number
+ last_active: string
+}
+
+/** 画像历史版本 */
+interface ProfileHistory {
+ id: number
+ version: number
+ profile: Record
+ source: string
+ created_at: string
+}
+
+/** 字段中文标签 */
+const FIELD_LABELS: Record = {
+ industry: '行业',
+ company_size: '企业规模',
+ role: '角色',
+ risk_tolerance: '风险偏好',
+ communication_style: '沟通偏好',
+ decision_style: '决策风格',
+}
+
+/** 企业规模映射 */
+const SIZE_LABELS: Record = {
+ micro: '微型(<10人)',
+ small: '小型(10-50人)',
+ medium: '中型(50-200人)',
+ large: '大型(200+人)',
+}
+
+/** 风险偏好映射 */
+const RISK_LABELS: Record = {
+ conservative: '保守型',
+ moderate: '稳健型',
+ aggressive: '进取型',
+}
+
+/** 角色映射 */
+const ROLE_LABELS: Record = {
+ owner: '老板/创始人',
+ manager: '管理者',
+ ceo: 'CEO',
+ director: '总监',
+}
+
+/** 决策风格映射 */
+const DECISION_STYLE_LABELS: Record = {
+ 'data-driven': '数据驱动型',
+ intuitive: '直觉型',
+ collaborative: '协作型',
+}
+
+/** 沟通偏好映射 */
+const COMMUNICATION_STYLE_LABELS: Record = {
+ concise: '简洁',
+ detailed: '详细',
+ structured: '结构化',
+}
+
+/** 行业映射 */
+const INDUSTRY_LABELS: Record = {
+ retail: '零售',
+ manufacturing: '制造业',
+ tech: '科技',
+ service: '服务业',
+ finance: '金融',
+ realestate: '房地产',
+ education: '教育',
+ healthcare: '医疗健康',
+ construction: '建筑',
+ logistics: '物流',
+ agriculture: '农业',
+ hospitality: '餐饮酒店',
+}
+
+/** key_facts 的 key 中文映射 */
+const KEY_FACT_LABELS: Record = {
+ region: '地区',
+ company_type: '公司类型',
+ team_ability: '团队能力',
+ communication: '沟通情况',
+ medical_proof: '医疗证明',
+ contract_terms: '合同条款',
+ medical_period: '医疗期',
+ financial_status: '财务状况',
+ injury_situation: '工伤情况',
+ legal_procedures: '法律程序',
+ market_potential: '市场潜力',
+ alternative_option: '备选方案',
+ work_injury_details: '工伤详情',
+ alternative_solution: '替代方案',
+ work_injury_insurance: '工伤保险',
+ performance_assessment: '绩效评估',
+ relevant_legal_provisions: '相关法律条款',
+ employee_count: '员工人数',
+ revenue: '营收',
+ profit_status: '盈利状况',
+ business_model: '商业模式',
+ funding_stage: '融资阶段',
+ expansion_plan: '扩张计划',
+ competitor: '竞争对手',
+ customer_concentration: '客户集中度',
+ supply_chain: '供应链',
+ cash_flow: '现金流',
+ asset_status: '资产状况',
+ liability: '负债',
+ contract_dispute: '合同纠纷',
+ debt_amount: '债务金额',
+ tax_risk: '税务风险',
+ litigation_status: '诉讼状态',
+ shareholder_structure: '股权结构',
+ partnership: '合伙关系',
+ salary_status: '薪资状况',
+ social_security: '社保',
+ severance: '经济补偿',
+ working_years: '工作年限',
+ probation_period: '试用期',
+ termination_reason: '解雇原因',
+ overtime_status: '加班情况',
+ export_capability: '出口能力',
+ family_situation: '家庭情况',
+ health_status: '健康状况',
+ travel_plan: '出行计划',
+ important_event: '重要事项',
+ education_background: '教育背景',
+ stress_source: '压力来源',
+ personality: '性格特征',
+ work_time: '工作时间',
+ contract_status: '合同状态',
+ salary_calculation_basis: '薪资计算依据',
+ injury_detail: '受伤详情',
+ treatment_status: '治疗状况',
+ recovery_status: '康复状况',
+ compensation_claim: '赔偿诉求',
+ arbitration_status: '仲裁状态',
+ employment_relationship: '劳动关系',
+ wage_standard: '工资标准',
+ working_hours: '工时',
+ leave_record: '请假记录',
+ discipline_record: '违纪记录',
+ training_agreement: '培训协议',
+ confidentiality_agreement: '保密协议',
+ non_compete: '竞业限制',
+ ip_ownership: '知识产权归属',
+ data_protection: '数据保护',
+ compliance_status: '合规状况',
+ regulatory_requirement: '监管要求',
+ license_status: '许可状态',
+ audit_finding: '审计发现',
+ internal_control: '内部控制',
+ risk_management: '风险管理',
+ insurance_coverage: '保险覆盖',
+ claim_status: '理赔状态',
+ policy_detail: '保单详情',
+ premium_status: '保费状态',
+ beneficiary: '受益人',
+ coverage_amount: '保额',
+ deductible: '免赔额',
+ policy_period: '保险期间',
+ renewal_status: '续保状态',
+ cancellation_reason: '退保原因',
+ loan_purpose: '借款用途',
+ repayment_plan: '还款计划',
+ collateral: '抵押物',
+ guarantor_info: '担保人',
+ interest_rate: '利率',
+ penalty_rate: '罚息率',
+ overdue_days: '逾期天数',
+ collection_status: '催收状态',
+ settlement_status: '结算状态',
+ bankruptcy_status: '破产状态',
+ restructure_plan: '重组方案',
+ liquidation_status: '清算状态',
+ debt_restructuring: '债务重组',
+ asset_disposal: '资产处置',
+ business_transfer: '业务转让',
+ merger_status: '并购状态',
+ acquisition_status: '收购状态',
+ divestiture: '剥离',
+ joint_venture: '合资',
+ strategic_alliance: '战略联盟',
+ ip_valuation: '知识产权估值',
+ technology_transfer: '技术转让',
+ royalty_rate: '许可费率',
+ infringement_scope: '侵权范围',
+ damages_claim: '损害赔偿诉求',
+ injunction_status: '禁令状态',
+ invalidation_request: '无效宣告请求',
+ opposition_status: '异议状态',
+ licensing_term: '许可期限',
+ territory: '地域范围',
+ exclusivity: '排他性',
+ sublicensing: '再许可',
+ warranty_term: '保证条款',
+ indemnity: '补偿条款',
+ limitation_liability: '责任限制',
+ force_majeure: '不可抗力',
+ dispute_resolution: '争议解决',
+ governing_law: '适用法律',
+ jurisdiction: '管辖',
+ arbitration_clause: '仲裁条款',
+ mediation_status: '调解状态',
+ enforcement_status: '执行状态',
+ execution_status: '执行状况',
+ preservation_measure: '保全措施',
+ objection_status: '异议状态',
+ retrial_request: '再审请求',
+ appeal_status: '上诉状态',
+ court_level: '法院层级',
+ case_procedure: '案件程序',
+ hearing_date: '开庭日期',
+ evidence_status: '举证状况',
+ witness_status: '证人状况',
+ expert_evaluation: '专家鉴定',
+ judicial_interpretation: '司法解释',
+ precedent_case: '先例案例',
+ legislation_reference: '法律依据',
+}
+
+/** key_facts 的值中文映射 */
+const KEY_FACT_VALUE_LABELS: Record = {
+ yes: '是',
+ no: '否',
+ true: '是',
+ false: '否',
+ none: '无',
+ unknown: '未知',
+ 'n/a': '不适用',
+ pending: '待定',
+ active: '活跃',
+ inactive: '停用',
+ profit: '盈利',
+ loss: '亏损',
+ expansion: '扩张',
+ contraction: '收缩',
+ stable: '稳定',
+ growth: '增长中',
+ declining: '下滑中',
+ ...SIZE_LABELS,
+ ...RISK_LABELS,
+ ...ROLE_LABELS,
+ ...INDUSTRY_LABELS,
+}
+
+/** 场景中文映射 */
+const SCENE_LABELS: Record = {
+ legal: '法务参谋',
+ decision: '经营决策',
+ life: '生活助理',
+ emotion: '情绪中心',
+}
+
+/** 来源映射 */
+const SOURCE_LABELS: Record = {
+ auto: 'AI 自动提取',
+ manual: '用户确认',
+}
+
+/** 格式化字段值 */
+function formatValue(key: string, value: string): string {
+ if (key === 'company_size') return SIZE_LABELS[value] || value
+ if (key === 'risk_tolerance') return RISK_LABELS[value] || value
+ if (key === 'role') return ROLE_LABELS[value] || value
+ if (key === 'decision_style') return DECISION_STYLE_LABELS[value] || value
+ if (key === 'communication_style') return COMMUNICATION_STYLE_LABELS[value] || value
+ if (key === 'industry') return INDUSTRY_LABELS[value] || value
+ return value
+}
+
+/** 翻译 key_facts 的 key */
+function translateFactKey(key: string): string {
+ return KEY_FACT_LABELS[key.toLowerCase()] || key
+}
+
+/** 翻译 key_facts 的值 */
+function translateFactValue(value: string): string {
+ const lower = value.toLowerCase().trim()
+ return KEY_FACT_VALUE_LABELS[lower] || value
+}
+
+export function MyProfile() {
+ const [profile, setProfile] = useState(null)
+ const [history, setHistory] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [currentHistory, setCurrentHistory] = useState(null)
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const [profileRes, historyRes] = await Promise.all([
+ api.getProfile(),
+ api.getProfileHistory(),
+ ])
+ setProfile(profileRes as unknown as ProfileData)
+ setHistory(historyRes.results)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : '加载失败')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => { load() }, [load])
+
+ if (loading) return
+ if (error) return
+ if (!profile) return null
+
+ // ===== 历史详情视图 =====
+ if (currentHistory) {
+ const hp = currentHistory.profile as Record
+ return (
+ setCurrentHistory(null)}>
+ 返回
+
+ }
+ >
+
+
+
+ {/* 标准字段:多列网格 */}
+
+ {Object.entries(FIELD_LABELS).map(([key, label]) => {
+ const val = hp[key] as string
+ if (!val) return null
+ return (
+
+ {label}
+ {formatValue(key, val)}
+
+ )
+ })}
+
+ {Array.isArray(hp.common_topics) && (hp.common_topics as string[]).length > 0 && (
+
+
常问话题
+
+ {(hp.common_topics as string[]).map((t, i) => (
+ {String(t)}
+ ))}
+
+
+ )}
+ {Boolean(hp.key_facts && typeof hp.key_facts === 'object' && !Array.isArray(hp.key_facts) && Object.keys(hp.key_facts as Record
).length > 0) && (
+
+
关键事实
+
+ {Object.entries(hp.key_facts as Record
).map(([k, v]) => (
+
+ {translateFactKey(k)}
+ {translateFactValue(String(v))}
+
+ ))}
+
+
+ )}
+ {Array.isArray(hp.common_scenes) && (hp.common_scenes as string[]).length > 0 && (
+
+
常用场景
+
+ {(hp.common_scenes as string[]).map((s, i) => (
+
+ {SCENE_LABELS[String(s)] || String(s)}
+
+ ))}
+
+
+ )}
+
+
+
+
+ )
+ }
+
+ // ===== 画像主视图 =====
+ return (
+
+ {/* 当前画像 */}
+
+
+
+
+
当前画像
+
+ 交互 {profile.interaction_count} 次
+
+
+
+
+ {/* 标准字段:多列网格 */}
+
+ {Object.entries(FIELD_LABELS).map(([key, label]) => {
+ const val = profile[key as keyof ProfileData] as string
+ if (!val) return null
+ return (
+
+ {label}
+ {formatValue(key, val)}
+
+ )
+ })}
+
+
+ {profile.common_topics && profile.common_topics.length > 0 && (
+
+
常问话题
+
+ {profile.common_topics.map((t, i) => (
+ {t}
+ ))}
+
+
+ )}
+
+ {profile.key_facts && Object.keys(profile.key_facts).length > 0 && (
+
+
关键事实
+
+ {Object.entries(profile.key_facts).map(([k, v]) => (
+
+ {translateFactKey(k)}
+ {translateFactValue(v)}
+
+ ))}
+
+
+ )}
+
+ {profile.common_scenes && profile.common_scenes.length > 0 && (
+
+
常用场景
+
+ {profile.common_scenes.map((s, i) => (
+
+ {SCENE_LABELS[s] || s}
+
+ ))}
+
+
+ )}
+
+ {profile.last_active && (
+
+ 最后活跃:{formatTime(profile.last_active)}
+
+ )}
+
+
+
+
+ {/* 版本历史 */}
+
+
+
+
版本历史
+ {history.length} 个版本
+
+ {history.length === 0 ? (
+
+ ) : (
+
+ {history.map((h) => (
+
setCurrentHistory(h)}
+ >
+
+
+
+
+
+
版本 v{h.version}
+
+
+ {SOURCE_LABELS[h.source] || h.source}
+
+ {formatTime(h.created_at)}
+
+
+
+
+
+ ))}
+
+ )}
+
+
+ )
+}
diff --git a/web2/src/pages/Placeholder.tsx b/web2/src/pages/Placeholder.tsx
new file mode 100644
index 0000000..212d5ec
--- /dev/null
+++ b/web2/src/pages/Placeholder.tsx
@@ -0,0 +1,12 @@
+/** 通用占位页:未实现路由的兜底 */
+import { PageLayout } from '@/components/common/PageLayout'
+import { EmptyState } from '@/components/common/EmptyState'
+import { FileQuestion } from 'lucide-react'
+
+export function Placeholder({ title }: { title: string }) {
+ return (
+
+
+
+ )
+}
diff --git a/web2/src/pages/Settings.tsx b/web2/src/pages/Settings.tsx
new file mode 100644
index 0000000..bbd0f91
--- /dev/null
+++ b/web2/src/pages/Settings.tsx
@@ -0,0 +1,233 @@
+import { useEffect, useState, useCallback } from 'react'
+import { toast } from 'sonner'
+import {
+ Shield,
+ Cloud,
+ Database,
+ Trash2,
+ Bell,
+ Eye,
+ EyeOff,
+ Info,
+ Loader2,
+ Check,
+} from 'lucide-react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { Separator } from '@/components/ui/separator'
+import { LoadingState } from '@/components/common/LoadingState'
+import { ErrorState } from '@/components/common/ErrorState'
+import { api } from '@/lib/api'
+import { cn } from '@/lib/utils'
+import type { Settings } from '@/types'
+
+/** 设置项配置 */
+const SETTING_GROUPS = [
+ {
+ title: '隐私与会话',
+ icon: Shield,
+ desc: '控制数据留存和销毁策略',
+ items: [
+ { key: 'show_inference_location', label: '显示推理位置', desc: '在对话中标注当前处理位置(本地/边缘/云端),保持透明', icon: Eye, danger: false },
+ { key: 'ephemeral_disable_copy', label: '阅后即焚默认禁用复制', desc: '阅后即焚模式下禁止复制、导出、转发和长期记忆', icon: EyeOff, danger: false },
+ { key: 'auto_destroy_on_exit', label: '退出会话自动销毁', desc: '离开页面或关闭应用时自动销毁阅后即焚会话', icon: Trash2, danger: true },
+ ],
+ },
+ {
+ title: '数据与记忆',
+ icon: Database,
+ desc: '控制 AI 记忆范围和数据用途',
+ items: [
+ { key: 'long_term_memory', label: '长期记忆', desc: '记住你的压力来源、性格和决策风格,提供个性化建议。可随时关闭', icon: Database, danger: false },
+ { key: 'knowledge_base_write', label: '企业知识库写入', desc: '允许将脱敏内容写入企业知识库,供团队复用', icon: Database, danger: false },
+ { key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, 左右(BossAi) 不采集训练数据', icon: Info, danger: false },
+ ],
+ },
+ {
+ title: '云端授权',
+ icon: Cloud,
+ desc: '本地能力不足时,控制是否调用云端模型',
+ items: [
+ { key: 'cloud_call_allowed', label: '允许云端调用', desc: '本地模型能力不足时,经授权后调用云端模型。关闭后仅使用本地/边缘推理', icon: Cloud, danger: false },
+ { key: 'pii_redact_on_transfer', label: 'PII 脱敏后传输', desc: '手机号、身份证、银行卡等敏感信息在传输前自动脱敏', icon: Shield, danger: false },
+ ],
+ },
+ {
+ title: '通知与自动升级',
+ icon: Bell,
+ desc: '每日提醒、风险预警和自动升级规则',
+ items: [
+ { key: 'daily_reminder', label: '每日经营提醒', desc: '每天早上推送今天最该做的一件事', icon: Bell, danger: false },
+ { key: 'risk_alert', label: '风险预警', desc: '检测到合同到期、应收逾期、员工试用期届满等风险时主动提醒', icon: Shield, danger: false },
+ { key: 'auto_escalate_red', label: '红色风险自动提醒', desc: '检测到红色风险时自动提醒用户咨询专家,无需手动操作', icon: Shield, danger: false },
+ { key: 'auto_escalate_orange', label: '橙色风险自动提醒', desc: '检测到橙色风险时自动提醒用户咨询专家(默认手动)', icon: Shield, danger: false },
+ ],
+ },
+]
+
+/** 设置:从后端加载,修改后持久化到 PostgreSQL */
+export function Settings() {
+ const [settings, setSettings] = useState(null)
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState(null)
+ const [saving, setSaving] = useState(false)
+ const [dirty, setDirty] = useState(false)
+ const [pendingChanges, setPendingChanges] = useState({})
+
+ const load = useCallback(async () => {
+ setLoading(true)
+ setError(null)
+ try {
+ const res = await api.getSettings()
+ setSettings(res.settings)
+ setPendingChanges({})
+ setDirty(false)
+ } catch (e) {
+ setError(e instanceof Error ? e.message : '加载设置失败')
+ } finally {
+ setLoading(false)
+ }
+ }, [])
+
+ useEffect(() => {
+ load()
+ }, [load])
+
+ const toggle = (key: string) => {
+ const current = settings?.[key] ?? 'false'
+ const newValue = current === 'true' ? 'false' : 'true'
+ setPendingChanges((prev) => ({ ...prev, [key]: newValue }))
+ setSettings((prev) => prev ? { ...prev, [key]: newValue } : prev)
+ setDirty(true)
+ }
+
+ const handleSave = async () => {
+ setSaving(true)
+ try {
+ const res = await api.updateSettings(pendingChanges)
+ setSettings(res.settings)
+ setPendingChanges({})
+ setDirty(false)
+ toast.success('设置已保存')
+ } catch (e) {
+ toast.error(e instanceof Error ? e.message : '保存失败')
+ } finally {
+ setSaving(false)
+ }
+ }
+
+ if (loading) return
+ if (error) return
+ if (!settings) return null
+
+ return (
+
+ 放弃
+
+ {saving ? : }
+ 保存设置
+
+
+ ) : undefined
+ }
+ >
+ {SETTING_GROUPS.map((group) => (
+
+
+
+ {group.title}
+
+ {group.desc}
+
+
+ {group.items.map((item) => {
+ const on = settings[item.key] === 'true'
+ const pending = pendingChanges[item.key]
+ const isPending = pending !== undefined
+ return (
+
+
+
+
+ {item.label}
+ {isPending && 未保存}
+
+
{item.desc}
+
+
toggle(item.key)}
+ className={cn(
+ 'relative h-6 w-11 shrink-0 rounded-full transition-colors',
+ on ? 'bg-primary' : 'bg-muted',
+ )}
+ role="switch"
+ aria-checked={on}
+ aria-label={item.label}
+ >
+
+
+
+ )
+ })}
+
+
+ ))}
+
+ {/* 危险操作 */}
+
+
+
+ 危险操作
+
+ 以下操作不可恢复,请谨慎执行
+
+
+
+
+
清除全部会话历史
+
销毁所有普通和保密模式的历史会话
+
+
toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
+ >
+ 清除
+
+
+
+
+
+
导出并删除全部数据
+
导出你的全部数据后永久删除
+
+
toast.warning('此操作需要二次确认,功能将在后续迭代完善')}
+ >
+ 导出并删除
+
+
+
+
+
+
+ 设置已持久化到 PostgreSQL 数据库,跨会话保留。
+
+
+ )
+}
diff --git a/web2/src/pages/Team.tsx b/web2/src/pages/Team.tsx
new file mode 100644
index 0000000..f654518
--- /dev/null
+++ b/web2/src/pages/Team.tsx
@@ -0,0 +1,101 @@
+/** 我的团队(TASK-017)
+ * 重构专家库为关系视图:咨询过的专家自动沉淀
+ */
+import { useEffect, useState } from 'react'
+import { useNavigate } from 'react-router-dom'
+import { Users, MessageSquare, Plus } from 'lucide-react'
+import { PageLayout } from '@/components/common/PageLayout'
+import { Card, CardContent } from '@/components/ui/card'
+import { Button } from '@/components/ui/button'
+import { LoadingState } from '@/components/common/LoadingState'
+import { ErrorState } from '@/components/common/ErrorState'
+import { EmptyState } from '@/components/common/EmptyState'
+import { ExpertAvatar } from '@/components/common/ExpertAvatar'
+import { api } from '@/lib/api'
+import { formatRelative, SCENE_LABELS } from '@/lib/utils'
+import type { ExpertRelation } from '@/types'
+
+export function Team() {
+ const navigate = useNavigate()
+ const [team, setTeam] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [error, setError] = useState('')
+
+ const load = () => {
+ setLoading(true); setError('')
+ api.getTeam()
+ .then((r) => { setTeam(r.results); setLoading(false) })
+ .catch((e) => { setError(e.message); setLoading(false) })
+ }
+
+ useEffect(load, [])
+
+ if (loading) return
+ if (error) return
+
+ return (
+ navigate('/legal')}> 找新专家}
+ >
+ {team.length === 0 ? (
+ navigate('/legal')}>开始咨询}
+ />
+ ) : (
+
+ {team.map((member) => (
+
+
+
+
+
+ {member.name}
+ {member.title}
+ {member.online && (
+
+ 在线
+
+ )}
+
+
+ {SCENE_LABELS[member.category] || member.category}
+ ·
+ 咨询 {member.consultation_count} 次
+ {member.last_consulted_at && (
+ <>
+ ·
+ 最近 {formatRelative(member.last_consulted_at)}
+ >
+ )}
+
+ {member.last_topic && (
+
+ 上次话题: {member.last_topic}
+
+ )}
+
+
+ navigate(`/${member.category}`, { state: { expertId: member.expert_id, lastTopic: member.last_topic } })}
+ >
+ 新咨询
+
+
+
+
+ ))}
+
+ )}
+
+ )
+}
diff --git a/web2/src/types/index.ts b/web2/src/types/index.ts
new file mode 100644
index 0000000..26f6dc5
--- /dev/null
+++ b/web2/src/types/index.ts
@@ -0,0 +1,279 @@
+/** BossAi 2.0 类型定义
+ * 在 v1.0 基础上扩展案卡(Matter)、风险推送(RiskAlert)、专家关系(ExpertRelation)
+ */
+
+/** 风险等级 */
+export type RiskLevel = 'green' | 'amber' | 'orange' | 'red'
+
+/** 隐私会话模式 */
+export type SessionMode = 'normal' | 'confidential' | 'ephemeral'
+
+/** 业务场景 */
+export type Scene = 'legal' | 'decision' | 'life' | 'emotion'
+
+/** 情绪模式 */
+export type EmotionMode = 'calm' | 'companion' | 'push'
+
+/** 案卡状态 */
+export type MatterStatus = 'new' | 'asking_facts' | 'pending_confirm' | 'analyzing' | 'pending_expert' | 'closed'
+
+/** 风险卡片 */
+export interface RiskCard {
+ conclusion: string
+ level: RiskLevel
+ actions: string[]
+ evidence: string[]
+ escalation: boolean
+}
+
+/** 行动摘要 */
+export interface ActionSummary {
+ risk_level: RiskLevel
+ conclusion: string
+ actions: string[]
+}
+
+/** /ask 响应 */
+export interface AskResponse {
+ risk: RiskCard
+ answer: string | null
+ action_summary: ActionSummary
+ model: 'kimi' | 'local-triage' | string
+ scene: Scene
+ rag_context?: KnowledgeDoc[]
+ inference_location?: 'cloud' | 'local'
+ fallback_used?: boolean
+}
+
+/** 会话响应 */
+export interface SessionResponse {
+ session_id: string
+ mode: SessionMode
+ expires_at: string
+}
+
+/** 文件检查响应 */
+export interface FileInspectResponse {
+ filename: string
+ bytes: number
+ text_preview: string
+ message: string
+}
+
+/** 知识库文档 */
+export interface KnowledgeDoc {
+ id?: number
+ title: string
+ content: string
+ source: string
+ doc_type?: string
+ region?: string | null
+ effective_date?: string | null
+ status?: string
+ keywords?: string | null
+ score?: number
+ created_at?: string
+}
+
+/** 专家 */
+export interface Expert {
+ id: number
+ name: string
+ phone: string
+ category: 'legal' | 'decision' | 'emotion' | 'life'
+ title: string
+ bio: string
+ expertise: string
+ avatar_url: string | null
+ rate: number
+ rating_count: number
+ status: 'active' | 'inactive'
+ created_at?: string
+ ratings?: ExpertRating[]
+}
+
+/** 专家评价 */
+export interface ExpertRating {
+ id: number
+ consultation_id: number
+ user_phone: string
+ rating: number
+ comment: string
+ created_at: string
+}
+
+/** 专家服务关系 */
+export interface ExpertService {
+ service_id: number
+ expert_id: number
+ category: string
+ status: 'pending' | 'active' | 'closed'
+ signed_at: string | null
+ created_at: string
+ expert_name?: string
+ expert_title?: string
+ expert_bio?: string
+ user_phone?: string
+}
+
+/** 专家咨询 */
+export interface Consultation {
+ id: number
+ service_id: number
+ expert_id: number
+ category: string
+ topic: string
+ context?: string
+ question?: string
+ reply: string | null
+ status: 'pending' | 'replied' | 'closed'
+ replied_at: string | null
+ created_at: string
+ expert_name?: string
+ expert_title?: string
+ user_phone?: string
+}
+
+/** 用户设置 */
+export type Settings = Record
+
+/** 对话历史会话摘要 */
+export interface HistorySession {
+ session_id: string
+ mode: SessionMode
+ message_count: number
+ first_message_at: string | null
+ last_message_at: string | null
+ scene: string | null
+ title?: string
+}
+
+/** 对话历史消息 */
+export interface HistoryMessage {
+ id: number
+ role: 'user' | 'assistant'
+ content: string
+ mode: SessionMode
+ risk: {
+ level?: RiskLevel
+ conclusion?: string
+ actions?: string[]
+ evidence?: string[]
+ escalation?: boolean
+ }
+ meta: {
+ scene?: Scene
+ inference_location?: string
+ model?: string
+ fallback_used?: boolean
+ rag_context?: Array<{
+ title: string
+ doc_type?: string
+ region?: string | null
+ }>
+ }
+ created_at: string | null
+}
+
+// ===== 2.0 新增类型 =====
+
+/** 案卡 */
+export interface Matter {
+ id: number
+ code: string
+ user_phone: string
+ scene: Scene
+ title: string
+ status: MatterStatus
+ session_id: string | null
+ risk_level: RiskLevel | null
+ summary: string | null
+ closed_at: string | null
+ execution_result: '顺利' | '受阻' | '未执行' | null
+ execution_note: string | null
+ created_at: string
+ updated_at: string
+}
+
+/** 案卡看板分组 */
+export interface MatterBoard {
+ new: Matter[]
+ asking_facts: Matter[]
+ pending_confirm: Matter[]
+ analyzing: Matter[]
+ pending_expert: Matter[]
+ closed: Matter[]
+}
+
+/** 老板台聚合数据 */
+export interface DashboardToday {
+ greeting: string
+ high_risks: Array<{
+ matter_id: number
+ title: string
+ level: RiskLevel
+ scene: Scene
+ }>
+ pending_confirm: Array<{
+ matter_id: number
+ title: string
+ scene: Scene
+ updated_at: string
+ }>
+ expert_replies: Array<{
+ consultation_id: number
+ expert_name: string
+ expert_title: string
+ snippet: string
+ replied_at: string
+ }>
+ health_score: {
+ legal: number
+ operation: number
+ compliance: number
+ }
+ unread_alerts: number
+}
+
+/** 风险推送 */
+export interface RiskAlert {
+ id: number
+ level: 'red' | 'orange' | 'blue'
+ title: string
+ content: string
+ source: string
+ related_matter_id: number | null
+ read_at: string | null
+ created_at: string
+}
+
+/** 专家关系(我的团队) */
+export interface ExpertRelation {
+ expert_id: number
+ name: string
+ avatar_url: string | null
+ category: string
+ title: string
+ expertise: string
+ online: boolean
+ consultation_count: number
+ last_consulted_at: string | null
+ last_topic: string | null
+ tags: string | null
+}
+
+/** 快捷指令 */
+export interface QuickCommand {
+ id: string
+ label: string
+ template: string
+}
+
+/** 快捷指令分组 */
+export interface QuickCommands {
+ legal: QuickCommand[]
+ decision: QuickCommand[]
+ life: QuickCommand[]
+ profile_incomplete: boolean
+ incomplete_hint?: string
+}
diff --git a/web2/tailwind.config.js b/web2/tailwind.config.js
new file mode 100644
index 0000000..0755539
--- /dev/null
+++ b/web2/tailwind.config.js
@@ -0,0 +1,94 @@
+/** @type {import('tailwindcss').Config} */
+export default {
+ darkMode: ['class'],
+ content: ['./index.html', './src/**/*.{ts,tsx}'],
+ theme: {
+ container: {
+ center: true,
+ padding: '1rem',
+ screens: { '2xl': '1400px' },
+ },
+ extend: {
+ colors: {
+ border: 'hsl(var(--border))',
+ input: 'hsl(var(--input))',
+ ring: 'hsl(var(--ring))',
+ background: 'hsl(var(--background))',
+ foreground: 'hsl(var(--foreground))',
+ primary: {
+ DEFAULT: 'hsl(var(--primary))',
+ foreground: 'hsl(var(--primary-foreground))',
+ },
+ secondary: {
+ DEFAULT: 'hsl(var(--secondary))',
+ foreground: 'hsl(var(--secondary-foreground))',
+ },
+ destructive: {
+ DEFAULT: 'hsl(var(--destructive))',
+ foreground: 'hsl(var(--destructive-foreground))',
+ },
+ muted: {
+ DEFAULT: 'hsl(var(--muted))',
+ foreground: 'hsl(var(--muted-foreground))',
+ },
+ accent: {
+ DEFAULT: 'hsl(var(--accent))',
+ foreground: 'hsl(var(--accent-foreground))',
+ },
+ card: {
+ DEFAULT: 'hsl(var(--card))',
+ foreground: 'hsl(var(--card-foreground))',
+ },
+ popover: {
+ DEFAULT: 'hsl(var(--popover))',
+ foreground: 'hsl(var(--popover-foreground))',
+ },
+ // 风险等级语义色
+ risk: {
+ green: 'hsl(142 71% 45%)',
+ amber: 'hsl(38 92% 50%)',
+ orange: 'hsl(25 95% 53%)',
+ red: 'hsl(0 84% 60%)',
+ },
+ // 案卡状态色
+ matter: {
+ new: 'hsl(217 91% 60%)', // blue
+ asking: 'hsl(38 92% 50%)', // amber
+ confirm: 'hsl(243 75% 59%)', // indigo
+ analyzing: 'hsl(262 83% 58%)', // purple
+ expert: 'hsl(25 95% 53%)', // orange
+ closed: 'hsl(220 9% 46%)', // gray
+ },
+ },
+ borderRadius: {
+ xl: 'calc(var(--radius) + 4px)',
+ lg: 'var(--radius)',
+ md: 'calc(var(--radius) - 2px)',
+ sm: 'calc(var(--radius) - 4px)',
+ },
+ fontFamily: {
+ sans: ['Geist', 'system-ui', '-apple-system', 'sans-serif'],
+ },
+ keyframes: {
+ 'slide-up': {
+ from: { transform: 'translateY(100%)' },
+ to: { transform: 'translateY(0)' },
+ },
+ 'slide-down': {
+ from: { transform: 'translateY(0)' },
+ to: { transform: 'translateY(100%)' },
+ },
+ 'fade-in': {
+ from: { opacity: '0' },
+ to: { opacity: '1' },
+ },
+ },
+ animation: {
+ 'slide-up': 'slide-up 300ms ease-out',
+ 'slide-down': 'slide-down 200ms ease-in',
+ 'fade-in': 'fade-in 200ms ease-out',
+ },
+ },
+ },
+ plugins: [require('tailwindcss-animate'), require('@tailwindcss/typography')],
+}
diff --git a/web2/tsconfig.app.json b/web2/tsconfig.app.json
new file mode 100644
index 0000000..d541922
--- /dev/null
+++ b/web2/tsconfig.app.json
@@ -0,0 +1,23 @@
+{
+ "compilerOptions": {
+ "target": "ES2020",
+ "useDefineForClassFields": true,
+ "lib": ["ES2020", "DOM", "DOM.Iterable"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "jsx": "react-jsx",
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true,
+ "paths": {
+ "@/*": ["./src/*"]
+ }
+ },
+ "include": ["src"]
+}
diff --git a/web2/tsconfig.json b/web2/tsconfig.json
new file mode 100644
index 0000000..1ffef60
--- /dev/null
+++ b/web2/tsconfig.json
@@ -0,0 +1,7 @@
+{
+ "files": [],
+ "references": [
+ { "path": "./tsconfig.app.json" },
+ { "path": "./tsconfig.node.json" }
+ ]
+}
diff --git a/web2/tsconfig.node.json b/web2/tsconfig.node.json
new file mode 100644
index 0000000..8e5b203
--- /dev/null
+++ b/web2/tsconfig.node.json
@@ -0,0 +1,18 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2023"],
+ "module": "ESNext",
+ "skipLibCheck": true,
+ "moduleResolution": "bundler",
+ "allowImportingTsExtensions": true,
+ "isolatedModules": true,
+ "moduleDetection": "force",
+ "noEmit": true,
+ "strict": true,
+ "noUnusedLocals": true,
+ "noUnusedParameters": true,
+ "noFallthroughCasesInSwitch": true
+ },
+ "include": ["vite.config.ts"]
+}
diff --git a/web2/vite.config.ts b/web2/vite.config.ts
new file mode 100644
index 0000000..c57eb91
--- /dev/null
+++ b/web2/vite.config.ts
@@ -0,0 +1,37 @@
+import { defineConfig } from 'vite'
+import react from '@vitejs/plugin-react'
+import path from 'node:path'
+
+export default defineConfig({
+ plugins: [react()],
+ resolve: {
+ alias: {
+ '@': path.resolve(__dirname, './src'),
+ },
+ },
+ server: {
+ port: 5174,
+ host: true,
+ proxy: {
+ '/api': {
+ target: 'http://127.0.0.1:8000',
+ changeOrigin: true,
+ rewrite: (p) => p.replace(/^\/api/, ''),
+ },
+ '/uploads': {
+ target: 'http://127.0.0.1:8000',
+ changeOrigin: true,
+ },
+ },
+ },
+ build: {
+ rollupOptions: {
+ output: {
+ manualChunks: {
+ 'react-vendor': ['react', 'react-dom', 'react-router-dom'],
+ 'markdown-vendor': ['react-markdown', 'remark-gfm', 'rehype-highlight', 'highlight.js'],
+ },
+ },
+ },
+ },
+})