feat: 迁移专家工作台和管理后台到web2
CI / 后端测试 (push) Has been cancelled
CI / 前端构建 (push) Has been cancelled

专家工作台(4页面):
- WorkbenchPendingServices: 待确认服务
- WorkbenchPending: 待回复咨询
- WorkbenchReplied: 已回复咨询
- WorkbenchUsers: 服务中用户
- shared.tsx: 共享hook/弹窗/外壳

管理后台(5页面):
- AdminStats: 平台统计
- AdminAiUsage: AI使用情况(含图表)
- AdminExpertActivity: 专家沟通情况(含图表)
- AdminUsers: 用户管理(搜索/重置密码/停用)
- AdminExperts: 专家管理(上下架/编辑/头像)
- constants.ts: 标签映射和图表配色

路由: workbench/* 和 admin/* 接入实际页面
适配: EmptyState icon 从JSX元素改为ComponentType
修正: 管理端场景标签"法务参谋"→"私人法务"

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 15:46:01 +08:00
co-authored by Devin
parent 5272ed5339
commit 9310ba141d
15 changed files with 1577 additions and 5 deletions
+13 -3
View File
@@ -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() {
<Route path="profile" element={<ProtectedRoute allowedRoles={['user']}><MyProfile /></ProtectedRoute>} />
<Route path="me" element={<ProtectedRoute allowedRoles={['user']}><Me /></ProtectedRoute>} />
<Route path="settings" element={<ProtectedRoute allowedRoles={['user']}><Settings /></ProtectedRoute>} />
{/* 专家/管理端占位(后续阶段实现) */}
<Route path="workbench/*" element={<Placeholder title="专家工作台(待迁移)" />} />
<Route path="admin/*" element={<Placeholder title="管理后台(待迁移)" />} />
{/* 专家工作台 */}
<Route path="workbench" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchPendingServices /></ProtectedRoute>} />
<Route path="workbench/pending" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchPending /></ProtectedRoute>} />
<Route path="workbench/replied" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchReplied /></ProtectedRoute>} />
<Route path="workbench/users" element={<ProtectedRoute allowedRoles={['expert']}><WorkbenchUsers /></ProtectedRoute>} />
{/* 管理后台 */}
<Route path="admin" element={<ProtectedRoute allowedRoles={['admin']}><AdminStats /></ProtectedRoute>} />
<Route path="admin/ai-usage" element={<ProtectedRoute allowedRoles={['admin']}><AdminAiUsage /></ProtectedRoute>} />
<Route path="admin/expert-activity" element={<ProtectedRoute allowedRoles={['admin']}><AdminExpertActivity /></ProtectedRoute>} />
<Route path="admin/users" element={<ProtectedRoute allowedRoles={['admin']}><AdminUsers /></ProtectedRoute>} />
<Route path="admin/experts" element={<ProtectedRoute allowedRoles={['admin']}><AdminExperts /></ProtectedRoute>} />
<Route path="*" element={<Placeholder title="页面未找到" />} />
</Route>
</Routes>
+20 -2
View File
@@ -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<Record<string, unknown>>('/admin/ai-usage'),
adminExpertActivity: () => request<Record<string, unknown>>('/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') =>
+172
View File
@@ -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<ReturnType<typeof api.adminAiUsage>>
/** 管理后台 - AI 使用情况:提问统计、场景/模式/风险/推理位置分布、趋势、用户排行(不含具体内容) */
export function AdminAiUsage() {
const [data, setData] = useState<AiUsage | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.adminAiUsage()
.then(setData)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading || !data) {
return (
<PageLayout title="AI 使用情况" description="用户 AI 使用统计(仅统计,不含具体对话内容)">
<LoadingState />
</PageLayout>
)
}
return (
<PageLayout title="AI 使用情况" description="用户 AI 使用统计(仅统计,不含具体对话内容)">
<div className="space-y-6">
{/* 概览数字 */}
<div className="grid gap-4 md:grid-cols-3">
<Card><CardContent className="py-4 text-center">
<MessageSquare className="mx-auto h-6 w-6 text-primary" />
<p className="mt-2 text-2xl font-bold">{data.total_questions}</p>
<p className="text-sm text-muted-foreground">总提问数</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<Users className="mx-auto h-6 w-6 text-primary" />
<p className="mt-2 text-2xl font-bold">{data.active_users}</p>
<p className="text-sm text-muted-foreground">活跃用户数</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<Activity className="mx-auto h-6 w-6 text-emerald-500" />
<p className="mt-2 text-2xl font-bold">{data.daily_trend.reduce((s, d) => s + d.count, 0)}</p>
<p className="text-sm text-muted-foreground">近 7 天提问</p>
</CardContent></Card>
</div>
{/* 趋势图 */}
{data.daily_trend.length > 0 && (
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">近 7 天提问趋势</p>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data.daily_trend.map(d => ({ date: d.date.slice(5), count: d.count }))}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="#3b82f6" strokeWidth={2} name="提问数" />
</LineChart>
</ResponsiveContainer>
</CardContent></Card>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* 场景分布 */}
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">按场景分布</p>
{data.by_scene.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={data.by_scene.map(s => ({ 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) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
) : <EmptyState title="暂无数据" icon={MessageSquare} />}
</CardContent></Card>
{/* 隐私模式分布 */}
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">按隐私模式分布</p>
{data.by_mode.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={data.by_mode.map(m => ({ 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) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
) : <EmptyState title="暂无数据" icon={Shield} />}
</CardContent></Card>
{/* 风险等级分布 */}
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">按风险等级分布</p>
{data.by_risk.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data.by_risk.map(r => ({ name: RISK_LABELS[r.level] || r.level, count: r.count }))}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#f59e0b" name="回复数" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : <EmptyState title="暂无数据" icon={Shield} />}
</CardContent></Card>
{/* 推理位置分布 */}
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">按推理位置分布</p>
{data.by_inference.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data.by_inference.map(r => ({ name: r.location === 'cloud' ? '云端' : '本地', count: r.count }))}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#10b981" name="回复数" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : <EmptyState title="暂无数据" icon={Activity} />}
</CardContent></Card>
</div>
{/* 用户使用排行 */}
{data.by_user.length > 0 && (
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">用户使用情况(脱敏,Top 50)</p>
<div className="space-y-2">
{data.by_user.map((u, i) => (
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
<div className="flex min-w-0 items-center gap-3">
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
<span className="font-medium">{u.phone_masked}</span>
{u.top_scene && (
<Badge variant="secondary" className="shrink-0 text-xs">
{SCENE_LABELS[u.top_scene] || u.top_scene}
</Badge>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>提问 {u.questions}</span>
<span>回复 {u.replies}</span>
<span>{u.last_active ? u.last_active.slice(0, 10) : '-'}</span>
</div>
</div>
))}
</div>
</CardContent></Card>
)}
</div>
</PageLayout>
)
}
@@ -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<ReturnType<typeof api.adminExpertActivity>>
/** 管理后台 - 专家沟通情况:咨询统计、状态/分类分布、专家统计、趋势、用户排行(不含具体内容) */
export function AdminExpertActivity() {
const [data, setData] = useState<ExpertActivity | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.adminExpertActivity()
.then(setData)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading || !data) {
return (
<PageLayout title="专家沟通情况" description="用户与专家的咨询统计(仅统计,不含具体咨询内容)">
<LoadingState />
</PageLayout>
)
}
return (
<PageLayout title="专家沟通情况" description="用户与专家的咨询统计(仅统计,不含具体咨询内容)">
<div className="space-y-6">
{/* 概览数字 */}
<div className="grid gap-4 md:grid-cols-3">
<Card><CardContent className="py-4 text-center">
<Activity className="mx-auto h-6 w-6 text-primary" />
<p className="mt-2 text-2xl font-bold">{data.total}</p>
<p className="text-sm text-muted-foreground">总咨询数</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<Shield className="mx-auto h-6 w-6 text-emerald-500" />
<p className="mt-2 text-2xl font-bold">{data.replied}</p>
<p className="text-sm text-muted-foreground">已回复</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<Shield className="mx-auto h-6 w-6 text-amber-500" />
<p className="mt-2 text-2xl font-bold">{data.pending}</p>
<p className="text-sm text-muted-foreground">待回复</p>
</CardContent></Card>
</div>
{/* 趋势图 */}
{data.daily_trend.length > 0 && (
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">近 7 天咨询趋势</p>
<ResponsiveContainer width="100%" height={200}>
<LineChart data={data.daily_trend.map(d => ({ date: d.date.slice(5), count: d.count }))}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="date" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip />
<Line type="monotone" dataKey="count" stroke="#10b981" strokeWidth={2} name="咨询数" />
</LineChart>
</ResponsiveContainer>
</CardContent></Card>
)}
<div className="grid gap-4 md:grid-cols-2">
{/* 状态分布 */}
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">按状态分布</p>
{data.by_status.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<PieChart>
<Pie data={data.by_status.map(s => ({ 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) => <Cell key={i} fill={PIE_COLORS[i % PIE_COLORS.length]} />)}
</Pie>
<Tooltip />
<Legend wrapperStyle={{ fontSize: 12 }} />
</PieChart>
</ResponsiveContainer>
) : <EmptyState title="暂无数据" icon={Activity} />}
</CardContent></Card>
{/* 分类分布 */}
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">按分类分布</p>
{data.by_category.length > 0 ? (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={data.by_category.map(c => ({ name: CATEGORY_LABELS[c.category] || c.category, count: c.count }))}>
<CartesianGrid strokeDasharray="3 3" className="opacity-30" />
<XAxis dataKey="name" tick={{ fontSize: 12 }} />
<YAxis tick={{ fontSize: 12 }} allowDecimals={false} />
<Tooltip />
<Bar dataKey="count" fill="#3b82f6" name="咨询数" radius={[4, 4, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : <EmptyState title="暂无数据" icon={Briefcase} />}
</CardContent></Card>
</div>
{/* 专家咨询统计 */}
{data.by_expert.length > 0 && (
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">专家咨询统计</p>
<div className="space-y-2">
{data.by_expert.filter(e => e.total > 0).map((e, i) => (
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
<div className="flex min-w-0 items-center gap-3">
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
<span className="font-medium">{e.name}</span>
<Badge variant="secondary" className="shrink-0 text-xs">{CATEGORY_LABELS[e.category] || e.category}</Badge>
{e.rating_count > 0 && (
<span className="text-xs text-amber-500">★ {e.avg_rating}</span>
)}
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>总 {e.total}</span>
<span className="text-emerald-500">已回 {e.replied}</span>
<span className="text-amber-500">待回 {e.pending}</span>
</div>
</div>
))}
{data.by_expert.every(e => e.total === 0) && (
<EmptyState title="暂无咨询记录" icon={Activity} />
)}
</div>
</CardContent></Card>
)}
{/* 用户咨询排行 */}
{data.by_user.length > 0 && (
<Card><CardContent className="p-4">
<p className="mb-3 text-sm font-medium">用户咨询情况(脱敏,Top 50)</p>
<div className="space-y-2">
{data.by_user.map((u, i) => (
<div key={i} className="flex flex-wrap items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm">
<div className="flex min-w-0 items-center gap-3">
<span className="w-6 shrink-0 text-xs text-muted-foreground">{i + 1}</span>
<span className="font-medium">{u.phone_masked}</span>
</div>
<div className="flex flex-wrap items-center gap-3 text-xs text-muted-foreground">
<span>咨询 {u.consultations}</span>
<span>{u.last_active ? u.last_active.slice(0, 10) : '-'}</span>
</div>
</div>
))}
</div>
</CardContent></Card>
)}
</div>
</PageLayout>
)
}
+311
View File
@@ -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<Expert[]>([])
const [loading, setLoading] = useState(true)
const [toggling, setToggling] = useState<number | null>(null)
const [editing, setEditing] = useState<Expert | null>(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 (
<PageLayout title="专家管理" description="专家上下架管理">
<LoadingState />
</PageLayout>
)
}
return (
<PageLayout title="专家管理" description="专家上下架、编辑信息、上传头像">
{experts.length === 0 ? (
<EmptyState title="暂无专家" icon={Briefcase} />
) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
{experts.map((ex) => {
const stars = (ex.rate / 100).toFixed(1)
return (
<Card key={ex.id}>
<CardContent className="p-5 pt-5">
{/* 头部:长方形头像(左) + 姓名/状态(右) */}
<div className="flex items-start gap-4">
<ExpertAvatar name={ex.name} avatarUrl={ex.avatar_url} size="xl" shape="rect" className="shrink-0" />
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<h3 className="font-medium truncate">{ex.name}</h3>
{ex.status === 'active'
? <Badge>上架中</Badge>
: <Badge variant="outline">已下架</Badge>}
</div>
<p className="mt-0.5 text-sm text-muted-foreground truncate">{ex.title}</p>
<div className="mt-1 flex items-center gap-1 text-xs">
<Star className="h-3 w-3 fill-amber-400 text-amber-400" />
<span>{stars}</span>
<span className="text-muted-foreground">({ex.rating_count}人评价)</span>
</div>
{/* 简介 */}
{ex.bio && (
<p className="mt-2 text-sm text-muted-foreground line-clamp-2">{ex.bio}</p>
)}
{/* 专长标签 */}
{ex.expertise && (
<div className="mt-2 flex flex-wrap gap-1">
{ex.expertise.split(',').map((tag, i) => (
<Badge key={i} variant="outline" className="text-xs">{tag.trim()}</Badge>
))}
</div>
)}
</div>
</div>
{/* 底部:分类 + 操作按钮 */}
<div className="mt-3 flex items-center justify-between gap-2 border-t pt-3">
<Badge variant="secondary">{CATEGORY_LABELS[ex.category] || ex.category}</Badge>
<div className="flex gap-1.5">
<Button
size="sm"
variant="outline"
onClick={() => setEditing(ex)}
>
<Pencil className="h-3.5 w-3.5" /> 编辑
</Button>
<Button
size="sm"
variant={ex.status === 'active' ? 'destructive' : 'default'}
onClick={() => handleToggle(ex)}
disabled={toggling === ex.id}
>
{toggling === ex.id ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> :
ex.status === 'active' ? <PowerOff className="h-3.5 w-3.5" /> : <Power className="h-3.5 w-3.5" />}
{ex.status === 'active' ? '下架' : '上架'}
</Button>
</div>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* 编辑弹窗 */}
{editing && (
<EditExpertDialog
expert={editing}
onClose={() => setEditing(null)}
onSuccess={() => { loadExperts(); setEditing(null) }}
/>
)}
</PageLayout>
)
}
/** 编辑专家信息弹窗:含头像上传 + 表单字段 */
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<HTMLInputElement>(null)
const handleUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Dialog open onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>编辑专家信息</DialogTitle>
<DialogDescription>修改专家资料、上传头像</DialogDescription>
</DialogHeader>
<div className="max-h-[70vh] space-y-4 overflow-y-auto py-2">
{/* 头像上传区 */}
<div className="flex items-center gap-4">
<ExpertAvatar name={name || expert.name} avatarUrl={avatarUrl} size="2xl" shape="rect" />
<div className="space-y-2">
<p className="text-sm font-medium">专家头像</p>
<p className="text-xs text-muted-foreground">支持 JPG / PNG / WebP,最大 5MB</p>
<input
ref={fileInputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
className="hidden"
onChange={handleUpload}
/>
<div className="flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={() => fileInputRef.current?.click()}
disabled={uploading}
>
{uploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
{uploading ? '上传中…' : avatarUrl ? '更换图片' : '选择图片'}
</Button>
{avatarUrl && (
<Button
size="sm"
variant="outline"
className="text-rose-600 hover:bg-rose-50"
onClick={handleRemoveAvatar}
disabled={uploading}
>
<Trash2 className="h-3.5 w-3.5" /> 删除
</Button>
)}
</div>
</div>
</div>
{/* 姓名 */}
<div className="space-y-1.5">
<Label htmlFor="edit-name">姓名 *</Label>
<Input id="edit-name" value={name} onChange={(e) => setName(e.target.value)} placeholder="专家姓名" />
</div>
{/* 分类 */}
<div className="space-y-1.5">
<Label>分类</Label>
<Select value={category} onValueChange={(v) => setCategory(v as Expert['category'])}>
<SelectTrigger><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="legal">法务</SelectItem>
<SelectItem value="decision">经营</SelectItem>
<SelectItem value="emotion">情绪</SelectItem>
<SelectItem value="life">生活</SelectItem>
</SelectContent>
</Select>
</div>
{/* 职称 */}
<div className="space-y-1.5">
<Label htmlFor="edit-title">职称 / 头衔</Label>
<Input id="edit-title" value={title} onChange={(e) => setTitle(e.target.value)} placeholder="如:高级律师" />
</div>
{/* 简介 */}
<div className="space-y-1.5">
<Label htmlFor="edit-bio">简介</Label>
<Textarea id="edit-bio" value={bio} onChange={(e) => setBio(e.target.value)} placeholder="专家背景介绍" className="min-h-[80px]" />
</div>
{/* 专长 */}
<div className="space-y-1.5">
<Label htmlFor="edit-expertise">专长(逗号分隔)</Label>
<Input id="edit-expertise" value={expertise} onChange={(e) => setExpertise(e.target.value)} placeholder="如:劳动用工,合同审查,股权纠纷" />
<p className="text-xs text-muted-foreground">多个专长用英文逗号分隔</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleSave} disabled={saving}>
{saving ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : null}
保存
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
+61
View File
@@ -0,0 +1,61 @@
import { useEffect, useState } from 'react'
import { Shield, Users, Briefcase, BarChart3 } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { LoadingState } from '@/components/common/LoadingState'
import { Card, CardContent } from '@/components/ui/card'
import { api } from '@/lib/api'
/** 管理后台 - 平台统计:注册用户、专家、服务关系、咨询概览 */
export function AdminStats() {
const [stats, setStats] = useState<Record<string, number> | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.adminStats()
.then(setStats)
.catch(() => {})
.finally(() => setLoading(false))
}, [])
if (loading) {
return (
<PageLayout title="平台统计" description="用户、专家、服务关系和咨询概览">
<LoadingState />
</PageLayout>
)
}
return (
<PageLayout title="平台统计" description="用户、专家、服务关系和咨询概览">
{stats && (
<div className="grid gap-4 md:grid-cols-3">
<Card><CardContent className="py-4 text-center">
<Users className="mx-auto h-6 w-6 text-primary" />
<p className="mt-2 text-2xl font-bold">{stats.users}</p>
<p className="text-sm text-muted-foreground">注册用户</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<Briefcase className="mx-auto h-6 w-6 text-primary" />
<p className="mt-2 text-2xl font-bold">{stats.experts_active}/{stats.experts_total}</p>
<p className="text-sm text-muted-foreground">上架/总专家</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<BarChart3 className="mx-auto h-6 w-6 text-primary" />
<p className="mt-2 text-2xl font-bold">{stats.services_active}</p>
<p className="text-sm text-muted-foreground">服务中关系</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<Shield className="mx-auto h-6 w-6 text-primary" />
<p className="mt-2 text-2xl font-bold">{stats.consultations_total}</p>
<p className="text-sm text-muted-foreground">总咨询数</p>
</CardContent></Card>
<Card><CardContent className="py-4 text-center">
<Shield className="mx-auto h-6 w-6 text-amber-500" />
<p className="mt-2 text-2xl font-bold">{stats.consultations_pending}</p>
<p className="text-sm text-muted-foreground">待回复咨询</p>
</CardContent></Card>
</div>
)}
</PageLayout>
)
}
+332
View File
@@ -0,0 +1,332 @@
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', {
timeZone: 'Asia/Shanghai',
month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit',
})
}
export function AdminUsers() {
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)
// 重置密码弹窗
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)
}
}, [])
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="平台注册用户列表,含对话统计、活跃度和账号管理"
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}
/>
) : (
<div className="space-y-2">
{users.map((u) => (
<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>
)
}
+24
View File
@@ -0,0 +1,24 @@
/** 管理后台共享常量:标签映射、图表配色 */
export const CATEGORY_LABELS: Record<string, string> = {
legal: '法务', decision: '经营', emotion: '情绪', life: '生活',
}
export const SCENE_LABELS: Record<string, string> = {
legal: '私人法务', decision: '经营决策', life: '生活助理', emotion: '情绪中心', unknown: '未知',
}
export const MODE_LABELS: Record<string, string> = {
normal: '普通模式', confidential: '保密模式',
}
export const RISK_LABELS: Record<string, string> = {
green: '低风险', amber: '需补证', orange: '建议复核', red: '高风险', unknown: '未知',
}
export const STATUS_LABELS: Record<string, string> = {
pending: '待回复', replied: '已回复', closed: '已关闭',
}
/** 饼图配色 */
export const PIE_COLORS = ['#3b82f6', '#f59e0b', '#10b981', '#ef4444', '#8b5cf6', '#6b7280']
+6
View File
@@ -0,0 +1,6 @@
/** 管理后台页面集合 barrel 导出 */
export { AdminStats } from './AdminStats'
export { AdminAiUsage } from './AdminAiUsage'
export { AdminExpertActivity } from './AdminExpertActivity'
export { AdminUsers } from './AdminUsers'
export { AdminExperts } from './AdminExperts'
@@ -0,0 +1,61 @@
import { useState } from 'react'
import { MessageSquare, Send } from 'lucide-react'
import { EmptyState } from '@/components/common/EmptyState'
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'
/** 专家工作台 - 待回复咨询:用户发起的咨询,专家在此回复 */
export function WorkbenchPending() {
const { expert, consultations, loading, error, loadData } = useWorkbenchData()
const [replyTarget, setReplyTarget] = useState<Consultation | null>(null)
const pendingConsultations = consultations.filter(c => c.status === 'pending')
return (
<WorkbenchShell expert={expert} loading={loading} error={error} title="待回复咨询" description="用户发起的咨询,在此回复">
{pendingConsultations.length === 0 ? (
<EmptyState title="暂无待回复咨询" description="用户发起咨询后会显示在这里" icon={MessageSquare} />
) : (
<div className="space-y-3">
{pendingConsultations.map((c) => (
<Card key={c.id}>
<CardContent className="py-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<p className="truncate text-sm font-medium">{c.topic}</p>
<Badge variant="destructive" className="shrink-0 text-xs">待回复</Badge>
</div>
<p className="mt-0.5 text-xs text-muted-foreground">用户:{maskPhone(c.user_phone || '')}</p>
{c.question && (
<div className="mt-2 text-sm text-muted-foreground">
<Markdown content={c.question} />
</div>
)}
{c.context && (
<div className="mt-2 rounded-md bg-muted/30 p-2 text-xs text-muted-foreground">
<p className="mb-1 font-medium">AI 对话上下文:</p>
<Markdown content={c.context} />
</div>
)}
<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" /> 回复
</Button>
</div>
</CardContent>
</Card>
))}
</div>
)}
<ReplyDialog target={replyTarget} onClose={() => setReplyTarget(null)} onSuccess={loadData} />
</WorkbenchShell>
)
}
@@ -0,0 +1,55 @@
import { useState } from 'react'
import { toast } from 'sonner'
import { Check, Loader2 } from 'lucide-react'
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'
/** 专家工作台 - 待确认服务:用户选定专家后,专家确认接单 */
export function WorkbenchPendingServices() {
const { expert, services, loading, error, loadData } = useWorkbenchData()
const [accepting, setAccepting] = useState<number | null>(null)
const pendingServices = services.filter(s => s.status === 'pending')
const handleAccept = async (serviceId: number) => {
setAccepting(serviceId)
try {
await api.acceptService(serviceId)
toast.success('已确认服务关系')
loadData()
} catch (e) {
toast.error(e instanceof Error ? e.message : '确认失败')
} finally {
setAccepting(null)
}
}
return (
<WorkbenchShell expert={expert} loading={loading} error={error} title="待确认服务" description="用户选定你后,在此确认接单">
{pendingServices.length === 0 ? (
<EmptyState title="暂无待确认的服务" description="用户选定你后会显示在这里" icon={Check} />
) : (
<div className="space-y-2">
{pendingServices.map((s) => (
<Card key={s.service_id}>
<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">请求时间:{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" />}
确认接单
</Button>
</CardContent>
</Card>
))}
</div>
)}
</WorkbenchShell>
)
}
@@ -0,0 +1,43 @@
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'
/** 专家工作台 - 已回复咨询:查看已回复过的咨询详情 */
export function WorkbenchReplied() {
const { expert, consultations, loading, error } = useWorkbenchData()
const [detailTarget, setDetailTarget] = useState<Consultation | null>(null)
const repliedConsultations = consultations.filter(c => c.status === 'replied')
return (
<WorkbenchShell expert={expert} loading={loading} error={error} title="已回复咨询" description="回复过的咨询,点击查看详情">
{repliedConsultations.length === 0 ? (
<EmptyState title="暂无已回复咨询" description="回复过的咨询会显示在这里" icon={Star} />
) : (
<div className="space-y-2">
{repliedConsultations.map((c) => (
<Card key={c.id} className="cursor-pointer transition hover:bg-muted/30" onClick={() => setDetailTarget(c)}>
<CardContent className="py-3">
<div className="flex items-center justify-between">
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-medium">{c.topic}</p>
<p className="mt-0.5 text-xs text-muted-foreground">用户:{maskPhone(c.user_phone || '')}</p>
</div>
<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">回复时间:{formatTime(c.replied_at)}</p>
</CardContent>
</Card>
))}
</div>
)}
<ConsultationDetailDialog target={detailTarget} onClose={() => setDetailTarget(null)} />
</WorkbenchShell>
)
}
+110
View File
@@ -0,0 +1,110 @@
import { useMemo, useState } from 'react'
import { toast } from 'sonner'
import { Briefcase, Loader2, XCircle } from 'lucide-react'
import { EmptyState } from '@/components/common/EmptyState'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import {
Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter,
} from '@/components/ui/dialog'
import type { Consultation, ExpertService } from '@/types'
import { api } from '@/lib/api'
import { useWorkbenchData, WorkbenchShell, maskPhone } from './shared'
/** 专家工作台 - 服务中用户:已确认服务关系的用户列表,可取消服务 */
export function WorkbenchUsers() {
const { expert, services, consultations, loading, error, loadData } = useWorkbenchData()
const [cancelService, setCancelService] = useState<ExpertService | null>(null)
const [cancelling, setCancelling] = useState(false)
const activeServices = services.filter(s => s.status === 'active')
/** 按服务用户分组咨询 */
const consultationsByUser = useMemo(() => {
const map = new Map<string, Consultation[]>()
for (const c of consultations) {
const phone = c.user_phone || 'unknown'
if (!map.has(phone)) {
map.set(phone, [])
}
map.get(phone)!.push(c)
}
return map
}, [consultations])
/** 确认取消服务关系 */
const handleCancel = async () => {
if (!cancelService) return
setCancelling(true)
try {
await api.cancelExpertService(cancelService.service_id)
toast.success('服务关系已取消')
setCancelService(null)
loadData()
} catch (e) {
toast.error(e instanceof Error ? e.message : '取消失败')
} finally {
setCancelling(false)
}
}
return (
<WorkbenchShell expert={expert} loading={loading} error={error} title="服务中用户" description="已确认服务关系的用户">
{activeServices.length === 0 ? (
<EmptyState title="暂无服务中用户" description="确认接单的用户会显示在这里" icon={Briefcase} />
) : (
<div className="space-y-2">
{activeServices.map((s) => {
const userConsultations = consultationsByUser.get(s.user_phone || '') || []
return (
<Card key={s.service_id}>
<CardContent className="p-4">
<div className="flex items-center justify-between gap-2">
<div className="min-w-0">
<p className="text-sm font-medium">用户:{maskPhone(s.user_phone || '')}</p>
<p className="text-xs text-muted-foreground">
签约时间:{s.signed_at}
{userConsultations.length > 0 && ` · ${userConsultations.length} 条咨询`}
</p>
</div>
<Badge className="shrink-0">服务中</Badge>
</div>
<div className="mt-3 border-t pt-3">
<Button
size="sm"
variant="outline"
className="text-rose-600 hover:bg-rose-50"
onClick={() => setCancelService(s)}
>
<XCircle className="h-3.5 w-3.5" /> 取消服务
</Button>
</div>
</CardContent>
</Card>
)
})}
</div>
)}
{/* 取消服务确认弹窗 */}
<Dialog open={!!cancelService} onOpenChange={(o) => !o && setCancelService(null)}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle>取消服务关系</DialogTitle>
<DialogDescription>
确认取消与用户「{cancelService ? maskPhone(cancelService.user_phone || '') : ''}」的服务关系?取消后不可恢复。
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCancelService(null)}>再想想</Button>
<Button variant="destructive" onClick={handleCancel} disabled={cancelling}>
{cancelling ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <XCircle className="h-3.5 w-3.5" />}
确认取消
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</WorkbenchShell>
)
}
+5
View File
@@ -0,0 +1,5 @@
/** 专家工作台页面集合 barrel 导出 */
export { WorkbenchPendingServices } from './WorkbenchPendingServices'
export { WorkbenchPending } from './WorkbenchPending'
export { WorkbenchReplied } from './WorkbenchReplied'
export { WorkbenchUsers } from './WorkbenchUsers'
+203
View File
@@ -0,0 +1,203 @@
import { useEffect, useState, useCallback } from 'react'
import { toast } from 'sonner'
import { Briefcase, Loader2, Send, MessageSquare, FileText, Check } from 'lucide-react'
import { PageLayout } from '@/components/common/PageLayout'
import { EmptyState } from '@/components/common/EmptyState'
import { LoadingState } from '@/components/common/LoadingState'
import { Markdown } from '@/components/common/Markdown'
import { Button } from '@/components/ui/button'
import { Textarea } from '@/components/ui/textarea'
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'
/** 脱敏手机号 */
export function maskPhone(phone: string): string {
if (!phone || phone === 'unknown') return '未知用户'
if (phone.length >= 8) return phone.slice(0, 3) + '****' + phone.slice(-4)
return phone
}
/** 专家工作台数据加载 hook */
export function useWorkbenchData() {
const [expert, setExpert] = useState<Expert | null>(null)
const [services, setServices] = useState<ExpertService[]>([])
const [consultations, setConsultations] = useState<Consultation[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const loadData = useCallback(async () => {
setLoading(true)
setError(null)
try {
const res = await api.expertWorkbench()
setExpert(res.expert)
setServices(res.services)
setConsultations(res.consultations)
} catch (e) {
setError(e instanceof Error ? e.message : '加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadData() }, [loadData])
return { expert, services, consultations, loading, error, loadData }
}
/** 回复弹窗 */
export function ReplyDialog({
target,
onClose,
onSuccess,
}: {
target: Consultation | null
onClose: () => void
onSuccess: () => void
}) {
const [replyText, setReplyText] = useState('')
const [replying, setReplying] = useState(false)
const handleReply = async () => {
if (!target || !replyText.trim()) return
setReplying(true)
try {
await api.replyConsultation(target.id, replyText.trim())
toast.success('回复已发送')
onClose()
setReplyText('')
onSuccess()
} catch (e) {
toast.error(e instanceof Error ? e.message : '回复失败')
} finally {
setReplying(false)
}
}
return (
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>回复咨询</DialogTitle>
<DialogDescription>{target?.topic}</DialogDescription>
</DialogHeader>
<Textarea
value={replyText}
onChange={(e) => setReplyText(e.target.value)}
placeholder="输入你的专业回复…"
className="min-h-[120px]"
/>
<DialogFooter>
<Button variant="outline" onClick={onClose}>取消</Button>
<Button onClick={handleReply} disabled={replying || !replyText.trim()}>
{replying ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
发送回复
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 咨询详情弹窗 */
export function ConsultationDetailDialog({
target,
onClose,
}: {
target: Consultation | null
onClose: () => void
}) {
return (
<Dialog open={!!target} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-w-xl">
<DialogHeader>
<DialogTitle>咨询详情</DialogTitle>
<DialogDescription>{target?.topic}</DialogDescription>
</DialogHeader>
<div className="max-h-[60vh] space-y-4 overflow-y-auto">
{/* 用户问题 */}
<div>
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<MessageSquare className="h-3.5 w-3.5" /> 用户问题
</p>
<div className="rounded-md bg-muted/30 p-3 text-sm">
<Markdown content={target?.question || ''} />
</div>
</div>
{/* AI 上下文 */}
{target?.context && (
<div>
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<FileText className="h-3.5 w-3.5" /> AI 对话上下文
</p>
<div className="rounded-md bg-muted/20 p-3 text-xs text-muted-foreground">
<Markdown content={target.context} />
</div>
</div>
)}
{/* 专家回复 */}
<div>
<p className="mb-1 flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
<Check className="h-3.5 w-3.5 text-emerald-500" /> 专家回复
</p>
<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">回复时间:{formatTime(target?.replied_at)}</p>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={onClose}>关闭</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
/** 工作台页面外壳:处理 loading/error/非专家的通用逻辑 */
export function WorkbenchShell({
expert,
loading,
error,
title,
description,
children,
}: {
expert: Expert | null
loading: boolean
error: string | null
title: string
description?: string
children: React.ReactNode
}) {
if (loading) {
return (
<PageLayout title={title} description={description}>
<LoadingState />
</PageLayout>
)
}
if (error) {
return (
<PageLayout title={title} description={description}>
<EmptyState title="无法访问" description={error} icon={Briefcase} />
</PageLayout>
)
}
if (!expert) {
return (
<PageLayout title={title} description={description}>
<EmptyState title="您不是注册专家" description="请联系管理员开通专家账号" icon={Briefcase} />
</PageLayout>
)
}
return (
<PageLayout title={title} description={description || `${expert.name} · ${expert.title}`}>
{children}
</PageLayout>
)
}