feat: 品牌更名、用户管理增强、画像版本管理、对话体验优化
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

- 品牌统一为"左右(BossAi)":网页标签、侧边栏、登录页、manifest、后端
- 用户管理:搜索、对话统计、重置密码、激活/停用账号
- 用户画像:确认事实步骤(带入内容+追问内容)、版本历史管理、PUT /profile/me
- 对话历史:按场景分组、保密模式标题处理
- AI助手页面:话题区可收起、模式选择紧凑化、footer免责声明
- 设置页:toggle宽度修复、工单文案改为专家咨询
- 去掉工单功能(前后端)

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 09:05:31 +08:00
co-authored by Devin
parent 038ab17278
commit 4e2b4666aa
24 changed files with 1623 additions and 333 deletions
+115 -42
View File
@@ -9,11 +9,14 @@ import {
Shield,
ChevronRight,
ArrowLeft,
BookOpen,
Sparkles,
} 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 { Markdown } from '@/components/common/Markdown'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
@@ -46,6 +49,15 @@ const RISK_COLORS: Record<string, string> = {
red: 'bg-red-100 text-red-700',
}
/** 知识库文档类型标签映射 */
const DOC_TYPE_LABELS: Record<string, string> = {
law: '法规',
case: '案例',
guide: '指南',
industry: '行业',
template: '模板',
}
/** 场景中文映射 */
const SCENE_LABELS: Record<string, string> = {
legal: '法务参谋',
@@ -202,18 +214,19 @@ export function ChatHistory() {
<div className="mb-4 flex items-center gap-3 border-b pb-3">
<modeCfg.icon className="h-5 w-5 text-muted-foreground" />
<div className="flex-1">
<div className="flex items-center gap-2">
<span className="font-medium">{modeCfg.label}</span>
<p className="font-medium">{currentSession.title || '未命名对话'}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
{currentSession.scene && (
<Badge variant="secondary" className="text-xs">
{SCENE_LABELS[currentSession.scene] || currentSession.scene}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
{' · '}{currentSession.message_count} 条消息
</span>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{formatTime(currentSession.first_message_at)} ~ {formatTime(currentSession.last_message_at)}
{' · '}{currentSession.message_count} 条消息
</p>
</div>
</div>
@@ -315,40 +328,65 @@ export function ChatHistory() {
description="普通模式和保密模式的对话会保存在这里,阅后即焚不落库"
/>
) : (
<div className="space-y-2">
{sessions.map((session) => {
const modeCfg = MODE_CONFIG[session.mode]
return (
<Card
key={session.session_id}
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() => handleView(session)}
>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', modeCfg.color)}>
<modeCfg.icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-medium">{modeCfg.label}</span>
{session.scene && (
<Badge variant="secondary" className="text-xs">
{SCENE_LABELS[session.scene] || session.scene}
</Badge>
)}
<span className="text-xs text-muted-foreground">
{session.message_count} 条
</span>
</div>
<p className="mt-0.5 truncate text-xs text-muted-foreground">
{formatTime(session.last_message_at)}
</p>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</CardContent>
</Card>
)
})}
<div className="space-y-6">
{/* 按场景分组,每组内按时间倒序 */}
{(() => {
// 定义场景顺序
const SCENE_ORDER = ['legal', 'decision', 'life', 'emotion']
// 按 scene 分组(无 scene 归为"其他")
const groups = new Map<string, HistorySession[]>()
for (const s of sessions) {
const key = s.scene || 'other'
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(s)
}
// 每组内按 last_message_at 倒序
for (const items of groups.values()) {
items.sort((a, b) => {
const ta = a.last_message_at ? new Date(a.last_message_at).getTime() : 0
const tb = b.last_message_at ? new Date(b.last_message_at).getTime() : 0
return tb - ta
})
}
// 渲染:按 SCENE_ORDER 顺序,最后是 other
const orderedKeys = [...SCENE_ORDER.filter(k => groups.has(k)), ...['other']].filter(k => groups.has(k))
return orderedKeys.map((key) => (
<div key={key}>
<p className="mb-2 px-1 text-xs font-medium uppercase tracking-wider text-muted-foreground/70">
{SCENE_LABELS[key] || '其他'}
<span className="ml-1.5 text-muted-foreground/50">{groups.get(key)!.length}</span>
</p>
<div className="space-y-2">
{groups.get(key)!.map((session) => {
const modeCfg = MODE_CONFIG[session.mode]
return (
<Card
key={session.session_id}
className="cursor-pointer transition-colors hover:bg-accent/50"
onClick={() => handleView(session)}
>
<CardContent className="flex items-center gap-3 p-4">
<div className={cn('flex h-10 w-10 items-center justify-center rounded-lg', modeCfg.color)}>
<modeCfg.icon className="h-5 w-5" />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{session.title || '未命名对话'}</p>
<div className="mt-0.5 flex flex-wrap items-center gap-2">
<span className="text-xs text-muted-foreground">{modeCfg.label}</span>
<span className="text-xs text-muted-foreground">
{session.message_count} 条 · {formatTime(session.last_message_at)}
</span>
</div>
</div>
<ChevronRight className="h-4 w-4 text-muted-foreground" />
</CardContent>
</Card>
)
})}
</div>
</div>
))
})()}
</div>
)}
@@ -369,8 +407,12 @@ function MessageBubble({ message }: { message: HistoryMessage }) {
return (
<div className={cn('flex', isUser ? 'justify-end' : 'justify-start')}>
<div className={cn('max-w-[80%] rounded-lg p-3', isUser ? 'bg-primary text-primary-foreground' : 'bg-muted')}>
{/* 消息正文 */}
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
{/* 消息正文:用户消息纯文本,AI 消息 Markdown 渲染 */}
{isUser ? (
<p className="whitespace-pre-wrap text-sm">{message.content}</p>
) : (
<Markdown content={message.content} className="text-sm" />
)}
{/* AI 回复的风险卡信息 */}
{!isUser && riskLevel && (
@@ -398,6 +440,37 @@ function MessageBubble({ message }: { message: HistoryMessage }) {
</div>
)}
{/* 参考来源:知识库引用 tag 或 AI 提供 tag */}
{!isUser && (
<div className="mt-2 border-t border-border/30 pt-2">
<div className="mb-1 flex items-center gap-1 text-[10px] text-muted-foreground">
<BookOpen className="h-3 w-3" /> 参考来源
</div>
{message.meta?.rag_context && message.meta.rag_context.length > 0 ? (
<div className="space-y-1">
{message.meta.rag_context.map((doc, i) => (
<div key={i} className="flex flex-wrap items-center gap-1 text-xs">
<span className="font-medium">{doc.title}</span>
{doc.doc_type && (
<Badge variant="secondary" className="text-[10px]">
{DOC_TYPE_LABELS[doc.doc_type] || doc.doc_type}
</Badge>
)}
{doc.region && (
<Badge variant="outline" className="text-[10px]">{doc.region}</Badge>
)}
</div>
))}
</div>
) : (
<div className="flex items-center gap-1 text-xs text-muted-foreground">
<Sparkles className="h-3 w-3" />
<Badge variant="outline" className="text-[10px]">AI 提供</Badge>
</div>
)}
</div>
)}
{/* 时间戳 */}
<p className={cn('mt-1 text-[10px]', isUser ? 'text-primary-foreground/60' : 'text-muted-foreground')}>
{formatTime(message.created_at)}
+13 -31
View File
@@ -1,17 +1,10 @@
import { useState } from 'react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Lightbulb, Scale, TrendingUp, Wallet, Store, Handshake, Tag, UserPlus, Repeat } from 'lucide-react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Wallet, Store, Handshake, Tag, UserPlus, Repeat, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const FRAMEWORK = [
{ icon: TrendingUp, title: '预期收益', desc: '各方案的收益估算与时间线' },
{ icon: Scale, title: '主要风险', desc: '每个方案的核心风险点' },
{ icon: Wallet, title: '现金流影响', desc: '对短期和长期现金流的冲击' },
{ icon: Lightbulb, title: '推荐程度', desc: '综合评估后的推荐等级' },
]
/** 决策话题:每个话题对应不同的示例问题 */
const TOPICS = [
{
@@ -85,6 +78,7 @@ const TOPICS = [
/** 经营决策助手:情景推演、方案对比、收益/风险/现金流/推荐程度 */
export function Decisions() {
const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
return (
<PageLayout
@@ -93,25 +87,15 @@ export function Decisions() {
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">决策推演框架</CardTitle>
</CardHeader>
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
{FRAMEWORK.map((f) => (
<div key={f.title} className="rounded-md border p-3">
<f.icon className="mb-2 h-5 w-5 text-primary" aria-hidden />
<p className="text-sm font-medium">{f.title}</p>
<p className="mt-1 text-xs text-muted-foreground">{f.desc}</p>
</div>
))}
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">决策话题</CardTitle>
<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>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => (
@@ -134,6 +118,7 @@ export function Decisions() {
))}
</div>
</CardContent>
)}
</Card>
<ChatPanel
@@ -144,11 +129,8 @@ export function Decisions() {
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 提供情景推演和利弊分析,最终决策由老板做出。"
/>
<p className="text-center text-xs text-muted-foreground">
AI 提供情景推演和利弊分析,最终决策由老板做出。
</p>
</PageLayout>
)
}
+13 -7
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { Brain, HeartHandshake, Zap, Phone } from 'lucide-react'
import { Brain, HeartHandshake, Zap, Phone, ChevronDown } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { cn } from '@/lib/utils'
import type { EmotionMode } from '@/types'
@@ -48,6 +48,7 @@ const MODES: { value: EmotionMode; label: string; desc: string; icon: typeof Bra
/** 情绪价值引擎:三模式切换 + 危机干预入口 */
export function EmotionCenter() {
const [emotionMode, setEmotionMode] = useState<EmotionMode>('calm')
const [modesCollapsed, setModesCollapsed] = useState(false)
return (
<PageLayout
@@ -70,8 +71,15 @@ export function EmotionCenter() {
{/* 情绪模式选择器 */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">回复模式</CardTitle>
<button
onClick={() => setModesCollapsed(!modesCollapsed)}
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', modesCollapsed && '-rotate-90')} />
回复模式
</button>
</CardHeader>
{!modesCollapsed && (
<CardContent>
<div className="grid gap-3 sm:grid-cols-3">
{MODES.map((m) => (
@@ -92,6 +100,7 @@ export function EmotionCenter() {
))}
</div>
</CardContent>
)}
</Card>
<ChatPanel
@@ -103,11 +112,8 @@ export function EmotionCenter() {
placeholder="说说你现在的感受…"
showRiskCard={false}
enableUpload={false}
footer="情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。"
/>
<p className="text-center text-xs text-muted-foreground">
情绪对话默认使用阅后即焚模式,退出后不留痕迹。传统文化内容仅供反思与陪伴。
</p>
</PageLayout>
)
}
+13 -7
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Briefcase, FileText, Coins, Users, Receipt, Gavel } from 'lucide-react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Briefcase, FileText, Coins, Users, Receipt, Gavel, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
/** 法务话题:每个话题对应不同的示例问题 */
@@ -78,6 +78,7 @@ const TOPICS = [
/** 法务参谋:劳动用工、合同审查、债权债务、股东纠纷、税务风险 */
export function LegalAdvisor() {
const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
return (
<PageLayout
@@ -86,8 +87,15 @@ export function LegalAdvisor() {
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">法务话题</CardTitle>
<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>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => (
@@ -110,6 +118,7 @@ export function LegalAdvisor() {
))}
</div>
</CardContent>
)}
</Card>
<ChatPanel
@@ -120,11 +129,8 @@ export function LegalAdvisor() {
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard
enableUpload
footer="AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。重大风险事项请寻求执业专业人士复核。"
/>
<p className="text-center text-xs text-muted-foreground">
AI 输出仅供参考,不构成律师、会计师、税务师或医生的正式意见。
重大风险事项请寻求执业专业人士复核。
</p>
</PageLayout>
)
}
+13 -3
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { PageLayout } from '@/components/common/PageLayout'
import { ChatPanel } from '@/components/common/ChatPanel'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Heart, Users, Plane, Moon, GraduationCap, Stethoscope } from 'lucide-react'
import { Card, CardContent, CardHeader } from '@/components/ui/card'
import { Heart, Users, Plane, Moon, GraduationCap, Stethoscope, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
/** 话题定义:每个话题对应不同的示例问题 */
@@ -72,6 +72,7 @@ const TOPICS = [
/** 老板生活助理:降低非经营决策负担 */
export function LifeAssistant() {
const [selectedTopic, setSelectedTopic] = useState(0)
const [topicsCollapsed, setTopicsCollapsed] = useState(false)
return (
<PageLayout
@@ -80,8 +81,15 @@ export function LifeAssistant() {
>
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">支持话题</CardTitle>
<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>
</CardHeader>
{!topicsCollapsed && (
<CardContent>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{TOPICS.map((t, i) => (
@@ -104,6 +112,7 @@ export function LifeAssistant() {
))}
</div>
</CardContent>
)}
</Card>
<ChatPanel
@@ -114,6 +123,7 @@ export function LifeAssistant() {
placeholder={`描述你的${TOPICS[selectedTopic].title}问题…`}
showRiskCard={false}
enableUpload={false}
footer="AI 输出仅供参考,重大事项请咨询相关领域专业人士。"
/>
</PageLayout>
)
+4 -1
View File
@@ -9,6 +9,7 @@ import { Label } from '@/components/ui/label'
import { toast } from 'sonner'
import { Loader2, ShieldCheck, UserCog, Briefcase, Shield, User } from 'lucide-react'
import { cn } from '@/lib/utils'
import { Toaster } from '@/components/ui/sonner'
/** 角色配置 */
const ROLES: Array<{
@@ -130,7 +131,7 @@ export function Login() {
<ShieldCheck className="h-6 w-6 text-primary" />
</div>
<CardTitle className="text-xl">
{mode === 'login' ? '登录 BossAi' : '注册 BossAi'}
{mode === 'login' ? '登录 左右(BossAi)' : '注册 左右(BossAi)'}
</CardTitle>
<p className="text-sm text-muted-foreground">
{currentRole.desc}
@@ -229,6 +230,8 @@ export function Login() {
)}
</CardContent>
</Card>
{/* Login 页在 AppLayout 之外,需自带 Toaster 才能显示 toast */}
<Toaster />
</div>
)
}
+316
View File
@@ -0,0 +1,316 @@
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 } 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 SCENE_LABELS: Record<string, string> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
/** 来源映射 */
const SOURCE_LABELS: Record<string, string> = {
auto: 'AI 自动提取',
manual: '用户确认',
}
/** 格式化时间 */
function formatTime(dt: string): string {
if (!dt) return '-'
return new Date(dt).toLocaleString('zh-CN', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
})
}
/** 格式化字段值 */
function formatValue(key: string, value: string): string {
if (key === 'company_size') return SIZE_LABELS[value] || value
if (key === 'risk_tolerance') return RISK_LABELS[value] || value
return 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">
{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>
)
})}
{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">
{(hp.common_topics as string[]).map((t, i) => (
<Badge key={i} variant="secondary" className="text-xs">{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">
{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>
</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">
{(hp.common_scenes as string[]).map((s, i) => (
<Badge key={i} variant="outline" className="text-xs">
{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">
{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>
)
})}
{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">
{profile.common_topics.map((t, i) => (
<Badge key={i} variant="secondary" className="text-xs">{t}</Badge>
))}
</div>
</div>
)}
{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">
{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>
</div>
))}
</div>
</div>
)}
{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">
{profile.common_scenes.map((s, i) => (
<Badge key={i} variant="outline" className="text-xs">
{SCENE_LABELS[s] || s}
</Badge>
))}
</div>
</div>
)}
{profile.last_active && (
<p className="pt-2 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 className="h-8 w-8" />}
/>
) : (
<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>
)
}
+5 -5
View File
@@ -41,7 +41,7 @@ const SETTING_GROUPS = [
items: [
{ key: 'long_term_memory', label: '长期记忆', desc: '记住你的压力来源、性格和决策风格,提供个性化建议。可随时关闭', icon: Database, danger: false },
{ key: 'knowledge_base_write', label: '企业知识库写入', desc: '允许将脱敏内容写入企业知识库,供团队复用', icon: Database, danger: false },
{ key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, BossAi 不采集训练数据', icon: Info, danger: false },
{ key: 'training_data_collection', label: '模型训练数据采集', desc: '允许使用你的对话数据改进模型。默认关闭, 左右(BossAi) 不采集训练数据', icon: Info, danger: false },
],
},
{
@@ -60,8 +60,8 @@ const SETTING_GROUPS = [
items: [
{ key: 'daily_reminder', label: '每日经营提醒', desc: '每天早上推送今天最该做的一件事', icon: Bell, danger: false },
{ key: 'risk_alert', label: '风险预警', desc: '检测到合同到期、应收逾期、员工试用期届满等风险时主动提醒', icon: Shield, danger: false },
{ key: 'auto_escalate_red', label: '红色风险自动升级', desc: '检测到红色风险时自动创建复核工单,无需手动操作', icon: Shield, danger: false },
{ key: 'auto_escalate_orange', label: '橙色风险自动升级', desc: '检测到橙色风险时自动创建复核工单(默认手动)', icon: Shield, danger: false },
{ key: 'auto_escalate_red', label: '红色风险自动提醒', desc: '检测到红色风险时自动提醒用户咨询专家,无需手动操作', icon: Shield, danger: false },
{ key: 'auto_escalate_orange', label: '橙色风险自动提醒', desc: '检测到橙色风险时自动提醒用户咨询专家(默认手动)', icon: Shield, danger: false },
],
},
]
@@ -172,8 +172,8 @@ export function Settings() {
>
<span
className={cn(
'absolute top-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
on ? 'translate-x-5' : 'translate-x-0.5',
'absolute top-0.5 left-0.5 h-5 w-5 rounded-full bg-white shadow transition-transform',
on ? 'translate-x-5' : 'translate-x-0',
)}
/>
</button>
+308 -25
View File
@@ -1,48 +1,331 @@
import { useEffect, useState } from 'react'
import { Users } from 'lucide-react'
import { useEffect, useState, useCallback } from 'react'
import { toast } from 'sonner'
import { Users, Search, MessageSquare, Clock, KeyRound, Ban, CheckCircle, Loader2 } 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 { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { api } from '@/lib/api'
import { cn } from '@/lib/utils'
/** 用户列表项(含统计) */
interface AdminUser {
id: number
phone: string
name: string
status: string
created_at: string
message_count: number
last_active: string | null
last_scene: string | null
}
/** 场景中文映射 */
const SCENE_LABELS: Record<string, string> = {
legal: '法务参谋',
decision: '经营决策',
life: '生活助理',
emotion: '情绪中心',
}
/** 格式化时间 */
function formatTime(dt: string | null): string {
if (!dt) return '从未使用'
return new Date(dt).toLocaleString('zh-CN', {
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
/** 管理后台 - 用户管理:列出所有注册用户 */
export function AdminUsers() {
const [users, setUsers] = useState<Array<{ id: number; phone: string; name: string; created_at: string }>>([])
const [users, setUsers] = useState<AdminUser[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [search, setSearch] = useState('')
const [searching, setSearching] = useState(false)
useEffect(() => {
api.adminListUsers()
.then(res => setUsers(res.results))
.catch(() => {})
.finally(() => setLoading(false))
// 重置密码弹窗
const [resetTarget, setResetTarget] = useState<AdminUser | null>(null)
const [newPassword, setNewPassword] = useState('')
const [resetting, setResetting] = useState(false)
// 状态切换弹窗
const [statusTarget, setStatusTarget] = useState<AdminUser | null>(null)
const [statusUpdating, setStatusUpdating] = useState(false)
const load = useCallback(async (q?: string) => {
setLoading(true)
setError(null)
try {
const res = await api.adminListUsers(q)
setUsers(res.results as AdminUser[])
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
setSearching(false)
}
}, [])
if (loading) {
return (
<PageLayout title="用户管理" description="平台注册用户列表">
<LoadingState />
</PageLayout>
)
useEffect(() => { load() }, [load])
/** 搜索(防抖) */
useEffect(() => {
if (search === undefined) return
const timer = setTimeout(() => {
if (search !== '') {
setSearching(true)
load(search)
} else {
load()
}
}, 400)
return () => clearTimeout(timer)
}, [search]) // eslint-disable-line react-hooks/exhaustive-deps
/** 确认重置密码 */
const handleResetPassword = async () => {
if (!resetTarget || !newPassword.trim()) return
if (newPassword.length < 6) {
toast.error('密码至少 6 位')
return
}
setResetting(true)
try {
await api.adminResetPassword(resetTarget.id, newPassword)
toast.success(`已重置 ${resetTarget.name || resetTarget.phone} 的密码`)
setResetTarget(null)
setNewPassword('')
load(search || undefined)
} catch (e) {
toast.error(e instanceof Error ? e.message : '重置失败')
} finally {
setResetting(false)
}
}
/** 确认激活/停用 */
const handleToggleStatus = async () => {
if (!statusTarget) return
const newStatus = statusTarget.status === 'active' ? 'inactive' : 'active'
setStatusUpdating(true)
try {
await api.adminUpdateUserStatus(statusTarget.id, newStatus)
toast.success(`已${newStatus === 'active' ? '激活' : '停用'} ${statusTarget.name || statusTarget.phone}`)
setStatusTarget(null)
load(search || undefined)
} catch (e) {
toast.error(e instanceof Error ? e.message : '操作失败')
} finally {
setStatusUpdating(false)
}
}
/** 统计概览 */
const totalUsers = users.length
const activeUsers = users.filter(u => u.status === 'active' && u.message_count > 0).length
const inactiveUsers = users.filter(u => u.status === 'inactive').length
const totalMessages = users.reduce((sum, u) => sum + u.message_count, 0)
return (
<PageLayout title="用户管理" description="平台注册用户列表">
{users.length === 0 ? (
<EmptyState title="暂无用户" icon={<Users className="h-8 w-8" />} />
<PageLayout
title="用户管理"
description="平台注册用户列表,含对话统计、活跃度和账号管理"
actions={
<div className="relative">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="搜索手机号或昵称..."
className="h-8 w-48 pl-8 text-xs"
/>
</div>
}
>
{/* 统计概览 */}
<div className="mb-4 grid grid-cols-4 gap-3">
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">总用户数</p>
<p className="mt-1 text-xl font-semibold">{totalUsers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">活跃用户</p>
<p className="mt-1 text-xl font-semibold text-emerald-600">{activeUsers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">已停用</p>
<p className="mt-1 text-xl font-semibold text-rose-600">{inactiveUsers}</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-3">
<p className="text-xs text-muted-foreground">总对话数</p>
<p className="mt-1 text-xl font-semibold">{totalMessages}</p>
</CardContent>
</Card>
</div>
{loading ? (
<LoadingState label={searching ? '搜索中...' : '加载用户...'} />
) : error ? (
<ErrorState message={error} onRetry={() => load(search || undefined)} />
) : users.length === 0 ? (
<EmptyState
title={search ? '未找到匹配用户' : '暂无用户'}
description={search ? '尝试其他关键词' : '注册用户将显示在这里'}
icon={<Users className="h-8 w-8" />}
/>
) : (
<div className="space-y-2">
{users.map((u) => (
<Card key={u.id}><CardContent className="flex items-center justify-between py-3">
<div>
<p className="text-sm font-medium">{u.name || '未设置昵称'}</p>
<p className="text-xs text-muted-foreground">{u.phone}</p>
</div>
<p className="text-xs text-muted-foreground">注册:{u.created_at}</p>
</CardContent></Card>
<Card key={u.id} className={u.status === 'inactive' ? 'opacity-60' : ''}>
<CardContent className="p-4">
<div className="flex items-center gap-3">
{/* 头像占位 */}
<div className={cn(
'flex h-10 w-10 shrink-0 items-center justify-center rounded-full text-sm font-medium',
u.status === 'inactive'
? 'bg-muted text-muted-foreground'
: u.message_count > 0
? 'bg-primary/10 text-primary'
: 'bg-muted text-muted-foreground',
)}>
{(u.name || u.phone.slice(-2)).slice(0, 2)}
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{u.name || '未设置昵称'}</p>
<span className="truncate text-xs text-muted-foreground">{u.phone}</span>
</div>
<div className="mt-1 flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<MessageSquare className="h-3 w-3" />
{u.message_count} 条对话
</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(u.last_active)}
</span>
{u.last_scene && (
<Badge variant="secondary" className="text-[10px]">
{SCENE_LABELS[u.last_scene] || u.last_scene}
</Badge>
)}
<span>注册:{formatTime(u.created_at)}</span>
</div>
</div>
{/* 状态 Badge */}
<Badge
variant={u.status === 'active' ? 'default' : 'destructive'}
className="shrink-0 text-xs"
>
{u.status === 'active' ? '正常' : '已停用'}
</Badge>
</div>
{/* 操作按钮 */}
<div className="mt-3 flex flex-wrap gap-2 border-t pt-3">
<Button
size="sm"
variant="outline"
onClick={() => { setResetTarget(u); setNewPassword('') }}
>
<KeyRound className="h-3.5 w-3.5" /> 重置密码
</Button>
{u.status === 'active' ? (
<Button
size="sm"
variant="outline"
className="text-rose-600 hover:bg-rose-50"
onClick={() => setStatusTarget(u)}
>
<Ban className="h-3.5 w-3.5" /> 停用账号
</Button>
) : (
<Button
size="sm"
variant="outline"
className="text-emerald-600 hover:bg-emerald-50"
onClick={() => setStatusTarget(u)}
>
<CheckCircle className="h-3.5 w-3.5" /> 激活账号
</Button>
)}
</div>
</CardContent>
</Card>
))}
</div>
)}
{/* 重置密码弹窗 */}
<Dialog open={!!resetTarget} onOpenChange={(o) => !o && setResetTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>重置密码</DialogTitle>
<DialogDescription>
为用户「{resetTarget?.name || resetTarget?.phone}」设置新密码,用户需用新密码登录。
</DialogDescription>
</DialogHeader>
<div className="space-y-2">
<Label htmlFor="new-password">新密码(至少 6 位)</Label>
<Input
id="new-password"
type="password"
value={newPassword}
onChange={(e) => setNewPassword(e.target.value)}
placeholder="输入新密码..."
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setResetTarget(null)}>取消</Button>
<Button onClick={handleResetPassword} disabled={resetting || newPassword.length < 6}>
{resetting ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <KeyRound className="h-3.5 w-3.5" />}
确认重置
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 激活/停用确认弹窗 */}
<Dialog open={!!statusTarget} onOpenChange={(o) => !o && setStatusTarget(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>
{statusTarget?.status === 'active' ? '停用账号' : '激活账号'}
</DialogTitle>
<DialogDescription>
{statusTarget?.status === 'active'
? `确认停用用户「${statusTarget?.name || statusTarget?.phone}」?停用后该用户将无法登录。`
: `确认激活用户「${statusTarget?.name || statusTarget?.phone}」?激活后该用户可正常登录。`
}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setStatusTarget(null)}>再想想</Button>
<Button
variant={statusTarget?.status === 'active' ? 'destructive' : 'default'}
onClick={handleToggleStatus}
disabled={statusUpdating}
>
{statusUpdating ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
确认{statusTarget?.status === 'active' ? '停用' : '激活'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageLayout>
)
}