✨ feat(sync): multi-language sync wizard, backend locale support, and conflict modal UX improvements
Frontend (web)
- ModelsActions.jsx
- Replace “Sync Official” with “Sync” and open a new two-step SyncWizard.
- Pass selected locale through to preview, sync, and overwrite flows.
- Keep conflict resolution flow; inject locale into overwrite submission.
- New: models/modals/SyncWizardModal.jsx
- Two-step wizard: (1) method selection (config-sync disabled for now), (2) language selection (en/zh/ja).
- Horizontal, centered Radio cards; returns { option, locale } via onConfirm.
- UpstreamConflictModal.jsx
- Add search input (model fuzzy search) and native pagination.
- Column header checkbox now only applies to rows in the current filtered result.
- Fix “Cannot access ‘filteredDataSource’ before initialization”.
- Refactor with useMemo/useCallback; extract helpers to remove duplicated logic:
- getPresentRowsForField, getHeaderState, applyHeaderChange
- Minor code cleanups and stability improvements.
- i18n (en.json)
- Add strings for the sync wizard and related actions (Sync, Sync Wizard, Select method/source/language, etc.).
- Adjust minor translations.
Hooks
- useModelsData.jsx
- Extend previewUpstreamDiff, syncUpstream, applyUpstreamOverwrite to accept options with locale.
- Send locale via query/body accordingly.
Backend (Go)
- controller/model_sync.go
- Accept locale from query/body and resolve i18n upstream URLs.
- Add SYNC_UPSTREAM_BASE for upstream base override (default: https://basellm.github.io/llm-metadata).
- Make HTTP timeouts/retries/limits configurable:
- SYNC_HTTP_TIMEOUT_SECONDS, SYNC_HTTP_RETRY, SYNC_HTTP_MAX_MB
- Add ETag-based caching and support both envelope and pure array JSON formats.
- Concurrently fetch vendors and models; improve error responses with locale and source URLs.
- Include source meta (locale, models_url, vendors_url) in success payloads.
Notes
- No breaking changes expected.
- Lint passes for touched files.
This commit is contained in:
@@ -100,7 +100,7 @@ const ApiInfoPanel = ({
|
||||
</React.Fragment>
|
||||
))
|
||||
) : (
|
||||
<div className='flex justify-center items-center py-8'>
|
||||
<div className='flex justify-center items-center min-h-[20rem] w-full'>
|
||||
<Empty
|
||||
image={<IllustrationConstruction style={ILLUSTRATION_SIZE} />}
|
||||
darkModeImage={
|
||||
|
||||
@@ -21,11 +21,12 @@ import React, { useState } from 'react';
|
||||
import MissingModelsModal from './modals/MissingModelsModal';
|
||||
import PrefillGroupManagement from './modals/PrefillGroupManagement';
|
||||
import EditPrefillGroupModal from './modals/EditPrefillGroupModal';
|
||||
import { Button, Modal, Popover } from '@douyinfe/semi-ui';
|
||||
import { Button, Modal, Popover, RadioGroup, Radio } from '@douyinfe/semi-ui';
|
||||
import { showSuccess, showError, copy } from '../../../helpers';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import SelectionNotification from './components/SelectionNotification';
|
||||
import UpstreamConflictModal from './modals/UpstreamConflictModal';
|
||||
import SyncWizardModal from './modals/SyncWizardModal';
|
||||
|
||||
const ModelsActions = ({
|
||||
selectedKeys,
|
||||
@@ -50,10 +51,12 @@ const ModelsActions = ({
|
||||
const [prefillInit, setPrefillInit] = useState({ id: undefined });
|
||||
const [showConflict, setShowConflict] = useState(false);
|
||||
const [conflicts, setConflicts] = useState([]);
|
||||
const [showSyncModal, setShowSyncModal] = useState(false);
|
||||
const [syncLocale, setSyncLocale] = useState('zh');
|
||||
|
||||
const handleSyncUpstream = async () => {
|
||||
const handleSyncUpstream = async (locale) => {
|
||||
// 先预览
|
||||
const data = await previewUpstreamDiff?.();
|
||||
const data = await previewUpstreamDiff?.({ locale });
|
||||
const conflictItems = data?.conflicts || [];
|
||||
if (conflictItems.length > 0) {
|
||||
setConflicts(conflictItems);
|
||||
@@ -61,7 +64,7 @@ const ModelsActions = ({
|
||||
return;
|
||||
}
|
||||
// 无冲突,直接同步缺失
|
||||
await syncUpstream?.();
|
||||
await syncUpstream?.({ locale });
|
||||
};
|
||||
|
||||
// Handle delete selected models with confirmation
|
||||
@@ -151,9 +154,12 @@ const ModelsActions = ({
|
||||
className='flex-1 md:flex-initial'
|
||||
size='small'
|
||||
loading={syncing || previewing}
|
||||
onClick={handleSyncUpstream}
|
||||
onClick={() => {
|
||||
setSyncLocale('zh');
|
||||
setShowSyncModal(true);
|
||||
}}
|
||||
>
|
||||
{t('同步官方')}
|
||||
{t('同步')}
|
||||
</Button>
|
||||
</Popover>
|
||||
|
||||
@@ -196,6 +202,20 @@ const ModelsActions = ({
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<SyncWizardModal
|
||||
visible={showSyncModal}
|
||||
onClose={() => setShowSyncModal(false)}
|
||||
loading={syncing || previewing}
|
||||
t={t}
|
||||
onConfirm={async ({ option, locale }) => {
|
||||
setSyncLocale(locale);
|
||||
if (option === 'official') {
|
||||
await handleSyncUpstream(locale);
|
||||
}
|
||||
setShowSyncModal(false);
|
||||
}}
|
||||
/>
|
||||
|
||||
<MissingModelsModal
|
||||
visible={showMissingModal}
|
||||
onClose={() => setShowMissingModal(false)}
|
||||
@@ -224,7 +244,10 @@ const ModelsActions = ({
|
||||
onClose={() => setShowConflict(false)}
|
||||
conflicts={conflicts}
|
||||
onSubmit={async (payload) => {
|
||||
return await applyUpstreamOverwrite?.(payload);
|
||||
return await applyUpstreamOverwrite?.({
|
||||
...payload,
|
||||
locale: syncLocale,
|
||||
});
|
||||
}}
|
||||
t={t}
|
||||
loading={syncing}
|
||||
|
||||
@@ -96,7 +96,7 @@ const MissingModelsModal = ({ visible, onClose, onConfigureModel, t }) => {
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
width: 100,
|
||||
width: 120,
|
||||
render: (text, record) => (
|
||||
<Button
|
||||
type='primary'
|
||||
|
||||
132
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
132
web/src/components/table/models/modals/SyncWizardModal.jsx
Normal file
@@ -0,0 +1,132 @@
|
||||
/*
|
||||
Copyright (C) 2025 QuantumNous
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU Affero General Public License as
|
||||
published by the Free Software Foundation, either version 3 of the
|
||||
License, or (at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Affero General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU Affero General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Modal, RadioGroup, Radio, Steps, Button } from '@douyinfe/semi-ui';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const SyncWizardModal = ({ visible, onClose, onConfirm, loading, t }) => {
|
||||
const [step, setStep] = useState(0);
|
||||
const [option, setOption] = useState('official');
|
||||
const [locale, setLocale] = useState('zh');
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setStep(0);
|
||||
setOption('official');
|
||||
setLocale('zh');
|
||||
}
|
||||
}, [visible]);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={t('同步向导')}
|
||||
visible={visible}
|
||||
onCancel={onClose}
|
||||
footer={
|
||||
<div className='flex justify-end'>
|
||||
{step === 1 && (
|
||||
<Button onClick={() => setStep(0)}>{t('上一步')}</Button>
|
||||
)}
|
||||
<Button onClick={onClose}>{t('取消')}</Button>
|
||||
{step === 0 && (
|
||||
<Button
|
||||
type='primary'
|
||||
onClick={() => setStep(1)}
|
||||
disabled={option !== 'official'}
|
||||
>
|
||||
{t('下一步')}
|
||||
</Button>
|
||||
)}
|
||||
{step === 1 && (
|
||||
<Button
|
||||
type='primary'
|
||||
theme='solid'
|
||||
loading={loading}
|
||||
onClick={async () => {
|
||||
await onConfirm?.({ option, locale });
|
||||
}}
|
||||
>
|
||||
{t('开始同步')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
width={isMobile ? '100%' : 'small'}
|
||||
>
|
||||
<div className='mb-3'>
|
||||
<Steps type='basic' current={step} size='small'>
|
||||
<Steps.Step title={t('选择方式')} description={t('选择同步来源')} />
|
||||
<Steps.Step title={t('选择语言')} description={t('选择同步语言')} />
|
||||
</Steps>
|
||||
</div>
|
||||
|
||||
{step === 0 && (
|
||||
<div className='mt-2 flex justify-center'>
|
||||
<RadioGroup
|
||||
value={option}
|
||||
onChange={(e) => setOption(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='同步方式选择'
|
||||
name='sync-mode-selection'
|
||||
>
|
||||
<Radio value='official' extra={t('从官方模型库同步')}>
|
||||
{t('官方模型同步')}
|
||||
</Radio>
|
||||
<Radio value='config' extra={t('从配置文件同步')} disabled>
|
||||
{t('配置文件同步')}
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 1 && (
|
||||
<div className='mt-2'>
|
||||
<div className='mb-2 text-[var(--semi-color-text-2)]'>
|
||||
{t('请选择同步语言')}
|
||||
</div>
|
||||
<div className='flex justify-center'>
|
||||
<RadioGroup
|
||||
value={locale}
|
||||
onChange={(e) => setLocale(e?.target?.value ?? e)}
|
||||
type='card'
|
||||
direction='horizontal'
|
||||
aria-label='语言选择'
|
||||
name='sync-locale-selection'
|
||||
>
|
||||
<Radio value='en' extra='English'>
|
||||
EN
|
||||
</Radio>
|
||||
<Radio value='zh' extra='中文'>
|
||||
ZH
|
||||
</Radio>
|
||||
<Radio value='ja' extra='日本語'>
|
||||
JA
|
||||
</Radio>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default SyncWizardModal;
|
||||
@@ -17,7 +17,7 @@ along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
For commercial licensing, please contact support@quantumnous.com
|
||||
*/
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import React, { useEffect, useMemo, useState, useCallback } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Table,
|
||||
@@ -26,9 +26,12 @@ import {
|
||||
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;
|
||||
|
||||
@@ -52,6 +55,8 @@ const UpstreamConflictModal = ({
|
||||
}) => {
|
||||
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 '-';
|
||||
@@ -70,12 +75,14 @@ const UpstreamConflictModal = ({
|
||||
init[item.model_name] = new Set();
|
||||
});
|
||||
setSelections(init);
|
||||
setCurrentPage(1);
|
||||
setSearchKeyword('');
|
||||
} else {
|
||||
setSelections({});
|
||||
}
|
||||
}, [visible, conflicts]);
|
||||
|
||||
const toggleField = (modelName, field, checked) => {
|
||||
const toggleField = useCallback((modelName, field, checked) => {
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
const set = new Set(next[modelName] || []);
|
||||
@@ -84,7 +91,67 @@ const UpstreamConflictModal = ({
|
||||
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 = [
|
||||
@@ -100,37 +167,11 @@ const UpstreamConflictModal = ({
|
||||
const rawLabel = FIELD_LABELS[fieldKey] || fieldKey;
|
||||
const label = t(rawLabel);
|
||||
|
||||
// 统计列头复选框状态(仅统计存在该字段冲突的行)
|
||||
const presentRows = (conflicts || []).filter((row) =>
|
||||
(row.fields || []).some((f) => f.field === fieldKey),
|
||||
);
|
||||
const selectedCount = presentRows.filter((row) =>
|
||||
selections[row.model_name]?.has(fieldKey),
|
||||
).length;
|
||||
const allCount = presentRows.length;
|
||||
if (allCount === 0) {
|
||||
return null; // 若此字段在所有行中都不存在,则不展示该列
|
||||
}
|
||||
const headerChecked = allCount > 0 && selectedCount === allCount;
|
||||
const headerIndeterminate = selectedCount > 0 && selectedCount < allCount;
|
||||
|
||||
const onHeaderChange = (e) => {
|
||||
const checked = e?.target?.checked;
|
||||
setSelections((prev) => {
|
||||
const next = { ...prev };
|
||||
(conflicts || []).forEach((row) => {
|
||||
const hasField = (row.fields || []).some(
|
||||
(f) => f.field === fieldKey,
|
||||
);
|
||||
if (!hasField) return;
|
||||
const set = new Set(next[row.model_name] || []);
|
||||
if (checked) set.add(fieldKey);
|
||||
else set.delete(fieldKey);
|
||||
next[row.model_name] = set;
|
||||
});
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const { headerChecked, headerIndeterminate, hasAny } =
|
||||
getHeaderState(fieldKey);
|
||||
if (!hasAny) return null;
|
||||
const onHeaderChange = (e) =>
|
||||
applyHeaderChange(fieldKey, e?.target?.checked);
|
||||
|
||||
return {
|
||||
title: (
|
||||
@@ -194,13 +235,20 @@ const UpstreamConflictModal = ({
|
||||
});
|
||||
|
||||
return [...base, ...cols.filter(Boolean)];
|
||||
}, [t, selections, conflicts]);
|
||||
}, [
|
||||
t,
|
||||
selections,
|
||||
filteredDataSource,
|
||||
getHeaderState,
|
||||
applyHeaderChange,
|
||||
toggleField,
|
||||
]);
|
||||
|
||||
const dataSource = conflicts.map((c) => ({
|
||||
key: c.model_name,
|
||||
model_name: c.model_name,
|
||||
fields: c.fields || [],
|
||||
}));
|
||||
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)
|
||||
@@ -236,12 +284,41 @@ const UpstreamConflictModal = ({
|
||||
<div className='mb-3 text-[var(--semi-color-text-2)]'>
|
||||
{t('仅会覆盖你勾选的字段,未勾选的字段保持本地不变。')}
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={dataSource}
|
||||
pagination={false}
|
||||
scroll={{ x: 'max-content' }}
|
||||
/>
|
||||
{/* 搜索框 */}
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user