UpstreamConflictModal.jsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  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 } from 'react';
  16. import {
  17. Modal,
  18. Table,
  19. Checkbox,
  20. Typography,
  21. Empty,
  22. Tag,
  23. Popover,
  24. } from '@douyinfe/semi-ui';
  25. import { MousePointerClick } from 'lucide-react';
  26. import { useIsMobile } from '../../../../hooks/common/useIsMobile';
  27. const { Text } = Typography;
  28. const FIELD_LABELS = {
  29. description: '描述',
  30. icon: '图标',
  31. tags: '标签',
  32. vendor: '供应商',
  33. name_rule: '命名规则',
  34. status: '状态',
  35. };
  36. const FIELD_KEYS = Object.keys(FIELD_LABELS);
  37. const UpstreamConflictModal = ({
  38. visible,
  39. onClose,
  40. conflicts = [],
  41. onSubmit,
  42. t,
  43. loading = false,
  44. }) => {
  45. const [selections, setSelections] = useState({});
  46. const isMobile = useIsMobile();
  47. const formatValue = (v) => {
  48. if (v === null || v === undefined) return '-';
  49. if (typeof v === 'string') return v || '-';
  50. try {
  51. return JSON.stringify(v, null, 2);
  52. } catch (_) {
  53. return String(v);
  54. }
  55. };
  56. useEffect(() => {
  57. if (visible) {
  58. const init = {};
  59. conflicts.forEach((item) => {
  60. init[item.model_name] = new Set();
  61. });
  62. setSelections(init);
  63. } else {
  64. setSelections({});
  65. }
  66. }, [visible, conflicts]);
  67. const toggleField = (modelName, field, checked) => {
  68. setSelections((prev) => {
  69. const next = { ...prev };
  70. const set = new Set(next[modelName] || []);
  71. if (checked) set.add(field);
  72. else set.delete(field);
  73. next[modelName] = set;
  74. return next;
  75. });
  76. };
  77. const columns = useMemo(() => {
  78. const base = [
  79. {
  80. title: t('模型'),
  81. dataIndex: 'model_name',
  82. fixed: 'left',
  83. render: (text) => <Text strong>{text}</Text>,
  84. },
  85. ];
  86. const cols = FIELD_KEYS.map((fieldKey) => {
  87. const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
  88. const label = t(rawLabel);
  89. // 统计列头复选框状态(仅统计存在该字段冲突的行)
  90. const presentRows = (conflicts || []).filter((row) =>
  91. (row.fields || []).some((f) => f.field === fieldKey),
  92. );
  93. const selectedCount = presentRows.filter((row) =>
  94. selections[row.model_name]?.has(fieldKey),
  95. ).length;
  96. const allCount = presentRows.length;
  97. if (allCount === 0) {
  98. return null; // 若此字段在所有行中都不存在,则不展示该列
  99. }
  100. const headerChecked = allCount > 0 && selectedCount === allCount;
  101. const headerIndeterminate = selectedCount > 0 && selectedCount < allCount;
  102. const onHeaderChange = (e) => {
  103. const checked = e?.target?.checked;
  104. setSelections((prev) => {
  105. const next = { ...prev };
  106. (conflicts || []).forEach((row) => {
  107. const hasField = (row.fields || []).some(
  108. (f) => f.field === fieldKey,
  109. );
  110. if (!hasField) return;
  111. const set = new Set(next[row.model_name] || []);
  112. if (checked) set.add(fieldKey);
  113. else set.delete(fieldKey);
  114. next[row.model_name] = set;
  115. });
  116. return next;
  117. });
  118. };
  119. return {
  120. title: (
  121. <div className='flex items-center gap-2'>
  122. <Checkbox
  123. checked={headerChecked}
  124. indeterminate={headerIndeterminate}
  125. onChange={onHeaderChange}
  126. />
  127. <Text>{label}</Text>
  128. </div>
  129. ),
  130. dataIndex: fieldKey,
  131. render: (_, record) => {
  132. const f = (record.fields || []).find((x) => x.field === fieldKey);
  133. if (!f) return <Text type='tertiary'>-</Text>;
  134. const checked = selections[record.model_name]?.has(fieldKey) || false;
  135. return (
  136. <Checkbox
  137. checked={checked}
  138. onChange={(e) =>
  139. toggleField(record.model_name, fieldKey, e?.target?.checked)
  140. }
  141. >
  142. <Popover
  143. trigger='hover'
  144. position='top'
  145. content={
  146. <div className='p-2 max-w-[520px]'>
  147. <div className='mb-2'>
  148. <Text type='tertiary' size='small'>
  149. {t('本地')}
  150. </Text>
  151. <pre className='whitespace-pre-wrap m-0'>
  152. {formatValue(f.local)}
  153. </pre>
  154. </div>
  155. <div>
  156. <Text type='tertiary' size='small'>
  157. {t('官方')}
  158. </Text>
  159. <pre className='whitespace-pre-wrap m-0'>
  160. {formatValue(f.upstream)}
  161. </pre>
  162. </div>
  163. </div>
  164. }
  165. >
  166. <Tag
  167. color='white'
  168. size='small'
  169. prefixIcon={<MousePointerClick size={14} />}
  170. >
  171. {t('点击查看差异')}
  172. </Tag>
  173. </Popover>
  174. </Checkbox>
  175. );
  176. },
  177. };
  178. });
  179. return [...base, ...cols.filter(Boolean)];
  180. }, [t, selections, conflicts]);
  181. const dataSource = conflicts.map((c) => ({
  182. key: c.model_name,
  183. model_name: c.model_name,
  184. fields: c.fields || [],
  185. }));
  186. const handleOk = async () => {
  187. const payload = Object.entries(selections)
  188. .map(([modelName, set]) => ({
  189. model_name: modelName,
  190. fields: Array.from(set || []),
  191. }))
  192. .filter((x) => x.fields.length > 0);
  193. if (payload.length === 0) {
  194. onClose?.();
  195. return;
  196. }
  197. const ok = await onSubmit?.(payload);
  198. if (ok) onClose?.();
  199. };
  200. return (
  201. <Modal
  202. title={t('选择要覆盖的冲突项')}
  203. visible={visible}
  204. onCancel={onClose}
  205. onOk={handleOk}
  206. confirmLoading={loading}
  207. okText={t('应用覆盖')}
  208. cancelText={t('取消')}
  209. width={isMobile ? '100%' : 1000}
  210. >
  211. {dataSource.length === 0 ? (
  212. <Empty description={t('无冲突项')} className='p-6' />
  213. ) : (
  214. <>
  215. <div className='mb-3 text-[var(--semi-color-text-2)]'>
  216. {t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
  217. </div>
  218. <Table
  219. columns={columns}
  220. dataSource={dataSource}
  221. pagination={false}
  222. scroll={{ x: 'max-content' }}
  223. />
  224. </>
  225. )}
  226. </Modal>
  227. );
  228. };
  229. export default UpstreamConflictModal;