Files
foka-ci/apps/web/src/pages/project/detail/components/EnvPresetsEditor.tsx
hurole d22fdc9618 feat: 实现环境变量预设功能 & 移除稀疏检出
## 后端改动
- 添加 Project.envPresets 字段(JSON 格式)
- 移除 Deployment.env 字段,统一使用 envVars
- 更新部署 DTO,支持 envVars (Record<string, string>)
- pipeline-runner 支持解析并注入 envVars 到环境
- 移除稀疏检出模板和相关环境变量
- 优化代码格式(Biome lint & format)

## 前端改动
- 新增 EnvPresetsEditor 组件(支持单选/多选/输入框类型)
- 项目创建/编辑界面集成环境预设编辑器
- 部署界面基于预设动态生成环境变量表单
- 移除稀疏检出表单项
- 项目详情页添加环境变量预设配置 tab
- 优化部署界面布局(基本参数 & 环境变量分区)

## 文档
- 添加完整文档目录结构(docs/)
- 创建设计文档 design-0005(部署流程重构)
- 添加 API 文档、架构设计文档等

## 数据库
- 执行 prisma db push 同步 schema 变更
2026-01-03 22:59:20 +08:00

215 lines
6.7 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Button, Checkbox, Input, Select, Space } from '@arco-design/web-react';
import { IconDelete, IconPlus } from '@arco-design/web-react/icon';
import { useEffect, useState } from 'react';
export interface EnvPreset {
key: string;
label: string;
type: 'select' | 'multiselect' | 'input';
required?: boolean; // 是否必填
options?: Array<{ label: string; value: string }>;
}
interface EnvPresetsEditorProps {
value?: EnvPreset[];
onChange?: (value: EnvPreset[]) => void;
}
function EnvPresetsEditor({ value = [], onChange }: EnvPresetsEditorProps) {
const [presets, setPresets] = useState<EnvPreset[]>(value);
// 当外部 value 变化时同步到内部状态
useEffect(() => {
setPresets(value);
}, [value]);
const handleAddPreset = () => {
const newPreset: EnvPreset = {
key: '',
label: '',
type: 'select',
options: [{ label: '', value: '' }],
};
const newPresets = [...presets, newPreset];
setPresets(newPresets);
onChange?.(newPresets);
};
const handleRemovePreset = (index: number) => {
const newPresets = presets.filter((_, i) => i !== index);
setPresets(newPresets);
onChange?.(newPresets);
};
const handlePresetChange = (
index: number,
field: keyof EnvPreset,
val: string | boolean | EnvPreset['type'] | EnvPreset['options'],
) => {
const newPresets = [...presets];
newPresets[index] = { ...newPresets[index], [field]: val };
setPresets(newPresets);
onChange?.(newPresets);
};
const handleAddOption = (presetIndex: number) => {
const newPresets = [...presets];
if (!newPresets[presetIndex].options) {
newPresets[presetIndex].options = [];
}
newPresets[presetIndex].options?.push({ label: '', value: '' });
setPresets(newPresets);
onChange?.(newPresets);
};
const handleRemoveOption = (presetIndex: number, optionIndex: number) => {
const newPresets = [...presets];
newPresets[presetIndex].options = newPresets[presetIndex].options?.filter(
(_, i) => i !== optionIndex,
);
setPresets(newPresets);
onChange?.(newPresets);
};
const handleOptionChange = (
presetIndex: number,
optionIndex: number,
field: 'label' | 'value',
val: string,
) => {
const newPresets = [...presets];
if (newPresets[presetIndex].options) {
newPresets[presetIndex].options![optionIndex][field] = val;
setPresets(newPresets);
onChange?.(newPresets);
}
};
return (
<div className="space-y-4">
{presets.map((preset, presetIndex) => (
<div
key={`preset-${preset.key || presetIndex}`}
className="border border-gray-200 rounded p-4"
>
<div className="flex items-start justify-between mb-3">
<div className="font-medium text-gray-700">
#{presetIndex + 1}
</div>
<Button
size="small"
status="danger"
icon={<IconDelete />}
onClick={() => handleRemovePreset(presetIndex)}
>
</Button>
</div>
<Space direction="vertical" style={{ width: '100%' }}>
<div className="grid grid-cols-2 gap-2">
<Input
placeholder="变量名 (key)"
value={preset.key}
onChange={(val) => handlePresetChange(presetIndex, 'key', val)}
/>
<Input
placeholder="显示名称 (label)"
value={preset.label}
onChange={(val) =>
handlePresetChange(presetIndex, 'label', val)
}
/>
</div>
<div className="grid grid-cols-2 gap-2">
<Select
placeholder="选择类型"
value={preset.type}
onChange={(val) => handlePresetChange(presetIndex, 'type', val)}
>
<Select.Option value="select"></Select.Option>
<Select.Option value="multiselect"></Select.Option>
<Select.Option value="input"></Select.Option>
</Select>
<div className="flex items-center">
<Checkbox
checked={preset.required || false}
onChange={(checked) =>
handlePresetChange(presetIndex, 'required', checked)
}
>
</Checkbox>
</div>
</div>
{(preset.type === 'select' || preset.type === 'multiselect') && (
<div className="mt-2">
<div className="text-sm text-gray-600 mb-2"></div>
{preset.options?.map((option, optionIndex) => (
<div
key={`option-${option.value || optionIndex}`}
className="flex items-center gap-2 mb-2"
>
<Input
size="small"
placeholder="显示文本"
value={option.label}
onChange={(val) =>
handleOptionChange(
presetIndex,
optionIndex,
'label',
val,
)
}
/>
<Input
size="small"
placeholder="值"
value={option.value}
onChange={(val) =>
handleOptionChange(
presetIndex,
optionIndex,
'value',
val,
)
}
/>
<Button
size="small"
status="danger"
icon={<IconDelete />}
onClick={() =>
handleRemoveOption(presetIndex, optionIndex)
}
/>
</div>
))}
<Button
size="small"
type="dashed"
long
icon={<IconPlus />}
onClick={() => handleAddOption(presetIndex)}
>
</Button>
</div>
)}
</Space>
</div>
))}
<Button type="dashed" long icon={<IconPlus />} onClick={handleAddPreset}>
</Button>
</div>
);
}
export default EnvPresetsEditor;