imeeting/frontend/src/pages/Dictionaries.tsx

466 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import {
Button,
Card,
Col,
Drawer,
Form,
Input,
InputNumber,
message,
Popconfirm,
Row,
Select,
Space,
Table,
Tag,
Typography,
Empty
} from "antd";
import { useEffect, useState, useCallback } 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, SearchOutlined } from "@ant-design/icons";
import { useDict } from "../hooks/useDict";
import type { SysDictItem, SysDictType } from "../types";
import PageHeader from "../components/shared/PageHeader";
import { getStandardPagination } from "../utils/pagination";
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);
const [typeTotal, setTypeTotal] = useState(0);
const [typeParams, setTypeParams] = useState({
current: 1,
size: 10,
typeCode: "",
typeName: ""
});
// Dictionaries
const { items: statusDict } = useDict("sys_common_status");
// 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 = useCallback(async (params = typeParams) => {
setLoadingTypes(true);
try {
const data = await fetchDictTypes(params);
setTypes(data.records || []);
setTypeTotal(data.total || 0);
// If we have data and nothing is selected, select the first one
if (data.records && data.records.length > 0 && !selectedType) {
setSelectedType(data.records[0]);
} else if (selectedType) {
// Refresh selected type info if it still exists in the new list
const updatedSelected = data.records.find((t: SysDictType) => t.dictTypeId === selectedType.dictTypeId);
if (updatedSelected) setSelectedType(updatedSelected);
}
} finally {
setLoadingTypes(false);
}
}, [selectedType, typeParams]);
const loadItems = async (typeCode: string) => {
setLoadingItems(true);
try {
const data = await fetchDictItems(typeCode);
setItems(data || []);
} finally {
setLoadingItems(false);
}
};
useEffect(() => {
loadTypes();
}, [typeParams.current, typeParams.size]);
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();
};
const handleTypeSearch = (val: string) => {
setTypeParams({ ...typeParams, current: 1, typeName: val });
loadTypes({ ...typeParams, current: 1, typeName: val });
};
// 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 flex flex-col h-full overflow-hidden">
<PageHeader
title={t('dicts.title')}
subtitle={t('dicts.subtitle')}
extra={can("sys_dict:type:create") && (
<Button
type="primary"
icon={<PlusOutlined aria-hidden="true" />}
onClick={handleAddType}
>
{t('common.create')}
</Button>
)}
/>
<Row gutter={24} className="flex-1 min-h-0 overflow-hidden">
<Col span={8} className="h-full flex flex-col overflow-hidden">
<Card
title={
<Space>
<BookOutlined aria-hidden="true" />
<span>{t('dicts.dictType')}</span>
</Space>
}
className="flex-1 flex flex-col overflow-hidden shadow-sm"
styles={{ body: { padding: '12px', flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
>
<div style={{ marginBottom: 12 }} className="flex-shrink-0">
<Input.Search
placeholder="搜索类型名称"
allowClear
onSearch={handleTypeSearch}
enterButton
/>
</div>
<div className="flex-1 min-h-0">
<Table
rowKey="dictTypeId"
loading={loadingTypes}
dataSource={types}
pagination={{
...getStandardPagination(typeTotal, typeParams.current, typeParams.size, (page, size) => setTypeParams({ ...typeParams, current: page, size })),
simple: true,
size: 'small',
position: ['bottomCenter']
}}
size="small"
showHeader={false}
scroll={{ y: 'calc(100vh - 480px)' }}
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-1">
<div className="min-w-0 flex-1">
<div className="dict-type-name font-medium truncate" style={{ fontSize: '14px' }}>{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" style={{ fontSize: '12px' }} />}
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" style={{ fontSize: '12px' }} />}
onClick={(e) => e.stopPropagation()}
/>
</Popconfirm>
)}
</div>
</div>
)
}
]}
/>
</div>
</Card>
</Col>
<Col span={16} className="h-full flex flex-col overflow-hidden">
<Card
title={
<Space>
<ProfileOutlined aria-hidden="true" />
<span>{t('dicts.dictItem')}{selectedType ? ` - ${selectedType.typeName}` : ""}</span>
</Space>
}
className="flex-1 flex flex-col overflow-hidden shadow-sm"
styles={{ body: { padding: 0, flex: 1, display: 'flex', flexDirection: 'column', overflow: 'hidden' } }}
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 className="flex-1 overflow-hidden">
<Table
rowKey="dictItemId"
loading={loadingItems}
dataSource={items}
pagination={false}
size="middle"
scroll={{ y: 'calc(100vh - 320px)' }}
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) => {
const item = statusDict.find(i => i.itemValue === String(v));
return (
<Tag color={v === 1 ? "green" : "red"}>
{item ? item.itemLabel : (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={statusDict.map(i => ({ label: i.itemLabel, value: Number(i.itemValue) }))}
/>
</Form.Item>
<Form.Item label={t('common.remark')} name="remark">
<Input.TextArea placeholder="可选项,备注详细信息…" rows={3} />
</Form.Item>
</Form>
</Drawer>
</div>
);
}