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
+383
View File
@@ -0,0 +1,383 @@
/** BossAi 2.0 API 客户端
* 在 v1.0 基础上新增:案卡 CRUD、老板台聚合、我的团队、快捷指令、风险推送
* 共享同一后端,新增端点向后兼容
*/
import type {
AskResponse, EmotionMode, Expert, ExpertService, Consultation,
FileInspectResponse, HistoryMessage, HistorySession, KnowledgeDoc,
Scene, SessionMode, SessionResponse, Settings,
Matter, MatterBoard, DashboardToday, RiskAlert,
ExpertRelation, QuickCommands,
} from '@/types'
const BASE = '/api'
const DEFAULT_TIMEOUT = 30000
const LONG_TIMEOUT = 60000
export const network = {
isOnline: () => typeof navigator !== 'undefined' ? navigator.onLine : true,
onChange: (cb: (online: boolean) => void) => {
if (typeof window === 'undefined') return () => {}
const handler = () => cb(navigator.onLine)
window.addEventListener('online', handler)
window.addEventListener('offline', handler)
return () => {
window.removeEventListener('online', handler)
window.removeEventListener('offline', handler)
}
},
}
function authHeaders(): Record<string, string> {
const token = typeof window !== 'undefined' ? localStorage.getItem('bossai_token') : null
return token ? { Authorization: `Bearer ${token}` } : {}
}
async function fetchWithTimeout(url: string, init: RequestInit, timeout: number): Promise<Response> {
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), timeout)
try {
return await fetch(url, { ...init, signal: controller.signal })
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') {
throw new Error('请求超时,请检查网络后重试')
}
if (!navigator.onLine) throw new Error('网络已断开,请检查网络连接')
throw err
} finally {
clearTimeout(timer)
}
}
async function requestWithRetry<T>(
path: string, init?: RequestInit, options?: { timeout?: number; retries?: number },
): Promise<T> {
const timeout = options?.timeout ?? DEFAULT_TIMEOUT
const maxRetries = options?.retries ?? 1
let lastError: Error
for (let attempt = 0; attempt <= maxRetries; attempt++) {
if (!navigator.onLine) throw new Error('网络已断开,请检查网络连接')
try {
const isFormData = init?.body instanceof FormData
const res = await fetchWithTimeout(BASE + path, {
...init,
headers: {
...(isFormData ? {} : { 'Content-Type': 'application/json' }),
...authHeaders(),
...(init?.headers || {}),
},
}, timeout)
if (res.status === 429) {
const retryAfter = res.headers.get('Retry-After')
const wait = retryAfter ? parseInt(retryAfter, 10) : 2
throw new Error(`请求过于频繁,请 ${wait} 秒后重试`)
}
if (!res.ok) {
const body = await res.json().catch(() => ({ detail: res.statusText }))
let msg: string
if (typeof body.detail === 'string') msg = body.detail
else if (Array.isArray(body.detail) && body.detail.length > 0)
msg = body.detail.map((e: { msg?: string }) => e.msg || JSON.stringify(e)).join('; ')
else msg = `请求失败 ${res.status}`
throw new Error(msg)
}
return res.json() as Promise<T>
} catch (err) {
lastError = err as Error
const isRetryable = lastError.message.includes('超时') ||
lastError.message.includes('网络') || lastError.message.includes('Failed to fetch')
if (!isRetryable || attempt === maxRetries) throw lastError
await new Promise(r => setTimeout(r, 1000 * (attempt + 1)))
}
}
throw lastError!
}
async function request<T>(path: string, init?: RequestInit): Promise<T> {
return requestWithRetry<T>(path, init)
}
export const api = {
// ===== 基础 =====
health: () => request<{ status: string; service: string }>('/health'),
// ===== 认证 =====
register: (phone: string, password: string, name: string = '') =>
request<{ phone: string; name: string; token: string }>('/auth/register', {
method: 'POST', body: JSON.stringify({ phone, password, name }),
}),
login: (phone: string, password: string) =>
request<{ phone: string; name: string; token: string; role?: string; expert_id?: number; admin_id?: number }>('/auth/login', {
method: 'POST', body: JSON.stringify({ phone, password }),
}),
// ===== 会话与提问(与 v1.0 兼容) =====
createSession: (mode: SessionMode) =>
request<SessionResponse>('/sessions', { method: 'POST', body: JSON.stringify({ mode }) }),
/** 检查内存 session 是否存活(后端重启后内存 session 会丢失) */
sessionAlive: (sid: string) =>
request<{ alive: boolean }>(`/sessions/${sid}/alive`),
ask: (sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode) =>
requestWithRetry<AskResponse>(`/sessions/${sid}/ask`, {
method: 'POST', body: JSON.stringify({ question, scene, emotion_mode: emotionMode }),
}, { timeout: LONG_TIMEOUT, retries: 0 }),
askAnalyze: (
sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode,
factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>, topic?: string,
) =>
request<{
need_ask: boolean; need_analysis: boolean; round: number;
questions?: Array<{ dim_key: string; dim_desc: string; question: string }>;
fact_summary?: string; 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({ question, scene, emotion_mode: emotionMode, fact_answers: factAnswers, topic }),
}),
askStream: async (
sid: string, question: string, scene: Scene = 'legal', emotionMode?: EmotionMode,
callbacks?: {
onRisk?: (risk: AskResponse['risk']) => void
onDelta?: (content: string) => void
onDone?: (data: Partial<AskResponse>) => void
onError?: (error: string) => void
},
factAnswers?: Array<{ dim_key: string; dim_desc: string; answer: string }>,
) => {
if (!navigator.onLine) { callbacks?.onError?.('网络已断开,请检查网络连接'); return }
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), LONG_TIMEOUT)
try {
const res = await fetch(`${BASE}/sessions/${sid}/ask-stream`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...authHeaders() },
body: JSON.stringify({ question, scene, emotion_mode: emotionMode, fact_answers: factAnswers }),
signal: controller.signal,
})
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }))
callbacks?.onError?.(detail.detail || `请求失败 ${res.status}`); return
}
const reader = res.body?.getReader()
if (!reader) { callbacks?.onError?.('浏览器不支持流式读取'); return }
const decoder = new TextDecoder()
let buffer = ''
while (true) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
const parts = buffer.split('\n\n')
buffer = parts.pop() || ''
for (const part of parts) {
if (!part.trim()) continue
let eventType = 'message'; let dataLine = ''
for (const line of part.split('\n')) {
if (line.startsWith('event: ')) eventType = line.slice(7)
else if (line.startsWith('data: ')) dataLine = line.slice(6)
}
if (!dataLine) continue
try {
const data = JSON.parse(dataLine)
if (eventType === 'risk') callbacks?.onRisk?.(data)
else if (eventType === 'delta') callbacks?.onDelta?.(data.content || '')
else if (eventType === 'done') callbacks?.onDone?.(data)
else if (eventType === 'error') callbacks?.onError?.(data.detail || '未知错误')
} catch { /* 忽略解析失败 */ }
}
}
} catch (err) {
if (err instanceof DOMException && err.name === 'AbortError') callbacks?.onError?.('请求超时,请检查网络后重试')
else callbacks?.onError?.(err instanceof Error ? err.message : '流式请求失败')
} finally { clearTimeout(timer) }
},
destroy: (sid: string) =>
request<{ destroyed: boolean; session_id: string }>(`/sessions/${sid}/destroy`, { method: 'POST' }),
uploadFile: async (sid: string, file: File) => {
const form = new FormData(); form.append('file', file)
const res = await fetch(`${BASE}/sessions/${sid}/file`, { method: 'POST', body: form })
if (!res.ok) {
const detail = await res.json().catch(() => ({ detail: res.statusText }))
throw new Error(detail.detail || `上传失败 ${res.status}`)
}
return res.json() as Promise<FileInspectResponse>
},
// ===== 画像 =====
getProfile: () => requestWithRetry<Record<string, unknown>>('/profile/me', {}),
updateProfile: (data: {
industry?: string; company_size?: string; role?: string;
risk_tolerance?: string; decision_style?: string; key_facts?: Record<string, string>;
}) => request<{ updated: boolean }>('/profile/me', { method: 'PUT', body: JSON.stringify(data) }),
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 }),
}),
// ===== 话题与示例 =====
getTopics: (scene: Scene = 'legal', existing?: string[]) =>
request<{ topics: Array<{ title: string; desc: string; samples: string[] }> }>('/topics', {
method: 'POST', body: JSON.stringify({ scene, existing }),
}),
generateSamples: (scene: Scene = 'legal', topic?: string, existing?: string[]) =>
request<{ samples: string[] }>('/samples', {
method: 'POST', body: JSON.stringify({ scene, topic, existing }),
}),
// ===== 知识库 =====
addKnowledge: (data: { title: string; content: string; source?: string; doc_type?: string; region?: string | null; effective_date?: string | null; keywords?: string | null }) =>
request<KnowledgeDoc>('/knowledge', { method: 'POST', body: JSON.stringify(data) }),
listKnowledge: (docType?: string) =>
request<{ results: KnowledgeDoc[]; counts: Record<string, number>; total: number }>(`/knowledge${docType ? `?doc_type=${docType}` : ''}`),
searchKnowledge: (q: string, docType?: string) =>
request<{ results: KnowledgeDoc[] }>(`/knowledge/search?q=${encodeURIComponent(q)}${docType ? `&doc_type=${docType}` : ''}`),
deleteKnowledge: (id: number) => request<{ deleted: boolean; id: number }>(`/knowledge/${id}`, { method: 'DELETE' }),
vectorSearchKnowledge: (q: string, limit?: number) =>
request<{ results: KnowledgeDoc[] }>(`/knowledge/vector-search?q=${encodeURIComponent(q)}${limit ? `&limit=${limit}` : ''}`),
// ===== 设置 =====
getSettings: () => request<{ settings: Settings }>('/settings'),
updateSettings: (settings: Settings) =>
request<{ updated: boolean; settings: Settings }>('/settings', { method: 'PUT', body: JSON.stringify({ settings }) }),
// ===== 专家库(v1.0 兼容) =====
listExperts: (category?: string) =>
request<{ results: Expert[] }>(`/experts${category ? `?category=${category}` : ''}`),
getExpert: (id: number) => request<Expert>(`/experts/${id}`),
selectExpert: (expertId: number) =>
request<{ service_id: number; status: string }>(`/experts/${expertId}/select`, { method: 'POST' }),
myServices: () => request<{ results: ExpertService[] }>('/my/services'),
myConsultations: (status?: string) =>
request<{ results: Consultation[] }>(`/my/consultations${status ? `?status=${status}` : ''}`),
createConsultation: (data: { expert_id: number; topic: string; context?: string; question?: string }) =>
request<{ consultation_id: number; created_at: string; status: string }>('/consultations', {
method: 'POST', body: JSON.stringify(data),
}),
rateConsultation: (consultationId: number, rating: number, comment: string = '') =>
request<{ rating_id: number; created_at: string }>(`/consultations/${consultationId}/rate`, {
method: 'POST', body: JSON.stringify({ consultation_id: consultationId, rating, comment }),
}),
cancelMyService: (serviceId: number) =>
request<{ cancelled: boolean }>(`/my/services/${serviceId}/cancel`, { method: 'POST' }),
// ===== 专家工作台 =====
expertWorkbench: () =>
request<{ expert: Expert; services: ExpertService[]; consultations: Consultation[] }>('/expert/workbench'),
acceptService: (serviceId: number) =>
request<{ accepted: boolean }>(`/expert/services/${serviceId}/accept`, { method: 'POST' }),
cancelExpertService: (serviceId: number) =>
request<{ cancelled: boolean }>(`/expert/services/${serviceId}/cancel`, { method: 'POST' }),
replyConsultation: (consultationId: number, reply: string) =>
request<{ replied: boolean }>(`/expert/consultations/${consultationId}/reply`, {
method: 'POST', body: JSON.stringify({ reply }),
}),
// ===== 管理后台 =====
adminListExperts: (status?: string) =>
request<{ results: Expert[] }>(`/admin/experts${status ? `?status=${status}` : ''}`),
adminUpdateExpertStatus: (expertId: number, status: 'active' | 'inactive') =>
request<{ updated: boolean; expert_id: number; status: string }>(`/admin/experts/${expertId}/status`, {
method: 'PATCH', body: JSON.stringify({ status }),
}),
adminUpdateExpert: (expertId: number, data: Partial<Pick<Expert, 'name' | 'category' | 'title' | 'bio' | 'expertise' | 'avatar_url' | 'status'>>) =>
request<{ updated: boolean; expert: Expert }>(`/admin/experts/${expertId}`, { method: 'PATCH', body: JSON.stringify(data) }),
adminUploadExpertAvatar: (expertId: number, file: File) => {
const formData = new FormData(); formData.append('file', file)
return requestWithRetry<{ avatar_url: string }>(`/admin/experts/${expertId}/avatar`, {
method: 'POST', body: formData, headers: {},
}, { timeout: 30000, retries: 0 })
},
adminListUsers: (q?: string) =>
request<{ results: Array<{ id: number; phone: string; name: string; status: string; created_at: string; message_count: number; last_active: string | null; last_scene: string | null }> }>(`/admin/users${q ? `?q=${encodeURIComponent(q)}` : ''}`),
adminResetPassword: (userId: number, password: string) =>
request<{ reset: boolean }>(`/admin/users/${userId}/reset-password`, { method: 'POST', body: JSON.stringify({ password }) }),
adminUpdateUserStatus: (userId: number, status: 'active' | 'inactive') =>
request<{ status: string }>(`/admin/users/${userId}/status`, { method: 'POST', body: JSON.stringify({ status }) }),
adminStats: () => request<{
users: number; experts_active: number; experts_total: number;
services_active: number; consultations_total: number; consultations_pending: number;
}>('/admin/stats'),
adminAiUsage: () => request<Record<string, unknown>>('/admin/ai-usage'),
adminExpertActivity: () => request<Record<string, unknown>>('/admin/expert-activity'),
// ===== 对话历史 =====
listHistorySessions: (mode?: 'normal' | 'confidential') =>
request<{ results: HistorySession[] }>(`/history/sessions${mode ? `?mode=${mode}` : ''}`),
getHistoryMessages: (sessionId: string) =>
request<{ session_id: string; mode: 'normal' | 'confidential'; need_verify: boolean; message_count?: number; messages?: HistoryMessage[] }>(`/history/sessions/${sessionId}`),
verifyHistory: (sessionId: string, password: string) =>
request<{ session_id: string; mode: 'confidential'; verified: boolean; messages: HistoryMessage[] }>(`/history/sessions/${sessionId}/verify`, {
method: 'POST', body: JSON.stringify({ password }),
}),
deleteHistory: (sessionId: string) =>
request<{ deleted: boolean; session_id: string; count: number }>(`/history/sessions/${sessionId}`, { method: 'DELETE' }),
// ===== 2.0 新增:案卡 =====
/** 案卡看板(按状态分组) */
getMatters: (status?: string) =>
request<{ board: MatterBoard; total: number }>(`/matters${status ? `?status=${status}` : ''}`),
/** 创建案卡(绑定会话) */
createMatter: (data: { scene: Scene; title: string; session_id?: string }) =>
request<Matter>('/matters', { method: 'POST', body: JSON.stringify(data) }),
/** 案卡详情 */
getMatter: (id: number) => request<Matter>(`/matters/${id}`),
/** 通过 session_id 查询案卡(断点续接时恢复 matterId) */
getMatterBySession: (sid: string) => request<Matter>(`/matters/by-session/${sid}`),
/** 更新案卡状态 */
updateMatterStatus: (id: number, status: Matter['status']) =>
request<{ updated: boolean; status: string }>(`/matters/${id}/status`, { method: 'PATCH', body: JSON.stringify({ status }) }),
/** 闭环案卡 */
closeMatter: (id: number, executionResult?: string, executionNote?: string) =>
request<{ closed: boolean; closed_at: string }>(`/matters/${id}/close`, {
method: 'POST', body: JSON.stringify({ execution_result: executionResult, execution_note: executionNote }),
}),
// ===== 2.0 新增:老板台 =====
getDashboardToday: () => request<DashboardToday>('/dashboard/today'),
// ===== 2.0 新增:我的团队 =====
getTeam: () => request<{ results: ExpertRelation[] }>('/team'),
// ===== 2.0 新增:快捷指令 =====
getQuickCommands: () => request<QuickCommands>('/quick-commands'),
// ===== 2.0 新增:风险推送 =====
getRiskAlerts: (unread?: boolean) =>
request<{ results: RiskAlert[] }>(`/risk-alerts${unread ? '?unread=true' : ''}`),
readRiskAlert: (id: number) =>
request<{ read: boolean }>(`/risk-alerts/${id}/read`, { method: 'POST' }),
scanRiskAlerts: () =>
request<{ scanned: number; created: number }>('/risk-alerts/scan', { method: 'POST' }),
// ===== 2.0 新增:决策日志 =====
getDecisionsLog: (month?: string) =>
request<{
results: Matter[]
by_month: Record<string, Matter[]>
stats: { total: number; executed: number; execution_rate: number }
}>(`/decisions-log${month ? `?month=${month}` : ''}`),
updateExecutionResult: (matterId: number, executionResult: string, executionNote?: string) =>
request<{ updated: boolean }>(`/matters/${matterId}/execution`, {
method: 'PATCH',
body: JSON.stringify({ execution_result: executionResult, execution_note: executionNote }),
}),
// ===== 可观测性 =====
getMetrics: () => request<Record<string, unknown>>('/metrics'),
}
+46
View File
@@ -0,0 +1,46 @@
/** 用户认证:token 管理 + 登录状态(与 v1.0 共享 localStorage key,无缝切换) */
const TOKEN_KEY = 'bossai_token'
const PHONE_KEY = 'bossai_phone'
const NAME_KEY = 'bossai_name'
const ROLE_KEY = 'bossai_role'
export type Role = 'user' | 'expert' | 'admin'
export function getToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(TOKEN_KEY)
}
export function getPhone(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(PHONE_KEY)
}
export function getName(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(NAME_KEY)
}
export function getRole(): Role {
if (typeof window === 'undefined') return 'user'
return (localStorage.getItem(ROLE_KEY) as Role) || 'user'
}
export function setAuth(token: string, phone: string, name: string = '', role: Role = 'user') {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(PHONE_KEY, phone)
if (name) localStorage.setItem(NAME_KEY, name)
localStorage.setItem(ROLE_KEY, role)
}
export function clearAuth() {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(PHONE_KEY)
localStorage.removeItem(NAME_KEY)
localStorage.removeItem(ROLE_KEY)
}
export function isLoggedIn(): boolean {
return !!getToken()
}
+114
View File
@@ -0,0 +1,114 @@
/** BossAi 2.0 共享导航配置
* TASK-006:合并 Sidebar/MobileNav/BottomTab 的导航常量为单一来源
* 桌面 Sidebar 和移动端 BottomTab 消费同一配置,仅渲染层不同
*/
import type { Role } from './auth'
import type { ComponentType } from 'react'
import {
LayoutDashboard, ClipboardList, Scale, TrendingUp, Heart, Users,
BookOpen, History, User, Settings, ShieldCheck, FileText,
BarChart3, Cpu, MessageSquare, UserCog, Briefcase,
} from 'lucide-react'
export interface NavItem {
/** 路由路径 */
path: string
/** 显示标签 */
label: string
/** 图标组件 */
icon: ComponentType<{ className?: string }>
/** 分组(用于桌面 Sidebar 分区) */
group: string
/** 移动端底部 Tab 是否显示(仅 top-level) */
tab?: boolean
}
/** 用户端导航(老板) */
export const USER_NAV: NavItem[] = [
// 总览
{ path: '/', label: '老板台', icon: LayoutDashboard, group: '总览', tab: true },
// 我的事项
{ path: '/matters', label: '事项看板', icon: ClipboardList, group: '我的事项', tab: true },
// 智囊
{ path: '/legal', label: '法务参谋', icon: Scale, group: '智囊' },
{ path: '/decisions', label: '经营决策', icon: TrendingUp, group: '智囊' },
{ path: '/life', label: '生活助理', icon: Heart, group: '智囊' },
{ path: '/emotion', label: '情绪中心', icon: ShieldCheck, group: '智囊' },
// 我的资源
{ path: '/team', label: '我的团队', icon: Users, group: '我的资源', tab: true },
{ path: '/decisions-log', label: '决策日志', icon: FileText, group: '我的资源' },
{ path: '/knowledge', label: '知识库', icon: BookOpen, group: '我的资源' },
{ path: '/history', label: '对话历史', icon: History, group: '我的资源' },
{ path: '/profile', label: '我的画像', icon: User, group: '我的资源' },
// 系统
{ path: '/settings', label: '设置', icon: Settings, group: '系统' },
]
/** 移动端底部 Tab(从 USER_NAV 提取 tab=true 的项,最后一项为"我") */
export const MOBILE_TABS: NavItem[] = [
...USER_NAV.filter((n) => n.tab),
// "我"聚合页
{ path: '/me', label: '我', icon: UserCog, group: '我', tab: true },
]
/** 专家端导航 */
export const EXPERT_NAV: NavItem[] = [
{ path: '/workbench', label: '待确认服务', icon: Briefcase, group: '工作台' },
{ path: '/workbench/pending', label: '待回复咨询', icon: MessageSquare, group: '工作台' },
{ path: '/workbench/replied', label: '已回复咨询', icon: History, group: '工作台' },
{ path: '/workbench/users', label: '服务中用户', icon: Users, group: '工作台' },
]
/** 管理端导航 */
export const ADMIN_NAV: NavItem[] = [
{ path: '/admin', label: '平台统计', icon: BarChart3, group: '管理后台' },
{ path: '/admin/ai-usage', label: 'AI 使用', icon: Cpu, group: '管理后台' },
{ path: '/admin/expert-activity', label: '专家沟通', icon: MessageSquare, group: '管理后台' },
{ path: '/admin/users', label: '用户管理', icon: Users, group: '管理后台' },
{ path: '/admin/experts', label: '专家管理', icon: UserCog, group: '管理后台' },
]
/** 按角色获取导航 */
export function getNavByRole(role: Role): NavItem[] {
switch (role) {
case 'expert': return EXPERT_NAV
case 'admin': return ADMIN_NAV
default: return USER_NAV
}
}
/** 按分组归类导航项(用于桌面 Sidebar 分区渲染) */
export function groupNav(items: NavItem[]): Array<{ group: string; items: NavItem[] }> {
const map = new Map<string, NavItem[]>()
for (const item of items) {
if (!map.has(item.group)) map.set(item.group, [])
map.get(item.group)!.push(item)
}
return Array.from(map, ([group, items]) => ({ group, items }))
}
/** Header 标题映射(补齐 v1.0 缺失的 /history /profile /admin/* ) */
export const HEADER_TITLES: Record<string, string> = {
'/': '老板台',
'/matters': '事项看板',
'/legal': '法务参谋',
'/decisions': '经营决策',
'/life': '生活助理',
'/emotion': '情绪中心',
'/team': '我的团队',
'/decisions-log': '决策日志',
'/knowledge': '知识库',
'/history': '对话历史',
'/profile': '我的画像',
'/me': '我',
'/settings': '设置',
'/workbench': '待确认服务',
'/workbench/pending': '待回复咨询',
'/workbench/replied': '已回复咨询',
'/workbench/users': '服务中用户',
'/admin': '平台统计',
'/admin/ai-usage': 'AI 使用统计',
'/admin/expert-activity': '专家沟通统计',
'/admin/users': '用户管理',
'/admin/experts': '专家管理',
}
+96
View File
@@ -0,0 +1,96 @@
import { clsx, type ClassValue } from 'clsx'
import { twMerge } from 'tailwind-merge'
/** 合并 Tailwind 类名,处理冲突 */
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
/** 将后端时间字符串(UTC 或带时区)格式化为北京时间(Asia/Shanghai) */
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',
})
}
/** 相对时间:刚刚 / X分钟前 / X小时前 / X天前 / 日期 */
export function formatRelative(dt: string | null | undefined): string {
if (!dt) return '-'
const d = new Date(dt)
if (isNaN(d.getTime())) return dt
const now = new Date()
const diff = now.getTime() - d.getTime()
const minutes = Math.floor(diff / 60000)
const hours = Math.floor(diff / 3600000)
const days = Math.floor(diff / 86400000)
if (minutes < 1) return '刚刚'
if (minutes < 60) return `${minutes}分钟前`
if (hours < 24) return `${hours}小时前`
if (days < 7) return `${days}天前`
return formatDate(dt)
}
/** 时段问候语 */
export function greeting(): string {
const h = new Date().getHours()
if (h < 6) return '夜深了'
if (h < 9) return '早上好'
if (h < 12) return '上午好'
if (h < 14) return '中午好'
if (h < 18) return '下午好'
if (h < 22) return '晚上好'
return '夜深了'
}
/** 案卡状态中文标签 */
export const MATTER_STATUS_LABELS: Record<string, string> = {
new: '待补充事实',
asking_facts: '追问中',
pending_confirm: '待确认',
analyzing: '分析中',
pending_expert: '待专家',
closed: '已闭环',
}
/** 案卡状态颜色类(Tailwind) */
export const MATTER_STATUS_COLORS: Record<string, string> = {
new: 'bg-blue-100 text-blue-700 border-blue-200',
asking_facts: 'bg-amber-100 text-amber-700 border-amber-200',
pending_confirm: 'bg-indigo-100 text-indigo-700 border-indigo-200',
analyzing: 'bg-purple-100 text-purple-700 border-purple-200',
pending_expert: 'bg-orange-100 text-orange-700 border-orange-200',
closed: 'bg-gray-100 text-gray-600 border-gray-200',
}
/** 场景中文标签 */
export const SCENE_LABELS: Record<string, string> = {
legal: '法务',
decision: '经营',
life: '生活',
emotion: '情绪',
}
/** 风险等级中文标签 */
export const RISK_LABELS: Record<string, string> = {
green: '低风险',
amber: '需关注',
orange: '较高风险',
red: '高风险',
}