Files
cn-rdms-web/src/views/project/list/modules/project-operate-dialog.vue

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

<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>