refactor(web): 系统设置模块页面表单一致性重构
Some checks failed
CI / rust-check (push) Has been cancelled
CI / rust-test (push) Has been cancelled
CI / frontend-build (push) Has been cancelled
CI / security-audit (push) Has been cancelled

- 新增 useCrudDrawer hook 封装 CRUD Drawer 通用模式(状态管理/提交/错误处理)
- 新增 useListData hook 封装非分页列表数据获取
- 11 个页面统一迁移到 DrawerForm + 共享 hooks,消除重复代码
- 错误处理统一使用 useApiRequest.execute(),移除内联 try-catch
- Modal 全部替换为 DrawerForm,保持 UI 一致性
- 净减少 ~1300 行代码(858 增 / 2136 删)
This commit is contained in:
iven
2026-05-04 11:57:38 +08:00
parent 444dc7dd8d
commit d436888ca5
13 changed files with 960 additions and 2131 deletions

View File

@@ -0,0 +1,74 @@
import { useState, useCallback } from 'react';
import { useApiRequest } from './useApiRequest';
export interface UseCrudDrawerOptions<T> {
getId: (record: T) => string;
onCreate: (values: Record<string, unknown>) => Promise<void>;
onUpdate: (id: string, values: Record<string, unknown> & { version: number }) => Promise<void>;
onSuccess?: () => void;
}
export interface UseCrudDrawerReturn<T> {
open: boolean;
editingRecord: T | null;
initialValues: Record<string, unknown> | undefined;
openCreate: (defaults?: Record<string, unknown>) => void;
openEdit: (record: T, fieldMap?: (record: T) => Record<string, unknown>) => void;
close: () => void;
handleSubmit: (values: Record<string, unknown>) => Promise<void>;
loading: boolean;
}
export function useCrudDrawer<T extends { version: number }>(
options: UseCrudDrawerOptions<T>,
): UseCrudDrawerReturn<T> {
const [open, setOpen] = useState(false);
const [editingRecord, setEditingRecord] = useState<T | null>(null);
const [initialValues, setInitialValues] = useState<Record<string, unknown> | undefined>(undefined);
const { execute, loading } = useApiRequest();
const openCreate = useCallback((defaults?: Record<string, unknown>) => {
setEditingRecord(null);
setInitialValues(defaults);
setOpen(true);
}, []);
const openEdit = useCallback((record: T, fieldMap?: (record: T) => Record<string, unknown>) => {
setEditingRecord(record);
setInitialValues(fieldMap ? fieldMap(record) : (record as unknown as Record<string, unknown>));
setOpen(true);
}, []);
const close = useCallback(() => {
setOpen(false);
setEditingRecord(null);
setInitialValues(undefined);
}, []);
const handleSubmit = useCallback(
async (values: Record<string, unknown>) => {
if (editingRecord) {
await execute(
() => options.onUpdate(options.getId(editingRecord), { ...values, version: (editingRecord as unknown as { version: number }).version }),
'更新成功',
);
} else {
await execute(() => options.onCreate(values), '创建成功');
}
close();
options.onSuccess?.();
},
[editingRecord, options, close, execute],
);
return {
open,
editingRecord,
initialValues,
openCreate,
openEdit,
close,
handleSubmit,
loading,
};
}

View File

@@ -0,0 +1,33 @@
import { useState, useCallback, useEffect, useRef } from 'react';
export interface UseListDataReturn<T> {
data: T[];
loading: boolean;
refresh: () => Promise<void>;
}
export function useListData<T>(fetchFn: () => Promise<T[]>, autoFetch = true): UseListDataReturn<T> {
const [data, setData] = useState<T[]>([]);
const [loading, setLoading] = useState(false);
const fetchFnRef = useRef(fetchFn);
fetchFnRef.current = fetchFn;
const refresh = useCallback(async () => {
setLoading(true);
try {
const result = await fetchFnRef.current();
setData(result);
} catch {
setData([]);
}
setLoading(false);
}, []);
useEffect(() => {
if (autoFetch) {
refresh();
}
}, [refresh, autoFetch]);
return { data, loading, refresh };
}

View File

