| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| 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; |
|
|