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 变更
This commit is contained in:
2026-01-03 22:59:20 +08:00
parent c40532c757
commit d22fdc9618
71 changed files with 9611 additions and 5849 deletions

View File

@@ -1,23 +1,24 @@
import {
Button,
Form,
Input,
Message,
Modal,
Select,
} from '@arco-design/web-react';
import { formatDateTime } from '../../../../utils/time';
import { IconDelete, IconPlus } from '@arco-design/web-react/icon';
import { Form, Input, Message, Modal, Select } from '@arco-design/web-react';
import { useCallback, useEffect, useState } from 'react';
import type { Branch, Commit, Pipeline } from '../../types';
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({
@@ -26,12 +27,29 @@ function DeployModal({
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) => {
@@ -91,16 +109,27 @@ function DeployModal({
try {
const values = await form.validate();
const selectedCommit = commits.find((c) => c.sha === values.commitHash);
const selectedPipeline = pipelines.find((p) => p.id === values.pipelineId);
const selectedPipeline = pipelines.find(
(p) => p.id === values.pipelineId,
);
if (!selectedCommit || !selectedPipeline) {
return;
}
// 格式化环境变量
const env = values.envVars
?.map((item: { key: string; value: string }) => `${item.key}=${item.value}`)
.join('\n');
// 收集所有环境变量(从预设项中提取)
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,
@@ -108,8 +137,7 @@ function DeployModal({
branch: values.branch,
commitHash: selectedCommit.sha,
commitMessage: selectedCommit.commit.message,
env: env,
sparseCheckoutPaths: values.sparseCheckoutPaths,
envVars, // 提交所有环境变量
});
Message.success('部署任务已创建');
@@ -128,126 +156,162 @@ function DeployModal({
onCancel={onCancel}
autoFocus={false}
focusLock={true}
style={{ width: 650 }}
>
<Form form={form} layout="vertical">
<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>
{/* 基本参数 */}
<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="branch"
rules={[{ required: true, message: '请选择分支' }]}
>
<Select
placeholder="请选择分支"
loading={branchLoading}
onChange={handleBranchChange}
<Form.Item
label="选择流水线"
field="pipelineId"
rules={[{ required: true, message: '请选择流水线' }]}
>
{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>
<Form.Item
label="稀疏检出路径用于monorepo项目每行一个路径"
field="sparseCheckoutPaths"
tooltip="在monorepo项目中指定需要检出的目录路径每行一个路径。留空则检出整个仓库。"
>
<Input.TextArea
placeholder={`例如:\n/packages/frontend\n/packages/backend`}
autoSize={{ minRows: 2, maxRows: 6 }}
/>
</Form.Item>
<div className="mb-2 font-medium text-gray-700"></div>
<Form.List field="envVars">
{(fields, { add, remove }) => (
<div>
{fields.map((item, index) => (
<div key={item.key} className="flex items-center gap-2 mb-2">
<Form.Item
field={`${item.field}.key`}
noStyle
rules={[{ required: true, message: '请输入变量名' }]}
>
<Input placeholder="变量名" />
</Form.Item>
<span className="text-gray-400">=</span>
<Form.Item
field={`${item.field}.value`}
noStyle
rules={[{ required: true, message: '请输入变量值' }]}
>
<Input placeholder="变量值" />
</Form.Item>
<Button
icon={<IconDelete />}
status="danger"
onClick={() => remove(index)}
/>
</div>
<Select placeholder="请选择流水线">
{pipelines.map((pipeline) => (
<Select.Option key={pipeline.id} value={pipeline.id}>
{pipeline.name}
</Select.Option>
))}
<Button
type="dashed"
long
onClick={() => add()}
icon={<IconPlus />}
>
</Button>
</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>
)}
</Form.List>
{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>
);