feat: 平台管理员端 — SUPER_ADMIN 角色 + 企业租户管理 + 用户管理 + 数据总览

This commit is contained in:
selfrelease
2026-07-29 11:49:48 +08:00
parent fb36b10402
commit 987a4678f7
17 changed files with 1744 additions and 14 deletions
+47
View File
@@ -37,6 +37,13 @@ const HealthCheck = lazy(() => import('./pages/tools/HealthCheck'))
const AnnualValueReport = lazy(() => import('./pages/tools/AnnualValueReport'))
const CalendarPage = lazy(() => import('./pages/Calendar'))
// 平台管理端
const PlatformLogin = lazy(() => import('./pages/platform/PlatformLogin'))
const PlatformDashboard = lazy(() => import('./pages/platform/PlatformDashboard'))
const PlatformOrgs = lazy(() => import('./pages/platform/PlatformOrgs'))
const PlatformUsers = lazy(() => import('./pages/platform/PlatformUsers'))
const PlatformSidebar = lazy(() => import('./components/layout/PlatformSidebar'))
function ProtectedRoute({ children }: { children: React.ReactNode }) {
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated) return <Navigate to="/login" replace />
@@ -67,6 +74,40 @@ function AdminLayout({ children }: { children: React.ReactNode }) {
)
}
function PlatformRoute({ children }: { children: React.ReactNode }) {
const user = useAuthStore((s) => s.user)
const isAuthenticated = useAuthStore((s) => s.isAuthenticated)
if (!isAuthenticated || user?.role !== 'SUPER_ADMIN') return <Navigate to="/platform/login" replace />
return <>{children}</>
}
function PlatformLayout({ children }: { children: React.ReactNode }) {
const [sidebarOpen, setSidebarOpen] = useState(false)
return (
<div className="flex min-h-screen bg-slate-100">
<PlatformSidebar mobileOpen={sidebarOpen} onClose={() => setSidebarOpen(false)} />
<div className="flex-1 flex flex-col min-w-0">
<header className="h-14 bg-slate-950 border-b border-slate-800 flex items-center px-4 shrink-0">
<button
className="md:hidden mr-3 text-slate-400"
onClick={() => setSidebarOpen(true)}
aria-label="打开菜单"
>
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><line x1="3" y1="6" x2="21" y2="6"/><line x1="3" y1="12" x2="21" y2="12"/><line x1="3" y1="18" x2="21" y2="18"/></svg>
</button>
<span className="text-sm text-slate-400"></span>
<span className="ml-2 px-2 py-0.5 rounded text-[10px] font-bold bg-amber-400 text-slate-950">ADMIN</span>
</header>
<main className="flex-1 py-6 px-4 md:px-6">
<div className="max-w-7xl mx-auto">
<Suspense fallback={<SkeletonPage />}>{children}</Suspense>
</div>
</main>
</div>
</div>
)
}
function PortalLayoutWrapper({ children, showNav = true }: { children: React.ReactNode; showNav?: boolean }) {
if (!showNav) {
return (
@@ -112,6 +153,12 @@ export default function App() {
<Route path="/tools/health-check" element={<ProtectedRoute><AdminLayout><HealthCheck /></AdminLayout></ProtectedRoute>} />
<Route path="/tools/annual-value" element={<ProtectedRoute><AdminLayout><AnnualValueReport /></AdminLayout></ProtectedRoute>} />
{/* 平台管理端 */}
<Route path="/platform/login" element={<Suspense fallback={<SkeletonPage />}><PlatformLogin /></Suspense>} />
<Route path="/platform/dashboard" element={<PlatformRoute><PlatformLayout><PlatformDashboard /></PlatformLayout></PlatformRoute>} />
<Route path="/platform/orgs" element={<PlatformRoute><PlatformLayout><PlatformOrgs /></PlatformLayout></PlatformRoute>} />
<Route path="/platform/users" element={<PlatformRoute><PlatformLayout><PlatformUsers /></PlatformLayout></PlatformRoute>} />
{/* 员工端 */}
<Route path="/portal/login" element={<PortalLayoutWrapper showNav={false}><PortalLogin /></PortalLayoutWrapper>} />
<Route path="/portal/payslip" element={<PortalLayoutWrapper><Payslip /></PortalLayoutWrapper>} />
@@ -0,0 +1,150 @@
/**
* 平台管理端侧边栏导航
* 深色主题,amber 主色,区别于企业端
*/
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useState } from 'react'
import clsx from 'clsx'
import {
LayoutDashboard, Building2, Users, Shield, LogOut,
ChevronDown, ChevronRight,
} from 'lucide-react'
import { useAuthStore } from '../../store/authStore'
interface NavItem {
path: string
label: string
icon: typeof LayoutDashboard
}
const navGroups: { title: string; items: NavItem[] }[] = [
{
title: '平台管理',
items: [
{ path: '/platform/dashboard', label: '数据总览', icon: LayoutDashboard },
{ path: '/platform/orgs', label: '企业租户', icon: Building2 },
{ path: '/platform/users', label: '用户管理', icon: Users },
],
},
]
export default function PlatformSidebar({ mobileOpen, onClose }: { mobileOpen: boolean; onClose: () => void }) {
const location = useLocation()
const navigate = useNavigate()
const { user, logout } = useAuthStore()
const [expandedGroups, setExpandedGroups] = useState<Set<string>>(new Set(navGroups.map(g => g.title)))
const toggleGroup = (title: string) => {
setExpandedGroups(prev => {
const next = new Set(prev)
if (next.has(title)) next.delete(title)
else next.add(title)
return next
})
}
const isActive = (path: string) => location.pathname.startsWith(path)
const handleLogout = () => {
logout()
navigate('/platform/login')
}
return (
<>
{mobileOpen && (
<div className="fixed inset-0 bg-black/60 z-40 md:hidden" onClick={onClose} aria-label="关闭侧边栏" />
)}
<aside
className={clsx(
'fixed md:sticky top-0 left-0 z-50 md:z-auto',
'w-56 h-screen flex-shrink-0',
'bg-slate-950 border-r border-slate-800',
'flex flex-col',
'transition-transform duration-200',
mobileOpen ? 'translate-x-0' : '-translate-x-full md:translate-x-0',
)}
>
{/* Logo 区 */}
<div className="h-14 flex items-center gap-2 px-4 border-b border-slate-800 shrink-0">
<div className="w-7 h-7 rounded-md bg-amber-400 flex items-center justify-center">
<Shield className="w-4 h-4 text-slate-950" />
</div>
<div>
<div className="font-bold text-sm text-white"></div>
<div className="text-[10px] text-amber-400 tracking-wider">ADMIN</div>
</div>
</div>
{/* 导航菜单 */}
<nav className="flex-1 overflow-y-auto py-3 px-2 space-y-3">
{navGroups.map((group) => {
const isExpanded = expandedGroups.has(group.title)
const hasActiveItem = group.items.some(item => isActive(item.path))
return (
<div key={group.title}>
<button
onClick={() => toggleGroup(group.title)}
className={clsx(
'flex items-center justify-between w-full px-2 py-1.5 text-xs font-medium rounded-md transition-colors',
hasActiveItem ? 'text-slate-200' : 'text-slate-400 hover:text-slate-200',
)}
>
<span>{group.title}</span>
{isExpanded ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
</button>
{isExpanded && (
<div className="mt-0.5 space-y-0.5">
{group.items.map((item) => {
const Icon = item.icon
const active = isActive(item.path)
return (
<Link
key={item.path}
to={item.path}
onClick={onClose}
className={clsx(
'flex items-center gap-2 px-2 py-1.5 rounded-md text-sm transition-colors',
active
? 'bg-amber-400/10 text-amber-400 font-medium'
: 'text-slate-400 hover:bg-slate-800 hover:text-slate-200',
)}
>
<Icon className="w-4 h-4 flex-shrink-0" />
<span className="truncate">{item.label}</span>
</Link>
)
})}
</div>
)}
</div>
)
})}
</nav>
{/* 底部用户区 */}
<div className="border-t border-slate-800 p-3 shrink-0">
<div className="flex items-center gap-2 mb-2">
<div className="w-8 h-8 rounded-full bg-slate-800 flex items-center justify-center text-amber-400 text-sm font-medium">
{user?.name?.charAt(0) || 'A'}
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-white truncate">{user?.name}</div>
<div className="text-[10px] text-amber-400">SUPER_ADMIN</div>
</div>
</div>
<button
onClick={handleLogout}
className="flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm text-slate-400 hover:bg-slate-800 hover:text-slate-200 transition-colors"
>
<LogOut className="w-4 h-4" />
<span>退</span>
</button>
</div>
</aside>
</>
)
}
@@ -0,0 +1,122 @@
/**
* 平台总览页 — 企业数、员工数、套餐分布、最近注册企业
*/
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { Building2, Users, FileText, Calculator, TrendingUp } from 'lucide-react'
import api from '../../lib/api'
interface DashboardData {
totalOrgs: number
totalUsers: number
totalEmployees: number
totalContracts: number
totalPayslips: number
orgsByPlan: { plan: string; count: number }[]
recentOrgs: {
id: string; name: string; plan: string; city: string
createdAt: string; maxEmployees: number
employeeCount: number; userCount: number
}[]
}
const PLAN_LABELS: Record<string, string> = { FREE: '免费版', PRO: '专业版', ENTERPRISE: '企业版' }
const PLAN_COLORS: Record<string, string> = { FREE: 'bg-gray-100 text-gray-700', PRO: 'bg-blue-50 text-blue-700', ENTERPRISE: 'bg-purple-50 text-purple-700' }
export default function PlatformDashboard() {
const [data, setData] = useState<DashboardData | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
api.get('/platform/dashboard').then((res: any) => setData(res.data)).finally(() => setLoading(false))
}, [])
if (loading) return <div className="text-center py-12 text-slate-400">...</div>
if (!data) return null
const stats = [
{ label: '企业总数', value: data.totalOrgs, icon: Building2, color: 'text-amber-400' },
{ label: '用户总数', value: data.totalUsers, icon: Users, color: 'text-blue-400' },
{ label: '员工总数', value: data.totalEmployees, icon: Users, color: 'text-emerald-400' },
{ label: '合同总数', value: data.totalContracts, icon: FileText, color: 'text-purple-400' },
{ label: '工资条总数', value: data.totalPayslips, icon: Calculator, color: 'text-rose-400' },
]
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-sm text-slate-400 mt-1"></p>
</div>
{/* 统计卡片 */}
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-5 gap-4">
{stats.map((s) => {
const Icon = s.icon
return (
<div key={s.label} className="bg-slate-900 border border-slate-800 rounded-lg p-4">
<div className="flex items-center justify-between mb-2">
<span className="text-sm text-slate-400">{s.label}</span>
<Icon className={`w-5 h-5 ${s.color}`} />
</div>
<div className="text-2xl font-bold text-white">{s.value.toLocaleString()}</div>
</div>
)
})}
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* 套餐分布 */}
<div className="bg-slate-900 border border-slate-800 rounded-lg p-5">
<h2 className="text-sm font-semibold text-white mb-4 flex items-center gap-2">
<TrendingUp className="w-4 h-4 text-amber-400" />
</h2>
<div className="space-y-3">
{data.orgsByPlan.map((item) => (
<div key={item.plan} className="flex items-center justify-between">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PLAN_COLORS[item.plan] || 'bg-gray-100 text-gray-700'}`}>
{PLAN_LABELS[item.plan] || item.plan}
</span>
<div className="flex items-center gap-2 flex-1 ml-3">
<div className="flex-1 h-2 bg-slate-800 rounded-full overflow-hidden">
<div
className="h-full bg-amber-400 rounded-full"
style={{ width: `${data.totalOrgs > 0 ? (item.count / data.totalOrgs) * 100 : 0}%` }}
/>
</div>
<span className="text-sm text-slate-300 w-8 text-right">{item.count}</span>
</div>
</div>
))}
</div>
</div>
{/* 最近注册企业 */}
<div className="bg-slate-900 border border-slate-800 rounded-lg p-5">
<div className="flex items-center justify-between mb-4">
<h2 className="text-sm font-semibold text-white"></h2>
<Link to="/platform/orgs" className="text-xs text-amber-400 hover:underline"> </Link>
</div>
<div className="space-y-2">
{data.recentOrgs.map((org) => (
<Link
key={org.id}
to={`/platform/orgs?id=${org.id}`}
className="flex items-center justify-between p-2 rounded-md hover:bg-slate-800 transition-colors"
>
<div className="min-w-0">
<div className="text-sm text-white truncate">{org.name}</div>
<div className="text-xs text-slate-500">{org.city || '未设置'} · {org.employeeCount} </div>
</div>
<span className={`px-2 py-0.5 rounded text-xs font-medium shrink-0 ${PLAN_COLORS[org.plan] || 'bg-gray-100 text-gray-700'}`}>
{PLAN_LABELS[org.plan] || org.plan}
</span>
</Link>
))}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,118 @@
/**
* 平台管理员登录页
* 独立入口 /platform/login,仅 SUPER_ADMIN 角色可登录
*/
import { useState } from 'react'
import { useNavigate, Link } from 'react-router-dom'
import { Eye, EyeOff, Shield } from 'lucide-react'
import { useForm } from 'react-hook-form'
import { zodResolver } from '@hookform/resolvers/zod'
import { z } from 'zod'
import { useAuthStore } from '../../store/authStore'
import api from '../../lib/api'
import { Input, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
const schema = z.object({
phone: z.string().regex(/^1[3-9]\d{9}$/, '手机号格式不正确'),
password: z.string().min(1, '请输入密码'),
})
type FormData = z.infer<typeof schema>
export default function PlatformLogin() {
const navigate = useNavigate()
const { setAuth } = useAuthStore()
const [showPassword, setShowPassword] = useState(false)
const [error, setError] = useState('')
const [loading, setLoading] = useState(false)
const { register, handleSubmit, formState: { errors } } = useForm<FormData>({
resolver: zodResolver(schema),
defaultValues: {
phone: '13800000000',
password: 'admin123456',
},
})
const onSubmit = async (data: FormData) => {
setError('')
setLoading(true)
try {
const res = await api.post('/auth/platform-login', data) as any
setAuth(res.data.user, res.data.accessToken, res.data.refreshToken)
navigate('/platform/dashboard')
} catch (err: any) {
setError(err.response?.data?.error?.message || '登录失败,请稍后重试')
} finally {
setLoading(false)
}
}
return (
<div className="min-h-screen flex items-center justify-center bg-slate-950 px-4">
<div className="w-full max-w-sm">
<div className="flex items-center justify-center gap-2 mb-8">
<div className="w-10 h-10 rounded-lg bg-amber-400 flex items-center justify-center">
<Shield className="w-6 h-6 text-slate-950" />
</div>
<div>
<div className="text-xl font-bold text-white"></div>
<div className="text-xs text-amber-400">PLATFORM ADMIN</div>
</div>
</div>
<div className="bg-slate-900 border border-slate-800 rounded-lg p-6">
<h1 className="text-lg font-semibold text-white mb-1"></h1>
<p className="text-xs text-slate-400 mb-4"></p>
{error && (
<div className="mb-4 px-3 py-2 rounded-md bg-red-950 border border-red-800 text-red-400 text-sm">{error}</div>
)}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label className="text-slate-300"></Label>
<Input
type="tel"
placeholder="请输入手机号"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500"
{...register('phone')}
maxLength={11}
/>
{errors.phone && <p className="text-xs text-red-400 mt-1">{errors.phone.message}</p>}
</div>
<div>
<Label className="text-slate-300"></Label>
<div className="relative">
<Input
type={showPassword ? 'text' : 'password'}
placeholder="请输入密码"
className="bg-slate-800 border-slate-700 text-white placeholder:text-slate-500 pr-10"
{...register('password')}
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-2 top-1/2 -translate-y-1/2 text-slate-400"
>
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
</div>
{errors.password && <p className="text-xs text-red-400 mt-1">{errors.password.message}</p>}
</div>
<Button type="submit" className="w-full bg-amber-400 text-slate-950 hover:bg-amber-300" disabled={loading}>
{loading ? '登录中...' : '登录'}
</Button>
</form>
<div className="mt-4 text-center">
<Link to="/login" className="text-sm text-slate-400 hover:text-slate-200"> </Link>
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,246 @@
/**
* 企业租户管理页 — 列表、搜索、查看详情、编辑套餐、删除
*/
import { useEffect, useState } from 'react'
import { Search, Building2, Eye, Trash2, Edit2 } from 'lucide-react'
import api from '../../lib/api'
import { Input, Select, Label } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
interface Org {
id: string; name: string; plan: string; maxEmployees: number
city: string | null; contactName: string | null; contactPhone: string | null
payrollFrequency: number; retirementReminderEnabled: boolean
createdAt: string; updatedAt: string
employeeCount: number; userCount: number; contractCount: number; payslipCount: number
}
const PLAN_LABELS: Record<string, string> = { FREE: '免费版', PRO: '专业版', ENTERPRISE: '企业版' }
const PLAN_COLORS: Record<string, string> = { FREE: 'bg-gray-100 text-gray-700', PRO: 'bg-blue-50 text-blue-700', ENTERPRISE: 'bg-purple-50 text-purple-700' }
export default function PlatformOrgs() {
const [orgs, setOrgs] = useState<Org[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [planFilter, setPlanFilter] = useState('')
const [loading, setLoading] = useState(true)
const [editOrg, setEditOrg] = useState<Org | null>(null)
const [deleteOrg, setDeleteOrg] = useState<Org | null>(null)
const fetchOrgs = async () => {
setLoading(true)
try {
const params: any = { page, pageSize }
if (search) params.search = search
if (planFilter) params.plan = planFilter
const res = await api.get('/platform/orgs', { params }) as any
setOrgs(res.data.list)
setTotal(res.data.total)
} finally {
setLoading(false)
}
}
useEffect(() => { fetchOrgs() }, [page, planFilter])
useEffect(() => { setPage(1) }, [search, planFilter])
const totalPages = Math.ceil(total / pageSize)
const handleSaveEdit = async () => {
if (!editOrg) return
try {
await api.put(`/platform/orgs/${editOrg.id}`, {
name: editOrg.name,
plan: editOrg.plan,
maxEmployees: editOrg.maxEmployees,
city: editOrg.city,
contactName: editOrg.contactName,
contactPhone: editOrg.contactPhone,
})
setEditOrg(null)
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '保存失败')
}
}
const handleDelete = async () => {
if (!deleteOrg) return
try {
await api.delete(`/platform/orgs/${deleteOrg.id}`)
setDeleteOrg(null)
fetchOrgs()
} catch (err: any) {
alert(err.response?.data?.error?.message || '删除失败')
}
}
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-sm text-slate-400 mt-1"> {total} </p>
</div>
{/* 搜索栏 */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
placeholder="搜索企业名称、城市、联系人..."
className="bg-slate-900 border-slate-700 text-white placeholder:text-slate-500 pl-10"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchOrgs()}
/>
</div>
<Select
className="bg-slate-900 border-slate-700 text-white w-32"
value={planFilter}
onChange={(e) => setPlanFilter(e.target.value)}
>
<option value=""></option>
<option value="FREE"></option>
<option value="PRO"></option>
<option value="ENTERPRISE"></option>
</Select>
<Button variant="secondary" onClick={fetchOrgs} className="bg-slate-800 text-slate-200 border border-slate-700 hover:bg-slate-700">
</Button>
</div>
{/* 表格 */}
<div className="bg-slate-900 border border-slate-800 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800 text-slate-400 text-xs">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-right px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={8} className="text-center py-12 text-slate-400">...</td></tr>
) : orgs.length === 0 ? (
<tr><td colSpan={8} className="text-center py-12 text-slate-400"></td></tr>
) : orgs.map((org) => (
<tr key={org.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
<td className="px-4 py-3">
<div className="text-white font-medium">{org.name}</div>
<div className="text-xs text-slate-500">{org.contactName || '-'} {org.contactPhone || ''}</div>
</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${PLAN_COLORS[org.plan] || 'bg-gray-100 text-gray-700'}`}>
{PLAN_LABELS[org.plan] || org.plan}
</span>
</td>
<td className="px-4 py-3 text-slate-300">{org.city || '-'}</td>
<td className="px-4 py-3 text-right text-slate-300">{org.employeeCount}</td>
<td className="px-4 py-3 text-right text-slate-300">{org.userCount}</td>
<td className="px-4 py-3 text-right text-slate-300">{org.contractCount}</td>
<td className="px-4 py-3 text-slate-400 text-xs">{new Date(org.createdAt).toLocaleDateString('zh-CN')}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-center gap-1">
<button
onClick={() => setEditOrg(org)}
className="p-1.5 rounded text-slate-400 hover:text-amber-400 hover:bg-slate-800"
title="编辑"
>
<Edit2 className="w-4 h-4" />
</button>
<button
onClick={() => setDeleteOrg(org)}
className="p-1.5 rounded text-slate-400 hover:text-red-400 hover:bg-slate-800"
title="删除"
>
<Trash2 className="w-4 h-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
{/* 分页 */}
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
<span className="text-xs text-slate-400"> {page} / {totalPages} {total} </span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)} className="bg-slate-800 text-slate-200 border border-slate-700"></Button>
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)} className="bg-slate-800 text-slate-200 border border-slate-700"></Button>
</div>
</div>
)}
</div>
{/* 编辑弹窗 */}
{editOrg && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={() => setEditOrg(null)}>
<div className="bg-slate-900 border border-slate-700 rounded-lg p-6 w-full max-w-md" onClick={(e) => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-white mb-4"></h2>
<div className="space-y-3">
<div>
<Label className="text-slate-300"></Label>
<Input className="bg-slate-800 border-slate-700 text-white" value={editOrg.name} onChange={(e) => setEditOrg({ ...editOrg, name: e.target.value })} />
</div>
<div>
<Label className="text-slate-300"></Label>
<Select className="bg-slate-800 border-slate-700 text-white" value={editOrg.plan} onChange={(e) => setEditOrg({ ...editOrg, plan: e.target.value })}>
<option value="FREE"></option>
<option value="PRO"></option>
<option value="ENTERPRISE"></option>
</Select>
</div>
<div>
<Label className="text-slate-300"></Label>
<Input type="number" className="bg-slate-800 border-slate-700 text-white" value={editOrg.maxEmployees} onChange={(e) => setEditOrg({ ...editOrg, maxEmployees: parseInt(e.target.value) || 0 })} />
</div>
<div>
<Label className="text-slate-300"></Label>
<Input className="bg-slate-800 border-slate-700 text-white" value={editOrg.city || ''} onChange={(e) => setEditOrg({ ...editOrg, city: e.target.value })} />
</div>
<div className="flex gap-2 pt-2">
<Button className="bg-amber-400 text-slate-950 hover:bg-amber-300 flex-1" onClick={handleSaveEdit}></Button>
<Button variant="secondary" className="bg-slate-800 text-slate-200 border border-slate-700" onClick={() => setEditOrg(null)}></Button>
</div>
</div>
</div>
</div>
)}
{/* 删除确认 */}
{deleteOrg && (
<div className="fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4" onClick={() => setDeleteOrg(null)}>
<div className="bg-slate-900 border border-slate-700 rounded-lg p-6 w-full max-w-sm" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center gap-3 mb-4">
<div className="w-10 h-10 rounded-full bg-red-950 flex items-center justify-center">
<Trash2 className="w-5 h-5 text-red-400" />
</div>
<div>
<h2 className="text-lg font-semibold text-white"></h2>
<p className="text-xs text-slate-400"></p>
</div>
</div>
<p className="text-sm text-slate-300 mb-4">
<span className="text-white font-medium">{deleteOrg.name}</span>
{deleteOrg.employeeCount} {deleteOrg.contractCount} {deleteOrg.payslipCount}
</p>
<div className="flex gap-2">
<Button variant="danger" className="flex-1" onClick={handleDelete}></Button>
<Button variant="secondary" className="bg-slate-800 text-slate-200 border border-slate-700" onClick={() => setDeleteOrg(null)}></Button>
</div>
</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,166 @@
/**
* 用户管理页 — 查看所有企业用户、搜索、启用/禁用
*/
import { useEffect, useState } from 'react'
import { Search, Ban, CheckCircle } from 'lucide-react'
import api from '../../lib/api'
import { Input, Select } from '../../components/ui/Input'
import Button from '../../components/ui/Button'
interface UserItem {
id: string; name: string; phone: string; role: string
disabled: boolean; lastLoginAt: string | null; createdAt: string
orgName: string | null
}
const ROLE_LABELS: Record<string, string> = {
SUPER_ADMIN: '超级管理员', ADMIN: '管理员', HR: 'HR', VIEWER: '只读',
}
const ROLE_COLORS: Record<string, string> = {
SUPER_ADMIN: 'bg-amber-100 text-amber-800', ADMIN: 'bg-blue-50 text-blue-700',
HR: 'bg-emerald-50 text-emerald-700', VIEWER: 'bg-gray-100 text-gray-600',
}
export default function PlatformUsers() {
const [users, setUsers] = useState<UserItem[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [pageSize] = useState(20)
const [search, setSearch] = useState('')
const [orgFilter, setOrgFilter] = useState('')
const [loading, setLoading] = useState(true)
const [orgs, setOrgs] = useState<{ id: string; name: string }[]>([])
const fetchUsers = async () => {
setLoading(true)
try {
const params: any = { page, pageSize }
if (search) params.search = search
if (orgFilter) params.orgId = orgFilter
const res = await api.get('/platform/users', { params }) as any
setUsers(res.data.list)
setTotal(res.data.total)
} finally {
setLoading(false)
}
}
useEffect(() => {
api.get('/platform/orgs', { params: { pageSize: 200 } }).then((res: any) => {
setOrgs(res.data.list.map((o: any) => ({ id: o.id, name: o.name })))
})
}, [])
useEffect(() => { fetchUsers() }, [page, orgFilter])
useEffect(() => { setPage(1) }, [search, orgFilter])
const handleToggle = async (user: UserItem) => {
try {
await api.put(`/platform/users/${user.id}/toggle`)
fetchUsers()
} catch (err: any) {
alert(err.response?.data?.error?.message || '操作失败')
}
}
const totalPages = Math.ceil(total / pageSize)
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-sm text-slate-400 mt-1"> {total} </p>
</div>
{/* 搜索栏 */}
<div className="flex flex-wrap gap-3">
<div className="relative flex-1 min-w-[200px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input
placeholder="搜索姓名、手机号..."
className="bg-slate-900 border-slate-700 text-white placeholder:text-slate-500 pl-10"
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && fetchUsers()}
/>
</div>
<Select
className="bg-slate-900 border-slate-700 text-white w-40"
value={orgFilter}
onChange={(e) => setOrgFilter(e.target.value)}
>
<option value=""></option>
{orgs.map((o) => <option key={o.id} value={o.id}>{o.name}</option>)}
</Select>
<Button variant="secondary" onClick={fetchUsers} className="bg-slate-800 text-slate-200 border border-slate-700 hover:bg-slate-700">
</Button>
</div>
{/* 表格 */}
<div className="bg-slate-900 border border-slate-800 rounded-lg overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-slate-800 text-slate-400 text-xs">
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-left px-4 py-3 font-medium"></th>
<th className="text-center px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody>
{loading ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400">...</td></tr>
) : users.length === 0 ? (
<tr><td colSpan={7} className="text-center py-12 text-slate-400"></td></tr>
) : users.map((user) => (
<tr key={user.id} className="border-b border-slate-800/50 hover:bg-slate-800/30">
<td className="px-4 py-3 text-white font-medium">{user.name}</td>
<td className="px-4 py-3 text-slate-300">{user.phone}</td>
<td className="px-4 py-3">
<span className={`px-2 py-0.5 rounded text-xs font-medium ${ROLE_COLORS[user.role] || 'bg-gray-100 text-gray-600'}`}>
{ROLE_LABELS[user.role] || user.role}
</span>
</td>
<td className="px-4 py-3 text-slate-300">{user.orgName || '-'}</td>
<td className="px-4 py-3">
{user.disabled
? <span className="text-xs text-red-400"></span>
: <span className="text-xs text-emerald-400"></span>}
</td>
<td className="px-4 py-3 text-slate-400 text-xs">
{user.lastLoginAt ? new Date(user.lastLoginAt).toLocaleString('zh-CN') : '从未登录'}
</td>
<td className="px-4 py-3 text-center">
<button
onClick={() => handleToggle(user)}
className={`inline-flex items-center gap-1 px-2 py-1 rounded text-xs transition-colors ${
user.disabled
? 'text-emerald-400 hover:bg-slate-800'
: 'text-red-400 hover:bg-slate-800'
}`}
>
{user.disabled ? <><CheckCircle className="w-3 h-3" /> </> : <><Ban className="w-3 h-3" /> </>}
</button>
</td>
</tr>
))}
</tbody>
</table>
{totalPages > 1 && (
<div className="flex items-center justify-between px-4 py-3 border-t border-slate-800">
<span className="text-xs text-slate-400"> {page} / {totalPages} {total} </span>
<div className="flex gap-2">
<Button size="sm" variant="secondary" disabled={page <= 1} onClick={() => setPage(page - 1)} className="bg-slate-800 text-slate-200 border border-slate-700"></Button>
<Button size="sm" variant="secondary" disabled={page >= totalPages} onClick={() => setPage(page + 1)} className="bg-slate-800 text-slate-200 border border-slate-700"></Button>
</div>
</div>
)}
</div>
</div>
)
}
+2 -2
View File
@@ -3,10 +3,10 @@ import { persist } from 'zustand/middleware'
interface User {
id: string
orgId: string
orgId: string | null
name: string
phone: string
role: 'ADMIN' | 'HR' | 'VIEWER'
role: 'SUPER_ADMIN' | 'ADMIN' | 'HR' | 'VIEWER'
}
interface AuthState {