feat(产品需求): 实现产品需求相关代码。
This commit is contained in:
@@ -0,0 +1,433 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetRequirement, fetchGetRequirementModuleTree, fetchUpdateRequirement } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementDetailDialog' });
|
||||
|
||||
type DialogMode = 'view' | 'edit';
|
||||
|
||||
interface Props {
|
||||
mode: DialogMode;
|
||||
requirement: Api.Product.Requirement | null;
|
||||
productId: string;
|
||||
memberOptions: Api.Product.ProductMember[];
|
||||
categoryDictCode: string;
|
||||
priorityDictCode: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', requirementId?: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
|
||||
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
reviewRequired: number;
|
||||
completionDate: string;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
proposerId: string;
|
||||
currentHandlerUserId: string;
|
||||
implementProjectId: string | null;
|
||||
sort: number;
|
||||
lastStatusReason: string;
|
||||
}
|
||||
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const isViewMode = computed(() => props.mode === 'view');
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
const dialogTitle = computed(() => {
|
||||
if (isViewMode.value) {
|
||||
return '查看需求';
|
||||
}
|
||||
return '编辑需求';
|
||||
});
|
||||
|
||||
const memberUserOptions = computed(() => {
|
||||
return props.memberOptions.filter(m => m.status === 0);
|
||||
});
|
||||
|
||||
const memberLabelMap = computed(() => {
|
||||
return new Map(memberUserOptions.value.map(item => [String(item.userId), item.userNickname]));
|
||||
});
|
||||
|
||||
const moduleLabelMap = computed(() => {
|
||||
const map = new Map<string | undefined, string>();
|
||||
function traverse(modules: Api.Product.RequirementModule[]) {
|
||||
for (const module of modules) {
|
||||
map.set(module.id, module.moduleName);
|
||||
if (module.children?.length) {
|
||||
traverse(module.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
traverse(moduleTree.value);
|
||||
return map;
|
||||
});
|
||||
|
||||
const moduleTreeProps = {
|
||||
label: 'moduleName',
|
||||
value: 'id',
|
||||
children: 'children'
|
||||
};
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
{ label: '需要', value: 1 }
|
||||
];
|
||||
|
||||
const rules = computed(() => {
|
||||
const baseRules: Record<string, App.Global.FormRule[]> = {
|
||||
title: isEditMode.value ? [createRequiredRule('请输入需求标题')] : [],
|
||||
category: isEditMode.value ? [createRequiredRule('请选择分类')] : [],
|
||||
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
|
||||
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
|
||||
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : [],
|
||||
completionDate: isEditMode.value ? [createRequiredRule('请选择预期完成时间')] : []
|
||||
};
|
||||
|
||||
return baseRules;
|
||||
});
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
reviewRequired: 0,
|
||||
completionDate: '',
|
||||
moduleId: '0',
|
||||
category: '',
|
||||
priority: 1,
|
||||
proposerId: '',
|
||||
currentHandlerUserId: '',
|
||||
implementProjectId: null,
|
||||
sort: 0,
|
||||
lastStatusReason: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.productId || !props.requirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const updatePayload: Api.Product.UpdateRequirementParams = {
|
||||
id: props.requirement.id,
|
||||
productId: props.productId,
|
||||
moduleId: model.value.moduleId || '0',
|
||||
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
implementProjectId: model.value.implementProjectId,
|
||||
completionDate: model.value.completionDate,
|
||||
sort: model.value.sort
|
||||
};
|
||||
|
||||
const { error } = await fetchUpdateRequirement(updatePayload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('需求更新成功');
|
||||
closeDialog();
|
||||
emit('submitted', props.requirement.id);
|
||||
}
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!props.productId) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
|
||||
|
||||
if (error || !data) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
moduleTree.value = data;
|
||||
}
|
||||
|
||||
async function loadRequirementDetail() {
|
||||
if (!props.productId || !props.requirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetRequirement(props.requirement.id, props.productId);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = {
|
||||
title: data.title || '',
|
||||
description: data.description || '',
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
completionDate: data.completionDate || '',
|
||||
moduleId: data.moduleId || '0',
|
||||
category: data.category || '',
|
||||
priority: data.priority ?? null,
|
||||
proposerId: data.proposerId || '',
|
||||
currentHandlerUserId: data.currentHandlerUserId || '',
|
||||
implementProjectId: data.implementProjectId || null,
|
||||
sort: data.sort ?? 0,
|
||||
lastStatusReason: data.lastStatusReason || ''
|
||||
};
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadModuleTree();
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="lg"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
:show-footer="isEditMode"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="标题" prop="title">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="model.title" />
|
||||
</template>
|
||||
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField
|
||||
:value="model.completionDate ? dayjs(Number(model.completionDate)).format('YYYY-MM-DD HH:mm:ss') : '--'"
|
||||
/>
|
||||
</template>
|
||||
<ElDatePicker
|
||||
v-else
|
||||
v-model="model.completionDate"
|
||||
type="datetime"
|
||||
class="w-full"
|
||||
placeholder="选择预期完成时间"
|
||||
value-format="x"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="描述">
|
||||
<template v-if="isViewMode">
|
||||
<div class="readonly-textarea">
|
||||
{{ model.description || '--' }}
|
||||
</div>
|
||||
</template>
|
||||
<ElInput
|
||||
v-else
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="请输入需求描述"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="模块">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
</template>
|
||||
<ElTreeSelect
|
||||
v-else
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTree"
|
||||
:props="moduleTreeProps"
|
||||
class="w-full"
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
placeholder="请选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="分类" prop="category">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
|
||||
</template>
|
||||
<DictSelect
|
||||
v-else
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
filterable
|
||||
placeholder="请选择分类"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField
|
||||
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
|
||||
/>
|
||||
</template>
|
||||
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
|
||||
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
</template>
|
||||
<ElSelect v-else v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
|
||||
<ElOption
|
||||
v-for="item in memberUserOptions"
|
||||
:key="item.userId"
|
||||
:label="item.userNickname"
|
||||
:value="item.userId"
|
||||
>
|
||||
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
|
||||
</template>
|
||||
<ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
|
||||
<ElOption
|
||||
v-for="item in memberUserOptions"
|
||||
:key="item.userId"
|
||||
:label="item.userNickname"
|
||||
:value="item.userId"
|
||||
>
|
||||
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="实现项目">
|
||||
<ReadonlyField :value="model.implementProjectId || '--'" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="排序值">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="model.sort" />
|
||||
</template>
|
||||
<ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol v-if="isViewMode && model.lastStatusReason" :span="24">
|
||||
<ElFormItem label="状态变更原因">
|
||||
<div class="readonly-textarea">
|
||||
{{ model.lastStatusReason }}
|
||||
</div>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.readonly-textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
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;
|
||||
color: rgb(51 65 85 / 96%);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user