UpstreamConflictModal.jsx 9.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324
  1. /*
  2. Copyright (C) 2025 QuantumNous
  3. This program is free software: you can redistribute it and/or modify
  4. it under the terms of the GNU Affero General Public License as
  5. published by the Free Software Foundation, either version 3 of the
  6. License, or (at your option) any later version.
  7. This program is distributed in the hope that it will be useful,
  8. but WITHOUT ANY WARRANTY; without even the implied warranty of
  9. MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  10. GNU Affero General Public License for more details.
  11. You should have received a copy of the GNU Affero General Public License
  12. along with this program. If not, see <https://www.gnu.org/licenses/>.
  13. For commercial licensing, please contact support@quantumnous.com
  14. */
  15. import React, { useEffect, useMemo, useState, useCallback } from 'react';
  16. import {
  17. Modal,
  18. Table,
  19. Checkbox,
  20. Typography,
  21. Empty,
  22. Tag,
  23. Popover,
  24. Input,
  25. } from '@douyinfe/semi-ui';
  26. import { MousePointerClick } from 'lucide-react';
  27. import { useIsMobile } from '../../../../hooks/common/useIsMobile';
  28. import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants';
  29. import { IconSearch } from '@douyinfe/semi-icons';
  30. const { Text } = Typography;
  31. const FIELD_LABELS = {
  32. description: '描述',
  33. icon: '图标',
  34. tags: '标签',
  35. vendor: '供应商',
  36. name_rule: '命名规则',
  37. status: '状态',
  38. };
  39. const FIELD_KEYS = Object.keys(FIELD_LABELS);
  40. const UpstreamConflictModal = ({
  41. visible,
  42. onClose,
  43. conflicts = [],
  44. onSubmit,
  45. t,
  46. loading = false,
  47. }) => {
  48. const [selections, setSelections] = useState({});
  49. const isMobile = useIsMobile();
  50. const [currentPage, setCurrentPage] = useState(1);
  51. const [searchKeyword, setSearchKeyword] = useState('');
  52. const formatValue = (v) => {
  53. if (v === null || v === undefined) return '-';
  54. if (typeof v === 'string') return v || '-';
  55. try {
  56. return JSON.stringify(v, null, 2);
  57. } catch (_) {
  58. return String(v);
  59. }
  60. };
  61. useEffect(() => {
  62. if (visible) {
  63. const init = {};
  64. conflicts.forEach((item) => {
  65. init[item.model_name] = new Set();
  66. });
  67. setSelections(init);
  68. setCurrentPage(1);
  69. setSearchKeyword('');
  70. } else {
  71. setSelections({});
  72. }
  73. }, [visible, conflicts]);
  74. const toggleField = useCallback((modelName, field, checked) => {
  75. setSelections((prev) => {
  76. const next = { ...prev };
  77. const set = new Set(next[modelName] || []);
  78. if (checked) set.add(field);
  79. else set.delete(field);
  80. next[modelName] = set;
  81. return next;
  82. });
  83. }, []);
  84. // 构造数据源与过滤后的数据源
  85. const dataSource = useMemo(
  86. () =>
  87. (conflicts || []).map((c) => ({
  88. key: c.model_name,
  89. model_name: c.model_name,
  90. fields: c.fields || [],
  91. })),
  92. [conflicts],
  93. );
  94. const filteredDataSource = useMemo(() => {
  95. const kw = (searchKeyword || '').toLowerCase();
  96. if (!kw) return dataSource;
  97. return dataSource.filter((item) =>
  98. (item.model_name || '').toLowerCase().includes(kw),
  99. );
  100. }, [dataSource, searchKeyword]);
  101. // 列头工具:当前过滤范围内可操作的行集合/勾选状态/批量设置
  102. const getPresentRowsForField = useCallback(
  103. (fieldKey) =>
  104. (filteredDataSource || []).filter((row) =>
  105. (row.fields || []).some((f) => f.field === fieldKey),
  106. ),
  107. [filteredDataSource],
  108. );
  109. const getHeaderState = useCallback(
  110. (fieldKey) => {
  111. const presentRows = getPresentRowsForField(fieldKey);
  112. const selectedCount = presentRows.filter((row) =>
  113. selections[row.model_name]?.has(fieldKey),
  114. ).length;
  115. const allCount = presentRows.length;
  116. return {
  117. headerChecked: allCount > 0 && selectedCount === allCount,
  118. headerIndeterminate: selectedCount > 0 && selectedCount < allCount,
  119. hasAny: allCount > 0,
  120. };
  121. },
  122. [getPresentRowsForField, selections],
  123. );
  124. const applyHeaderChange = useCallback(
  125. (fieldKey, checked) => {
  126. setSelections((prev) => {
  127. const next = { ...prev };
  128. getPresentRowsForField(fieldKey).forEach((row) => {
  129. const set = new Set(next[row.model_name] || []);
  130. if (checked) set.add(fieldKey);
  131. else set.delete(fieldKey);
  132. next[row.model_name] = set;
  133. });
  134. return next;
  135. });
  136. },
  137. [getPresentRowsForField],
  138. );
  139. const columns = useMemo(() => {
  140. const base = [
  141. {
  142. title: t('模型'),
  143. dataIndex: 'model_name',
  144. fixed: 'left',
  145. render: (text) => <Text strong>{text}</Text>,
  146. },
  147. ];
  148. const cols = FIELD_KEYS.map((fieldKey) => {
  149. const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
  150. const label = t(rawLabel);
  151. const { headerChecked, headerIndeterminate, hasAny } =
  152. getHeaderState(fieldKey);
  153. if (!hasAny) return null;
  154. const onHeaderChange = (e) =>
  155. applyHeaderChange(fieldKey, e?.target?.checked);
  156. return {
  157. title: (
  158. <div className='flex items-center gap-2'>
  159. <Checkbox
  160. checked={headerChecked}
  161. indeterminate={headerIndeterminate}
  162. onChange={onHeaderChange}
  163. />
  164. <Text>{label}</Text>
  165. </div>
  166. ),
  167. dataIndex: fieldKey,
  168. render: (_, record) => {
  169. const f = (record.fields || []).find((x) => x.field === fieldKey);
  170. if (!f) return <Text type='tertiary'>-</Text>;
  171. const checked = selections[record.model_name]?.has(fieldKey) || false;
  172. return (
  173. <Checkbox
  174. checked={checked}
  175. onChange={(e) =>
  176. toggleField(record.model_name, fieldKey, e?.target?.checked)
  177. }
  178. >
  179. <Popover
  180. trigger='hover'
  181. position='top'
  182. content={
  183. <div className='p-2 max-w-[520px]'>
  184. <div className='mb-2'>
  185. <Text type='tertiary' size='small'>
  186. {t('本地')}
  187. </Text>
  188. <pre className='whitespace-pre-wrap m-0'>
  189. {formatValue(f.local)}
  190. </pre>
  191. </div>
  192. <div>
  193. <Text type='tertiary' size='small'>
  194. {t('官方')}
  195. </Text>
  196. <pre className='whitespace-pre-wrap m-0'>
  197. {formatValue(f.upstream)}
  198. </pre>
  199. </div>
  200. </div>
  201. }
  202. >
  203. <Tag
  204. color='white'
  205. size='small'
  206. prefixIcon={<MousePointerClick size={14} />}
  207. >
  208. {t('点击查看差异')}
  209. </Tag>
  210. </Popover>
  211. </Checkbox>
  212. );
  213. },
  214. };
  215. });
  216. return [...base, ...cols.filter(Boolean)];
  217. }, [
  218. t,
  219. selections,
  220. filteredDataSource,
  221. getHeaderState,
  222. applyHeaderChange,
  223. toggleField,
  224. ]);
  225. const pagedDataSource = useMemo(() => {
  226. const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE;
  227. const end = start + MODEL_TABLE_PAGE_SIZE;
  228. return filteredDataSource.slice(start, end);
  229. }, [filteredDataSource, currentPage]);
  230. const handleOk = async () => {
  231. const payload = Object.entries(selections)
  232. .map(([modelName, set]) => ({
  233. model_name: modelName,
  234. fields: Array.from(set || []),
  235. }))
  236. .filter((x) => x.fields.length > 0);
  237. const ok = await onSubmit?.(payload);
  238. if (ok) onClose?.();
  239. };
  240. return (
  241. <Modal
  242. title={t('选择要覆盖的冲突项')}
  243. visible={visible}
  244. onCancel={onClose}
  245. onOk={handleOk}
  246. confirmLoading={loading}
  247. okText={t('应用覆盖')}
  248. cancelText={t('取消')}
  249. width={isMobile ? '100%' : 1000}
  250. >
  251. {dataSource.length === 0 ? (
  252. <Empty description={t('无冲突项')} className='p-6' />
  253. ) : (
  254. <>
  255. <div className='mb-3 text-[var(--semi-color-text-2)]'>
  256. {t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
  257. </div>
  258. {/* 搜索框 */}
  259. <div className='flex items-center justify-end gap-2 w-full mb-4'>
  260. <Input
  261. placeholder={t('搜索模型...')}
  262. value={searchKeyword}
  263. onChange={(v) => {
  264. setSearchKeyword(v);
  265. setCurrentPage(1);
  266. }}
  267. className='!w-full'
  268. prefix={<IconSearch />}
  269. showClear
  270. />
  271. </div>
  272. {filteredDataSource.length > 0 ? (
  273. <Table
  274. columns={columns}
  275. dataSource={pagedDataSource}
  276. pagination={{
  277. currentPage: currentPage,
  278. pageSize: MODEL_TABLE_PAGE_SIZE,
  279. total: filteredDataSource.length,
  280. showSizeChanger: false,
  281. onPageChange: (page) => setCurrentPage(page),
  282. }}
  283. scroll={{ x: 'max-content' }}
  284. />
  285. ) : (
  286. <Empty
  287. description={
  288. searchKeyword ? t('未找到匹配的模型') : t('无冲突项')
  289. }
  290. className='p-6'
  291. />
  292. )}
  293. </>
  294. )}
  295. </Modal>
  296. );
  297. };
  298. export default UpstreamConflictModal;