WbwSent.tsx 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260
  1. import { Alert, Button, Dropdown, message, Progress, Space, Tree } from "antd";
  2. import { useEffect, useState, useMemo, useCallback, memo } from "react";
  3. import { MoreOutlined, ExclamationCircleOutlined } from "@ant-design/icons";
  4. import { useAppSelector } from "../../hooks";
  5. import { mode as _mode } from "../../reducers/article-mode";
  6. import { delete_, get, post } from "../../request";
  7. import type { ArticleMode } from "../article/Article";
  8. import WbwWord, {
  9. type IWbw,
  10. type IWbwFields,
  11. type TWbwDisplayMode,
  12. type WbwElement,
  13. WbwStatus,
  14. } from "./Wbw/WbwWord";
  15. import type { TChannelType } from "../../api/Channel";
  16. import type { IDictRequest } from "../../api/Dict";
  17. import { useIntl } from "react-intl";
  18. import { add } from "../../reducers/sent-word";
  19. import store from "../../store";
  20. import { settingInfo } from "../../reducers/setting";
  21. import { GetUserSetting } from "../auth/setting/default";
  22. import { getGrammar } from "../../reducers/term-vocabulary";
  23. import modal from "antd/lib/modal";
  24. import { UserWbwPost } from "../dict/MyCreate";
  25. import { currentUser } from "../../reducers/current-user";
  26. import Studio, { type IStudio } from "../auth/Studio";
  27. import type { IChannel } from "../channel/Channel";
  28. import TimeShow from "../general/TimeShow";
  29. import moment from "moment";
  30. import { courseInfo } from "../../reducers/current-course";
  31. import type { ISentenceWbwListResponse } from "../../api/Corpus";
  32. import type { IDeleteResponse } from "../../api/Article";
  33. import { useWbwStreamProcessor } from "./AIWbw";
  34. import { siteInfo } from "../../reducers/layout";
  35. // ============ 优化工具函数 ============
  36. // 优化1: 使用 Map 缓存 sn 索引,提升查找性能从 O(n) 到 O(1)
  37. const createSnIndexMap = (data: IWbw[]): Map<string, IWbw> => {
  38. const map = new Map<string, IWbw>();
  39. data.forEach((item) => {
  40. map.set(item.sn.join(), item);
  41. });
  42. return map;
  43. };
  44. // 优化2: 缓存字符串拼接结果
  45. const createSnKey = (sn: number[]): string => sn.join();
  46. // 优化3: 提取 paraMark 为纯函数,便于 memoization
  47. export const paraMark = (wbwData: IWbw[]): IWbw[] => {
  48. if (!wbwData || wbwData.length === 0) return wbwData;
  49. let start = false;
  50. let bookCode = "";
  51. let count = 0;
  52. let bookCodeStack: string[] = [];
  53. // 使用浅拷贝而非深拷贝
  54. const result = [...wbwData];
  55. result.forEach((value: IWbw, index: number) => {
  56. if (value.word.value === "(") {
  57. start = true;
  58. bookCode = "";
  59. bookCodeStack = [];
  60. return;
  61. }
  62. if (start) {
  63. if (!isNaN(Number(value.word.value.replaceAll("-", "")))) {
  64. if (bookCode === "" && bookCodeStack.length > 0) {
  65. bookCode = bookCodeStack[0];
  66. }
  67. const dot = bookCode.lastIndexOf(".");
  68. let bookName = "";
  69. if (dot === -1) {
  70. bookName = bookCode;
  71. } else {
  72. bookName = bookCode.substring(0, dot + 1);
  73. }
  74. bookName = bookName.substring(0, 64).toLowerCase();
  75. if (!bookCodeStack.includes(bookName)) {
  76. bookCodeStack.push(bookName);
  77. }
  78. if (bookName !== "") {
  79. result[index] = { ...result[index], bookName };
  80. count++;
  81. }
  82. } else if (value.word.value === ";") {
  83. bookCode = "";
  84. return;
  85. } else if (value.word.value === ")") {
  86. start = false;
  87. return;
  88. }
  89. bookCode += value.word.value;
  90. }
  91. });
  92. if (count > 0) {
  93. console.debug("para mark", count);
  94. }
  95. return result;
  96. };
  97. // 优化4: 提取进度计算为纯函数
  98. export const getWbwProgress = (data: IWbw[], answer?: IWbw[]): number => {
  99. const allWord = data.filter(
  100. (value) =>
  101. value.real.value &&
  102. value.real.value?.length > 0 &&
  103. value.type?.value !== ".ctl."
  104. );
  105. if (allWord.length === 0) return 0;
  106. let final: IWbw[];
  107. if (answer) {
  108. // 使用 Map 优化查找
  109. const answerMap = createSnIndexMap(answer);
  110. final = allWord.filter((value: IWbw) => {
  111. const snKey = createSnKey(value.sn);
  112. const currAnswer = answerMap.get(snKey);
  113. if (!currAnswer) return false;
  114. const checks = [
  115. ["meaning", currAnswer.meaning?.value, value.meaning?.value],
  116. ["factors", currAnswer.factors?.value, value.factors?.value],
  117. [
  118. "factorMeaning",
  119. currAnswer.factorMeaning?.value,
  120. value.factorMeaning?.value,
  121. ],
  122. ["case", currAnswer.case?.value, value.case?.value],
  123. ["parent", currAnswer.parent?.value, value.parent?.value],
  124. ];
  125. return checks.every(([_, answerVal, valueVal]) => {
  126. if (!answerVal) return true;
  127. return valueVal && valueVal.trim().length > 0;
  128. });
  129. });
  130. } else {
  131. final = allWord.filter(
  132. (value) =>
  133. value.meaning?.value &&
  134. value.factors?.value &&
  135. value.factorMeaning?.value &&
  136. value.case?.value
  137. );
  138. }
  139. const finalLen = final.reduce(
  140. (sum, v) => sum + (v.real.value?.length || 0),
  141. 0
  142. );
  143. const allLen = allWord.reduce(
  144. (sum, v) => sum + (v.real.value?.length || 0),
  145. 0
  146. );
  147. return allLen > 0 ? Math.round((finalLen * 100) / allLen) : 0;
  148. };
  149. // ============ 接口定义 ============
  150. interface IMagicDictRequest {
  151. book: number;
  152. para: number;
  153. word_start: number;
  154. word_end: number;
  155. data: IWbw[];
  156. channel_id: string;
  157. lang?: string[];
  158. }
  159. interface IMagicDictResponse {
  160. ok: boolean;
  161. message: string;
  162. data: IWbw[];
  163. }
  164. interface IWbwXml {
  165. id: string;
  166. pali: WbwElement<string>;
  167. real?: WbwElement<string | null>;
  168. type?: WbwElement<string | null>;
  169. gramma?: WbwElement<string | null>;
  170. mean?: WbwElement<string | null>;
  171. org?: WbwElement<string | null>;
  172. om?: WbwElement<string | null>;
  173. case?: WbwElement<string | null>;
  174. parent?: WbwElement<string | null>;
  175. pg?: WbwElement<string | null>;
  176. parent2?: WbwElement<string | null>;
  177. rela?: WbwElement<string | null>;
  178. lock?: boolean;
  179. bmt?: WbwElement<string | null>;
  180. bmc?: WbwElement<number | null>;
  181. cf: number;
  182. }
  183. interface IWbwUpdateResponse {
  184. ok: boolean;
  185. message: string;
  186. data: { rows: IWbw[]; count: number };
  187. }
  188. interface IWbwWord {
  189. words: IWbwXml[];
  190. sn: number;
  191. }
  192. interface IWbwRequest {
  193. book: number;
  194. para: number;
  195. sn: number;
  196. channel_id: string;
  197. data: IWbwWord[];
  198. }
  199. interface IWidget {
  200. data: IWbw[];
  201. answer?: IWbw[];
  202. book: number;
  203. para: number;
  204. wordStart: number;
  205. wordEnd: number;
  206. channel?: IChannel;
  207. channelId: string;
  208. channelType?: TChannelType;
  209. channelLang?: string;
  210. display?: TWbwDisplayMode;
  211. fields?: IWbwFields;
  212. layoutDirection?: "h" | "v";
  213. refreshable?: boolean;
  214. mode?: ArticleMode;
  215. wbwProgress?: boolean;
  216. studio?: IStudio;
  217. readonly?: boolean;
  218. onMagicDictDone?: Function;
  219. onChange?: Function;
  220. }
  221. // ============ 主组件 ============
  222. export const WbwSentCtl = memo(
  223. ({
  224. data,
  225. answer,
  226. channelId,
  227. channelType,
  228. channelLang,
  229. book,
  230. para,
  231. wordStart,
  232. wordEnd,
  233. display = "block",
  234. fields,
  235. layoutDirection = "h",
  236. mode,
  237. refreshable = false,
  238. wbwProgress = false,
  239. readonly = false,
  240. studio,
  241. onChange,
  242. onMagicDictDone,
  243. }: IWidget) => {
  244. const intl = useIntl();
  245. // ============ State ============
  246. const [wordData, setWordData] = useState<IWbw[]>(() => paraMark(data));
  247. const [wbwMode, setWbwMode] = useState(display);
  248. const [fieldDisplay, setFieldDisplay] = useState(fields);
  249. const [displayMode, setDisplayMode] = useState<ArticleMode>();
  250. const [loading, setLoading] = useState(false);
  251. const [showProgress, setShowProgress] = useState(false);
  252. const [check, setCheck] = useState(answer ? true : false);
  253. const [courseAnswer, setCourseAnswer] = useState<IWbw[]>();
  254. const { processStream, isProcessing, wbwData, error } =
  255. useWbwStreamProcessor();
  256. // ============ Selectors ============
  257. const user = useAppSelector(currentUser);
  258. const course = useAppSelector(courseInfo);
  259. const site = useAppSelector(siteInfo);
  260. const settings = useAppSelector(settingInfo);
  261. const newMode = useAppSelector(_mode);
  262. const sysGrammar = useAppSelector(getGrammar)?.filter(
  263. (value) => value.tag === ":collocation:"
  264. );
  265. // ============ Memoized Values ============
  266. // 优化5: 缓存句子ID
  267. const sentId = useMemo(
  268. () => `${book}-${para}-${wordStart}-${wordEnd}`,
  269. [book, para, wordStart, wordEnd]
  270. );
  271. // 优化6: 缓存模型配置
  272. const wbwModel = useMemo(
  273. () => site?.settings?.models?.wbw?.[0] ?? null,
  274. [site?.settings?.models?.wbw]
  275. );
  276. // 优化7: 缓存进度计算
  277. const progress = useMemo(
  278. () => getWbwProgress(wordData, answer),
  279. [wordData, answer]
  280. );
  281. // 优化8: 缓存更新时间
  282. const updatedAt = useMemo(() => {
  283. let latest = moment("1970-1-1");
  284. data.forEach((value) => {
  285. if (moment(value.updated_at).isAfter(latest)) {
  286. latest = moment(value.updated_at);
  287. }
  288. });
  289. return latest;
  290. }, [data]);
  291. // 优化9: 使用 Map 缓存语法匹配
  292. const grammarMap = useMemo(() => {
  293. if (!sysGrammar) return new Map();
  294. const map = new Map<string, string>();
  295. sysGrammar.forEach((g) => {
  296. g.word.split("...").forEach((word) => {
  297. map.set(word, g.guid ?? "1");
  298. });
  299. });
  300. return map;
  301. }, [sysGrammar]);
  302. // ============ Callbacks ============
  303. // 优化10: 使用 useCallback 缓存回调函数
  304. const update = useCallback(
  305. (data: IWbw[], replace: boolean = true) => {
  306. if (replace) {
  307. setWordData(paraMark(data));
  308. } else {
  309. setWordData((origin) => {
  310. const dataMap = createSnIndexMap(data);
  311. return origin.map((value) => {
  312. const snKey = createSnKey(value.sn);
  313. const newOne = dataMap.get(snKey);
  314. if (newOne) return newOne;
  315. // 检查 real.value 匹配
  316. const byReal = data.find(
  317. (d) => d.real.value === value.real.value
  318. );
  319. return byReal || value;
  320. });
  321. });
  322. }
  323. if (typeof onChange !== "undefined") {
  324. onChange(data);
  325. }
  326. },
  327. [onChange]
  328. );
  329. const wbwToXml = useCallback(
  330. (item: IWbw) => {
  331. return {
  332. pali: item.word,
  333. real: item.real,
  334. id: `${book}-${para}-${createSnKey(item.sn).replace(/,/g, "-")}`,
  335. type: item.type,
  336. gramma: item.grammar,
  337. mean: item.meaning
  338. ? {
  339. value: item.meaning.value,
  340. status: item.meaning?.status,
  341. }
  342. : undefined,
  343. org: item.factors,
  344. om: item.factorMeaning,
  345. case: item.case,
  346. parent: item.parent,
  347. pg: item.grammar2,
  348. parent2: item.parent2,
  349. rela: item.relation,
  350. lock: item.locked,
  351. note: item.note,
  352. bmt: item.bookMarkText,
  353. bmc: item.bookMarkColor,
  354. attachments: JSON.stringify(item.attachments),
  355. cf: item.confidence,
  356. };
  357. },
  358. [book, para]
  359. );
  360. const postWord = useCallback((postParam: IWbwRequest) => {
  361. const url = `/v2/wbw`;
  362. console.info("wbw api request", url, postParam);
  363. post<IWbwRequest, IWbwUpdateResponse>(url, postParam).then((json) => {
  364. console.info("wbw api response", json);
  365. if (json.ok) {
  366. message.info(json.data.count + " updated");
  367. setWordData(paraMark(json.data.rows));
  368. } else {
  369. message.error(json.message);
  370. }
  371. });
  372. }, []);
  373. const saveWbwAll = useCallback(
  374. (wbwData: IWbw[]) => {
  375. const snSet = new Set<number>();
  376. wbwData.forEach((value) => {
  377. snSet.add(value.sn[0]);
  378. });
  379. const arrSn = Array.from(snSet);
  380. const postParam: IWbwRequest = {
  381. book: book,
  382. para: para,
  383. channel_id: channelId,
  384. sn: wbwData[0].sn[0],
  385. data: arrSn.map((item) => {
  386. return {
  387. sn: item,
  388. words: wbwData
  389. .filter((value) => value.sn[0] === item)
  390. .map(wbwToXml),
  391. };
  392. }),
  393. };
  394. postWord(postParam);
  395. },
  396. [book, para, channelId, wbwToXml, postWord]
  397. );
  398. const saveWord = useCallback(
  399. (wbwData: IWbw[], sn: number) => {
  400. if (channelType === "nissaya") {
  401. return;
  402. }
  403. const data = wbwData.filter((value) => value.sn[0] === sn);
  404. const postParam: IWbwRequest = {
  405. book: book,
  406. para: para,
  407. channel_id: channelId,
  408. sn: sn,
  409. data: [
  410. {
  411. sn: sn,
  412. words: data.map(wbwToXml),
  413. },
  414. ],
  415. };
  416. postWord(postParam);
  417. },
  418. [channelType, book, para, channelId, wbwToXml, postWord]
  419. );
  420. const magicDictLookup = useCallback(() => {
  421. const _lang = GetUserSetting("setting.dict.lang", settings);
  422. const url = `/v2/wbwlookup`;
  423. post<IMagicDictRequest, IMagicDictResponse>(url, {
  424. book: book,
  425. para: para,
  426. word_start: wordStart,
  427. word_end: wordEnd,
  428. data: wordData,
  429. channel_id: channelId,
  430. lang: _lang?.toString().split(","),
  431. })
  432. .then((json) => {
  433. if (json.ok) {
  434. console.log("magic dict result", json.data);
  435. update(json.data);
  436. if (channelType !== "nissaya") {
  437. saveWbwAll(json.data);
  438. }
  439. } else {
  440. console.error(json.message);
  441. }
  442. })
  443. .finally(() => {
  444. setLoading(false);
  445. if (typeof onMagicDictDone !== "undefined") {
  446. onMagicDictDone();
  447. }
  448. });
  449. }, [
  450. settings,
  451. book,
  452. para,
  453. wordStart,
  454. wordEnd,
  455. wordData,
  456. channelId,
  457. channelType,
  458. update,
  459. saveWbwAll,
  460. onMagicDictDone,
  461. ]);
  462. const wbwPublish = useCallback(
  463. (wbwData: IWbw[], isPublic: boolean) => {
  464. const wordData: IDictRequest[] = [];
  465. wbwData.forEach((data) => {
  466. if (
  467. (typeof data.meaning?.value === "string" &&
  468. data.meaning?.value.trim().length > 0) ||
  469. (typeof data.factorMeaning?.value === "string" &&
  470. data.factorMeaning.value.trim().length > 0)
  471. ) {
  472. const [wordType, wordGrammar] = data.case?.value
  473. ? data.case?.value?.split("#")
  474. : ["", ""];
  475. let conf = data.confidence * 100;
  476. if (data.confidence.toString() === "0.5") {
  477. conf = 100;
  478. }
  479. wordData.push({
  480. word: data.real.value ? data.real.value : "",
  481. type: wordType,
  482. grammar: wordGrammar,
  483. mean: data.meaning?.value,
  484. parent: data.parent?.value,
  485. factors: data.factors?.value,
  486. factormean: data.factorMeaning?.value,
  487. note: data.note?.value,
  488. confidence: conf,
  489. language: channelLang,
  490. status: isPublic ? 30 : 5,
  491. });
  492. }
  493. });
  494. UserWbwPost(wordData, "wbw")
  495. .finally(() => {
  496. setLoading(false);
  497. })
  498. .then((json) => {
  499. if (json.ok) {
  500. message.success(
  501. "wbw " + intl.formatMessage({ id: "flashes.success" })
  502. );
  503. } else {
  504. message.error(json.message);
  505. }
  506. });
  507. },
  508. [channelLang, intl]
  509. );
  510. const resetWbw = useCallback(() => {
  511. const newData: IWbw[] = [];
  512. let count = 0;
  513. wordData.forEach((value: IWbw) => {
  514. if (
  515. value.type?.value !== null &&
  516. value.type?.value !== ".ctl." &&
  517. value.real.value &&
  518. value.real.value.length > 0
  519. ) {
  520. count++;
  521. newData.push({
  522. uid: value.uid,
  523. book: value.book,
  524. para: value.para,
  525. sn: value.sn,
  526. word: value.word,
  527. real: value.real,
  528. style: value.style,
  529. meaning: { value: "", status: 7 },
  530. type: { value: "", status: 7 },
  531. grammar: { value: "", status: 7 },
  532. grammar2: { value: "", status: 7 },
  533. parent: { value: "", status: 7 },
  534. parent2: { value: "", status: 7 },
  535. case: { value: "", status: 7 },
  536. factors: { value: "", status: 7 },
  537. factorMeaning: { value: "", status: 7 },
  538. confidence: value.confidence,
  539. });
  540. } else {
  541. newData.push(value);
  542. }
  543. });
  544. message.info(`已经重置${count}个`);
  545. update(newData);
  546. saveWbwAll(newData);
  547. }, [wordData, update, saveWbwAll]);
  548. const deleteWbw = useCallback(() => {
  549. const url = `/v2/wbw-sentence/${sentId}?channel=${channelId}`;
  550. console.info("api request", url);
  551. setLoading(true);
  552. delete_<IDeleteResponse>(url)
  553. .then((json) => {
  554. console.debug("api response", json);
  555. if (json.ok) {
  556. message.success(
  557. intl.formatMessage(
  558. { id: "message.delete.success" },
  559. { count: json.data }
  560. )
  561. );
  562. } else {
  563. message.error(json.message);
  564. }
  565. })
  566. .finally(() => setLoading(false))
  567. .catch((e) => console.log("Oops errors!", e));
  568. }, [sentId, channelId, intl]);
  569. const loadAnswer = useCallback(() => {
  570. if (courseAnswer || !course) {
  571. return;
  572. }
  573. let url = `/v2/wbw-sentence?view=course-answer`;
  574. url += `&book=${book}&para=${para}&wordStart=${wordStart}&wordEnd=${wordEnd}`;
  575. url += `&course=${course.courseId}`;
  576. setLoading(true);
  577. console.info("wbw sentence api request", url);
  578. get<ISentenceWbwListResponse>(url)
  579. .then((json) => {
  580. console.info("wbw sentence api response", json);
  581. if (json.ok) {
  582. if (json.data.rows.length > 0 && json.data.rows[0].origin) {
  583. const response = json.data.rows[0].origin[0];
  584. setCourseAnswer(
  585. response ? JSON.parse(response.content ?? "") : undefined
  586. );
  587. }
  588. }
  589. })
  590. .finally(() => setLoading(false));
  591. }, [courseAnswer, course, book, para, wordStart, wordEnd]);
  592. // ============ Effects ============
  593. // 优化11: 合并 AI 数据更新到单个 effect
  594. useEffect(() => {
  595. if (wbwData.length === 0) return;
  596. setWordData((origin) => {
  597. const wbwMap = createSnIndexMap(wbwData);
  598. return origin.map((item) => {
  599. const snKey = createSnKey(item.sn);
  600. const aiWbw =
  601. wbwMap.get(snKey) ||
  602. wbwData.find((v) => v.real.value === item.real.value);
  603. if (!aiWbw) return item;
  604. const newItem = { ...item };
  605. if (newItem.meaning && aiWbw.meaning) {
  606. newItem.meaning = {
  607. ...newItem.meaning,
  608. value: aiWbw.meaning.value,
  609. };
  610. }
  611. if (newItem.factors && aiWbw.factors) {
  612. newItem.factors = {
  613. ...newItem.factors,
  614. value: aiWbw.factors.value,
  615. };
  616. }
  617. if (newItem.factorMeaning && aiWbw.factorMeaning) {
  618. newItem.factorMeaning = {
  619. ...newItem.factorMeaning,
  620. value: aiWbw.factorMeaning.value,
  621. };
  622. }
  623. if (newItem.parent && aiWbw.parent?.value) {
  624. newItem.parent = { ...newItem.parent, value: aiWbw.parent.value };
  625. }
  626. if (newItem.type && aiWbw.type?.value) {
  627. newItem.type = {
  628. ...newItem.type,
  629. value: aiWbw.type.value.replaceAll(" ", ""),
  630. };
  631. if (newItem.grammar && aiWbw.grammar?.value) {
  632. newItem.grammar = {
  633. ...newItem.grammar,
  634. value: aiWbw.grammar.value.replaceAll(" ", ""),
  635. };
  636. if (newItem.case?.value === "") {
  637. newItem.case = {
  638. ...newItem.case,
  639. value: `${aiWbw.type.value}#${aiWbw.grammar.value}`,
  640. };
  641. }
  642. }
  643. }
  644. return newItem;
  645. });
  646. });
  647. }, [wbwData]);
  648. useEffect(() => setShowProgress(wbwProgress), [wbwProgress]);
  649. useEffect(() => {
  650. if (refreshable) {
  651. setWordData(paraMark(data));
  652. }
  653. }, [data, refreshable]);
  654. // 优化12: 优化单词发布逻辑
  655. useEffect(() => {
  656. const words = new Set<string>();
  657. wordData
  658. .filter(
  659. (value) =>
  660. value.type?.value !== null &&
  661. value.type?.value !== ".ctl." &&
  662. value.real.value &&
  663. value.real.value.length > 0
  664. )
  665. .forEach((value) => {
  666. if (value.real.value) {
  667. words.add(value.real.value);
  668. }
  669. if (value.parent?.value) {
  670. words.add(value.parent.value);
  671. }
  672. });
  673. const pubWords = Array.from(words);
  674. store.dispatch(add({ sentId, words: pubWords }));
  675. }, [sentId, wordData]);
  676. useEffect(() => {
  677. let currMode: ArticleMode | undefined;
  678. if (typeof mode !== "undefined") {
  679. currMode = mode;
  680. } else if (typeof newMode !== "undefined") {
  681. if (typeof newMode.id === "undefined") {
  682. currMode = newMode.mode;
  683. } else {
  684. const sentId = newMode.id.split("-");
  685. if (sentId.length === 2) {
  686. if (book === parseInt(sentId[0]) && para === parseInt(sentId[1])) {
  687. currMode = newMode.mode;
  688. }
  689. }
  690. }
  691. }
  692. setDisplayMode(currMode);
  693. switch (currMode) {
  694. case "edit":
  695. if (typeof display === "undefined") {
  696. setWbwMode("block");
  697. }
  698. if (typeof fields === "undefined") {
  699. setFieldDisplay({
  700. meaning: true,
  701. factors: false,
  702. factorMeaning: false,
  703. case: false,
  704. });
  705. }
  706. break;
  707. case "wbw":
  708. if (typeof display === "undefined") {
  709. setWbwMode("block");
  710. }
  711. if (typeof fields === "undefined") {
  712. setFieldDisplay({
  713. meaning: true,
  714. factors: true,
  715. factorMeaning: true,
  716. case: true,
  717. });
  718. }
  719. break;
  720. }
  721. }, [newMode, mode, book, para, display, fields]);
  722. // ============ Render Logic ============
  723. const wordSplit = useCallback(
  724. (id: number, hyphen = "-") => {
  725. let factors = wordData[id]?.factors?.value;
  726. if (typeof factors !== "string") return;
  727. let sFm = wordData[id]?.factorMeaning?.value;
  728. if (typeof sFm === "undefined" || sFm === null) {
  729. sFm = new Array(factors.split("+").length).fill("").join("+");
  730. }
  731. if (wordData[id].case?.value?.split("#")[0] === ".un.") {
  732. factors = `[+${factors}+]`;
  733. sFm = `+${sFm}+`;
  734. } else if (hyphen !== "") {
  735. factors = factors.replaceAll("+", `+${hyphen}+`);
  736. sFm = sFm.replaceAll("+", `+${hyphen}+`);
  737. }
  738. const fm = sFm.split("+");
  739. const children: IWbw[] = factors.split("+").map((item, index) => {
  740. return {
  741. word: { value: item, status: 5 },
  742. real: {
  743. value: item
  744. .replaceAll("-", "")
  745. .replaceAll("[", "")
  746. .replaceAll("]", ""),
  747. status: 5,
  748. },
  749. meaning: { value: fm[index], status: 5 },
  750. book: wordData[id].book,
  751. para: wordData[id].para,
  752. sn: [...wordData[id].sn, index],
  753. confidence: 1,
  754. };
  755. });
  756. console.log("children", children);
  757. const newData: IWbw[] = [...wordData];
  758. newData.splice(id + 1, 0, ...children);
  759. console.log("new-data", newData);
  760. update(newData);
  761. saveWord(newData, wordData[id].sn[0]);
  762. },
  763. [wordData, update, saveWord]
  764. );
  765. // 优化13: 使用 memo 包装 WbwWord 渲染
  766. const wbwRender = useCallback(
  767. (
  768. item: IWbw,
  769. id: number,
  770. options?: { studio?: IStudio; answer?: IWbw }
  771. ) => {
  772. console.log("test wbw word render", item.word.value);
  773. return (
  774. <WbwWord
  775. data={item}
  776. answer={options?.answer}
  777. channelId={channelId}
  778. key={id}
  779. mode={displayMode}
  780. display={wbwMode}
  781. fields={fieldDisplay}
  782. studio={studio}
  783. readonly={readonly}
  784. onChange={(e: IWbw, isPublish: boolean, isPublic: boolean) => {
  785. setWordData((origin) => {
  786. const newData = [...origin];
  787. const snKey = createSnKey(e.sn);
  788. // 更新当前单词
  789. const index = newData.findIndex(
  790. (v) => createSnKey(v.sn) === snKey
  791. );
  792. if (index !== -1) {
  793. newData[index] = e;
  794. }
  795. // 如果是拆分后的单词,更新父单词的 factorMeaning
  796. if (e.sn.length > 1) {
  797. const parentSn = e.sn.slice(0, e.sn.length - 1);
  798. const parentSnKey = createSnKey(parentSn);
  799. const factorMeaning = newData
  800. .filter(
  801. (value) =>
  802. value.sn.length === e.sn.length &&
  803. createSnKey(value.sn.slice(0, e.sn.length - 1)) ===
  804. parentSnKey &&
  805. value.real.value &&
  806. value.real.value.length > 0
  807. )
  808. .map((item) => item.meaning?.value)
  809. .join("+");
  810. const parentIndex = newData.findIndex(
  811. (v) => createSnKey(v.sn) === parentSnKey
  812. );
  813. if (parentIndex !== -1) {
  814. newData[parentIndex] = {
  815. ...newData[parentIndex],
  816. factorMeaning: {
  817. value: factorMeaning,
  818. status: 5,
  819. },
  820. };
  821. if (
  822. newData[parentIndex].meaning?.status !== WbwStatus.manual
  823. ) {
  824. newData[parentIndex].meaning = {
  825. value: factorMeaning.replaceAll("+", " "),
  826. status: 5,
  827. };
  828. }
  829. }
  830. }
  831. return newData;
  832. });
  833. // 延迟保存以批量处理
  834. setTimeout(() => {
  835. saveWord(wordData, e.sn[0]);
  836. }, 100);
  837. if (isPublish === true) {
  838. wbwPublish([e], isPublic);
  839. }
  840. }}
  841. onSplit={() => {
  842. const hasChildren =
  843. id < wordData.length - 1 &&
  844. createSnKey(wordData[id + 1].sn).startsWith(
  845. createSnKey(wordData[id].sn) + ","
  846. );
  847. if (hasChildren) {
  848. // 合并
  849. console.log("合并");
  850. const parentSnKey = createSnKey(wordData[id].sn);
  851. const compactData = wordData.filter((value, index) => {
  852. if (index === id) return true;
  853. return !createSnKey(value.sn).startsWith(parentSnKey + ",");
  854. });
  855. update(compactData);
  856. saveWord(compactData, wordData[id].sn[0]);
  857. } else {
  858. // 拆开
  859. console.log("拆开");
  860. wordSplit(id);
  861. }
  862. }}
  863. />
  864. );
  865. },
  866. [
  867. channelId,
  868. displayMode,
  869. wbwMode,
  870. fieldDisplay,
  871. studio,
  872. readonly,
  873. wordData,
  874. saveWord,
  875. wbwPublish,
  876. update,
  877. wordSplit,
  878. ]
  879. );
  880. // 优化14: 缓存处理后的渲染数据
  881. const enrichedWordData = useMemo(() => {
  882. return wordData.map((item) => {
  883. // 检查是否有 AI 更新
  884. const snKey = createSnKey(item.sn);
  885. const newData = wbwData.find(
  886. (v) => createSnKey(v.sn) === snKey || v.real.value === item.real.value
  887. );
  888. let enrichedItem = newData ?? item;
  889. // 添加语法匹配
  890. const spell = enrichedItem.real.value;
  891. if (spell) {
  892. const grammarId = grammarMap.get(spell);
  893. if (grammarId) {
  894. enrichedItem = { ...enrichedItem, grammarId };
  895. }
  896. }
  897. return enrichedItem;
  898. });
  899. }, [wordData, wbwData, grammarMap]);
  900. // 优化15: 缓存水平布局渲染
  901. const horizontalLayout = useMemo(() => {
  902. if (layoutDirection !== "h") return null;
  903. const aa = courseAnswer ?? answer;
  904. const answerMap = aa ? createSnIndexMap(aa) : null;
  905. return enrichedWordData.map((item, id) => {
  906. const currAnswer = answerMap?.get(createSnKey(item.sn));
  907. return wbwRender(item, id, {
  908. studio: studio,
  909. answer: check ? currAnswer : undefined,
  910. });
  911. });
  912. }, [
  913. layoutDirection,
  914. enrichedWordData,
  915. courseAnswer,
  916. answer,
  917. check,
  918. studio,
  919. wbwRender,
  920. ]);
  921. // 优化16: 缓存树形布局数据
  922. const treeData = useMemo(() => {
  923. if (layoutDirection !== "v") return null;
  924. return wordData
  925. .filter((value) => value.sn.length === 1)
  926. .map((item, id) => {
  927. const children = wordData.filter(
  928. (value) => value.sn.length === 2 && value.sn[0] === item.sn[0]
  929. );
  930. return {
  931. title: wbwRender(item, id),
  932. key: createSnKey(item.sn),
  933. isLeaf: !item.factors?.value?.includes("+"),
  934. children:
  935. children.length > 0
  936. ? children.map((childItem, childId) => ({
  937. title: wbwRender(childItem, childId),
  938. key: createSnKey(childItem.sn),
  939. isLeaf: true,
  940. }))
  941. : undefined,
  942. };
  943. });
  944. }, [layoutDirection, wordData, wbwRender]);
  945. // 菜单项配置
  946. const menuItems = useMemo(
  947. () => [
  948. {
  949. key: "magic-dict-current",
  950. label: intl.formatMessage({ id: "buttons.magic-dict" }),
  951. },
  952. {
  953. key: "ai-magic-dict-current",
  954. label: "ai-magic-dict",
  955. disabled: !wbwModel,
  956. },
  957. {
  958. key: "progress",
  959. label: "显示/隐藏进度条",
  960. },
  961. {
  962. key: "check",
  963. label: "显示/隐藏错误提示",
  964. },
  965. {
  966. key: "wbw-dict-publish-all",
  967. label: "发布全部单词",
  968. },
  969. {
  970. type: "divider" as const,
  971. },
  972. {
  973. key: "copy-text",
  974. label: intl.formatMessage({ id: "buttons.copy.pali.text" }),
  975. },
  976. {
  977. key: "reset",
  978. label: intl.formatMessage({ id: "buttons.reset.wbw" }),
  979. danger: true,
  980. },
  981. {
  982. type: "divider" as const,
  983. },
  984. {
  985. key: "delete",
  986. label: intl.formatMessage({ id: "buttons.delete.wbw.sentence" }),
  987. danger: true,
  988. disabled: true,
  989. },
  990. ],
  991. [intl, wbwModel]
  992. );
  993. const handleMenuClick = useCallback(
  994. ({ key }: { key: string }) => {
  995. console.log(`Click on item ${key}`);
  996. switch (key) {
  997. case "magic-dict-current":
  998. setLoading(true);
  999. magicDictLookup();
  1000. break;
  1001. case "ai-magic-dict-current":
  1002. if (wbwModel) {
  1003. processStream(wbwModel.uid, wordData);
  1004. }
  1005. break;
  1006. case "wbw-dict-publish-all":
  1007. wbwPublish(wordData, user?.roles?.includes("basic") ? false : true);
  1008. break;
  1009. case "copy-text": {
  1010. const paliText = wordData
  1011. .filter((value) => value.type?.value !== ".ctl.")
  1012. .map((item) => item.word.value)
  1013. .join(" ");
  1014. navigator.clipboard.writeText(paliText).then(() => {
  1015. message.success("已经拷贝到剪贴板");
  1016. });
  1017. break;
  1018. }
  1019. case "progress":
  1020. setShowProgress((origin) => !origin);
  1021. break;
  1022. case "check":
  1023. loadAnswer();
  1024. setCheck(!check);
  1025. break;
  1026. case "reset":
  1027. modal.confirm({
  1028. title: "清除逐词解析数据",
  1029. icon: <ExclamationCircleOutlined />,
  1030. content: "清除这个句子的逐词解析数据,此操作不可恢复",
  1031. okText: "确认",
  1032. cancelText: "取消",
  1033. onOk: resetWbw,
  1034. });
  1035. break;
  1036. case "delete":
  1037. modal.confirm({
  1038. title: "清除逐词解析数据",
  1039. icon: <ExclamationCircleOutlined />,
  1040. content: "删除整句的逐词解析数据,此操作不可恢复",
  1041. okText: "确认",
  1042. cancelText: "取消",
  1043. onOk: deleteWbw,
  1044. });
  1045. break;
  1046. }
  1047. },
  1048. [
  1049. magicDictLookup,
  1050. wbwModel,
  1051. processStream,
  1052. wordData,
  1053. wbwPublish,
  1054. user,
  1055. loadAnswer,
  1056. check,
  1057. resetWbw,
  1058. deleteWbw,
  1059. ]
  1060. );
  1061. // ============ Render ============
  1062. return (
  1063. <div style={{ width: "100%" }}>
  1064. <div
  1065. style={{
  1066. display: showProgress ? "flex" : "none",
  1067. justifyContent: "space-between",
  1068. }}
  1069. >
  1070. <div className="progress" style={{ width: 400 }}>
  1071. <Progress percent={progress} size="small" />
  1072. </div>
  1073. <Space>
  1074. <Studio data={studio} hideAvatar />
  1075. <TimeShow updatedAt={updatedAt.toString()} />
  1076. </Space>
  1077. </div>
  1078. {error && <Alert message={error} />}
  1079. {isProcessing && (
  1080. <div>
  1081. <Progress
  1082. percent={Math.round((wbwData.length * 100) / wordData.length)}
  1083. size="small"
  1084. />
  1085. </div>
  1086. )}
  1087. <div className={`layout-${layoutDirection}`}>
  1088. <Dropdown
  1089. menu={{
  1090. items: menuItems,
  1091. onClick: handleMenuClick,
  1092. }}
  1093. placement="bottomLeft"
  1094. >
  1095. <Button
  1096. loading={loading}
  1097. onClick={(e) => e.preventDefault()}
  1098. icon={<MoreOutlined />}
  1099. size="small"
  1100. type="text"
  1101. style={{ backgroundColor: "lightblue", opacity: 0.3 }}
  1102. />
  1103. </Dropdown>
  1104. {layoutDirection === "h" ? (
  1105. horizontalLayout
  1106. ) : (
  1107. <Tree
  1108. selectable={true}
  1109. blockNode
  1110. treeData={treeData || []}
  1111. loadData={({ key }: any) =>
  1112. new Promise<void>((resolve) => {
  1113. const index = wordData.findIndex(
  1114. (item) => createSnKey(item.sn) === key
  1115. );
  1116. if (index !== -1) {
  1117. wordSplit(index, "");
  1118. }
  1119. resolve();
  1120. })
  1121. }
  1122. />
  1123. )}
  1124. </div>
  1125. </div>
  1126. );
  1127. }
  1128. );
  1129. WbwSentCtl.displayName = "WbwSentCtl";
  1130. // ============ Widget 组件 ============
  1131. interface IWidgetWbwSent {
  1132. props: string;
  1133. }
  1134. const WbwSentWidget = memo(({ props }: IWidgetWbwSent) => {
  1135. const prop = useMemo(() => JSON.parse(atob(props)) as IWidget, [props]);
  1136. return <WbwSentCtl {...prop} />;
  1137. });
  1138. WbwSentWidget.displayName = "WbwSentWidget";
  1139. export default WbwSentWidget;