512 lines
16 KiB
Vue
512 lines
16 KiB
Vue
<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>
|