diff --git a/web2/src/App.tsx b/web2/src/App.tsx index 716e3f8..95fae9f 100644 --- a/web2/src/App.tsx +++ b/web2/src/App.tsx @@ -16,6 +16,8 @@ import { Me } from '@/pages/Me' import { Settings } from '@/pages/Settings' import { Login } from '@/pages/Login' import { Placeholder } from '@/pages/Placeholder' +import { WorkbenchPendingServices, WorkbenchPending, WorkbenchReplied, WorkbenchUsers } from '@/pages/workbench' +import { AdminStats, AdminAiUsage, AdminExpertActivity, AdminUsers, AdminExperts } from '@/pages/admin' import { isLoggedIn, getRole, type Role } from '@/lib/auth' /** 路由守卫:未登录跳转登录页,角色不匹配跳转各自首页 */ @@ -49,9 +51,17 @@ export default function App() { } /> } /> } /> - {/* 专家/管理端占位(后续阶段实现) */} - } /> - } /> + {/* 专家工作台 */} + } /> + } /> + } /> + } /> + {/* 管理后台 */} + } /> + } /> + } /> + } /> + } /> } /> diff --git a/web2/src/lib/api.ts b/web2/src/lib/api.ts index 20d1012..bcc1bd1 100644 --- a/web2/src/lib/api.ts +++ b/web2/src/lib/api.ts @@ -313,8 +313,26 @@ export const api = { users: number; experts_active: number; experts_total: number; services_active: number; consultations_total: number; consultations_pending: number; }>('/admin/stats'), - adminAiUsage: () => request>('/admin/ai-usage'), - adminExpertActivity: () => request>('/admin/expert-activity'), + adminAiUsage: () => request<{ + total_questions: number + active_users: number + by_scene: Array<{ scene: string; count: number }> + by_mode: Array<{ mode: string; count: number }> + by_risk: Array<{ level: string; count: number }> + by_inference: Array<{ location: string; count: number }> + daily_trend: Array<{ date: string; count: number }> + by_user: Array<{ phone_masked: string; questions: number; replies: number; last_active: string | null; top_scene: string | null }> + }>('/admin/ai-usage'), + adminExpertActivity: () => request<{ + total: number + replied: number + pending: number + by_status: Array<{ status: string; count: number }> + by_category: Array<{ category: string; count: number }> + by_expert: Array<{ name: string; category: string; title: string; total: number; replied: number; pending: number; avg_rating: number; rating_count: number }> + daily_trend: Array<{ date: string; count: number }> + by_user: Array<{ phone_masked: string; consultations: number; last_active: string | null }> + }>('/admin/expert-activity'), // ===== 对话历史 ===== listHistorySessions: (mode?: 'normal' | 'confidential') => diff --git a/web2/src/pages/admin/AdminAiUsage.tsx b/web2/src/pages/admin/AdminAiUsage.tsx new file mode 100644 index 0000000..517e3f4 --- /dev/null +++ b/web2/src/pages/admin/AdminAiUsage.tsx @@ -0,0 +1,172 @@ +import { useEffect, useState } from 'react' +import { + Shield, Users, MessageSquare, Activity, +} from 'lucide-react' +import { + PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, + ResponsiveContainer, CartesianGrid, LineChart, Line, Legend, +} from 'recharts' +import { PageLayout } from '@/components/common/PageLayout' +import { EmptyState } from '@/components/common/EmptyState' +import { LoadingState } from '@/components/common/LoadingState' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { api } from '@/lib/api' +import { SCENE_LABELS, MODE_LABELS, RISK_LABELS, PIE_COLORS } from './constants' + +type AiUsage = Awaited> + +/** 管理后台 - AI 使用情况:提问统计、场景/模式/风险/推理位置分布、趋势、用户排行(不含具体内容) */ +export function AdminAiUsage() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.adminAiUsage() + .then(setData) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + if (loading || !data) { + return ( + + + + ) + } + + return ( + +
+ {/* 概览数字 */} +
+ + +

{data.total_questions}

+

总提问数

+
+ + +

{data.active_users}

+

活跃用户数

+
+ + +

{data.daily_trend.reduce((s, d) => s + d.count, 0)}

+

近 7 天提问

+
+
+ + {/* 趋势图 */} + {data.daily_trend.length > 0 && ( + +

近 7 天提问趋势

+ + ({ date: d.date.slice(5), count: d.count }))}> + + + + + + + +
+ )} + +
+ {/* 场景分布 */} + +

按场景分布

