feat: BossAi v2.0 UI/UX 重构 + 看板断点续接 + 多轮上下文
- 新增 web2/ 前端:任务卡看板、风险雷达、决策日志、我的团队、快捷指令 - ChatPanel 增强:风险卡置顶、案卡接入、起诉状向导、语音输入 - 事项看板:点击查看详情弹窗、断点续接(恢复历史对话+matterId) - 保密模式恢复:密码验证后重建内存 session,保留多轮上下文 - 多轮对话:ask/ask-stream/ask-analyze 注入 session 历史,AI 有上下文 - 追问优化:紧扣用户问题,不再偏离主题(社保问社保,工伤问工伤) - 对话区可滚动,输入区固定可见 - 状态机放宽:允许从任意非终态转到 pending_expert - session 存活检查端点,后端重启后自动重建 session - DB migration:风险推送表、案卡表 Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
@@ -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" /> 发给专家咨询
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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' ? '待回复' : '已关闭'}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
))}
|
||||
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -11,7 +11,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
host: '127.0.0.1',
|
||||
host: true, // 允许局域网访问(手机测试)
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
|
||||
Reference in New Issue
Block a user