Files
gongxue-base/apps/admin/src/pages/Organizations/index.tsx

482 lines
15 KiB
TypeScript

// 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',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
interface OrganizationItem {
id: number;
code: string;
name: string;
isHost: boolean;
contactName?: string;
phone?: string;
color?: string;
notes?: string;
status: 'active' | 'archived';
}
const ORGANIZATION_FIELDS = {
name: 'name',
code: 'code',
contactName: 'contactName',
phone: 'phone',
notes: 'notes',
} as const;
const OrganizationsPage: React.FC = () => {
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();
const [saving, setSaving] = useState(false);
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) => {
const matchesKeyword =
!keyword ||
item.name.toLowerCase().includes(keyword) ||
item.code.toLowerCase().includes(keyword) ||
item.contactName?.toLowerCase().includes(keyword);
return matchesKeyword && (!filterStatus || item.status === filterStatus);
});
}, [data, searchText, filterStatus]);
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 统一处理
}
},
});
};
const openEditor = (record?: OrganizationItem) => {
setEditing(record ?? null);
form.resetFields();
if (record) form.setFieldsValue(record);
else form.setFieldsValue({ color: PRESET_COLORS[data.length % PRESET_COLORS.length] });
setModalOpen(true);
};
const handleSave = async () => {
const values = await form.validateFields();
setSaving(true);
try {
await saveMutation.mutateAsync(values);
message.success(editing ? '机构已更新' : '机构已创建');
setModalOpen(false);
} catch {
// 错误提示由 useApiMutation 统一处理
} finally {
setSaving(false);
}
};
const saveCell = async (record: OrganizationItem, field: string, value: unknown) => {
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) => (
<EditableOrganizationCell value={name} field={ORGANIZATION_FIELDS.name} record={record} required onSave={saveCell}>
<Space>
<span
style={{
width: 10,
height: 10,
borderRadius: '50%',
background: record.color || '#8c8c8c',
}}
/>
<strong>{name}</strong>
{record.isHost ? (
<Tag color="blue" icon={<BankOutlined />}>
</Tag>
) : (
<Tag></Tag>
)}
</Space>
</EditableOrganizationCell>
),
},
{
title: '机构编码',
dataIndex: 'code',
width: 130,
render: (value: string, record: OrganizationItem) => (
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.code} record={record} required onSave={saveCell}>
<code>{value}</code>
</EditableOrganizationCell>
),
},
{
title: '联系人',
dataIndex: 'contactName',
width: 120,
render: (value: string | undefined, record: OrganizationItem) => (
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.contactName} record={record} onSave={saveCell}>
{value || '-'}
</EditableOrganizationCell>
),
},
{
title: '电话',
dataIndex: 'phone',
width: 140,
render: (value: string | undefined, record: OrganizationItem) => (
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.phone} record={record} onSave={saveCell}>
{value || '-'}
</EditableOrganizationCell>
),
},
{
title: '备注',
dataIndex: 'notes',
ellipsis: true,
render: (value: string | undefined, record: OrganizationItem) => (
<EditableOrganizationCell value={value} field={ORGANIZATION_FIELDS.notes} record={record} editor="textarea" onSave={saveCell}>
{value || '-'}
</EditableOrganizationCell>
),
},
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (status: string) => (
<Tag color={status === 'active' ? 'green' : 'default'}>
{status === 'active' ? '正常' : '已归档'}
</Tag>
),
},
{
title: '操作',
width: 160,
render: (_: unknown, record: OrganizationItem) => (
<Space>
{record.status === 'archived' ? (
<>
<Popconfirm
title="确定恢复此机构?"
onConfirm={async () => {
try {
await statusMutation.mutateAsync({ id: record.id, status: 'active' });
message.success('机构已恢复');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
<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
permission="organization:edit"
size="small"
onClick={() => openEditor(record)}
>
</PermissionButton>
{!record.isHost ? (
<Popconfirm
title="归档后仍保留历史学生、入住和租赁记录"
onConfirm={async () => {
try {
await statusMutation.mutateAsync({ id: record.id, status: 'archived' });
message.success('机构已归档');
} catch {
// 错误提示由 useApiMutation 统一处理
}
}}
>
<PermissionButton
permission="organization:delete"
size="small"
icon={<InboxOutlined />}
>
</PermissionButton>
</Popconfirm>
) : null}
</>
)}
</Space>
),
},
];
return (
<div>
<Alert
type="info"
showIcon
title="统一机构管理"
description="本机构与外部机构使用同一套资料。学生明确归属机构;教室租赁则单独记录出租机构和承租机构。"
style={{ marginBottom: 16 }}
/>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索名称、编码或联系人"
allowClear
style={{ width: 260 }}
onChange={(event) => setSearchText(event.target.value)}
/>
<Select
placeholder="全部状态"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={setFilterStatus}
options={[
{ value: 'active', label: '正常' },
{ value: 'archived', label: '已归档' },
]}
/>
</Space>
<PermissionButton
permission="organization:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => openEditor()}
>
</PermissionButton>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
locale={{ emptyText: <Empty description="暂无机构" /> }}
scroll={{ x: 1100 }}
pagination={{
defaultPageSize: 20,
showSizeChanger: true,
pageSizeOptions: [20, 50, 100],
showTotal: (total) => `${total} 个机构`,
}}
/>
<Modal
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
open={modalOpen}
onOk={handleSave}
onCancel={() => setModalOpen(false)}
confirmLoading={saving}
okText="保存"
>
<Form form={form} layout="vertical">
{editing?.isHost ? (
<Alert
type="warning"
showIcon
title="这是系统本机构,不可归档,也不能改为外部机构。"
style={{ marginBottom: 16 }}
/>
) : null}
<Form.Item
name="name"
label="机构名称"
rules={[{ required: true, message: '请输入机构名称' }]}
>
<Input />
</Form.Item>
<Form.Item
name="code"
label="机构编码"
tooltip="用于导入和系统识别,建议使用大写英文、数字、下划线或短横线"
rules={[
{ required: true },
{ pattern: /^[A-Z0-9_-]+$/, message: '仅支持大写英文、数字、下划线和短横线' },
]}
>
<Input
disabled={editing?.isHost}
placeholder="如 PARTNER_A"
onChange={(event) => form.setFieldValue('code', event.target.value.toUpperCase())}
/>
</Form.Item>
<Form.Item name="contactName" label="联系人">
<Input />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="color" label="识别颜色">
<Space wrap>
{PRESET_COLORS.map((color) => (
<button
type="button"
key={color}
aria-label={`选择 ${color}`}
onClick={() => form.setFieldValue('color', color)}
style={{
width: 30,
height: 30,
borderRadius: 6,
border: '1px solid #d9d9d9',
background: color,
cursor: 'pointer',
}}
/>
))}
</Space>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={3} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default OrganizationsPage;