| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| | |
| |
|
| |
|
| | import React, { useState, useEffect, useMemo, useRef } from 'react';
|
| | import {
|
| | Modal,
|
| | Form,
|
| | Input,
|
| | Select,
|
| | InputNumber,
|
| | Switch,
|
| | Collapse,
|
| | Card,
|
| | Divider,
|
| | Button,
|
| | Typography,
|
| | Space,
|
| | Spin,
|
| | Tag,
|
| | Row,
|
| | Col,
|
| | Tooltip,
|
| | Radio,
|
| | } from '@douyinfe/semi-ui';
|
| | import {
|
| | IconPlus,
|
| | IconMinus,
|
| | IconHelpCircle,
|
| | IconCopy,
|
| | } from '@douyinfe/semi-icons';
|
| | import { API } from '../../../../helpers';
|
| | import { showError, showSuccess, copy } from '../../../../helpers';
|
| |
|
| | const { Text, Title } = Typography;
|
| | const { Option } = Select;
|
| | const RadioGroup = Radio.Group;
|
| |
|
| | const BUILTIN_IMAGE = 'ollama/ollama:latest';
|
| | const DEFAULT_TRAFFIC_PORT = 11434;
|
| |
|
| | const generateRandomKey = () => {
|
| | try {
|
| | if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
| | return `ionet-${crypto.randomUUID().replace(/-/g, '')}`;
|
| | }
|
| | } catch (error) {
|
| |
|
| | }
|
| | return `ionet-${Math.random().toString(36).slice(2)}${Math.random()
|
| | .toString(36)
|
| | .slice(2)}`;
|
| | };
|
| |
|
| | const CreateDeploymentModal = ({ visible, onCancel, onSuccess, t }) => {
|
| | const [formApi, setFormApi] = useState(null);
|
| | const [loading, setLoading] = useState(false);
|
| | const [submitting, setSubmitting] = useState(false);
|
| |
|
| |
|
| | const [hardwareTypes, setHardwareTypes] = useState([]);
|
| | const [hardwareTotalAvailable, setHardwareTotalAvailable] = useState(null);
|
| | const [locations, setLocations] = useState([]);
|
| | const [locationTotalAvailable, setLocationTotalAvailable] = useState(null);
|
| | const [priceEstimation, setPriceEstimation] = useState(null);
|
| |
|
| |
|
| | const [loadingHardware, setLoadingHardware] = useState(false);
|
| | const [loadingReplicas, setLoadingReplicas] = useState(false);
|
| | const [loadingPrice, setLoadingPrice] = useState(false);
|
| | const [showAdvanced, setShowAdvanced] = useState(false);
|
| | const [envVariables, setEnvVariables] = useState([{ key: '', value: '' }]);
|
| | const [secretEnvVariables, setSecretEnvVariables] = useState([
|
| | { key: '', value: '' },
|
| | ]);
|
| | const [entrypoint, setEntrypoint] = useState(['']);
|
| | const [args, setArgs] = useState(['']);
|
| | const [imageMode, setImageMode] = useState('builtin');
|
| | const [autoOllamaKey, setAutoOllamaKey] = useState('');
|
| | const customSecretEnvRef = useRef(null);
|
| | const customEnvRef = useRef(null);
|
| | const customImageRef = useRef('');
|
| | const customTrafficPortRef = useRef(null);
|
| | const prevImageModeRef = useRef('builtin');
|
| | const basicSectionRef = useRef(null);
|
| | const priceSectionRef = useRef(null);
|
| | const advancedSectionRef = useRef(null);
|
| | const replicaRequestIdRef = useRef(0);
|
| | const [formDefaults, setFormDefaults] = useState({
|
| | resource_private_name: '',
|
| | image_url: BUILTIN_IMAGE,
|
| | gpus_per_container: 1,
|
| | replica_count: 1,
|
| | duration_hours: 1,
|
| | traffic_port: DEFAULT_TRAFFIC_PORT,
|
| | location_ids: [],
|
| | });
|
| | const [formKey, setFormKey] = useState(0);
|
| | const [priceCurrency, setPriceCurrency] = useState('usdc');
|
| | const normalizeCurrencyValue = (value) => {
|
| | if (typeof value === 'string') return value.toLowerCase();
|
| | if (value && typeof value === 'object') {
|
| | if (typeof value.value === 'string') return value.value.toLowerCase();
|
| | if (typeof value.target?.value === 'string') {
|
| | return value.target.value.toLowerCase();
|
| | }
|
| | }
|
| | return 'usdc';
|
| | };
|
| |
|
| | const handleCurrencyChange = (value) => {
|
| | const normalized = normalizeCurrencyValue(value);
|
| | setPriceCurrency(normalized);
|
| | };
|
| |
|
| | const hardwareLabelMap = useMemo(() => {
|
| | const map = {};
|
| | hardwareTypes.forEach((hardware) => {
|
| | const displayName = hardware.brand_name
|
| | ? `${hardware.brand_name} ${hardware.name}`.trim()
|
| | : hardware.name;
|
| | map[hardware.id] = displayName;
|
| | });
|
| | return map;
|
| | }, [hardwareTypes]);
|
| |
|
| | const locationLabelMap = useMemo(() => {
|
| | const map = {};
|
| | locations.forEach((location) => {
|
| | map[location.id] = location.name;
|
| | });
|
| | return map;
|
| | }, [locations]);
|
| |
|
| | const getHardwareMaxGpus = (hardwareId) => {
|
| | if (!hardwareId) return 1;
|
| | const hardware = hardwareTypes.find((h) => h.id === hardwareId);
|
| | const maxGpus = Number(hardware?.max_gpus);
|
| | return Number.isFinite(maxGpus) && maxGpus > 0 ? maxGpus : 1;
|
| | };
|
| |
|
| |
|
| | const [selectedHardwareId, setSelectedHardwareId] = useState(null);
|
| | const [selectedLocationIds, setSelectedLocationIds] = useState([]);
|
| | const [gpusPerContainer, setGpusPerContainer] = useState(1);
|
| | const [durationHours, setDurationHours] = useState(1);
|
| | const [replicaCount, setReplicaCount] = useState(1);
|
| |
|
| | useEffect(() => {
|
| | if (!selectedHardwareId) {
|
| | return;
|
| | }
|
| |
|
| | const nextMaxGpus = getHardwareMaxGpus(selectedHardwareId);
|
| | if (gpusPerContainer !== nextMaxGpus) {
|
| | setGpusPerContainer(nextMaxGpus);
|
| | }
|
| | if (formApi) {
|
| | formApi.setValue('gpus_per_container', nextMaxGpus);
|
| | }
|
| | }, [selectedHardwareId, hardwareTypes, formApi, gpusPerContainer]);
|
| |
|
| |
|
| | useEffect(() => {
|
| | if (visible) {
|
| | loadHardwareTypes();
|
| | resetFormState();
|
| | }
|
| | }, [visible]);
|
| |
|
| |
|
| | useEffect(() => {
|
| | if (!visible) {
|
| | return;
|
| | }
|
| | if (selectedHardwareId && gpusPerContainer > 0) {
|
| | loadAvailableReplicas(selectedHardwareId, gpusPerContainer);
|
| | }
|
| | }, [selectedHardwareId, gpusPerContainer, visible]);
|
| |
|
| |
|
| | useEffect(() => {
|
| | if (!visible) {
|
| | return;
|
| | }
|
| | if (
|
| | selectedHardwareId &&
|
| | selectedLocationIds.length > 0 &&
|
| | gpusPerContainer > 0 &&
|
| | durationHours > 0 &&
|
| | replicaCount > 0
|
| | ) {
|
| | calculatePrice();
|
| | } else {
|
| | setPriceEstimation(null);
|
| | }
|
| | }, [
|
| | selectedHardwareId,
|
| | selectedLocationIds,
|
| | gpusPerContainer,
|
| | durationHours,
|
| | replicaCount,
|
| | priceCurrency,
|
| | visible,
|
| | ]);
|
| |
|
| | useEffect(() => {
|
| | if (!visible) {
|
| | return;
|
| | }
|
| | const prevMode = prevImageModeRef.current;
|
| | if (prevMode === imageMode) {
|
| | return;
|
| | }
|
| |
|
| | if (imageMode === 'builtin') {
|
| | if (prevMode === 'custom') {
|
| | if (formApi) {
|
| | customImageRef.current =
|
| | formApi.getValue('image_url') || customImageRef.current;
|
| | customTrafficPortRef.current =
|
| | formApi.getValue('traffic_port') ?? customTrafficPortRef.current;
|
| | }
|
| | customSecretEnvRef.current = secretEnvVariables.map((item) => ({
|
| | ...item,
|
| | }));
|
| | customEnvRef.current = envVariables.map((item) => ({ ...item }));
|
| | }
|
| | const newKey = generateRandomKey();
|
| | setAutoOllamaKey(newKey);
|
| | setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: newKey }]);
|
| | setEnvVariables([{ key: '', value: '' }]);
|
| | if (formApi) {
|
| | formApi.setValue('image_url', BUILTIN_IMAGE);
|
| | formApi.setValue('traffic_port', DEFAULT_TRAFFIC_PORT);
|
| | }
|
| | } else {
|
| | const restoredSecrets =
|
| | customSecretEnvRef.current && customSecretEnvRef.current.length > 0
|
| | ? customSecretEnvRef.current.map((item) => ({ ...item }))
|
| | : [{ key: '', value: '' }];
|
| | const restoredEnv =
|
| | customEnvRef.current && customEnvRef.current.length > 0
|
| | ? customEnvRef.current.map((item) => ({ ...item }))
|
| | : [{ key: '', value: '' }];
|
| | setSecretEnvVariables(restoredSecrets);
|
| | setEnvVariables(restoredEnv);
|
| | if (formApi) {
|
| | const restoredImage = customImageRef.current || '';
|
| | formApi.setValue('image_url', restoredImage);
|
| | if (customTrafficPortRef.current) {
|
| | formApi.setValue('traffic_port', customTrafficPortRef.current);
|
| | }
|
| | }
|
| | }
|
| |
|
| | prevImageModeRef.current = imageMode;
|
| | }, [imageMode, visible, secretEnvVariables, envVariables, formApi]);
|
| |
|
| | useEffect(() => {
|
| | if (!visible || !formApi) {
|
| | return;
|
| | }
|
| | if (imageMode === 'builtin') {
|
| | formApi.setValue('image_url', BUILTIN_IMAGE);
|
| | }
|
| | }, [formApi, imageMode, visible]);
|
| |
|
| | useEffect(() => {
|
| | if (!formApi) {
|
| | return;
|
| | }
|
| | if (selectedHardwareId !== null && selectedHardwareId !== undefined) {
|
| | formApi.setValue('hardware_id', selectedHardwareId);
|
| | }
|
| | }, [formApi, selectedHardwareId]);
|
| |
|
| | useEffect(() => {
|
| | if (!formApi) {
|
| | return;
|
| | }
|
| | formApi.setValue('location_ids', selectedLocationIds);
|
| | }, [formApi, selectedLocationIds]);
|
| |
|
| | useEffect(() => {
|
| | if (!visible) {
|
| | return;
|
| | }
|
| | if (selectedHardwareId) {
|
| | return;
|
| | } else {
|
| | setLocations([]);
|
| | setSelectedLocationIds([]);
|
| | setLocationTotalAvailable(null);
|
| | setLoadingReplicas(false);
|
| | replicaRequestIdRef.current = 0;
|
| | if (formApi) {
|
| | formApi.setValue('location_ids', []);
|
| | }
|
| | }
|
| | }, [selectedHardwareId, visible, formApi]);
|
| |
|
| | const resetFormState = () => {
|
| | const randomName = `deployment-${Math.random().toString(36).slice(2, 8)}`;
|
| | const generatedKey = generateRandomKey();
|
| |
|
| | setSelectedHardwareId(null);
|
| | setSelectedLocationIds([]);
|
| | setGpusPerContainer(1);
|
| | setDurationHours(1);
|
| | setReplicaCount(1);
|
| | setPriceEstimation(null);
|
| | setLocations([]);
|
| | setLocationTotalAvailable(null);
|
| | setHardwareTotalAvailable(null);
|
| | setEnvVariables([{ key: '', value: '' }]);
|
| | setSecretEnvVariables([{ key: 'OLLAMA_API_KEY', value: generatedKey }]);
|
| | setEntrypoint(['']);
|
| | setArgs(['']);
|
| | setShowAdvanced(false);
|
| | setImageMode('builtin');
|
| | setAutoOllamaKey(generatedKey);
|
| | customSecretEnvRef.current = null;
|
| | customEnvRef.current = null;
|
| | customImageRef.current = '';
|
| | customTrafficPortRef.current = DEFAULT_TRAFFIC_PORT;
|
| | prevImageModeRef.current = 'builtin';
|
| | setFormDefaults({
|
| | resource_private_name: randomName,
|
| | image_url: BUILTIN_IMAGE,
|
| | gpus_per_container: 1,
|
| | replica_count: 1,
|
| | duration_hours: 1,
|
| | traffic_port: DEFAULT_TRAFFIC_PORT,
|
| | location_ids: [],
|
| | });
|
| | setFormKey((prev) => prev + 1);
|
| | setPriceCurrency('usdc');
|
| | };
|
| |
|
| | const arraysEqual = (a = [], b = []) =>
|
| | a.length === b.length && a.every((value, index) => value === b[index]);
|
| |
|
| | const loadHardwareTypes = async () => {
|
| | try {
|
| | setLoadingHardware(true);
|
| | const response = await API.get('/api/deployments/hardware-types');
|
| | if (response.data.success) {
|
| | const { hardware_types: hardwareList = [], total_available } =
|
| | response.data.data || {};
|
| |
|
| | const normalizedHardware = hardwareList.map((hardware) => {
|
| | const availableCountValue = Number(hardware.available_count);
|
| | const availableCount = Number.isNaN(availableCountValue)
|
| | ? 0
|
| | : availableCountValue;
|
| | const availableBool =
|
| | typeof hardware.available === 'boolean'
|
| | ? hardware.available
|
| | : availableCount > 0;
|
| |
|
| | return {
|
| | ...hardware,
|
| | available: availableBool,
|
| | available_count: availableCount,
|
| | };
|
| | });
|
| |
|
| | const providedTotal = Number(total_available);
|
| | const fallbackTotal = normalizedHardware.reduce(
|
| | (acc, item) =>
|
| | acc +
|
| | (Number.isNaN(item.available_count) ? 0 : item.available_count),
|
| | 0,
|
| | );
|
| | const hasProvidedTotal =
|
| | total_available !== undefined &&
|
| | total_available !== null &&
|
| | total_available !== '' &&
|
| | !Number.isNaN(providedTotal);
|
| |
|
| | setHardwareTypes(normalizedHardware);
|
| | setHardwareTotalAvailable(
|
| | hasProvidedTotal ? providedTotal : fallbackTotal,
|
| | );
|
| | } else {
|
| | showError(t('获取硬件类型失败: ') + response.data.message);
|
| | }
|
| | } catch (error) {
|
| | showError(t('获取硬件类型失败: ') + error.message);
|
| | } finally {
|
| | setLoadingHardware(false);
|
| | }
|
| | };
|
| |
|
| | const loadAvailableReplicas = async (hardwareId, gpuCount) => {
|
| | if (!hardwareId || !gpuCount) {
|
| | setLocations([]);
|
| | setLocationTotalAvailable(null);
|
| | setLoadingReplicas(false);
|
| | return;
|
| | }
|
| |
|
| | const requestId = Date.now();
|
| | replicaRequestIdRef.current = requestId;
|
| | setLoadingReplicas(true);
|
| | setLocations([]);
|
| | setLocationTotalAvailable(null);
|
| |
|
| | try {
|
| | const response = await API.get(
|
| | `/api/deployments/available-replicas?hardware_id=${hardwareId}&gpu_count=${gpuCount}`,
|
| | );
|
| |
|
| | if (replicaRequestIdRef.current !== requestId) {
|
| | return;
|
| | }
|
| |
|
| | if (response.data.success) {
|
| | const replicasList = response.data.data?.replicas || [];
|
| |
|
| | const nextLocationsMap = new Map();
|
| | replicasList.forEach((replica) => {
|
| | const rawId = replica?.location_id ?? replica?.location?.id;
|
| | if (rawId === null || rawId === undefined) {
|
| | return;
|
| | }
|
| | const id = rawId;
|
| | const mapKey = String(rawId);
|
| | const existing = nextLocationsMap.get(mapKey) || null;
|
| |
|
| | const rawIso2 =
|
| | replica?.iso2 ?? replica?.location_iso2 ?? replica?.location?.iso2;
|
| | const iso2 = rawIso2 ? String(rawIso2).toUpperCase() : '';
|
| |
|
| | const name =
|
| | replica?.location_name ??
|
| | replica?.location?.name ??
|
| | replica?.name ??
|
| | id;
|
| |
|
| | const available = Number(replica?.available_count) || 0;
|
| | if (existing) {
|
| | existing.available += available;
|
| | return;
|
| | }
|
| |
|
| | nextLocationsMap.set(mapKey, {
|
| | id,
|
| | name: String(name),
|
| | iso2,
|
| | region:
|
| | replica?.region ??
|
| | replica?.location_region ??
|
| | replica?.location?.region,
|
| | country:
|
| | replica?.country ??
|
| | replica?.location_country ??
|
| | replica?.location?.country,
|
| | code:
|
| | replica?.code ??
|
| | replica?.location_code ??
|
| | replica?.location?.code,
|
| | available,
|
| | });
|
| | });
|
| |
|
| | setLocations(Array.from(nextLocationsMap.values()));
|
| | setLocationTotalAvailable(
|
| | Array.from(nextLocationsMap.values()).reduce(
|
| | (total, location) => total + (location.available || 0),
|
| | 0,
|
| | ),
|
| | );
|
| | } else {
|
| | showError(t('获取可用资源失败: ') + response.data.message);
|
| | setLocationTotalAvailable(null);
|
| | }
|
| | } catch (error) {
|
| | if (replicaRequestIdRef.current === requestId) {
|
| | console.error('Load available replicas error:', error);
|
| | setLocationTotalAvailable(null);
|
| | }
|
| | } finally {
|
| | if (replicaRequestIdRef.current === requestId) {
|
| | setLoadingReplicas(false);
|
| | }
|
| | }
|
| | };
|
| |
|
| | const calculatePrice = async () => {
|
| | try {
|
| | setLoadingPrice(true);
|
| | const requestData = {
|
| | location_ids: selectedLocationIds,
|
| | hardware_id: selectedHardwareId,
|
| | gpus_per_container: gpusPerContainer,
|
| | duration_hours: durationHours,
|
| | replica_count: replicaCount,
|
| | currency: priceCurrency?.toLowerCase?.() || priceCurrency,
|
| | duration_type: 'hour',
|
| | duration_qty: durationHours,
|
| | hardware_qty: gpusPerContainer,
|
| | };
|
| |
|
| | const response = await API.post(
|
| | '/api/deployments/price-estimation',
|
| | requestData,
|
| | );
|
| | if (response.data.success) {
|
| | setPriceEstimation(response.data.data);
|
| | } else {
|
| | showError(t('价格计算失败: ') + response.data.message);
|
| | setPriceEstimation(null);
|
| | }
|
| | } catch (error) {
|
| | console.error('Price calculation error:', error);
|
| | setPriceEstimation(null);
|
| | } finally {
|
| | setLoadingPrice(false);
|
| | }
|
| | };
|
| |
|
| | const handleSubmit = async (values) => {
|
| | try {
|
| | setSubmitting(true);
|
| |
|
| |
|
| | const envVars = {};
|
| | envVariables.forEach((env) => {
|
| | if (env.key && env.value) {
|
| | envVars[env.key] = env.value;
|
| | }
|
| | });
|
| |
|
| | const secretEnvVars = {};
|
| | secretEnvVariables.forEach((env) => {
|
| | if (env.key && env.value) {
|
| | secretEnvVars[env.key] = env.value;
|
| | }
|
| | });
|
| |
|
| | if (imageMode === 'builtin') {
|
| | if (!secretEnvVars.OLLAMA_API_KEY) {
|
| | const ensuredKey = autoOllamaKey || generateRandomKey();
|
| | secretEnvVars.OLLAMA_API_KEY = ensuredKey;
|
| | setAutoOllamaKey(ensuredKey);
|
| | }
|
| | }
|
| |
|
| |
|
| | const cleanEntrypoint = entrypoint.filter((item) => item.trim() !== '');
|
| | const cleanArgs = args.filter((item) => item.trim() !== '');
|
| |
|
| | const resolvedImage =
|
| | imageMode === 'builtin' ? BUILTIN_IMAGE : values.image_url;
|
| | const resolvedTrafficPort =
|
| | values.traffic_port ||
|
| | (imageMode === 'builtin' ? DEFAULT_TRAFFIC_PORT : undefined);
|
| |
|
| | const requestData = {
|
| | resource_private_name: values.resource_private_name,
|
| | duration_hours: values.duration_hours,
|
| | gpus_per_container: gpusPerContainer,
|
| | hardware_id: values.hardware_id,
|
| | location_ids: values.location_ids,
|
| | container_config: {
|
| | replica_count: values.replica_count,
|
| | env_variables: envVars,
|
| | secret_env_variables: secretEnvVars,
|
| | entrypoint: cleanEntrypoint.length > 0 ? cleanEntrypoint : undefined,
|
| | args: cleanArgs.length > 0 ? cleanArgs : undefined,
|
| | traffic_port: resolvedTrafficPort,
|
| | },
|
| | registry_config: {
|
| | image_url: resolvedImage,
|
| | registry_username: values.registry_username || undefined,
|
| | registry_secret: values.registry_secret || undefined,
|
| | },
|
| | };
|
| |
|
| | const response = await API.post('/api/deployments', requestData);
|
| |
|
| | if (response.data.success) {
|
| | showSuccess(t('容器创建成功'));
|
| | onSuccess?.(response.data.data);
|
| | onCancel();
|
| | } else {
|
| | showError(t('容器创建失败: ') + response.data.message);
|
| | }
|
| | } catch (error) {
|
| | showError(t('容器创建失败: ') + error.message);
|
| | } finally {
|
| | setSubmitting(false);
|
| | }
|
| | };
|
| |
|
| | const handleAddEnvVariable = (type) => {
|
| | if (type === 'env') {
|
| | setEnvVariables([...envVariables, { key: '', value: '' }]);
|
| | } else {
|
| | setSecretEnvVariables([...secretEnvVariables, { key: '', value: '' }]);
|
| | }
|
| | };
|
| |
|
| | const handleRemoveEnvVariable = (index, type) => {
|
| | if (type === 'env') {
|
| | const newEnvVars = envVariables.filter((_, i) => i !== index);
|
| | setEnvVariables(
|
| | newEnvVars.length > 0 ? newEnvVars : [{ key: '', value: '' }],
|
| | );
|
| | } else {
|
| | const newSecretEnvVars = secretEnvVariables.filter((_, i) => i !== index);
|
| | setSecretEnvVariables(
|
| | newSecretEnvVars.length > 0
|
| | ? newSecretEnvVars
|
| | : [{ key: '', value: '' }],
|
| | );
|
| | }
|
| | };
|
| |
|
| | const handleEnvVariableChange = (index, field, value, type) => {
|
| | if (type === 'env') {
|
| | const newEnvVars = [...envVariables];
|
| | newEnvVars[index][field] = value;
|
| | setEnvVariables(newEnvVars);
|
| | } else {
|
| | const newSecretEnvVars = [...secretEnvVariables];
|
| | newSecretEnvVars[index][field] = value;
|
| | setSecretEnvVariables(newSecretEnvVars);
|
| | }
|
| | };
|
| |
|
| | const handleArrayFieldChange = (index, value, type) => {
|
| | if (type === 'entrypoint') {
|
| | const newEntrypoint = [...entrypoint];
|
| | newEntrypoint[index] = value;
|
| | setEntrypoint(newEntrypoint);
|
| | } else {
|
| | const newArgs = [...args];
|
| | newArgs[index] = value;
|
| | setArgs(newArgs);
|
| | }
|
| | };
|
| |
|
| | const handleAddArrayField = (type) => {
|
| | if (type === 'entrypoint') {
|
| | setEntrypoint([...entrypoint, '']);
|
| | } else {
|
| | setArgs([...args, '']);
|
| | }
|
| | };
|
| |
|
| | const handleRemoveArrayField = (index, type) => {
|
| | if (type === 'entrypoint') {
|
| | const newEntrypoint = entrypoint.filter((_, i) => i !== index);
|
| | setEntrypoint(newEntrypoint.length > 0 ? newEntrypoint : ['']);
|
| | } else {
|
| | const newArgs = args.filter((_, i) => i !== index);
|
| | setArgs(newArgs.length > 0 ? newArgs : ['']);
|
| | }
|
| | };
|
| |
|
| | useEffect(() => {
|
| | if (!visible) {
|
| | return;
|
| | }
|
| |
|
| | if (!selectedHardwareId) {
|
| | if (selectedLocationIds.length > 0) {
|
| | setSelectedLocationIds([]);
|
| | if (formApi) {
|
| | formApi.setValue('location_ids', []);
|
| | }
|
| | }
|
| | return;
|
| | }
|
| |
|
| | const validLocationIds = locations
|
| | .filter((location) => (Number(location.available) || 0) > 0)
|
| | .map((location) => location.id);
|
| |
|
| | if (validLocationIds.length === 0) {
|
| | if (selectedLocationIds.length > 0) {
|
| | setSelectedLocationIds([]);
|
| | if (formApi) {
|
| | formApi.setValue('location_ids', []);
|
| | }
|
| | }
|
| | return;
|
| | }
|
| |
|
| | if (selectedLocationIds.length === 0) {
|
| | return;
|
| | }
|
| |
|
| | const filteredSelection = selectedLocationIds.filter((id) =>
|
| | validLocationIds.includes(id),
|
| | );
|
| |
|
| | if (!arraysEqual(selectedLocationIds, filteredSelection)) {
|
| | setSelectedLocationIds(filteredSelection);
|
| | if (formApi) {
|
| | formApi.setValue('location_ids', filteredSelection);
|
| | }
|
| | }
|
| | }, [locations, selectedHardwareId, selectedLocationIds, visible, formApi]);
|
| |
|
| | const maxAvailableReplicas = useMemo(() => {
|
| | if (!selectedLocationIds.length) return 0;
|
| |
|
| | return locations
|
| | .filter((location) => selectedLocationIds.includes(location.id))
|
| | .reduce((total, location) => {
|
| | const availableValue = Number(location.available);
|
| | return total + (Number.isNaN(availableValue) ? 0 : availableValue);
|
| | }, 0);
|
| | }, [selectedLocationIds, locations]);
|
| |
|
| | const isPriceReady = useMemo(
|
| | () =>
|
| | selectedHardwareId &&
|
| | selectedLocationIds.length > 0 &&
|
| | gpusPerContainer > 0 &&
|
| | durationHours > 0 &&
|
| | replicaCount > 0,
|
| | [
|
| | selectedHardwareId,
|
| | selectedLocationIds,
|
| | gpusPerContainer,
|
| | durationHours,
|
| | replicaCount,
|
| | ],
|
| | );
|
| |
|
| | const currencyLabel = (
|
| | priceEstimation?.currency ||
|
| | priceCurrency ||
|
| | ''
|
| | ).toUpperCase();
|
| | const selectedHardwareLabel = selectedHardwareId
|
| | ? hardwareLabelMap[selectedHardwareId]
|
| | : '';
|
| | const selectedLocationNames = selectedLocationIds
|
| | .map((id) => locationLabelMap[id])
|
| | .filter(Boolean);
|
| | const totalGpuHours =
|
| | Number(gpusPerContainer || 0) *
|
| | Number(replicaCount || 0) *
|
| | Number(durationHours || 0);
|
| | const priceSummaryItems = [
|
| | {
|
| | key: 'hardware',
|
| | label: t('硬件类型'),
|
| | value: selectedHardwareLabel || '--',
|
| | },
|
| | {
|
| | key: 'locations',
|
| | label: t('部署位置'),
|
| | value: selectedLocationNames.length
|
| | ? selectedLocationNames.join('、')
|
| | : '--',
|
| | },
|
| | {
|
| | key: 'replicas',
|
| | label: t('副本数量'),
|
| | value: (replicaCount ?? 0).toString(),
|
| | },
|
| | {
|
| | key: 'gpus',
|
| | label: t('最大GPU数量'),
|
| | value: (gpusPerContainer ?? 0).toString(),
|
| | },
|
| | {
|
| | key: 'duration',
|
| | label: t('运行时长(小时)'),
|
| | value: durationHours ? durationHours.toString() : '0',
|
| | },
|
| | {
|
| | key: 'gpu-hours',
|
| | label: t('总 GPU 小时'),
|
| | value: totalGpuHours > 0 ? totalGpuHours.toLocaleString() : '0',
|
| | },
|
| | ];
|
| |
|
| | const scrollToSection = (ref) => {
|
| | if (ref?.current && typeof ref.current.scrollIntoView === 'function') {
|
| | ref.current.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
| | }
|
| | };
|
| |
|
| | const priceUnavailableContent = (
|
| | <div style={{ marginTop: 12 }}>
|
| | {loadingPrice ? (
|
| | <Space spacing={8} align='center'>
|
| | <Spin size='small' />
|
| | <Text size='small' type='tertiary'>
|
| | {t('价格计算中...')}
|
| | </Text>
|
| | </Space>
|
| | ) : (
|
| | <Text size='small' type='tertiary'>
|
| | {isPriceReady
|
| | ? t('价格暂时不可用,请稍后重试')
|
| | : t('完成硬件类型、部署位置、副本数量等配置后,将自动计算价格')}
|
| | </Text>
|
| | )}
|
| | </div>
|
| | );
|
| |
|
| | useEffect(() => {
|
| | if (!visible || !formApi) {
|
| | return;
|
| | }
|
| | if (maxAvailableReplicas > 0 && replicaCount > maxAvailableReplicas) {
|
| | setReplicaCount(maxAvailableReplicas);
|
| | formApi.setValue('replica_count', maxAvailableReplicas);
|
| | }
|
| | }, [maxAvailableReplicas, replicaCount, visible, formApi]);
|
| |
|
| | return (
|
| | <Modal
|
| | title={t('新建容器部署')}
|
| | visible={visible}
|
| | onCancel={onCancel}
|
| | onOk={() => formApi?.submitForm()}
|
| | okText={t('创建')}
|
| | cancelText={t('取消')}
|
| | width={800}
|
| | confirmLoading={submitting}
|
| | style={{ top: 20 }}
|
| | >
|
| | <Form
|
| | key={formKey}
|
| | initValues={formDefaults}
|
| | getFormApi={setFormApi}
|
| | onSubmit={handleSubmit}
|
| | style={{ maxHeight: '70vh', overflowY: 'auto' }}
|
| | labelPosition='top'
|
| | >
|
| | <Space
|
| | wrap
|
| | spacing={8}
|
| | style={{ justifyContent: 'flex-end', width: '100%', marginBottom: 8 }}
|
| | >
|
| | <Button
|
| | size='small'
|
| | theme='borderless'
|
| | type='tertiary'
|
| | onClick={() => scrollToSection(basicSectionRef)}
|
| | >
|
| | {t('部署配置')}
|
| | </Button>
|
| | <Button
|
| | size='small'
|
| | theme='borderless'
|
| | type='tertiary'
|
| | onClick={() => scrollToSection(priceSectionRef)}
|
| | >
|
| | {t('价格预估')}
|
| | </Button>
|
| | <Button
|
| | size='small'
|
| | theme='borderless'
|
| | type='tertiary'
|
| | onClick={() => scrollToSection(advancedSectionRef)}
|
| | >
|
| | {t('高级配置')}
|
| | </Button>
|
| | </Space>
|
| |
|
| | <div ref={basicSectionRef}>
|
| | <Card className='mb-4'>
|
| | <Title heading={6}>{t('部署配置')}</Title>
|
| |
|
| | <Form.Input
|
| | field='resource_private_name'
|
| | label={t('容器名称')}
|
| | placeholder={t('请输入容器名称')}
|
| | rules={[{ required: true, message: t('请输入容器名称') }]}
|
| | />
|
| |
|
| | <div className='mt-2'>
|
| | <Text strong>{t('镜像选择')}</Text>
|
| | <div style={{ marginTop: 8 }}>
|
| | <RadioGroup
|
| | type='button'
|
| | value={imageMode}
|
| | onChange={(value) =>
|
| | setImageMode(value?.target?.value ?? value)
|
| | }
|
| | >
|
| | <Radio value='builtin'>{t('内置 Ollama 镜像')}</Radio>
|
| | <Radio value='custom'>{t('自定义镜像')}</Radio>
|
| | </RadioGroup>
|
| | </div>
|
| | </div>
|
| |
|
| | <Form.Input
|
| | field='image_url'
|
| | label={t('镜像地址')}
|
| | placeholder={t('例如:nginx:latest')}
|
| | rules={[{ required: true, message: t('请输入镜像地址') }]}
|
| | disabled={imageMode === 'builtin'}
|
| | onChange={(value) => {
|
| | if (imageMode === 'custom') {
|
| | customImageRef.current = value;
|
| | }
|
| | }}
|
| | />
|
| |
|
| | {imageMode === 'builtin' && (
|
| | <Space align='center' spacing={8} className='mt-2'>
|
| | <Text size='small' type='tertiary'>
|
| | {t('系统已为该部署准备 Ollama 镜像与随机 API Key')}
|
| | </Text>
|
| | <Input
|
| | readOnly
|
| | value={autoOllamaKey}
|
| | size='small'
|
| | style={{ width: 220 }}
|
| | />
|
| | <Button
|
| | icon={<IconCopy />}
|
| | size='small'
|
| | theme='borderless'
|
| | onClick={async () => {
|
| | if (!autoOllamaKey) {
|
| | return;
|
| | }
|
| | const copied = await copy(autoOllamaKey);
|
| | if (copied) {
|
| | showSuccess(t('已复制自动生成的 API Key'));
|
| | } else {
|
| | showError(t('复制失败,请手动选择文本复制'));
|
| | }
|
| | }}
|
| | >
|
| | {t('复制')}
|
| | </Button>
|
| | </Space>
|
| | )}
|
| |
|
| | <Row gutter={16}>
|
| | <Col xs={24} md={12}>
|
| | <Form.Select
|
| | field='hardware_id'
|
| | label={t('硬件类型')}
|
| | placeholder={t('选择硬件类型')}
|
| | loading={loadingHardware}
|
| | rules={[{ required: true, message: t('请选择硬件类型') }]}
|
| | onChange={(value) => {
|
| | const nextMaxGpus = getHardwareMaxGpus(value);
|
| | setSelectedHardwareId(value);
|
| | setGpusPerContainer(nextMaxGpus);
|
| | setSelectedLocationIds([]);
|
| | if (formApi) {
|
| | formApi.setValue('location_ids', []);
|
| | formApi.setValue('gpus_per_container', nextMaxGpus);
|
| | }
|
| | }}
|
| | style={{ width: '100%' }}
|
| | dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }}
|
| | renderSelectedItem={(optionNode) =>
|
| | optionNode
|
| | ? hardwareLabelMap[optionNode?.value] ||
|
| | optionNode?.label ||
|
| | optionNode?.value ||
|
| | ''
|
| | : ''
|
| | }
|
| | >
|
| | {hardwareTypes.map((hardware) => {
|
| | const displayName = hardware.brand_name
|
| | ? `${hardware.brand_name} ${hardware.name}`.trim()
|
| | : hardware.name;
|
| | const availableCount =
|
| | typeof hardware.available_count === 'number'
|
| | ? hardware.available_count
|
| | : 0;
|
| | const hasAvailability = availableCount > 0;
|
| |
|
| | return (
|
| | <Option key={hardware.id} value={hardware.id}>
|
| | <div className='flex flex-col gap-1'>
|
| | <Text strong>{displayName}</Text>
|
| | <div className='flex items-center gap-2 text-xs text-[var(--semi-color-text-2)]'>
|
| | <span>
|
| | {t('最大GPU数量')}: {hardware.max_gpus}
|
| | </span>
|
| | <Tag
|
| | color={hasAvailability ? 'green' : 'red'}
|
| | size='small'
|
| | >
|
| | {t('可用数量')}: {availableCount}
|
| | </Tag>
|
| | </div>
|
| | </div>
|
| | </Option>
|
| | );
|
| | })}
|
| | </Form.Select>
|
| | </Col>
|
| | <Col xs={24} md={12}>
|
| | <Form.InputNumber
|
| | field='gpus_per_container'
|
| | label={t('最大GPU数量')}
|
| | placeholder={1}
|
| | min={1}
|
| | max={getHardwareMaxGpus(selectedHardwareId)}
|
| | step={1}
|
| | disabled
|
| | style={{ width: '100%' }}
|
| | />
|
| | </Col>
|
| | </Row>
|
| |
|
| | {typeof hardwareTotalAvailable === 'number' && (
|
| | <Text size='small' type='tertiary'>
|
| | {t('全部硬件总可用资源')}: {hardwareTotalAvailable}
|
| | </Text>
|
| | )}
|
| |
|
| | <Form.Select
|
| | field='location_ids'
|
| | label={
|
| | <Space>
|
| | {t('部署位置')}
|
| | {loadingReplicas && <Spin size='small' />}
|
| | </Space>
|
| | }
|
| | placeholder={
|
| | !selectedHardwareId
|
| | ? t('请先选择硬件类型')
|
| | : loadingReplicas
|
| | ? t('正在加载可用部署位置...')
|
| | : t('选择部署位置(可多选)')
|
| | }
|
| | multiple
|
| | loading={loadingReplicas}
|
| | disabled={!selectedHardwareId || loadingReplicas}
|
| | rules={[{ required: true, message: t('请选择至少一个部署位置') }]}
|
| | onChange={(value) => setSelectedLocationIds(value)}
|
| | style={{ width: '100%' }}
|
| | dropdownStyle={{ maxHeight: 360, overflowY: 'auto' }}
|
| | renderSelectedItem={(optionNode) => ({
|
| | isRenderInTag: true,
|
| | content: !optionNode
|
| | ? ''
|
| | : loadingReplicas
|
| | ? t('部署位置加载中...')
|
| | : locationLabelMap[optionNode?.value] ||
|
| | optionNode?.label ||
|
| | optionNode?.value ||
|
| | '',
|
| | })}
|
| | >
|
| | {locations.map((location) => {
|
| | const numeric = Number(location.available);
|
| | const availableCount = Number.isNaN(numeric) ? 0 : numeric;
|
| | const locationLabel =
|
| | location.region ||
|
| | location.country ||
|
| | (location.iso2 ? location.iso2.toUpperCase() : '') ||
|
| | location.code ||
|
| | '';
|
| | const disableOption = availableCount === 0;
|
| |
|
| | return (
|
| | <Option
|
| | key={location.id}
|
| | value={location.id}
|
| | disabled={disableOption}
|
| | >
|
| | <div className='flex flex-col gap-1'>
|
| | <div className='flex items-center gap-2'>
|
| | <Text strong>{location.name}</Text>
|
| | {locationLabel && (
|
| | <Tag color='blue' size='small'>
|
| | {locationLabel}
|
| | </Tag>
|
| | )}
|
| | </div>
|
| | <Text
|
| | size='small'
|
| | type={availableCount > 0 ? 'success' : 'danger'}
|
| | >
|
| | {t('可用数量')}: {availableCount}
|
| | </Text>
|
| | </div>
|
| | </Option>
|
| | );
|
| | })}
|
| | </Form.Select>
|
| |
|
| | {typeof locationTotalAvailable === 'number' && (
|
| | <Text size='small' type='tertiary'>
|
| | {t('全部地区总可用资源')}: {locationTotalAvailable}
|
| | </Text>
|
| | )}
|
| |
|
| | <Row gutter={16}>
|
| | <Col xs={24} md={8}>
|
| | <Form.InputNumber
|
| | field='replica_count'
|
| | label={t('副本数量')}
|
| | placeholder={1}
|
| | min={1}
|
| | max={maxAvailableReplicas || 100}
|
| | rules={[{ required: true, message: t('请输入副本数量') }]}
|
| | onChange={(value) => setReplicaCount(value)}
|
| | style={{ width: '100%' }}
|
| | />
|
| | {maxAvailableReplicas > 0 && (
|
| | <Text size='small' type='tertiary'>
|
| | {t('最大可用')}: {maxAvailableReplicas}
|
| | </Text>
|
| | )}
|
| | </Col>
|
| | <Col xs={24} md={8}>
|
| | <Form.InputNumber
|
| | field='duration_hours'
|
| | label={t('运行时长(小时)')}
|
| | placeholder={1}
|
| | min={1}
|
| | max={8760} // 1 year
|
| | rules={[{ required: true, message: t('请输入运行时长') }]}
|
| | onChange={(value) => setDurationHours(value)}
|
| | style={{ width: '100%' }}
|
| | />
|
| | </Col>
|
| | <Col xs={24} md={8}>
|
| | <Form.InputNumber
|
| | field='traffic_port'
|
| | label={
|
| | <Space>
|
| | {t('流量端口')}
|
| | <Tooltip content={t('容器对外服务的端口号,可选')}>
|
| | <IconHelpCircle />
|
| | </Tooltip>
|
| | </Space>
|
| | }
|
| | placeholder={DEFAULT_TRAFFIC_PORT}
|
| | min={1}
|
| | max={65535}
|
| | style={{ width: '100%' }}
|
| | disabled={imageMode === 'builtin'}
|
| | />
|
| | </Col>
|
| | </Row>
|
| |
|
| | <div ref={advancedSectionRef}>
|
| | <Collapse className='mt-4'>
|
| | <Collapse.Panel header={t('高级配置')} itemKey='advanced'>
|
| | <Card>
|
| | <Title heading={6}>{t('镜像仓库配置')}</Title>
|
| | <Row gutter={16}>
|
| | <Col span={12}>
|
| | <Form.Input
|
| | field='registry_username'
|
| | label={t('镜像仓库用户名')}
|
| | placeholder={t('私有镜像仓库的用户名')}
|
| | />
|
| | </Col>
|
| | <Col span={12}>
|
| | <Form.Input
|
| | field='registry_secret'
|
| | label={t('镜像仓库密码')}
|
| | type='password'
|
| | placeholder={t('私有镜像仓库的密码')}
|
| | />
|
| | </Col>
|
| | </Row>
|
| | </Card>
|
| |
|
| | <Divider />
|
| |
|
| | <Card>
|
| | <Title heading={6}>{t('容器启动配置')}</Title>
|
| |
|
| | <div style={{ marginBottom: 16 }}>
|
| | <Text strong>{t('启动命令 (Entrypoint)')}</Text>
|
| | {entrypoint.map((cmd, index) => (
|
| | <div
|
| | key={index}
|
| | style={{ display: 'flex', marginTop: 8 }}
|
| | >
|
| | <Input
|
| | value={cmd}
|
| | placeholder={t('例如:/bin/bash')}
|
| | onChange={(value) =>
|
| | handleArrayFieldChange(index, value, 'entrypoint')
|
| | }
|
| | style={{ flex: 1, marginRight: 8 }}
|
| | />
|
| | <Button
|
| | icon={<IconMinus />}
|
| | onClick={() =>
|
| | handleRemoveArrayField(index, 'entrypoint')
|
| | }
|
| | disabled={entrypoint.length === 1}
|
| | />
|
| | </div>
|
| | ))}
|
| | <Button
|
| | icon={<IconPlus />}
|
| | onClick={() => handleAddArrayField('entrypoint')}
|
| | style={{ marginTop: 8 }}
|
| | >
|
| | {t('添加启动命令')}
|
| | </Button>
|
| | </div>
|
| |
|
| | <div style={{ marginBottom: 16 }}>
|
| | <Text strong>{t('启动参数 (Args)')}</Text>
|
| | {args.map((arg, index) => (
|
| | <div
|
| | key={index}
|
| | style={{ display: 'flex', marginTop: 8 }}
|
| | >
|
| | <Input
|
| | value={arg}
|
| | placeholder={t('例如:-c')}
|
| | onChange={(value) =>
|
| | handleArrayFieldChange(index, value, 'args')
|
| | }
|
| | style={{ flex: 1, marginRight: 8 }}
|
| | />
|
| | <Button
|
| | icon={<IconMinus />}
|
| | onClick={() =>
|
| | handleRemoveArrayField(index, 'args')
|
| | }
|
| | disabled={args.length === 1}
|
| | />
|
| | </div>
|
| | ))}
|
| | <Button
|
| | icon={<IconPlus />}
|
| | onClick={() => handleAddArrayField('args')}
|
| | style={{ marginTop: 8 }}
|
| | >
|
| | {t('添加启动参数')}
|
| | </Button>
|
| | </div>
|
| | </Card>
|
| |
|
| | <Divider />
|
| |
|
| | <Card>
|
| | <Title heading={6}>{t('环境变量')}</Title>
|
| |
|
| | <div style={{ marginBottom: 16 }}>
|
| | <Text strong>{t('普通环境变量')}</Text>
|
| | {envVariables.map((env, index) => (
|
| | <Row key={index} gutter={8} style={{ marginTop: 8 }}>
|
| | <Col span={10}>
|
| | <Input
|
| | placeholder={t('变量名')}
|
| | value={env.key}
|
| | onChange={(value) =>
|
| | handleEnvVariableChange(
|
| | index,
|
| | 'key',
|
| | value,
|
| | 'env',
|
| | )
|
| | }
|
| | />
|
| | </Col>
|
| | <Col span={10}>
|
| | <Input
|
| | placeholder={t('变量值')}
|
| | value={env.value}
|
| | onChange={(value) =>
|
| | handleEnvVariableChange(
|
| | index,
|
| | 'value',
|
| | value,
|
| | 'env',
|
| | )
|
| | }
|
| | />
|
| | </Col>
|
| | <Col span={4}>
|
| | <Button
|
| | icon={<IconMinus />}
|
| | onClick={() =>
|
| | handleRemoveEnvVariable(index, 'env')
|
| | }
|
| | disabled={envVariables.length === 1}
|
| | />
|
| | </Col>
|
| | </Row>
|
| | ))}
|
| | <Button
|
| | icon={<IconPlus />}
|
| | onClick={() => handleAddEnvVariable('env')}
|
| | style={{ marginTop: 8 }}
|
| | >
|
| | {t('添加环境变量')}
|
| | </Button>
|
| | </div>
|
| |
|
| | <div>
|
| | <Text strong>{t('密钥环境变量')}</Text>
|
| | {secretEnvVariables.map((env, index) => {
|
| | const isAutoSecret =
|
| | imageMode === 'builtin' &&
|
| | env.key === 'OLLAMA_API_KEY';
|
| | return (
|
| | <Row key={index} gutter={8} style={{ marginTop: 8 }}>
|
| | <Col span={10}>
|
| | <Input
|
| | placeholder={t('变量名')}
|
| | value={env.key}
|
| | onChange={(value) =>
|
| | handleEnvVariableChange(
|
| | index,
|
| | 'key',
|
| | value,
|
| | 'secret',
|
| | )
|
| | }
|
| | disabled={isAutoSecret}
|
| | />
|
| | </Col>
|
| | <Col span={10}>
|
| | <Input
|
| | placeholder={t('变量值')}
|
| | type='password'
|
| | value={env.value}
|
| | onChange={(value) =>
|
| | handleEnvVariableChange(
|
| | index,
|
| | 'value',
|
| | value,
|
| | 'secret',
|
| | )
|
| | }
|
| | disabled={isAutoSecret}
|
| | />
|
| | </Col>
|
| | <Col span={4}>
|
| | <Button
|
| | icon={<IconMinus />}
|
| | onClick={() =>
|
| | handleRemoveEnvVariable(index, 'secret')
|
| | }
|
| | disabled={
|
| | secretEnvVariables.length === 1 ||
|
| | isAutoSecret
|
| | }
|
| | />
|
| | </Col>
|
| | </Row>
|
| | );
|
| | })}
|
| | <Button
|
| | icon={<IconPlus />}
|
| | onClick={() => handleAddEnvVariable('secret')}
|
| | style={{ marginTop: 8 }}
|
| | >
|
| | {t('添加密钥环境变量')}
|
| | </Button>
|
| | </div>
|
| | </Card>
|
| | </Collapse.Panel>
|
| | </Collapse>
|
| | </div>
|
| | </Card>
|
| | </div>
|
| |
|
| | <div ref={priceSectionRef}>
|
| | <Card className='mb-4'>
|
| | <div className='flex flex-wrap items-center justify-between gap-3'>
|
| | <Title heading={6} style={{ margin: 0 }}>
|
| | {t('价格预估')}
|
| | </Title>
|
| | <Space align='center' spacing={12} className='flex flex-wrap'>
|
| | <Text type='secondary' size='small'>
|
| | {t('计价币种')}
|
| | </Text>
|
| | <RadioGroup
|
| | type='button'
|
| | value={priceCurrency}
|
| | onChange={handleCurrencyChange}
|
| | >
|
| | <Radio value='usdc'>USDC</Radio>
|
| | <Radio value='iocoin'>IOCOIN</Radio>
|
| | </RadioGroup>
|
| | <Tag size='small' color='blue'>
|
| | {currencyLabel}
|
| | </Tag>
|
| | </Space>
|
| | </div>
|
| |
|
| | {priceEstimation ? (
|
| | <div className='mt-4 flex w-full flex-col gap-4'>
|
| | <div className='grid w-full gap-4 md:grid-cols-2 lg:grid-cols-3'>
|
| | <div
|
| | className='flex flex-col gap-1 rounded-md px-4 py-3'
|
| | style={{
|
| | border: '1px solid var(--semi-color-border)',
|
| | backgroundColor: 'var(--semi-color-fill-0)',
|
| | }}
|
| | >
|
| | <Text size='small' type='tertiary'>
|
| | {t('预估总费用')}
|
| | </Text>
|
| | <div
|
| | style={{
|
| | fontSize: 24,
|
| | fontWeight: 600,
|
| | color: 'var(--semi-color-text-0)',
|
| | }}
|
| | >
|
| | {typeof priceEstimation.estimated_cost === 'number'
|
| | ? `${priceEstimation.estimated_cost.toFixed(4)} ${currencyLabel}`
|
| | : '--'}
|
| | </div>
|
| | </div>
|
| | <div
|
| | className='flex flex-col gap-1 rounded-md px-4 py-3'
|
| | style={{
|
| | border: '1px solid var(--semi-color-border)',
|
| | backgroundColor: 'var(--semi-color-fill-0)',
|
| | }}
|
| | >
|
| | <Text size='small' type='tertiary'>
|
| | {t('小时费率')}
|
| | </Text>
|
| | <Text strong>
|
| | {typeof priceEstimation.price_breakdown?.hourly_rate ===
|
| | 'number'
|
| | ? `${priceEstimation.price_breakdown.hourly_rate.toFixed(4)} ${currencyLabel}/h`
|
| | : '--'}
|
| | </Text>
|
| | </div>
|
| | <div
|
| | className='flex flex-col gap-1 rounded-md px-4 py-3'
|
| | style={{
|
| | border: '1px solid var(--semi-color-border)',
|
| | backgroundColor: 'var(--semi-color-fill-0)',
|
| | }}
|
| | >
|
| | <Text size='small' type='tertiary'>
|
| | {t('计算成本')}
|
| | </Text>
|
| | <Text strong>
|
| | {typeof priceEstimation.price_breakdown?.compute_cost ===
|
| | 'number'
|
| | ? `${priceEstimation.price_breakdown.compute_cost.toFixed(4)} ${currencyLabel}`
|
| | : '--'}
|
| | </Text>
|
| | </div>
|
| | </div>
|
| |
|
| | <div className='grid gap-3 sm:grid-cols-2 lg:grid-cols-3'>
|
| | {priceSummaryItems.map((item) => (
|
| | <div
|
| | key={item.key}
|
| | className='flex items-center justify-between gap-3 rounded-md px-3 py-2'
|
| | style={{
|
| | border: '1px solid var(--semi-color-border)',
|
| | backgroundColor: 'var(--semi-color-fill-0)',
|
| | }}
|
| | >
|
| | <Text size='small' type='tertiary'>
|
| | {item.label}
|
| | </Text>
|
| | <Text strong>{item.value}</Text>
|
| | </div>
|
| | ))}
|
| | </div>
|
| | </div>
|
| | ) : (
|
| | priceUnavailableContent
|
| | )}
|
| |
|
| | {priceEstimation && loadingPrice && (
|
| | <Space align='center' spacing={8} style={{ marginTop: 12 }}>
|
| | <Spin size='small' />
|
| | <Text size='small' type='tertiary'>
|
| | {t('价格重新计算中...')}
|
| | </Text>
|
| | </Space>
|
| | )}
|
| | </Card>
|
| | </div>
|
| | </Form>
|
| | </Modal>
|
| | );
|
| | };
|
| |
|
| | export default CreateDeploymentModal;
|
| |
|