+ {data.by_scene.length > 0 ? ( + + + ({ name: SCENE_LABELS[s.scene] || s.scene, value: s.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label> + {data.by_scene.map((_, i) => )} + + + + + + ) : } +
+ + {/* 隐私模式分布 */} + +

按隐私模式分布

+ {data.by_mode.length > 0 ? ( + + + ({ name: MODE_LABELS[m.mode] || m.mode, value: m.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label> + {data.by_mode.map((_, i) => )} + + + + + + ) : } +
+ + {/* 风险等级分布 */} + +

按风险等级分布

+ {data.by_risk.length > 0 ? ( + + ({ name: RISK_LABELS[r.level] || r.level, count: r.count }))}> + + + + + + + + ) : } +
+ + {/* 推理位置分布 */} + +

按推理位置分布

+ {data.by_inference.length > 0 ? ( + + ({ name: r.location === 'cloud' ? '云端' : '本地', count: r.count }))}> + + + + + + + + ) : } +
+
+ + {/* 用户使用排行 */} + {data.by_user.length > 0 && ( + +

用户使用情况(脱敏,Top 50)

+
+ {data.by_user.map((u, i) => ( +
+
+ {i + 1} + {u.phone_masked} + {u.top_scene && ( + + {SCENE_LABELS[u.top_scene] || u.top_scene} + + )} +
+
+ 提问 {u.questions} + 回复 {u.replies} + {u.last_active ? u.last_active.slice(0, 10) : '-'} +
+
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/web2/src/pages/admin/AdminExpertActivity.tsx b/web2/src/pages/admin/AdminExpertActivity.tsx new file mode 100644 index 0000000..7840e96 --- /dev/null +++ b/web2/src/pages/admin/AdminExpertActivity.tsx @@ -0,0 +1,161 @@ +import { useEffect, useState } from 'react' +import { Shield, Activity, Briefcase } from 'lucide-react' +import { + PieChart, Pie, Cell, BarChart, Bar, XAxis, YAxis, Tooltip, + ResponsiveContainer, CartesianGrid, LineChart, Line, Legend, +} from 'recharts' +import { PageLayout } from '@/components/common/PageLayout' +import { EmptyState } from '@/components/common/EmptyState' +import { LoadingState } from '@/components/common/LoadingState' +import { Card, CardContent } from '@/components/ui/card' +import { Badge } from '@/components/ui/badge' +import { api } from '@/lib/api' +import { CATEGORY_LABELS, STATUS_LABELS, PIE_COLORS } from './constants' + +type ExpertActivity = Awaited> + +/** 管理后台 - 专家沟通情况:咨询统计、状态/分类分布、专家统计、趋势、用户排行(不含具体内容) */ +export function AdminExpertActivity() { + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + + useEffect(() => { + api.adminExpertActivity() + .then(setData) + .catch(() => {}) + .finally(() => setLoading(false)) + }, []) + + if (loading || !data) { + return ( + + + + ) + } + + return ( + +
+ {/* 概览数字 */} +
+ + +

{data.total}

+

总咨询数

+
+ + +

{data.replied}

+

已回复

+
+ + +

{data.pending}

+

待回复

+
+
+ + {/* 趋势图 */} + {data.daily_trend.length > 0 && ( + +

近 7 天咨询趋势

+ + ({ date: d.date.slice(5), count: d.count }))}> + + + + + + + +
+ )} + +
+ {/* 状态分布 */} + +

按状态分布

+ {data.by_status.length > 0 ? ( + + + ({ name: STATUS_LABELS[s.status] || s.status, value: s.count }))} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={70} label> + {data.by_status.map((_, i) => )} + + + + + + ) : } +
+ + {/* 分类分布 */} + +

按分类分布

+ {data.by_category.length > 0 ? ( + + ({ name: CATEGORY_LABELS[c.category] || c.category, count: c.count }))}> + + + + + + + + ) : } +
+
+ + {/* 专家咨询统计 */} + {data.by_expert.length > 0 && ( + +

专家咨询统计

+
+ {data.by_expert.filter(e => e.total > 0).map((e, i) => ( +
+
+ {i + 1} + {e.name} + {CATEGORY_LABELS[e.category] || e.category} + {e.rating_count > 0 && ( + ★ {e.avg_rating} + )} +
+
+ 总 {e.total} + 已回 {e.replied} + 待回 {e.pending} +
+
+ ))} + {data.by_expert.every(e => e.total === 0) && ( + + )} +
+
+ )} + + {/* 用户咨询排行 */} + {data.by_user.length > 0 && ( + +

用户咨询情况(脱敏,Top 50)

+
+ {data.by_user.map((u, i) => ( +
+
+ {i + 1} + {u.phone_masked} +
+
+ 咨询 {u.consultations} + {u.last_active ? u.last_active.slice(0, 10) : '-'} +
+
+ ))} +
+
+ )} +
+
+ ) +} diff --git a/web2/src/pages/admin/AdminExperts.tsx b/web2/src/pages/admin/AdminExperts.tsx new file mode 100644 index 0000000..f4123a9 --- /dev/null +++ b/web2/src/pages/admin/AdminExperts.tsx @@ -0,0 +1,311 @@ +import { useEffect, useRef, useState } from 'react' +import { toast } from 'sonner' +import { Briefcase, Loader2, Pencil, Power, PowerOff, Star, Trash2, Upload } from 'lucide-react' +import { PageLayout } from '@/components/common/PageLayout' +import { EmptyState } from '@/components/common/EmptyState' +import { LoadingState } from '@/components/common/LoadingState' +import { ExpertAvatar } from '@/components/common/ExpertAvatar' +import { Card, CardContent } from '@/components/ui/card' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Textarea } from '@/components/ui/textarea' +import { + Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, DialogDescription, +} from '@/components/ui/dialog' +import { + Select, SelectContent, SelectItem, SelectTrigger, SelectValue, +} from '@/components/ui/select' +import { api } from '@/lib/api' +import type { Expert } from '@/types' +import { CATEGORY_LABELS } from './constants' + +/** 管理后台 - 专家管理:专家上下架、编辑信息、上传头像 */ +export function AdminExperts() { + const [experts, setExperts] = useState([]) + const [loading, setLoading] = useState(true) + const [toggling, setToggling] = useState(null) + const [editing, setEditing] = useState(null) + + const loadExperts = async () => { + try { + const res = await api.adminListExperts() + setExperts(res.results) + } catch { /* ignore */ } + finally { setLoading(false) } + } + + useEffect(() => { loadExperts() }, []) + + const handleToggle = async (expert: Expert) => { + setToggling(expert.id) + try { + const newStatus = expert.status === 'active' ? 'inactive' : 'active' + await api.adminUpdateExpertStatus(expert.id, newStatus) + toast.success(newStatus === 'active' ? '已上架' : '已下架') + loadExperts() + } catch { + toast.error('操作失败') + } finally { + setToggling(null) + } + } + + if (loading) { + return ( + + + + ) + } + + return ( + + {experts.length === 0 ? ( + + ) : ( +
+ {experts.map((ex) => { + const stars = (ex.rate / 100).toFixed(1) + return ( + + + {/* 头部:长方形头像(左) + 姓名/状态(右) */} +
+ +
+
+

{ex.name}

+ {ex.status === 'active' + ? 上架中 + : 已下架} +
+

{ex.title}

+
+ + {stars} + ({ex.rating_count}人评价) +
+ {/* 简介 */} + {ex.bio && ( +

{ex.bio}

+ )} + {/* 专长标签 */} + {ex.expertise && ( +
+ {ex.expertise.split(',').map((tag, i) => ( + {tag.trim()} + ))} +
+ )} +
+
+ + {/* 底部:分类 + 操作按钮 */} +
+ {CATEGORY_LABELS[ex.category] || ex.category} +
+ + +
+
+
+
+ ) + })} +
+ )} + + {/* 编辑弹窗 */} + {editing && ( + setEditing(null)} + onSuccess={() => { loadExperts(); setEditing(null) }} + /> + )} +
+ ) +} + +/** 编辑专家信息弹窗:含头像上传 + 表单字段 */ +function EditExpertDialog({ + expert, + onClose, + onSuccess, +}: { + expert: Expert + onClose: () => void + onSuccess: () => void +}) { + const [name, setName] = useState(expert.name) + const [category, setCategory] = useState(expert.category) + const [title, setTitle] = useState(expert.title) + const [bio, setBio] = useState(expert.bio) + const [expertise, setExpertise] = useState(expert.expertise) + const [avatarUrl, setAvatarUrl] = useState(expert.avatar_url) + const [saving, setSaving] = useState(false) + const [uploading, setUploading] = useState(false) + const fileInputRef = useRef(null) + + const handleUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0] + if (!file) return + setUploading(true) + try { + const res = await api.adminUploadExpertAvatar(expert.id, file) + setAvatarUrl(res.avatar_url) + toast.success('头像上传成功') + } catch (err) { + toast.error(err instanceof Error ? err.message : '上传失败') + } finally { + setUploading(false) + // 清空 input 允许重复选同一文件 + if (fileInputRef.current) fileInputRef.current.value = '' + } + } + + /** 删除头像:清空 avatarUrl,保存时传空字符串后端清除为 NULL */ + const handleRemoveAvatar = async () => { + setAvatarUrl('') + toast.success('头像已删除,保存后生效') + } + + const handleSave = async () => { + if (!name.trim()) { + toast.error('姓名不能为空') + return + } + setSaving(true) + try { + await api.adminUpdateExpert(expert.id, { + name: name.trim(), + category, + title: title.trim(), + bio: bio.trim(), + expertise: expertise.trim(), + avatar_url: avatarUrl, + }) + toast.success('专家信息已更新') + onSuccess() + } catch (err) { + toast.error(err instanceof Error ? err.message : '保存失败') + } finally { + setSaving(false) + } + } + + return ( + !o && onClose()}> + + + 编辑专家信息 + 修改专家资料、上传头像 + + +
+ {/* 头像上传区 */} +
+ +
+

专家头像

+

支持 JPG / PNG / WebP,最大 5MB

+ +
+ + {avatarUrl && ( + + )} +
+
+
+ + {/* 姓名 */} +
+ + setName(e.target.value)} placeholder="专家姓名" /> +
+ + {/* 分类 */} +
+ + +
+ + {/* 职称 */} +
+ + setTitle(e.target.value)} placeholder="如:高级律师" /> +
+ + {/* 简介 */} +
+ +