Files
udmin/frontend/src/pages/Users.tsx
2025-08-28 00:55:35 +08:00

482 lines
20 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useMemo, useState } from 'react'
import { Button, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag, Typography, message, TreeSelect } from 'antd'
import api from '../utils/axios'
import type { ColumnsType } from 'antd/es/table'
import { formatDateTime } from '../utils/datetime'
import { EditOutlined, DeleteOutlined, KeyOutlined, UserOutlined } from '@ant-design/icons'
import { usePermission } from '../utils/permission'
import PageHeader from '../components/PageHeader'
interface UserItem {
id: number
username: string
nickname?: string
status?: number
created_at?: string
}
// 简单的 {id, name} 类型守卫,便于从未知数组安全映射
type IdName = { id: number; name: string }
const isIdName = (o: unknown): o is IdName => {
if (typeof o !== 'object' || o === null) return false
const rec = o as Record<string, unknown>
return typeof rec.id === 'number' && typeof rec.name === 'string'
}
// 部门基础类型与守卫(包含 parent_id 便于构建树)
type DeptBasic = { id: number; name: string; parent_id?: number }
const isDeptBasic = (o: unknown): o is DeptBasic => {
if (typeof o !== 'object' || o === null) return false
const rec = o as Record<string, unknown>
const okId = typeof rec.id === 'number'
const okName = typeof rec.name === 'string'
const okPid = rec.parent_id === undefined || typeof rec.parent_id === 'number'
return okId && okName && okPid
}
export default function Users(){
const [loading, setLoading] = useState(false)
const [data, setData] = useState([] as UserItem[])
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [total, setTotal] = useState(0)
const [keyword, setKeyword] = useState('')
const [createOpen, setCreateOpen] = useState(false)
const [editOpen, setEditOpen] = useState(false)
const [pwdOpen, setPwdOpen] = useState(false)
const [positionsOpen, setPositionsOpen] = useState(false)
const [current, setCurrent] = useState(null as UserItem | null)
const [currentUserId, setCurrentUserId] = useState(null as number | null)
const [form] = Form.useForm()
const [editForm] = Form.useForm()
const [pwdForm] = Form.useForm()
const [searchForm] = Form.useForm()
// 分配角色(移到编辑弹窗内)
const [selectedRoleIds, setSelectedRoleIds] = useState([] as number[])
const [allRoles, setAllRoles] = useState([] as { id: number; name: string }[])
// 分配部门(移到编辑弹窗内)
const [selectedDeptIds, setSelectedDeptIds] = useState([] as number[])
const [allDepts, setAllDepts] = useState([] as DeptBasic[])
// 岗位分配相关状态
const [positionOptions, setPositionOptions] = useState([] as { label: string; value: number }[])
const [userPositions, setUserPositions] = useState([] as number[])
// 新增/编辑弹窗内的岗位选择
const [createPositionIds, setCreatePositionIds] = useState([] as number[])
const [editPositionIds, setEditPositionIds] = useState([] as number[])
// 权限判断
const { has } = usePermission()
// 根据 allDepts 构建部门树(用于选择多个部门)
type DeptTreeNode = { title: string; value: number; key: number; children?: DeptTreeNode[] }
const deptTreeData: DeptTreeNode[] = useMemo(() => {
const map = new Map<number, DeptTreeNode & { children: DeptTreeNode[] }>()
allDepts.forEach((d: DeptBasic) => map.set(d.id, { title: d.name, value: d.id, key: d.id, children: [] }))
const roots: (DeptTreeNode & { children: DeptTreeNode[] })[] = []
allDepts.forEach((d: DeptBasic) => {
const node = map.get(d.id)!
if (d.parent_id && map.has(d.parent_id)) {
const parent = map.get(d.parent_id)
if (parent) parent.children.push(node)
} else {
roots.push(node)
}
})
return roots
}, [allDepts])
// 获取岗位选项
const fetchPositionOptions = async () => {
try {
const { data } = await api.get('/positions', { params: { page: 1, page_size: 1000 } })
if (data?.code === 0) {
setPositionOptions((data.data.items || []).map((it: any) => ({ label: it.name, value: it.id })))
}
} catch (e) {
console.error('获取岗位列表失败:', e)
}
}
useEffect(() => {
fetchPositionOptions()
}, [])
const fetchUsers = async (p: number = page, ps: number = pageSize, kw: string = keyword) => {
setLoading(true)
try{
const { data } = await api.get(`/users`, { params: { page: p, page_size: ps, keyword: kw } })
if(data?.code === 0){
const resp = data.data
setData(resp.items || [])
setTotal(resp.total || 0)
setPage(resp.page || p)
setPageSize(resp.page_size || ps)
}else{
throw new Error(data?.message || '获取用户失败')
}
}catch(e: any){
message.error(e.message || '获取用户失败')
}finally{
setLoading(false)
}
}
useEffect(() => { fetchUsers(1, pageSize, keyword) }, [])
const onCreate = async () => {
form.resetFields()
// 新增用户:预置清空角色与部门选择,并加载候选数据
setSelectedRoleIds([])
setSelectedDeptIds([])
setCreatePositionIds([])
setCreateOpen(true)
try {
await fetchPositionOptions()
const [rolesRes, deptsRes] = await Promise.all([
api.get('/roles', { params: { page: 1, page_size: 1000 } }),
api.get('/departments', { params: { keyword: '' } })
])
if (rolesRes.data?.code === 0) {
const rolesItemsSrc = Array.isArray(rolesRes.data?.data?.items) ? (rolesRes.data.data.items as unknown[]) : []
const roleItems = rolesItemsSrc.filter(isIdName).map(({ id, name }) => ({ id, name }))
setAllRoles(roleItems)
}
if (deptsRes.data?.code === 0) {
const deptsSrc = Array.isArray(deptsRes.data?.data) ? (deptsRes.data.data as unknown[]) : []
const deptBasics = deptsSrc.filter(isDeptBasic)
setAllDepts(deptBasics)
}
} catch (e: any) {
message.error(e.message || '加载角色/部门失败')
}
}
const handleCreate = async () => {
try{
const values = await form.validateFields()
const { data } = await api.post('/users', values)
if(data?.code !== 0){ throw new Error(data?.message || '创建失败') }
const uid = typeof data?.data?.id === 'number' ? data.data.id : undefined
if (uid) {
const [rolesSave, deptsSave, posSave] = await Promise.all([
api.put(`/users/${uid}/roles`, { ids: selectedRoleIds }),
api.put(`/users/${uid}/departments`, { ids: selectedDeptIds }),
api.put(`/users/${uid}/positions`, { ids: createPositionIds })
])
if (rolesSave.data?.code !== 0) throw new Error(rolesSave.data?.message || '保存角色失败')
if (deptsSave.data?.code !== 0) throw new Error(deptsSave.data?.message || '保存部门失败')
if (posSave.data?.code !== 0) throw new Error(posSave.data?.message || '保存岗位失败')
} else {
message.warning('创建成功但未获取到用户ID未能分配角色/部门/岗位')
}
message.success('创建成功')
setCreateOpen(false)
fetchUsers(1, pageSize)
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '创建失败') }
}
const onEdit = async (record: UserItem) => {
setCurrent(record)
editForm.setFieldsValue({ nickname: record.nickname, status: record.status })
setEditOpen(true)
// 加载该用户已分配的角色/部门/岗位及候选列表
try{
await fetchPositionOptions()
const [roleIdsRes, rolesRes, deptIdsRes, deptsRes, posIdsRes] = await Promise.all([
api.get(`/users/${record.id}/roles`),
api.get('/roles', { params: { page: 1, page_size: 1000 } }),
api.get(`/users/${record.id}/departments`),
api.get('/departments', { params: { keyword: '' } }),
api.get(`/users/${record.id}/positions`)
])
if(roleIdsRes.data?.code !== 0) throw new Error(roleIdsRes.data?.message || '获取用户角色失败')
if(rolesRes.data?.code !== 0) throw new Error(rolesRes.data?.message || '获取角色列表失败')
if(deptIdsRes.data?.code !== 0) throw new Error(deptIdsRes.data?.message || '获取用户部门失败')
if(deptsRes.data?.code !== 0) throw new Error(deptsRes.data?.message || '获取部门列表失败')
if(posIdsRes.data?.code !== 0) throw new Error(posIdsRes.data?.message || '获取用户岗位失败')
const roleIds = Array.isArray(roleIdsRes.data?.data) ? (roleIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setSelectedRoleIds(roleIds)
const rolesItemsSrc = Array.isArray(rolesRes.data?.data?.items) ? rolesRes.data.data.items as unknown[] : []
const roleItems = rolesItemsSrc.filter(isIdName).map(({ id, name }) => ({ id, name }))
setAllRoles(roleItems)
const deptIds = Array.isArray(deptIdsRes.data?.data) ? (deptIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setSelectedDeptIds(deptIds)
const deptsSrc = Array.isArray(deptsRes.data?.data) ? (deptsRes.data.data as unknown[]) : []
const deptBasics = deptsSrc.filter(isDeptBasic)
setAllDepts(deptBasics)
const posIds = Array.isArray(posIdsRes.data?.data) ? (posIdsRes.data.data as unknown[]).filter((n): n is number => typeof n === 'number') : []
setEditPositionIds(posIds)
}catch(e: any){ message.error(e.message || '加载编辑数据失败') }
}
const handleEdit = async () => {
if(!current) return
try{
const values = await editForm.validateFields()
// 先保存基础信息
const { data: upd } = await api.put(`/users/${current!.id}`, values)
if(upd?.code !== 0) throw new Error(upd?.message || '更新失败')
// 再保存角色与部门
const [rolesSave, deptsSave] = await Promise.all([
api.put(`/users/${current.id}/roles`, { ids: selectedRoleIds }),
api.put(`/users/${current.id}/departments`, { ids: selectedDeptIds }),
api.put(`/users/${current.id}/positions`, { ids: editPositionIds })
])
if(rolesSave.data?.code !== 0) throw new Error(rolesSave.data?.message || '保存角色失败')
if(deptsSave.data?.code !== 0) throw new Error(deptsSave.data?.message || '保存部门失败')
message.success('更新成功')
setEditOpen(false)
fetchUsers(page, pageSize)
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '更新失败') }
}
const onResetPwd = (record: UserItem) => {
setCurrent(record)
pwdForm.resetFields()
setPwdOpen(true)
}
const handleResetPwd = async () => {
try{
const values = await pwdForm.validateFields()
const { data } = await api.post(`/users/${current!.id}/reset_password`, values)
if(data?.code === 0){
message.success('密码已重置')
setPwdOpen(false)
}else{ throw new Error(data?.message || '重置失败') }
}catch(e: any){ if(e?.errorFields) return; message.error(e.message || '重置失败') }
}
// 岗位分配相关方法
const openPositions = async (userId: number) => {
setCurrentUserId(userId)
try {
const { data } = await api.get(`/users/${userId}/positions`)
if (data?.code === 0) {
setUserPositions(data.data || [])
} else {
throw new Error(data?.message || '获取用户岗位失败')
}
} catch (e: any) {
message.error(e.message || '获取用户岗位失败')
setUserPositions([])
}
setPositionsOpen(true)
}
const savePositions = async () => {
if (!currentUserId) return
try {
const { data } = await api.put(`/users/${currentUserId}/positions`, { ids: userPositions })
if (data?.code === 0) {
message.success('岗位分配成功')
setPositionsOpen(false)
} else {
throw new Error(data?.message || '保存岗位失败')
}
} catch (e: any) {
message.error(e.message || '保存岗位失败')
}
}
const columns: ColumnsType<UserItem> = useMemo(() => [
{ title: 'ID', dataIndex: 'id', width: 80 },
{ title: '用户名', dataIndex: 'username' },
{ title: '昵称', dataIndex: 'nickname' },
{ title: '状态', dataIndex: 'status', render: (v: number) => v === 1 ? <Tag color="green"></Tag> : <Tag></Tag> },
{ title: '创建时间', dataIndex: 'created_at', render: (v: any) => formatDateTime(v) },
{ title: '操作', key: 'actions', width: 360, render: (_: any, r: UserItem) => (
<Space>
{has('system:user:update') && (
<a className="action-link" onClick={() => onEdit(r)}>
<EditOutlined />
<span></span>
</a>
)}
{has('system:user:reset') && (
<a className="action-link" onClick={() => onResetPwd(r)}>
<KeyOutlined />
<span></span>
</a>
)}
{has('system:user:assignPosition') && (
<a className="action-link" onClick={() => openPositions(r.id)}>
<UserOutlined />
<span></span>
</a>
)}
{has('system:user:delete') && (
<Popconfirm title={`确认删除用户「${r.username}」?`} onConfirm={async ()=>{ try{ const { data } = await api.delete(`/users/${r.id}`); if(data?.code===0){ message.success('删除成功'); fetchUsers(page, pageSize, keyword) } else { throw new Error(data?.message||'删除失败') } }catch(e:any){ message.error(e.message||'删除失败') } }}>
<a className="action-link action-danger">
<DeleteOutlined />
<span></span>
</a>
</Popconfirm>
)}
</Space>
)},
], [page, pageSize, keyword, has])
return (
<div>
<PageHeader items={["系统管理","用户管理"]} title="" />
<Form form={searchForm} layout="inline" onFinish={(vals: any)=>{ const kw = String(vals?.keyword ?? '').trim(); setKeyword(kw); fetchUsers(1, pageSize, kw) }} style={{ marginBottom: 12 }}>
<Form.Item name="keyword">
<Input allowClear placeholder="搜索用户名/昵称" style={{ width: 320 }} />
</Form.Item>
<Form.Item>
<Space>
<Button type="primary" htmlType="submit"></Button>
<Button onClick={()=>{ searchForm.resetFields(); setKeyword(''); fetchUsers(1, pageSize, '') }}></Button>
{has('system:user:create') && (
<Button type="primary" onClick={onCreate}></Button>
)}
</Space>
</Form.Item>
</Form>
<Table<UserItem>
rowKey="id"
loading={loading}
dataSource={data}
columns={columns}
pagination={{ current: page, pageSize, total, showSizeChanger: true, onChange: (p: number, ps?: number) => fetchUsers(p, ps ?? pageSize, keyword) }}
/>
<Typography.Paragraph type="secondary" style={{ marginTop: 12 }}>
</Typography.Paragraph>
<Modal title="新增用户" open={createOpen} onOk={handleCreate} onCancel={() => setCreateOpen(false)} okText="创建" width={840}>
<Form form={form} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="username" label="用户名" rules={[{ required: true }]}>
<Input placeholder="请输入用户名" />
</Form.Item>
<Form.Item name="password" label="密码" rules={[{ required: true }]}>
<Input.Password placeholder="请输入密码" />
</Form.Item>
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="status" label="状态" initialValue={1}>
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
<Form.Item label="角色">
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择角色"
value={selectedRoleIds}
options={allRoles.map((r: { id: number; name: string }) => ({ label: r.name, value: r.id }))}
onChange={(vals: number[]) => setSelectedRoleIds(vals)}
/>
</Form.Item>
<Form.Item label="部门">
<TreeSelect
allowClear
multiple
treeCheckable
placeholder="选择部门(支持多选)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
value={selectedDeptIds}
onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])}
/>
</Form.Item>
{has('system:user:assignPosition') && (
<Form.Item label="岗位">
<Select
mode="multiple"
allowClear
placeholder="选择岗位"
value={createPositionIds}
options={positionOptions}
style={{ width: '100%' }}
onChange={setCreatePositionIds}
/>
</Form.Item>
)}
</Form>
</Modal>
<Modal title="编辑用户" open={editOpen} onOk={handleEdit} onCancel={() => setEditOpen(false)} okText="保存" width={840}>
<Form form={editForm} layout="horizontal" labelCol={{ span: 2 }} wrapperCol={{ span: 22 }} labelAlign="right">
<Form.Item name="nickname" label="昵称">
<Input placeholder="请输入昵称" />
</Form.Item>
<Form.Item name="status" label="状态">
<Select options={[{label:'启用', value:1},{label:'禁用', value:0}]} />
</Form.Item>
<Form.Item label="角色">
<Select
mode="multiple"
style={{ width: '100%' }}
placeholder="选择角色"
value={selectedRoleIds}
options={allRoles.map((r: { id: number; name: string }) => ({ label: r.name, value: r.id }))}
onChange={(vals: number[]) => setSelectedRoleIds(vals)}
/>
</Form.Item>
<Form.Item label="部门">
<TreeSelect
allowClear
multiple
treeCheckable
placeholder="选择部门(支持多选)"
style={{ width: '100%' }}
treeData={deptTreeData}
treeDefaultExpandAll
showSearch
filterTreeNode={(input: string, node: any) => String(node?.title ?? '').toLowerCase().includes(String(input).toLowerCase())}
value={selectedDeptIds}
onChange={(vals) => setSelectedDeptIds(Array.isArray(vals) ? (vals as (string | number)[]).map(v => Number(v)) : [])}
/>
</Form.Item>
{has('system:user:assignPosition') && (
<Form.Item label="岗位">
<Select
mode="multiple"
allowClear
placeholder="选择岗位"
value={editPositionIds}
options={positionOptions}
style={{ width: '100%' }}
onChange={setEditPositionIds}
/>
</Form.Item>
)}
</Form>
</Modal>
<Modal title={`重置密码${current ? `${current.username}` : ''}`} open={pwdOpen} onOk={handleResetPwd} onCancel={() => setPwdOpen(false)} okText="重置">
<Form form={pwdForm} layout="vertical">
<Form.Item name="password" label="新密码" rules={[{ required: true }]}>
<Input.Password placeholder="请输入新密码" />
</Form.Item>
</Form>
</Modal>
<Modal title="分配岗位" open={positionsOpen} onOk={savePositions} onCancel={() => setPositionsOpen(false)} okText="保存">
<Form layout="vertical">
<Form.Item label="选择岗位">
<Select
mode="multiple"
allowClear
value={userPositions}
onChange={setUserPositions}
options={positionOptions}
style={{ width: '100%' }}
placeholder="选择岗位"
/>
</Form.Item>
</Form>
</Modal>
</div>
)
}