feat(projects): 新增项目、执行、任务等功能

This commit is contained in:
2026-05-09 11:30:34 +08:00
parent f4f43814b3
commit 824392b564
106 changed files with 13060 additions and 1049 deletions

View File

@@ -0,0 +1,511 @@
<script setup lang="tsx">
import { computed, nextTick, ref, watch } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreateProject, fetchGetProductPage, fetchUpdateProject } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProjectOperateDialog' });
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
rowData?: Api.Project.Project | null;
}
const props = defineProps<Props>();
interface Emits {
(e: 'submitted', projectId?: string): void;
}
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
interface Model {
projectCode: string;
projectName: string;
directionCode: string;
projectType: string;
productId: string | null;
managerUserId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
projectDesc: string;
}
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const isEditMode = computed(() => Boolean(props.rowData?.id));
const dialogTitle = computed(() => (isEditMode.value ? '编辑项目' : '新增项目'));
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
// 产品选项,包含 ID、名称、方向
interface ProductOption {
id: string;
name: string;
directionCode: string;
}
const productOptions = ref<ProductOption[]>([]);
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
if (!managerUserId) {
return '';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
// 当前选中产品的方向
const selectedProductDirection = computed(() => {
if (!model.value.productId) {
return '';
}
const product = productOptions.value.find(p => p.id === model.value.productId);
return product?.directionCode || '';
});
// 判断是否关联了产品(创建/编辑模式都适用)
const hasAssociatedProduct = computed(() => Boolean(model.value.productId));
// 方向字段是否只读:关联了产品时只读,未关联时可编辑
const directionReadonly = computed(() => hasAssociatedProduct.value);
// 当前生效的方向:关联产品则用产品方向,否则用用户选择的方向
const effectiveDirectionCode = computed({
get: () => {
if (hasAssociatedProduct.value) {
// 编辑/创建模式下,关联产品时使用产品方向
return selectedProductDirection.value || model.value.directionCode;
}
return model.value.directionCode;
},
set: (val: string) => {
if (!hasAssociatedProduct.value) {
model.value.directionCode = val;
}
}
});
const directionDisplayName = computed(() => {
const directionCode = effectiveDirectionCode.value;
if (!directionCode) {
return '';
}
return getDirectionLabel(directionCode, directionCode);
});
function parsePlannedDate(value: string | null | undefined) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
let startDate = parsePlannedDate(model.value.plannedStartDate);
if (!startDate) {
startDate = new Date();
model.value.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
}
const endDate = new Date(startDate.getTime());
mutator(endDate);
return endDate;
}
};
}
const plannedEndDateShortcuts = [
buildEndDateShortcut('一星期', date => date.setDate(date.getDate() + 7)),
buildEndDateShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildEndDateShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
buildEndDateShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
buildEndDateShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
buildEndDateShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
// 产品下拉的标签,显示产品名称 + 方向
const productOptionLabel = (item: ProductOption) => {
return `${item.name}`;
};
const rules = {
projectName: [createRequiredRule('请输入项目名称')],
directionCode: [createRequiredRule('请选择项目方向')],
projectType: [createRequiredRule('请选择项目类型')],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(model.value.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
callback();
},
trigger: 'change'
}
]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
projectCode: '',
projectName: '',
directionCode: '',
projectType: '',
productId: null,
managerUserId: null,
plannedStartDate: null,
plannedEndDate: null,
projectDesc: ''
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
async function loadProductOptions() {
const { error, data } = await fetchGetProductPage({ pageNo: 1, pageSize: 200 });
if (error || !data) {
productOptions.value = [];
return;
}
productOptions.value = data.list.map(item => ({
id: item.id,
name: item.name || item.code || item.id,
directionCode: item.directionCode || ''
}));
}
// 监听产品选择变化,联动方向(创建模式)
watch(
() => model.value.productId,
(newProductId, oldProductId) => {
if (isEditMode.value) {
return; // 编辑模式下不处理,产品字段只读
}
if (newProductId && newProductId !== oldProductId) {
// 选择了产品,自动填充方向
const product = productOptions.value.find(p => p.id === newProductId);
if (product) {
model.value.directionCode = product.directionCode;
}
}
// 取消选择产品时directionCode 保留,用户可重新选择
}
);
async function handleSubmit() {
await validate();
// 提交时,如果关联了产品,使用产品方向
const finalDirectionCode = hasAssociatedProduct.value
? selectedProductDirection.value || model.value.directionCode
: model.value.directionCode;
const payload: Api.Project.SaveProjectParams = {
projectCode: getNullableText(model.value.projectCode),
projectName: model.value.projectName.trim(),
directionCode: finalDirectionCode,
projectType: model.value.projectType,
productId: model.value.productId,
managerUserId: model.value.managerUserId || '',
plannedStartDate: model.value.plannedStartDate,
plannedEndDate: model.value.plannedEndDate,
actualStartDate: isEditMode.value ? props.rowData?.actualStartDate || null : undefined,
actualEndDate: isEditMode.value ? props.rowData?.actualEndDate || null : undefined,
projectDesc: getNullableText(model.value.projectDesc)
};
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const updateParams: Api.Project.UpdateProjectParams = {
id: props.rowData.id,
...payload
};
const result = await fetchUpdateProject(updateParams);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('项目编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
return;
}
const result = await fetchCreateProject(payload);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('项目新增成功');
closeDialog();
emit('submitted', result.data);
}
watch(visible, async value => {
if (!value) {
return;
}
await loadProductOptions();
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
await nextTick();
formRef.value?.clearValidate();
return;
}
model.value = {
projectCode: props.rowData.projectCode || '',
projectName: props.rowData.projectName || '',
directionCode: props.rowData.directionCode || '',
projectType: props.rowData.projectType || '',
productId: props.rowData.productId,
managerUserId: props.rowData.managerUserId || null,
plannedStartDate: props.rowData.plannedStartDate,
plannedEndDate: props.rowData.plannedEndDate,
projectDesc: props.rowData.projectDesc || ''
};
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="md"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<BusinessFormSection title="项目信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="项目编码" prop="projectCode">
<ElInput
:model-value="model.projectCode"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目编码"
/>
</ElFormItem>
<ElFormItem v-else label="项目编码" prop="projectCode">
<ElInput v-model="model.projectCode" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目名称" prop="projectName">
<ElInput v-model="model.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目方向" prop="directionCode">
<DictSelect
v-if="!directionReadonly"
v-model="effectiveDirectionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择项目方向"
/>
<ElInput
v-else
:model-value="directionDisplayName"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目类型" prop="projectType">
<DictSelect
v-model="model.projectType"
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
filterable
placeholder="请选择项目类型"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="所属产品">
<ElInput
:model-value="
productOptions.find(p => p.id === model.productId)?.name ||
props.rowData?.productName ||
model.productId ||
'未关联产品'
"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未关联产品"
/>
</ElFormItem>
<ElFormItem v-else label="所属产品" prop="productId">
<ElSelect
v-model="model.productId"
clearable
filterable
placeholder="选择所属产品可选选择后将锁定项目方向"
>
<ElOption
v-for="item in productOptions"
:key="item.id"
:label="productOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode">
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整项目经理请到项目内的团队管理处处理"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>项目经理</span>
</span>
</template>
<ElInput
:model-value="managerDisplayName"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目经理"
/>
</ElFormItem>
<ElFormItem v-else label="项目经理" prop="managerUserId">
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择项目经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择开始日期"
class="project-operate-dialog__date-picker"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择结束日期"
:shortcuts="plannedEndDateShortcuts"
class="project-operate-dialog__date-picker"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="项目说明" prop="projectDesc">
<ElInput
v-model="model.projectDesc"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入项目说明"
/>
</ElFormItem>
</ElCol>
</ElRow>
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.project-operate-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.project-operate-dialog__readonly-input .el-input__wrapper:hover),
:deep(.project-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.project-operate-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
:deep(.project-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>