Files
foka-ci/apps/web/src/pages/project/detail/components/DeployModal.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

321 lines
9.9 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 { Form, Input, Message, Modal, Select } from '@arco-design/web-react';
import { useCallback, useEffect, useState } from 'react';
import { formatDateTime } from '../../../../utils/time';
import type { Branch, Commit, Pipeline, Project } from '../../types';
import { detailService } from '../service';
interface EnvPreset {
key: string;
label: string;
type: 'select' | 'multiselect' | 'input';
required?: boolean;
options?: Array<{ label: string; value: string }>;
}
interface DeployModalProps {
visible: boolean;
onCancel: () => void;
onOk: () => void;
pipelines: Pipeline[];
projectId: number;
project?: Project | null;
}
function DeployModal({
visible,
onCancel,
onOk,
pipelines,
projectId,
project,
}: DeployModalProps) {
const [form] = Form.useForm();
const [branches, setBranches] = useState<Branch[]>([]);
const [commits, setCommits] = useState<Commit[]>([]);
const [loading, setLoading] = useState(false);
const [branchLoading, setBranchLoading] = useState(false);
const [envPresets, setEnvPresets] = useState<EnvPreset[]>([]);
// 解析项目环境预设
useEffect(() => {
if (project?.envPresets) {
try {
const presets = JSON.parse(project.envPresets);
setEnvPresets(presets);
} catch (error) {
console.error('解析环境预设失败:', error);
setEnvPresets([]);
}
} else {
setEnvPresets([]);
}
}, [project]);
const fetchCommits = useCallback(
async (branch: string) => {
try {
setLoading(true);
const data = await detailService.getCommits(projectId, branch);
setCommits(data);
if (data.length > 0) {
form.setFieldValue('commitHash', data[0].sha);
}
} catch (error) {
console.error('获取提交记录失败:', error);
Message.error('获取提交记录失败');
} finally {
setLoading(false);
}
},
[projectId, form],
);
const fetchBranches = useCallback(async () => {
try {
setBranchLoading(true);
const data = await detailService.getBranches(projectId);
setBranches(data);
// 默认选中 master 或 main
const defaultBranch = data.find(
(b) => b.name === 'master' || b.name === 'main',
);
if (defaultBranch) {
form.setFieldValue('branch', defaultBranch.name);
fetchCommits(defaultBranch.name);
} else if (data.length > 0) {
form.setFieldValue('branch', data[0].name);
fetchCommits(data[0].name);
}
} catch (error) {
console.error('获取分支列表失败:', error);
Message.error('获取分支列表失败');
} finally {
setBranchLoading(false);
}
}, [projectId, form, fetchCommits]);
useEffect(() => {
if (visible && projectId) {
fetchBranches();
}
}, [visible, projectId, fetchBranches]);
const handleBranchChange = (value: string) => {
fetchCommits(value);
form.setFieldValue('commitHash', undefined);
};
const handleSubmit = async () => {
try {
const values = await form.validate();
const selectedCommit = commits.find((c) => c.sha === values.commitHash);
const selectedPipeline = pipelines.find(
(p) => p.id === values.pipelineId,
);
if (!selectedCommit || !selectedPipeline) {
return;
}
// 收集所有环境变量(从预设项中提取)
const envVars: Record<string, string> = {};
for (const preset of envPresets) {
const value = values[preset.key];
if (value !== undefined && value !== null) {
// 对于 multiselect将数组转为逗号分隔的字符串
if (preset.type === 'multiselect' && Array.isArray(value)) {
envVars[preset.key] = value.join(',');
} else {
envVars[preset.key] = String(value);
}
}
}
await detailService.createDeployment({
projectId,
pipelineId: values.pipelineId,
branch: values.branch,
commitHash: selectedCommit.sha,
commitMessage: selectedCommit.commit.message,
envVars, // 提交所有环境变量
});
Message.success('部署任务已创建');
onOk();
} catch (error) {
console.error('创建部署失败:', error);
Message.error('创建部署失败');
}
};
return (
<Modal
title="开始部署"
visible={visible}
onOk={handleSubmit}
onCancel={onCancel}
autoFocus={false}
focusLock={true}
style={{ width: 650 }}
>
<Form form={form} layout="vertical">
{/* 基本参数 */}
<div className="mb-4 pb-4 border-b border-gray-200">
<div className="text-sm font-semibold text-gray-700 mb-3">
</div>
<Form.Item
label="选择流水线"
field="pipelineId"
rules={[{ required: true, message: '请选择流水线' }]}
>
<Select placeholder="请选择流水线">
{pipelines.map((pipeline) => (
<Select.Option key={pipeline.id} value={pipeline.id}>
{pipeline.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="选择分支"
field="branch"
rules={[{ required: true, message: '请选择分支' }]}
>
<Select
placeholder="请选择分支"
loading={branchLoading}
onChange={handleBranchChange}
>
{branches.map((branch) => (
<Select.Option key={branch.name} value={branch.name}>
{branch.name}
</Select.Option>
))}
</Select>
</Form.Item>
<Form.Item
label="选择提交"
field="commitHash"
rules={[{ required: true, message: '请选择提交记录' }]}
>
<Select
placeholder="请选择提交记录"
loading={loading}
renderFormat={(option) => {
const commit = commits.find((c) => c.sha === option?.value);
return commit ? commit.sha.substring(0, 7) : '';
}}
>
{commits.map((commit) => (
<Select.Option key={commit.sha} value={commit.sha}>
<div className="flex flex-col py-1">
<div className="flex items-center justify-between">
<span className="font-mono font-medium">
{commit.sha.substring(0, 7)}
</span>
<span className="text-gray-500 text-xs">
{formatDateTime(commit.commit.author.date)}
</span>
</div>
<div className="text-gray-600 text-sm truncate">
{commit.commit.message}
</div>
<div className="text-gray-400 text-xs">
{commit.commit.author.name}
</div>
</div>
</Select.Option>
))}
</Select>
</Form.Item>
</div>
{/* 环境变量预设 */}
{envPresets.length > 0 && (
<div>
<div className="text-sm font-semibold text-gray-700 mb-3">
</div>
{envPresets.map((preset) => {
if (preset.type === 'select' && preset.options) {
return (
<Form.Item
key={preset.key}
label={preset.label}
field={preset.key}
rules={
preset.required
? [{ required: true, message: `请选择${preset.label}` }]
: []
}
>
<Select placeholder={`请选择${preset.label}`}>
{preset.options.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
);
}
if (preset.type === 'multiselect' && preset.options) {
return (
<Form.Item
key={preset.key}
label={preset.label}
field={preset.key}
rules={
preset.required
? [{ required: true, message: `请选择${preset.label}` }]
: []
}
>
<Select
mode="multiple"
placeholder={`请选择${preset.label}`}
allowClear
>
{preset.options.map((option) => (
<Select.Option key={option.value} value={option.value}>
{option.label}
</Select.Option>
))}
</Select>
</Form.Item>
);
}
if (preset.type === 'input') {
return (
<Form.Item
key={preset.key}
label={preset.label}
field={preset.key}
rules={
preset.required
? [{ required: true, message: `请输入${preset.label}` }]
: []
}
>
<Input placeholder={`请输入${preset.label}`} />
</Form.Item>
);
}
return null;
})}
</div>
)}
</Form>
</Modal>
);
}
export default DeployModal;