forked from wangziqi/gongxue-base
feat: 重构各业务模块管理页面与服务
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
// aislop-ignore-file: duplicate-block -- 表格/表单声明结构相似且参数不同,渲染逻辑已共享组件化
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { App, Alert, Button, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import { BankOutlined, InboxOutlined, PlusOutlined, UndoOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import EditableCell from '../../components/EditableCell';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { useApiMutation } from '../../hooks/useApiMutation';
|
||||
import { validateResponse } from '../../utils/validate';
|
||||
import { organizationsSchema } from '../../api/schemas';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ff7875',
|
||||
@@ -31,9 +37,18 @@ interface OrganizationItem {
|
||||
status: 'active' | 'archived';
|
||||
}
|
||||
|
||||
const ORGANIZATION_FIELDS = {
|
||||
name: 'name',
|
||||
code: 'code',
|
||||
contactName: 'contactName',
|
||||
phone: 'phone',
|
||||
notes: 'notes',
|
||||
} as const;
|
||||
|
||||
const OrganizationsPage: React.FC = () => {
|
||||
const [data, setData] = useState<OrganizationItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { modal } = App.useApp();
|
||||
const { hasPermission } = usePermission();
|
||||
const canPurgeOrganization = hasPermission('organization:purge');
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OrganizationItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
@@ -41,6 +56,48 @@ const OrganizationsPage: React.FC = () => {
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>();
|
||||
|
||||
const { data = [], isLoading, isFetching } = useQuery({
|
||||
queryKey: ['organizations'],
|
||||
queryFn: async () => {
|
||||
try {
|
||||
return validateResponse<OrganizationItem[]>(
|
||||
organizationsSchema,
|
||||
await api.get<OrganizationItem[]>('/organizations', {
|
||||
params: { includeArchived: true },
|
||||
}),
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '机构数据加载失败');
|
||||
return [];
|
||||
}
|
||||
},
|
||||
});
|
||||
const loading = isLoading || isFetching;
|
||||
|
||||
const saveMutation = useApiMutation(
|
||||
async (values: { name: string; code: string; color?: string; notes?: string }) =>
|
||||
editing
|
||||
? api.put(`/organizations/${editing.id}`, values)
|
||||
: api.post('/organizations', values),
|
||||
{ invalidate: [['organizations']] },
|
||||
);
|
||||
const saveCellMutation = useApiMutation(
|
||||
async ({ record, field, value }: { record: OrganizationItem; field: string; value: unknown }) =>
|
||||
api.put(`/organizations/${record.id}`, { [field]: value }),
|
||||
{ invalidate: [['organizations']] },
|
||||
);
|
||||
const statusMutation = useApiMutation(
|
||||
async ({ id, status }: { id: number; status: 'active' | 'archived' }) =>
|
||||
status === 'active'
|
||||
? api.put(`/organizations/${id}`, { status: 'active' })
|
||||
: api.delete(`/organizations/${id}`),
|
||||
{ invalidate: [['organizations']] },
|
||||
);
|
||||
const purgeMutation = useApiMutation(
|
||||
async (id: number) => api.delete(`/organizations/${id}/permanent`),
|
||||
{ invalidate: [['organizations']] },
|
||||
);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase();
|
||||
return data.filter((item) => {
|
||||
@@ -53,23 +110,24 @@ const OrganizationsPage: React.FC = () => {
|
||||
});
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(
|
||||
await api.get<OrganizationItem[]>('/organizations', { params: { includeArchived: true } }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '机构数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
const handlePurge = (record: OrganizationItem) => {
|
||||
modal.confirm({
|
||||
title: `永久删除机构「${record.name}」?`,
|
||||
content: '删除后不可恢复,存在学生归属、入住或租赁关联时将无法删除。确定继续?',
|
||||
okText: '永久删除',
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: '取消',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await purgeMutation.mutateAsync(record.id);
|
||||
message.success('已永久删除(不可恢复)');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
const openEditor = (record?: OrganizationItem) => {
|
||||
setEditing(record ?? null);
|
||||
form.resetFields();
|
||||
@@ -82,37 +140,63 @@ const OrganizationsPage: React.FC = () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) await api.put(`/organizations/${editing.id}`, values);
|
||||
else await api.post('/organizations', values);
|
||||
await saveMutation.mutateAsync(values);
|
||||
message.success(editing ? '机构已更新' : '机构已创建');
|
||||
setModalOpen(false);
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '保存失败');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveCell = async (record: OrganizationItem, field: string, value: unknown) => {
|
||||
await api.put(`/organizations/${record.id}`, { [field]: value });
|
||||
message.success('已保存');
|
||||
await fetchData();
|
||||
try {
|
||||
await saveCellMutation.mutateAsync({ record, field, value });
|
||||
message.success('已保存');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
};
|
||||
|
||||
const EditableOrganizationCell = <R extends { id: number; status?: string }>({
|
||||
value,
|
||||
field,
|
||||
record,
|
||||
editor,
|
||||
required,
|
||||
onSave,
|
||||
children,
|
||||
}: {
|
||||
value: unknown;
|
||||
field: string;
|
||||
record: R;
|
||||
editor?: React.ComponentProps<typeof EditableCell>['editor'];
|
||||
required?: boolean;
|
||||
onSave: (record: R, field: string, value: unknown) => Promise<void> | void;
|
||||
children?: React.ReactNode;
|
||||
}) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor={editor}
|
||||
required={required}
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={async (next) => {
|
||||
await onSave(record, field, next);
|
||||
}}
|
||||
>
|
||||
{children ?? String(value ?? '-')}
|
||||
</EditableCell>
|
||||
);
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '机构',
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
render: (name: string, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={name}
|
||||
required
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'name', next)}
|
||||
>
|
||||
<EditableOrganizationCell value={name} field={ORGANIZATION_FIELDS.name} record={record} required onSave={saveCell}>
|
||||
<Space>
|
||||
<span
|
||||
style={{
|
||||
@@ -131,71 +215,54 @@ const OrganizationsPage: React.FC = () => {
|
||||
<Tag>外部机构</Tag>
|
||||
)}
|
||||
</Space>
|
||||
</EditableCell>
|
||||
</EditableOrganizationCell>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: '机构编码',
|
||||
dataIndex: 'code',
|
||||
width: 130,
|
||||
render: (value: string, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
required
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'code', next)}
|
||||
>
|
||||
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.code} record={record} required onSave={saveCell}>
|
||||
<code>{value}</code>
|
||||
</EditableCell>
|
||||
</EditableOrganizationCell>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contactName',
|
||||
width: 120,
|
||||
render: (value: string | undefined, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'contactName', next)}
|
||||
>
|
||||
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.contactName} record={record} onSave={saveCell}>
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
</EditableOrganizationCell>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (value: string | undefined, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'phone', next)}
|
||||
>
|
||||
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.phone} record={record} onSave={saveCell}>
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
</EditableOrganizationCell>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: '备注',
|
||||
dataIndex: 'notes',
|
||||
ellipsis: true,
|
||||
render: (value: string | undefined, record: OrganizationItem) => (
|
||||
<EditableCell
|
||||
value={value}
|
||||
editor="textarea"
|
||||
permission="organization:edit"
|
||||
disabled={record.status === 'archived'}
|
||||
onSave={(next) => saveCell(record, 'notes', next)}
|
||||
>
|
||||
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.notes} record={record} editor="textarea" onSave={saveCell}>
|
||||
{value || '-'}
|
||||
</EditableCell>
|
||||
</EditableOrganizationCell>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -206,33 +273,40 @@ const OrganizationsPage: React.FC = () => {
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_: unknown, record: OrganizationItem) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<Popconfirm
|
||||
title="确定恢复此机构?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await api.put(`/organizations/${record.id}`, { status: 'active' });
|
||||
message.success('机构已恢复');
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '恢复失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="organization:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<UndoOutlined />}
|
||||
<>
|
||||
<Popconfirm
|
||||
title="确定恢复此机构?"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await statusMutation.mutateAsync({ id: record.id, status: 'active' });
|
||||
message.success('机构已恢复');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
}}
|
||||
>
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="organization:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
icon={<UndoOutlined />}
|
||||
>
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
{canPurgeOrganization && !record.isHost ? (
|
||||
<Button size="small" danger type="link" onClick={() => handlePurge(record)}>
|
||||
删除
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton
|
||||
@@ -247,11 +321,10 @@ const OrganizationsPage: React.FC = () => {
|
||||
title="归档后仍保留历史学生、入住和租赁记录"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await api.delete(`/organizations/${record.id}`);
|
||||
await statusMutation.mutateAsync({ id: record.id, status: 'archived' });
|
||||
message.success('机构已归档');
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '归档失败');
|
||||
} catch {
|
||||
// 错误提示由 useApiMutation 统一处理
|
||||
}
|
||||
}}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user