- 品牌统一为"左右(BossAi)":网页标签、侧边栏、登录页、manifest、后端 - 用户管理:搜索、对话统计、重置密码、激活/停用账号 - 用户画像:确认事实步骤(带入内容+追问内容)、版本历史管理、PUT /profile/me - 对话历史:按场景分组、保密模式标题处理 - AI助手页面:话题区可收起、模式选择紧凑化、footer免责声明 - 设置页:toggle宽度修复、工单文案改为专家咨询 - 去掉工单功能(前后端) Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
332 lines
12 KiB
TypeScript
332 lines
12 KiB
TypeScript
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', {
|
|
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 className="h-8 w-8" />}
|
|
/>
|
|
) : (
|
|
<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>
|
|
)
|
|
}
|