ChannelMy.tsx 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. import { useEffect, useState } from "react";
  2. import { useIntl } from "react-intl";
  3. import type { Key } from "antd/es/table/interface";
  4. import {
  5. Badge,
  6. Button,
  7. Card,
  8. Dropdown,
  9. Input,
  10. Select,
  11. Skeleton,
  12. Space,
  13. Tag,
  14. Tooltip,
  15. Tree,
  16. } from "antd";
  17. import {
  18. GlobalOutlined,
  19. EditOutlined,
  20. ReloadOutlined,
  21. MoreOutlined,
  22. CopyOutlined,
  23. InfoCircleOutlined,
  24. } from "@ant-design/icons";
  25. import { get, post } from "../../request";
  26. import type {
  27. IApiResponseChannelList,
  28. ISentInChapterListResponse,
  29. } from "../../api/Channel";
  30. import type { IItem, IProgressRequest } from "./ChannelPickerTable";
  31. import { LockFillIcon, LockIcon } from "../../assets/icon";
  32. import StudioName from "../auth/Studio";
  33. import ProgressSvg from "./ProgressSvg";
  34. import type { IChannel } from "./Channel";
  35. import CopyToModal from "./CopyToModal";
  36. import type { ArticleType } from "../article/Article";
  37. import { ChannelInfoModal } from "./ChannelInfo";
  38. import TokenModal from "../article/TokenModal";
  39. import NissayaAlignerModal from "../corpus/NissayaAlignerModal";
  40. const { Search } = Input;
  41. export const getSentIdInArticle = () => {
  42. const sentList: string[] = [];
  43. const sentElement = document.querySelectorAll(".pcd_sent");
  44. for (let index = 0; index < sentElement.length; index++) {
  45. const element = sentElement[index];
  46. const id = element.id.split("_")[1];
  47. sentList.push(id);
  48. }
  49. return sentList;
  50. };
  51. interface IToken {
  52. channelId?: string;
  53. articleId?: string;
  54. type?: ArticleType;
  55. }
  56. interface ChannelTreeNode {
  57. key: string;
  58. title: string | React.ReactNode;
  59. channel: IItem;
  60. icon?: React.ReactNode;
  61. children?: ChannelTreeNode[];
  62. }
  63. interface IWidget {
  64. type?: ArticleType | "editable";
  65. articleId?: string;
  66. selectedKeys?: string[];
  67. style?: React.CSSProperties;
  68. onSelect?: Function;
  69. }
  70. const ChannelMy = ({
  71. type,
  72. articleId,
  73. selectedKeys = [],
  74. style,
  75. onSelect,
  76. }: IWidget) => {
  77. const intl = useIntl();
  78. const [selectedRowKeys, setSelectedRowKeys] =
  79. useState<React.Key[]>(selectedKeys);
  80. const [treeData, setTreeData] = useState<ChannelTreeNode[]>();
  81. const [dirty, setDirty] = useState(false);
  82. const [channels, setChannels] = useState<IItem[]>([]);
  83. const [owner, setOwner] = useState("all");
  84. const [search, setSearch] = useState<string>();
  85. const [loading, setLoading] = useState(true);
  86. const [copyChannel, setCopyChannel] = useState<IChannel>();
  87. const [nissayaOpen, setNissayaOpen] = useState(false);
  88. const [copyOpen, setCopyOpen] = useState<boolean>(false);
  89. const [infoOpen, setInfoOpen] = useState<boolean>(false);
  90. const [statistic, setStatistic] = useState<IItem>();
  91. const [sentenceCount, setSentenceCount] = useState<number>(0);
  92. const [sentencesId, setSentencesId] = useState<string[]>();
  93. const [token, SetToken] = useState<IToken>();
  94. const [tokenOpen, setTokenOpen] = useState(false);
  95. console.debug("ChannelMy render", type, articleId);
  96. //TODO remove useEffect
  97. useEffect(() => {
  98. load();
  99. }, [type, articleId]);
  100. useEffect(() => {
  101. if (selectedRowKeys.join() !== selectedKeys.join()) {
  102. setSelectedRowKeys(selectedKeys);
  103. }
  104. }, [selectedKeys]);
  105. useEffect(() => {
  106. sortChannels(channels);
  107. }, [channels, selectedRowKeys, owner]);
  108. interface IChannelFilter {
  109. key?: string;
  110. owner?: string;
  111. selectedRowKeys?: React.Key[];
  112. }
  113. const sortChannels = (channelList: IItem[], filter?: IChannelFilter) => {
  114. const mOwner = filter?.owner ?? owner;
  115. if (mOwner === "my") {
  116. //我自己的
  117. const myChannel = channelList.filter((value) => value.role === "owner");
  118. const data = myChannel.map((item, _index) => {
  119. return { key: item.uid, title: item.title, channel: item };
  120. });
  121. setTreeData(data);
  122. } else {
  123. //当前被选择的
  124. const selectedChannel: IItem[] = [];
  125. const mSelectedRowKeys = filter?.selectedRowKeys ?? selectedRowKeys;
  126. mSelectedRowKeys.forEach((channelId) => {
  127. const channel = channelList.find((value) => value.uid === channelId);
  128. if (channel) {
  129. selectedChannel.push(channel);
  130. }
  131. });
  132. let show = mSelectedRowKeys;
  133. //有进度的
  134. const progressing = channelList.filter(
  135. (value) => value.progress > 0 && !show.includes(value.uid)
  136. );
  137. show = [...show, ...progressing.map((item) => item.uid)];
  138. //我自己的
  139. const myChannel = channelList.filter(
  140. (value) => value.role === "owner" && !show.includes(value.uid)
  141. );
  142. show = [...show, ...myChannel.map((item) => item.uid)];
  143. //其他的
  144. const others = channelList.filter(
  145. (value) => !show.includes(value.uid) && value.role !== "member"
  146. );
  147. let channelData = [
  148. ...selectedChannel,
  149. ...progressing,
  150. ...myChannel,
  151. ...others,
  152. ];
  153. const key = filter?.key ?? search;
  154. if (key) {
  155. channelData = channelData.filter((value) => value.title.includes(key));
  156. }
  157. const data = channelData.map((item, _index) => {
  158. return { key: item.uid, title: item.title, channel: item };
  159. });
  160. setTreeData(data);
  161. }
  162. };
  163. const load = () => {
  164. let sentList: string[] = [];
  165. if (type === "chapter") {
  166. const id = articleId?.split("-");
  167. if (id?.length === 2) {
  168. const url = `/v2/sentences-in-chapter?book=${id[0]}&para=${id[1]}`;
  169. console.info("ChannelMy url api request", url);
  170. get<ISentInChapterListResponse>(url)
  171. .then((res) => {
  172. console.debug(
  173. "ChannelMy ISentInChapterListResponse api response",
  174. res
  175. );
  176. if (res && res.ok) {
  177. sentList = res.data.rows.map((item) => {
  178. return `${item.book}-${item.paragraph}-${item.word_begin}-${item.word_end}`;
  179. });
  180. setSentencesId(sentList);
  181. loadChannel(sentList);
  182. } else {
  183. console.error("res", res);
  184. }
  185. })
  186. .catch((reason: any) => {
  187. console.error(reason);
  188. });
  189. }
  190. } else {
  191. sentList = getSentIdInArticle();
  192. setSentencesId(sentList);
  193. loadChannel(sentList);
  194. }
  195. };
  196. function loadChannel(sentences: string[]) {
  197. setSentenceCount(sentences.length);
  198. console.debug("sentences", sentences);
  199. const currOwner = "all";
  200. const url = `/v2/channel-progress`;
  201. console.info("api request", url);
  202. setLoading(true);
  203. post<IProgressRequest, IApiResponseChannelList>(url, {
  204. sentence: sentences,
  205. owner: currOwner,
  206. })
  207. .then((res) => {
  208. console.debug("progress data api response", res);
  209. const items: IItem[] = res.data.rows
  210. .filter((value) => value.name.substring(0, 4) !== "_sys")
  211. .map((item, id) => {
  212. const date = new Date(item.created_at);
  213. let all: number = 0;
  214. let finished: number = 0;
  215. item.final?.forEach((value) => {
  216. all += value[0];
  217. finished += value[1] ? value[0] : 0;
  218. });
  219. const progress = finished / all;
  220. return {
  221. id: id,
  222. uid: item.uid,
  223. title: item.name,
  224. summary: item.summary,
  225. studio: item.studio,
  226. shareType: "my",
  227. role: item.role,
  228. type: item.type,
  229. publicity: item.status,
  230. createdAt: date.getTime(),
  231. final: item.final,
  232. progress: progress,
  233. content_created_at: item.content_created_at,
  234. content_updated_at: item.content_updated_at,
  235. };
  236. });
  237. setChannels(items);
  238. })
  239. .finally(() => {
  240. setLoading(false);
  241. });
  242. }
  243. return (
  244. <div style={style}>
  245. <TokenModal
  246. {...token}
  247. open={tokenOpen}
  248. onClose={() => setTokenOpen(false)}
  249. />
  250. <Card
  251. size="small"
  252. title={
  253. <Space>
  254. <Search
  255. placeholder="版本名称"
  256. onSearch={(value) => {
  257. console.debug(value);
  258. setSearch(value);
  259. sortChannels(channels, { key: value });
  260. }}
  261. style={{ width: 120 }}
  262. />
  263. <Select
  264. defaultValue="all"
  265. style={{ width: 80 }}
  266. bordered={false}
  267. options={[
  268. {
  269. value: "all",
  270. label: intl.formatMessage({ id: "buttons.channel.all" }),
  271. },
  272. {
  273. value: "my",
  274. label: intl.formatMessage({ id: "buttons.channel.my" }),
  275. },
  276. ]}
  277. onSelect={(value: string) => {
  278. setOwner(value);
  279. }}
  280. />
  281. </Space>
  282. }
  283. extra={
  284. <Space size={"small"}>
  285. <Button
  286. size="small"
  287. type="link"
  288. disabled={!dirty}
  289. onClick={() => {
  290. if (typeof onSelect !== "undefined") {
  291. setDirty(false);
  292. onSelect(
  293. selectedRowKeys.map((item) => {
  294. return {
  295. id: item,
  296. name: treeData?.find(
  297. (value) => value.channel.uid === item
  298. )?.channel.title,
  299. };
  300. })
  301. );
  302. }
  303. }}
  304. >
  305. {intl.formatMessage({
  306. id: "buttons.ok",
  307. })}
  308. </Button>
  309. <Button
  310. size="small"
  311. type="link"
  312. disabled={!dirty}
  313. onClick={() => {
  314. setSelectedRowKeys(selectedKeys);
  315. setDirty(false);
  316. }}
  317. >
  318. {intl.formatMessage({
  319. id: "buttons.cancel",
  320. })}
  321. </Button>
  322. <Button
  323. type="link"
  324. size="small"
  325. icon={<ReloadOutlined />}
  326. onClick={() => {
  327. load();
  328. }}
  329. />
  330. </Space>
  331. }
  332. >
  333. {loading ? (
  334. <Skeleton active />
  335. ) : (
  336. <Tree
  337. selectedKeys={selectedRowKeys}
  338. multiple
  339. checkedKeys={selectedRowKeys}
  340. checkable
  341. treeData={treeData}
  342. blockNode
  343. onCheck={(
  344. checked: Key[] | { checked: Key[]; halfChecked: Key[] }
  345. ) => {
  346. setDirty(true);
  347. if (Array.isArray(checked)) {
  348. if (checked.length > selectedRowKeys.length) {
  349. const add = checked.filter(
  350. (value) => !selectedRowKeys.includes(value.toString())
  351. );
  352. if (add.length > 0) {
  353. setSelectedRowKeys([...selectedRowKeys, add[0]]);
  354. }
  355. } else {
  356. setSelectedRowKeys(
  357. selectedRowKeys.filter((value) => checked.includes(value))
  358. );
  359. }
  360. }
  361. }}
  362. onSelect={(_keys: Key[]) => {}}
  363. titleRender={(node: ChannelTreeNode) => {
  364. let pIcon = <></>;
  365. switch (node.channel.publicity) {
  366. case 5:
  367. pIcon = (
  368. <Tooltip title={"私有不可公开"}>
  369. <LockFillIcon />
  370. </Tooltip>
  371. );
  372. break;
  373. case 10:
  374. pIcon = (
  375. <Tooltip title={"私有"}>
  376. <LockIcon />
  377. </Tooltip>
  378. );
  379. break;
  380. case 30:
  381. pIcon = (
  382. <Tooltip title={"公开"}>
  383. <GlobalOutlined />
  384. </Tooltip>
  385. );
  386. break;
  387. }
  388. const badge = selectedRowKeys.findIndex(
  389. (value) => value === node.channel.uid
  390. );
  391. return (
  392. <div
  393. style={{
  394. display: "flex",
  395. justifyContent: "space-between",
  396. width: "100%",
  397. }}
  398. >
  399. <div
  400. style={{
  401. width: "100%",
  402. borderRadius: 5,
  403. padding: "0 5px",
  404. }}
  405. onClick={(
  406. _e: React.MouseEvent<HTMLSpanElement, MouseEvent>
  407. ) => {
  408. console.log(node);
  409. if (channels) {
  410. sortChannels(channels);
  411. }
  412. setDirty(false);
  413. if (typeof onSelect !== "undefined") {
  414. onSelect([
  415. {
  416. id: node.key,
  417. name: node.title,
  418. },
  419. ]);
  420. }
  421. }}
  422. >
  423. <div
  424. key="info"
  425. style={{ overflowX: "clip", display: "flex" }}
  426. >
  427. <Space>
  428. {pIcon}
  429. {node.channel.role !== "member" ? (
  430. <EditOutlined />
  431. ) : undefined}
  432. </Space>
  433. <Button type="link">
  434. <Space>
  435. <StudioName data={node.channel.studio} hideName />
  436. <>{node.channel.title}</>
  437. <Tag>
  438. {intl.formatMessage({
  439. id: `channel.type.${node.channel.type}.label`,
  440. })}
  441. </Tag>
  442. </Space>
  443. </Button>
  444. </div>
  445. <div key="progress">
  446. <ProgressSvg data={node.channel.final} width={200} />
  447. </div>
  448. </div>
  449. <Badge count={dirty ? badge + 1 : 0}>
  450. <div>
  451. <Dropdown
  452. trigger={["click"]}
  453. menu={{
  454. items: [
  455. {
  456. key: "copy-to",
  457. label: intl.formatMessage({
  458. id: "buttons.copy.to",
  459. }),
  460. icon: <CopyOutlined />,
  461. },
  462. {
  463. key: "import-nissaya",
  464. label: intl.formatMessage({
  465. id: "buttons.import",
  466. }),
  467. icon: <CopyOutlined />,
  468. },
  469. {
  470. key: "statistic",
  471. label: intl.formatMessage({
  472. id: "buttons.statistic",
  473. }),
  474. icon: <InfoCircleOutlined />,
  475. },
  476. {
  477. key: "token",
  478. label: intl.formatMessage({
  479. id: "buttons.access-token.get",
  480. }),
  481. icon: <InfoCircleOutlined />,
  482. },
  483. ],
  484. onClick: (e) => {
  485. switch (e.key) {
  486. case "copy-to":
  487. setCopyChannel({
  488. id: node.channel.uid,
  489. name: node.channel.title,
  490. type: node.channel.type,
  491. });
  492. setCopyOpen(true);
  493. break;
  494. case "import-nissaya":
  495. setCopyChannel({
  496. id: node.channel.uid,
  497. name: node.channel.title,
  498. type: node.channel.type,
  499. });
  500. setNissayaOpen(true);
  501. break;
  502. case "statistic":
  503. setInfoOpen(true);
  504. setStatistic(node.channel);
  505. break;
  506. case "token":
  507. SetToken({
  508. channelId: node.channel.uid,
  509. type: type as ArticleType,
  510. articleId: articleId,
  511. });
  512. setTokenOpen(true);
  513. break;
  514. default:
  515. break;
  516. }
  517. },
  518. }}
  519. placement="bottomRight"
  520. >
  521. <Button
  522. type="link"
  523. size="small"
  524. icon={<MoreOutlined />}
  525. ></Button>
  526. </Dropdown>
  527. </div>
  528. </Badge>
  529. </div>
  530. );
  531. }}
  532. />
  533. )}
  534. </Card>
  535. <CopyToModal
  536. sentencesId={sentencesId}
  537. channel={copyChannel}
  538. open={copyOpen}
  539. onClose={() => setCopyOpen(false)}
  540. />
  541. <NissayaAlignerModal
  542. sentencesId={sentencesId}
  543. channel={copyChannel}
  544. open={nissayaOpen}
  545. onClose={() => setNissayaOpen(false)}
  546. />
  547. <ChannelInfoModal
  548. sentenceCount={sentenceCount}
  549. channel={statistic}
  550. open={infoOpen}
  551. onClose={() => setInfoOpen(false)}
  552. />
  553. </div>
  554. );
  555. };
  556. export default ChannelMy;