feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 新增 web2/ 前端:任务卡看板、风险雷达、决策日志、我的团队、快捷指令
- ChatPanel 增强:风险卡置顶、案卡接入、起诉状向导、语音输入
- 事项看板:点击查看详情弹窗、断点续接(恢复历史对话+matterId)
- 保密模式恢复:密码验证后重建内存 session,保留多轮上下文
- 多轮对话:ask/ask-stream/ask-analyze 注入 session 历史,AI 有上下文
- 追问优化:紧扣用户问题,不再偏离主题(社保问社保,工伤问工伤)
- 对话区可滚动,输入区固定可见
- 状态机放宽:允许从任意非终态转到 pending_expert
- session 存活检查端点,后端重启后自动重建 session
- DB migration:风险推送表、案卡表

Generated with [Devin](https://devin.ai)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-31 08:38:33 +08:00
co-authored by Devin
parent 7a788bf8dc
commit 4de36547cd
88 changed files with 15237 additions and 62 deletions
+535
View File
@@ -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<string, string>
interaction_count: number
last_active: string
}
/** 画像历史版本 */
interface ProfileHistory {
id: number
version: number
profile: Record<string, unknown>
source: string
created_at: string
}
/** 字段中文标签 */
const FIELD_LABELS: Record<string, string> = {
industry: '行业',
company_size: '企业规模',
role: '角色',
risk_tolerance: '风险偏好',
communication_style: '沟通偏好',
decision_style: '决策风格',
}
/** 企业规模映射 */
const SIZE_LABELS: Record<string, string> = {
micro: '微型(<10人)',
small: '小型(10-50人)',
medium: '中型(50-200人)',
large: '大型(200+人)',
}
/** 风险偏好映射 */
const RISK_LABELS: Record<string, string> = {
conservative: '保守型',
moderate: '稳健型',
aggressive: '进取型',
}
/** 角色映射 */
const ROLE_LABELS: Record<string, string> = {
owner: '老板/创始人',
manager: '管理者',
ceo: 'CEO',
director: '总监',
}
/** 决策风格映射 */
const DECISION_STYLE_LABELS: Record<string, string> = {
'data-driven': '数据驱动型',
intuitive: '直觉型',
collaborative: '协作型',
}
/** 沟通偏好映射 */
const COMMUNICATION_STYLE_LABELS: Record<string, string> = {
concise: '简洁',
detailed: '详细',
structured: '结构化',
}
/** 行业映射 */
const INDUSTRY_LABELS: Record<string, string> = {
retail: '零售',
manufacturing: '制造业',
tech: '科技',
service: '服务业',
finance: '金融',
realestate: '房地产',
education: '教育',
healthcare: '医疗健康',
construction: '建筑',
logistics: '物流',
agriculture: '农业',
hospitality: '餐饮酒店',
}
/** key_facts 的 key 中文映射 */
const KEY_FACT_LABELS: Record<string, string> = {
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<string, string> = {
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<string, string> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
/** 来源映射 */
const SOURCE_LABELS: Record<string, string> = {
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<ProfileData | null>(null)
const [history, setHistory] = useState<ProfileHistory[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [currentHistory, setCurrentHistory] = useState<ProfileHistory | null>(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 <PageLayout title="我的画像"><LoadingState /></PageLayout>
if (error) return <PageLayout title="我的画像"><ErrorState message={error} onRetry={load} /></PageLayout>
if (!profile) return null
// ===== 历史详情视图 =====
if (currentHistory) {
const hp = currentHistory.profile as Record<string, unknown>
return (
<PageLayout
title={`画像版本 v${currentHistory.version}`}
description={`${SOURCE_LABELS[currentHistory.source] || currentHistory.source} · ${formatTime(currentHistory.created_at)}`}
actions={
<Button variant="outline" size="sm" onClick={() => setCurrentHistory(null)}>
<ArrowLeft className="h-3.5 w-3.5" /> 返回
</Button>
}
>
<Card>
<CardContent className="p-5">
<div className="space-y-3">
{/* 标准字段:多列网格 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{Object.entries(FIELD_LABELS).map(([key, label]) => {
const val = hp[key] as string
if (!val) return null
return (
<div key={key} className="flex items-center justify-between border-b pb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{formatValue(key, val)}</span>
</div>
)
})}
</div>
{Array.isArray(hp.common_topics) && (hp.common_topics as string[]).length > 0 && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1">
{(hp.common_topics as string[]).map((t, i) => (
<Badge key={i} variant="secondary" className="text-[10px] px-1 py-0">{String(t)}</Badge>
))}
</div>
</div>
)}
{Boolean(hp.key_facts && typeof hp.key_facts === 'object' && !Array.isArray(hp.key_facts) && Object.keys(hp.key_facts as Record<string, unknown>).length > 0) && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">关键事实</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{Object.entries(hp.key_facts as Record<string, string>).map(([k, v]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{translateFactKey(k)}</span>
<span className="font-medium">{translateFactValue(String(v))}</span>
</div>
))}
</div>
</div>
)}
{Array.isArray(hp.common_scenes) && (hp.common_scenes as string[]).length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">常用场景</span>
<div className="flex flex-wrap gap-1">
{(hp.common_scenes as string[]).map((s, i) => (
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
{SCENE_LABELS[String(s)] || String(s)}
</Badge>
))}
</div>
</div>
)}
</div>
</CardContent>
</Card>
</PageLayout>
)
}
// ===== 画像主视图 =====
return (
<PageLayout
title="我的画像"
description="系统根据你的对话累积的抽象特征,用于个性化回复。不存储对话原文。"
>
{/* 当前画像 */}
<Card>
<CardContent className="p-5">
<div className="mb-4 flex items-center gap-2">
<UserCircle className="h-5 w-5 text-primary" aria-hidden />
<h3 className="text-sm font-semibold">当前画像</h3>
<Badge variant="secondary" className="ml-auto text-xs">
交互 {profile.interaction_count} 次
</Badge>
</div>
<div className="space-y-3">
{/* 标准字段:多列网格 */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 sm:grid-cols-3">
{Object.entries(FIELD_LABELS).map(([key, label]) => {
const val = profile[key as keyof ProfileData] as string
if (!val) return null
return (
<div key={key} className="flex items-center justify-between border-b pb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-xs font-medium">{formatValue(key, val)}</span>
</div>
)
})}
</div>
{profile.common_topics && profile.common_topics.length > 0 && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1">
{profile.common_topics.map((t, i) => (
<Badge key={i} variant="secondary" className="text-[10px] px-1 py-0">{t}</Badge>
))}
</div>
</div>
)}
{profile.key_facts && Object.keys(profile.key_facts).length > 0 && (
<div className="border-b pb-2">
<p className="mb-1 text-xs text-muted-foreground">关键事实</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{Object.entries(profile.key_facts).map(([k, v]) => (
<div key={k} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{translateFactKey(k)}</span>
<span className="font-medium">{translateFactValue(v)}</span>
</div>
))}
</div>
</div>
)}
{profile.common_scenes && profile.common_scenes.length > 0 && (
<div className="flex items-center gap-2">
<span className="text-xs text-muted-foreground">常用场景</span>
<div className="flex flex-wrap gap-1">
{profile.common_scenes.map((s, i) => (
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
{SCENE_LABELS[s] || s}
</Badge>
))}
</div>
</div>
)}
{profile.last_active && (
<p className="text-xs text-muted-foreground">
最后活跃:{formatTime(profile.last_active)}
</p>
)}
</div>
</CardContent>
</Card>
{/* 版本历史 */}
<div>
<div className="mb-2 flex items-center gap-2 px-1">
<History className="h-4 w-4 text-muted-foreground" aria-hidden />
<h3 className="text-sm font-semibold">版本历史</h3>
<span className="text-xs text-muted-foreground">{history.length} 个版本</span>
</div>
{history.length === 0 ? (
<EmptyState
title="暂无历史版本"
description="每次画像更新都会记录版本快照"
icon={History}
/>
) : (
<div className="space-y-2">
{history.map((h) => (
<Card
key={h.id}
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() => setCurrentHistory(h)}
>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn(
'flex h-9 w-9 shrink-0 items-center justify-center rounded-lg',
h.source === 'manual' ? 'bg-blue-100 text-blue-700' : 'bg-muted text-muted-foreground',
)}>
<History className="h-4 w-4" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">版本 v{h.version}</p>
<div className="mt-0.5 flex items-center gap-2">
<Badge
variant={h.source === 'manual' ? 'default' : 'secondary'}
className="text-[10px]"
>
{SOURCE_LABELS[h.source] || h.source}
</Badge>
<span className="text-xs text-muted-foreground">{formatTime(h.created_at)}</span>
</div>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</CardContent>
</Card>
))}
</div>
)}
</div>
</PageLayout>
)
}