feat: 话题动态化 + 要素化立案 + 知识库增强
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

话题动态化:
- 新增 POST /topics 端点,AI 根据场景+画像动态生成话题
- 前端3个助手页面改为兜底话题先行,换一批时AI生成
- 去除固化话题列表,支持个性化推荐

要素化立案(对接最高法67类要素表):
- 导入68类要素式起诉状模板到知识库(法〔2025〕82号)
- 覆盖民事/商事/知识产权/行政/刑事自诉/海事/环境/国家赔偿/执行9领域
- ask-analyze 法务场景自动匹配案由模板
- 新增 POST /element-filing 端点,AI填充要素生成要素式起诉状
- ChatPanel 确认事实后可一键生成要素式起诉状,支持复制全文

知识库增强:
- 要素表模板展示全中文化(type/required/标签翻译)
- 知识库页面要素表模板结构化展示(案由/要素清单/诉讼请求)
- 各类型tab显示数量和总数量

画像翻译完善:
- 补充90+个key_facts key中文翻译
- MyProfile多列紧凑布局

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-30 15:12:33 +08:00
co-authored by Devin
parent 4e2b4666aa
commit 7a788bf8dc
11 changed files with 2291 additions and 390 deletions
+191 -15
View File
@@ -64,6 +64,97 @@ const PROFILE_VALUE_LABELS: Record<string, string> = {
// 通用 yes/no
yes: '是',
no: '否',
// 行业
retail: '零售',
manufacturing: '制造业',
tech: '科技',
service: '服务业',
finance: '金融',
realestate: '房地产',
education: '教育',
healthcare: '医疗健康',
construction: '建筑',
logistics: '物流',
agriculture: '农业',
hospitality: '餐饮酒店',
// 常见 key_facts 英文值
true: '是',
false: '否',
none: '无',
unknown: '未知',
'n/a': '不适用',
pending: '待定',
active: '活跃',
inactive: '停用',
profit: '盈利',
loss: '亏损',
expansion: '扩张',
contraction: '收缩',
stable: '稳定',
growth: '增长中',
declining: '下滑中',
}
/** 画像字段标签(dim_desc)英文 → 中文映射 */
const PROFILE_DIM_LABELS: Record<string, string> = {
// 标准画像字段
industry: '行业',
company_size: '企业规模',
role: '角色',
risk_tolerance: '风险偏好',
decision_style: '决策风格',
communication_style: '沟通偏好',
// key_facts 常见 key
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: '融资阶段',
partnership: '合伙关系',
shareholder_structure: '股权结构',
contract_dispute: '合同纠纷',
debt_amount: '债务金额',
tax_risk: '税务风险',
litigation_status: '诉讼状态',
employee_role: '员工角色',
salary_status: '薪资状况',
social_security: '社保',
severance: '经济补偿',
working_years: '工作年限',
probation_period: '试用期',
termination_reason: '解雇原因',
overtime_status: '加班情况',
export_capability: '出口能力',
expansion_plan: '扩张计划',
competitor: '竞争对手',
customer_concentration: '客户集中度',
supply_chain: '供应链',
cash_flow: '现金流',
asset_status: '资产状况',
liability: '负债',
}
/** 将画像字段的英文标签翻译为中文 */
function translateDimLabel(label: string): string {
const lower = label.toLowerCase().trim()
return PROFILE_DIM_LABELS[lower] ?? label
}
/** 将画像字段的英文值翻译为中文显示 */
@@ -179,6 +270,9 @@ export function ChatPanel({
const [factConfirm, setFactConfirm] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [factConfirmEditing, setFactConfirmEditing] = useState<Record<string, string>>({})
const [profileFacts, setProfileFacts] = useState<Array<{ dim_key: string; dim_desc: string; answer: string }>>([])
const [elementTemplate, setElementTemplate] = useState<Record<string, unknown> | null>(null)
const [elementFiling, setElementFiling] = useState<{ cause_of_action: string; elements: Array<{ key: string; label: string; value: string }>; claims: string[]; filing_text: string } | null>(null)
const [generatingFiling, setGeneratingFiling] = useState(false)
const [analyzing, setAnalyzing] = useState(false)
const fileRef = useRef<HTMLInputElement>(null)
/** 待切换的目标模式(销毁确认后切换) */
@@ -300,12 +394,13 @@ export function ChatPanel({
// 第1步:分析问题,判断是否需要追问
setAnalyzing(true)
const analysis = await api.askAnalyze(currentSid, userQuestion, scene, emotionMode)
const analysis = await api.askAnalyze(currentSid, userQuestion, scene, emotionMode, undefined, topic)
setAnalyzing(false)
// 保存画像带入事实
const pf = analysis.profile_facts || []
setProfileFacts(pf)
setElementTemplate(analysis.element_template || null)
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
// 需要追问:显示追问问题
@@ -358,12 +453,14 @@ export function ChatPanel({
scene,
emotionMode,
allAnswers,
topic,
)
setAnalyzing(false)
// 更新画像带入事实
const pf = analysis.profile_facts || []
setProfileFacts(pf)
setElementTemplate(analysis.element_template || null)
if (analysis.need_ask && analysis.questions && analysis.questions.length > 0) {
// 还需追问
@@ -743,25 +840,24 @@ export function ChatPanel({
确认事实
</div>
<p className="text-xs text-blue-700">请核对以下信息,可直接修改后继续分析。确认后将更新你的用户画像。</p>
<div className="grid gap-2 sm:grid-cols-2">
<div className="grid grid-cols-2 gap-1.5 sm:grid-cols-3">
{factConfirm.map((f) => {
const isProfile = profileFacts.some((p) => p.dim_key === f.dim_key)
// 画像带入的字段值翻译为中文显示
const displayValue = isProfile
? (factConfirmEditing[f.dim_key] ?? translateProfileValue(f.answer))
: (factConfirmEditing[f.dim_key] ?? f.answer)
const rawValue = factConfirmEditing[f.dim_key] ?? f.answer
const displayValue = isProfile ? translateProfileValue(rawValue) : rawValue
return (
<div key={f.dim_key} className="space-y-0.5">
<div className="flex items-center gap-1">
<Label className="text-xs text-blue-800">{f.dim_desc}</Label>
{isProfile && (
<Badge variant="secondary" className="text-[10px] px-1 py-0">画像</Badge>
)}
</div>
<Input
<div key={f.dim_key} className="flex items-center gap-1 rounded border border-blue-200 bg-white px-1.5 py-0.5">
<Badge
variant={isProfile ? 'secondary' : 'outline'}
className="shrink-0 text-[10px] font-medium px-1 py-0"
>
{translateDimLabel(f.dim_desc)}
</Badge>
<input
type="text"
value={displayValue}
onChange={(e) => setFactConfirmEditing((prev) => ({ ...prev, [f.dim_key]: e.target.value }))}
className="bg-white h-8 text-sm"
className="min-w-0 flex-1 border-none bg-transparent px-0.5 py-0 text-xs text-foreground focus:outline-none"
/>
</div>
)
@@ -821,6 +917,86 @@ export function ChatPanel({
{loading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
确认并分析
</Button>
{scene === 'legal' && elementTemplate && (
<Button
variant="outline"
onClick={async () => {
const confirmedAnswers = factConfirm.map((f) => ({
dim_key: f.dim_key,
dim_desc: f.dim_desc,
answer: (factConfirmEditing[f.dim_key] ?? '').trim() || f.answer,
}))
setGeneratingFiling(true)
try {
const currentSid = sid || (await ensureSession())
const causeOfAction = (elementTemplate as Record<string, unknown>).cause_of_action as string || '劳动争议纠纷'
const res = await api.elementFiling(currentSid, currentQuestionRef.current, causeOfAction, confirmedAnswers, elementTemplate)
setElementFiling(res)
} catch (e) {
toast.error(e instanceof Error ? e.message : '要素式起诉状生成失败')
} finally {
setGeneratingFiling(false)
}
}}
disabled={loading || generatingFiling}
size="sm"
>
{generatingFiling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
生成要素式起诉状
</Button>
)}
</div>
</div>
)}
{/* 要素化立案结果 */}
{elementFiling && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4 space-y-3">
<div className="flex items-center gap-2 text-sm font-medium text-green-900">
<FileText className="h-4 w-4" aria-hidden />
要素式起诉状 · {elementFiling.cause_of_action}
</div>
<div className="space-y-1.5">
<p className="text-xs font-medium text-green-800">诉讼请求</p>
<div className="flex flex-wrap gap-1">
{elementFiling.claims.map((c, i) => (
<Badge key={i} variant="outline" className="text-[10px]">☐ {c}</Badge>
))}
</div>
</div>
<div className="space-y-1">
<p className="text-xs font-medium text-green-800">要素清单</p>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{elementFiling.elements.map((e, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-muted-foreground">{e.label}</span>
<span className="font-medium">{e.value}</span>
</div>
))}
</div>
</div>
<details className="text-xs">
<summary className="cursor-pointer text-green-800">查看完整起诉状文本</summary>
<pre className="mt-2 whitespace-pre-wrap rounded bg-white p-3 text-xs text-foreground border">{elementFiling.filing_text}</pre>
</details>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
onClick={() => {
navigator.clipboard.writeText(elementFiling.filing_text)
toast.success('已复制到剪贴板')
}}
>
复制全文
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setElementFiling(null)}
>
关闭
</Button>
</div>
</div>
)}
+37 -1
View File
@@ -127,6 +127,16 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export const api = {
health: () => request<{ status: string; service: string }>('/health'),
/** AI 动态生成话题列表 */
getTopics: (
scene: Scene = 'legal',
existing?: string[],
) =>
request<{ topics: Array<{ title: string; desc: string; samples: string[] }> }>('/topics', {
method: 'POST',
body: JSON.stringify({ scene, existing }),
}),
/** AI 生成示例问题(换一批) */
generateSamples: (
scene: Scene = 'legal',
@@ -186,6 +196,29 @@ export const api = {
getProfileHistory: () =>
request<{ results: Array<{ id: number; version: number; profile: Record<string, unknown>; source: string; created_at: string }> }>('/profile/me/history'),
/** 要素化立案:根据确认事实 + 案由模板生成要素式起诉状 */
elementFiling: (
sid: string,
question: string,
causeOfAction: string,
factAnswers: Array<{ dim_key: string; dim_desc: string; answer: string }>,
template?: Record<string, unknown>,
) =>
request<{
cause_of_action: string
elements: Array<{ key: string; label: string; value: string }>
claims: string[]
filing_text: string
}>(`/sessions/${sid}/element-filing`, {
method: 'POST',
body: JSON.stringify({
question,
cause_of_action: causeOfAction,
fact_answers: factAnswers,
template,
}),
}),
/** 事实追问分析:返回追问问题或标记事实已充分 */
askAnalyze: (
sid: string,
@@ -193,6 +226,7 @@ export const api = {
scene: Scene = 'legal',
emotionMode?: EmotionMode,
factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>,
topic?: string,
) =>
request<{
need_ask: boolean
@@ -203,6 +237,7 @@ export const api = {
question_type: string
risk_level: string
profile_facts?: Array<{ dim_key: string; dim_desc: string; answer: string }>
element_template?: Record<string, unknown>
}>(`/sessions/${sid}/ask-analyze`, {
method: 'POST',
body: JSON.stringify({
@@ -210,6 +245,7 @@ export const api = {
scene,
emotion_mode: emotionMode,
fact_answers: factAnswers,
topic,
}),
}),
@@ -331,7 +367,7 @@ export const api = {
}),
listKnowledge: (docType?: string) =>
request<{ results: KnowledgeDoc[] }>(
request<{ results: KnowledgeDoc[]; counts: Record<string, number>; total: number }>(
`/knowledge${docType ? `?doc_type=${docType}` : ''}`,
),
+93 -108
View File
@@ -1,84 +1,51 @@
import { useState } from 'react'
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 { Wallet, Store, Handshake, Tag, UserPlus, Repeat, ChevronDown } from 'lucide-react'
import { TrendingUp, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
/** 决策话题:每个话题对应不同的示例问题 */
const TOPICS = [
{
icon: Store,
title: '扩张收缩',
desc: '开店、关店、业务线调整',
samples: [
'现在适合扩张开第二家店吗?',
'要不要关闭亏损的业务线?',
'新业务线该自营还是加盟?',
'应该先扩张还是先巩固现有市场?',
],
},
{
icon: Handshake,
title: '项目承接',
desc: '大单、长期合同、付款条件',
samples: [
'是否应该接一个付款周期 90 天的大单?',
'长期框架合同要不要签?',
'预付款比例低的项目能接吗?',
'客户要求独家合作,利弊怎么权衡?',
],
},
{
icon: Tag,
title: '定价策略',
desc: '降价、涨价、促销',
samples: [
'该不该降价 15% 抢竞品的客户?',
'产品涨价 10% 会流失多少客户?',
'要不要做满减促销活动?',
'新产品定价应该走高端还是性价比?',
],
},
{
icon: UserPlus,
title: '招人裁员',
desc: '关键岗位、团队扩张、减员',
samples: [
'应该招一个销售总监吗?',
'要不要招应届生自己培养?',
'团队人效低,该裁谁?',
'核心员工被挖,要不要加薪留人?',
],
},
{
icon: Wallet,
title: '融资合伙',
desc: '投资、入股、融资',
samples: [
'要不要接受天使投资?',
'合伙人要退股,怎么谈条件?',
'朋友要入股,股权怎么分?',
'银行贷款还是股权融资更合适?',
],
},
{
icon: Repeat,
title: '转型外包',
desc: '业务转型、外包、合作',
samples: [
'传统业务要不要转型线上?',
'非核心业务外包还是自己做?',
'要不要和同行做联合采购?',
'代工还是自建生产线?',
],
},
/** 话题类型 */
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<Topic[]>(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 (
<PageLayout
@@ -87,50 +54,68 @@ export function Decisions() {
>
<Card>
<CardHeader className="pb-3">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
决策话题
</button>
<div className="flex items-center justify-between">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
决策话题
</button>
<button
onClick={refreshTopics}
disabled={loadingTopics}
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
>
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
换一批
</button>
</div>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => (
<button
key={t.title}
onClick={() => 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',
)}
>
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
<div>
<p className="text-sm font-medium">{t.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
</div>
</button>
))}
</div>
{loadingTopics && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
AI 正在生成新话题…
</div>
)}
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
{topics.map((t, i) => (
<button
key={t.title + i}
onClick={() => 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',
)}
>
<TrendingUp className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
<div>
<p className="text-sm font-medium">{t.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
</div>
</button>
))}
</div>
</CardContent>
)}
</Card>
<ChatPanel
scene="decision"
samples={TOPICS[selectedTopic].samples}
topic={TOPICS[selectedTopic].title}
defaultMode="normal"
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 提供情景推演和利弊分析,最终决策由老板做出。"
/>
{currentTopic && (
<ChatPanel
scene="decision"
samples={currentTopic.samples}
topic={currentTopic.title}
defaultMode="normal"
placeholder={`描述你的${currentTopic.title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 提供情景推演和利弊分析,最终决策由老板做出。"
/>
)}
</PageLayout>
)
}
+40 -2
View File
@@ -54,6 +54,7 @@ const DOC_TYPES = [
{ value: 'industry', label: '行业知识', icon: Factory },
{ value: 'emotion', label: '情绪疏导', icon: Heart },
{ value: 'life', label: '生活常识', icon: Coffee },
{ value: 'element_template', label: '要素表模板', icon: FileText },
]
/** 来源英文 → 中文映射 */
@@ -67,6 +68,8 @@ const SOURCE_LABELS: Record<string, string> = {
/** 法规知识库管理:7 类分类,录入、检索、删除 */
export function KnowledgeBase() {
const [docs, setDocs] = useState<KnowledgeDoc[]>([])
const [counts, setCounts] = useState<Record<string, number>>({})
const [totalCount, setTotalCount] = useState(0)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [searchQuery, setSearchQuery] = useState('')
@@ -93,6 +96,8 @@ export function KnowledgeBase() {
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 {
@@ -255,7 +260,7 @@ export function KnowledgeBase() {
size="sm"
onClick={() => setFilterType('all')}
>
全部
全部 <span className="ml-1 text-xs opacity-70">{totalCount}</span>
</Button>
{DOC_TYPES.map((t) => (
<Button
@@ -265,6 +270,7 @@ export function KnowledgeBase() {
onClick={() => setFilterType(t.value)}
>
<t.icon className="h-3.5 w-3.5" /> {t.label}
<span className="ml-1 text-xs opacity-70">{counts[t.value] || 0}</span>
</Button>
))}
</div>
@@ -305,7 +311,39 @@ export function KnowledgeBase() {
<Badge variant="outline">相关度 {doc.score}</Badge>
)}
</div>
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
{doc.doc_type !== 'element_template' && (
<p className="mt-2 text-sm text-muted-foreground">{doc.content}</p>
)}
{doc.doc_type === 'element_template' && (() => {
try {
const tmpl = JSON.parse(doc.content)
return (
<div className="mt-2 rounded border bg-muted/30 p-3 text-xs">
<p className="font-medium">案由:{tmpl.cause_of_action}</p>
<p className="mt-0.5 text-muted-foreground">适用:{tmpl.applicable}</p>
<p className="mt-2 font-medium">要素清单({tmpl.elements?.length || 0}项)</p>
<div className="mt-1 grid grid-cols-2 gap-x-4 gap-y-1 sm:grid-cols-3">
{tmpl.elements?.map((e: Record<string, string>, i: number) => (
<div key={i} className="flex items-center justify-between">
<span className="text-muted-foreground">{e.label}</span>
<span className="font-medium">{e.type}{e.required === '是' ? ' ·必填' : ''}</span>
</div>
))}
</div>
{tmpl.claims?.length > 0 && (
<>
<p className="mt-2 font-medium">诉讼请求</p>
<div className="mt-1 flex flex-wrap gap-1">
{tmpl.claims.map((c: string, i: number) => (
<Badge key={i} variant="outline" className="text-[10px]">{c}</Badge>
))}
</div>
</>
)}
</div>
)
} catch { return null }
})()}
<div className="mt-2 flex flex-wrap gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<BookOpen className="h-3 w-3" /> 来源:{SOURCE_LABELS[doc.source] || doc.source}
+95 -109
View File
@@ -1,136 +1,122 @@
import { useState } from 'react'
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, FileText, Coins, Users, Receipt, Gavel, ChevronDown } from 'lucide-react'
import { Briefcase, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
/** 法务话题:每个话题对应不同的示例问题 */
const TOPICS = [
{
icon: Briefcase,
title: '劳动用工',
desc: '工伤、解雇、工资、社保',
samples: [
'员工工伤后能否开除?',
'员工旷工三天怎么处理?',
'试用期不合格怎么辞退才合法?',
'加班费计算基数怎么确定?',
],
},
{
icon: FileText,
title: '合同审查',
desc: '违约金、付款条款、风险点',
samples: [
'这份合同付款条款有什么风险?',
'合同违约金怎么约定才合理?',
'对方提供的格式合同有哪些坑?',
'口头协议有法律效力吗?',
],
},
{
icon: Coins,
title: '债权债务',
desc: '催款、欠款、债务纠纷',
samples: [
'客户拖欠货款 3 个月如何催收?',
'欠款人跑了,钱还能要回来吗?',
'供应商要求先款后货,有风险吗?',
'债务超过诉讼时效怎么办?',
],
},
{
icon: Users,
title: '股东纠纷',
desc: '股权、分红、退股',
samples: [
'合伙人要退股,怎么谈条件?',
'小股东查账被拒绝怎么办?',
'股东会决议不执行怎么处理?',
'隐名股东权益如何保护?',
],
},
{
icon: Receipt,
title: '税务风险',
desc: '稽查、发票、合规',
samples: [
'收到税务稽查通知怎么办?',
'公司缺进项发票怎么解决?',
'个税汇算清缴有哪些风险点?',
'关联交易定价被质疑怎么办?',
],
},
{
icon: Gavel,
title: '诉讼仲裁',
desc: '起诉、应诉、执行',
samples: [
'官司打赢了对方不执行怎么办?',
'收到法院传票要不要请律师?',
'仲裁和诉讼哪个更适合?',
'诉讼时效快到了还能起诉吗?',
],
},
/** 话题类型 */
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<Topic[]>(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 (
<PageLayout
title="法务参谋"
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险。AI 输出不替代执业律师正式意见。"
description="劳动用工、合同审查、债权债务、股东纠纷、税务风险、诉讼仲裁。AI 输出不替代执业律师正式意见。"
>
<Card>
<CardHeader className="pb-3">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
法务话题
</button>
<div className="flex items-center justify-between">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
法务话题
</button>
<button
onClick={refreshTopics}
disabled={loadingTopics}
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
>
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
换一批
</button>
</div>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => (
<button
key={t.title}
onClick={() => 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',
)}
>
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
<div>
<p className="text-sm font-medium">{t.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
</div>
</button>
))}
</div>
{loadingTopics && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
AI 正在生成新话题…
</div>
)}
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
{topics.map((t, i) => (
<button
key={t.title + i}
onClick={() => 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',
)}
>
<Briefcase className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
<div>
<p className="text-sm font-medium">{t.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
</div>
</button>
))}
</div>
</CardContent>
)}
</Card>
<ChatPanel
scene="legal"
samples={TOPICS[selectedTopic].samples}
topic={TOPICS[selectedTopic].title}
defaultMode="confidential"
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard
enableUpload
footer="AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。重大风险事项请寻求执业专业人士复核。"
/>
{currentTopic && (
<ChatPanel
scene="legal"
samples={currentTopic.samples}
topic={currentTopic.title}
defaultMode="confidential"
placeholder={`描述你的${currentTopic.title}问题…`}
showRiskCard
enableUpload
footer="AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。重大风险事项请寻求执业专业人士复核。"
/>
)}
</PageLayout>
)
}
+95 -104
View File
@@ -1,130 +1,121 @@
import { useState } from 'react'
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, Users, Plane, Moon, GraduationCap, Stethoscope, ChevronDown } from 'lucide-react'
import { Heart, ChevronDown, RefreshCw, Loader2 } from 'lucide-react'
import { cn } from '@/lib/utils'
import { api } from '@/lib/api'
/** 话题定义:每个话题对应不同的示例问题 */
const TOPICS = [
{
icon: Users,
title: '家庭沟通',
desc: '伴侣、子女、父母关系',
samples: [
'怎么和青春期的孩子沟通不想他接班的事?',
'和伴侣在花钱观念上有分歧,怎么谈?',
'父母总是干涉经营决策,怎么温和地划清边界?',
],
},
{
icon: GraduationCap,
title: '子女教育',
desc: '升学、留学、职业方向',
samples: [
'孩子中考成绩一般,选普高还是国际学校?',
'要不要送孩子出国留学?什么阶段出去最合适?',
'孩子想学艺术,但担心就业,该怎么引导?',
],
},
{
icon: Heart,
title: '父母养老',
desc: '照护方案、医疗安排',
samples: [
'父母年纪大了,居家养老还是去养老机构?',
'父母需要长期服药,怎么管理用药和复诊?',
'怎么和兄弟姐妹分担父母的养老责任?',
],
},
{
icon: Moon,
title: '睡眠与压力',
desc: '身心状态管理',
samples: [
'最近睡眠很差,有什么改善建议?',
'经营压力大,经常焦虑,怎么自我调节?',
'长期高强度工作,怎么避免职业倦怠?',
],
},
{
icon: Plane,
title: '出差日程',
desc: '行程、商务礼仪',
samples: [
'下周出差三天,怎么安排最高效?',
'第一次见重要客户,商务礼仪要注意什么?',
'出差期间公司有事,怎么远程处理不耽误?',
],
},
{
icon: Stethoscope,
title: '健康习惯',
desc: '体检、运动、饮食',
samples: [
'体检报告有几项异常,需要进一步检查吗?',
'久坐办公,每天多少运动量才够?',
'应酬多,怎么调整饮食减少对身体的负担?',
],
},
/** 话题类型 */
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<Topic[]>(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 (
<PageLayout
title="老板生活助理"
description="家庭、健康、出差、重要事项。帮你处理经营之外的事,把精力留给关键决策。"
title="生活助理"
description="家庭沟通、子女教育、父母养老、健康习惯、出行规划。老板的生活问题,也值得认真对待。"
>
<Card>
<CardHeader className="pb-3">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
支持话题
</button>
<div className="flex items-center justify-between">
<button
onClick={() => setTopicsCollapsed(!topicsCollapsed)}
className="flex items-center gap-1.5 text-base font-semibold transition-opacity hover:opacity-70"
>
<ChevronDown className={cn('h-4 w-4 transition-transform', topicsCollapsed && '-rotate-90')} />
生活话题
</button>
<button
onClick={refreshTopics}
disabled={loadingTopics}
className="flex items-center gap-1 text-xs text-muted-foreground transition-opacity hover:opacity-70 disabled:opacity-50"
>
{loadingTopics ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <RefreshCw className="h-3.5 w-3.5" />}
换一批
</button>
</div>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => (
<button
key={t.title}
onClick={() => 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',
)}
>
<t.icon className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
<div>
<p className="text-sm font-medium">{t.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
</div>
</button>
))}
</div>
{loadingTopics && (
<div className="flex items-center justify-center gap-2 py-6 text-sm text-muted-foreground">
<Loader2 className="h-4 w-4 animate-spin" />
AI 正在生成新话题…
</div>
)}
<div className={cn('grid gap-3 sm:grid-cols-2 lg:grid-cols-3', loadingTopics && 'pointer-events-none opacity-50')}>
{topics.map((t, i) => (
<button
key={t.title + i}
onClick={() => 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',
)}
>
<Heart className="mt-0.5 h-5 w-5 shrink-0 text-primary" aria-hidden />
<div>
<p className="text-sm font-medium">{t.title}</p>
<p className="mt-0.5 text-xs text-muted-foreground">{t.desc}</p>
</div>
</button>
))}
</div>
</CardContent>
)}
</Card>
<ChatPanel
scene="life"
samples={TOPICS[selectedTopic].samples}
topic={TOPICS[selectedTopic].title}
defaultMode="confidential"
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 输出仅供参考,重大事项请咨询相关领域专业人士。"
/>
{currentTopic && (
<ChatPanel
scene="life"
samples={currentTopic.samples}
topic={currentTopic.title}
defaultMode="confidential"
placeholder={`描述你的${currentTopic.title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 输出仅供参考,重大事项请咨询相关领域专业人士。"
/>
)}
</PageLayout>
)
}
+271 -43
View File
@@ -60,6 +60,213 @@ const RISK_LABELS: Record<string, string> = {
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: '法务参谋',
@@ -87,9 +294,24 @@ function formatTime(dt: string): string {
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[]>([])
@@ -136,45 +358,48 @@ export function MyProfile() {
<Card>
<CardContent className="p-5">
<div className="space-y-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-2">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{formatValue(key, val)}</span>
</div>
)
})}
{/* 标准字段:多列网格 */}
<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.5 text-sm text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1.5">
<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-xs">{String(t)}</Badge>
<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.5 text-sm text-muted-foreground">关键事实</p>
<div className="space-y-1">
<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">{k}</span>
<span className="font-medium">{String(v)}</span>
<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>
<p className="mb-1.5 text-sm text-muted-foreground">常用场景</p>
<div className="flex flex-wrap gap-1.5">
<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-xs">
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
{SCENE_LABELS[String(s)] || String(s)}
</Badge>
))}
@@ -206,23 +431,26 @@ export function MyProfile() {
</div>
<div className="space-y-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-2">
<span className="text-sm text-muted-foreground">{label}</span>
<span className="text-sm font-medium">{formatValue(key, val)}</span>
</div>
)
})}
{/* 标准字段:多列网格 */}
<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.5 text-sm text-muted-foreground">常问话题</p>
<div className="flex flex-wrap gap-1.5">
<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-xs">{t}</Badge>
<Badge key={i} variant="secondary" className="text-[10px] px-1 py-0">{t}</Badge>
))}
</div>
</div>
@@ -230,12 +458,12 @@ export function MyProfile() {
{profile.key_facts && Object.keys(profile.key_facts).length > 0 && (
<div className="border-b pb-2">
<p className="mb-1.5 text-sm text-muted-foreground">关键事实</p>
<div className="space-y-1">
<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">{k}</span>
<span className="font-medium">{v}</span>
<span className="text-muted-foreground">{translateFactKey(k)}</span>
<span className="font-medium">{translateFactValue(v)}</span>
</div>
))}
</div>
@@ -243,11 +471,11 @@ export function MyProfile() {
)}
{profile.common_scenes && profile.common_scenes.length > 0 && (
<div>
<p className="mb-1.5 text-sm text-muted-foreground">常用场景</p>
<div className="flex flex-wrap gap-1.5">
<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-xs">
<Badge key={i} variant="outline" className="text-[10px] px-1 py-0">
{SCENE_LABELS[s] || s}
</Badge>
))}
@@ -256,7 +484,7 @@ export function MyProfile() {
)}
{profile.last_active && (
<p className="pt-2 text-xs text-muted-foreground">
<p className="text-xs text-muted-foreground">
最后活跃:{formatTime(profile.last_active)}
</p>
)}