@@ -1,15 +1,13 @@
import { useEffect, useState, useCallback } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import { import {
Tree, Tree,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Table, Table,
Popconfirm, Popconfirm,
message,
Empty, Empty,
Tag, Tag,
} from 'antd'; } from 'antd';
@@ -21,7 +19,9 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { DataNode } from 'antd/es/tree'; import type { DataNode } from 'antd/es/tree';
import { useThemeMode } from '../hooks/useThemeMode'; import { useThemeMode } from '../hooks/useThemeMode';
import { handleApiError } from '../api/client'; import { DrawerForm } from '../components/DrawerForm';
import { useCrudDrawer } from '../hooks/useCrudDrawer';
import { useApiRequest } from '../hooks/useApiRequest';
import { import {
listOrgTree, listOrgTree,
createOrg, createOrg,
@@ -40,6 +40,7 @@ import {
export default function Organizations() { export default function Organizations() {
const isDark = useThemeMode(); const isDark = useThemeMode();
const { execute } = useApiRequest();
const cardStyle = { const cardStyle = {
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF',
@@ -50,7 +51,6 @@ export default function Organizations() {
// --- Org tree state --- // --- Org tree state ---
const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]); const [orgTree, setOrgTree] = useState<OrganizationInfo[]>([]);
const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null); const [selectedOrg, setSelectedOrg] = useState<OrganizationInfo | null>(null);
const [, setLoading] = useState(false);
// --- Department tree state --- // --- Department tree state ---
const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]); const [deptTree, setDeptTree] = useState<DepartmentInfo[]>([]);
@@ -59,41 +59,44 @@ export default function Organizations() {
// --- Position list state --- // --- Position list state ---
const [positions, setPositions] = useState<PositionInfo[]>([]); const [positions, setPositions] = useState<PositionInfo[]>([]);
// --- Modal state --- // --- Ref for drawer onSuccess callback (avoids before-declaration issue) ---
const [orgModalOpen, setOrgModalOpen] = useState(false); const refreshOrgTreeRef = useRef<() => void>(() => {});
const [deptModalOpen, setDeptModalOpen] = useState(false);
const [positionModalOpen, setPositionModalOpen] = useState(false);
const [editOrg, setEditOrg] = useState<OrganizationInfo | null>(null);
const [orgForm] = Form.useForm();
const [deptForm] = Form.useForm();
const [positionForm] = Form.useForm();
// --- Fetch org tree --- // --- Fetch org tree ---
const fetchOrgTree = useCallback(async () => { const fetchOrgTree = useCallback(async () => {
setLoading(true);
try { try {
const tree = await listOrgTree(); const tree = await listOrgTree();
setOrgTree(tree); setOrgTree(tree);
if (selectedOrg) { if (selectedOrg) {
const stillExists = findOrgInTree(tree, selectedOrg.id); const stillExists = findOrgInTree(tree, selectedOrg.id);
if (!stillExists) { if (!stillExists) { setSelectedOrg(null); setDeptTree([]); setPositions([]); }
setSelectedOrg(null);
setDeptTree([]);
setPositions([]);
}
} }
} catch { } catch { /* silent */ }
message.error('加载组织树失败');
}
setLoading(false);
}, [selectedOrg]); }, [selectedOrg]);
useEffect(() => { refreshOrgTreeRef.current = () => { fetchOrgTree(); };
fetchOrgTree();
}, [fetchOrgTree]);
// --- Fetch dept tree when org selected --- useEffect(() => { fetchOrgTree(); }, [fetchOrgTree]);
// --- Dept Drawer ---
const [deptDrawerOpen, setDeptDrawerOpen] = useState(false);
// --- Position Drawer ---
const [positionDrawerOpen, setPositionDrawerOpen] = useState(false);
// --- Org Drawer (uses ref to avoid before-declaration) ---
const orgDrawer = useCrudDrawer<OrganizationInfo>({
getId: (r) => r.id,
onCreate: async (values) => {
await createOrg({ ...(values as { name: string; code?: string; sort_order?: number }), parent_id: selectedOrg?.id });
},
onUpdate: async (id, values) => {
await updateOrg(id, values as { name: string; code?: string; sort_order?: number; version: number });
},
onSuccess: () => { refreshOrgTreeRef.current(); },
});
// --- Fetch dept tree ---
const fetchDeptTree = useCallback(async () => { const fetchDeptTree = useCallback(async () => {
if (!selectedOrg) return; if (!selectedOrg) return;
try { try {
@@ -101,209 +104,87 @@ export default function Organizations() {
setDeptTree(tree); setDeptTree(tree);
if (selectedDept) { if (selectedDept) {
const stillExists = findDeptInTree(tree, selectedDept.id); const stillExists = findDeptInTree(tree, selectedDept.id);
if (!stillExists) { if (!stillExists) { setSelectedDept(null); setPositions([]); }
setSelectedDept(null);
setPositions([]);
}
} }
} catch { } catch { /* silent */ }
message.error('加载部门树失败');
}
}, [selectedOrg, selectedDept]); }, [selectedOrg, selectedDept]);
useEffect(() => { useEffect(() => { fetchDeptTree(); }, [fetchDeptTree]);
fetchDeptTree();
}, [fetchDeptTree]);
// --- Fetch positions when dept selected --- // --- Fetch positions ---
const fetchPositions = useCallback(async () => { const fetchPositions = useCallback(async () => {
if (!selectedDept) return; if (!selectedDept) return;
try { try {
const list = await listPositions(selectedDept.id); setPositions(await listPositions(selectedDept.id));
setPositions(list); } catch { /* silent */ }
} catch {
message.error('加载岗位列表失败');
}
}, [selectedDept]); }, [selectedDept]);
useEffect(() => { useEffect(() => { fetchPositions(); }, [fetchPositions]);
fetchPositions();
}, [fetchPositions]);
// --- Org handlers --- // --- Org handlers ---
const handleCreateOrg = async (values: {
name: string;
code?: string;
sort_order?: number;
}) => {
try {
if (editOrg) {
await updateOrg(editOrg.id, {
name: values.name,
code: values.code,
sort_order: values.sort_order,
version: editOrg.version,
});
message.success('组织更新成功');
} else {
await createOrg({
name: values.name,
code: values.code,
parent_id: selectedOrg?.id,
sort_order: values.sort_order,
});
message.success('组织创建成功');
}
setOrgModalOpen(false);
setEditOrg(null);
orgForm.resetFields();
fetchOrgTree();
} catch (err: unknown) {
handleApiError(err, '操作失败');
}
};
const handleDeleteOrg = async (id: string) => { const handleDeleteOrg = async (id: string) => {
try { await execute(() => deleteOrg(id), '组织已删除');
await deleteOrg(id); setSelectedOrg(null); setDeptTree([]); setPositions([]);
message.success('组织已删除'); fetchOrgTree();
setSelectedOrg(null);
setDeptTree([]);
setPositions([]);
fetchOrgTree();
} catch (err: unknown) {
handleApiError(err, '删除失败');
}
}; };
// --- Dept handlers --- // --- Dept handlers ---
const handleCreateDept = async (values: { const handleCreateDept = async (values: Record<string, unknown>) => {
name: string;
code?: string;
sort_order?: number;
}) => {
if (!selectedOrg) return; if (!selectedOrg) return;
try { await execute(() => createDept(selectedOrg.id, {
await createDept(selectedOrg.id, { name: values.name as string, code: values.code as string | undefined,
name: values.name, parent_id: selectedDept?.id, sort_order: values.sort_order as number | undefined,
code: values.code, }), '部门创建成功');
parent_id: selectedDept?.id, setDeptDrawerOpen(false);
sort_order: values.sort_order, fetchDeptTree();
});
message.success('部门创建成功');
setDeptModalOpen(false);
deptForm.resetFields();
fetchDeptTree();
} catch (err: unknown) {
handleApiError(err, '操作失败');
}
}; };
const handleDeleteDept = async (id: string) => { const handleDeleteDept = async (id: string) => {
try { await execute(() => deleteDept(id), '部门已删除');
await deleteDept(id); setSelectedDept(null); setPositions([]);
message.success('部门已删除'); fetchDeptTree();
setSelectedDept(null);
setPositions([]);
fetchDeptTree();
} catch (err: unknown) {
handleApiError(err, '删除失败');
}
}; };
// --- Position handlers --- // --- Position handlers ---
const handleCreatePosition = async (values: { const handleCreatePosition = async (values: Record<string, unknown>) => {
name: string;
code?: string;
level?: number;
sort_order?: number;
}) => {
if (!selectedDept) return; if (!selectedDept) return;
try { await execute(() => createPosition(selectedDept.id, {
await createPosition(selectedDept.id, { name: values.name as string, code: values.code as string | undefined,
name: values.name, level: values.level as number | undefined, sort_order: values.sort_order as number | undefined,
code: values.code, }), '岗位创建成功');
level: values.level, setPositionDrawerOpen(false);
sort_order: values.sort_order, fetchPositions();
});
message.success('岗位创建成功');
setPositionModalOpen(false);
positionForm.resetFields();
fetchPositions();
} catch (err: unknown) {
handleApiError(err, '操作失败');
}
}; };
const handleDeletePosition = async (id: string) => { const handleDeletePosition = async (id: string) => {
try { await execute(() => deletePosition(id), '岗位已删除');
await deletePosition(id); fetchPositions();
message.success('岗位已删除');
fetchPositions();
} catch {
message.error('删除失败');
}
}; };
// --- Tree node converters --- // --- Tree node converters ---
const convertOrgTree = (items: OrganizationInfo[]): DataNode[] => const convertOrgTree = (items: OrganizationInfo[]): DataNode[] =>
items.map((item) => ({ items.map((item) => ({
key: item.id, key: item.id,
title: ( title: <span>{item.name} {item.code && <Tag style={{ marginLeft: 4, background: isDark ? '#0f172a' : '#eff6ff', border: 'none', color: '#2563eb', fontSize: 11 }}>{item.code}</Tag>}</span>,
<span>
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#0f172a' : '#eff6ff',
border: 'none',
color: '#2563eb',
fontSize: 11,
}}>{item.code}</Tag>}
</span>
),
children: convertOrgTree(item.children), children: convertOrgTree(item.children),
})); }));
const convertDeptTree = (items: DepartmentInfo[]): DataNode[] => const convertDeptTree = (items: DepartmentInfo[]): DataNode[] =>
items.map((item) => ({ items.map((item) => ({
key: item.id, key: item.id,
title: ( title: <span>{item.name} {item.code && <Tag style={{ marginLeft: 4, background: isDark ? '#0f172a' : '#ECFDF5', border: 'none', color: '#059669', fontSize: 11 }}>{item.code}</Tag>}</span>,
<span>
{item.name}{' '}
{item.code && <Tag style={{
marginLeft: 4,
background: isDark ? '#0f172a' : '#ECFDF5',
border: 'none',
color: '#059669',
fontSize: 11,
}}>{item.code}</Tag>}
</span>
),
children: convertDeptTree(item.children), children: convertDeptTree(item.children),
})); }));
const onSelectOrg = (selectedKeys: React.Key[]) => { const onSelectOrg = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) { if (selectedKeys.length === 0) { setSelectedOrg(null); setDeptTree([]); setSelectedDept(null); setPositions([]); return; }
setSelectedOrg(null); setSelectedOrg(findOrgInTree(orgTree, selectedKeys[0] as string));
setDeptTree([]); setSelectedDept(null); setPositions([]);
setSelectedDept(null);
setPositions([]);
return;
}
const org = findOrgInTree(orgTree, selectedKeys[0] as string);
setSelectedOrg(org);
setSelectedDept(null);
setPositions([]);
}; };
const onSelectDept = (selectedKeys: React.Key[]) => { const onSelectDept = (selectedKeys: React.Key[]) => {
if (selectedKeys.length === 0) { if (selectedKeys.length === 0) { setSelectedDept(null); setPositions([]); return; }
setSelectedDept(null); setSelectedDept(findDeptInTree(deptTree, selectedKeys[0] as string));
setPositions([]);
return;
}
const dept = findDeptInTree(deptTree, selectedKeys[0] as string);
setSelectedDept(dept);
}; };
const positionColumns = [ const positionColumns = [
@@ -312,16 +193,10 @@ export default function Organizations() {
{ title: '级别', dataIndex: 'level', key: 'level' }, { title: '级别', dataIndex: 'level', key: 'level' },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order' }, { title: '排序', dataIndex: 'sort_order', key: 'sort_order' },
{ {
title: '操作', title: '操作', key: 'actions',
key: 'actions',
render: (_: unknown, record: PositionInfo) => ( render: (_: unknown, record: PositionInfo) => (
<Popconfirm <Popconfirm title="确定删除此岗位?" onConfirm={() => handleDeletePosition(record.id)}>
title="确定删除此岗位?" <Button size="small" type="text" danger icon={<DeleteOutlined />}></Button>
onConfirm={() => handleDeletePosition(record.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm> </Popconfirm>
), ),
}, },
@@ -329,60 +204,29 @@ export default function Organizations() {
return ( return (
<div> <div>
{/* 页面标题 */}
<div className="erp-page-header"> <div className="erp-page-header">
<div> <div>
<h4> <h4><ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} /></h4>
<ApartmentOutlined style={{ marginRight: 8, color: '#2563eb' }} />
</h4>
<div className="erp-page-subtitle"></div> <div className="erp-page-subtitle"></div>
</div> </div>
</div> </div>
{/* 三栏布局 */}
<div style={{ display: 'flex', gap: 16, minHeight: 500 }}> <div style={{ display: 'flex', gap: 16, minHeight: 500 }}>
{/* 左栏:组织树 */} {/* 左栏:组织树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}> <div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}> }}>
<span style={{ fontWeight: 600, fontSize: 14 }}></span> <span style={{ fontWeight: 600, fontSize: 14 }}></span>
<Space size={4}> <Space size={4}>
<Button <Button size="small" type="text" icon={<PlusOutlined />} onClick={() => orgDrawer.openCreate()} />
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
setEditOrg(null);
orgForm.resetFields();
setOrgModalOpen(true);
}}
/>
{selectedOrg && ( {selectedOrg && (
<> <>
<Button <Button size="small" type="text" icon={<EditOutlined />} onClick={() => orgDrawer.openEdit(selectedOrg, (r) => ({
size="small" name: r.name, code: r.code, sort_order: r.sort_order,
type="text" }))} />
icon={<EditOutlined />} <Popconfirm title="确定删除此组织?" onConfirm={() => handleDeleteOrg(selectedOrg.id)}>
onClick={() => {
setEditOrg(selectedOrg);
orgForm.setFieldsValue({
name: selectedOrg.name,
code: selectedOrg.code,
sort_order: selectedOrg.sort_order,
});
setOrgModalOpen(true);
}}
/>
<Popconfirm
title="确定删除此组织?"
onConfirm={() => handleDeleteOrg(selectedOrg.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
</> </>
@@ -391,47 +235,23 @@ export default function Organizations() {
</div> </div>
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
{orgTree.length > 0 ? ( {orgTree.length > 0 ? (
<Tree <Tree showLine defaultExpandAll treeData={convertOrgTree(orgTree)} onSelect={onSelectOrg} selectedKeys={selectedOrg ? [selectedOrg.id] : []} />
showLine ) : <Empty description="暂无组织" />}
defaultExpandAll
treeData={convertOrgTree(orgTree)}
onSelect={onSelectOrg}
selectedKeys={selectedOrg ? [selectedOrg.id] : []}
/>
) : (
<Empty description="暂无组织" />
)}
</div> </div>
</div> </div>
{/* 中栏:部门树 */} {/* 中栏:部门树 */}
<div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}> <div style={{ width: 300, flexShrink: 0, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}> }}>
<span style={{ fontWeight: 600, fontSize: 14 }}> <span style={{ fontWeight: 600, fontSize: 14 }}>{selectedOrg ? `${selectedOrg.name} · 部门` : '部门'}</span>
{selectedOrg ? `${selectedOrg.name} · 部门` : '部门'}
</span>
{selectedOrg && ( {selectedOrg && (
<Space size={4}> <Space size={4}>
<Button <Button size="small" type="text" icon={<PlusOutlined />} onClick={() => setDeptDrawerOpen(true)} />
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
deptForm.resetFields();
setDeptModalOpen(true);
}}
/>
{selectedDept && ( {selectedDept && (
<Popconfirm <Popconfirm title="确定删除此部门?" onConfirm={() => handleDeleteDept(selectedDept.id)}>
title="确定删除此部门?"
onConfirm={() => handleDeleteDept(selectedDept.id)}
>
<Button size="small" type="text" danger icon={<DeleteOutlined />} /> <Button size="small" type="text" danger icon={<DeleteOutlined />} />
</Popconfirm> </Popconfirm>
)} )}
@@ -441,157 +261,98 @@ export default function Organizations() {
<div style={{ padding: 12 }}> <div style={{ padding: 12 }}>
{selectedOrg ? ( {selectedOrg ? (
deptTree.length > 0 ? ( deptTree.length > 0 ? (
<Tree <Tree showLine defaultExpandAll treeData={convertDeptTree(deptTree)} onSelect={onSelectDept} selectedKeys={selectedDept ? [selectedDept.id] : []} />
showLine ) : <Empty description="暂无部门" />
defaultExpandAll ) : <Empty description="请先选择组织" />}
treeData={convertDeptTree(deptTree)}
onSelect={onSelectDept}
selectedKeys={selectedDept ? [selectedDept.id] : []}
/>
) : (
<Empty description="暂无部门" />
)
) : (
<Empty description="请先选择组织" />
)}
</div> </div>
</div> </div>
{/* 右栏:岗位表 */} {/* 右栏:岗位表 */}
<div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}> <div style={{ flex: 1, ...cardStyle, overflow: 'hidden' }}>
<div style={{ <div style={{
padding: '14px 20px', padding: '14px 20px', borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
borderBottom: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, display: 'flex', alignItems: 'center', justifyContent: 'space-between',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}> }}>
<span style={{ fontWeight: 600, fontSize: 14 }}> <span style={{ fontWeight: 600, fontSize: 14 }}>{selectedDept ? `${selectedDept.name} · 岗位` : '岗位'}</span>
{selectedDept ? `${selectedDept.name} · 岗位` : '岗位'}
</span>
{selectedDept && ( {selectedDept && (
<Button <Button size="small" type="text" icon={<PlusOutlined />} onClick={() => setPositionDrawerOpen(true)}></Button>
size="small"
type="text"
icon={<PlusOutlined />}
onClick={() => {
positionForm.resetFields();
setPositionModalOpen(true);
}}
>
</Button>
)} )}
</div> </div>
<div style={{ padding: '0 4px' }}> <div style={{ padding: '0 4px' }}>
{selectedDept ? ( {selectedDept ? (
<Table <Table columns={positionColumns} dataSource={positions} rowKey="id" size="small" pagination={false} />
columns={positionColumns} ) : <div style={{ padding: 24 }}><Empty description="请先选择部门" /></div>}
dataSource={positions}
rowKey="id"
size="small"
pagination={false}
/>
) : (
<div style={{ padding: 24 }}>
<Empty description="请先选择部门" />
</div>
)}
</div> </div>
</div> </div>
</div> </div>
{/* Org Modal */} {/* Org Drawer */}
<Modal <DrawerForm
title={editOrg ? '编辑组织' : selectedOrg ? `${selectedOrg.name} 下新建子组织` : '新建根组织'} title={orgDrawer.editingRecord ? '编辑组织' : selectedOrg ? `${selectedOrg.name} 下新建子组织` : '新建根组织'}
open={orgModalOpen} open={orgDrawer.open}
onCancel={() => { onClose={orgDrawer.close}
setOrgModalOpen(false); onSubmit={orgDrawer.handleSubmit}
setEditOrg(null); initialValues={orgDrawer.initialValues}
}} loading={orgDrawer.loading}
onOk={() => orgForm.submit()} width={480}
columns={1}
> >
<Form form={orgForm} onFinish={handleCreateOrg} layout="vertical" style={{ marginTop: 16 }}> <Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入组织名称' }]}>
<Form.Item <Input />
name="name" </Form.Item>
label="名称" <Form.Item name="code" label="编码"><Input /></Form.Item>
rules={[{ required: true, message: '请输入组织名称' }]} <Form.Item name="sort_order" label="排序" initialValue={0}>
> <InputNumber min={0} style={{ width: '100%' }} />
<Input /> </Form.Item>
</Form.Item> </DrawerForm>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* Dept Modal */} {/* Dept Drawer */}
<Modal <DrawerForm
title={ title={selectedDept ? `${selectedDept.name} 下新建子部门` : `${selectedOrg?.name} 下新建部门`}
selectedDept open={deptDrawerOpen}
? `${selectedDept.name} 下新建子部门` onClose={() => setDeptDrawerOpen(false)}
: `${selectedOrg?.name} 下新建部门` onSubmit={handleCreateDept}
} initialValues={{ sort_order: 0 }}
open={deptModalOpen} loading={false}
onCancel={() => setDeptModalOpen(false)} width={480}
onOk={() => deptForm.submit()} columns={1}
> >
<Form form={deptForm} onFinish={handleCreateDept} layout="vertical" style={{ marginTop: 16 }}> <Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入部门名称' }]}>
<Form.Item <Input />
name="name" </Form.Item>
label="名称" <Form.Item name="code" label="编码"><Input /></Form.Item>
rules={[{ required: true, message: '请输入部门名称' }]} <Form.Item name="sort_order" label="排序" initialValue={0}>
> <InputNumber min={0} style={{ width: '100%' }} />
<Input /> </Form.Item>
</Form.Item> </DrawerForm>
<Form.Item name="code" label="编码">
<Input />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
{/* Position Modal */} {/* Position Drawer */}
<Modal <DrawerForm
title={`${selectedDept?.name} 下新建岗位`} title={`${selectedDept?.name} 下新建岗位`}
open={positionModalOpen} open={positionDrawerOpen}
onCancel={() => setPositionModalOpen(false)} onClose={() => setPositionDrawerOpen(false)}
onOk={() => positionForm.submit()} onSubmit={handleCreatePosition}
initialValues={{ level: 1, sort_order: 0 }}
loading={false}
width={480}
columns={1}
> >
<Form form={positionForm} onFinish={handleCreatePosition} layout="vertical" style={{ marginTop: 16 }}> <Form.Item name="name" label="岗位名称" rules={[{ required: true, message: '请输入岗位名称' }]}>
<Form.Item <Input />
name="name" </Form.Item>
label="岗位名称" <Form.Item name="code" label="编码"><Input /></Form.Item>
rules={[{ required: true, message: '请输入岗位名称' }]} <Form.Item name="level" label="级别" initialValue={1}>
> <InputNumber min={1} style={{ width: '100%' }} />
<Input /> </Form.Item>
</Form.Item> <Form.Item name="sort_order" label="排序" initialValue={0}>
<Form.Item name="code" label="编码"> <InputNumber min={0} style={{ width: '100%' }} />
<Input /> </Form.Item>
</Form.Item> </DrawerForm>
<Form.Item name="level" label="级别" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }
// --- Helpers --- function findOrgInTree(tree: OrganizationInfo[], id: string): OrganizationInfo | null {
function findOrgInTree(
tree: OrganizationInfo[],
id: string,
): OrganizationInfo | null {
for (const item of tree) { for (const item of tree) {
if (item.id === id) return item; if (item.id === id) return item;
const found = findOrgInTree(item.children, id); const found = findOrgInTree(item.children, id);
@@ -600,10 +361,7 @@ function findOrgInTree(
return null; return null;
} }
function findDeptInTree( function findDeptInTree(tree: DepartmentInfo[], id: string): DepartmentInfo | null {
tree: DepartmentInfo[],
id: string,
): DepartmentInfo | null {
for (const item of tree) { for (const item of tree) {
if (item.id === id) return item; if (item.id === id) return item;
const found = findDeptInTree(item.children, id); const found = findDeptInTree(item.children, id);

View File

@@ -1,15 +1,13 @@
import { useEffect, useState, useCallback } from 'react'; import { useState, useEffect } from 'react';
import { import {
Table, Table,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
Tag, Tag,
Popconfirm, Popconfirm,
Checkbox, Checkbox,
message,
} from 'antd'; } from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyCertificateOutlined } from '@ant-design/icons'; import { PlusOutlined, EditOutlined, DeleteOutlined, SafetyCertificateOutlined } from '@ant-design/icons';
import { import {
@@ -23,78 +21,48 @@ import {
type RoleInfo, type RoleInfo,
type PermissionInfo, type PermissionInfo,
} from '../api/roles'; } from '../api/roles';
import { handleApiError } from '../api/client'; import { PageContainer } from '../components/PageContainer';
import { DrawerForm } from '../components/DrawerForm';
import { useCrudDrawer } from '../hooks/useCrudDrawer';
import { useApiRequest } from '../hooks/useApiRequest';
import { useThemeMode } from '../hooks/useThemeMode'; import { useThemeMode } from '../hooks/useThemeMode';
import { useListData } from '../hooks/useListData';
export default function Roles() { export default function Roles() {
const [roles, setRoles] = useState<RoleInfo[]>([]); const isDark = useThemeMode();
const { execute } = useApiRequest();
const { data: roles, loading, refresh } = useListData<RoleInfo>(async () => {
const result = await listRoles();
return result.data;
});
const [permissions, setPermissions] = useState<PermissionInfo[]>([]); const [permissions, setPermissions] = useState<PermissionInfo[]>([]);
const [loading, setLoading] = useState(false); const [permDrawerOpen, setPermDrawerOpen] = useState(false);
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editRole, setEditRole] = useState<RoleInfo | null>(null);
const [permModalOpen, setPermModalOpen] = useState(false);
const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null); const [selectedRole, setSelectedRole] = useState<RoleInfo | null>(null);
const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]); const [selectedPermIds, setSelectedPermIds] = useState<string[]>([]);
const [form] = Form.useForm();
const isDark = useThemeMode();
const fetchRoles = useCallback(async () => {
setLoading(true);
try {
const result = await listRoles();
setRoles(result.data);
} catch {
message.error('加载角色失败');
}
setLoading(false);
}, []);
const fetchPermissions = useCallback(async () => {
try {
setPermissions(await listPermissions());
} catch {
// 静默处理
}
}, []);
useEffect(() => { useEffect(() => {
fetchRoles(); listPermissions().then(setPermissions).catch(() => {});
fetchPermissions(); }, []);
}, [fetchRoles, fetchPermissions]);
const handleCreate = async (values: { const roleDrawer = useCrudDrawer<RoleInfo>({
name: string; getId: (r) => r.id,
code: string; onCreate: async (values) => {
description?: string; await createRole(values as unknown as { name: string; code: string; description?: string });
}) => { },
try { onUpdate: async (id, values) => {
if (editRole) { await updateRole(id, values as unknown as { name: string; code: string; description?: string; version: number });
await updateRole(editRole.id, { ...values, version: editRole.version }); },
message.success('角色更新成功'); onSuccess: refresh,
} else { });
await createRole(values);
message.success('角色创建成功');
}
setCreateModalOpen(false);
setEditRole(null);
form.resetFields();
fetchRoles();
} catch (err: unknown) {
handleApiError(err, '操作失败');
}
};
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { await execute(() => deleteRole(id), '角色已删除');
await deleteRole(id); refresh();
message.success('角色已删除');
fetchRoles();
} catch {
message.error('删除失败');
}
}; };
const openPermModal = async (role: RoleInfo) => { const openPermDrawer = async (role: RoleInfo) => {
setSelectedRole(role); setSelectedRole(role);
try { try {
const rolePerms = await getRolePermissions(role.id); const rolePerms = await getRolePermissions(role.id);
@@ -102,64 +70,26 @@ export default function Roles() {
} catch { } catch {
setSelectedPermIds([]); setSelectedPermIds([]);
} }
setPermModalOpen(true); setPermDrawerOpen(true);
}; };
const savePermissions = async () => { const savePermissions = async () => {
if (!selectedRole) return; if (!selectedRole) return;
try { await execute(() => assignPermissions(selectedRole.id, selectedPermIds), '权限分配成功');
await assignPermissions(selectedRole.id, selectedPermIds); setPermDrawerOpen(false);
message.success('权限分配成功');
setPermModalOpen(false);
} catch {
message.error('权限分配失败');
}
};
const openEditModal = (role: RoleInfo) => {
setEditRole(role);
form.setFieldsValue({
name: role.name,
code: role.code,
description: role.description,
});
setCreateModalOpen(true);
};
const openCreateModal = () => {
setEditRole(null);
form.resetFields();
setCreateModalOpen(true);
};
const closeCreateModal = () => {
setCreateModalOpen(false);
setEditRole(null);
form.resetFields();
}; };
const columns = [ const columns = [
{ {
title: '角色名称', title: '角色名称', dataIndex: 'name', key: 'name',
dataIndex: 'name',
key: 'name',
render: (v: string, record: RoleInfo) => ( render: (v: string, record: RoleInfo) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div <div style={{
style={{ width: 32, height: 32, borderRadius: 8,
width: 32, background: record.is_system ? 'linear-gradient(135deg, #2563eb, #60a5fa)' : isDark ? '#0f172a' : '#f8fafc',
height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 8, color: record.is_system ? '#fff' : isDark ? '#94a3b8' : '#475569', fontSize: 14,
background: record.is_system }}>
? 'linear-gradient(135deg, #2563eb, #60a5fa)'
: isDark ? '#0f172a' : '#f8fafc',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: record.is_system ? '#fff' : isDark ? '#94a3b8' : '#475569',
fontSize: 14,
}}
>
<SafetyCertificateOutlined /> <SafetyCertificateOutlined />
</div> </div>
<span style={{ fontWeight: 500 }}>{v}</span> <span style={{ fontWeight: 500 }}>{v}</span>
@@ -167,82 +97,33 @@ export default function Roles() {
), ),
}, },
{ {
title: '编码', title: '编码', dataIndex: 'code', key: 'code',
dataIndex: 'code', render: (v: string) => <Tag style={{ background: isDark ? '#0f172a' : '#f8fafc', border: 'none', color: isDark ? '#94a3b8' : '#475569', fontFamily: 'monospace', fontSize: 12 }}>{v}</Tag>,
key: 'code',
render: (v: string) => (
<Tag style={{
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#94a3b8' : '#475569',
fontFamily: 'monospace',
fontSize: 12,
}}>
{v}
</Tag>
),
}, },
{ {
title: '描述', title: '描述', dataIndex: 'description', key: 'description', ellipsis: true,
dataIndex: 'description', render: (v?: string) => <span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{v || '-'}</span>,
key: 'description',
ellipsis: true,
render: (v: string | undefined) => (
<span style={{ color: isDark ? '#475569' : '#94a3b8' }}>{v || '-'}</span>
),
}, },
{ {
title: '类型', title: '类型', dataIndex: 'is_system', key: 'is_system', width: 100,
dataIndex: 'is_system',
key: 'is_system',
width: 100,
render: (v: boolean) => ( render: (v: boolean) => (
<Tag <Tag style={{ color: v ? '#2563eb' : isDark ? '#94a3b8' : '#475569', background: v ? '#eff6ff' : isDark ? '#0f172a' : '#f8fafc', border: 'none', fontWeight: 500 }}>
style={{
color: v ? '#2563eb' : (isDark ? '#94a3b8' : '#475569'),
background: v ? '#eff6ff' : (isDark ? '#0f172a' : '#f8fafc'),
border: 'none',
fontWeight: 500,
}}
>
{v ? '系统' : '自定义'} {v ? '系统' : '自定义'}
</Tag> </Tag>
), ),
}, },
{ {
title: '操作', title: '操作', key: 'actions', width: 180,
key: 'actions',
width: 180,
render: (_: unknown, record: RoleInfo) => ( render: (_: unknown, record: RoleInfo) => (
<Space size={4}> <Space size={4}>
<Button <Button size="small" type="text" icon={<SafetyCertificateOutlined />} onClick={() => openPermDrawer(record)} style={{ color: '#2563eb' }}></Button>
size="small"
type="text"
icon={<SafetyCertificateOutlined />}
onClick={() => openPermModal(record)}
style={{ color: '#2563eb' }}
>
</Button>
{!record.is_system && ( {!record.is_system && (
<> <>
<Button <Button size="small" type="text" icon={<EditOutlined />} onClick={() => roleDrawer.openEdit(record, (r) => ({
size="small" name: r.name, code: r.code, description: r.description,
type="text" }))} style={{ color: isDark ? '#94a3b8' : '#475569' }} />
icon={<EditOutlined />} <Popconfirm title="确定删除此角色?" onConfirm={() => handleDelete(record.id)}>
onClick={() => openEditModal(record)} <Button size="small" type="text" icon={<DeleteOutlined />} danger />
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Popconfirm
title="确定删除此角色?"
onConfirm={() => handleDelete(record.id)}
>
<Button
size="small"
type="text"
icon={<DeleteOutlined />}
danger
/>
</Popconfirm> </Popconfirm>
</> </>
)} )}
@@ -251,100 +132,67 @@ export default function Roles() {
}, },
]; ];
const groupedPermissions = permissions.reduce<Record<string, PermissionInfo[]>>( const groupedPermissions = permissions.reduce<Record<string, PermissionInfo[]>>((acc, p) => {
(acc, p) => { if (!acc[p.resource]) acc[p.resource] = [];
if (!acc[p.resource]) acc[p.resource] = []; acc[p.resource].push(p);
acc[p.resource].push(p); return acc;
return acc; }, {});
},
{},
);
return ( return (
<div> <PageContainer
{/* 页面标题和工具栏 */} title="角色管理"
<div className="erp-page-header"> subtitle="管理系统角色和权限分配"
<div> actions={<Button type="primary" icon={<PlusOutlined />} onClick={() => roleDrawer.openCreate()}></Button>}
<h4></h4> >
<div className="erp-page-subtitle"></div> <Table
</div> columns={columns}
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateModal}> dataSource={roles}
rowKey="id"
</Button> loading={loading}
</div> pagination={{ pageSize: 20, showTotal: (t) => `${t} 条记录` }}
/>
{/* 表格容器 */} {/* 新建/编辑角色 Drawer */}
<div style={{ <DrawerForm
background: isDark ? '#111827' : '#FFFFFF', title={roleDrawer.editingRecord ? '编辑角色' : '新建角色'}
borderRadius: 12, open={roleDrawer.open}
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, onClose={roleDrawer.close}
overflow: 'hidden', onSubmit={roleDrawer.handleSubmit}
}}> initialValues={roleDrawer.initialValues}
<Table loading={roleDrawer.loading}
columns={columns}
dataSource={roles}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (t) => `${t} 条记录` }}
/>
</div>
{/* 新建/编辑角色弹窗 */}
<Modal
title={editRole ? '编辑角色' : '新建角色'}
open={createModalOpen}
onCancel={closeCreateModal}
onOk={() => form.submit()}
width={480} width={480}
columns={1}
> >
<Form form={form} onFinish={handleCreate} layout="vertical" style={{ marginTop: 16 }}> <Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Form.Item <Input />
name="name" </Form.Item>
label="名称" <Form.Item name="code" label="编码" rules={[{ required: true, message: '请输入角色编码' }]}>
rules={[{ required: true, message: '请输入角色名称' }]} <Input disabled={!!roleDrawer.editingRecord} />
> </Form.Item>
<Input /> <Form.Item name="description" label="描述">
</Form.Item> <Input.TextArea rows={3} />
<Form.Item </Form.Item>
name="code" </DrawerForm>
label="编码"
rules={[{ required: true, message: '请输入角色编码' }]}
>
<Input disabled={!!editRole} />
</Form.Item>
<Form.Item name="description" label="描述">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
{/* 权限分配弹窗 */} {/* 权限分配 Drawer */}
<Modal <DrawerForm
title={`权限分配 - ${selectedRole?.name || ''}`} title={`权限分配 - ${selectedRole?.name || ''}`}
open={permModalOpen} open={permDrawerOpen}
onCancel={() => setPermModalOpen(false)} onClose={() => setPermDrawerOpen(false)}
onOk={savePermissions} onSubmit={savePermissions}
initialValues={{}}
loading={false}
width={600} width={600}
columns={1}
> >
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
{Object.entries(groupedPermissions).map(([resource, perms]) => ( {Object.entries(groupedPermissions).map(([resource, perms]) => (
<div <div key={resource} style={{
key={resource} marginBottom: 16, padding: 16, borderRadius: 10,
style={{ border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
marginBottom: 16, background: isDark ? '#0B0F1A' : '#f1f5f9',
padding: 16, }}>
borderRadius: 10, <div style={{ fontWeight: 600, marginBottom: 12, textTransform: 'capitalize', color: isDark ? '#E2E8F0' : '#334155', fontSize: 14 }}>
border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#f1f5f9',
}}
>
<div style={{
fontWeight: 600,
marginBottom: 12,
textTransform: 'capitalize',
color: isDark ? '#E2E8F0' : '#334155',
fontSize: 14,
}}>
{resource} {resource}
</div> </div>
<Checkbox.Group <Checkbox.Group
@@ -353,19 +201,13 @@ export default function Roles() {
style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }} style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}
> >
{perms.map((p) => ( {perms.map((p) => (
<Checkbox <Checkbox key={p.id} value={p.id} style={{ marginRight: 0 }}>{p.name}</Checkbox>
key={p.id}
value={p.id}
style={{ marginRight: 0 }}
>
{p.name}
</Checkbox>
))} ))}
</Checkbox.Group> </Checkbox.Group>
</div> </div>
))} ))}
</div> </div>
</Modal> </DrawerForm>
</div> </PageContainer>
); );
} }

View File

@@ -1,19 +1,16 @@
import { useEffect, useState, useCallback, useRef } from 'react'; import { useState, useCallback, useEffect } from 'react';
import { import {
Table, Table,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
Tag, Tag,
Popconfirm, Popconfirm,
Checkbox, Checkbox,
message,
} from 'antd'; } from 'antd';
import { import {
PlusOutlined, PlusOutlined,
SearchOutlined,
EditOutlined, EditOutlined,
DeleteOutlined, DeleteOutlined,
UserOutlined, UserOutlined,
@@ -32,311 +29,132 @@ import {
} from '../api/users'; } from '../api/users';
import { listRoles, type RoleInfo } from '../api/roles'; import { listRoles, type RoleInfo } from '../api/roles';
import type { UserInfo } from '../api/auth'; import type { UserInfo } from '../api/auth';
import { handleApiError } from '../api/client'; import { PageContainer } from '../components/PageContainer';
import { DrawerForm } from '../components/DrawerForm';
import { useCrudDrawer } from '../hooks/useCrudDrawer';
import { usePaginatedData } from '../hooks/usePaginatedData';
import { useApiRequest } from '../hooks/useApiRequest';
import { useThemeMode } from '../hooks/useThemeMode'; import { useThemeMode } from '../hooks/useThemeMode';
const STATUS_COLOR_MAP: Record<string, string> = { const STATUS_COLOR_MAP: Record<string, string> = { active: '#059669', disabled: '#dc2626', locked: '#d97706' };
active: '#059669', const STATUS_BG_MAP: Record<string, string> = { active: '#ECFDF5', disabled: '#FEF2F2', locked: '#FFFBEB' };
disabled: '#dc2626', const STATUS_LABEL_MAP: Record<string, string> = { active: '正常', disabled: '禁用', locked: '锁定' };
locked: '#d97706',
};
const STATUS_BG_MAP: Record<string, string> = {
active: '#ECFDF5',
disabled: '#FEF2F2',
locked: '#FFFBEB',
};
const STATUS_LABEL_MAP: Record<string, string> = {
active: '正常',
disabled: '禁用',
locked: '锁定',
};
export default function Users() { export default function Users() {
const [users, setUsers] = useState<UserInfo[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [searchText, setSearchText] = useState('');
const [createModalOpen, setCreateModalOpen] = useState(false);
const [editUser, setEditUser] = useState<UserInfo | null>(null);
const [roleModalOpen, setRoleModalOpen] = useState(false);
const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
const [allRoles, setAllRoles] = useState<RoleInfo[]>([]);
const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
const [form] = Form.useForm();
const isDark = useThemeMode(); const isDark = useThemeMode();
const { execute } = useApiRequest();
const fetchUsers = useCallback(async (p = page) => { const {
setLoading(true); data: users, total, page, loading, refresh,
try { } = usePaginatedData<UserInfo>(async (p, pageSize, search) => {
const result = await listUsers(p, 20, searchText); const result = await listUsers(p, pageSize, search);
setUsers(result.data); return { data: result.data, total: result.total };
setTotal(result.total); }, 20);
} catch {
message.error('加载用户列表失败');
}
setLoading(false);
}, [page, searchText]);
// 搜索防抖:输入后 300ms 才触发查询 const [allRoles, setAllRoles] = useState<RoleInfo[]>([]);
const debounceTimer = useRef<ReturnType<typeof setTimeout> | null>(null); const [roleDrawerOpen, setRoleDrawerOpen] = useState(false);
const debouncedSearch = useCallback((_text: string) => { const [selectedUser, setSelectedUser] = useState<UserInfo | null>(null);
if (debounceTimer.current) clearTimeout(debounceTimer.current); const [selectedRoleIds, setSelectedRoleIds] = useState<string[]>([]);
debounceTimer.current = setTimeout(() => {
setPage(1);
}, 300);
}, []);
const fetchRoles = useCallback(async () => { const fetchRoles = useCallback(async () => {
try { try {
const result = await listRoles(); const result = await listRoles();
setAllRoles(result.data); setAllRoles(result.data);
} catch { } catch { /* silent */ }
// 静默处理
}
}, []); }, []);
useEffect(() => { useEffect(() => { fetchRoles(); }, [fetchRoles]);
fetchUsers();
fetchRoles();
}, [fetchUsers, fetchRoles]);
const handleCreateOrEdit = async (values: { const userDrawer = useCrudDrawer<UserInfo>({
username: string; getId: (r) => r.id,
password?: string; onCreate: async (values) => {
display_name?: string; await createUser(values as unknown as CreateUserRequest);
email?: string; },
phone?: string; onUpdate: async (id, values) => {
}) => { await updateUser(id, values as unknown as UpdateUserRequest);
try { },
if (editUser) { onSuccess: refresh,
const req: UpdateUserRequest = { });
display_name: values.display_name,
email: values.email,
phone: values.phone,
version: editUser.version,
};
await updateUser(editUser.id, req);
message.success('用户更新成功');
} else {
const req: CreateUserRequest = {
username: values.username,
password: values.password ?? '',
display_name: values.display_name,
email: values.email,
phone: values.phone,
};
await createUser(req);
message.success('用户创建成功');
}
setCreateModalOpen(false);
setEditUser(null);
form.resetFields();
fetchUsers();
} catch (err: unknown) {
handleApiError(err, '操作失败');
}
};
const handleDelete = async (id: string) => { const handleDelete = async (id: string) => {
try { await execute(() => deleteUser(id), '用户已删除');
await deleteUser(id); refresh();
message.success('用户已删除');
fetchUsers();
} catch {
message.error('删除失败');
}
}; };
const handleToggleStatus = async (id: string, status: string) => { const handleToggleStatus = async (id: string, status: string) => {
try { const user = users.find(u => u.id === id);
const user = users.find(u => u.id === id); if (!user) return;
if (!user) return; await execute(() => updateUser(id, { status, version: user.version }), status === 'disabled' ? '用户已禁用' : '用户已启用');
await updateUser(id, { status, version: user.version }); refresh();
message.success(status === 'disabled' ? '用户已禁用' : '用户已启用');
fetchUsers();
} catch {
message.error('状态更新失败');
}
}; };
const handleAssignRoles = async () => { const handleAssignRoles = async () => {
if (!selectedUser) return; if (!selectedUser) return;
try { await execute(() => assignRoles(selectedUser.id, selectedRoleIds), '角色分配成功');
await assignRoles(selectedUser.id, selectedRoleIds); setRoleDrawerOpen(false);
message.success('角色分配成功'); refresh();
setRoleModalOpen(false);
fetchUsers();
} catch {
message.error('角色分配失败');
}
}; };
const openCreateModal = () => { const openRoleDrawer = (user: UserInfo) => {
setEditUser(null);
form.resetFields();
setCreateModalOpen(true);
};
const openEditModal = (user: UserInfo) => {
setEditUser(user);
form.setFieldsValue({
username: user.username,
display_name: user.display_name,
email: user.email,
phone: user.phone,
});
setCreateModalOpen(true);
};
const closeCreateModal = () => {
setCreateModalOpen(false);
setEditUser(null);
form.resetFields();
};
const openRoleModal = (user: UserInfo) => {
setSelectedUser(user); setSelectedUser(user);
setSelectedRoleIds(user.roles.map((r) => r.id)); setSelectedRoleIds(user.roles.map((r) => r.id));
setRoleModalOpen(true); setRoleDrawerOpen(true);
}; };
const filteredUsers = users;
const columns = [ const columns = [
{ {
title: '用户', title: '用户', dataIndex: 'username', key: 'username',
dataIndex: 'username',
key: 'username',
render: (v: string, record: UserInfo) => ( render: (v: string, record: UserInfo) => (
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<div <div style={{
style={{ width: 32, height: 32, borderRadius: 8,
width: 32, background: 'linear-gradient(135deg, #2563eb, #60a5fa)',
height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center',
borderRadius: 8, color: '#fff', fontSize: 13, fontWeight: 600,
background: 'linear-gradient(135deg, #2563eb, #60a5fa)', }}>
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: 13,
fontWeight: 600,
}}
>
{(record.display_name?.[0] || v?.[0] || 'U').toUpperCase()} {(record.display_name?.[0] || v?.[0] || 'U').toUpperCase()}
</div> </div>
<div> <div>
<div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div> <div style={{ fontWeight: 500, fontSize: 14 }}>{v}</div>
{record.display_name && ( {record.display_name && <div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>{record.display_name}</div>}
<div style={{ fontSize: 12, color: isDark ? '#475569' : '#94a3b8' }}>
{record.display_name}
</div>
)}
</div> </div>
</div> </div>
), ),
}, },
{ title: '邮箱', dataIndex: 'email', key: 'email', render: (v?: string) => v || '-' },
{ title: '电话', dataIndex: 'phone', key: 'phone', render: (v?: string) => v || '-' },
{ {
title: '邮箱', title: '状态', dataIndex: 'status', key: 'status', width: 100,
dataIndex: 'email',
key: 'email',
render: (v: string | undefined) => v || '-',
},
{
title: '电话',
dataIndex: 'phone',
key: 'phone',
render: (v: string | undefined) => v || '-',
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: (status: string) => ( render: (status: string) => (
<Tag <Tag style={{ color: STATUS_COLOR_MAP[status] || '#62625b', background: STATUS_BG_MAP[status] || '#f8fafc', border: 'none', fontWeight: 500 }}>
style={{
color: STATUS_COLOR_MAP[status] || '#62625b',
background: STATUS_BG_MAP[status] || '#f8fafc',
border: 'none',
fontWeight: 500,
}}
>
{STATUS_LABEL_MAP[status] || status} {STATUS_LABEL_MAP[status] || status}
</Tag> </Tag>
), ),
}, },
{ {
title: '角色', title: '角色', dataIndex: 'roles', key: 'roles',
dataIndex: 'roles',
key: 'roles',
render: (roles: RoleInfo[]) => render: (roles: RoleInfo[]) =>
roles.length > 0 roles.length > 0
? roles.map((r) => ( ? roles.map((r) => <Tag key={r.id} style={{ background: isDark ? '#0f172a' : '#f8fafc', border: 'none', color: isDark ? '#CBD5E1' : '#475569' }}>{r.name}</Tag>)
<Tag key={r.id} style={{
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
}}>
{r.name}
</Tag>
))
: <span style={{ color: isDark ? '#475569' : '#CBD5E1' }}>-</span>, : <span style={{ color: isDark ? '#475569' : '#CBD5E1' }}>-</span>,
}, },
{ {
title: '操作', title: '操作', key: 'actions', width: 240,
key: 'actions',
width: 240,
render: (_: unknown, record: UserInfo) => ( render: (_: unknown, record: UserInfo) => (
<Space size={4}> <Space size={4}>
<Button <Button size="small" type="text" icon={<EditOutlined />} onClick={() => userDrawer.openEdit(record, (r) => ({
size="small" username: r.username, display_name: r.display_name, email: r.email, phone: r.phone,
type="text" }))} style={{ color: isDark ? '#94a3b8' : '#475569' }} />
icon={<EditOutlined />} <Button size="small" type="text" icon={<SafetyCertificateOutlined />} onClick={() => openRoleDrawer(record)} style={{ color: isDark ? '#94a3b8' : '#475569' }} />
onClick={() => openEditModal(record)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Button
size="small"
type="text"
icon={<SafetyCertificateOutlined />}
onClick={() => openRoleModal(record)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
{record.status === 'active' ? ( {record.status === 'active' ? (
<Popconfirm <Popconfirm title="确定禁用此用户?" onConfirm={() => handleToggleStatus(record.id, 'disabled')}>
title="确定禁用此用户?" <Button size="small" type="text" icon={<StopOutlined />} danger />
onConfirm={() => handleToggleStatus(record.id, 'disabled')}
>
<Button
size="small"
type="text"
icon={<StopOutlined />}
danger
/>
</Popconfirm> </Popconfirm>
) : ( ) : (
<Button <Button size="small" type="text" icon={<CheckCircleOutlined />} onClick={() => handleToggleStatus(record.id, 'active')} style={{ color: '#059669' }} />
size="small"
type="text"
icon={<CheckCircleOutlined />}
onClick={() => handleToggleStatus(record.id, 'active')}
style={{ color: '#059669' }}
/>
)} )}
<Popconfirm <Popconfirm title="确定删除此用户?" onConfirm={() => handleDelete(record.id)}>
title="确定删除此用户?" <Button size="small" type="text" icon={<DeleteOutlined />} danger />
onConfirm={() => handleDelete(record.id)}
>
<Button
size="small"
type="text"
icon={<DeleteOutlined />}
danger
/>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -344,112 +162,59 @@ export default function Users() {
]; ];
return ( return (
<div> <PageContainer
{/* 页面标题和工具栏 */} title="用户管理"
<div className="erp-page-header"> subtitle="管理系统用户账户、角色分配和状态"
<div> actions={<Button type="primary" icon={<PlusOutlined />} onClick={() => userDrawer.openCreate()}></Button>}
<h4></h4> >
<div className="erp-page-subtitle"></div> <Table
</div> columns={columns}
<Space size={8}> dataSource={users}
<Input rowKey="id"
placeholder="搜索用户名..." loading={loading}
prefix={<SearchOutlined style={{ color: '#94a3b8' }} />} pagination={{
value={searchText} current: page, total, pageSize: 20,
onChange={(e) => { onChange: (p) => refresh(p),
setSearchText(e.target.value); showTotal: (t) => `${t} 条记录`,
debouncedSearch(e.target.value); }}
}} />
allowClear
style={{ width: 220, borderRadius: 8 }}
/>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={openCreateModal}
>
</Button>
</Space>
</div>
{/* 表格容器 */} {/* 新建/编辑用户 Drawer */}
<div style={{ <DrawerForm
background: isDark ? '#111827' : '#FFFFFF', title={userDrawer.editingRecord ? '编辑用户' : '新建用户'}
borderRadius: 12, open={userDrawer.open}
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, onClose={userDrawer.close}
overflow: 'hidden', onSubmit={userDrawer.handleSubmit}
}}> initialValues={userDrawer.initialValues}
<Table loading={userDrawer.loading}
columns={columns}
dataSource={filteredUsers}
rowKey="id"
loading={loading}
pagination={{
current: page,
total,
pageSize: 20,
onChange: (p) => {
setPage(p);
fetchUsers(p);
},
showTotal: (t) => `${t} 条记录`,
style: { padding: '12px 16px', margin: 0 },
}}
/>
</div>
{/* 新建/编辑用户弹窗 */}
<Modal
title={editUser ? '编辑用户' : '新建用户'}
open={createModalOpen}
onCancel={closeCreateModal}
onOk={() => form.submit()}
width={480} width={480}
columns={1}
> >
<Form form={form} onFinish={handleCreateOrEdit} layout="vertical" style={{ marginTop: 16 }}> <Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Form.Item <Input prefix={<UserOutlined style={{ color: '#94a3b8' }} />} disabled={!!userDrawer.editingRecord} />
name="username" </Form.Item>
label="用户名" {!userDrawer.editingRecord && (
rules={[{ required: true, message: '请输入用户名' }]} <Form.Item name="password" label="密码" rules={[{ required: true, message: '请输入密码' }, { min: 6, message: '密码至少6位' }]}>
> <Input.Password />
<Input prefix={<UserOutlined style={{ color: '#94a3b8' }} />} disabled={!!editUser} />
</Form.Item> </Form.Item>
{!editUser && ( )}
<Form.Item <Form.Item name="display_name" label="显示名"><Input /></Form.Item>
name="password" <Form.Item name="email" label="邮箱" rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}>
label="密码" <Input type="email" />
rules={[ </Form.Item>
{ required: true, message: '请输入密码' }, <Form.Item name="phone" label="电话"><Input /></Form.Item>
{ min: 6, message: '密码至少6位' }, </DrawerForm>
]}
>
<Input.Password />
</Form.Item>
)}
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>
<Form.Item
name="email"
label="邮箱"
rules={[{ type: 'email', message: '请输入有效的邮箱地址' }]}
>
<Input type="email" />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
</Form>
</Modal>
{/* 角色分配弹窗 */} {/* 角色分配 Drawer */}
<Modal <DrawerForm
title={`分配角色 - ${selectedUser?.username || ''}`} title={`分配角色 - ${selectedUser?.username || ''}`}
open={roleModalOpen} open={roleDrawerOpen}
onCancel={() => setRoleModalOpen(false)} onClose={() => setRoleDrawerOpen(false)}
onOk={handleAssignRoles} onSubmit={async () => { await handleAssignRoles(); }}
width={480} initialValues={{}}
loading={false}
width={520}
columns={1}
> >
<div style={{ marginTop: 8 }}> <div style={{ marginTop: 8 }}>
<Checkbox.Group <Checkbox.Group
@@ -458,26 +223,20 @@ export default function Users() {
style={{ display: 'flex', flexDirection: 'column', gap: 12 }} style={{ display: 'flex', flexDirection: 'column', gap: 12 }}
> >
{allRoles.map((r) => ( {allRoles.map((r) => (
<div <div key={r.id} style={{
key={r.id} padding: '10px 14px', borderRadius: 8,
style={{ border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
padding: '10px 14px', background: isDark ? '#0B0F1A' : '#f1f5f9',
borderRadius: 8, }}>
border: `1px solid ${isDark ? '#0f172a' : '#E2E8F0'}`,
background: isDark ? '#0B0F1A' : '#f1f5f9',
}}
>
<Checkbox value={r.id}> <Checkbox value={r.id}>
<span style={{ fontWeight: 500 }}>{r.name}</span> <span style={{ fontWeight: 500 }}>{r.name}</span>
<span style={{ color: isDark ? '#475569' : '#94a3b8', marginLeft: 8, fontSize: 12 }}> <span style={{ color: isDark ? '#475569' : '#94a3b8', marginLeft: 8, fontSize: 12 }}>{r.code}</span>
{r.code}
</span>
</Checkbox> </Checkbox>
</div> </div>
))} ))}
</Checkbox.Group> </Checkbox.Group>
</div> </div>
</Modal> </DrawerForm>
</div> </PageContainer>
); );
} }

View File

@@ -1,33 +1,23 @@
import { useState, useEffect, useCallback, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { Table, Select, Input, Tag, message } from 'antd'; import { Table, Select, Input, Tag } from 'antd';
import type { ColumnsType, TablePaginationConfig } from 'antd/es/table'; import type { ColumnsType, TablePaginationConfig } from 'antd/es/table';
import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs'; import { listAuditLogs, type AuditLogItem, type AuditLogQuery } from '../../api/auditLogs';
import { listUsers } from '../../api/users'; import { listUsers } from '../../api/users';
import { useThemeMode } from '../../hooks/useThemeMode'; import { useThemeMode } from '../../hooks/useThemeMode';
import { usePaginatedData } from '../../hooks/usePaginatedData';
const RESOURCE_TYPE_OPTIONS = [ const RESOURCE_TYPE_OPTIONS = [
{ value: 'user', label: '用户' }, { value: 'user', label: '用户' }, { value: 'role', label: '角色' },
{ value: 'role', label: '角色' }, { value: 'position', label: '岗位' }, { value: 'organization', label: '组织' },
{ value: 'position', label: '岗位' }, { value: 'department', label: '部门' }, { value: 'process_instance', label: '流程实例' },
{ value: 'organization', label: '组织' }, { value: 'process_definition', label: '流程定义' }, { value: 'task', label: '流程任务' },
{ value: 'department', label: '部门' }, { value: 'dictionary', label: '字典' }, { value: 'menu', label: '菜单' },
{ value: 'process_instance', label: '流程实例' }, { value: 'setting', label: '设置' }, { value: 'numbering_rule', label: '编号规则' },
{ value: 'process_definition', label: '流程定义' }, { value: 'patient', label: '患者' }, { value: 'patient_tag', label: '患者标签' },
{ value: 'task', label: '流程任务' }, { value: 'patient_family_member', label: '家庭成员' }, { value: 'patient_doctor_relation', label: '医患关系' },
{ value: 'dictionary', label: '字典' }, { value: 'points_transaction', label: '积分流水' }, { value: 'points_product', label: '积分商品' },
{ value: 'menu', label: '菜单' }, { value: 'points_order', label: '积分订单' }, { value: 'points_rule', label: '积分规则' },
{ value: 'setting', label: '设置' }, { value: 'offline_event', label: '线下活动' }, { value: 'offline_event_registration', label: '活动签到' },
{ value: 'numbering_rule', label: '编号规则' },
{ value: 'patient', label: '患者' },
{ value: 'patient_tag', label: '患者标签' },
{ value: 'patient_family_member', label: '家庭成员' },
{ value: 'patient_doctor_relation', label: '医患关系' },
{ value: 'points_transaction', label: '积分流水' },
{ value: 'points_product', label: '积分商品' },
{ value: 'points_order', label: '积分订单' },
{ value: 'points_rule', label: '积分规则' },
{ value: 'offline_event', label: '线下活动' },
{ value: 'offline_event_registration', label: '活动签到' },
]; ];
const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = { const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }> = {
@@ -38,37 +28,25 @@ const ACTION_STYLES: Record<string, { bg: string; color: string; text: string }>
function formatDateTime(value: string): string { function formatDateTime(value: string): string {
return new Date(value).toLocaleString('zh-CN', { return new Date(value).toLocaleString('zh-CN', {
year: 'numeric', year: 'numeric', month: '2-digit', day: '2-digit',
month: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
}); });
} }
export default function AuditLogViewer() { export default function AuditLogViewer() {
const [logs, setLogs] = useState<AuditLogItem[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [query, setQuery] = useState<AuditLogQuery>({ page: 1, page_size: 20 });
const isDark = useThemeMode(); const isDark = useThemeMode();
const userNameCache = useRef<Record<string, string>>({}); const userNameCache = useRef<Record<string, string>>({});
const cacheLoaded = useRef(false); const cacheLoaded = useRef(false);
const fetchLogs = useCallback(async (params: AuditLogQuery) => { const { data: logs, total, page, loading, filters, setFilters, refresh } = usePaginatedData<AuditLogItem, AuditLogQuery>(
setLoading(true); async (p, pageSize, query) => {
try { const result = await listAuditLogs({ ...query, page: p, page_size: pageSize });
const result = await listAuditLogs(params); return { data: result.data, total: result.total };
setLogs(result.data); },
setTotal(result.total); { pageSize: 20, defaultFilters: { page: 1, page_size: 20 } as unknown as AuditLogQuery, autoFetch: false },
} catch { );
message.error('加载审计日志失败');
}
setLoading(false);
}, []);
// 加载用户名称缓存(分页遍历所有用户) // Load user name cache
useEffect(() => { useEffect(() => {
if (cacheLoaded.current) return; if (cacheLoaded.current) return;
let cancelled = false; let cancelled = false;
@@ -85,109 +63,45 @@ export default function AuditLogViewer() {
hasMore = result.data.length >= pageSize; hasMore = result.data.length >= pageSize;
currentPage += 1; currentPage += 1;
} }
if (!cancelled) { if (!cancelled) cacheLoaded.current = true;
cacheLoaded.current = true; } catch { /* silent */ }
}
} catch {
// 静默失败,将显示 UUID
}
}; };
loadAllUsers(); loadAllUsers();
return () => { cancelled = true; }; return () => { cancelled = true; };
}, []); }, []);
useEffect(() => { useEffect(() => { refresh(1); }, []);
fetchLogs(query);
}, [query, fetchLogs]);
const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => { const handleFilterChange = (field: keyof AuditLogQuery, value: string | undefined) => {
setQuery((prev) => ({ setFilters((prev) => ({ ...prev, [field]: value || undefined, page: 1 }));
...prev,
[field]: value || undefined,
page: 1,
}));
};
const handleTableChange = (pagination: TablePaginationConfig) => {
setQuery((prev) => ({
...prev,
page: pagination.current,
page_size: pagination.pageSize,
}));
}; };
const columns: ColumnsType<AuditLogItem> = [ const columns: ColumnsType<AuditLogItem> = [
{ {
title: '操作', title: '操作', dataIndex: 'action', key: 'action', width: 100,
dataIndex: 'action',
key: 'action',
width: 100,
render: (action: string) => { render: (action: string) => {
const info = ACTION_STYLES[action] || { bg: '#f8fafc', color: '#475569', text: action }; const info = ACTION_STYLES[action] || { bg: '#f8fafc', color: '#475569', text: action };
return ( return <Tag style={{ background: info.bg, border: 'none', color: info.color, fontWeight: 500 }}>{info.text}</Tag>;
<Tag style={{
background: info.bg,
border: 'none',
color: info.color,
fontWeight: 500,
}}>
{info.text}
</Tag>
);
}, },
}, },
{ {
title: '资源类型', title: '资源类型', dataIndex: 'resource_type', key: 'resource_type', width: 120,
dataIndex: 'resource_type', render: (v: string) => <Tag style={{ background: isDark ? '#0f172a' : '#f8fafc', border: 'none', color: isDark ? '#CBD5E1' : '#475569' }}>{v}</Tag>,
key: 'resource_type',
width: 120,
render: (v: string) => (
<Tag style={{
background: isDark ? '#0f172a' : '#f8fafc',
border: 'none',
color: isDark ? '#CBD5E1' : '#475569',
}}>
{v}
</Tag>
),
}, },
{ {
title: '资源 ID', title: '资源 ID', dataIndex: 'resource_id', key: 'resource_id', width: 200, ellipsis: true,
dataIndex: 'resource_id', render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>{v}</span>,
key: 'resource_id',
width: 200,
ellipsis: true,
render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12, color: isDark ? '#94a3b8' : '#475569' }}>
{v}
</span>
),
}, },
{ {
title: '操作用户', title: '操作用户', dataIndex: 'user_id', key: 'user_id', width: 200, ellipsis: true,
dataIndex: 'user_id',
key: 'user_id',
width: 200,
ellipsis: true,
render: (v: string) => { render: (v: string) => {
const name = userNameCache.current[v]; const name = userNameCache.current[v];
return ( return <span title={v} style={{ fontSize: 13, color: isDark ? '#CBD5E1' : '#334155' }}>{name || v}</span>;
<span title={v} style={{ fontSize: 13, color: isDark ? '#CBD5E1' : '#334155' }}>
{name || v}
</span>
);
}, },
}, },
{ {
title: '时间', title: '时间', dataIndex: 'created_at', key: 'created_at', width: 180,
dataIndex: 'created_at', render: (value: string) => <span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>{formatDateTime(value)}</span>,
key: 'created_at',
width: 180,
render: (value: string) => (
<span style={{ color: isDark ? '#475569' : '#94a3b8', fontSize: 13 }}>
{formatDateTime(value)}
</span>
),
}, },
]; ];
@@ -195,54 +109,35 @@ export default function AuditLogViewer() {
<div> <div>
{/* 筛选工具栏 */} {/* 筛选工具栏 */}
<div style={{ <div style={{
display: 'flex', display: 'flex', alignItems: 'center', gap: 12, marginBottom: 16, padding: 12,
alignItems: 'center', background: isDark ? '#111827' : '#FFFFFF', borderRadius: 10,
gap: 12,
marginBottom: 16,
padding: 12,
background: isDark ? '#111827' : '#FFFFFF',
borderRadius: 10,
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
}}> }}>
<Select <Select
allowClear allowClear placeholder="资源类型" style={{ width: 160 }}
placeholder="资源类型"
style={{ width: 160 }}
options={RESOURCE_TYPE_OPTIONS} options={RESOURCE_TYPE_OPTIONS}
value={query.resource_type} value={filters.resource_type}
onChange={(value) => handleFilterChange('resource_type', value)} onChange={(value) => handleFilterChange('resource_type', value)}
/> />
<Input <Input
allowClear allowClear placeholder="操作用户 ID" style={{ width: 240 }}
placeholder="操作用户 ID" value={filters.user_id ?? ''}
style={{ width: 240 }}
value={query.user_id ?? ''}
onChange={(e) => handleFilterChange('user_id', e.target.value)} onChange={(e) => handleFilterChange('user_id', e.target.value)}
/> />
<span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8', marginLeft: 'auto' }}> <span style={{ fontSize: 13, color: isDark ? '#475569' : '#94a3b8', marginLeft: 'auto' }}> {total} </span>
{total}
</span>
</div> </div>
{/* 表格 */} {/* 表格 */}
<div style={{ <div style={{
background: isDark ? '#111827' : '#FFFFFF', background: isDark ? '#111827' : '#FFFFFF', borderRadius: 12,
borderRadius: 12, border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`, overflow: 'hidden',
border: `1px solid ${isDark ? '#0f172a' : '#f8fafc'}`,
overflow: 'hidden',
}}> }}>
<Table <Table
rowKey="id" rowKey="id" columns={columns} dataSource={logs} loading={loading}
columns={columns} onChange={(pagination: TablePaginationConfig) => refresh(pagination.current)}
dataSource={logs}
loading={loading}
onChange={handleTableChange}
pagination={{ pagination={{
current: query.page, current: page, pageSize: 20, total,
pageSize: query.page_size, showSizeChanger: true, showTotal: (t) => `${t}`,
total,
showSizeChanger: true,
showTotal: (t) => `${t}`,
}} }}
scroll={{ x: 900 }} scroll={{ x: 900 }}
/> />

View File

@@ -1,13 +1,12 @@
import { Form, Input, Button, message, Card, Typography } from 'antd'; import { Form, Input, Button, Card, Typography } from 'antd';
import { LockOutlined } from '@ant-design/icons'; import { LockOutlined } from '@ant-design/icons';
import { useAuthStore } from '../../stores/auth'; import { useAuthStore } from '../../stores/auth';
import { changePassword } from '../../api/auth'; import { changePassword } from '../../api/auth';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { useApiRequest } from '../../hooks/useApiRequest';
const { Title } = Typography;
export default function ChangePassword() { export default function ChangePassword() {
const [messageApi, contextHolder] = message.useMessage(); const { execute } = useApiRequest();
const logout = useAuthStore((s) => s.logout); const logout = useAuthStore((s) => s.logout);
const navigate = useNavigate(); const navigate = useNavigate();
const [form] = Form.useForm(); const [form] = Form.useForm();
@@ -18,84 +17,53 @@ export default function ChangePassword() {
confirm_password: string; confirm_password: string;
}) => { }) => {
if (values.new_password !== values.confirm_password) { if (values.new_password !== values.confirm_password) {
messageApi.error('两次输入的新密码不一致');
return; return;
} }
try { await execute(async () => {
await changePassword(values.current_password, values.new_password); await changePassword(values.current_password, values.new_password);
messageApi.success('密码修改成功,请重新登录');
await logout(); await logout();
navigate('/login'); navigate('/login');
} catch (err: unknown) { }, '密码修改成功,请重新登录', '密码修改失败');
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '密码修改失败';
messageApi.error(errorMsg);
}
}; };
return ( return (
<Card style={{ maxWidth: 480, margin: '0 auto' }}> <Card style={{ maxWidth: 480, margin: '0 auto' }}>
{contextHolder} <Typography.Title level={4} style={{ marginBottom: 24 }}></Typography.Title>
<Title level={4} style={{ marginBottom: 24 }}>
</Title>
<Form form={form} layout="vertical" onFinish={onFinish}> <Form form={form} layout="vertical" onFinish={onFinish}>
<Form.Item <Form.Item name="current_password" label="当前密码" rules={[{ required: true, message: '请输入当前密码' }]}>
name="current_password" <Input.Password prefix={<LockOutlined />} placeholder="请输入当前密码" />
label="当前密码"
rules={[{ required: true, message: '请输入当前密码' }]}
>
<Input.Password
prefix={<LockOutlined />}
placeholder="请输入当前密码"
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="new_password" name="new_password" label="新密码"
label="新密码"
rules={[ rules={[
{ required: true, message: '请输入新密码' }, { required: true, message: '请输入新密码' },
{ min: 8, message: '密码长度不能少于8位' }, { min: 8, message: '密码长度不能少于8位' },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('current_password') !== value) { if (!value || getFieldValue('current_password') !== value) return Promise.resolve();
return Promise.resolve();
}
return Promise.reject(new Error('新密码不能与当前密码相同')); return Promise.reject(new Error('新密码不能与当前密码相同'));
}, },
}), }),
]} ]}
> >
<Input.Password <Input.Password prefix={<LockOutlined />} placeholder="请输入新密码至少8位" />
prefix={<LockOutlined />}
placeholder="请输入新密码至少8位"
/>
</Form.Item> </Form.Item>
<Form.Item <Form.Item
name="confirm_password" name="confirm_password" label="确认新密码"
label="确认新密码"
rules={[ rules={[
{ required: true, message: '请确认新密码' }, { required: true, message: '请确认新密码' },
({ getFieldValue }) => ({ ({ getFieldValue }) => ({
validator(_, value) { validator(_, value) {
if (!value || getFieldValue('new_password') === value) { if (!value || getFieldValue('new_password') === value) return Promise.resolve();
return Promise.resolve();
}
return Promise.reject(new Error('两次输入的密码不一致')); return Promise.reject(new Error('两次输入的密码不一致'));
}, },
}), }),
]} ]}
> >
<Input.Password <Input.Password prefix={<LockOutlined />} placeholder="请再次输入新密码" />
prefix={<LockOutlined />}
placeholder="请再次输入新密码"
/>
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" block> <Button type="primary" htmlType="submit" block></Button>
</Button>
</Form.Item> </Form.Item>
</Form> </Form>
</Card> </Card>

View File

@@ -1,14 +1,12 @@
import { useEffect, useState, useCallback } from 'react'; import { useState } from 'react';
import { import {
Table, Table,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Popconfirm, Popconfirm,
message,
Typography, Typography,
Tag, Tag,
} from 'antd'; } from 'antd';
@@ -27,100 +25,46 @@ import {
type CreateDictionaryItemRequest, type CreateDictionaryItemRequest,
type UpdateDictionaryItemRequest, type UpdateDictionaryItemRequest,
} from '../../api/dictionaries'; } from '../../api/dictionaries';
import { useListData } from '../../hooks/useListData';
// --- Types --- import { useCrudDrawer } from '../../hooks/useCrudDrawer';
import { DrawerForm } from '../../components/DrawerForm';
import { useApiRequest } from '../../hooks/useApiRequest';
type DictItem = DictionaryItemInfo; type DictItem = DictionaryItemInfo;
type Dictionary = DictionaryInfo; type Dictionary = DictionaryInfo;
// --- Component ---
export default function DictionaryManager() { export default function DictionaryManager() {
const [dictionaries, setDictionaries] = useState<Dictionary[]>([]); const { data: dictionaries, loading, refresh } = useListData<Dictionary>(async () => {
const [loading, setLoading] = useState(false); const result = await listDictionaries();
const [dictModalOpen, setDictModalOpen] = useState(false); return Array.isArray(result) ? result : result.data ?? [];
const [editDict, setEditDict] = useState<Dictionary | null>(null); });
const [itemModalOpen, setItemModalOpen] = useState(false);
const { execute } = useApiRequest();
// 字典 CRUD Drawer
const dictDrawer = useCrudDrawer<Dictionary>({
getId: (r) => r.id,
onCreate: async (values) => {
await createDictionary(values as unknown as CreateDictionaryRequest);
},
onUpdate: async (id, values) => {
await updateDictionary(id, values as unknown as CreateDictionaryRequest & { version: number });
},
onSuccess: refresh,
});
// 字典项 Drawer — 因需要 activeDictId手写状态管理
const [itemDrawerOpen, setItemDrawerOpen] = useState(false);
const [activeDictId, setActiveDictId] = useState<string | null>(null); const [activeDictId, setActiveDictId] = useState<string | null>(null);
const [editItem, setEditItem] = useState<DictItem | null>(null); const [editItem, setEditItem] = useState<DictItem | null>(null);
const [dictForm] = Form.useForm();
const [itemForm] = Form.useForm(); const [itemForm] = Form.useForm();
const fetchDictionaries = useCallback(async () => {
setLoading(true);
try {
const result = await listDictionaries();
setDictionaries(Array.isArray(result) ? result : result.data ?? []);
} catch {
message.error('加载字典列表失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchDictionaries();
}, [fetchDictionaries]);
// --- Dictionary CRUD ---
const handleDictSubmit = async (values: CreateDictionaryRequest) => {
try {
if (editDict) {
await updateDictionary(editDict.id, { ...values, version: editDict.version });
message.success('字典更新成功');
} else {
await createDictionary(values);
message.success('字典创建成功');
}
closeDictModal();
fetchDictionaries();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteDict = async (id: string, version: number) => {
try {
await deleteDictionary(id, version);
message.success('字典已删除');
fetchDictionaries();
} catch {
message.error('删除失败');
}
};
const openEditDict = (dict: Dictionary) => {
setEditDict(dict);
dictForm.setFieldsValue({
name: dict.name,
code: dict.code,
description: dict.description,
});
setDictModalOpen(true);
};
const openCreateDict = () => {
setEditDict(null);
dictForm.resetFields();
setDictModalOpen(true);
};
const closeDictModal = () => {
setDictModalOpen(false);
setEditDict(null);
dictForm.resetFields();
};
// --- Dictionary Item CRUD ---
const openAddItem = (dictId: string) => { const openAddItem = (dictId: string) => {
setActiveDictId(dictId); setActiveDictId(dictId);
setEditItem(null); setEditItem(null);
itemForm.resetFields(); itemForm.resetFields();
setItemModalOpen(true); itemForm.setFieldsValue({ sort_order: 0 });
setItemDrawerOpen(true);
}; };
const openEditItem = (dictId: string, item: DictItem) => { const openEditItem = (dictId: string, item: DictItem) => {
@@ -132,75 +76,56 @@ export default function DictionaryManager() {
sort_order: item.sort_order, sort_order: item.sort_order,
color: item.color, color: item.color,
}); });
setItemModalOpen(true); setItemDrawerOpen(true);
}; };
const handleItemSubmit = async (values: CreateDictionaryItemRequest & { sort_order: number }) => { const closeItemDrawer = () => {
if (!activeDictId) return; setItemDrawerOpen(false);
try {
if (editItem) {
await updateDictionaryItem(activeDictId, editItem.id, { ...values, version: editItem.version } as UpdateDictionaryItemRequest);
message.success('字典项更新成功');
} else {
await createDictionaryItem(activeDictId, values);
message.success('字典项添加成功');
}
closeItemModal();
fetchDictionaries();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDeleteItem = async (dictId: string, itemId: string, version: number) => {
try {
await deleteDictionaryItem(dictId, itemId, version);
message.success('字典项已删除');
fetchDictionaries();
} catch {
message.error('删除失败');
}
};
const closeItemModal = () => {
setItemModalOpen(false);
setActiveDictId(null); setActiveDictId(null);
setEditItem(null); setEditItem(null);
itemForm.resetFields(); itemForm.resetFields();
}; };
// --- Columns --- const handleItemSubmit = async (values: Record<string, unknown>) => {
if (!activeDictId) return;
const itemValues = values as unknown as CreateDictionaryItemRequest & { sort_order: number };
if (editItem) {
await execute(
() => updateDictionaryItem(activeDictId, editItem.id, { ...itemValues, version: editItem.version } as UpdateDictionaryItemRequest),
'字典项更新成功',
);
} else {
await execute(() => createDictionaryItem(activeDictId, itemValues), '字典项添加成功');
}
closeItemDrawer();
refresh();
};
const handleDeleteDict = async (id: string, version: number) => {
await execute(() => deleteDictionary(id, version), '字典已删除');
refresh();
};
const handleDeleteItem = async (dictId: string, itemId: string, version: number) => {
await execute(() => deleteDictionaryItem(dictId, itemId, version), '字典项已删除');
refresh();
};
const columns = [ const columns = [
{ title: '名称', dataIndex: 'name', key: 'name' }, { title: '名称', dataIndex: 'name', key: 'name' },
{ title: '编码', dataIndex: 'code', key: 'code' }, { title: '编码', dataIndex: 'code', key: 'code' },
{ { title: '说明', dataIndex: 'description', key: 'description', ellipsis: true },
title: '说明',
dataIndex: 'description',
key: 'description',
ellipsis: true,
},
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
render: (_: unknown, record: Dictionary) => ( render: (_: unknown, record: Dictionary) => (
<Space> <Space>
<Button size="small" onClick={() => openAddItem(record.id)}> <Button size="small" onClick={() => openAddItem(record.id)}></Button>
<Button size="small" onClick={() => dictDrawer.openEdit(record, (r) => ({
</Button> name: r.name, code: r.code, description: r.description,
<Button size="small" onClick={() => openEditDict(record)}> }))}></Button>
<Popconfirm title="确定删除此字典?" onConfirm={() => handleDeleteDict(record.id, record.version)}>
</Button> <Button size="small" danger></Button>
<Popconfirm
title="确定删除此字典?"
onConfirm={() => handleDeleteDict(record.id, record.version)}
>
<Button size="small" danger>
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -212,31 +137,17 @@ export default function DictionaryManager() {
{ title: '值', dataIndex: 'value', key: 'value' }, { title: '值', dataIndex: 'value', key: 'value' },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 }, { title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 },
{ {
title: '颜色', title: '颜色', dataIndex: 'color', key: 'color', width: 80,
dataIndex: 'color', render: (color?: string) => color ? <Tag color={color}>{color}</Tag> : '-',
key: 'color',
width: 80,
render: (color?: string) =>
color ? <Tag color={color}>{color}</Tag> : '-',
}, },
{ {
title: '操作', title: '操作',
key: 'actions', key: 'actions',
render: (_: unknown, record: DictItem) => ( render: (_: unknown, record: DictItem) => (
<Space> <Space>
<Button <Button size="small" onClick={() => openEditItem(dictId, record)}></Button>
size="small" <Popconfirm title="确定删除此字典项?" onConfirm={() => handleDeleteItem(dictId, record.id, record.version)}>
onClick={() => openEditItem(dictId, record)} <Button size="small" danger></Button>
>
</Button>
<Popconfirm
title="确定删除此字典项?"
onConfirm={() => handleDeleteItem(dictId, record.id, record.version)}
>
<Button size="small" danger>
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -245,17 +156,9 @@ export default function DictionaryManager() {
return ( return (
<div> <div>
<div <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
style={{ <Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
display: 'flex', <Button type="primary" icon={<PlusOutlined />} onClick={() => dictDrawer.openCreate()}>
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreateDict}>
</Button> </Button>
</div> </div>
@@ -279,68 +182,52 @@ export default function DictionaryManager() {
}} }}
/> />
{/* Dictionary Modal */} {/* 字典 Drawer */}
<Modal <DrawerForm
title={editDict ? '编辑字典' : '新建字典'} title={dictDrawer.editingRecord ? '编辑字典' : '新建字典'}
open={dictModalOpen} open={dictDrawer.open}
onCancel={closeDictModal} onClose={dictDrawer.close}
onOk={() => dictForm.submit()} onSubmit={dictDrawer.handleSubmit}
initialValues={dictDrawer.initialValues}
loading={dictDrawer.loading}
width={480}
columns={1}
> >
<Form form={dictForm} onFinish={handleDictSubmit} layout="vertical"> <Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入字典名称' }]}>
<Form.Item <Input />
name="name" </Form.Item>
label="名称" <Form.Item name="code" label="编码" rules={[{ required: true, message: '请输入字典编码' }]}>
rules={[{ required: true, message: '请输入字典名称' }]} <Input disabled={!!dictDrawer.editingRecord} />
> </Form.Item>
<Input /> <Form.Item name="description" label="说明">
</Form.Item> <Input.TextArea rows={3} />
<Form.Item </Form.Item>
name="code" </DrawerForm>
label="编码"
rules={[{ required: true, message: '请输入字典编码' }]}
>
<Input disabled={!!editDict} />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
{/* Dictionary Item Modal */} {/* 字典项 Drawer */}
<Modal <DrawerForm
title={editItem ? '编辑字典项' : '添加字典项'} title={editItem ? '编辑字典项' : '添加字典项'}
open={itemModalOpen} open={itemDrawerOpen}
onCancel={closeItemModal} onClose={closeItemDrawer}
onOk={() => itemForm.submit()} onSubmit={handleItemSubmit}
initialValues={editItem ? { label: editItem.label, value: editItem.value, sort_order: editItem.sort_order, color: editItem.color } : { sort_order: 0 }}
loading={false}
width={480}
columns={1}
> >
<Form form={itemForm} onFinish={handleItemSubmit} layout="vertical"> <Form.Item name="label" label="标签" rules={[{ required: true, message: '请输入标签' }]}>
<Form.Item <Input />
name="label" </Form.Item>
label="标签" <Form.Item name="value" label="值" rules={[{ required: true, message: '请输入值' }]}>
rules={[{ required: true, message: '请输入标签' }]} <Input />
> </Form.Item>
<Input /> <Form.Item name="sort_order" label="排序" initialValue={0}>
</Form.Item> <InputNumber min={0} style={{ width: '100%' }} />
<Form.Item </Form.Item>
name="value" <Form.Item name="color" label="颜色">
label="值" <Input placeholder="如blue, red, green 或十六进制色值" />
rules={[{ required: true, message: '请输入值' }]} </Form.Item>
> </DrawerForm>
<Input />
</Form.Item>
<Form.Item
name="sort_order"
label="排序"
initialValue={0}
>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="color" label="颜色">
<Input placeholder="如blue, red, green 或十六进制色值" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,13 +1,10 @@
import { useEffect, useState, useCallback } from 'react'; import { useState } from 'react';
import { import {
Table, Table,
Switch, Switch,
Modal,
Button, Button,
Space, Space,
Typography, Typography,
message,
Card,
Form, Form,
Input, Input,
} from 'antd'; } from 'antd';
@@ -17,113 +14,53 @@ import {
updateLanguage, updateLanguage,
type LanguageInfo, type LanguageInfo,
} from '../../api/languages'; } from '../../api/languages';
import { useListData } from '../../hooks/useListData';
import { DrawerForm } from '../../components/DrawerForm';
import { useApiRequest } from '../../hooks/useApiRequest';
export default function LanguageManager() { export default function LanguageManager() {
const [languages, setLanguages] = useState<LanguageInfo[]>([]); const { data: languages, loading, refresh } = useListData<LanguageInfo>(listLanguages);
const [loading, setLoading] = useState(false); const { execute } = useApiRequest();
const [editModalOpen, setEditModalOpen] = useState(false);
// LanguageInfo 没有 version/id 字段,手动管理 drawer 状态
const [drawerOpen, setDrawerOpen] = useState(false);
const [editingLang, setEditingLang] = useState<LanguageInfo | null>(null); const [editingLang, setEditingLang] = useState<LanguageInfo | null>(null);
const [form] = Form.useForm<{ name: string }>();
const fetchLanguages = useCallback(async () => {
setLoading(true);
try {
const result = await listLanguages();
setLanguages(result);
} catch {
message.error('加载语言列表失败');
}
setLoading(false);
}, []);
useEffect(() => {
fetchLanguages();
}, [fetchLanguages]);
const handleToggle = async (record: LanguageInfo, checked: boolean) => { const handleToggle = async (record: LanguageInfo, checked: boolean) => {
try { await execute(() => updateLanguage(record.code, { is_active: checked, name: record.name }), checked ? '已启用' : '已禁用');
await updateLanguage(record.code, { is_active: checked }); refresh();
setLanguages((prev) =>
prev.map((lang) =>
lang.code === record.code ? { ...lang, is_active: checked } : lang,
),
);
message.success(checked ? '已启用' : '已禁用');
} catch {
message.error('操作失败');
}
}; };
const openEdit = (lang: LanguageInfo) => { const openEdit = (lang: LanguageInfo) => {
setEditingLang(lang); setEditingLang(lang);
form.setFieldsValue({ name: lang.name }); setDrawerOpen(true);
setEditModalOpen(true);
}; };
const closeEdit = () => { const handleEditSubmit = async (values: Record<string, unknown>) => {
setEditModalOpen(false);
setEditingLang(null);
form.resetFields();
};
const handleEditSubmit = async () => {
if (!editingLang) return; if (!editingLang) return;
try { await execute(
const values = await form.validateFields(); () => updateLanguage(editingLang.code, { is_active: editingLang.is_active, name: values.name as string }),
const updated = await updateLanguage(editingLang.code, { '语言更新成功',
is_active: editingLang.is_active, );
name: values.name, setDrawerOpen(false);
}); setEditingLang(null);
setLanguages((prev) => refresh();
prev.map((lang) =>
lang.code === editingLang.code ? updated : lang,
),
);
message.success('语言更新成功');
closeEdit();
} catch (err: unknown) {
if (err && typeof err === 'object' && 'errorFields' in err) return;
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '更新失败';
message.error(errorMsg);
}
}; };
const columns = [ const columns = [
{ title: '语言代码', dataIndex: 'code', key: 'code', width: 160 },
{ title: '语言名称', dataIndex: 'name', key: 'name', width: 200 },
{ {
title: '语言代码', title: '状态', dataIndex: 'is_active', key: 'is_active', width: 120,
dataIndex: 'code',
key: 'code',
width: 160,
},
{
title: '语言名称',
dataIndex: 'name',
key: 'name',
width: 200,
},
{
title: '状态',
dataIndex: 'is_active',
key: 'is_active',
width: 120,
render: (is_active: boolean, record: LanguageInfo) => ( render: (is_active: boolean, record: LanguageInfo) => (
<Switch checked={is_active} onChange={(checked) => handleToggle(record, checked)} /> <Switch checked={is_active} onChange={(checked) => handleToggle(record, checked)} />
), ),
}, },
{ {
title: '操作', title: '操作', key: 'actions',
key: 'actions',
render: (_: unknown, record: LanguageInfo) => ( render: (_: unknown, record: LanguageInfo) => (
<Space> <Space>
<Button <Button size="small" icon={<EditOutlined />} onClick={() => openEdit(record)}></Button>
size="small"
icon={<EditOutlined />}
onClick={() => openEdit(record)}
>
</Button>
</Space> </Space>
), ),
}, },
@@ -131,40 +68,33 @@ export default function LanguageManager() {
return ( return (
<div> <div>
<Typography.Title level={5} style={{ marginBottom: 16 }}> <Typography.Title level={5} style={{ marginBottom: 16 }}></Typography.Title>
</Typography.Title>
<Card> <Table
<Table columns={columns}
columns={columns} dataSource={languages}
dataSource={languages} rowKey="code"
rowKey="code" loading={loading}
loading={loading} pagination={false}
pagination={false} />
/>
</Card>
<Modal <DrawerForm
title={`编辑语言 - ${editingLang?.code ?? ''}`} title={`编辑语言 - ${editingLang?.code ?? ''}`}
open={editModalOpen} open={drawerOpen}
onCancel={closeEdit} onClose={() => { setDrawerOpen(false); setEditingLang(null); }}
onOk={handleEditSubmit} onSubmit={handleEditSubmit}
okText="保存" initialValues={editingLang ? { name: editingLang.name } : undefined}
loading={false}
width={480}
columns={1}
> >
<Form form={form} layout="vertical" style={{ marginTop: 16 }}> <Form.Item label="语言代码">
<Form.Item label="语言代码"> <Input value={editingLang?.code} disabled />
<Input value={editingLang?.code} disabled /> </Form.Item>
</Form.Item> <Form.Item label="语言名称" name="name" rules={[{ required: true, message: '请输入语言名称' }]}>
<Form.Item <Input placeholder="例如:简体中文" maxLength={100} />
label="语言名称" </Form.Item>
name="name" </DrawerForm>
rules={[{ required: true, message: '请输入语言名称' }]}
>
<Input placeholder="例如:简体中文" maxLength={100} />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,9 +1,7 @@
import { useEffect, useState, useCallback } from 'react';
import { import {
Table, Table,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
InputNumber, InputNumber,
@@ -11,7 +9,6 @@ import {
Switch, Switch,
TreeSelect, TreeSelect,
Popconfirm, Popconfirm,
message,
Typography, Typography,
Tag, Tag,
} from 'antd'; } from 'antd';
@@ -24,190 +21,80 @@ import {
type MenuInfo, type MenuInfo,
type MenuItemReq, type MenuItemReq,
} from '../../api/menus'; } from '../../api/menus';
import { useListData } from '../../hooks/useListData';
// --- Types --- import { useCrudDrawer } from '../../hooks/useCrudDrawer';
import { DrawerForm } from '../../components/DrawerForm';
import { useApiRequest } from '../../hooks/useApiRequest';
type MenuItem = MenuInfo; type MenuItem = MenuInfo;
// --- Helpers --- function toTreeSelectData(items: MenuItem[]): Array<{ title: string; value: string; children?: Array<{ title: string; value: string }> }> {
/** Convert nested menu tree back to flat list */
function flattenMenuTree(tree: MenuItem[]): MenuItem[] {
const result: MenuItem[] = [];
const walk = (items: MenuItem[]) => {
for (const item of items) {
const { children, ...rest } = item;
result.push(rest as MenuItem);
if (children?.length) walk(children);
}
};
walk(tree);
return result;
}
/** Convert menu tree to TreeSelect data nodes */
function toTreeSelectData(
items: MenuItem[],
): Array<{ title: string; value: string; children?: Array<{ title: string; value: string }> }> {
return items.map((item) => ({ return items.map((item) => ({
title: item.title, title: item.title,
value: item.id, value: item.id,
children: children: item.children?.length ? toTreeSelectData(item.children) : undefined,
item.children && item.children.length > 0
? toTreeSelectData(item.children)
: undefined,
})); }));
} }
const menuTypeLabels: Record<string, { text: string; color: string }> = { const MENU_TYPE_LABELS: Record<string, { text: string; color: string }> = {
directory: { text: '目录', color: 'blue' }, directory: { text: '目录', color: 'blue' },
menu: { text: '菜单', color: 'green' }, menu: { text: '菜单', color: 'green' },
button: { text: '按钮', color: 'orange' }, button: { text: '按钮', color: 'orange' },
}; };
// --- Component ---
export default function MenuConfig() { export default function MenuConfig() {
const [_menus, setMenus] = useState<MenuItem[]>([]); const { data: menuTree, loading, refresh } = useListData<MenuItem>(getMenus);
const [menuTree, setMenuTree] = useState<MenuItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editMenu, setEditMenu] = useState<MenuItem | null>(null);
const [form] = Form.useForm();
const fetchMenus = useCallback(async () => { const { execute } = useApiRequest();
setLoading(true);
try {
const tree = await getMenus();
setMenus(flattenMenuTree(tree));
setMenuTree(tree);
} catch {
message.error('加载菜单失败');
}
setLoading(false);
}, []);
useEffect(() => { const drawer = useCrudDrawer<MenuItem>({
fetchMenus(); getId: (r) => r.id,
}, [fetchMenus]); onCreate: async (values) => {
await createMenu(values as unknown as MenuItemReq);
const handleSubmit = async (values: MenuItemReq) => { },
try { onUpdate: async (id, values) => {
if (editMenu) { await updateMenu(id, values as unknown as MenuItemReq & { version: number });
await updateMenu(editMenu.id, { ...values, version: editMenu.version }); },
message.success('菜单更新成功'); onSuccess: refresh,
} else { });
await createMenu(values);
message.success('菜单创建成功');
}
closeModal();
fetchMenus();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string, version: number) => { const handleDelete = async (id: string, version: number) => {
try { await execute(() => deleteMenu(id, version), '菜单已删除');
await deleteMenu(id, version); refresh();
message.success('菜单已删除');
fetchMenus();
} catch {
message.error('删除失败');
}
};
const openCreate = () => {
setEditMenu(null);
form.resetFields();
form.setFieldsValue({
menu_type: 'menu',
sort_order: 0,
visible: true,
});
setModalOpen(true);
};
const openEdit = (menu: MenuItem) => {
setEditMenu(menu);
form.setFieldsValue({
parent_id: menu.parent_id || undefined,
title: menu.title,
path: menu.path,
icon: menu.icon,
menu_type: menu.menu_type,
sort_order: menu.sort_order,
visible: menu.visible,
permission: menu.permission,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditMenu(null);
form.resetFields();
}; };
const columns = [ const columns = [
{ title: '标题', dataIndex: 'title', key: 'title', width: 200 }, { title: '标题', dataIndex: 'title', key: 'title', width: 200 },
{ title: '路径', dataIndex: 'path', key: 'path', ellipsis: true, render: (v?: string) => v || '-' },
{ title: '图标', dataIndex: 'icon', key: 'icon', width: 100, render: (v?: string) => v || '-' },
{ {
title: '路径', title: '类型', dataIndex: 'menu_type', key: 'menu_type', width: 90,
dataIndex: 'path',
key: 'path',
ellipsis: true,
render: (v?: string) => v || '-',
},
{
title: '图标',
dataIndex: 'icon',
key: 'icon',
width: 100,
render: (v?: string) => v || '-',
},
{
title: '类型',
dataIndex: 'menu_type',
key: 'menu_type',
width: 90,
render: (v: string) => { render: (v: string) => {
const info = menuTypeLabels[v] ?? { text: v, color: 'default' }; const info = MENU_TYPE_LABELS[v] ?? { text: v, color: 'default' };
return <Tag color={info.color}>{info.text}</Tag>; return <Tag color={info.color}>{info.text}</Tag>;
}, },
}, },
{ title: '排序', dataIndex: 'sort_order', key: 'sort_order', width: 80 },
{ {
title: '排序', title: '可见', dataIndex: 'visible', key: 'visible', width: 80,
dataIndex: 'sort_order', render: (v: boolean) => v ? <Tag color="green"></Tag> : <Tag color="default"></Tag>,
key: 'sort_order',
width: 80,
}, },
{ {
title: '可见', title: '操作', key: 'actions', width: 150,
dataIndex: 'visible',
key: 'visible',
width: 80,
render: (v: boolean) =>
v ? <Tag color="green"></Tag> : <Tag color="default"></Tag>,
},
{
title: '操作',
key: 'actions',
width: 150,
render: (_: unknown, record: MenuItem) => ( render: (_: unknown, record: MenuItem) => (
<Space> <Space>
<Button size="small" onClick={() => openEdit(record)}> <Button size="small" onClick={() => drawer.openEdit(record, (r) => ({
parent_id: r.parent_id || undefined,
</Button> title: r.title,
<Popconfirm path: r.path,
title="确定删除此菜单?" icon: r.icon,
onConfirm={() => handleDelete(record.id, record.version)} menu_type: r.menu_type,
> sort_order: r.sort_order,
<Button size="small" danger> visible: r.visible,
permission: r.permission,
</Button> }))}></Button>
<Popconfirm title="确定删除此菜单?" onConfirm={() => handleDelete(record.id, record.version)}>
<Button size="small" danger></Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -216,17 +103,13 @@ export default function MenuConfig() {
return ( return (
<div> <div>
<div <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
style={{ <Typography.Title level={5} style={{ margin: 0 }}></Typography.Title>
display: 'flex', <Button
justifyContent: 'space-between', type="primary"
marginBottom: 16, icon={<PlusOutlined />}
}} onClick={() => drawer.openCreate({ menu_type: 'menu', sort_order: 0, visible: true })}
> >
<Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
</Button> </Button>
</div> </div>
@@ -240,59 +123,50 @@ export default function MenuConfig() {
indentSize={20} indentSize={20}
/> />
<Modal <DrawerForm
title={editMenu ? '编辑菜单' : '添加菜单'} title={drawer.editingRecord ? '编辑菜单' : '添加菜单'}
open={modalOpen} open={drawer.open}
onCancel={closeModal} onClose={drawer.close}
onOk={() => form.submit()} onSubmit={drawer.handleSubmit}
width={560} initialValues={drawer.initialValues}
loading={drawer.loading}
width={520}
columns={1}
> >
<Form form={form} onFinish={handleSubmit} layout="vertical"> <Form.Item name="parent_id" label="上级菜单">
<Form.Item name="parent_id" label="上级菜单"> <TreeSelect
<TreeSelect treeData={toTreeSelectData(menuTree)}
treeData={toTreeSelectData(menuTree)} placeholder="无(顶级菜单)"
placeholder="无(顶级菜单)" allowClear
allowClear treeDefaultExpandAll
treeDefaultExpandAll />
/> </Form.Item>
</Form.Item> <Form.Item name="title" label="标题" rules={[{ required: true, message: '请输入菜单标题' }]}>
<Form.Item <Input />
name="title" </Form.Item>
label="标题" <Form.Item name="path" label="路径">
rules={[{ required: true, message: '请输入菜单标题' }]} <Input placeholder="/example/path" />
> </Form.Item>
<Input /> <Form.Item name="icon" label="图标">
</Form.Item> <Input placeholder="图标名称,如 HomeOutlined" />
<Form.Item name="path" label="路径"> </Form.Item>
<Input placeholder="/example/path" /> <Form.Item name="menu_type" label="类型" rules={[{ required: true, message: '请选择菜单类型' }]}>
</Form.Item> <Select options={[
<Form.Item name="icon" label="图标"> { label: '目录', value: 'directory' },
<Input placeholder="图标名称,如 HomeOutlined" /> { label: '菜单', value: 'menu' },
</Form.Item> { label: '按钮', value: 'button' },
<Form.Item ]} />
name="menu_type" </Form.Item>
label="类型" <Form.Item name="sort_order" label="排序" initialValue={0}>
rules={[{ required: true, message: '请选择菜单类型' }]} <InputNumber min={0} style={{ width: '100%' }} />
> </Form.Item>
<Select <Form.Item name="visible" label="可见" valuePropName="checked" initialValue>
options={[ <Switch />
{ label: '目录', value: 'directory' }, </Form.Item>
{ label: '菜单', value: 'menu' }, <Form.Item name="permission" label="权限标识">
{ label: '按钮', value: 'button' }, <Input placeholder="如 system:user:list" />
]} </Form.Item>
/> </DrawerForm>
</Form.Item>
<Form.Item name="sort_order" label="排序" initialValue={0}>
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="visible" label="可见" valuePropName="checked" initialValue>
<Switch />
</Form.Item>
<Form.Item name="permission" label="权限标识">
<Input placeholder="如 system:user:list" />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,15 +1,12 @@
import { useEffect, useState, useCallback } from 'react';
import { import {
Table, Table,
Button, Button,
Space, Space,
Modal,
Form, Form,
Input, Input,
InputNumber, InputNumber,
Select, Select,
Popconfirm, Popconfirm,
message,
Typography, Typography,
} from 'antd'; } from 'antd';
import { PlusOutlined, NumberOutlined } from '@ant-design/icons'; import { PlusOutlined, NumberOutlined } from '@ant-design/icons';
@@ -23,123 +20,57 @@ import {
type CreateNumberingRuleRequest, type CreateNumberingRuleRequest,
type UpdateNumberingRuleRequest, type UpdateNumberingRuleRequest,
} from '../../api/numberingRules'; } from '../../api/numberingRules';
import { useListData } from '../../hooks/useListData';
// --- Types --- import { useCrudDrawer } from '../../hooks/useCrudDrawer';
import { DrawerForm } from '../../components/DrawerForm';
import { useApiRequest } from '../../hooks/useApiRequest';
import { message } from 'antd';
type NumberingRule = NumberingRuleInfo; type NumberingRule = NumberingRuleInfo;
// --- Constants --- const RESET_CYCLE_OPTIONS = [
const resetCycleOptions = [
{ label: '不重置', value: 'never' }, { label: '不重置', value: 'never' },
{ label: '每天', value: 'daily' }, { label: '每天', value: 'daily' },
{ label: '每月', value: 'monthly' }, { label: '每月', value: 'monthly' },
{ label: '每年', value: 'yearly' }, { label: '每年', value: 'yearly' },
]; ];
const resetCycleLabels: Record<string, string> = { const RESET_CYCLE_LABELS: Record<string, string> = {
never: '不重置', never: '不重置',
daily: '每天', daily: '每天',
monthly: '每月', monthly: '每月',
yearly: '每年', yearly: '每年',
}; };
// --- Component ---
export default function NumberingRules() { export default function NumberingRules() {
const [rules, setRules] = useState<NumberingRule[]>([]); const { data: rules, loading, refresh } = useListData<NumberingRule>(async () => {
const [loading, setLoading] = useState(false); const result = await listNumberingRules();
const [modalOpen, setModalOpen] = useState(false); return Array.isArray(result) ? result : result.data ?? [];
const [editRule, setEditRule] = useState<NumberingRule | null>(null); });
const [form] = Form.useForm();
const fetchRules = useCallback(async () => { const { execute } = useApiRequest();
setLoading(true);
try {
const result = await listNumberingRules();
setRules(Array.isArray(result) ? result : result.data ?? []);
} catch {
message.error('加载编号规则失败');
}
setLoading(false);
}, []);
useEffect(() => { const drawer = useCrudDrawer<NumberingRule>({
fetchRules(); getId: (r) => r.id,
}, [fetchRules]); onCreate: async (values) => {
await createNumberingRule(values as unknown as CreateNumberingRuleRequest);
const handleSubmit = async (values: CreateNumberingRuleRequest) => { },
try { onUpdate: async (id, values) => {
if (editRule) { await updateNumberingRule(id, values as unknown as UpdateNumberingRuleRequest);
await updateNumberingRule(editRule.id, { ...values, version: editRule.version } as UpdateNumberingRuleRequest); },
message.success('编号规则更新成功'); onSuccess: refresh,
} else { });
await createNumberingRule(values);
message.success('编号规则创建成功');
}
closeModal();
fetchRules();
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '操作失败';
message.error(errorMsg);
}
};
const handleDelete = async (id: string, version: number) => { const handleDelete = async (id: string, version: number) => {
try { await execute(() => deleteNumberingRule(id, version), '删除成功');
await deleteNumberingRule(id, version); refresh();
message.success('编号规则已删除');
fetchRules();
} catch {
message.error('删除失败');
}
}; };
const handleGenerate = async (rule: NumberingRule) => { const handleGenerate = async (rule: NumberingRule) => {
try { await execute(async () => {
const result = await generateNumber(rule.id); const result = await generateNumber(rule.id);
message.success(`生成编号: ${result.number}`); message.success(`生成编号: ${result.number}`);
} catch (err: unknown) { }, undefined, '生成编号失败');
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '生成编号失败';
message.error(errorMsg);
}
};
const openCreate = () => {
setEditRule(null);
form.resetFields();
form.setFieldsValue({
seq_length: 4,
seq_start: 1,
separator: '-',
reset_cycle: 'never',
});
setModalOpen(true);
};
const openEdit = (rule: NumberingRule) => {
setEditRule(rule);
form.setFieldsValue({
name: rule.name,
code: rule.code,
prefix: rule.prefix,
date_format: rule.date_format,
seq_length: rule.seq_length,
seq_start: rule.seq_start,
separator: rule.separator,
reset_cycle: rule.reset_cycle,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditRule(null);
form.resetFields();
}; };
const columns = [ const columns = [
@@ -174,7 +105,7 @@ export default function NumberingRules() {
dataIndex: 'reset_cycle', dataIndex: 'reset_cycle',
key: 'reset_cycle', key: 'reset_cycle',
width: 100, width: 100,
render: (v: string) => resetCycleLabels[v] ?? v, render: (v: string) => RESET_CYCLE_LABELS[v] ?? v,
}, },
{ {
title: '操作', title: '操作',
@@ -188,16 +119,23 @@ export default function NumberingRules() {
> >
</Button> </Button>
<Button size="small" onClick={() => openEdit(record)}> <Button size="small" onClick={() => drawer.openEdit(record, (r) => ({
name: r.name,
code: r.code,
prefix: r.prefix,
date_format: r.date_format,
seq_length: r.seq_length,
seq_start: r.seq_start,
separator: r.separator,
reset_cycle: r.reset_cycle,
}))}>
</Button> </Button>
<Popconfirm <Popconfirm
title="确定删除此编号规则?" title="确定删除此编号规则?"
onConfirm={() => handleDelete(record.id, record.version)} onConfirm={() => handleDelete(record.id, record.version)}
> >
<Button size="small" danger> <Button size="small" danger></Button>
</Button>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -206,17 +144,17 @@ export default function NumberingRules() {
return ( return (
<div> <div>
<div <div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 16 }}>
style={{
display: 'flex',
justifyContent: 'space-between',
marginBottom: 16,
}}
>
<Typography.Title level={5} style={{ margin: 0 }}> <Typography.Title level={5} style={{ margin: 0 }}>
</Typography.Title> </Typography.Title>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button
type="primary"
icon={<PlusOutlined />}
onClick={() => drawer.openCreate({
seq_length: 4, seq_start: 1, separator: '-', reset_cycle: 'never',
})}
>
</Button> </Button>
</div> </div>
@@ -229,56 +167,41 @@ export default function NumberingRules() {
pagination={{ pageSize: 20 }} pagination={{ pageSize: 20 }}
/> />
<Modal <DrawerForm
title={editRule ? '编辑编号规则' : '新建编号规则'} title={drawer.editingRecord ? '编辑编号规则' : '新建编号规则'}
open={modalOpen} open={drawer.open}
onCancel={closeModal} onClose={drawer.close}
onOk={() => form.submit()} onSubmit={drawer.handleSubmit}
width={560} initialValues={drawer.initialValues}
loading={drawer.loading}
width={520}
columns={1}
> >
<Form form={form} onFinish={handleSubmit} layout="vertical"> <Form.Item name="name" label="名称" rules={[{ required: true, message: '请输入规则名称' }]}>
<Form.Item <Input />
name="name" </Form.Item>
label="名称" <Form.Item name="code" label="编码" rules={[{ required: true, message: '请输入规则编码' }]}>
rules={[{ required: true, message: '请输入规则名称' }]} <Input disabled={!!drawer.editingRecord} />
> </Form.Item>
<Input /> <Form.Item name="prefix" label="前缀">
</Form.Item> <Input placeholder="如 PO、SO" />
<Form.Item </Form.Item>
name="code" <Form.Item name="date_format" label="日期格式">
label="编码" <Input placeholder="如 YYYYMMDD" />
rules={[{ required: true, message: '请输入规则编码' }]} </Form.Item>
> <Form.Item name="separator" label="分隔符">
<Input disabled={!!editRule} /> <Input placeholder="默认 -" />
</Form.Item> </Form.Item>
<Form.Item name="prefix" label="前缀"> <Form.Item name="seq_length" label="序列长度" rules={[{ required: true, message: '请输入序列长度' }]}>
<Input placeholder="如 PO、SO" /> <InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="date_format" label="日期格式"> <Form.Item name="seq_start" label="起始值" initialValue={1}>
<Input placeholder="如 YYYYMMDD" /> <InputNumber min={1} style={{ width: '100%' }} />
</Form.Item> </Form.Item>
<Form.Item name="separator" label="分隔符"> <Form.Item name="reset_cycle" label="重置周期" rules={[{ required: true, message: '请选择重置周期' }]}>
<Input placeholder="默认 -" /> <Select options={RESET_CYCLE_OPTIONS} />
</Form.Item> </Form.Item>
<Form.Item </DrawerForm>
name="seq_length"
label="序列长度"
rules={[{ required: true, message: '请输入序列长度' }]}
>
<InputNumber min={1} max={20} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="seq_start" label="起始值" initialValue={1}>
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="reset_cycle"
label="重置周期"
rules={[{ required: true, message: '请选择重置周期' }]}
>
<Select options={resetCycleOptions} />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }

View File

@@ -5,10 +5,9 @@ import {
Input, Input,
Space, Space,
Popconfirm, Popconfirm,
message,
Table, Table,
Modal,
Tag, Tag,
message,
} from 'antd'; } from 'antd';
import { PlusOutlined, SearchOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons'; import { PlusOutlined, SearchOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import { import {
@@ -16,7 +15,8 @@ import {
updateSetting, updateSetting,
deleteSetting, deleteSetting,
} from '../../api/settings'; } from '../../api/settings';
import { handleApiError } from '../../api/client'; import { DrawerForm } from '../../components/DrawerForm';
import { useApiRequest } from '../../hooks/useApiRequest';
import { useThemeMode } from '../../hooks/useThemeMode'; import { useThemeMode } from '../../hooks/useThemeMode';
interface SettingEntry { interface SettingEntry {
@@ -28,23 +28,22 @@ interface SettingEntry {
export default function SystemSettings() { export default function SystemSettings() {
const [entries, setEntries] = useState<SettingEntry[]>([]); const [entries, setEntries] = useState<SettingEntry[]>([]);
const [searchKey, setSearchKey] = useState(''); const [searchKey, setSearchKey] = useState('');
const [modalOpen, setModalOpen] = useState(false); const [drawerOpen, setDrawerOpen] = useState(false);
const [editEntry, setEditEntry] = useState<SettingEntry | null>(null); const [editEntry, setEditEntry] = useState<SettingEntry | null>(null);
const [form] = Form.useForm();
const isDark = useThemeMode(); const isDark = useThemeMode();
const { execute } = useApiRequest();
const handleSearch = async () => { const handleSearch = async () => {
if (!searchKey.trim()) { if (!searchKey.trim()) {
message.warning('请输入设置键名'); message.warning('请输入设置键名');
return; return;
} }
try { await execute(async () => {
const result = await getSetting(searchKey.trim()); const result = await getSetting(searchKey.trim());
const value = typeof result.setting_value === 'object' && result.setting_value !== null const value = typeof result.setting_value === 'object' && result.setting_value !== null
? JSON.stringify(result.setting_value, null, 2) ? JSON.stringify(result.setting_value, null, 2)
: String(result.setting_value ?? ''); : String(result.setting_value ?? '');
const version = result.version; const version = result.version;
setEntries((prev) => { setEntries((prev) => {
const exists = prev.findIndex((e) => e.key === searchKey.trim()); const exists = prev.findIndex((e) => e.key === searchKey.trim());
if (exists >= 0) { if (exists >= 0) {
@@ -54,34 +53,25 @@ export default function SystemSettings() {
} }
return [...prev, { key: searchKey.trim(), value, version }]; return [...prev, { key: searchKey.trim(), value, version }];
}); });
message.success('查询成功'); }, '查询成功', '查询失败');
} catch (err: unknown) {
const status = (err as { response?: { status?: number } })?.response?.status;
if (status === 404) {
message.info('该设置键不存在,可点击"添加设置"创建');
} else {
message.error('查询失败');
}
}
}; };
const handleSave = async (values: { setting_key: string; setting_value: string }) => { const handleSave = async (values: Record<string, unknown>) => {
const key = values.setting_key.trim(); const key = (values.setting_key as string).trim();
const value = values.setting_value; const value = values.setting_value as string;
try { try {
try { JSON.parse(value);
JSON.parse(value); } catch {
} catch { message.error('设置值必须是有效的 JSON 格式');
message.error('设置值必须是有效的 JSON 格式'); return;
return; }
}
const editVersion = editEntry?.version; const editVersion = editEntry?.version;
const jsonValue = JSON.parse(value); const jsonValue = JSON.parse(value);
await execute(async () => {
const result = await updateSetting(key, jsonValue, editVersion); const result = await updateSetting(key, jsonValue, editVersion);
const displayValue = typeof jsonValue === 'object' ? JSON.stringify(jsonValue, null, 2) : value;
setEntries((prev) => { setEntries((prev) => {
const displayValue = typeof jsonValue === 'object' ? JSON.stringify(jsonValue, null, 2) : value;
const exists = prev.findIndex((e) => e.key === key); const exists = prev.findIndex((e) => e.key === key);
if (exists >= 0) { if (exists >= 0) {
const updated = [...prev]; const updated = [...prev];
@@ -90,95 +80,48 @@ export default function SystemSettings() {
} }
return [...prev, { key, value: displayValue, version: result.version }]; return [...prev, { key, value: displayValue, version: result.version }];
}); });
}, '设置已保存');
message.success('设置已保存'); setDrawerOpen(false);
closeModal(); setEditEntry(null);
} catch (err: unknown) {
handleApiError(err, '保存失败');
}
}; };
const handleDelete = async (key: string, version: number) => { const handleDelete = async (key: string, version: number) => {
try { await execute(() => deleteSetting(key, version), '设置已删除');
await deleteSetting(key, version); setEntries((prev) => prev.filter((e) => e.key !== key));
setEntries((prev) => prev.filter((e) => e.key !== key));
message.success('设置已删除');
} catch {
message.error('删除失败');
}
}; };
const openCreate = () => { const openCreate = () => {
setEditEntry(null); setEditEntry(null);
form.resetFields(); setDrawerOpen(true);
setModalOpen(true);
}; };
const openEdit = (entry: SettingEntry) => { const openEdit = (entry: SettingEntry) => {
setEditEntry(entry); setEditEntry(entry);
form.setFieldsValue({ setDrawerOpen(true);
setting_key: entry.key,
setting_value: entry.value,
});
setModalOpen(true);
};
const closeModal = () => {
setModalOpen(false);
setEditEntry(null);
form.resetFields();
}; };
const columns = [ const columns = [
{ {
title: '键', title: '键', dataIndex: 'key', key: 'key', width: 250,
dataIndex: 'key',
key: 'key',
width: 250,
render: (v: string) => ( render: (v: string) => (
<Tag style={{ <Tag style={{
background: isDark ? '#0f172a' : '#f8fafc', background: isDark ? '#0f172a' : '#f8fafc', border: 'none',
border: 'none', color: isDark ? '#CBD5E1' : '#475569', fontFamily: 'monospace', fontSize: 12,
color: isDark ? '#CBD5E1' : '#475569', }}>{v}</Tag>
fontFamily: 'monospace',
fontSize: 12,
}}>
{v}
</Tag>
), ),
}, },
{ {
title: '值 (JSON)', title: '值 (JSON)', dataIndex: 'value', key: 'value', ellipsis: true,
dataIndex: 'value', render: (v: string) => <span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>,
key: 'value',
ellipsis: true,
render: (v: string) => (
<span style={{ fontFamily: 'monospace', fontSize: 12 }}>{v}</span>
),
}, },
{ {
title: '操作', title: '操作', key: 'actions', width: 120,
key: 'actions',
width: 120,
render: (_: unknown, record: SettingEntry) => ( render: (_: unknown, record: SettingEntry) => (
<Space size={4}> <Space size={4}>
<Button <Button size="small" type="text" icon={<EditOutlined />} onClick={() => openEdit(record)} style={{ color: isDark ? '#94a3b8' : '#475569' }} />
size="small" <Popconfirm title="确定删除此设置?" onConfirm={() => handleDelete(record.key, record.version)}>
type="text" <Button size="small" type="text" danger icon={<DeleteOutlined />} />
icon={<EditOutlined />}
onClick={() => openEdit(record)}
style={{ color: isDark ? '#94a3b8' : '#475569' }}
/>
<Popconfirm
title="确定删除此设置?"
onConfirm={() => handleDelete(record.key, record.version)}
>
<Button
size="small"
type="text"
danger
icon={<DeleteOutlined />}
/>
</Popconfirm> </Popconfirm>
</Space> </Space>
), ),
@@ -187,12 +130,7 @@ export default function SystemSettings() {
return ( return (
<div> <div>
<div style={{ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}>
<Space> <Space>
<Input <Input
placeholder="输入设置键名查询" placeholder="输入设置键名查询"
@@ -204,9 +142,7 @@ export default function SystemSettings() {
/> />
<Button onClick={handleSearch}></Button> <Button onClick={handleSearch}></Button>
</Space> </Space>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}></Button>
</Button>
</div> </div>
<div style={{ <div style={{
@@ -225,30 +161,23 @@ export default function SystemSettings() {
/> />
</div> </div>
<Modal <DrawerForm
title={editEntry ? '编辑设置' : '添加设置'} title={editEntry ? '编辑设置' : '添加设置'}
open={modalOpen} open={drawerOpen}
onCancel={closeModal} onClose={() => { setDrawerOpen(false); setEditEntry(null); }}
onOk={() => form.submit()} onSubmit={handleSave}
initialValues={editEntry ? { setting_key: editEntry.key, setting_value: editEntry.value } : undefined}
loading={false}
width={560} width={560}
columns={1}
> >
<Form form={form} onFinish={handleSave} layout="vertical" style={{ marginTop: 16 }}> <Form.Item name="setting_key" label="键名" rules={[{ required: true, message: '请输入设置键名' }]}>
<Form.Item <Input disabled={!!editEntry} />
name="setting_key" </Form.Item>
label="键名" <Form.Item name="setting_value" label="值 (JSON)" rules={[{ required: true, message: '请输入设置值' }]}>
rules={[{ required: true, message: '请输入设置键名' }]} <Input.TextArea rows={6} placeholder='{"key": "value"}' style={{ fontFamily: 'monospace' }} />
> </Form.Item>
<Input disabled={!!editEntry} /> </DrawerForm>
</Form.Item>
<Form.Item
name="setting_value"
label="值 (JSON)"
rules={[{ required: true, message: '请输入设置值' }]}
>
<Input.TextArea rows={6} placeholder='{"key": "value"}' style={{ fontFamily: 'monospace' }} />
</Form.Item>
</Form>
</Modal>
</div> </div>
); );
} }

View File

@@ -1,17 +1,13 @@
import { useEffect, useState, useCallback } from 'react'; import { useEffect, useCallback } from 'react';
import { Form, Input, Select, Button, ColorPicker, message, Typography, Divider } from 'antd'; import { Form, Input, Select, Button, ColorPicker, Typography, Divider } from 'antd';
import { import { getTheme, updateTheme } from '../../api/themes';
getTheme, import { useApiRequest } from '../../hooks/useApiRequest';
updateTheme,
} from '../../api/themes';
export default function ThemeSettings() { export default function ThemeSettings() {
const [form] = Form.useForm(); const [form] = Form.useForm();
const [, setLoading] = useState(false); const { execute, loading } = useApiRequest();
const [saving, setSaving] = useState(false);
const fetchTheme = useCallback(async () => { const fetchTheme = useCallback(async () => {
setLoading(true);
try { try {
const theme = await getTheme(); const theme = await getTheme();
form.setFieldsValue({ form.setFieldsValue({
@@ -25,67 +21,35 @@ export default function ThemeSettings() {
}); });
} catch { } catch {
form.setFieldsValue({ form.setFieldsValue({
primary_color: '#1677ff', primary_color: '#1677ff', logo_url: '', sidebar_style: 'light',
logo_url: '', brand_name: '', brand_slogan: '', brand_features: '', brand_copyright: '',
sidebar_style: 'light',
brand_name: '',
brand_slogan: '',
brand_features: '',
brand_copyright: '',
}); });
} }
setLoading(false);
}, [form]); }, [form]);
useEffect(() => { useEffect(() => { fetchTheme(); }, [fetchTheme]);
fetchTheme();
}, [fetchTheme]);
const handleSave = async (values: { const handleSave = async (values: Record<string, unknown>) => {
primary_color: string; await execute(async () => {
logo_url: string;
sidebar_style: 'light' | 'dark';
brand_name: string;
brand_slogan: string;
brand_features: string;
brand_copyright: string;
}) => {
setSaving(true);
try {
await updateTheme({ await updateTheme({
primary_color: primary_color: typeof values.primary_color === 'string'
typeof values.primary_color === 'string' ? values.primary_color
? values.primary_color : (values.primary_color as { toHexString?: () => string }).toHexString?.() ?? String(values.primary_color),
: (values.primary_color as { toHexString?: () => string }).toHexString?.() ?? String(values.primary_color), logo_url: values.logo_url as string,
logo_url: values.logo_url, sidebar_style: values.sidebar_style as 'light' | 'dark',
sidebar_style: values.sidebar_style, brand_name: (values.brand_name as string) || undefined,
brand_name: values.brand_name || undefined, brand_slogan: (values.brand_slogan as string) || undefined,
brand_slogan: values.brand_slogan || undefined, brand_features: (values.brand_features as string) || undefined,
brand_features: values.brand_features || undefined, brand_copyright: (values.brand_copyright as string) || undefined,
brand_copyright: values.brand_copyright || undefined,
}); });
message.success('主题设置已保存'); }, '主题设置已保存');
} catch (err: unknown) {
const errorMsg =
(err as { response?: { data?: { message?: string } } })?.response?.data
?.message || '保存失败';
message.error(errorMsg);
}
setSaving(false);
}; };
return ( return (
<div> <div>
<Typography.Title level={5} style={{ marginBottom: 16 }}> <Typography.Title level={5} style={{ marginBottom: 16 }}></Typography.Title>
</Typography.Title>
<Form <Form form={form} onFinish={handleSave} layout="vertical" style={{ maxWidth: 480 }}>
form={form}
onFinish={handleSave}
layout="vertical"
style={{ maxWidth: 480 }}
>
<Form.Item name="primary_color" label="主色调"> <Form.Item name="primary_color" label="主色调">
<ColorPicker format="hex" /> <ColorPicker format="hex" />
</Form.Item> </Form.Item>
@@ -93,12 +57,7 @@ export default function ThemeSettings() {
<Input placeholder="https://example.com/logo.png" /> <Input placeholder="https://example.com/logo.png" />
</Form.Item> </Form.Item>
<Form.Item name="sidebar_style" label="侧边栏风格"> <Form.Item name="sidebar_style" label="侧边栏风格">
<Select <Select options={[{ label: '亮色', value: 'light' }, { label: '暗色', value: 'dark' }]} />
options={[
{ label: '亮色', value: 'light' },
{ label: '暗色', value: 'dark' },
]}
/>
</Form.Item> </Form.Item>
<Divider></Divider> <Divider></Divider>
@@ -117,9 +76,7 @@ export default function ThemeSettings() {
</Form.Item> </Form.Item>
<Form.Item> <Form.Item>
<Button type="primary" htmlType="submit" loading={saving}> <Button type="primary" htmlType="submit" loading={loading}></Button>
</Button>
</Form.Item> </Form.Item>
</Form> </Form>
</div> </div>