MaterialBlacklistPage.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import React, { useCallback, useEffect, useState } from 'react';
  2. import dayjs from 'dayjs';
  3. import { Button, Table, Modal, Input, Transfer, Spin } from 'antd';
  4. import type { TransferProps } from 'antd/es/transfer';
  5. import type { ColumnsType } from 'antd/es/table';
  6. import { Plus, Trash2 } from 'lucide-react';
  7. import { toast } from 'sonner';
  8. import { useTranslation } from 'react-i18next';
  9. import { materialBlacklistApi } from '../../../api/material/blacklist';
  10. import { pickRecords, pickTotal, buildPageParams } from '../materialListUtils';
  11. import {
  12. MaterialPageRoot,
  13. MaterialToolbarCard,
  14. MaterialTableCard,
  15. MaterialPaginationBar,
  16. MaterialDeleteButton,
  17. materialConfirmDelete,
  18. getMaterialFormModalProps,
  19. } from '../MaterialPageLayout';
  20. type TransferRecord = NonNullable<TransferProps['dataSource']>[number];
  21. function firstDefinedRow(row: Record<string, any>, keys: string[]) {
  22. for (const k of keys) {
  23. const v = row[k];
  24. if (v !== undefined && v !== null && v !== '') return v;
  25. }
  26. return undefined;
  27. }
  28. function formatDateTimeCell(v: unknown): string {
  29. if (v === undefined || v === null || v === '') return '—';
  30. const n = typeof v === 'number' ? v : /^\d+$/.test(String(v).trim()) ? Number(String(v).trim()) : NaN;
  31. const d = Number.isFinite(n) && n > 1e12 ? dayjs(n) : dayjs(v as string | number | Date);
  32. return d.isValid() ? d.format('YYYY-MM-DD HH:mm:ss') : '—';
  33. }
  34. /** 与旧版 BlacklistForm.vue 一致:module 固定为物资柜等业务维度 */
  35. const BLACKLIST_MODULE = '2';
  36. export default function MaterialBlacklistPage() {
  37. const { t } = useTranslation();
  38. const [loading, setLoading] = useState(false);
  39. const [list, setList] = useState<any[]>([]);
  40. const [total, setTotal] = useState(0);
  41. const [pageNo, setPageNo] = useState(1);
  42. const [pageSize] = useState(10);
  43. const [draftUserName, setDraftUserName] = useState('');
  44. const [draftNickName, setDraftNickName] = useState('');
  45. const [appliedUserName, setAppliedUserName] = useState('');
  46. const [appliedNickName, setAppliedNickName] = useState('');
  47. const [modalOpen, setModalOpen] = useState(false);
  48. const [submitLoading, setSubmitLoading] = useState(false);
  49. const [selectedRowKeys, setSelectedRowKeys] = useState<React.Key[]>([]);
  50. const [transferLoading, setTransferLoading] = useState(false);
  51. const [transferDataSource, setTransferDataSource] = useState<TransferRecord[]>([]);
  52. const [targetKeys, setTargetKeys] = useState<string[]>([]);
  53. /** key 为穿梭框 key(与旧版一致为 userId 字符串),值为提交用 userId */
  54. const [userIdByKey, setUserIdByKey] = useState<Map<string, string | number>>(new Map());
  55. const fetchList = useCallback(async () => {
  56. setLoading(true);
  57. try {
  58. const res = await materialBlacklistApi.listBlacklist({
  59. ...buildPageParams(pageNo, pageSize),
  60. userName: appliedUserName || undefined,
  61. nickName: appliedNickName || undefined,
  62. });
  63. setList(pickRecords(res));
  64. setTotal(pickTotal(res));
  65. } catch (e: any) {
  66. toast.error(e?.message || t('common.operationFailed'));
  67. } finally {
  68. setLoading(false);
  69. }
  70. }, [pageNo, pageSize, appliedUserName, appliedNickName, t]);
  71. useEffect(() => {
  72. fetchList();
  73. }, [fetchList]);
  74. /**
  75. * 与旧版 BlacklistForm getLeftList 一致:白名单分页接口拉可选用户,
  76. * 不再用「全量用户 − 黑名单」自行过滤,避免 userId 形态与后端不一致。
  77. */
  78. const loadTransferUsers = useCallback(async () => {
  79. setTransferLoading(true);
  80. try {
  81. const res = await materialBlacklistApi.listWhitelist({
  82. ...buildPageParams(1, -1),
  83. nickName: undefined,
  84. });
  85. const rows = pickRecords(res);
  86. const map = new Map<string, string | number>();
  87. const items: TransferRecord[] = [];
  88. for (const item of rows as any[]) {
  89. const userId = firstDefinedRow(item, ['userId', 'user_id', 'id']);
  90. if (userId === undefined || userId === null || userId === '') continue;
  91. const sid = String(userId);
  92. map.set(sid, userId as string | number);
  93. items.push({
  94. key: sid,
  95. title: `${item.nickName ?? item.nickname ?? '—'} (${item.userName ?? item.username ?? sid})`,
  96. });
  97. }
  98. setUserIdByKey(map);
  99. setTransferDataSource(items);
  100. } catch (e: any) {
  101. toast.error(e?.message || '加载用户列表失败');
  102. setTransferDataSource([]);
  103. setUserIdByKey(new Map());
  104. } finally {
  105. setTransferLoading(false);
  106. }
  107. }, []);
  108. useEffect(() => {
  109. if (!modalOpen) return;
  110. setTargetKeys([]);
  111. void loadTransferUsers();
  112. }, [modalOpen, loadTransferUsers]);
  113. const handleQuery = () => {
  114. setAppliedUserName(draftUserName);
  115. setAppliedNickName(draftNickName);
  116. setPageNo(1);
  117. setSelectedRowKeys([]);
  118. };
  119. const resetQuery = () => {
  120. setDraftUserName('');
  121. setDraftNickName('');
  122. setAppliedUserName('');
  123. setAppliedNickName('');
  124. setPageNo(1);
  125. setSelectedRowKeys([]);
  126. };
  127. /** 与旧版 submitForm 一致:一次 POST 数组 [{ userId, module: '2' }, ...] */
  128. const submitAdd = async () => {
  129. if (targetKeys.length === 0) {
  130. toast.error('请至少选择一名用户加入黑名单');
  131. return;
  132. }
  133. setSubmitLoading(true);
  134. try {
  135. const payload = targetKeys.map((key) => ({
  136. userId: userIdByKey.get(key) ?? key,
  137. module: BLACKLIST_MODULE,
  138. }));
  139. await materialBlacklistApi.addBlacklist(payload);
  140. toast.success(t('common.addSuccess'));
  141. setModalOpen(false);
  142. setTargetKeys([]);
  143. fetchList();
  144. } catch (e: any) {
  145. toast.error(e?.message || t('common.operationFailed'));
  146. } finally {
  147. setSubmitLoading(false);
  148. }
  149. };
  150. const onDelete = (row: any) => {
  151. const id = row.recordId ?? row.id;
  152. materialConfirmDelete(t, {
  153. count: 1,
  154. onOk: async () => {
  155. await materialBlacklistApi.delBlacklist(id);
  156. toast.success(t('common.deleteSuccess'));
  157. setSelectedRowKeys((prev) => prev.filter((k) => String(k) !== String(id)));
  158. fetchList();
  159. },
  160. });
  161. };
  162. const batchDelete = () => {
  163. if (selectedRowKeys.length === 0) {
  164. toast.error(t('common.pleaseSelectDataToDelete'));
  165. return;
  166. }
  167. const ids = selectedRowKeys
  168. .map((k) => Number(k))
  169. .filter((n) => Number.isFinite(n) && !Number.isNaN(n));
  170. if (ids.length === 0) {
  171. toast.error(t('common.pleaseSelectDataToDelete'));
  172. return;
  173. }
  174. materialConfirmDelete(t, {
  175. count: ids.length,
  176. onOk: async () => {
  177. await materialBlacklistApi.delBlacklist(ids.join(','));
  178. toast.success(t('common.deleteSuccess'));
  179. setSelectedRowKeys([]);
  180. fetchList();
  181. },
  182. });
  183. };
  184. /** Ant Design Transfer:operations[0]=左→右(添加),operations[1]=右→左(移除) */
  185. const transferFilter: TransferProps['filterOption'] = (inputValue, item) => {
  186. const title = (item.title ?? '').toLowerCase();
  187. const q = inputValue.trim().toLowerCase();
  188. return !q || title.includes(q);
  189. };
  190. const columns: ColumnsType<any> = [
  191. { title: '用户编号', dataIndex: 'userId', align: 'center' },
  192. { title: '工号', dataIndex: 'userName', align: 'center' },
  193. { title: '姓名', dataIndex: 'nickName', align: 'center' },
  194. {
  195. title: '创建时间',
  196. key: 'createTime',
  197. width: 170,
  198. align: 'center',
  199. render: (_, row) =>
  200. formatDateTimeCell(
  201. firstDefinedRow(row, ['createTime', 'create_time', 'gmtCreate', 'gmt_create', 'createDate']),
  202. ),
  203. },
  204. {
  205. title: t('table.operation'),
  206. width: 120,
  207. align: 'center',
  208. fixed: 'right',
  209. render: (_, row) => (
  210. <div className="flex justify-center">
  211. <MaterialDeleteButton label={t('common.delete')} onClick={() => onDelete(row)} />
  212. </div>
  213. ),
  214. },
  215. ];
  216. return (
  217. <MaterialPageRoot>
  218. <MaterialToolbarCard
  219. filterRow={
  220. <>
  221. <div className="flex items-center gap-2">
  222. <label className="text-sm font-medium text-gray-700 whitespace-nowrap">工号:</label>
  223. <Input
  224. allowClear
  225. placeholder="请输入"
  226. className="w-[140px]"
  227. value={draftUserName}
  228. onChange={(e) => setDraftUserName(e.target.value)}
  229. />
  230. </div>
  231. <div className="flex items-center gap-2">
  232. <label className="text-sm font-medium text-gray-700 whitespace-nowrap">姓名:</label>
  233. <Input
  234. allowClear
  235. placeholder="请输入"
  236. className="w-[140px]"
  237. value={draftNickName}
  238. onChange={(e) => setDraftNickName(e.target.value)}
  239. />
  240. </div>
  241. </>
  242. }
  243. onSearch={handleQuery}
  244. onReset={resetQuery}
  245. toolbarRight={
  246. <>
  247. <Button
  248. type="primary"
  249. icon={<Plus />}
  250. onClick={() => {
  251. setTargetKeys([]);
  252. setModalOpen(true);
  253. }}
  254. >
  255. {t('common.addNew')}
  256. </Button>
  257. <Button
  258. danger
  259. icon={<Trash2 className="h-4 w-4" />}
  260. disabled={selectedRowKeys.length === 0}
  261. onClick={batchDelete}
  262. >
  263. {t('common.batchDelete')}
  264. </Button>
  265. </>
  266. }
  267. />
  268. <MaterialTableCard>
  269. <Table
  270. rowKey={(r) => r.recordId ?? r.id}
  271. loading={loading}
  272. columns={columns}
  273. dataSource={list}
  274. pagination={false}
  275. scroll={{ x: 'max-content' }}
  276. rowSelection={{
  277. selectedRowKeys,
  278. onChange: setSelectedRowKeys,
  279. preserveSelectedRowKeys: true,
  280. getCheckboxProps: (record) => ({ name: record.userName }),
  281. }}
  282. rowClassName={(_, index) => (index !== undefined && index % 2 === 1 ? 'isolation-work-list-row-alt' : '')}
  283. />
  284. </MaterialTableCard>
  285. <MaterialPaginationBar
  286. total={total}
  287. current={pageNo}
  288. pageSize={pageSize}
  289. onPageChange={setPageNo}
  290. loading={loading}
  291. listLength={list.length}
  292. />
  293. <Modal
  294. title="新增"
  295. open={modalOpen}
  296. onOk={submitAdd}
  297. onCancel={() => {
  298. setModalOpen(false);
  299. setTargetKeys([]);
  300. }}
  301. confirmLoading={submitLoading}
  302. {...getMaterialFormModalProps(t, 880)}
  303. >
  304. {transferLoading ? (
  305. <div className="flex justify-center py-16">
  306. <Spin />
  307. </div>
  308. ) : (
  309. <Transfer
  310. dataSource={transferDataSource}
  311. titles={['可选用户', '已选用户']}
  312. targetKeys={targetKeys}
  313. onChange={(next) => setTargetKeys(next as string[])}
  314. render={(item) => item.title ?? item.key}
  315. showSearch
  316. filterOption={transferFilter}
  317. listStyle={{ width: 360, height: 400 }}
  318. operations={['添加', '移除']}
  319. locale={{
  320. itemUnit: '项',
  321. itemsUnit: '项',
  322. searchPlaceholder: '请输入姓名或登录名',
  323. }}
  324. />
  325. )}
  326. </Modal>
  327. </MaterialPageRoot>
  328. );
  329. }