907 lines
34 KiB
TypeScript
907 lines
34 KiB
TypeScript
import { useEffect, useState, useCallback } from 'react'
|
|
import { UserCircle, History, ChevronRight, ArrowLeft, ChevronDown, Pencil, Plus, Trash2 } 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 { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
|
import { Input } from '@/components/ui/input'
|
|
import {
|
|
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
|
|
} from '@/components/ui/dialog'
|
|
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: '法律依据',
|
|
// 补充:AI 分析维度生成的 key
|
|
evidence: '证据',
|
|
amount: '涉及金额',
|
|
party: '对方当事人',
|
|
timeline: '时间线',
|
|
contract: '合同',
|
|
scale: '决策规模',
|
|
cashflow: '现金流',
|
|
alternatives: '备选方案',
|
|
market: '市场状况',
|
|
team: '团队',
|
|
risk_tolerance: '风险承受',
|
|
profitability: '盈利能力',
|
|
situation: '情境',
|
|
people: '涉及人员',
|
|
duration: '持续时间',
|
|
attempt: '已尝试方式',
|
|
goal: '期望结果',
|
|
feeling: '情绪状态',
|
|
trigger: '触发事件',
|
|
time_range: '时间范围',
|
|
region_policy: '地区政策',
|
|
employer_status: '雇主身份',
|
|
has_written_contract_agreement: '是否有书面合同',
|
|
event_occurrence_and_current_status: '事件发生及现状',
|
|
notification_negotiation: '通知与协商',
|
|
labor_contract: '劳动合同',
|
|
performance_evaluation: '绩效评估',
|
|
salary_basis: '薪资计算依据',
|
|
legal_provisions: '法律条款',
|
|
alternative_plan: '备选方案',
|
|
replacement_plan: '替代方案',
|
|
financial_situation: '财务状况',
|
|
}
|
|
|
|
/** 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,
|
|
// 补充值翻译
|
|
employer: '雇主',
|
|
employee: '员工',
|
|
'contract review': '合同审查',
|
|
'work-life balance': '工作生活平衡',
|
|
'has growth potential': '有增长潜力',
|
|
'separate financing': '单独融资',
|
|
'change product line': '改变产品线',
|
|
'compulsory payment': '强制缴纳',
|
|
'can terminate if unqualified': '不合格可以辞退',
|
|
'can dismiss if unqualified': '不合格可以解除合同',
|
|
'in negotiation': '在协商',
|
|
'loss 1 million': '亏损100万',
|
|
'has written contract': '有书面合同',
|
|
'no written contract': '无书面合同',
|
|
'limited company': '有限公司',
|
|
'not capable': '不具备',
|
|
'can terminate': '可以解除',
|
|
'yes, recognized': '是,已认定',
|
|
'incompetent': '不胜任',
|
|
'communicated': '沟通了',
|
|
'not communicated': '未沟通',
|
|
'clear standards': '有明确评估标准和记录',
|
|
'6 months ago': '6个月前',
|
|
'last year': '去年',
|
|
'has ended': '已结束',
|
|
'yes, mandatory': '是,强制',
|
|
}
|
|
|
|
/** 场景中文映射 */
|
|
const SCENE_LABELS: Record<string, string> = {
|
|
legal: '私人法务',
|
|
decision: '经营决策',
|
|
life: '生活助理',
|
|
emotion: '情绪中心',
|
|
}
|
|
|
|
/** 来源映射 */
|
|
const SOURCE_LABELS: Record<string, string> = {
|
|
auto: 'AI 自动提取',
|
|
manual: '用户确认',
|
|
}
|
|
|
|
/** 画像特征 key(稳定的用户属性,直接展示) */
|
|
const PROFILE_FACT_KEYS = new Set([
|
|
'region', 'company_type', 'team_ability', 'industry', 'company_size',
|
|
'role', 'risk_tolerance', 'communication_style', 'decision_style',
|
|
'market_potential', 'financial_status', 'financial_situation',
|
|
'cash_flow', 'asset_status', 'liability', 'revenue', 'profit_status',
|
|
'business_model', 'funding_stage', 'expansion_plan', 'competitor',
|
|
'customer_concentration', 'supply_chain', 'employee_count',
|
|
'shareholder_structure', 'partnership',
|
|
])
|
|
|
|
/** 判断 key 属于画像特征还是个案事实 */
|
|
function isProfileFact(key: string): boolean {
|
|
return PROFILE_FACT_KEYS.has(String(key).toLowerCase())
|
|
}
|
|
|
|
|
|
const TOPIC_LABELS: Record<string, string> = {
|
|
'contract review': '合同审查',
|
|
'work-life balance': '工作生活平衡',
|
|
'tax': '税务',
|
|
'expansion': '扩张',
|
|
'financing': '融资',
|
|
'labor': '劳动用工',
|
|
'employment': '劳动用工',
|
|
'litigation': '诉讼',
|
|
'contract': '合同',
|
|
'shareholder': '股东',
|
|
'debt': '债务',
|
|
'salary': '薪资',
|
|
'social security': '社保',
|
|
'work injury': '工伤',
|
|
'termination': '辞退',
|
|
'decision': '决策',
|
|
'pricing': '定价',
|
|
'hiring': '招聘',
|
|
'layoff': '裁员',
|
|
'partnership': '合伙',
|
|
'investment': '投资',
|
|
'family': '家庭',
|
|
'health': '健康',
|
|
'stress': '压力',
|
|
'sleep': '睡眠',
|
|
'parenting': '亲子',
|
|
'education': '教育',
|
|
'marriage': '婚姻',
|
|
}
|
|
|
|
/** 翻译常问话题 */
|
|
function translateTopic(topic: string): string {
|
|
const t = String(topic)
|
|
const lower = t.toLowerCase().trim()
|
|
return TOPIC_LABELS[lower] || t
|
|
}
|
|
|
|
/** 格式化字段值 */
|
|
function formatValue(key: string, value: string): string {
|
|
const v = String(value)
|
|
if (key === 'company_size') return SIZE_LABELS[v] || v
|
|
if (key === 'risk_tolerance') return RISK_LABELS[v] || v
|
|
if (key === 'role') return ROLE_LABELS[v] || v
|
|
if (key === 'decision_style') return DECISION_STYLE_LABELS[v] || v
|
|
if (key === 'communication_style') return COMMUNICATION_STYLE_LABELS[v] || v
|
|
if (key === 'industry') return INDUSTRY_LABELS[v] || v
|
|
return v
|
|
}
|
|
|
|
/** 翻译 key_facts 的 key */
|
|
function translateFactKey(key: string): string {
|
|
const k = String(key).toLowerCase()
|
|
return KEY_FACT_LABELS[k] || String(key)
|
|
}
|
|
|
|
/** 翻译 key_facts 的值 */
|
|
function translateFactValue(value: string): string {
|
|
const v = String(value)
|
|
const lower = v.toLowerCase().trim()
|
|
return KEY_FACT_VALUE_LABELS[lower] || v
|
|
}
|
|
|
|
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 [showCaseFacts, setShowCaseFacts] = useState(false)
|
|
const [editing, setEditing] = useState<{ field: string; label: string; value: string; type: 'select' | 'text' } | null>(null)
|
|
const [editValue, setEditValue] = useState('')
|
|
const [saving, setSaving] = useState(false)
|
|
const [addingFact, setAddingFact] = useState(false)
|
|
const [newFactKey, setNewFactKey] = useState('')
|
|
const [newFactValue, setNewFactValue] = useState('')
|
|
const [deletingFact, setDeletingFact] = useState<{ key: string; label: string } | 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])
|
|
|
|
/** 标准字段选项(下拉选择) */
|
|
const FIELD_OPTIONS: Record<string, { value: string; label: string }[]> = {
|
|
industry: Object.entries(INDUSTRY_LABELS).map(([v, l]) => ({ value: v, label: l })),
|
|
company_size: Object.entries(SIZE_LABELS).map(([v, l]) => ({ value: v, label: l })),
|
|
role: Object.entries(ROLE_LABELS).map(([v, l]) => ({ value: v, label: l })),
|
|
risk_tolerance: Object.entries(RISK_LABELS).map(([v, l]) => ({ value: v, label: l })),
|
|
communication_style: Object.entries(COMMUNICATION_STYLE_LABELS).map(([v, l]) => ({ value: v, label: l })),
|
|
decision_style: Object.entries(DECISION_STYLE_LABELS).map(([v, l]) => ({ value: v, label: l })),
|
|
}
|
|
|
|
/** 打开编辑 */
|
|
const handleEdit = (field: string, label: string, value: string) => {
|
|
const isSelect = !!FIELD_OPTIONS[field]
|
|
setEditing({ field, label, value, type: isSelect ? 'select' : 'text' })
|
|
setEditValue(value)
|
|
}
|
|
|
|
/** 保存编辑 */
|
|
const handleSave = async () => {
|
|
if (!editing || !editValue.trim()) { setEditing(null); return }
|
|
setSaving(true)
|
|
try {
|
|
const field = editing.field
|
|
if (FIELD_LABELS[field]) {
|
|
// 标准字段
|
|
await api.updateProfile({ [field]: editValue })
|
|
setProfile((prev) => prev ? { ...prev, [field]: editValue } : prev)
|
|
} else {
|
|
// key_facts
|
|
await api.updateProfile({ key_facts: { [field]: editValue } })
|
|
setProfile((prev) => prev ? { ...prev, key_facts: { ...prev.key_facts, [field]: editValue } } : prev)
|
|
}
|
|
setEditing(null)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : '保存失败')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
/** 删除画像特征(确认后执行) */
|
|
const handleDeleteFact = (key: string, label: string) => {
|
|
setDeletingFact({ key, label })
|
|
}
|
|
|
|
/** 确认删除 */
|
|
const confirmDeleteFact = async () => {
|
|
if (!deletingFact || !profile) return
|
|
try {
|
|
const newFacts = { ...profile.key_facts }
|
|
delete newFacts[deletingFact.key]
|
|
await api.updateProfile({ key_facts: newFacts })
|
|
setProfile((prev) => prev ? { ...prev, key_facts: newFacts } : prev)
|
|
setDeletingFact(null)
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : '删除失败')
|
|
}
|
|
}
|
|
|
|
/** 添加画像特征 */
|
|
const handleAddFact = async () => {
|
|
if (!newFactKey.trim() || !newFactValue.trim() || !profile) return
|
|
setSaving(true)
|
|
try {
|
|
const key = newFactKey.trim()
|
|
const newFacts = { ...profile.key_facts, [key]: newFactValue.trim() }
|
|
await api.updateProfile({ key_facts: newFacts })
|
|
setProfile((prev) => prev ? { ...prev, key_facts: newFacts } : prev)
|
|
setAddingFact(false)
|
|
setNewFactKey('')
|
|
setNewFactValue('')
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : '添加失败')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
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">{translateTopic(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="group flex items-center justify-between border-b pb-1">
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
<div className="flex items-center gap-1">
|
|
<span className="text-xs font-medium">{formatValue(key, val)}</span>
|
|
<button
|
|
onClick={() => handleEdit(key, label, val)}
|
|
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
aria-label={`编辑${label}`}
|
|
>
|
|
<Pencil className="h-3 w-3 text-muted-foreground hover:text-primary" />
|
|
</button>
|
|
</div>
|
|
</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">{translateTopic(t)}</Badge>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{profile.key_facts && Object.keys(profile.key_facts).length > 0 && (() => {
|
|
const entries = Object.entries(profile.key_facts)
|
|
const profileFacts = entries.filter(([k]) => isProfileFact(k))
|
|
const caseFacts = entries.filter(([k]) => !isProfileFact(k))
|
|
return (
|
|
<>
|
|
{/* 画像特征:直接展示(可编辑/可增删) */}
|
|
{profileFacts.length > 0 && (
|
|
<div className="border-b pb-2">
|
|
<div className="mb-1 flex items-center justify-between">
|
|
<p className="text-xs text-muted-foreground">画像特征</p>
|
|
<button
|
|
onClick={() => setAddingFact(true)}
|
|
className="flex items-center gap-0.5 text-xs text-primary hover:text-primary/80"
|
|
>
|
|
<Plus className="h-3 w-3" />添加
|
|
</button>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
|
|
{profileFacts.map(([k, v]) => (
|
|
<div key={k} className="group flex items-center justify-between text-xs">
|
|
<span className="text-muted-foreground">{translateFactKey(k)}</span>
|
|
<div className="flex items-center gap-1">
|
|
<span className="font-medium">{translateFactValue(v)}</span>
|
|
<button
|
|
onClick={() => handleEdit(k, translateFactKey(k), v)}
|
|
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
aria-label={`编辑${translateFactKey(k)}`}
|
|
>
|
|
<Pencil className="h-3 w-3 text-muted-foreground hover:text-primary" />
|
|
</button>
|
|
<button
|
|
onClick={() => handleDeleteFact(k, translateFactKey(k))}
|
|
className="opacity-0 transition-opacity group-hover:opacity-100"
|
|
aria-label={`删除${translateFactKey(k)}`}
|
|
>
|
|
<Trash2 className="h-3 w-3 text-muted-foreground hover:text-red-500" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
{/* 无画像特征时也显示添加按钮 */}
|
|
{profileFacts.length === 0 && (
|
|
<div className="border-b pb-2">
|
|
<button
|
|
onClick={() => setAddingFact(true)}
|
|
className="flex items-center gap-0.5 text-xs text-primary hover:text-primary/80"
|
|
>
|
|
<Plus className="h-3 w-3" />添加画像特征
|
|
</button>
|
|
</div>
|
|
)}
|
|
{/* 个案事实:折叠展示 */}
|
|
{caseFacts.length > 0 && (
|
|
<div className="border-b pb-2">
|
|
<button
|
|
onClick={() => setShowCaseFacts(!showCaseFacts)}
|
|
className="mb-1 flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ChevronDown className={cn('h-3 w-3 transition-transform', !showCaseFacts && '-rotate-90')} />
|
|
个案事实({caseFacts.length})
|
|
</button>
|
|
{showCaseFacts && (
|
|
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
|
|
{caseFacts.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">最近 3 个版本</span>
|
|
</div>
|
|
{history.length === 0 ? (
|
|
<EmptyState
|
|
title="暂无历史版本"
|
|
description="每次画像更新都会记录版本快照"
|
|
icon={History}
|
|
/>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{history.slice(0, 3).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>
|
|
|
|
{/* 编辑弹窗 */}
|
|
<Dialog open={!!editing} onOpenChange={(open) => !open && setEditing(null)}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>编辑{editing?.label}</DialogTitle>
|
|
<DialogDescription>修改后将保存为新的画像版本</DialogDescription>
|
|
</DialogHeader>
|
|
{editing?.type === 'select' && editing.field in FIELD_OPTIONS ? (
|
|
<Select value={editValue} onValueChange={setEditValue}>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder="请选择" />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{FIELD_OPTIONS[editing.field].map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : (
|
|
<Input
|
|
value={editValue}
|
|
onChange={(e) => setEditValue(e.target.value)}
|
|
placeholder={`请输入${editing?.label}`}
|
|
onKeyDown={(e) => e.key === 'Enter' && handleSave()}
|
|
/>
|
|
)}
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setEditing(null)}>取消</Button>
|
|
<Button onClick={handleSave} disabled={saving || !editValue.trim()}>
|
|
{saving ? '保存中...' : '保存'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 添加画像特征弹窗 */}
|
|
<Dialog open={addingFact} onOpenChange={setAddingFact}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>添加画像特征</DialogTitle>
|
|
<DialogDescription>填写特征名称和值,保存后生效</DialogDescription>
|
|
</DialogHeader>
|
|
<div className="space-y-3">
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground">特征名称</label>
|
|
<Input
|
|
value={newFactKey}
|
|
onChange={(e) => setNewFactKey(e.target.value)}
|
|
placeholder="如:行业、公司规模、地区"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<label className="mb-1 block text-xs text-muted-foreground">特征值</label>
|
|
<Input
|
|
value={newFactValue}
|
|
onChange={(e) => setNewFactValue(e.target.value)}
|
|
placeholder="如:科技、小型、上海"
|
|
onKeyDown={(e) => e.key === 'Enter' && handleAddFact()}
|
|
/>
|
|
</div>
|
|
</div>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setAddingFact(false)}>取消</Button>
|
|
<Button onClick={handleAddFact} disabled={saving || !newFactKey.trim() || !newFactValue.trim()}>
|
|
{saving ? '保存中...' : '添加'}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
{/* 删除确认弹窗 */}
|
|
<Dialog open={!!deletingFact} onOpenChange={(open) => !open && setDeletingFact(null)}>
|
|
<DialogContent className="max-w-sm">
|
|
<DialogHeader>
|
|
<DialogTitle>删除画像特征</DialogTitle>
|
|
<DialogDescription>
|
|
确定要删除「{deletingFact?.label}」吗?此操作不可撤销。
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
<DialogFooter>
|
|
<Button variant="outline" onClick={() => setDeletingFact(null)}>取消</Button>
|
|
<Button variant="destructive" onClick={confirmDeleteFact}>删除</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</PageLayout>
|
|
)
|
|
}
|