AttachmentList.tsx 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508
  1. import { useIntl } from "react-intl";
  2. import {
  3. Button,
  4. Space,
  5. Table,
  6. Dropdown,
  7. message,
  8. Modal,
  9. Typography,
  10. Image,
  11. Segmented,
  12. } from "antd";
  13. import {
  14. PlusOutlined,
  15. ExclamationCircleOutlined,
  16. FileOutlined,
  17. AudioOutlined,
  18. FileImageOutlined,
  19. MoreOutlined,
  20. BarsOutlined,
  21. AppstoreOutlined,
  22. } from "@ant-design/icons";
  23. import { type ActionType, ProList } from "@ant-design/pro-components";
  24. import type { IUserDictDeleteRequest } from "../../api/Dict";
  25. import { delete_2, get, put } from "../../request";
  26. import { useRef, useState } from "react";
  27. import type { IDeleteResponse } from "../../api/Article";
  28. import TimeShow from "../general/TimeShow";
  29. import { getSorterUrl } from "../../utils";
  30. import type {
  31. IAttachmentListResponse,
  32. IAttachmentRequest,
  33. IAttachmentResponse,
  34. IAttachmentUpdate,
  35. } from "../../api/Attachments";
  36. import { VideoIcon } from "../../assets/icon";
  37. import AttachmentImport, { deleteRes } from "./AttachmentImport";
  38. import VideoModal from "../general/VideoModal";
  39. import FileSize from "../general/FileSize";
  40. import modal from "antd/lib/modal";
  41. import { useAppSelector } from "../../hooks";
  42. import { currentUser } from "../../reducers/current-user";
  43. const { Text } = Typography;
  44. export interface IAttachment {
  45. id: string;
  46. name: string;
  47. filename: string;
  48. title: string;
  49. size: number;
  50. content_type: string;
  51. url: string;
  52. }
  53. interface IParams {
  54. content_type?: string;
  55. }
  56. interface IWidget {
  57. studioName?: string;
  58. view?: "studio" | "all";
  59. multiSelect?: boolean;
  60. onClick?: (record: IAttachmentRequest) => void;
  61. }
  62. const AttachmentWidget = ({
  63. studioName,
  64. view = "studio",
  65. multiSelect = false,
  66. onClick,
  67. }: IWidget) => {
  68. const intl = useIntl();
  69. const [replaceId, setReplaceId] = useState<string>();
  70. const [importOpen, setImportOpen] = useState(false);
  71. const [imgVisible, setImgVisible] = useState(false);
  72. const [imgPrev, setImgPrev] = useState<string>();
  73. const [list, setList] = useState("list");
  74. const [videoVisible, setVideoVisible] = useState(false);
  75. const [videoUrl, setVideoUrl] = useState<string>();
  76. const user = useAppSelector(currentUser);
  77. const currStudio = studioName ? studioName : user?.realName;
  78. const showDeleteConfirm = (id: string[], title: string) => {
  79. Modal.confirm({
  80. icon: <ExclamationCircleOutlined />,
  81. title:
  82. intl.formatMessage({
  83. id: "message.delete.confirm",
  84. }) +
  85. intl.formatMessage({
  86. id: "message.irrevocable",
  87. }),
  88. content: title,
  89. okText: intl.formatMessage({
  90. id: "buttons.delete",
  91. }),
  92. okType: "danger",
  93. cancelText: intl.formatMessage({
  94. id: "buttons.no",
  95. }),
  96. onOk() {
  97. console.log("delete", id);
  98. return delete_2<IUserDictDeleteRequest, IDeleteResponse>(
  99. `/v2/userdict/${id}`,
  100. {
  101. id: JSON.stringify(id),
  102. }
  103. )
  104. .then((json) => {
  105. if (json.ok) {
  106. message.success("删除成功");
  107. ref.current?.reload();
  108. } else {
  109. message.error(json.message);
  110. }
  111. })
  112. .catch((e: unknown) => console.log("Oops errors!", e));
  113. },
  114. });
  115. };
  116. const ref = useRef<ActionType | null>(null);
  117. return (
  118. <>
  119. <ProList<IAttachmentRequest, IParams>
  120. actionRef={ref}
  121. editable={{
  122. onSave: async (key, record, originRow) => {
  123. console.log(key, record, originRow);
  124. const url = `/v2/attachment/${key}`;
  125. const res = await put<IAttachmentUpdate, IAttachmentResponse>(url, {
  126. title: record.title,
  127. });
  128. return res.ok;
  129. },
  130. }}
  131. ghost={list === "list" ? false : true}
  132. onItem={(record: IAttachmentRequest) => {
  133. return {
  134. onClick: () => {
  135. // 点击行
  136. if (typeof onClick !== "undefined") {
  137. onClick(record);
  138. }
  139. },
  140. };
  141. }}
  142. //TODO remove meta
  143. metas={{
  144. title: {
  145. dataIndex: "title",
  146. search: false,
  147. render: (_dom, entity) => {
  148. return (
  149. <Button
  150. type="link"
  151. onClick={() => {
  152. const ct = entity.content_type.split("/");
  153. switch (ct[0]) {
  154. case "image":
  155. setImgPrev(entity.url);
  156. setImgVisible(true);
  157. break;
  158. case "video":
  159. setVideoUrl(entity.url);
  160. setVideoVisible(true);
  161. break;
  162. default:
  163. break;
  164. }
  165. }}
  166. >
  167. {entity.title}
  168. </Button>
  169. );
  170. },
  171. },
  172. description: {
  173. editable: false,
  174. search: false,
  175. render: (_dom, entity) => {
  176. return (
  177. <Text type="secondary">
  178. <Space>
  179. {entity.content_type}
  180. <FileSize size={entity.size} />
  181. <TimeShow
  182. type="secondary"
  183. createdAt={entity.created_at}
  184. updatedAt={entity.updated_at}
  185. />
  186. </Space>
  187. </Text>
  188. );
  189. },
  190. },
  191. content:
  192. list === "list"
  193. ? { editable: false, search: false }
  194. : {
  195. editable: false,
  196. search: false,
  197. render: (_dom, entity) => {
  198. const thumbnail = entity.thumbnail
  199. ? entity.thumbnail.middle
  200. : entity.url;
  201. return (
  202. <Image
  203. src={thumbnail}
  204. preview={{
  205. src: entity.url,
  206. }}
  207. />
  208. );
  209. },
  210. },
  211. avatar: {
  212. editable: false,
  213. search: false,
  214. render: (_dom, entity) => {
  215. const ct = entity.content_type.split("/");
  216. let icon = <FileOutlined />;
  217. switch (ct[0]) {
  218. case "video":
  219. icon = <VideoIcon />;
  220. break;
  221. case "audio":
  222. icon = <AudioOutlined />;
  223. break;
  224. case "image":
  225. icon = <FileImageOutlined />;
  226. break;
  227. }
  228. return icon;
  229. },
  230. },
  231. actions: {
  232. render: (_text, row, _index, action) => {
  233. return [
  234. <Button
  235. type="link"
  236. size="small"
  237. onClick={() => {
  238. action?.startEditable(row.id);
  239. }}
  240. >
  241. 编辑
  242. </Button>,
  243. <Dropdown
  244. menu={{
  245. items: [
  246. { label: "链接", key: "link" },
  247. {
  248. label: "Markdown",
  249. key: "markdown",
  250. disabled: row.content_type.includes("image")
  251. ? false
  252. : true,
  253. },
  254. { label: "替换", key: "replace" },
  255. { label: "引用模版", key: "tpl" },
  256. { label: "删除", key: "delete", danger: true },
  257. ],
  258. onClick: (e) => {
  259. console.log("click ", e.key);
  260. switch (e.key) {
  261. case "link": {
  262. const link = `/attachments/${row.filename}`;
  263. navigator.clipboard.writeText(link).then(() => {
  264. message.success("已经拷贝到剪贴板");
  265. });
  266. break;
  267. }
  268. case "markdown": {
  269. const markdown = `![${row.title}](/attachments/${row.filename})`;
  270. navigator.clipboard.writeText(markdown).then(() => {
  271. message.success("已经拷贝到剪贴板");
  272. });
  273. break;
  274. }
  275. case "replace":
  276. setReplaceId(row.id);
  277. setImportOpen(true);
  278. break;
  279. case "delete":
  280. modal.confirm({
  281. title: intl.formatMessage({
  282. id: "message.delete.confirm",
  283. }),
  284. icon: <ExclamationCircleOutlined />,
  285. content: intl.formatMessage({
  286. id: "message.irrevocable",
  287. }),
  288. okText: "确认",
  289. cancelText: "取消",
  290. okType: "danger",
  291. onOk: () => {
  292. deleteRes(row.id);
  293. ref.current?.reload();
  294. },
  295. });
  296. break;
  297. default:
  298. break;
  299. }
  300. },
  301. }}
  302. placement="bottomRight"
  303. >
  304. <Button
  305. type="link"
  306. size="small"
  307. icon={<MoreOutlined />}
  308. onClick={(e) => e.preventDefault()}
  309. />
  310. </Dropdown>,
  311. ];
  312. },
  313. },
  314. content_type: {
  315. // 自己扩展的字段,主要用于筛选,不在列表中显示
  316. editable: false,
  317. title: "类型",
  318. valueType: "select",
  319. valueEnum: {
  320. all: { text: "全部", status: "Default" },
  321. image: {
  322. text: "图片",
  323. status: "Error",
  324. },
  325. video: {
  326. text: "视频",
  327. status: "Success",
  328. },
  329. audio: {
  330. text: "音频",
  331. status: "Processing",
  332. },
  333. },
  334. },
  335. }}
  336. rowSelection={
  337. view === "all"
  338. ? undefined
  339. : multiSelect
  340. ? {
  341. // 自定义选择项参考: https://ant.design/components/table-cn/#components-table-demo-row-selection-custom
  342. // 注释该行则默认不显示下拉选项
  343. selections: [Table.SELECTION_ALL, Table.SELECTION_INVERT],
  344. }
  345. : undefined
  346. }
  347. tableAlertRender={
  348. view === "all"
  349. ? undefined
  350. : ({ selectedRowKeys, onCleanSelected }) => (
  351. <Space size={24}>
  352. <span>
  353. {intl.formatMessage({ id: "buttons.selected" })}
  354. {selectedRowKeys.length}
  355. <Button
  356. type="link"
  357. style={{ marginInlineStart: 8 }}
  358. onClick={onCleanSelected}
  359. >
  360. {intl.formatMessage({ id: "buttons.unselect" })}
  361. </Button>
  362. </span>
  363. </Space>
  364. )
  365. }
  366. tableAlertOptionRender={
  367. view === "all"
  368. ? undefined
  369. : ({ selectedRowKeys, onCleanSelected }) => {
  370. return (
  371. <Space size={16}>
  372. <Button
  373. type="link"
  374. onClick={() => {
  375. console.log(selectedRowKeys);
  376. showDeleteConfirm(
  377. selectedRowKeys.map((item) => item.toString()),
  378. selectedRowKeys.length + "个单词"
  379. );
  380. onCleanSelected();
  381. }}
  382. >
  383. 批量删除
  384. </Button>
  385. </Space>
  386. );
  387. }
  388. }
  389. request={async (params = {}, sorter, filter) => {
  390. console.log(params, sorter, filter);
  391. const offset =
  392. ((params.current ? params.current : 1) - 1) *
  393. (params.pageSize ? params.pageSize : 20);
  394. let url = "/v2/attachment?";
  395. switch (view) {
  396. case "studio":
  397. url += `view=studio&studio=${currStudio}`;
  398. break;
  399. case "all":
  400. url += `view=all`;
  401. break;
  402. default:
  403. break;
  404. }
  405. url += `&limit=${params.pageSize}&offset=${offset}`;
  406. url += params.keyword ? "&search=" + params.keyword : "";
  407. if (params.content_type && params.content_type !== "all") {
  408. url += "&content_type=" + params.content_type;
  409. }
  410. url += getSorterUrl(sorter);
  411. console.log(url);
  412. const res = await get<IAttachmentListResponse>(url);
  413. return {
  414. total: res.data.count,
  415. success: res.ok,
  416. data: res.data.rows,
  417. };
  418. }}
  419. rowKey="id"
  420. bordered
  421. pagination={{
  422. showQuickJumper: true,
  423. showSizeChanger: true,
  424. }}
  425. search={{
  426. filterType: "light",
  427. }}
  428. options={{
  429. search: true,
  430. }}
  431. grid={list === "list" ? undefined : { gutter: 16, column: 3 }}
  432. headerTitle=""
  433. toolBarRender={() => [
  434. <Segmented
  435. options={[
  436. { label: "List", value: "list", icon: <BarsOutlined /> },
  437. {
  438. label: "Thumbnail",
  439. value: "thumbnail",
  440. icon: <AppstoreOutlined />,
  441. },
  442. ]}
  443. onChange={(value) => {
  444. console.log(value); // string
  445. setList(value.toString());
  446. }}
  447. />,
  448. <Button
  449. key="button"
  450. icon={<PlusOutlined />}
  451. type="primary"
  452. onClick={() => {
  453. setReplaceId(undefined);
  454. setImportOpen(true);
  455. }}
  456. disabled={view === "all"}
  457. >
  458. {intl.formatMessage({ id: "buttons.import" })}
  459. </Button>,
  460. ]}
  461. />
  462. <AttachmentImport
  463. replaceId={replaceId}
  464. open={importOpen}
  465. onOpenChange={(open: boolean) => {
  466. setImportOpen(open);
  467. ref.current?.reload();
  468. }}
  469. />
  470. <Image
  471. width={200}
  472. style={{ display: "none" }}
  473. preview={{
  474. visible: imgVisible,
  475. src: imgPrev,
  476. onVisibleChange: (value) => {
  477. setImgVisible(value);
  478. },
  479. }}
  480. />
  481. <VideoModal
  482. src={videoUrl}
  483. open={videoVisible}
  484. onOpenChange={(open: boolean) => setVideoVisible(open)}
  485. />
  486. </>
  487. );
  488. };
  489. export default AttachmentWidget;