AttachmentList.tsx 15 KB

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