feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
- 新增 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:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user