feat: 迁移专家工作台和管理后台到web2
专家工作台(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:
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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']
|
||||
@@ -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'
|
||||
Reference in New Issue
Block a user