db-editor.tsx 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674
  1. import { sendGetRequest, sendSpacePostRequest } from '@/utils/request';
  2. import Icon from '@ant-design/icons';
  3. import { OnChange } from '@monaco-editor/react';
  4. import { useRequest } from 'ahooks';
  5. import { Button, Input, Select, Table, Tooltip, Tree } from 'antd';
  6. import type { DataNode } from 'antd/es/tree';
  7. import { useSearchParams } from 'next/navigation';
  8. import { ChangeEvent, Key, useEffect, useMemo, useState } from 'react';
  9. import Chart from '../chart';
  10. import Header from './header';
  11. import MonacoEditor, { ISession } from './monaco-editor';
  12. import SplitScreenHeight from '@/components/icons/split-screen-height';
  13. import SplitScreenWeight from '@/components/icons/split-screen-width';
  14. import { CaretRightOutlined, LeftOutlined, RightOutlined, SaveFilled } from '@ant-design/icons';
  15. import { ColumnType } from 'antd/es/table';
  16. import classNames from 'classnames';
  17. import MyEmpty from '../common/MyEmpty';
  18. import Database from '../icons/database';
  19. import Field from '../icons/field';
  20. import TableIcon from '../icons/table';
  21. const { Search } = Input;
  22. type ITableData = {
  23. columns: string[];
  24. values: (string | number)[][];
  25. };
  26. interface EditorValueProps {
  27. sql?: string;
  28. thoughts?: string;
  29. title?: string;
  30. showcase?: string;
  31. }
  32. interface RoundProps {
  33. db_name: string;
  34. round: number;
  35. round_name: string;
  36. }
  37. interface IProps {
  38. editorValue?: EditorValueProps;
  39. chartData?: any;
  40. tableData?: ITableData;
  41. layout?: 'TB' | 'LR';
  42. tables?: any;
  43. handleChange: OnChange;
  44. }
  45. interface ITableTreeItem {
  46. title: string;
  47. key: string;
  48. type: string;
  49. default_value: string | null;
  50. can_null: string;
  51. comment: string | null;
  52. children: Array<ITableTreeItem>;
  53. }
  54. function DbEditorContent({ layout = 'LR', editorValue, chartData, tableData, tables, handleChange }: IProps) {
  55. const chartWrapper = useMemo(() => {
  56. if (!chartData) return null;
  57. return (
  58. <div className='flex-1 overflow-auto p-2' style={{ flexShrink: 0, overflow: 'hidden' }}>
  59. <Chart chartsData={[chartData]} />
  60. </div>
  61. );
  62. }, [chartData]);
  63. const { columns, dataSource } = useMemo<{
  64. columns: ColumnType<any>[];
  65. dataSource: Record<string, string | number>[];
  66. }>(() => {
  67. const { columns: cols = [], values: vals = [] } = tableData ?? {};
  68. const tbCols = cols.map<ColumnType<any>>(item => ({
  69. key: item,
  70. dataIndex: item,
  71. title: item,
  72. }));
  73. const tbDatas = vals.map(row => {
  74. return row.reduce<Record<string, string | number>>((acc, item, index) => {
  75. acc[cols[index]] = item;
  76. return acc;
  77. }, {});
  78. });
  79. return {
  80. columns: tbCols,
  81. dataSource: tbDatas,
  82. };
  83. }, [tableData]);
  84. const session: ISession = useMemo(() => {
  85. const map: Record<string, { columnName: string; columnType: string }[]> = {};
  86. const db = tables?.data;
  87. const tableList = db?.children;
  88. tableList?.forEach((table: ITableTreeItem) => {
  89. map[table.title] = table.children.map((column: ITableTreeItem) => {
  90. return {
  91. columnName: column.title,
  92. columnType: column.type,
  93. };
  94. });
  95. });
  96. return {
  97. async getTableList(schemaName: any) {
  98. if (schemaName && schemaName !== db?.title) {
  99. return [];
  100. }
  101. return tableList?.map((table: ITableTreeItem) => table.title) || [];
  102. },
  103. async getTableColumns(tableName: any) {
  104. return map[tableName] || [];
  105. },
  106. async getSchemaList() {
  107. return db?.title ? [db?.title] : [];
  108. },
  109. };
  110. }, [tables]);
  111. return (
  112. <div
  113. className={classNames('flex w-full flex-1 h-full gap-2 overflow-hidden', {
  114. 'flex-col': layout === 'TB',
  115. 'flex-row': layout === 'LR',
  116. })}
  117. >
  118. <div className='flex-1 flex overflow-hidden rounded'>
  119. <MonacoEditor
  120. value={editorValue?.sql || ''}
  121. language='mysql'
  122. onChange={handleChange}
  123. thoughts={editorValue?.thoughts || ''}
  124. session={session}
  125. />
  126. </div>
  127. <div className='flex-1 h-full overflow-auto bg-white dark:bg-theme-dark-container rounded p-4'>
  128. {tableData?.values.length ? (
  129. <Table bordered scroll={{ x: 'auto' }} rowKey={columns[0].key} columns={columns} dataSource={dataSource} />
  130. ) : (
  131. <div className='h-full flex justify-center items-center'>
  132. <MyEmpty />
  133. </div>
  134. )}
  135. {chartWrapper}
  136. </div>
  137. </div>
  138. );
  139. }
  140. function DbEditor() {
  141. const [expandedKeys, setExpandedKeys] = useState<Key[]>([]);
  142. const [searchValue, setSearchValue] = useState('');
  143. const [currentRound, setCurrentRound] = useState<null | string | number>();
  144. const [autoExpandParent, setAutoExpandParent] = useState(true);
  145. const [chartData, setChartData] = useState<any>();
  146. const [editorValue, setEditorValue] = useState<EditorValueProps | EditorValueProps[]>();
  147. const [newEditorValue, setNewEditorValue] = useState<EditorValueProps>();
  148. const [tableData, setTableData] = useState<{ columns: string[]; values: (string | number)[] }>();
  149. const [currentTabIndex, setCurrentTabIndex] = useState<number>();
  150. const [isMenuExpand, setIsMenuExpand] = useState<boolean>(false);
  151. const [layout, setLayout] = useState<'TB' | 'LR'>('TB');
  152. const searchParams = useSearchParams();
  153. const id = searchParams?.get('id');
  154. const scene = searchParams?.get('scene');
  155. const { data: rounds } = useRequest(
  156. async () =>
  157. await sendGetRequest('/v1/editor/sql/rounds', {
  158. con_uid: id,
  159. }),
  160. {
  161. onSuccess: res => {
  162. const lastItem = res?.data?.[res?.data?.length - 1];
  163. if (lastItem) {
  164. setCurrentRound(lastItem?.round);
  165. }
  166. },
  167. },
  168. );
  169. const { run: runSql, loading: runLoading } = useRequest(
  170. async () => {
  171. const db_name = rounds?.data?.find((item: any) => item.round === currentRound)?.db_name;
  172. return await sendSpacePostRequest(`/api/v1/editor/sql/run`, {
  173. db_name,
  174. sql: newEditorValue?.sql,
  175. });
  176. },
  177. {
  178. manual: true,
  179. onSuccess: res => {
  180. setTableData({
  181. columns: res?.data?.colunms,
  182. values: res?.data?.values,
  183. });
  184. },
  185. },
  186. );
  187. const { run: runCharts, loading: runChartsLoading } = useRequest(
  188. async () => {
  189. const db_name = rounds?.data?.find((item: any) => item.round === currentRound)?.db_name;
  190. const params: {
  191. db_name: string;
  192. sql?: string;
  193. chart_type?: string;
  194. } = {
  195. db_name,
  196. sql: newEditorValue?.sql,
  197. };
  198. if (scene === 'chat_dashboard') {
  199. params['chart_type'] = newEditorValue?.showcase;
  200. }
  201. return await sendSpacePostRequest(`/api/v1/editor/chart/run`, params);
  202. },
  203. {
  204. manual: true,
  205. ready: !!newEditorValue?.sql,
  206. onSuccess: res => {
  207. if (res?.success) {
  208. setTableData({
  209. columns: res?.data?.sql_data?.colunms || [],
  210. values: res?.data?.sql_data?.values || [],
  211. });
  212. if (!res?.data?.chart_values) {
  213. setChartData(undefined);
  214. } else {
  215. setChartData({
  216. type: res?.data?.chart_type,
  217. values: res?.data?.chart_values,
  218. title: newEditorValue?.title,
  219. description: newEditorValue?.thoughts,
  220. });
  221. }
  222. }
  223. },
  224. },
  225. );
  226. const { run: submitSql, loading: submitLoading } = useRequest(
  227. async () => {
  228. const db_name = rounds?.data?.find((item: RoundProps) => item.round === currentRound)?.db_name;
  229. return await sendSpacePostRequest(`/api/v1/sql/editor/submit`, {
  230. conv_uid: id,
  231. db_name,
  232. conv_round: currentRound,
  233. old_sql: editorValue?.sql,
  234. old_speak: editorValue?.thoughts,
  235. new_sql: newEditorValue?.sql,
  236. new_speak: newEditorValue?.thoughts?.match(/^\n--(.*)\n\n$/)?.[1]?.trim() || newEditorValue?.thoughts,
  237. });
  238. },
  239. {
  240. manual: true,
  241. onSuccess: res => {
  242. if (res?.success) {
  243. runSql();
  244. }
  245. },
  246. },
  247. );
  248. const { run: submitChart, loading: submitChartLoading } = useRequest(
  249. async () => {
  250. const db_name = rounds?.data?.find((item: any) => item.round === currentRound)?.db_name;
  251. return await sendSpacePostRequest(`/api/v1/chart/editor/submit`, {
  252. conv_uid: id,
  253. chart_title: newEditorValue?.title,
  254. db_name,
  255. old_sql: editorValue?.[currentTabIndex ?? 0]?.sql,
  256. new_chart_type: newEditorValue?.showcase,
  257. new_sql: newEditorValue?.sql,
  258. new_comment: newEditorValue?.thoughts?.match(/^\n--(.*)\n\n$/)?.[1]?.trim() || newEditorValue?.thoughts,
  259. gmt_create: new Date().getTime(),
  260. });
  261. },
  262. {
  263. manual: true,
  264. onSuccess: res => {
  265. if (res?.success) {
  266. runCharts();
  267. }
  268. },
  269. },
  270. );
  271. const { data: tables } = useRequest(
  272. async () => {
  273. const db_name = rounds?.data?.find((item: RoundProps) => item.round === currentRound)?.db_name;
  274. return await sendGetRequest('/v1/editor/db/tables', {
  275. db_name,
  276. page_index: 1,
  277. page_size: 200,
  278. });
  279. },
  280. {
  281. ready: !!rounds?.data?.find((item: RoundProps) => item.round === currentRound)?.db_name,
  282. refreshDeps: [rounds?.data?.find((item: RoundProps) => item.round === currentRound)?.db_name],
  283. },
  284. );
  285. const { run: handleGetEditorSql } = useRequest(
  286. async round =>
  287. await sendGetRequest('/v1/editor/sql', {
  288. con_uid: id,
  289. round,
  290. }),
  291. {
  292. manual: true,
  293. onSuccess: res => {
  294. let sql = undefined;
  295. try {
  296. if (Array.isArray(res?.data)) {
  297. sql = res?.data;
  298. setCurrentTabIndex(0);
  299. } else if (typeof res?.data === 'string') {
  300. const d = JSON.parse(res?.data);
  301. sql = d;
  302. } else {
  303. sql = res?.data;
  304. }
  305. } catch (e) {
  306. console.log(e);
  307. } finally {
  308. setEditorValue(sql);
  309. if (Array.isArray(sql)) {
  310. setNewEditorValue(sql?.[Number(currentTabIndex || 0)]);
  311. } else {
  312. setNewEditorValue(sql);
  313. }
  314. }
  315. },
  316. },
  317. );
  318. const treeData = useMemo(() => {
  319. const loop = (data: Array<ITableTreeItem>, parentKey?: string | number): DataNode[] =>
  320. data.map((item: ITableTreeItem) => {
  321. const strTitle = item.title;
  322. const index = strTitle.indexOf(searchValue);
  323. const beforeStr = strTitle.substring(0, index);
  324. const afterStr = strTitle.slice(index + searchValue.length);
  325. const renderIcon = (type: string) => {
  326. switch (type) {
  327. case 'db':
  328. return <Database />;
  329. case 'table':
  330. return <TableIcon />;
  331. default:
  332. return <Field />;
  333. }
  334. };
  335. const showTitle =
  336. index > -1 ? (
  337. <Tooltip
  338. title={(item?.comment || item?.title) + (item?.can_null === 'YES' ? '(can null)' : `(can't null)`)}
  339. >
  340. <div className='flex items-center'>
  341. {renderIcon(item.type)}&nbsp;&nbsp;&nbsp;
  342. {beforeStr}
  343. <span className='text-[#1677ff]'>{searchValue}</span>
  344. {afterStr}&nbsp;
  345. {item?.type && <div className='text-gray-400'>{item?.type}</div>}
  346. </div>
  347. </Tooltip>
  348. ) : (
  349. <Tooltip
  350. title={(item?.comment || item?.title) + (item?.can_null === 'YES' ? '(can null)' : `(can't null)`)}
  351. >
  352. <div className='flex items-center'>
  353. {renderIcon(item.type)}&nbsp;&nbsp;&nbsp;
  354. {strTitle}&nbsp;
  355. {item?.type && <div className='text-gray-400'>{item?.type}</div>}
  356. </div>
  357. </Tooltip>
  358. );
  359. if (item.children) {
  360. const itemKey = parentKey ? String(parentKey) + '_' + item.key : item.key;
  361. return { title: strTitle, showTitle, key: itemKey, children: loop(item.children, itemKey) };
  362. }
  363. return {
  364. title: strTitle,
  365. showTitle,
  366. key: item.key,
  367. };
  368. });
  369. if (tables?.data) {
  370. // default expand first node
  371. setExpandedKeys([tables?.data.key]);
  372. return loop([tables?.data]);
  373. }
  374. return [];
  375. }, [searchValue, tables]);
  376. const dataList = useMemo(() => {
  377. const res: { key: string | number; title: string; parentKey?: string | number }[] = [];
  378. const generateList = (data: DataNode[], parentKey?: string | number) => {
  379. if (!data || data?.length <= 0) return;
  380. for (let i = 0; i < data.length; i++) {
  381. const node = data[i];
  382. const { key, title } = node;
  383. res.push({ key, title: title as string, parentKey });
  384. if (node.children) {
  385. generateList(node.children, key);
  386. }
  387. }
  388. };
  389. if (treeData) {
  390. generateList(treeData);
  391. }
  392. return res;
  393. }, [treeData]);
  394. const getParentKey = (key: Key, tree: DataNode[]): Key => {
  395. let parentKey: Key;
  396. for (let i = 0; i < tree.length; i++) {
  397. const node = tree[i];
  398. if (node.children) {
  399. if (node.children.some(item => item.key === key)) {
  400. parentKey = node.key;
  401. } else if (getParentKey(key, node.children)) {
  402. parentKey = getParentKey(key, node.children);
  403. }
  404. }
  405. }
  406. return parentKey!;
  407. };
  408. const onChange = (e: ChangeEvent<HTMLInputElement>) => {
  409. const { value } = e.target;
  410. if (tables?.data) {
  411. if (!value) {
  412. setExpandedKeys([]);
  413. } else {
  414. const newExpandedKeys = dataList
  415. .map(item => {
  416. if (item.title.indexOf(value) > -1) {
  417. return getParentKey(item.key, treeData);
  418. }
  419. return null;
  420. })
  421. .filter((item, i, self) => item && self.indexOf(item) === i);
  422. setExpandedKeys(newExpandedKeys as Key[]);
  423. }
  424. setSearchValue(value);
  425. setAutoExpandParent(true);
  426. }
  427. };
  428. useEffect(() => {
  429. if (currentRound) {
  430. handleGetEditorSql(currentRound);
  431. }
  432. }, [handleGetEditorSql, currentRound]);
  433. useEffect(() => {
  434. if (editorValue && scene === 'chat_dashboard' && currentTabIndex) {
  435. runCharts();
  436. }
  437. }, [currentTabIndex, scene, editorValue, runCharts]);
  438. useEffect(() => {
  439. if (editorValue && scene !== 'chat_dashboard') {
  440. runSql();
  441. }
  442. }, [scene, editorValue, runSql]);
  443. function resolveSqlAndThoughts(value: string | undefined) {
  444. if (!value) {
  445. return { sql: '', thoughts: '' };
  446. }
  447. const match = value && value.match(/(--.*)?\n?([\s\S]*)/);
  448. let thoughts = '';
  449. let sql;
  450. if (match && match.length >= 3) {
  451. thoughts = match[1];
  452. sql = match[2];
  453. }
  454. return { sql, thoughts };
  455. }
  456. return (
  457. <div className='flex flex-col w-full h-full overflow-hidden'>
  458. <Header />
  459. <div className='relative flex flex-1 p-4 pt-0 overflow-hidden'>
  460. <div className='relative flex overflow-hidden mr-4'>
  461. <div
  462. className={classNames('h-full relative transition-[width] overflow-hidden', {
  463. 'w-0': isMenuExpand,
  464. 'w-64': !isMenuExpand,
  465. })}
  466. >
  467. <div className='relative w-64 h-full overflow-hidden flex flex-col rounded bg-white dark:bg-theme-dark-container p-4'>
  468. <Select
  469. size='middle'
  470. className='w-full mb-2'
  471. value={currentRound}
  472. options={rounds?.data?.map((item: RoundProps) => {
  473. return {
  474. label: item.round_name,
  475. value: item.round,
  476. };
  477. })}
  478. onChange={e => {
  479. setCurrentRound(e);
  480. }}
  481. />
  482. <Search className='mb-2' placeholder='Search' onChange={onChange} />
  483. {treeData && treeData.length > 0 && (
  484. <div className='flex-1 overflow-y-auto'>
  485. <Tree
  486. onExpand={(newExpandedKeys: Key[]) => {
  487. setExpandedKeys(newExpandedKeys);
  488. setAutoExpandParent(false);
  489. }}
  490. expandedKeys={expandedKeys}
  491. autoExpandParent={autoExpandParent}
  492. treeData={treeData}
  493. fieldNames={{
  494. title: 'showTitle',
  495. }}
  496. />
  497. </div>
  498. )}
  499. </div>
  500. </div>
  501. <div className='absolute right-0 top-0 translate-x-full h-full flex items-center justify-center opacity-0 hover:opacity-100 group-hover/side:opacity-100 transition-opacity'>
  502. <div
  503. className='bg-white w-4 h-10 flex items-center justify-center dark:bg-theme-dark-container rounded-tr rounded-br z-10 text-xs cursor-pointer shadow-[4px_0_10px_rgba(0,0,0,0.06)] text-opacity-80'
  504. onClick={() => {
  505. setIsMenuExpand(!isMenuExpand);
  506. }}
  507. >
  508. {!isMenuExpand ? <LeftOutlined /> : <RightOutlined />}
  509. </div>
  510. </div>
  511. </div>
  512. <div className='flex flex-col flex-1 max-w-full overflow-hidden'>
  513. {/* Actions */}
  514. <div className='mb-2 bg-white dark:bg-theme-dark-container p-2 flex justify-between items-center'>
  515. <div className='flex gap-2'>
  516. <Button
  517. className='text-xs rounded-none'
  518. size='small'
  519. type='primary'
  520. icon={<CaretRightOutlined />}
  521. loading={runLoading || runChartsLoading}
  522. onClick={async () => {
  523. if (scene === 'chat_dashboard') {
  524. runCharts();
  525. } else {
  526. runSql();
  527. }
  528. }}
  529. >
  530. Run
  531. </Button>
  532. <Button
  533. className='text-xs rounded-none'
  534. type='primary'
  535. size='small'
  536. loading={submitLoading || submitChartLoading}
  537. icon={<SaveFilled />}
  538. onClick={async () => {
  539. if (scene === 'chat_dashboard') {
  540. await submitChart();
  541. } else {
  542. await submitSql();
  543. }
  544. }}
  545. >
  546. Save
  547. </Button>
  548. </div>
  549. <div className='flex gap-2'>
  550. <Icon
  551. className={classNames('flex items-center justify-center w-6 h-6 text-lg rounded', {
  552. 'bg-theme-primary bg-opacity-10': layout === 'TB',
  553. })}
  554. component={SplitScreenWeight}
  555. onClick={() => {
  556. setLayout('TB');
  557. }}
  558. />
  559. <Icon
  560. className={classNames('flex items-center justify-center w-6 h-6 text-lg rounded', {
  561. 'bg-theme-primary bg-opacity-10': layout === 'LR',
  562. })}
  563. component={SplitScreenHeight}
  564. onClick={() => {
  565. setLayout('LR');
  566. }}
  567. />
  568. </div>
  569. </div>
  570. {/* Panel */}
  571. {Array.isArray(editorValue) ? (
  572. <div className='flex flex-col h-full overflow-hidden'>
  573. <div className='w-full whitespace-nowrap overflow-x-auto bg-white dark:bg-theme-dark-container mb-2 text-[0px]'>
  574. {editorValue.map((item, index) => (
  575. <Tooltip className='inline-block' key={item.title} title={item.title}>
  576. <div
  577. className={classNames(
  578. 'max-w-[240px] px-3 h-10 text-ellipsis overflow-hidden whitespace-nowrap text-sm leading-10 cursor-pointer font-semibold hover:text-theme-primary transition-colors mr-2 last-of-type:mr-0',
  579. {
  580. 'border-b-2 border-solid border-theme-primary text-theme-primary': currentTabIndex === index,
  581. },
  582. )}
  583. onClick={() => {
  584. setCurrentTabIndex(index);
  585. setNewEditorValue(editorValue?.[index]);
  586. }}
  587. >
  588. {item.title}
  589. </div>
  590. </Tooltip>
  591. ))}
  592. </div>
  593. <div className='flex flex-1 overflow-hidden'>
  594. {editorValue.map((item, index) => (
  595. <div
  596. key={item.title}
  597. className={classNames('w-full overflow-hidden', {
  598. hidden: index !== currentTabIndex,
  599. 'block flex-1': index === currentTabIndex,
  600. })}
  601. >
  602. <DbEditorContent
  603. layout={layout}
  604. editorValue={item}
  605. handleChange={value => {
  606. const { sql, thoughts } = resolveSqlAndThoughts(value);
  607. setNewEditorValue(old => {
  608. return Object.assign({}, old, {
  609. sql,
  610. thoughts,
  611. });
  612. });
  613. }}
  614. tableData={tableData}
  615. chartData={chartData}
  616. />
  617. </div>
  618. ))}
  619. </div>
  620. </div>
  621. ) : (
  622. <DbEditorContent
  623. layout={layout}
  624. editorValue={editorValue}
  625. handleChange={value => {
  626. const { sql, thoughts } = resolveSqlAndThoughts(value);
  627. setNewEditorValue(old => {
  628. return Object.assign({}, old, {
  629. sql,
  630. thoughts,
  631. });
  632. });
  633. }}
  634. tableData={tableData}
  635. chartData={undefined}
  636. tables={tables}
  637. />
  638. )}
  639. </div>
  640. </div>
  641. </div>
  642. );
  643. }
  644. export default DbEditor;