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

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

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
selfrelease
2026-08-31 08:38:33 +08:00
co-authored by Devin
parent 7a788bf8dc
commit 4de36547cd
88 changed files with 15237 additions and 62 deletions
+5 -3
View File
@@ -717,8 +717,10 @@ export function ChatPanel({
>
<div
className={cn(
'max-w-[80%] rounded-lg p-3 text-sm',
msg.role === 'user' ? 'bg-primary text-primary-foreground' : 'bg-muted',
'rounded-lg p-3 text-sm',
msg.role === 'user'
? 'max-w-[80%] bg-primary text-primary-foreground'
: 'max-w-[95%] bg-muted sm:max-w-[85%]',
)}
>
{msg.risk ? (
@@ -1067,7 +1069,7 @@ export function ChatPanel({
{/* 专家咨询弹窗 */}
<Dialog open={expertConsultOpen} onOpenChange={setExpertConsultOpen}>
<DialogContent className="max-w-xl">
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Users className="h-4 w-4 text-primary" /> 发给专家咨询
+6 -5
View File
@@ -1,8 +1,8 @@
import { useState } from 'react'
import { createPortal } from 'react-dom'
import { useLocation, useNavigate } from 'react-router-dom'
import { Menu, X, ShieldCheck, LogOut } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { clearAuth, getName, getPhone } from '@/lib/auth'
import { toast } from 'sonner'
@@ -65,11 +65,11 @@ export function Header({ nav }: { nav: React.ReactNode }) {
</div>
</div>
{/* 移动端抽屉 */}
{open && (
{/* 移动端抽屉 - 用 Portal 渲染到 body,避免 header backdrop-blur 影响 */}
{open && createPortal(
<div className="fixed inset-0 z-50 md:hidden">
<div className="absolute inset-0 bg-black/60" onClick={() => setOpen(false)} />
<div className={cn('absolute left-0 top-0 h-full w-60 bg-card p-3 shadow-lg')}>
<div className="absolute left-0 top-0 h-full w-60 border-r bg-white p-3 shadow-xl">
<div className="flex h-11 items-center justify-between px-2">
<span className="font-semibold">左右(BossAi)</span>
<Button variant="ghost" size="icon" aria-label="关闭菜单" onClick={() => setOpen(false)}>
@@ -78,7 +78,8 @@ export function Header({ nav }: { nav: React.ReactNode }) {
</div>
<div onClick={() => setOpen(false)}>{nav}</div>
</div>
</div>
</div>,
document.body,
)}
</header>
)
+1 -1
View File
@@ -78,7 +78,7 @@ export function Sidebar() {
}
return (
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 md:border-r md:bg-card">
<aside className="hidden md:flex md:w-60 md:flex-col md:fixed md:inset-y-0 md:border-r md:bg-white">
<div className="flex h-14 items-center gap-2 border-b px-5">
<ShieldCheck className="h-5 w-5 text-primary" aria-hidden />
<span className="font-semibold tracking-tight">左右(BossAi)</span>
+1 -1
View File
@@ -32,7 +32,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg',
'fixed left-[50%] top-[50%] z-50 flex w-[calc(100vw-1.5rem)] max-w-lg translate-x-[-50%] translate-y-[-50%] flex-col gap-4 overflow-x-hidden overflow-y-auto border bg-background p-4 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 sm:rounded-lg sm:p-6',
className,
)}
{...props}
+32
View File
@@ -5,3 +5,35 @@ import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** 将后端时间字符串(UTC 或带时区)格式化为北京时间(Asia/Shanghai)
*
* 后端 PostgreSQL 存储 UTC 时间,返回的字符串可能为:
* - "2026-08-30T03:46:00+00:00" (带时区)
* - "2026-08-30 03:46:00" (无时区,按 UTC 处理)
* - "2026-08-30T03:46:00" (无时区,按 UTC 处理)
*
* 统一转换为北京时间显示。
*/
export function formatTime(dt: string | null | undefined): string {
if (!dt) return '-'
const d = new Date(dt)
if (isNaN(d.getTime())) return dt // 无法解析则原样返回
return d.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit',
hour12: false,
})
}
/** 仅日期格式(北京时间) */
export function formatDate(dt: string | null | undefined): string {
if (!dt) return '-'
const d = new Date(dt)
if (isNaN(d.getTime())) return dt
return d.toLocaleDateString('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric', month: '2-digit', day: '2-digit',
})
}
+2 -1
View File
@@ -66,11 +66,12 @@ const SCENE_LABELS: Record<string, string> = {
emotion: '情绪中心',
}
/** 格式化时间 */
/** 格式化时间(北京时间) */
function formatTime(iso: string | null): string {
if (!iso) return ''
const d = new Date(iso)
return d.toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
+3 -2
View File
@@ -24,6 +24,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import { api } from '@/lib/api'
import { formatTime } from '@/lib/utils'
import type { Expert, ExpertService, Consultation } from '@/types'
const CATEGORIES = [
@@ -204,7 +205,7 @@ export function Experts() {
<span className="truncate text-sm text-muted-foreground">{s.expert_title}</span>
</div>
<p className="mt-1 text-sm text-muted-foreground">{s.expert_bio}</p>
<p className="mt-1 text-xs text-muted-foreground">选定时间:{s.created_at}</p>
<p className="mt-1 text-xs text-muted-foreground">选定时间:{formatTime(s.created_at)}</p>
</div>
<Badge variant={s.status === 'active' ? 'default' : 'outline'} className="shrink-0">
{s.status === 'active' ? '服务中' : s.status === 'pending' ? '待确认' : '已结束'}
@@ -258,7 +259,7 @@ export function Experts() {
<Markdown content={c.reply} />
</div>
)}
<p className="mt-2 text-xs text-muted-foreground">{c.created_at}</p>
<p className="mt-2 text-xs text-muted-foreground">{formatTime(c.created_at)}</p>
</div>
<Badge variant={c.status === 'replied' ? 'default' : 'outline'} className="shrink-0">
{c.status === 'replied' ? '已回复' : c.status === 'pending' ? '待回复' : '已关闭'}
+1 -10
View File
@@ -8,7 +8,7 @@ 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'
import { cn, formatTime } from '@/lib/utils'
/** 用户画像数据 */
interface ProfileData {
@@ -281,15 +281,6 @@ const SOURCE_LABELS: Record<string, string> = {
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
+2 -1
View File
@@ -36,10 +36,11 @@ const SCENE_LABELS: Record<string, string> = {
emotion: '情绪中心',
}
/** 格式化时间 */
/** 格式化时间(北京时间) */
function formatTime(dt: string | null): string {
if (!dt) return '从未使用'
return new Date(dt).toLocaleString('zh-CN', {
timeZone: 'Asia/Shanghai',
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
+2 -1
View File
@@ -5,6 +5,7 @@ import { Markdown } from '@/components/common/Markdown'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { formatTime } from '@/lib/utils'
import type { Consultation } from '@/types'
import { useWorkbenchData, WorkbenchShell, ReplyDialog, maskPhone } from './shared'
@@ -42,7 +43,7 @@ export function WorkbenchPending() {
<Markdown content={c.context} />
</div>
)}
<p className="mt-2 text-xs text-muted-foreground">创建时间:{c.created_at}</p>
<p className="mt-2 text-xs text-muted-foreground">创建时间:{formatTime(c.created_at)}</p>
</div>
<Button size="sm" className="shrink-0" onClick={() => setReplyTarget(c)}>
<Send className="h-3.5 w-3.5" /> 回复
@@ -5,6 +5,7 @@ import { EmptyState } from '@/components/common/EmptyState'
import { Card, CardContent } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { api } from '@/lib/api'
import { formatTime } from '@/lib/utils'
import { useWorkbenchData, WorkbenchShell, maskPhone } from './shared'
/** 专家工作台 - 待确认服务:用户选定专家后,专家确认接单 */
@@ -38,7 +39,7 @@ export function WorkbenchPendingServices() {
<CardContent className="flex items-center justify-between gap-2 py-3">
<div className="min-w-0">
<p className="text-sm font-medium">用户:{maskPhone(s.user_phone || '')}</p>
<p className="text-xs text-muted-foreground">请求时间:{s.created_at}</p>
<p className="text-xs text-muted-foreground">请求时间:{formatTime(s.created_at)}</p>
</div>
<Button size="sm" className="shrink-0" onClick={() => handleAccept(s.service_id)} disabled={accepting === s.service_id}>
{accepting === s.service_id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Check className="h-3.5 w-3.5" />}
+2 -1
View File
@@ -2,6 +2,7 @@ import { useState } from 'react'
import { Star, Eye } from 'lucide-react'
import { EmptyState } from '@/components/common/EmptyState'
import { Card, CardContent } from '@/components/ui/card'
import { formatTime } from '@/lib/utils'
import type { Consultation } from '@/types'
import { useWorkbenchData, WorkbenchShell, ConsultationDetailDialog, maskPhone } from './shared'
@@ -29,7 +30,7 @@ export function WorkbenchReplied() {
<Eye className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
</div>
<p className="mt-1 line-clamp-2 text-xs text-muted-foreground">{c.reply}</p>
<p className="mt-1 text-xs text-muted-foreground">回复时间:{c.replied_at}</p>
<p className="mt-1 text-xs text-muted-foreground">回复时间:{formatTime(c.replied_at)}</p>
</CardContent>
</Card>
))}
+2 -1
View File
@@ -11,6 +11,7 @@ import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription,
} from '@/components/ui/dialog'
import { api } from '@/lib/api'
import { formatTime } from '@/lib/utils'
import type { Expert, ExpertService, Consultation } from '@/types'
/** 脱敏手机号 */
@@ -146,7 +147,7 @@ export function ConsultationDetailDialog({
<div className="rounded-md border border-emerald-500/20 bg-emerald-500/5 p-3 text-sm">
<Markdown content={target?.reply || ''} />
</div>
<p className="mt-1.5 text-xs text-muted-foreground">回复时间:{target?.replied_at}</p>
<p className="mt-1.5 text-xs text-muted-foreground">回复时间:{formatTime(target?.replied_at)}</p>
</div>
</div>
<DialogFooter>