🚀 feat: Introduce full Model & Vendor Management suite (backend + frontend) and UI refinements
Backend • Add `model/model_meta.go` and `model/vendor_meta.go` defining Model & Vendor entities with CRUD helpers, soft-delete and time stamps • Create corresponding controllers `controller/model_meta.go`, `controller/vendor_meta.go` and register routes in `router/api-router.go` • Auto-migrate new tables in DB startup logic Frontend • Build complete “Model Management” module under `/console/models` - New pages, tables, filters, actions, hooks (`useModelsData`) and dynamic vendor tabs - Modals `EditModelModal.jsx` & unified `EditVendorModal.jsx`; latter now uses default confirm/cancel footer and mobile-friendly modal sizing (`full-width` / `small`) via `useIsMobile` • Update sidebar (`SiderBar.js`) and routing (`App.js`) to surface the feature • Add helper updates (`render.js`) incl. `stringToColor`, dynamic LobeHub icon retrieval, and tag color palettes Table UX improvements • Replace separate status column with inline Enable / Disable buttons in operation column (matching channel table style) • Limit visible tags to max 3; overflow represented as “+x” tag with padded `Popover` showing remaining tags • Color all tags deterministically using `stringToColor` for consistent theming • Change vendor column tag color to white for better contrast Misc • Minor layout tweaks, compact-mode toggle relocation, lint fixes and TypeScript/ESLint clean-up These changes collectively deliver end-to-end model & vendor administration while unifying visual language across management tables.
This commit is contained in:
100
web/src/components/table/models/ModelsActions.jsx
Normal file
100
web/src/components/table/models/ModelsActions.jsx
Normal file
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
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, { useState } from 'react';
|
||||
import { Button, Space, Modal } from '@douyinfe/semi-ui';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
import { showError } from '../../../helpers';
|
||||
|
||||
const ModelsActions = ({
|
||||
selectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
t,
|
||||
}) => {
|
||||
// Modal states
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
|
||||
// Handle delete selected models with confirmation
|
||||
const handleDeleteSelectedModels = () => {
|
||||
if (selectedKeys.length === 0) {
|
||||
showError(t('请至少选择一个模型!'));
|
||||
return;
|
||||
}
|
||||
setShowDeleteModal(true);
|
||||
};
|
||||
|
||||
// Handle delete confirmation
|
||||
const handleConfirmDelete = () => {
|
||||
batchDeleteModels();
|
||||
setShowDeleteModal(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-wrap gap-2 w-full md:w-auto order-2 md:order-1">
|
||||
<Button
|
||||
type="primary"
|
||||
className="flex-1 md:flex-initial"
|
||||
onClick={() => {
|
||||
setEditingModel({
|
||||
id: undefined,
|
||||
});
|
||||
setShowEdit(true);
|
||||
}}
|
||||
size="small"
|
||||
>
|
||||
{t('添加模型')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='danger'
|
||||
className="w-full md:w-auto"
|
||||
onClick={handleDeleteSelectedModels}
|
||||
size="small"
|
||||
>
|
||||
{t('删除所选模型')}
|
||||
</Button>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Modal
|
||||
title={t('批量删除模型')}
|
||||
visible={showDeleteModal}
|
||||
onCancel={() => setShowDeleteModal(false)}
|
||||
onOk={handleConfirmDelete}
|
||||
type="warning"
|
||||
>
|
||||
<div>
|
||||
{t('确定要删除所选的 {{count}} 个模型吗?', { count: selectedKeys.length })}
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsActions;
|
||||
259
web/src/components/table/models/ModelsColumnDefs.js
Normal file
259
web/src/components/table/models/ModelsColumnDefs.js
Normal file
@@ -0,0 +1,259 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import {
|
||||
Button,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
Modal,
|
||||
Popover
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
timestamp2string,
|
||||
getLobeHubIcon,
|
||||
stringToColor
|
||||
} from '../../../helpers';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
// Render timestamp
|
||||
function renderTimestamp(timestamp) {
|
||||
return <>{timestamp2string(timestamp)}</>;
|
||||
}
|
||||
|
||||
// Render vendor column with icon
|
||||
const renderVendorTag = (vendorId, vendorMap, t) => {
|
||||
if (!vendorId || !vendorMap[vendorId]) return '-';
|
||||
const v = vendorMap[vendorId];
|
||||
return (
|
||||
<Tag
|
||||
color='white'
|
||||
shape='circle'
|
||||
prefixIcon={getLobeHubIcon(v.icon || 'Layers', 14)}
|
||||
>
|
||||
{v.name}
|
||||
</Tag>
|
||||
);
|
||||
};
|
||||
|
||||
// Render description with ellipsis
|
||||
const renderDescription = (text) => {
|
||||
return (
|
||||
<Text ellipsis={{ showTooltip: true }} style={{ maxWidth: 200 }}>
|
||||
{text || '-'}
|
||||
</Text>
|
||||
);
|
||||
};
|
||||
|
||||
// Render tags
|
||||
const renderTags = (text) => {
|
||||
if (!text) return '-';
|
||||
const tagsArr = text.split(',').filter(Boolean);
|
||||
const maxDisplayTags = 3;
|
||||
const displayTags = tagsArr.slice(0, maxDisplayTags);
|
||||
const remainingTags = tagsArr.slice(maxDisplayTags);
|
||||
|
||||
return (
|
||||
<Space spacing={1} wrap>
|
||||
{displayTags.map((tag, index) => (
|
||||
<Tag key={index} size="small" shape='circle' color={stringToColor(tag)}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
{remainingTags.length > 0 && (
|
||||
<Popover
|
||||
content={
|
||||
<div className='p-2'>
|
||||
<Space spacing={1} wrap>
|
||||
{remainingTags.map((tag, index) => (
|
||||
<Tag key={index} size="small" shape='circle' color={stringToColor(tag)}>
|
||||
{tag}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
position="top"
|
||||
>
|
||||
<Tag size="small" shape='circle' color="grey">
|
||||
+{remainingTags.length}
|
||||
</Tag>
|
||||
</Popover>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
// Render endpoints
|
||||
const renderEndpoints = (text) => {
|
||||
try {
|
||||
const arr = JSON.parse(text);
|
||||
if (Array.isArray(arr)) {
|
||||
return (
|
||||
<Space spacing={1} wrap>
|
||||
{arr.map((ep) => (
|
||||
<Tag key={ep} color="blue" size="small" shape='circle'>
|
||||
{ep}
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
}
|
||||
} catch (_) { }
|
||||
return text || '-';
|
||||
};
|
||||
|
||||
// Render bound channels
|
||||
const renderBoundChannels = (channels) => {
|
||||
if (!channels || channels.length === 0) return '-';
|
||||
return (
|
||||
<Space spacing={1} wrap>
|
||||
{channels.map((c, idx) => (
|
||||
<Tag key={idx} color="purple" size="small" shape='circle'>
|
||||
{c.name}({c.type})
|
||||
</Tag>
|
||||
))}
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
// Render operations column
|
||||
const renderOperations = (text, record, setEditingModel, setShowEdit, manageModel, refresh, t) => {
|
||||
return (
|
||||
<Space wrap>
|
||||
{record.status === 1 ? (
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => manageModel(record.id, 'disable', record)}
|
||||
>
|
||||
{t('禁用')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="small"
|
||||
onClick={() => manageModel(record.id, 'enable', record)}
|
||||
>
|
||||
{t('启用')}
|
||||
</Button>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditingModel(record);
|
||||
setShowEdit(true);
|
||||
}}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='danger'
|
||||
size="small"
|
||||
onClick={() => {
|
||||
Modal.confirm({
|
||||
title: t('确定是否要删除此模型?'),
|
||||
content: t('此修改将不可逆'),
|
||||
onOk: () => {
|
||||
(async () => {
|
||||
await manageModel(record.id, 'delete', record);
|
||||
await refresh();
|
||||
})();
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Button>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
export const getModelsColumns = ({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
}) => {
|
||||
return [
|
||||
{
|
||||
title: t('模型名称'),
|
||||
dataIndex: 'model_name',
|
||||
},
|
||||
{
|
||||
title: t('描述'),
|
||||
dataIndex: 'description',
|
||||
render: renderDescription,
|
||||
},
|
||||
{
|
||||
title: t('供应商'),
|
||||
dataIndex: 'vendor_id',
|
||||
render: (vendorId, record) => renderVendorTag(vendorId, vendorMap, t),
|
||||
},
|
||||
{
|
||||
title: t('标签'),
|
||||
dataIndex: 'tags',
|
||||
render: renderTags,
|
||||
},
|
||||
{
|
||||
title: t('端点'),
|
||||
dataIndex: 'endpoints',
|
||||
render: renderEndpoints,
|
||||
},
|
||||
{
|
||||
title: t('已绑定渠道'),
|
||||
dataIndex: 'bound_channels',
|
||||
render: renderBoundChannels,
|
||||
},
|
||||
{
|
||||
title: t('创建时间'),
|
||||
dataIndex: 'created_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('更新时间'),
|
||||
dataIndex: 'updated_time',
|
||||
render: (text, record, index) => {
|
||||
return <div>{renderTimestamp(text)}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '',
|
||||
dataIndex: 'operate',
|
||||
fixed: 'right',
|
||||
render: (text, record, index) => renderOperations(
|
||||
text,
|
||||
record,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
manageModel,
|
||||
refresh,
|
||||
t
|
||||
),
|
||||
},
|
||||
];
|
||||
};
|
||||
44
web/src/components/table/models/ModelsDescription.jsx
Normal file
44
web/src/components/table/models/ModelsDescription.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Typography } from '@douyinfe/semi-ui';
|
||||
import { Layers } from 'lucide-react';
|
||||
import CompactModeToggle from '../../common/ui/CompactModeToggle';
|
||||
|
||||
const { Text } = Typography;
|
||||
|
||||
const ModelsDescription = ({ compactMode, setCompactMode, t }) => {
|
||||
return (
|
||||
<div className="flex flex-col md:flex-row justify-between items-start md:items-center gap-2 w-full">
|
||||
<div className="flex items-center text-green-500">
|
||||
<Layers size={16} className="mr-2" />
|
||||
<Text>{t('模型管理')}</Text>
|
||||
</div>
|
||||
|
||||
<CompactModeToggle
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsDescription;
|
||||
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
106
web/src/components/table/models/ModelsFilters.jsx
Normal file
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
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, { useRef } from 'react';
|
||||
import { Form, Button } from '@douyinfe/semi-ui';
|
||||
import { IconSearch } from '@douyinfe/semi-icons';
|
||||
|
||||
const ModelsFilters = ({
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
t,
|
||||
}) => {
|
||||
// Handle form reset and immediate search
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const handleReset = () => {
|
||||
if (!formApiRef.current) return;
|
||||
formApiRef.current.reset();
|
||||
setTimeout(() => {
|
||||
searchModels();
|
||||
}, 100);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
initValues={formInitValues}
|
||||
getFormApi={(api) => {
|
||||
setFormApi(api);
|
||||
formApiRef.current = api;
|
||||
}}
|
||||
onSubmit={searchModels}
|
||||
allowEmpty={true}
|
||||
autoComplete="off"
|
||||
layout="horizontal"
|
||||
trigger="change"
|
||||
stopValidateWithError={false}
|
||||
className="w-full md:w-auto order-1 md:order-2"
|
||||
>
|
||||
<div className="flex flex-col md:flex-row items-center gap-2 w-full md:w-auto">
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchKeyword"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索模型名称')}
|
||||
showClear
|
||||
pure
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full md:w-56">
|
||||
<Form.Input
|
||||
field="searchVendor"
|
||||
prefix={<IconSearch />}
|
||||
placeholder={t('搜索供应商')}
|
||||
showClear
|
||||
pure
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 w-full md:w-auto">
|
||||
<Button
|
||||
type="tertiary"
|
||||
htmlType="submit"
|
||||
loading={loading || searching}
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
size="small"
|
||||
>
|
||||
{t('查询')}
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
type='tertiary'
|
||||
onClick={handleReset}
|
||||
className="flex-1 md:flex-initial md:w-auto"
|
||||
size="small"
|
||||
>
|
||||
{t('重置')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsFilters;
|
||||
110
web/src/components/table/models/ModelsTable.jsx
Normal file
110
web/src/components/table/models/ModelsTable.jsx
Normal file
@@ -0,0 +1,110 @@
|
||||
/*
|
||||
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, { useMemo } from 'react';
|
||||
import { Empty } from '@douyinfe/semi-ui';
|
||||
import CardTable from '../../common/ui/CardTable.js';
|
||||
import {
|
||||
IllustrationNoResult,
|
||||
IllustrationNoResultDark,
|
||||
} from '@douyinfe/semi-illustrations';
|
||||
import { getModelsColumns } from './ModelsColumnDefs.js';
|
||||
|
||||
const ModelsTable = (modelsData) => {
|
||||
const {
|
||||
models,
|
||||
loading,
|
||||
activePage,
|
||||
pageSize,
|
||||
modelCount,
|
||||
compactMode,
|
||||
handlePageChange,
|
||||
handlePageSizeChange,
|
||||
rowSelection,
|
||||
handleRow,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
// Get all columns
|
||||
const columns = useMemo(() => {
|
||||
return getModelsColumns({
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
vendorMap,
|
||||
});
|
||||
}, [
|
||||
t,
|
||||
manageModel,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
refresh,
|
||||
]);
|
||||
|
||||
// Handle compact mode by removing fixed positioning
|
||||
const tableColumns = useMemo(() => {
|
||||
return compactMode ? columns.map(col => {
|
||||
if (col.dataIndex === 'operate') {
|
||||
const { fixed, ...rest } = col;
|
||||
return rest;
|
||||
}
|
||||
return col;
|
||||
}) : columns;
|
||||
}, [compactMode, columns]);
|
||||
|
||||
return (
|
||||
<CardTable
|
||||
columns={tableColumns}
|
||||
dataSource={models}
|
||||
scroll={compactMode ? undefined : { x: 'max-content' }}
|
||||
pagination={{
|
||||
currentPage: activePage,
|
||||
pageSize: pageSize,
|
||||
total: modelCount,
|
||||
showSizeChanger: true,
|
||||
pageSizeOptions: [10, 20, 50, 100],
|
||||
onPageSizeChange: handlePageSizeChange,
|
||||
onPageChange: handlePageChange,
|
||||
}}
|
||||
hidePagination={true}
|
||||
loading={loading}
|
||||
rowSelection={rowSelection}
|
||||
onRow={handleRow}
|
||||
empty={
|
||||
<Empty
|
||||
image={<IllustrationNoResult style={{ width: 150, height: 150 }} />}
|
||||
darkModeImage={<IllustrationNoResultDark style={{ width: 150, height: 150 }} />}
|
||||
description={t('搜索无结果')}
|
||||
style={{ padding: 30 }}
|
||||
/>
|
||||
}
|
||||
className="rounded-xl overflow-hidden"
|
||||
size="middle"
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTable;
|
||||
169
web/src/components/table/models/ModelsTabs.jsx
Normal file
169
web/src/components/table/models/ModelsTabs.jsx
Normal file
@@ -0,0 +1,169 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import { Tabs, TabPane, Tag, Button, Dropdown, Modal } from '@douyinfe/semi-ui';
|
||||
import { IconEdit, IconDelete } from '@douyinfe/semi-icons';
|
||||
import { getLobeHubIcon, showError, showSuccess } from '../../../helpers';
|
||||
import { API } from '../../../helpers';
|
||||
|
||||
const ModelsTabs = ({
|
||||
activeVendorKey,
|
||||
setActiveVendorKey,
|
||||
vendorCounts,
|
||||
vendors,
|
||||
loadModels,
|
||||
activePage,
|
||||
pageSize,
|
||||
setActivePage,
|
||||
setShowAddVendor,
|
||||
setShowEditVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
t
|
||||
}) => {
|
||||
const handleTabChange = (key) => {
|
||||
setActiveVendorKey(key);
|
||||
setActivePage(1);
|
||||
loadModels(1, pageSize, key);
|
||||
};
|
||||
|
||||
const handleEditVendor = (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
setEditingVendor(vendor);
|
||||
setShowEditVendor(true);
|
||||
};
|
||||
|
||||
const handleDeleteVendor = async (vendor, e) => {
|
||||
e.stopPropagation(); // 阻止事件冒泡,避免触发tab切换
|
||||
try {
|
||||
const res = await API.delete(`/api/vendors/${vendor.id}`);
|
||||
if (res.data.success) {
|
||||
showSuccess(t('供应商删除成功'));
|
||||
// 如果删除的是当前选中的供应商,切换到"全部"
|
||||
if (activeVendorKey === String(vendor.id)) {
|
||||
setActiveVendorKey('all');
|
||||
loadModels(1, pageSize, 'all');
|
||||
} else {
|
||||
loadModels(activePage, pageSize, activeVendorKey);
|
||||
}
|
||||
loadVendors(); // 重新加载供应商列表
|
||||
} else {
|
||||
showError(res.data.message || t('删除失败'));
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('删除失败'));
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
activeKey={activeVendorKey}
|
||||
type="card"
|
||||
collapsible
|
||||
onChange={handleTabChange}
|
||||
className="mb-2"
|
||||
tabBarExtraContent={
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
onClick={() => setShowAddVendor(true)}
|
||||
>
|
||||
{t('新增供应商')}
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<TabPane
|
||||
itemKey="all"
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{t('全部')}
|
||||
<Tag color={activeVendorKey === 'all' ? 'red' : 'grey'} shape='circle'>
|
||||
{vendorCounts['all'] || 0}
|
||||
</Tag>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
|
||||
{vendors.map((vendor) => {
|
||||
const key = String(vendor.id);
|
||||
const count = vendorCounts[vendor.id] || 0;
|
||||
return (
|
||||
<TabPane
|
||||
key={key}
|
||||
itemKey={key}
|
||||
tab={
|
||||
<span className="flex items-center gap-2">
|
||||
{getLobeHubIcon(vendor.icon || 'Layers', 14)}
|
||||
{vendor.name}
|
||||
<Tag color={activeVendorKey === key ? 'red' : 'grey'} shape='circle'>
|
||||
{count}
|
||||
</Tag>
|
||||
<Dropdown
|
||||
trigger="click"
|
||||
position="bottomRight"
|
||||
render={
|
||||
<Dropdown.Menu>
|
||||
<Dropdown.Item
|
||||
icon={<IconEdit />}
|
||||
onClick={(e) => handleEditVendor(vendor, e)}
|
||||
>
|
||||
{t('编辑')}
|
||||
</Dropdown.Item>
|
||||
<Dropdown.Item
|
||||
type="danger"
|
||||
icon={<IconDelete />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
Modal.confirm({
|
||||
title: t('确认删除'),
|
||||
content: t('确定要删除供应商 "{{name}}" 吗?此操作不可撤销。', { name: vendor.name }),
|
||||
onOk: () => handleDeleteVendor(vendor, e),
|
||||
okText: t('删除'),
|
||||
cancelText: t('取消'),
|
||||
type: 'warning',
|
||||
okType: 'danger',
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t('删除')}
|
||||
</Dropdown.Item>
|
||||
</Dropdown.Menu>
|
||||
}
|
||||
onClickOutSide={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
type="tertiary"
|
||||
theme="outline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{t('操作')}
|
||||
</Button>
|
||||
</Dropdown>
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</Tabs>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsTabs;
|
||||
140
web/src/components/table/models/index.jsx
Normal file
140
web/src/components/table/models/index.jsx
Normal file
@@ -0,0 +1,140 @@
|
||||
/*
|
||||
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 from 'react';
|
||||
import CardPro from '../../common/ui/CardPro';
|
||||
import ModelsTable from './ModelsTable.jsx';
|
||||
import ModelsActions from './ModelsActions.jsx';
|
||||
import ModelsFilters from './ModelsFilters.jsx';
|
||||
import ModelsTabs from './ModelsTabs.jsx';
|
||||
import EditModelModal from './modals/EditModelModal.jsx';
|
||||
import EditVendorModal from './modals/EditVendorModal.jsx';
|
||||
import { useModelsData } from '../../../hooks/models/useModelsData';
|
||||
import { useIsMobile } from '../../../hooks/common/useIsMobile';
|
||||
import { createCardProPagination } from '../../../helpers/utils';
|
||||
|
||||
const ModelsPage = () => {
|
||||
const modelsData = useModelsData();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const {
|
||||
// Edit state
|
||||
showEdit,
|
||||
editingModel,
|
||||
closeEdit,
|
||||
refresh,
|
||||
|
||||
// Actions state
|
||||
selectedKeys,
|
||||
setEditingModel,
|
||||
setShowEdit,
|
||||
batchDeleteModels,
|
||||
|
||||
// Filters state
|
||||
formInitValues,
|
||||
setFormApi,
|
||||
searchModels,
|
||||
loading,
|
||||
searching,
|
||||
|
||||
// Description state
|
||||
compactMode,
|
||||
setCompactMode,
|
||||
|
||||
// Vendor state
|
||||
showAddVendor,
|
||||
setShowAddVendor,
|
||||
showEditVendor,
|
||||
setShowEditVendor,
|
||||
editingVendor,
|
||||
setEditingVendor,
|
||||
loadVendors,
|
||||
|
||||
// Translation
|
||||
t,
|
||||
} = modelsData;
|
||||
|
||||
return (
|
||||
<>
|
||||
<EditModelModal
|
||||
refresh={refresh}
|
||||
editingModel={editingModel}
|
||||
visiable={showEdit}
|
||||
handleClose={closeEdit}
|
||||
/>
|
||||
|
||||
<EditVendorModal
|
||||
visible={showAddVendor || showEditVendor}
|
||||
handleClose={() => {
|
||||
setShowAddVendor(false);
|
||||
setShowEditVendor(false);
|
||||
setEditingVendor({ id: undefined });
|
||||
}}
|
||||
editingVendor={showEditVendor ? editingVendor : { id: undefined }}
|
||||
refresh={() => {
|
||||
loadVendors();
|
||||
refresh();
|
||||
}}
|
||||
/>
|
||||
|
||||
<CardPro
|
||||
type="type3"
|
||||
tabsArea={<ModelsTabs {...modelsData} />}
|
||||
actionsArea={
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-2 w-full">
|
||||
<ModelsActions
|
||||
selectedKeys={selectedKeys}
|
||||
setEditingModel={setEditingModel}
|
||||
setShowEdit={setShowEdit}
|
||||
batchDeleteModels={batchDeleteModels}
|
||||
compactMode={compactMode}
|
||||
setCompactMode={setCompactMode}
|
||||
t={t}
|
||||
/>
|
||||
|
||||
<div className="w-full md:w-full lg:w-auto order-1 md:order-2">
|
||||
<ModelsFilters
|
||||
formInitValues={formInitValues}
|
||||
setFormApi={setFormApi}
|
||||
searchModels={searchModels}
|
||||
loading={loading}
|
||||
searching={searching}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
paginationArea={createCardProPagination({
|
||||
currentPage: modelsData.activePage,
|
||||
pageSize: modelsData.pageSize,
|
||||
total: modelsData.modelCount,
|
||||
onPageChange: modelsData.handlePageChange,
|
||||
onPageSizeChange: modelsData.handlePageSizeChange,
|
||||
isMobile: isMobile,
|
||||
t: modelsData.t,
|
||||
})}
|
||||
t={modelsData.t}
|
||||
>
|
||||
<ModelsTable {...modelsData} />
|
||||
</CardPro>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default ModelsPage;
|
||||
368
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
368
web/src/components/table/models/modals/EditModelModal.jsx
Normal file
@@ -0,0 +1,368 @@
|
||||
/*
|
||||
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, { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import {
|
||||
SideSheet,
|
||||
Form,
|
||||
Button,
|
||||
Space,
|
||||
Spin,
|
||||
Typography,
|
||||
Card,
|
||||
Tag,
|
||||
Avatar,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import {
|
||||
IconSave,
|
||||
IconClose,
|
||||
IconLayers,
|
||||
} from '@douyinfe/semi-icons';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const endpointOptions = [
|
||||
{ label: 'OpenAI', value: 'openai' },
|
||||
{ label: 'Anthropic', value: 'anthropic' },
|
||||
{ label: 'Gemini', value: 'gemini' },
|
||||
{ label: 'Image Generation', value: 'image-generation' },
|
||||
{ label: 'Jina Rerank', value: 'jina-rerank' },
|
||||
];
|
||||
|
||||
const { Text, Title } = Typography;
|
||||
|
||||
const EditModelModal = (props) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const isMobile = useIsMobile();
|
||||
const formApiRef = useRef(null);
|
||||
const isEdit = props.editingModel && props.editingModel.id !== undefined;
|
||||
const placement = useMemo(() => (isEdit ? 'right' : 'left'), [isEdit]);
|
||||
|
||||
// 供应商列表
|
||||
const [vendors, setVendors] = useState([]);
|
||||
|
||||
// 获取供应商列表
|
||||
const fetchVendors = async () => {
|
||||
try {
|
||||
const res = await API.get('/api/vendors/?page_size=1000'); // 获取全部供应商
|
||||
if (res.data.success) {
|
||||
const items = res.data.data.items || res.data.data || [];
|
||||
setVendors(Array.isArray(items) ? items : []);
|
||||
}
|
||||
} catch (error) {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchVendors();
|
||||
}, []);
|
||||
|
||||
|
||||
const getInitValues = () => ({
|
||||
model_name: '',
|
||||
description: '',
|
||||
tags: [],
|
||||
vendor_id: undefined,
|
||||
vendor: '',
|
||||
vendor_icon: '',
|
||||
endpoints: [],
|
||||
status: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
props.handleClose();
|
||||
};
|
||||
|
||||
const loadModel = async () => {
|
||||
if (!isEdit || !props.editingModel.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/models/${props.editingModel.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 处理tags
|
||||
if (data.tags) {
|
||||
data.tags = data.tags.split(',').filter(Boolean);
|
||||
} else {
|
||||
data.tags = [];
|
||||
}
|
||||
// 处理endpoints
|
||||
if (data.endpoints) {
|
||||
try {
|
||||
data.endpoints = JSON.parse(data.endpoints);
|
||||
} catch (e) {
|
||||
data.endpoints = [];
|
||||
}
|
||||
} else {
|
||||
data.endpoints = [];
|
||||
}
|
||||
// 处理status,将数字转为布尔值
|
||||
data.status = data.status === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载模型信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (formApiRef.current) {
|
||||
if (!isEdit) {
|
||||
formApiRef.current.setValues(getInitValues());
|
||||
}
|
||||
}
|
||||
}, [props.editingModel?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
if (props.visiable) {
|
||||
if (isEdit) {
|
||||
loadModel();
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [props.visiable, props.editingModel?.id]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const submitData = {
|
||||
...values,
|
||||
tags: Array.isArray(values.tags) ? values.tags.join(',') : values.tags,
|
||||
endpoints: JSON.stringify(values.endpoints || []),
|
||||
status: values.status ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = props.editingModel.id;
|
||||
const res = await API.put('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型更新成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/models/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('模型创建成功!'));
|
||||
props.refresh();
|
||||
props.handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
};
|
||||
|
||||
return (
|
||||
<SideSheet
|
||||
placement={placement}
|
||||
title={
|
||||
<Space>
|
||||
{isEdit ? (
|
||||
<Tag color='blue' shape='circle'>
|
||||
{t('更新')}
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag color='green' shape='circle'>
|
||||
{t('新建')}
|
||||
</Tag>
|
||||
)}
|
||||
<Title heading={4} className='m-0'>
|
||||
{isEdit ? t('更新模型信息') : t('创建新的模型')}
|
||||
</Title>
|
||||
</Space>
|
||||
}
|
||||
bodyStyle={{ padding: '0' }}
|
||||
visible={props.visiable}
|
||||
width={isMobile ? '100%' : 600}
|
||||
footer={
|
||||
<div className='flex justify-end bg-white'>
|
||||
<Space>
|
||||
<Button
|
||||
theme='solid'
|
||||
className='!rounded-lg'
|
||||
onClick={() => formApiRef.current?.submitForm()}
|
||||
icon={<IconSave />}
|
||||
loading={loading}
|
||||
>
|
||||
{t('提交')}
|
||||
</Button>
|
||||
<Button
|
||||
theme='light'
|
||||
className='!rounded-lg'
|
||||
type='primary'
|
||||
onClick={handleCancel}
|
||||
icon={<IconClose />}
|
||||
>
|
||||
{t('取消')}
|
||||
</Button>
|
||||
</Space>
|
||||
</div>
|
||||
}
|
||||
closeIcon={null}
|
||||
onCancel={() => handleCancel()}
|
||||
>
|
||||
<Spin spinning={loading}>
|
||||
<Form
|
||||
key={isEdit ? 'edit' : 'new'}
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
{({ values }) => (
|
||||
<div className='p-2'>
|
||||
{/* 基本信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='green' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('基本信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置模型的基本信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field='model_name'
|
||||
label={t('模型名称')}
|
||||
placeholder={t('请输入模型名称,如:gpt-4')}
|
||||
rules={[{ required: true, message: t('请输入模型名称') }]}
|
||||
disabled={isEdit}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field='description'
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入模型描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TagInput
|
||||
field='tags'
|
||||
label={t('标签')}
|
||||
placeholder={t('输入标签后按回车添加')}
|
||||
addOnBlur
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 供应商信息 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='blue' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('供应商信息')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置模型的供应商相关信息')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='vendor_id'
|
||||
label={t('供应商')}
|
||||
placeholder={t('选择模型供应商')}
|
||||
optionList={vendors.map(v => ({ label: v.name, value: v.id }))}
|
||||
filter
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
onChange={(value) => {
|
||||
const vendorInfo = vendors.find(v => v.id === value);
|
||||
if (vendorInfo && formApiRef.current) {
|
||||
formApiRef.current.setValue('vendor', vendorInfo.name);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* 功能配置 */}
|
||||
<Card className='!rounded-2xl shadow-sm border-0'>
|
||||
<div className='flex items-center mb-2'>
|
||||
<Avatar size='small' color='purple' className='mr-2 shadow-md'>
|
||||
<IconLayers size={16} />
|
||||
</Avatar>
|
||||
<div>
|
||||
<Text className='text-lg font-medium'>{t('功能配置')}</Text>
|
||||
<div className='text-xs text-gray-600'>{t('设置模型的功能和状态')}</div>
|
||||
</div>
|
||||
</div>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Select
|
||||
field='endpoints'
|
||||
label={t('支持端点')}
|
||||
placeholder={t('选择模型支持的端点类型')}
|
||||
optionList={endpointOptions}
|
||||
multiple
|
||||
showClear
|
||||
style={{ width: '100%' }}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field='status'
|
||||
label={t('状态')}
|
||||
size="large"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
</Spin>
|
||||
</SideSheet>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditModelModal;
|
||||
177
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
177
web/src/components/table/models/modals/EditVendorModal.jsx
Normal file
@@ -0,0 +1,177 @@
|
||||
/*
|
||||
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, { useState, useRef, useEffect } from 'react';
|
||||
import {
|
||||
Modal,
|
||||
Form,
|
||||
Col,
|
||||
Row,
|
||||
} from '@douyinfe/semi-ui';
|
||||
import { API, showError, showSuccess } from '../../../../helpers';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useIsMobile } from '../../../../hooks/common/useIsMobile';
|
||||
|
||||
const EditVendorModal = ({ visible, handleClose, refresh, editingVendor }) => {
|
||||
const { t } = useTranslation();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const formApiRef = useRef(null);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
const isEdit = editingVendor && editingVendor.id !== undefined;
|
||||
|
||||
const getInitValues = () => ({
|
||||
name: '',
|
||||
description: '',
|
||||
icon: '',
|
||||
status: true,
|
||||
});
|
||||
|
||||
const handleCancel = () => {
|
||||
handleClose();
|
||||
formApiRef.current?.reset();
|
||||
};
|
||||
|
||||
const loadVendor = async () => {
|
||||
if (!isEdit || !editingVendor.id) return;
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await API.get(`/api/vendors/${editingVendor.id}`);
|
||||
const { success, message, data } = res.data;
|
||||
if (success) {
|
||||
// 将数字状态转为布尔值
|
||||
data.status = data.status === 1;
|
||||
if (formApiRef.current) {
|
||||
formApiRef.current.setValues({ ...getInitValues(), ...data });
|
||||
}
|
||||
} else {
|
||||
showError(message);
|
||||
}
|
||||
} catch (error) {
|
||||
showError(t('加载供应商信息失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
if (isEdit) {
|
||||
loadVendor();
|
||||
} else {
|
||||
formApiRef.current?.setValues(getInitValues());
|
||||
}
|
||||
} else {
|
||||
formApiRef.current?.reset();
|
||||
}
|
||||
}, [visible, editingVendor?.id]);
|
||||
|
||||
const submit = async (values) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
// 转换 status 为数字
|
||||
const submitData = {
|
||||
...values,
|
||||
status: values.status ? 1 : 0,
|
||||
};
|
||||
|
||||
if (isEdit) {
|
||||
submitData.id = editingVendor.id;
|
||||
const res = await API.put('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商更新成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
} else {
|
||||
const res = await API.post('/api/vendors/', submitData);
|
||||
const { success, message } = res.data;
|
||||
if (success) {
|
||||
showSuccess(t('供应商创建成功!'));
|
||||
refresh();
|
||||
handleClose();
|
||||
} else {
|
||||
showError(t(message));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
showError(error.response?.data?.message || t('操作失败'));
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isEdit ? t('编辑供应商') : t('新增供应商')}
|
||||
visible={visible}
|
||||
onOk={() => formApiRef.current?.submitForm()}
|
||||
onCancel={handleCancel}
|
||||
confirmLoading={loading}
|
||||
size={isMobile ? 'full-width' : 'small'}
|
||||
>
|
||||
<Form
|
||||
initValues={getInitValues()}
|
||||
getFormApi={(api) => (formApiRef.current = api)}
|
||||
onSubmit={submit}
|
||||
>
|
||||
<Row gutter={12}>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field="name"
|
||||
label={t('供应商名称')}
|
||||
placeholder={t('请输入供应商名称,如:OpenAI')}
|
||||
rules={[{ required: true, message: t('请输入供应商名称') }]}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.TextArea
|
||||
field="description"
|
||||
label={t('描述')}
|
||||
placeholder={t('请输入供应商描述')}
|
||||
rows={3}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Input
|
||||
field="icon"
|
||||
label={t('供应商图标')}
|
||||
placeholder={t('请输入图标名称,如:OpenAI、Claude.Color')}
|
||||
showClear
|
||||
/>
|
||||
</Col>
|
||||
<Col span={24}>
|
||||
<Form.Switch
|
||||
field="status"
|
||||
label={t('状态')}
|
||||
size="large"
|
||||
initValue={true}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</Form>
|
||||
</Modal>
|
||||
);
|
||||
};
|
||||
|
||||
export default EditVendorModal;
|
||||
Reference in New Issue
Block a user