优化几个小问题

This commit is contained in:
yml2213
2026-08-31 16:37:13 +08:00
parent 198ff9e1fb
commit 05a88c1822
4 changed files with 849 additions and 375 deletions
+136 -68
View File
@@ -3,21 +3,69 @@ import { Form, Input, Button, message } from 'antd'
import {
MessageOutlined, UserOutlined, LockOutlined,
SafetyCertificateOutlined, CrownOutlined, ArrowRightOutlined,
CheckCircleFilled,
CheckCircleFilled, CustomerServiceOutlined,
} from '@ant-design/icons'
import { useNavigate } from 'react-router-dom'
import { useAuth } from '@/stores/auth'
type DemoRole = {
key: string
title: string
username: string
pass: string
icon: typeof CrownOutlined
}
const demoRoles: DemoRole[] = [
{ key: 'tenant', title: '租户管理', username: 'kefu_admin', pass: 'kefu_admin123', icon: CrownOutlined },
{ key: 'admin', title: '平台管理', username: 'platform_admin', pass: 'kefu_admin123', icon: SafetyCertificateOutlined },
{ key: 'agent', title: '坐席人员', username: 'agent1', pass: 'kefu_admin123', icon: CustomerServiceOutlined },
]
const Login = () => {
const [form] = Form.useForm()
const [loading, setLoading] = useState(false)
const [selectedRole, setSelectedRole] = useState<string>('tenant')
// 记住密码状态持久化
const [remember, setRemember] = useState<boolean>(() => {
return localStorage.getItem('kefu_remember_me') === 'true'
})
const [username, setUsername] = useState<string>(() => {
const isRemembered = localStorage.getItem('kefu_remember_me') === 'true'
if (isRemembered) {
return localStorage.getItem('kefu_saved_username') || 'kefu_admin'
}
return 'kefu_admin'
})
const [password, setPassword] = useState<string>(() => {
const isRemembered = localStorage.getItem('kefu_remember_me') === 'true'
if (isRemembered) {
return localStorage.getItem('kefu_saved_password') || 'kefu_admin123'
}
return 'kefu_admin123'
})
const { login } = useAuth()
const navigate = useNavigate()
const onFinish = async (values: { username: string; password: string }) => {
const onFinish = async (values: { username?: string; password?: string }) => {
setLoading(true)
const u = (values.username || username).trim()
const p = (values.password || password).trim()
try {
const user = await login(values.username, values.password)
const user = await login(u, p)
if (remember) {
localStorage.setItem('kefu_remember_me', 'true')
localStorage.setItem('kefu_saved_username', u)
localStorage.setItem('kefu_saved_password', p)
} else {
localStorage.removeItem('kefu_remember_me')
localStorage.removeItem('kefu_saved_username')
localStorage.removeItem('kefu_saved_password')
}
message.success('登录成功')
navigate(user.role === 'platform_admin' ? '/admin/dashboard' : '/agent/dashboard', { replace: true })
} catch {
@@ -27,27 +75,31 @@ const Login = () => {
}
}
const fillCredentials = (u: string, p: string) => {
form.setFieldsValue({ username: u, password: p })
const selectDemoRole = (role: DemoRole) => {
setSelectedRole(role.key)
setUsername(role.username)
setPassword(role.pass)
form.setFieldsValue({ username: role.username, password: role.pass })
}
return (
<div className="min-h-screen relative flex flex-col justify-between bg-gradient-to-br from-slate-900 via-slate-950 to-indigo-950 p-4 sm:p-6 overflow-hidden select-none">
{/* 动态光晕与网格背景 */}
<div className="absolute top-[-10%] left-[-10%] w-[600px] h-[600px] rounded-full bg-blue-500/15 blur-[140px] pointer-events-none" />
<div className="absolute bottom-[-10%] right-[-10%] w-[700px] h-[700px] rounded-full bg-indigo-500/15 blur-[160px] pointer-events-none" />
<div className="absolute top-[30%] right-[10%] w-[400px] h-[400px] rounded-full bg-cyan-400/10 blur-[120px] pointer-events-none" />
<div className="min-h-screen w-full relative flex flex-col justify-between bg-slate-950 p-4 sm:p-6 overflow-y-auto select-none">
{/* 科技感深邃渐变背景 */}
<div className="fixed inset-0 bg-gradient-to-br from-slate-950 via-[#0a0f1d] to-[#0d1527] pointer-events-none" />
<div className="fixed top-[-10%] left-[-10%] w-[550px] h-[550px] rounded-full bg-blue-600/15 blur-[140px] pointer-events-none" />
<div className="fixed bottom-[-10%] right-[-10%] w-[650px] h-[650px] rounded-full bg-indigo-600/15 blur-[160px] pointer-events-none" />
<div className="fixed top-[40%] right-[20%] w-[350px] h-[350px] rounded-full bg-cyan-500/10 blur-[120px] pointer-events-none" />
{/* 顶部品牌微标 */}
<header className="relative z-10 w-full max-w-5xl mx-auto flex items-center justify-between py-2">
{/* 顶栏品牌信息 */}
<header className="relative z-10 w-full max-w-4xl mx-auto flex items-center justify-between py-3">
<div className="flex items-center gap-2.5">
<div className="w-8 h-8 rounded-xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-white shadow-md shadow-blue-500/30">
<div className="w-8 h-8 rounded-xl bg-gradient-to-tr from-blue-600 to-indigo-500 flex items-center justify-center text-white shadow-md shadow-blue-500/30 border border-white/20">
<MessageOutlined className="text-base" />
</div>
<span className="text-base font-bold text-white tracking-tight">Kefu Cloud</span>
</div>
<div className="flex items-center gap-2">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-white/10 text-slate-300 border border-white/10 backdrop-blur-md">
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-slate-900/80 text-slate-300 border border-slate-700/60 backdrop-blur-md">
<span className="w-1.5 h-1.5 rounded-full bg-emerald-400 animate-pulse" />
系统服务正常
</span>
@@ -55,19 +107,48 @@ const Login = () => {
</header>
{/* 核心登录卡片 */}
<main className="relative z-10 flex-1 flex items-center justify-center my-6">
<div className="w-full max-w-[430px]">
<div className="relative rounded-3xl bg-slate-900/70 border border-slate-700/60 p-8 sm:p-9 shadow-2xl backdrop-blur-2xl">
{/* 顶端光效描边 */}
<div className="absolute inset-x-0 -top-px h-px bg-gradient-to-r from-transparent via-blue-500/60 to-transparent" />
<main className="relative z-10 flex-1 flex items-center justify-center py-6">
<div className="w-full max-w-[420px]">
<div className="relative rounded-3xl bg-slate-900/90 border border-slate-800 shadow-2xl p-7 sm:p-8 backdrop-blur-xl">
{/* 顶端亮色发光描边 */}
<div className="absolute inset-x-0 -top-px h-px bg-gradient-to-r from-transparent via-blue-500/50 to-transparent" />
{/* 卡片头部 */}
<div className="text-center mb-8">
<div className="inline-flex items-center justify-center w-14 h-14 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-500 text-white shadow-xl shadow-blue-500/30 mb-4 border border-white/20">
<MessageOutlined className="text-2xl" />
<div className="text-center mb-6">
<div className="inline-flex items-center justify-center w-13 h-13 rounded-2xl bg-gradient-to-tr from-blue-600 to-indigo-500 text-white shadow-lg shadow-blue-500/25 mb-3 border border-white/20">
<MessageOutlined className="text-xl" />
</div>
<h1 className="text-xl font-bold text-white tracking-tight m-0">登录客服云平台</h1>
<p className="text-xs text-slate-400 mt-1 m-0">全渠道多租户智能客服与运营管理</p>
</div>
{/* 顶部一键填入演示账号切换 Tab */}
<div className="mb-6">
<div className="flex items-center justify-between text-[11px] text-slate-400 mb-2 font-medium">
<span>快捷填入演示账号:</span>
<span className="text-slate-500">点击自动填入</span>
</div>
<div className="grid grid-cols-3 gap-1.5 p-1 rounded-xl bg-slate-950/80 border border-slate-800/80">
{demoRoles.map(role => {
const Icon = role.icon
const active = selectedRole === role.key
return (
<button
key={role.key}
type="button"
onClick={() => selectDemoRole(role)}
className={`flex flex-col items-center justify-center py-2 px-1 rounded-lg text-xs font-medium border-0 cursor-pointer transition-all ${
active
? 'bg-blue-600 text-white shadow-sm font-semibold'
: 'bg-transparent text-slate-400 hover:text-slate-200 hover:bg-slate-800/50'
}`}
>
<Icon className="text-xs mb-1" />
<span className="text-[11px] leading-none">{role.title}</span>
</button>
)
})}
</div>
<h1 className="text-2xl font-bold text-white tracking-tight m-0">欢迎登录客服云</h1>
<p className="text-xs text-slate-400 mt-2 m-0 font-normal">全渠道多租户智能客服工作台</p>
</div>
{/* 登录表单 */}
@@ -80,9 +161,14 @@ const Login = () => {
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300">账号 / 用户名</label>
<Input
value={username}
onChange={e => {
setUsername(e.target.value)
form.setFieldsValue({ username: e.target.value })
}}
prefix={<UserOutlined className="text-slate-400 mr-1.5 text-sm" />}
placeholder="请输入登录账号"
className="!h-11 !rounded-xl !bg-slate-800/80 !border-slate-700 !text-slate-100 placeholder:!text-slate-500 hover:!border-blue-500 focus:!border-blue-500 focus:!ring-2 focus:!ring-blue-500/20 transition-all text-sm"
className="login-dark-input !h-11 !rounded-xl !text-slate-100 placeholder:!text-slate-500 transition-all text-sm"
/>
</div>
</Form.Item>
@@ -90,19 +176,40 @@ const Login = () => {
<Form.Item
name="password"
rules={[{ required: true, message: '请输入密码' }]}
className="!mb-6"
className="!mb-4"
>
<div className="space-y-1.5">
<label className="block text-xs font-semibold text-slate-300">密码</label>
<Input.Password
value={password}
onChange={e => {
setPassword(e.target.value)
form.setFieldsValue({ password: e.target.value })
}}
prefix={<LockOutlined className="text-slate-400 mr-1.5 text-sm" />}
placeholder="请输入登录密码"
className="!h-11 !rounded-xl !bg-slate-800/80 !border-slate-700 !text-slate-100 placeholder:!text-slate-500 hover:!border-blue-500 focus:!border-blue-500 focus:!ring-2 focus:!ring-blue-500/20 transition-all text-sm"
className="login-dark-input !h-11 !rounded-xl !text-slate-100 placeholder:!text-slate-500 transition-all text-sm"
/>
</div>
</Form.Item>
<Form.Item className="!mb-5">
{/* 记住密码勾选栏 */}
<div className="flex items-center justify-between mb-5">
<label className="flex items-center gap-2 cursor-pointer text-xs text-slate-300 hover:text-white select-none transition-colors">
<input
type="checkbox"
checked={remember}
onChange={e => setRemember(e.target.checked)}
className="w-4 h-4 rounded bg-slate-800 border-slate-700 text-blue-600 focus:ring-blue-500/20 cursor-pointer accent-blue-600"
/>
<span className="font-medium">记住密码</span>
</label>
<span className="text-[11px] text-slate-500 hover:text-slate-400 cursor-pointer">
忘记密码请联系管理员
</span>
</div>
<Form.Item className="!mb-2">
<Button
type="primary"
htmlType="submit"
@@ -115,51 +222,12 @@ const Login = () => {
</Button>
</Form.Item>
</Form>
{/* 快捷体验账号 */}
<div className="pt-5 border-t border-slate-800/90">
<div className="flex items-center justify-between text-[11px] font-medium text-slate-400 mb-2.5">
<span>一键填入演示账号</span>
<span className="text-slate-500">点击自动填充</span>
</div>
<div className="grid grid-cols-2 gap-2.5">
<button
type="button"
onClick={() => fillCredentials('kefu_admin', 'kefu_admin123')}
className="group relative flex items-center justify-between p-2.5 rounded-xl bg-slate-800/60 hover:bg-slate-800 border border-slate-700/70 hover:border-blue-500/60 text-left transition-all cursor-pointer shadow-2xs"
>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-200 group-hover:text-blue-400 transition-colors">
<CrownOutlined className="text-amber-400 text-xs" />
<span>租户管理员</span>
</div>
<div className="text-[10px] text-slate-400 mt-0.5">kefu_admin</div>
</div>
<span className="text-[10px] text-slate-500 group-hover:text-slate-300 font-mono">填入</span>
</button>
<button
type="button"
onClick={() => fillCredentials('admin', 'admin123')}
className="group relative flex items-center justify-between p-2.5 rounded-xl bg-slate-800/60 hover:bg-slate-800 border border-slate-700/70 hover:border-indigo-500/60 text-left transition-all cursor-pointer shadow-2xs"
>
<div className="min-w-0">
<div className="flex items-center gap-1.5 text-xs font-semibold text-slate-200 group-hover:text-indigo-400 transition-colors">
<SafetyCertificateOutlined className="text-blue-400 text-xs" />
<span>平台管理员</span>
</div>
<div className="text-[10px] text-slate-400 mt-0.5">admin</div>
</div>
<span className="text-[10px] text-slate-500 group-hover:text-slate-300 font-mono">填入</span>
</button>
</div>
</div>
</div>
</div>
</main>
{/* 底部功能特性与版权 */}
<footer className="relative z-10 w-full max-w-5xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-slate-500 py-2 border-t border-white/5">
<footer className="relative z-10 w-full max-w-4xl mx-auto flex flex-col sm:flex-row items-center justify-between gap-2 text-xs text-slate-500 py-3 border-t border-slate-800/80">
<div className="flex items-center gap-4 text-[11px]">
<span className="inline-flex items-center gap-1"><CheckCircleFilled className="text-emerald-500 text-xs" /> 多租户数据隔离</span>
<span className="inline-flex items-center gap-1"><CheckCircleFilled className="text-emerald-500 text-xs" /> WebSocket 实时长连</span>
+207 -99
View File
@@ -1,8 +1,13 @@
import { useState, useEffect, useCallback } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import { Button, Spin, Card, Checkbox, Divider, Input, Select, message, Tag, Breadcrumb } from 'antd'
import { SaveOutlined, ArrowLeftOutlined } from '@ant-design/icons'
import { getPermissions, getRoleDetail, updateRole, updateBuiltinRole, type Permission, type RoleDetail } from '@/services/api'
import { Button, Spin, Checkbox, Input, Select, message, Breadcrumb } from 'antd'
import {
SaveOutlined, ArrowLeftOutlined, SafetyCertificateOutlined,
} from '@ant-design/icons'
import {
getPermissions, getRoleDetail, updateRole, updateBuiltinRole,
type Permission, type RoleDetail,
} from '@/services/api'
interface ModulePerms {
module: string
@@ -35,7 +40,7 @@ export default function RoleConfig() {
setRoleName(detailRes.data?.role.name || '')
setRoleDesc(detailRes.data?.role.desc || '')
} catch {
message.error('加载失败')
message.error('加载角色权限数据失败')
} finally {
setLoading(false)
}
@@ -46,20 +51,20 @@ export default function RoleConfig() {
const toggle = (code: string) => {
setSelected(prev => {
const next = new Set(prev)
const permission = allPerms.find(item => item.code === code)
if (!permission) return next
const modulePermissions = allPerms.filter(item => item.module === permission.module)
const viewCodes = modulePermissions.filter(item => item.category === 'view').map(item => item.code)
if (next.has(code)) {
const permission = allPerms.find(item => item.code === code)
if (!permission) return next
const modulePermissions = allPerms.filter(item => item.module === permission.module)
const viewCodes = modulePermissions.filter(item => item.category === 'view').map(item => item.code)
if (next.has(code)) {
next.delete(code)
if (permission.category === 'view' && !viewCodes.some(viewCode => next.has(viewCode))) {
modulePermissions.filter(item => item.category === 'operate').forEach(item => next.delete(item.code))
}
if (permission.category === 'view' && !viewCodes.some(viewCode => next.has(viewCode))) {
modulePermissions.filter(item => item.category === 'operate').forEach(item => next.delete(item.code))
}
} else {
next.add(code)
if (permission.category === 'operate' && viewCodes.length > 0) {
next.add(viewCodes[0])
}
if (permission.category === 'operate' && viewCodes.length > 0) {
next.add(viewCodes[0])
}
}
return next
})
@@ -71,11 +76,11 @@ export default function RoleConfig() {
try {
const permCodes = Array.from(selected)
if (detail.role.type === 'builtin') {
await updateBuiltinRole(Number(id), { desc: roleDesc, permissions: permCodes, data_scopes: dataScopes })
await updateBuiltinRole(Number(id), { desc: roleDesc, permissions: permCodes, data_scopes: dataScopes })
} else {
await updateRole(Number(id), { name: roleName, desc: roleDesc, permissions: permCodes, data_scopes: dataScopes })
await updateRole(Number(id), { name: roleName, desc: roleDesc, permissions: permCodes, data_scopes: dataScopes })
}
message.success('权限已保存,下次请求即时生效')
message.success('权限配置已保存,实时生效')
} catch {
message.error('保存失败')
} finally {
@@ -83,100 +88,203 @@ export default function RoleConfig() {
}
}
const moduleMap = new Map<string, Permission[]>()
for (const permission of allPerms) {
moduleMap.set(permission.module, [...(moduleMap.get(permission.module) || []), permission])
}
const modules: ModulePerms[] = Array.from(moduleMap, ([module, params]) => ({ module, params }))
const scopeModuleByLabel: Record<string, string> = {
工作台: 'session', 客户管理: 'customer', 对话记录: 'chat_history', 数据统计: 'statistics',
}
const moduleMap = new Map<string, Permission[]>()
for (const permission of allPerms) {
moduleMap.set(permission.module, [...(moduleMap.get(permission.module) || []), permission])
}
const modules: ModulePerms[] = Array.from(moduleMap, ([module, params]) => ({ module, params }))
const scopeModuleByLabel: Record<string, string> = {
工作台: 'session', 客户管理: 'customer', 对话记录: 'chat_history', 数据统计: 'statistics',
}
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
if (!detail) return <div className="h-full flex items-center justify-center text-neutral-400">角色不存在</div>
if (!detail) return <div className="h-full flex items-center justify-center text-slate-400 text-xs">角色不存在</div>
const { role } = detail
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-4xl mx-auto">
<Breadcrumb
className="mb-4"
items={[
{ title: <a onClick={() => navigate('/agent/roles')}>角色管理</a> },
{ title: `配置角色:${role.name}` },
]}
/>
<div className="h-full flex flex-col min-h-0 bg-slate-50/60 overflow-hidden">
{/* 顶栏 56px */}
<header
className="shrink-0 px-6 flex items-center justify-between border-b border-slate-200/80 bg-white"
style={{ height: 'var(--header-height)' }}
>
<div className="flex items-center gap-3 min-w-0">
<Button
type="text"
icon={<ArrowLeftOutlined />}
onClick={() => navigate('/agent/roles')}
className="!h-8 !px-2 text-slate-600 hover:text-slate-900"
>
返回
</Button>
<div className="h-4 w-px bg-slate-200" />
<Breadcrumb
items={[
{ title: <a onClick={() => navigate('/agent/roles')} className="text-xs text-slate-500">角色管理</a> },
{ title: <span className="text-xs font-semibold text-slate-800">{role.name}</span> },
]}
/>
</div>
<Card className="mb-4 shadow-sm">
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
<span className="text-blue-500 font-semibold">{role.name.slice(0, 1)}</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-lg font-semibold text-neutral-900">{role.name}</span>
<Tag color={role.type === 'builtin' ? 'blue' : 'green'}>
{role.type === 'builtin' ? '内置角色' : '自定义角色'}
</Tag>
{role.type === 'builtin' && (
<span className="text-xs text-neutral-400">标识:{role.code} · {detail.member_count} 位成员</span>
<div className="flex items-center gap-2">
<Button
type="primary"
icon={<SaveOutlined />}
onClick={handleSave}
loading={saving}
className="!h-8 !px-4 !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 !rounded-lg shadow-sm shadow-blue-500/20"
>
保存配置
</Button>
</div>
</header>
{/* 主配置区 */}
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
<div className="max-w-5xl mx-auto space-y-4">
{/* 角色基础概览卡片 */}
<div className="rounded-2xl bg-white border border-slate-200/80 p-5 shadow-xs">
<div className="flex items-start gap-4">
<div className="w-11 h-11 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center text-xl shrink-0 border border-blue-100 shadow-2xs">
<SafetyCertificateOutlined />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2.5 flex-wrap">
<h2 className="text-base font-bold text-slate-900 m-0">{role.name}</h2>
<span
className={`inline-flex px-2 py-0.5 rounded-md text-[10px] font-semibold ${
role.type === 'builtin'
? 'bg-blue-50 text-blue-600 border border-blue-100'
: 'bg-emerald-50 text-emerald-600 border border-emerald-100'
}`}
>
{role.type === 'builtin' ? '系统内置角色' : '自定义角色'}
</span>
<span className="text-xs text-slate-400 font-medium ml-auto">
已勾选 <span className="font-bold text-blue-600">{selected.size}</span> 项权限 · 关联 {detail.member_count} 位成员
</span>
</div>
{role.type === 'custom' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">角色名称</label>
<Input
value={roleName}
maxLength={50}
onChange={e => setRoleName(e.target.value)}
placeholder="角色名称"
className="!rounded-xl !h-9 text-xs"
/>
</div>
<div>
<label className="block text-xs font-semibold text-slate-600 mb-1">角色职责描述</label>
<Input
value={roleDesc}
maxLength={200}
onChange={e => setRoleDesc(e.target.value)}
placeholder="角色描述"
className="!rounded-xl !h-9 text-xs"
/>
</div>
</div>
) : (
<div className="mt-3">
<label className="block text-xs font-semibold text-slate-600 mb-1">角色职责描述</label>
<Input
value={roleDesc}
maxLength={200}
onChange={e => setRoleDesc(e.target.value)}
placeholder="角色描述"
className="!rounded-xl !h-9 text-xs"
/>
</div>
)}
</div>
{role.type === 'custom' ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-3 mt-3">
<Input value={roleName} maxLength={50} onChange={event => setRoleName(event.target.value)} placeholder="角色名称" />
<Input value={roleDesc} maxLength={200} onChange={event => setRoleDesc(event.target.value)} placeholder="角色描述" />
</div>
) : (
<Input value={roleDesc} maxLength={200} onChange={event => setRoleDesc(event.target.value)} className="mt-3" placeholder="角色描述" />
)}
</div>
</div>
</Card>
{modules.map(mod => (
<Card
key={mod.module}
className="mb-4 shadow-sm"
title={mod.module}
extra={scopeModuleByLabel[mod.module] ? (
<Select
className="w-32"
disabled={role.code === 'admin'}
value={dataScopes[scopeModuleByLabel[mod.module]] || 'self'}
onChange={scope => setDataScopes(prev => ({ ...prev, [scopeModuleByLabel[mod.module]]: scope }))}
options={[{ label: '全部数据', value: 'all' }, { label: '仅自己', value: 'self' }]}
/>
) : undefined}
>
<div className="flex flex-col gap-2">
{mod.params.map(p => (
<label
key={p.code}
className="flex items-center gap-3 py-1.5 cursor-pointer hover:bg-neutral-50 px-2 rounded"
>
<Checkbox
checked={selected.has(p.code)}
onChange={() => toggle(p.code)}
disabled={role.type === 'builtin' && role.code === 'admin' && p.code.startsWith('permission.')}
/>
<span className="text-sm text-neutral-700">{p.name}</span>
<Tag className="ml-auto" color={p.category === 'view' ? 'default' : 'blue'}>
{p.category === 'view' ? '查看' : '操作'}
</Tag>
</label>
))}
</div>
</Card>
))}
{/* 权限项分组卡片 */}
<div className="space-y-4">
{modules.map(mod => {
const scopeKey = scopeModuleByLabel[mod.module]
const currentScope = scopeKey ? (dataScopes[scopeKey] || 'self') : null
<Divider />
<div className="flex justify-end gap-3">
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/agent/roles')}>返回</Button>
<Button type="primary" icon={<SaveOutlined />} onClick={handleSave} loading={saving}>
保存
</Button>
return (
<div key={mod.module} className="rounded-2xl bg-white border border-slate-200/80 shadow-xs overflow-hidden">
<div className="flex items-center justify-between px-5 py-3.5 bg-slate-50/80 border-b border-slate-100">
<div className="flex items-center gap-2">
<span className="font-bold text-xs text-slate-800">{mod.module} 模块权限</span>
<span className="text-[11px] text-slate-400 font-medium">
({mod.params.filter(p => selected.has(p.code)).length}/{mod.params.length})
</span>
</div>
{scopeKey && (
<div className="flex items-center gap-2">
<span className="text-xs text-slate-500 font-medium">数据查看范围:</span>
<Select
size="small"
className="!w-28 !h-7.5"
disabled={role.code === 'admin'}
value={currentScope}
onChange={scope => setDataScopes(prev => ({ ...prev, [scopeKey]: scope }))}
options={[
{ label: '全企业数据', value: 'all' },
{ label: '仅限个人', value: 'self' },
]}
/>
</div>
)}
</div>
<div className="p-4 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-2.5">
{mod.params.map(p => {
const isChecked = selected.has(p.code)
const isDisabled = role.type === 'builtin' && role.code === 'admin' && p.code.startsWith('permission.')
return (
<label
key={p.code}
className={`flex items-center justify-between p-3 rounded-xl border cursor-pointer transition-all ${
isChecked
? 'bg-blue-50/60 border-blue-200 shadow-2xs'
: 'bg-slate-50/50 border-slate-100 hover:bg-slate-50 hover:border-slate-200'
} ${isDisabled ? 'opacity-50 cursor-not-allowed' : ''}`}
>
<div className="flex items-center gap-2.5 min-w-0 pr-2">
<Checkbox
checked={isChecked}
onChange={() => toggle(p.code)}
disabled={isDisabled}
/>
<div className="min-w-0">
<span className={`text-xs block font-medium truncate ${isChecked ? 'text-blue-950 font-semibold' : 'text-slate-700'}`}>
{p.name}
</span>
<span className="text-[10px] text-slate-400 font-mono block truncate">
{p.code}
</span>
</div>
</div>
<span
className={`inline-flex px-1.5 py-0.5 rounded text-[10px] font-semibold shrink-0 ${
p.category === 'view'
? 'bg-slate-200/70 text-slate-600'
: 'bg-blue-100 text-blue-700'
}`}
>
{p.category === 'view' ? '查看' : '操作'}
</span>
</label>
)
})}
</div>
</div>
)
})}
</div>
</div>
</div>
</div>
+127 -79
View File
@@ -1,12 +1,12 @@
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from 'react-router-dom'
import { Button, Card, Spin, Tag, Modal, Form, Input, message, Popconfirm, Empty } from 'antd'
import { PlusOutlined, SettingOutlined, DeleteOutlined, TeamOutlined, SafetyOutlined } from '@ant-design/icons'
import { Button, Spin, Modal, Form, Input, message, Popconfirm, Empty } from 'antd'
import {
PlusOutlined, SettingOutlined, DeleteOutlined, TeamOutlined,
SafetyCertificateOutlined,
} from '@ant-design/icons'
import { getRoles, createRole, deleteRole, type RoleWithStats } from '@/services/api'
const roleTypeColors: Record<string, string> = { builtin: 'blue', custom: 'green' }
const roleTypeLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
export default function RoleList() {
const navigate = useNavigate()
const [roles, setRoles] = useState<RoleWithStats[]>([])
@@ -51,98 +51,146 @@ export default function RoleList() {
await deleteRole(id)
message.success('已删除')
void load()
} catch (e: any) {
message.error(e?.message || '删除失败')
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '删除失败')
}
}
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-6">
<h2 className="text-xl font-semibold text-neutral-900">角色管理</h2>
<Button type="primary" icon={<PlusOutlined />} onClick={() => setModalOpen(true)}>
新建角色
</Button>
<div className="h-full flex flex-col min-h-0 bg-slate-50/60 overflow-hidden">
{/* 顶栏 56px */}
<header
className="shrink-0 px-6 flex items-center justify-between border-b border-slate-200/80 bg-white"
style={{ height: 'var(--header-height)' }}
>
<div className="min-w-0">
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">权限控制</h1>
<p className="text-[11px] text-slate-400 m-0 leading-tight">企业坐席角色定义、权限项配置与数据范围管控</p>
</div>
<Button
type="primary"
icon={<PlusOutlined />}
className="!h-8 !px-3.5 !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 !rounded-lg shadow-sm shadow-blue-500/20"
onClick={() => setModalOpen(true)}
>
新建角色
</Button>
</header>
{roles.length === 0 ? (
<Card><Empty description="暂无角色" /></Card>
{/* 主内容卡片网格 */}
<div className="flex-1 min-h-0 overflow-auto px-6 py-5">
{loading ? (
<div className="py-24 flex justify-center"><Spin size="large" /></div>
) : roles.length === 0 ? (
<div className="bg-white rounded-2xl border border-slate-200/80 p-16 text-center shadow-xs">
<Empty description="暂无角色数据" />
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{roles.map(role => (
<Card
<div
key={role.id}
className="shadow-sm"
actions={[
<Button
key="config"
type="link"
icon={<SettingOutlined />}
onClick={() => navigate(`/agent/roles/${role.id}`)}
>
配置权限
</Button>,
role.type === 'custom' && (
<Popconfirm
key="delete"
title="确定删除此角色?"
onConfirm={() => handleDelete(role.id)}
okText="确定"
cancelText="取消"
>
<Button type="link" danger icon={<DeleteOutlined />}>删除</Button>
</Popconfirm>
),
].filter(Boolean)}
className="flex flex-col justify-between rounded-2xl bg-white border border-slate-200/80 shadow-xs hover:shadow-md transition-all duration-200 p-5 group"
>
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-lg bg-blue-50 flex items-center justify-center shrink-0">
<SafetyOutlined className="text-blue-500 text-lg" />
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-neutral-900 text-base">{role.name}</span>
<Tag color={roleTypeColors[role.type]}>{roleTypeLabels[role.type]}</Tag>
<div>
<div className="flex items-start justify-between gap-3 mb-3">
<div className="flex items-center gap-3 min-w-0">
<div className="w-10 h-10 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center text-lg shrink-0 border border-blue-100 shadow-2xs">
<SafetyCertificateOutlined />
</div>
<div className="min-w-0">
<div className="flex items-center gap-2">
<h2 className="text-sm font-bold text-slate-900 m-0 truncate">{role.name}</h2>
<span
className={`inline-flex px-2 py-0.5 rounded-md text-[10px] font-semibold ${
role.type === 'builtin'
? 'bg-blue-50 text-blue-600 border border-blue-100'
: 'bg-emerald-50 text-emerald-600 border border-emerald-100'
}`}
>
{role.type === 'builtin' ? '内置角色' : '自定义'}
</span>
</div>
<p className="text-[11px] text-slate-400 m-0 mt-0.5 font-mono truncate">
{role.type === 'builtin' ? `CODE: ${role.code}` : `ID: R${String(role.id).padStart(4, '0')}`}
</p>
</div>
</div>
<p className="text-sm text-neutral-500 mb-3 truncate">
{role.desc || '暂无描述'}
</p>
<div className="flex items-center gap-4 text-xs text-neutral-400">
<span className="inline-flex items-center gap-1">
<TeamOutlined /> 成员数:{role.member_count}
</span>
<span>权限项:{role.perm_count}/42</span>
<span>最后更新:{role.type === 'builtin' ? '系统默认' : role.updated_at?.slice(0, 10)}</span>
</div>
<p className="text-xs text-slate-500 line-clamp-2 min-h-[36px] m-0 mb-4 leading-relaxed">
{role.desc || '暂无描述信息'}
</p>
<div className="grid grid-cols-2 gap-2 py-2 px-3 rounded-xl bg-slate-50 border border-slate-100 mb-4 text-xs">
<div className="flex items-center gap-1.5 text-slate-600">
<TeamOutlined className="text-slate-400" />
<span>成员:</span>
<span className="font-bold text-slate-900 tabular-nums">{role.member_count}</span>
</div>
<div className="flex items-center gap-1.5 text-slate-600">
<span>权限项:</span>
<span className="font-bold text-blue-600 tabular-nums">{role.perm_count}</span>
</div>
</div>
</div>
</Card>
<div className="flex items-center justify-between pt-3 border-t border-slate-100 mt-auto">
<span className="text-[11px] text-slate-400 truncate">
{role.type === 'builtin' ? '系统默认角色' : `更新于 ${role.updated_at?.slice(0, 10) || '—'}`}
</span>
<div className="flex items-center gap-2">
{role.type === 'custom' && (
<Popconfirm
title="确定删除此自定义角色?"
onConfirm={() => handleDelete(role.id)}
okText="确定"
cancelText="取消"
>
<button
type="button"
className="px-2 py-1 rounded-md text-xs font-medium text-red-500 hover:bg-red-50 border-0 bg-transparent cursor-pointer transition-colors"
>
<DeleteOutlined className="mr-1" />
删除
</button>
</Popconfirm>
)}
<button
type="button"
onClick={() => navigate(`/agent/roles/${role.id}`)}
className="px-3 py-1 rounded-md text-xs font-semibold text-blue-600 hover:bg-blue-50 border border-blue-200/80 hover:border-blue-300 bg-white cursor-pointer transition-all shadow-2xs"
>
<SettingOutlined className="mr-1 text-xs" />
配置权限
</button>
</div>
</div>
</div>
))}
</div>
)}
<Modal
title="新建自定义角色"
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleCreate}
confirmLoading={saving}
okText="创建"
cancelText="取消"
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input maxLength={50} placeholder="如:VIP 专属客服" />
</Form.Item>
<Form.Item name="desc" label="角色描述">
<Input.TextArea maxLength={200} rows={3} placeholder="描述该角色的职责与权限范围" />
</Form.Item>
</Form>
</Modal>
</div>
<Modal
title="新建自定义角色"
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleCreate}
confirmLoading={saving}
okText="创建"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical" className="mt-4">
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input maxLength={50} placeholder="如:VIP 专属客服、售前专家" />
</Form.Item>
<Form.Item name="desc" label="角色描述">
<Input.TextArea maxLength={200} rows={3} placeholder="描述该角色的职责分工与适用范围" />
</Form.Item>
</Form>
</Modal>
</div>
)
}
+379 -129
View File
@@ -1,15 +1,30 @@
import { useState, useEffect, useCallback } from 'react'
import { Button, Spin, Card, Table, Modal, Form, Input, Select, Tag, message, Space, type TableColumnsType } from 'antd'
import { PlusOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons'
import { useState, useEffect, useCallback, useMemo } from 'react'
import {
Button, Table, Modal, Form, Input, Select, Tag, message,
Popconfirm, Avatar, Progress,
} from 'antd'
import type { TableColumnsType } from 'antd'
import {
PlusOutlined, EditOutlined, ReloadOutlined, UserOutlined,
SearchOutlined, CheckCircleFilled,
} from '@ant-design/icons'
import { useAuth, usePermission } from '@/stores/auth'
import { getStaff, createStaff, updateStaff, deleteStaff, batchStaff, getRoles, type StaffUser, type RoleWithStats } from '@/services/api'
import {
getStaff, createStaff, updateStaff, deleteStaff, batchStaff,
getRoles, type StaffUser, type RoleWithStats,
} from '@/services/api'
const roleLabels: Record<string, string> = { admin: '管理员', supervisor: '主管', agent: '客服' }
const statusColors: Record<string, string> = { online: 'green', busy: 'gold', offline: 'default', disabled: 'red' }
const statusLabels: Record<string, string> = { online: '在线', busy: '忙碌', offline: '离线', disabled: '已停用' }
const statusConfig: Record<string, { label: string; bg: string; text: string; dot: string }> = {
online: { label: '在线', bg: 'bg-emerald-50 text-emerald-700 border-emerald-200', text: 'text-emerald-700', dot: 'bg-emerald-500' },
busy: { label: '忙碌', bg: 'bg-amber-50 text-amber-700 border-amber-200', text: 'text-amber-700', dot: 'bg-amber-500' },
offline: { label: '离线', bg: 'bg-slate-100 text-slate-600 border-slate-200', text: 'text-slate-500', dot: 'bg-slate-400' },
disabled: { label: '已停用', bg: 'bg-rose-50 text-rose-700 border-rose-200', text: 'text-rose-600', dot: 'bg-rose-500' },
}
export default function Staff() {
const { user } = useAuth()
const { user } = useAuth()
const canManage = usePermission('settings.staff')
const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([])
const [staff, setStaff] = useState<StaffUser[]>([])
@@ -18,8 +33,13 @@ export default function Staff() {
const [roles, setRoles] = useState<RoleWithStats[]>([])
const [loading, setLoading] = useState(true)
const [modalOpen, setModalOpen] = useState(false)
const [batchRoleModalOpen, setBatchRoleModalOpen] = useState(false)
const [batchRoleSelect, setBatchRoleSelect] = useState('agent')
const [editingUser, setEditingUser] = useState<StaffUser | null>(null)
const [saving, setSaving] = useState(false)
const [searchKeyword, setSearchKeyword] = useState('')
const [roleFilter, setRoleFilter] = useState('all')
const [statusFilter, setStatusFilter] = useState('all')
const [form] = Form.useForm()
const load = useCallback(async () => {
@@ -34,7 +54,7 @@ export default function Staff() {
setSeatLimit(staffRes.data?.seat_limit || 0)
setRoles(rolesRes.data || [])
} catch {
message.error('加载失败')
message.error('加载成员列表失败')
} finally {
setLoading(false)
}
@@ -66,15 +86,15 @@ export default function Staff() {
try {
if (editingUser) {
await updateStaff(editingUser.id, values)
message.success('已更新')
message.success('成员信息已更新')
} else {
await createStaff(values)
message.success('已创建')
message.success('新成员账号创建成功')
}
setModalOpen(false)
void load()
} catch (e: any) {
message.error(e?.message || '操作失败')
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '操作失败')
} finally {
setSaving(false)
}
@@ -84,172 +104,402 @@ export default function Staff() {
try {
if (u.status === 'disabled') {
await updateStaff(u.id, { status: 'offline' })
message.success('已启用')
message.success(`已启用账号 ${u.username}`)
} else {
await deleteStaff(u.id)
message.success('已停用')
message.success(`已停用账号 ${u.username}`)
}
void load()
} catch (e: any) {
message.error(e?.message || '操作失败')
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
const handleBatch = async (action: string, newRole?: string) => {
if (selectedRowKeys.length === 0) {
message.warning('请先选择账号')
message.warning('请先勾选需要操作的账号')
return
}
const actions: Record<string, string> = { enable: '启用', disable: '停用', change_role: '更换角色' }
const confirmed = confirm(`确定批量${actions[action]} ${selectedRowKeys.length} 个账号?`)
if (!confirmed) return
const actionNames: Record<string, string> = { enable: '启用', disable: '停用', change_role: '更换角色' }
try {
await batchStaff({ ids: selectedRowKeys.map(Number), action, new_role: newRole })
message.success(`批量${actions[action]}完成`)
message.success(`批量${actionNames[action]}完成`)
setSelectedRowKeys([])
setBatchRoleModalOpen(false)
void load()
} catch (e: any) {
message.error(e?.message || '操作失败')
} catch (e: unknown) {
message.error(e instanceof Error ? e.message : '操作失败')
}
}
const filteredStaff = useMemo(() => {
return staff.filter(item => {
const matchKeyword = !searchKeyword ||
item.username.toLowerCase().includes(searchKeyword.toLowerCase()) ||
item.nickname.toLowerCase().includes(searchKeyword.toLowerCase())
const matchRole = roleFilter === 'all' || item.role === roleFilter
const matchStatus = statusFilter === 'all' || item.status === statusFilter
return matchKeyword && matchRole && matchStatus
})
}, [staff, searchKeyword, roleFilter, statusFilter])
const rowSelection = canManage ? {
selectedRowKeys,
onChange: (keys: React.Key[]) => setSelectedRowKeys(keys),
} : undefined
const columns: TableColumnsType<StaffUser> = [
{ title: '账号', dataIndex: 'username', key: 'username', width: 160 },
{ title: '姓名', dataIndex: 'nickname', key: 'nickname', width: 120 },
{
title: '角色', dataIndex: 'role', key: 'role', width: 90,
render: (role: string) => <Tag>{roleLabels[role] || role}</Tag>,
title: '账号与成员',
dataIndex: 'username',
key: 'username',
width: 240,
render: (_, u) => (
<div className="flex items-center gap-3">
<Avatar
className="shrink-0 font-semibold bg-gradient-to-tr from-blue-600 to-indigo-500 text-white shadow-2xs"
size={36}
>
{u.nickname ? u.nickname.slice(0, 1) : u.username.slice(0, 1).toUpperCase()}
</Avatar>
<div className="min-w-0">
<div className="text-xs font-bold text-slate-900 truncate flex items-center gap-1.5">
<span>{u.nickname || u.username}</span>
{u.id === user?.user_id && (
<span className="inline-flex px-1.5 py-0.2 rounded text-[10px] bg-blue-50 text-blue-600 font-semibold border border-blue-100">
当前登录
</span>
)}
</div>
<div className="text-[11px] text-slate-400 font-mono truncate">{u.username}</div>
</div>
</div>
),
},
{
title: '状态', dataIndex: 'status', key: 'status', width: 80,
render: (s: string) => <Tag color={statusColors[s] || 'default'}>{statusLabels[s] || s}</Tag>,
title: '所属角色',
dataIndex: 'role',
key: 'role',
width: 130,
render: (role: string) => {
const isBuiltinAdmin = role === 'admin'
const isSupervisor = role === 'supervisor'
return (
<Tag
className={`!border-0 !rounded-md !px-2 !py-0.5 !text-xs !font-medium ${
isBuiltinAdmin
? '!bg-indigo-50 !text-indigo-700'
: isSupervisor
? '!bg-purple-50 !text-purple-700'
: '!bg-slate-100 !text-slate-700'
}`}
>
{roleLabels[role] || role}
</Tag>
)
},
},
{
title: '最近登录', dataIndex: 'last_online_at', key: 'last_online_at', width: 150,
render: (v: string) => v ? v.slice(0, 19).replace('T', ' ') : '-',
title: '当前状态',
dataIndex: 'status',
key: 'status',
width: 120,
render: (s: string) => {
const cfg = statusConfig[s] || statusConfig.offline
return (
<span className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium border ${cfg.bg}`}>
<span className={`w-1.5 h-1.5 rounded-full ${cfg.dot}`} />
{cfg.label}
</span>
)
},
},
{
title: '操作', key: 'actions', width: 140, fixed: 'right',
title: '最近登录',
dataIndex: 'last_online_at',
key: 'last_online_at',
width: 170,
render: (v: string) => (
<span className="text-xs text-slate-500 font-mono">
{v ? v.slice(0, 19).replace('T', ' ') : '—'}
</span>
),
},
{
title: '操作',
key: 'actions',
width: 150,
fixed: 'right',
render: (_, u) => {
if (!canManage) return null
const isSelf = u.id === user?.user_id
return (
<Space size={0}>
<Button type="link" size="small" icon={<EditOutlined />} onClick={() => openEdit(u)}>
编辑
</Button>
<Button
type="link" size="small"
danger={u.status !== 'disabled'}
disabled={u.id === user?.user_id}
onClick={() => handleToggleStatus(u)}
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => openEdit(u)}
className="px-2.5 py-1 rounded-md text-xs font-semibold text-blue-600 hover:bg-blue-50 border border-blue-200/80 bg-white cursor-pointer transition-all shadow-2xs"
>
{u.status === 'disabled' ? '启用' : '停用'}
</Button>
</Space>
<EditOutlined className="mr-1 text-xs" />
编辑
</button>
{!isSelf && (
<Popconfirm
title={u.status === 'disabled' ? '确定启用此账号?' : '确定停用此账号?停用后该客服无法继续登录。'}
onConfirm={() => handleToggleStatus(u)}
okText="确定"
cancelText="取消"
>
<button
type="button"
className={`px-2.5 py-1 rounded-md text-xs font-medium cursor-pointer transition-colors border-0 bg-transparent ${
u.status === 'disabled'
? 'text-emerald-600 hover:bg-emerald-50'
: 'text-rose-500 hover:bg-rose-50'
}`}
>
{u.status === 'disabled' ? '启用' : '停用'}
</button>
</Popconfirm>
)}
</div>
)
},
},
]
if (loading) return <div className="h-full flex items-center justify-center"><Spin size="large" /></div>
return (
<div className="h-full p-6 overflow-auto">
<div className="max-w-5xl mx-auto">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-neutral-900">成员账号</h2>
<Space>
<Button icon={<ReloadOutlined />} onClick={load}>刷新</Button>
{canManage && (
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate} disabled={seatUsed >= seatLimit}>
新增账号
</Button>
)}
</Space>
<div className="h-full flex flex-col min-h-0 bg-slate-50/60 overflow-hidden">
{/* 顶栏 56px */}
<header
className="shrink-0 px-6 flex items-center justify-between border-b border-slate-200/80 bg-white"
style={{ height: 'var(--header-height)' }}
>
<div className="min-w-0">
<h1 className="text-sm font-bold text-slate-900 m-0 truncate">成员账号</h1>
<p className="text-[11px] text-slate-400 m-0 leading-tight">企业坐席人员分配、账号权限与配额监控</p>
</div>
<Card size="small" className="mb-4">
<span className="text-sm text-neutral-500">
坐席配额:{seatUsed} / {seatLimit}
{seatUsed >= seatLimit && <Tag color="red" className="ml-2">已满</Tag>}
</span>
{selectedRowKeys.length > 0 && (
<Space className="float-right">
<span className="text-xs text-neutral-400">已选 {selectedRowKeys.length} 个</span>
<Button size="small" onClick={() => handleBatch('enable')}>批量启用</Button>
<Button size="small" danger onClick={() => handleBatch('disable')}>批量停用</Button>
<Button size="small" onClick={() => {
const role = prompt('请输入新角色码(admin/supervisor/agent):')
if (role) handleBatch('change_role', role)
}}>批量更换角色</Button>
<Button size="small" onClick={() => setSelectedRowKeys([])}>取消选择</Button>
</Space>
<div className="flex items-center gap-2.5">
<Button
icon={<ReloadOutlined />}
onClick={load}
loading={loading}
className="!h-8 !px-3 !text-xs !font-medium text-slate-600 !rounded-lg"
>
刷新
</Button>
{canManage && (
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreate}
disabled={seatUsed >= seatLimit && seatLimit > 0}
className="!h-8 !px-3.5 !text-xs !font-semibold !bg-gradient-to-r !from-blue-600 !to-blue-500 !rounded-lg shadow-sm shadow-blue-500/20"
>
新增成员
</Button>
)}
</Card>
</div>
</header>
<Table
rowKey="id"
columns={columns}
dataSource={staff}
rowSelection={rowSelection}
pagination={{ pageSize: 20, showSizeChanger: false, showTotal: t => `共 ${t} 个账号` }}
scroll={{ x: 700 }}
/>
{/* 主工作区 */}
<div className="flex-1 min-h-0 flex flex-col p-6 space-y-4 overflow-auto">
{/* 顶部配额与统计卡片 */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4 shrink-0">
<div className="rounded-2xl bg-white border border-slate-200/80 p-4.5 shadow-xs flex items-center justify-between">
<div className="space-y-1">
<span className="text-xs font-semibold text-slate-500">坐席席位配额</span>
<div className="flex items-baseline gap-2">
<span className="text-2xl font-black text-slate-900 tabular-nums">{seatUsed}</span>
<span className="text-xs text-slate-400">/ {seatLimit || '不限'} 席</span>
</div>
</div>
<div className="w-28 text-right">
<Progress
percent={seatLimit ? Math.round((seatUsed / seatLimit) * 100) : 0}
size="small"
status={seatUsed >= seatLimit ? 'exception' : 'active'}
strokeColor="#2563eb"
/>
<span className="text-[10px] text-slate-400 mt-1 block">
{seatUsed >= seatLimit ? '席位已满' : `剩余可用 ${Math.max(0, seatLimit - seatUsed)} 席`}
</span>
</div>
</div>
<Modal
title={editingUser ? '编辑账号' : '新增账号'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical" className="mt-4">
{!editingUser && (
<>
<Form.Item name="username" label="登录账号" rules={[{ required: true, min: 3, max: 30, message: '3-30个字符' }]}>
<Input placeholder="如 agent01@company.com" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true, min: 6, message: '至少6位' }]}>
<Input.Password placeholder="至少6位" />
</Form.Item>
</>
)}
<Form.Item name="nickname" label="姓名">
<Input maxLength={50} placeholder="客服姓名" />
</Form.Item>
<Form.Item name="role" label="角色" rules={[{ required: true }]}>
<div className="rounded-2xl bg-white border border-slate-200/80 p-4.5 shadow-xs flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-emerald-50 text-emerald-600 flex items-center justify-center text-lg shrink-0 border border-emerald-100">
<CheckCircleFilled />
</div>
<div>
<span className="text-xs font-semibold text-slate-500">当前在线客服</span>
<div className="text-2xl font-black text-emerald-600 tabular-nums">
{staff.filter(s => s.status === 'online').length}
<span className="text-xs font-normal text-slate-400 ml-1">人</span>
</div>
</div>
</div>
<div className="rounded-2xl bg-white border border-slate-200/80 p-4.5 shadow-xs flex items-center gap-4">
<div className="w-10 h-10 rounded-xl bg-blue-50 text-blue-600 flex items-center justify-center text-lg shrink-0 border border-blue-100">
<UserOutlined />
</div>
<div>
<span className="text-xs font-semibold text-slate-500">成员总数</span>
<div className="text-2xl font-black text-slate-900 tabular-nums">
{staff.length}
<span className="text-xs font-normal text-slate-400 ml-1">人</span>
</div>
</div>
</div>
</div>
{/* 筛选过滤工具条 */}
<div className="rounded-2xl bg-white border border-slate-200/80 p-3.5 shadow-xs flex flex-wrap items-center justify-between gap-3 shrink-0">
<div className="flex flex-wrap items-center gap-2.5">
<Input
prefix={<SearchOutlined className="text-slate-400 text-xs" />}
placeholder="搜索账号 / 姓名..."
value={searchKeyword}
onChange={e => setSearchKeyword(e.target.value)}
allowClear
className="!w-60 !h-8.5 !rounded-lg text-xs"
/>
<Select
value={roleFilter}
onChange={setRoleFilter}
className="!w-32 !h-8.5"
options={[
{ label: '全部角色', value: 'all' },
{ label: '管理员', value: 'admin' },
{ label: '主管', value: 'supervisor' },
{ label: '客服', value: 'agent' },
]}
/>
<Select
value={statusFilter}
onChange={setStatusFilter}
className="!w-32 !h-8.5"
options={[
{ label: '全部状态', value: 'all' },
{ label: '在线', value: 'online' },
{ label: '忙碌', value: 'busy' },
{ label: '离线', value: 'offline' },
{ label: '已停用', value: 'disabled' },
]}
/>
</div>
{/* 批量操作工具栏 */}
{selectedRowKeys.length > 0 && (
<div className="flex items-center gap-2 bg-blue-50/80 px-3 py-1 rounded-xl border border-blue-100">
<span className="text-xs font-medium text-blue-700">已选 {selectedRowKeys.length} 人:</span>
<Button size="small" onClick={() => handleBatch('enable')} className="!text-xs">批量启用</Button>
<Button size="small" danger onClick={() => handleBatch('disable')} className="!text-xs">批量停用</Button>
<Button size="small" onClick={() => setBatchRoleModalOpen(true)} className="!text-xs">更换角色</Button>
<Button type="link" size="small" onClick={() => setSelectedRowKeys([])} className="!text-xs text-slate-500">取消</Button>
</div>
)}
</div>
{/* 成员数据表格卡片 */}
<div className="flex-1 min-h-[300px] rounded-2xl bg-white border border-slate-200/80 shadow-xs overflow-hidden flex flex-col">
<Table
rowKey="id"
loading={loading}
columns={columns}
dataSource={filteredStaff}
rowSelection={rowSelection}
pagination={{
pageSize: 15,
showSizeChanger: false,
showTotal: t => `共 ${t} 位成员`,
className: '!px-6 !py-3 !m-0 border-t border-slate-100',
}}
scroll={{ x: 800 }}
className="flex-1"
/>
</div>
</div>
{/* 新增 / 编辑成员弹窗 */}
<Modal
title={editingUser ? '编辑成员信息' : '新增成员账号'}
open={modalOpen}
onCancel={() => setModalOpen(false)}
onOk={handleSave}
confirmLoading={saving}
okText="保存"
cancelText="取消"
destroyOnClose
>
<Form form={form} layout="vertical" className="mt-4">
{!editingUser && (
<>
<Form.Item name="username" label="登录账号" rules={[{ required: true, min: 3, max: 30, message: '3-30个字符' }]}>
<Input placeholder="如 agent01@company.com" className="!h-9 !rounded-lg text-xs" />
</Form.Item>
<Form.Item name="password" label="初始登录密码" rules={[{ required: true, min: 6, message: '至少6位' }]}>
<Input.Password placeholder="至少6位" className="!h-9 !rounded-lg text-xs" />
</Form.Item>
</>
)}
<Form.Item name="nickname" label="真实姓名 / 昵称" rules={[{ required: true, message: '请输入姓名' }]}>
<Input maxLength={50} placeholder="客服姓名" className="!h-9 !rounded-lg text-xs" />
</Form.Item>
<Form.Item name="role" label="分配角色" rules={[{ required: true }]}>
<Select
className="!h-9"
options={roles.map(r => ({ label: `${r.name}${r.type === 'builtin' ? ' (内置)' : ''}`, value: r.code }))}
/>
</Form.Item>
{editingUser && (
<Form.Item name="status" label="账号状态">
<Select
options={roles.map(r => ({ label: `${r.name}${r.type === 'builtin' ? ' (内置)' : ''}`, value: r.code }))}
className="!h-9"
options={[
{ label: '在线', value: 'online' },
{ label: '忙碌', value: 'busy' },
{ label: '离线', value: 'offline' },
{ label: '已停用', value: 'disabled' },
]}
/>
</Form.Item>
{editingUser && (
<Form.Item name="status" label="状态">
<Select
options={[
{ label: '在线', value: 'online' },
{ label: '忙碌', value: 'busy' },
{ label: '离线', value: 'offline' },
{ label: '已停用', value: 'disabled' },
]}
/>
</Form.Item>
)}
{editingUser && (
<Form.Item name="password" label="密码(留空不修改)">
<Input.Password placeholder="留空不修改" />
</Form.Item>
)}
</Form>
</Modal>
</div>
)}
{editingUser && (
<Form.Item name="password" label="重置密码(留空则不修改)">
<Input.Password placeholder="留空则保持原密码不变" className="!h-9 !rounded-lg text-xs" />
</Form.Item>
)}
</Form>
</Modal>
{/* 批量更换角色弹窗 */}
<Modal
title="批量更换成员角色"
open={batchRoleModalOpen}
onCancel={() => setBatchRoleModalOpen(false)}
onOk={() => handleBatch('change_role', batchRoleSelect)}
okText="确定修改"
cancelText="取消"
>
<div className="py-3">
<p className="text-xs text-slate-500 mb-3">
已选择 <span className="font-bold text-blue-600">{selectedRowKeys.length}</span> 位成员,请选择需要统一变更为的新角色:
</p>
<Select
value={batchRoleSelect}
onChange={setBatchRoleSelect}
className="w-full !h-9"
options={roles.map(r => ({ label: `${r.name}${r.type === 'builtin' ? ' (内置)' : ''}`, value: r.code }))}
/>
</div>
</Modal>
</div>
)
}