429 lines
15 KiB
TypeScript
429 lines
15 KiB
TypeScript
import {
|
||
Button,
|
||
Card,
|
||
Col,
|
||
Drawer,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
message,
|
||
Popconfirm,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Table,
|
||
Tag,
|
||
Typography,
|
||
Empty
|
||
} from "antd";
|
||
import { useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import {
|
||
createDictItem,
|
||
createDictType,
|
||
deleteDictItem,
|
||
deleteDictType,
|
||
fetchDictItems,
|
||
fetchDictTypes,
|
||
updateDictItem,
|
||
updateDictType
|
||
} from "../api";
|
||
import { usePermission } from "../hooks/usePermission";
|
||
import { PlusOutlined, EditOutlined, DeleteOutlined, BookOutlined, ProfileOutlined } from "@ant-design/icons";
|
||
import type { SysDictItem, SysDictType } from "../types";
|
||
import "./Dictionaries.css";
|
||
|
||
const { Title, Text } = Typography;
|
||
|
||
export default function Dictionaries() {
|
||
const { t } = useTranslation();
|
||
const { can } = usePermission();
|
||
const [types, setTypes] = useState<SysDictType[]>([]);
|
||
const [items, setItems] = useState<SysDictItem[]>([]);
|
||
const [selectedType, setSelectedType] = useState<SysDictType | null>(null);
|
||
const [loadingTypes, setLoadingTypes] = useState(false);
|
||
const [loadingItems, setLoadingItems] = useState(false);
|
||
|
||
// Type Drawer
|
||
const [typeDrawerVisible, setTypeDrawerVisible] = useState(false);
|
||
const [editingType, setEditingType] = useState<SysDictType | null>(null);
|
||
const [typeForm] = Form.useForm();
|
||
|
||
// Item Drawer
|
||
const [itemDrawerVisible, setItemDrawerVisible] = useState(false);
|
||
const [editingItem, setEditingItem] = useState<SysDictItem | null>(null);
|
||
const [itemForm] = Form.useForm();
|
||
|
||
const loadTypes = async () => {
|
||
setLoadingTypes(true);
|
||
try {
|
||
const data = await fetchDictTypes();
|
||
setTypes(data || []);
|
||
if (data && data.length > 0 && !selectedType) {
|
||
setSelectedType(data[0]);
|
||
}
|
||
} finally {
|
||
setLoadingTypes(false);
|
||
}
|
||
};
|
||
|
||
const loadItems = async (typeCode: string) => {
|
||
setLoadingItems(true);
|
||
try {
|
||
const data = await fetchDictItems(typeCode);
|
||
setItems(data || []);
|
||
} finally {
|
||
setLoadingItems(false);
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
loadTypes();
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
if (selectedType) {
|
||
loadItems(selectedType.typeCode);
|
||
} else {
|
||
setItems([]);
|
||
}
|
||
}, [selectedType]);
|
||
|
||
// Type Actions
|
||
const handleAddType = () => {
|
||
setEditingType(null);
|
||
typeForm.resetFields();
|
||
setTypeDrawerVisible(true);
|
||
};
|
||
|
||
const handleEditType = (record: SysDictType) => {
|
||
setEditingType(record);
|
||
typeForm.setFieldsValue(record);
|
||
setTypeDrawerVisible(true);
|
||
};
|
||
|
||
const handleDeleteType = async (id: number) => {
|
||
await deleteDictType(id);
|
||
message.success(t('common.success'));
|
||
loadTypes();
|
||
};
|
||
|
||
const handleTypeSubmit = async () => {
|
||
const values = await typeForm.validateFields();
|
||
if (editingType) {
|
||
await updateDictType(editingType.dictTypeId, values);
|
||
} else {
|
||
await createDictType(values);
|
||
}
|
||
message.success(t('common.success'));
|
||
setTypeDrawerVisible(false);
|
||
loadTypes();
|
||
};
|
||
|
||
// Item Actions
|
||
const handleAddItem = () => {
|
||
if (!selectedType) {
|
||
message.warning(t('dicts.selectType'));
|
||
return;
|
||
}
|
||
setEditingItem(null);
|
||
itemForm.resetFields();
|
||
itemForm.setFieldsValue({ typeCode: selectedType.typeCode, sortOrder: 0, status: 1 });
|
||
setItemDrawerVisible(true);
|
||
};
|
||
|
||
const handleEditItem = (record: SysDictItem) => {
|
||
setEditingItem(record);
|
||
itemForm.setFieldsValue(record);
|
||
setItemDrawerVisible(true);
|
||
};
|
||
|
||
const handleDeleteItem = async (id: number) => {
|
||
await deleteDictItem(id);
|
||
message.success(t('common.success'));
|
||
if (selectedType) loadItems(selectedType.typeCode);
|
||
};
|
||
|
||
const handleItemSubmit = async () => {
|
||
const values = await itemForm.validateFields();
|
||
if (editingItem) {
|
||
await updateDictItem(editingItem.dictItemId, values);
|
||
} else {
|
||
await createDictItem(values);
|
||
}
|
||
message.success(t('common.success'));
|
||
setItemDrawerVisible(false);
|
||
if (selectedType) loadItems(selectedType.typeCode);
|
||
};
|
||
|
||
return (
|
||
<div className="dictionaries-page p-6">
|
||
<div className="dictionaries-header mb-6">
|
||
<div>
|
||
<Title level={4} className="mb-1">{t('dicts.title')}</Title>
|
||
<Text type="secondary">{t('dicts.subtitle')}</Text>
|
||
</div>
|
||
</div>
|
||
|
||
<Row gutter={24} style={{ height: 'calc(100vh - 180px)' }}>
|
||
<Col span={8} style={{ height: '100%' }}>
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<BookOutlined aria-hidden="true" />
|
||
<span>{t('dicts.dictType')}</span>
|
||
</Space>
|
||
}
|
||
className="full-height-card shadow-sm"
|
||
extra={
|
||
can("sys_dict:type:create") && (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlusOutlined aria-hidden="true" />}
|
||
onClick={handleAddType}
|
||
>
|
||
{t('common.create')}
|
||
</Button>
|
||
)
|
||
}
|
||
>
|
||
<div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
|
||
<Table
|
||
rowKey="dictTypeId"
|
||
loading={loadingTypes}
|
||
dataSource={types}
|
||
pagination={false}
|
||
size="small"
|
||
showHeader={false}
|
||
onRow={(record) => ({
|
||
onClick: () => setSelectedType(record),
|
||
className: `cursor-pointer dict-type-row ${selectedType?.dictTypeId === record.dictTypeId ? "dict-type-row-selected" : ""}`
|
||
})}
|
||
columns={[
|
||
{
|
||
render: (_, record) => (
|
||
<div className="dict-type-item flex justify-between items-center p-2">
|
||
<div className="min-w-0 flex-1">
|
||
<div className="dict-type-name font-medium truncate">{record.typeName}</div>
|
||
<div className="dict-type-code text-xs text-gray-400 truncate tabular-nums">{record.typeCode}</div>
|
||
</div>
|
||
<div className="dict-type-actions flex gap-1">
|
||
{can("sys_dict:type:update") && (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<EditOutlined aria-hidden="true" />}
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleEditType(record);
|
||
}}
|
||
/>
|
||
)}
|
||
{can("sys_dict:type:delete") && (
|
||
<Popconfirm
|
||
title={`确定删除类型 "${record.typeName}" 吗?这会影响关联的字典项。`}
|
||
onConfirm={(e) => {
|
||
e?.stopPropagation();
|
||
handleDeleteType(record.dictTypeId);
|
||
}}
|
||
>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
danger
|
||
icon={<DeleteOutlined aria-hidden="true" />}
|
||
onClick={(e) => e.stopPropagation()}
|
||
/>
|
||
</Popconfirm>
|
||
)}
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</div>
|
||
</Card>
|
||
</Col>
|
||
|
||
<Col span={16} style={{ height: '100%' }}>
|
||
<Card
|
||
title={
|
||
<Space>
|
||
<ProfileOutlined aria-hidden="true" />
|
||
<span>{t('dicts.dictItem')}{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
|
||
</Space>
|
||
}
|
||
className="full-height-card shadow-sm"
|
||
extra={
|
||
can("sys_dict:item:create") && (
|
||
<Button
|
||
type="primary"
|
||
size="small"
|
||
icon={<PlusOutlined aria-hidden="true" />}
|
||
onClick={handleAddItem}
|
||
disabled={!selectedType}
|
||
>
|
||
{t('dicts.drawerTitleItemCreate')}
|
||
</Button>
|
||
)
|
||
}
|
||
>
|
||
{selectedType ? (
|
||
<div style={{ height: 'calc(100% - 10px)', overflowY: 'auto' }}>
|
||
<Table
|
||
rowKey="dictItemId"
|
||
loading={loadingItems}
|
||
dataSource={items}
|
||
pagination={false}
|
||
size="middle"
|
||
columns={[
|
||
{
|
||
title: t('dicts.itemLabel'),
|
||
dataIndex: "itemLabel",
|
||
render: (text) => <Text strong>{text}</Text>
|
||
},
|
||
{
|
||
title: t('dicts.itemValue'),
|
||
dataIndex: "itemValue",
|
||
className: "tabular-nums"
|
||
},
|
||
{
|
||
title: t('dicts.sort'),
|
||
dataIndex: "sortOrder",
|
||
width: 80,
|
||
className: "tabular-nums"
|
||
},
|
||
{
|
||
title: t('common.status'),
|
||
dataIndex: "status",
|
||
width: 100,
|
||
render: (v) => (
|
||
<Tag color={v === 1 ? "green" : "red"}>
|
||
{v === 1 ? "启用" : "禁用"}
|
||
</Tag>
|
||
)
|
||
},
|
||
{
|
||
title: t('common.action'),
|
||
width: 120,
|
||
fixed: "right" as const,
|
||
render: (_, record) => (
|
||
<Space>
|
||
{can("sys_dict:item:update") && (
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
icon={<EditOutlined aria-hidden="true" />}
|
||
onClick={() => handleEditItem(record)}
|
||
aria-label={t('common.edit')}
|
||
/>
|
||
)}
|
||
{can("sys_dict:item:delete") && (
|
||
<Popconfirm title={`确定删除字典项 "${record.itemLabel}" 吗?`} onConfirm={() => handleDeleteItem(record.dictItemId)}>
|
||
<Button
|
||
type="text"
|
||
size="small"
|
||
danger
|
||
icon={<DeleteOutlined aria-hidden="true" />}
|
||
aria-label={t('common.delete')}
|
||
/>
|
||
</Popconfirm>
|
||
)}
|
||
</Space>
|
||
)
|
||
}
|
||
]}
|
||
/>
|
||
</div>
|
||
) : (
|
||
<div className="flex items-center justify-center h-full">
|
||
<Empty description={t('dicts.selectType')} />
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</Col>
|
||
</Row>
|
||
|
||
{/* Type Drawer */}
|
||
<Drawer
|
||
title={
|
||
<Space>
|
||
<BookOutlined aria-hidden="true" />
|
||
<span>{editingType ? t('dicts.drawerTitleTypeEdit') : t('dicts.drawerTitleTypeCreate')}</span>
|
||
</Space>
|
||
}
|
||
open={typeDrawerVisible}
|
||
onClose={() => setTypeDrawerVisible(false)}
|
||
width={400}
|
||
destroyOnClose
|
||
footer={
|
||
<div className="flex justify-end gap-2 p-2">
|
||
<Button onClick={() => setTypeDrawerVisible(false)}>{t('common.cancel')}</Button>
|
||
<Button type="primary" onClick={handleTypeSubmit}>{t('common.confirm')}</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<Form form={typeForm} layout="vertical">
|
||
<Form.Item label={t('dicts.typeCode')} name="typeCode" rules={[{ required: true, message: t('dicts.typeCode') }]}>
|
||
<Input disabled={!!editingType} placeholder="例如:user_status…" />
|
||
</Form.Item>
|
||
<Form.Item label={t('dicts.typeName')} name="typeName" rules={[{ required: true, message: t('dicts.typeName') }]}>
|
||
<Input placeholder="例如:用户状态…" />
|
||
</Form.Item>
|
||
<Form.Item label={t('common.remark')} name="remark">
|
||
<Input.TextArea placeholder="该字典类型的用途描述…" rows={3} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Drawer>
|
||
|
||
{/* Item Drawer */}
|
||
<Drawer
|
||
title={
|
||
<Space>
|
||
<ProfileOutlined aria-hidden="true" />
|
||
<span>{editingItem ? t('dicts.drawerTitleItemEdit') : t('dicts.drawerTitleItemCreate')}</span>
|
||
</Space>
|
||
}
|
||
open={itemDrawerVisible}
|
||
onClose={() => setItemDrawerVisible(false)}
|
||
width={400}
|
||
destroyOnClose
|
||
footer={
|
||
<div className="flex justify-end gap-2 p-2">
|
||
<Button onClick={() => setItemDrawerVisible(false)}>{t('common.cancel')}</Button>
|
||
<Button type="primary" onClick={handleItemSubmit}>{t('common.confirm')}</Button>
|
||
</div>
|
||
}
|
||
>
|
||
<Form form={itemForm} layout="vertical">
|
||
<Form.Item label={t('dicts.typeCode')} name="typeCode">
|
||
<Input disabled className="tabular-nums" />
|
||
</Form.Item>
|
||
<Form.Item label={t('dicts.itemLabel')} name="itemLabel" rules={[{ required: true, message: t('dicts.itemLabel') }]}>
|
||
<Input placeholder="例如:正常、禁用…" />
|
||
</Form.Item>
|
||
<Form.Item label={t('dicts.itemValue')} name="itemValue" rules={[{ required: true, message: t('dicts.itemValue') }]}>
|
||
<Input placeholder="例如:1、0…" className="tabular-nums" />
|
||
</Form.Item>
|
||
<Form.Item label={t('dicts.sort')} name="sortOrder" initialValue={0}>
|
||
<InputNumber className="w-full tabular-nums" />
|
||
</Form.Item>
|
||
<Form.Item label={t('common.status')} name="status" initialValue={1}>
|
||
<Select
|
||
options={[
|
||
{ label: "启用", value: 1 },
|
||
{ label: "禁用", value: 0 }
|
||
]}
|
||
/>
|
||
</Form.Item>
|
||
<Form.Item label={t('common.remark')} name="remark">
|
||
<Input.TextArea placeholder="可选项,备注详细信息…" rows={3} />
|
||
</Form.Item>
|
||
</Form>
|
||
</Drawer>
|
||
</div>
|
||
);
|
||
}
|