| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| | import React, { useEffect, useMemo, useState, useCallback } from 'react'; |
| | import { |
| | Modal, |
| | Table, |
| | Checkbox, |
| | Typography, |
| | Empty, |
| | Tag, |
| | Popover, |
| | Input, |
| | } from '@douyinfe/semi-ui'; |
| | import { MousePointerClick } from 'lucide-react'; |
| | import { useIsMobile } from '../../../../hooks/common/useIsMobile'; |
| | import { MODEL_TABLE_PAGE_SIZE } from '../../../../constants'; |
| | import { IconSearch } from '@douyinfe/semi-icons'; |
| |
|
| | const { Text } = Typography; |
| |
|
| | const FIELD_LABELS = { |
| | description: '描述', |
| | icon: '图标', |
| | tags: '标签', |
| | vendor: '供应商', |
| | name_rule: '命名规则', |
| | status: '状态', |
| | }; |
| | const FIELD_KEYS = Object.keys(FIELD_LABELS); |
| |
|
| | const UpstreamConflictModal = ({ |
| | visible, |
| | onClose, |
| | conflicts = [], |
| | onSubmit, |
| | t, |
| | loading = false, |
| | }) => { |
| | const [selections, setSelections] = useState({}); |
| | const isMobile = useIsMobile(); |
| | const [currentPage, setCurrentPage] = useState(1); |
| | const [searchKeyword, setSearchKeyword] = useState(''); |
| |
|
| | const formatValue = (v) => { |
| | if (v === null || v === undefined) return '-'; |
| | if (typeof v === 'string') return v || '-'; |
| | try { |
| | return JSON.stringify(v, null, 2); |
| | } catch (_) { |
| | return String(v); |
| | } |
| | }; |
| |
|
| | useEffect(() => { |
| | if (visible) { |
| | const init = {}; |
| | conflicts.forEach((item) => { |
| | init[item.model_name] = new Set(); |
| | }); |
| | setSelections(init); |
| | setCurrentPage(1); |
| | setSearchKeyword(''); |
| | } else { |
| | setSelections({}); |
| | } |
| | }, [visible, conflicts]); |
| |
|
| | const toggleField = useCallback((modelName, field, checked) => { |
| | setSelections((prev) => { |
| | const next = { ...prev }; |
| | const set = new Set(next[modelName] || []); |
| | if (checked) set.add(field); |
| | else set.delete(field); |
| | next[modelName] = set; |
| | return next; |
| | }); |
| | }, []); |
| |
|
| | |
| | const dataSource = useMemo( |
| | () => |
| | (conflicts || []).map((c) => ({ |
| | key: c.model_name, |
| | model_name: c.model_name, |
| | fields: c.fields || [], |
| | })), |
| | [conflicts], |
| | ); |
| |
|
| | const filteredDataSource = useMemo(() => { |
| | const kw = (searchKeyword || '').toLowerCase(); |
| | if (!kw) return dataSource; |
| | return dataSource.filter((item) => |
| | (item.model_name || '').toLowerCase().includes(kw), |
| | ); |
| | }, [dataSource, searchKeyword]); |
| |
|
| | |
| | const getPresentRowsForField = useCallback( |
| | (fieldKey) => |
| | (filteredDataSource || []).filter((row) => |
| | (row.fields || []).some((f) => f.field === fieldKey), |
| | ), |
| | [filteredDataSource], |
| | ); |
| |
|
| | const getHeaderState = useCallback( |
| | (fieldKey) => { |
| | const presentRows = getPresentRowsForField(fieldKey); |
| | const selectedCount = presentRows.filter((row) => |
| | selections[row.model_name]?.has(fieldKey), |
| | ).length; |
| | const allCount = presentRows.length; |
| | return { |
| | headerChecked: allCount > 0 && selectedCount === allCount, |
| | headerIndeterminate: selectedCount > 0 && selectedCount < allCount, |
| | hasAny: allCount > 0, |
| | }; |
| | }, |
| | [getPresentRowsForField, selections], |
| | ); |
| |
|
| | const applyHeaderChange = useCallback( |
| | (fieldKey, checked) => { |
| | setSelections((prev) => { |
| | const next = { ...prev }; |
| | getPresentRowsForField(fieldKey).forEach((row) => { |
| | const set = new Set(next[row.model_name] || []); |
| | if (checked) set.add(fieldKey); |
| | else set.delete(fieldKey); |
| | next[row.model_name] = set; |
| | }); |
| | return next; |
| | }); |
| | }, |
| | [getPresentRowsForField], |
| | ); |
| |
|
| | const columns = useMemo(() => { |
| | const base = [ |
| | { |
| | title: t('模型'), |
| | dataIndex: 'model_name', |
| | fixed: 'left', |
| | render: (text) => <Text strong>{text}</Text>, |
| | }, |
| | ]; |
| |
|
| | const cols = FIELD_KEYS.map((fieldKey) => { |
| | const rawLabel = FIELD_LABELS[fieldKey] || fieldKey; |
| | const label = t(rawLabel); |
| |
|
| | const { headerChecked, headerIndeterminate, hasAny } = |
| | getHeaderState(fieldKey); |
| | if (!hasAny) return null; |
| | const onHeaderChange = (e) => |
| | applyHeaderChange(fieldKey, e?.target?.checked); |
| |
|
| | return { |
| | title: ( |
| | <div className='flex items-center gap-2'> |
| | <Checkbox |
| | checked={headerChecked} |
| | indeterminate={headerIndeterminate} |
| | onChange={onHeaderChange} |
| | /> |
| | <Text>{label}</Text> |
| | </div> |
| | ), |
| | dataIndex: fieldKey, |
| | render: (_, record) => { |
| | const f = (record.fields || []).find((x) => x.field === fieldKey); |
| | if (!f) return <Text type='tertiary'>-</Text>; |
| | const checked = selections[record.model_name]?.has(fieldKey) || false; |
| | return ( |
| | <Checkbox |
| | checked={checked} |
| | onChange={(e) => |
| | toggleField(record.model_name, fieldKey, e?.target?.checked) |
| | } |
| | > |
| | <Popover |
| | trigger='hover' |
| | position='top' |
| | content={ |
| | <div className='p-2 max-w-[520px]'> |
| | <div className='mb-2'> |
| | <Text type='tertiary' size='small'> |
| | {t('本地')} |
| | </Text> |
| | <pre className='whitespace-pre-wrap m-0'> |
| | {formatValue(f.local)} |
| | </pre> |
| | </div> |
| | <div> |
| | <Text type='tertiary' size='small'> |
| | {t('官方')} |
| | </Text> |
| | <pre className='whitespace-pre-wrap m-0'> |
| | {formatValue(f.upstream)} |
| | </pre> |
| | </div> |
| | </div> |
| | } |
| | > |
| | <Tag |
| | color='white' |
| | size='small' |
| | prefixIcon={<MousePointerClick size={14} />} |
| | > |
| | {t('点击查看差异')} |
| | </Tag> |
| | </Popover> |
| | </Checkbox> |
| | ); |
| | }, |
| | }; |
| | }); |
| |
|
| | return [...base, ...cols.filter(Boolean)]; |
| | }, [ |
| | t, |
| | selections, |
| | filteredDataSource, |
| | getHeaderState, |
| | applyHeaderChange, |
| | toggleField, |
| | ]); |
| |
|
| | const pagedDataSource = useMemo(() => { |
| | const start = (currentPage - 1) * MODEL_TABLE_PAGE_SIZE; |
| | const end = start + MODEL_TABLE_PAGE_SIZE; |
| | return filteredDataSource.slice(start, end); |
| | }, [filteredDataSource, currentPage]); |
| |
|
| | const handleOk = async () => { |
| | const payload = Object.entries(selections) |
| | .map(([modelName, set]) => ({ |
| | model_name: modelName, |
| | fields: Array.from(set || []), |
| | })) |
| | .filter((x) => x.fields.length > 0); |
| |
|
| | const ok = await onSubmit?.(payload); |
| | if (ok) onClose?.(); |
| | }; |
| |
|
| | return ( |
| | <Modal |
| | title={t('选择要覆盖的冲突项')} |
| | visible={visible} |
| | onCancel={onClose} |
| | onOk={handleOk} |
| | confirmLoading={loading} |
| | okText={t('应用覆盖')} |
| | cancelText={t('取消')} |
| | width={isMobile ? '100%' : 1000} |
| | > |
| | {dataSource.length === 0 ? ( |
| | <Empty description={t('无冲突项')} className='p-6' /> |
| | ) : ( |
| | <> |
| | <div className='mb-3 text-[var(--semi-color-text-2)]'> |
| | {t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')} |
| | </div> |
| | {/* 搜索框 */} |
| | <div className='flex items-center justify-end gap-2 w-full mb-4'> |
| | <Input |
| | placeholder={t('搜索模型...')} |
| | value={searchKeyword} |
| | onChange={(v) => { |
| | setSearchKeyword(v); |
| | setCurrentPage(1); |
| | }} |
| | className='!w-full' |
| | prefix={<IconSearch />} |
| | showClear |
| | /> |
| | </div> |
| | {filteredDataSource.length > 0 ? ( |
| | <Table |
| | columns={columns} |
| | dataSource={pagedDataSource} |
| | pagination={{ |
| | currentPage: currentPage, |
| | pageSize: MODEL_TABLE_PAGE_SIZE, |
| | total: filteredDataSource.length, |
| | showSizeChanger: false, |
| | onPageChange: (page) => setCurrentPage(page), |
| | }} |
| | scroll={{ x: 'max-content' }} |
| | /> |
| | ) : ( |
| | <Empty |
| | description={ |
| | searchKeyword ? t('未找到匹配的模型') : t('无冲突项') |
| | } |
| | className='p-6' |
| | /> |
| | )} |
| | </> |
| | )} |
| | </Modal> |
| | ); |
| | }; |
| |
|
| | export default UpstreamConflictModal; |
| |
|