feat(产品需求、项目需求): 开发两种需求的富文本和附件功能。
This commit is contained in:
@@ -183,7 +183,14 @@ const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
|
||||
|
||||
type RequirementResponse = Omit<
|
||||
Api.Product.Requirement,
|
||||
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId'
|
||||
| 'id'
|
||||
| 'parentId'
|
||||
| 'moduleId'
|
||||
| 'proposerId'
|
||||
| 'currentHandlerUserId'
|
||||
| 'implementProjectId'
|
||||
| 'sourceBizId'
|
||||
| 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
parentId: string | number;
|
||||
@@ -193,11 +200,32 @@ type RequirementResponse = Omit<
|
||||
implementProjectId?: string | number | null;
|
||||
implementProjectName?: string | null;
|
||||
sourceBizId?: string | number | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
children?: RequirementResponse[];
|
||||
};
|
||||
|
||||
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
||||
|
||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
fileId?: string | number;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return list.map(item => {
|
||||
const rawId = item.fileId ?? item.id;
|
||||
|
||||
return {
|
||||
...item,
|
||||
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
|
||||
return {
|
||||
...requirement,
|
||||
@@ -209,6 +237,7 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
|
||||
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
|
||||
implementProjectName: requirement.implementProjectName ?? null,
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
attachments: normalizeAttachments(requirement.attachments),
|
||||
children: requirement.children?.map(normalizeRequirement)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -744,7 +744,7 @@ const PROJECT_REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project/requir
|
||||
|
||||
type ProjectRequirementResponse = Omit<
|
||||
Api.Project.ProjectRequirement,
|
||||
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId'
|
||||
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId' | 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
projectId: string | number;
|
||||
@@ -753,6 +753,7 @@ type ProjectRequirementResponse = Omit<
|
||||
proposerId: string | number;
|
||||
currentHandlerUserId?: string | number | null;
|
||||
sourceBizId?: string | number | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
children?: ProjectRequirementResponse[];
|
||||
};
|
||||
|
||||
@@ -765,6 +766,26 @@ type ProjectRequirementModuleResponse = Omit<Api.Project.ProjectRequirementModul
|
||||
children?: ProjectRequirementModuleResponse[];
|
||||
};
|
||||
|
||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
fileId?: string | number;
|
||||
id?: string | number;
|
||||
};
|
||||
|
||||
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
|
||||
if (!list) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return list.map(item => {
|
||||
const rawId = item.fileId ?? item.id;
|
||||
|
||||
return {
|
||||
...item,
|
||||
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeProjectRequirement(requirement: ProjectRequirementResponse): Api.Project.ProjectRequirement {
|
||||
return {
|
||||
...requirement,
|
||||
@@ -775,6 +796,7 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
|
||||
proposerId: normalizeStringId(requirement.proposerId),
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
attachments: normalizeAttachments(requirement.attachments),
|
||||
children: requirement.children?.map(normalizeProjectRequirement)
|
||||
};
|
||||
}
|
||||
|
||||
4
src/typings/api/product.d.ts
vendored
4
src/typings/api/product.d.ts
vendored
@@ -270,6 +270,8 @@ declare namespace Api {
|
||||
title: string;
|
||||
/** 需求内容(富文本) */
|
||||
description?: string | null;
|
||||
/** 附件列表 */
|
||||
attachments?: Api.Project.AttachmentItem[] | null;
|
||||
/** 需求类型字典值 */
|
||||
category: string;
|
||||
/** 需求类型名称 */
|
||||
@@ -391,6 +393,7 @@ declare namespace Api {
|
||||
| 'reviewRequired'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'attachments'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
@@ -430,6 +433,7 @@ declare namespace Api {
|
||||
| 'reviewRequired'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'attachments'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
|
||||
4
src/typings/api/project.d.ts
vendored
4
src/typings/api/project.d.ts
vendored
@@ -635,6 +635,8 @@ declare namespace Api {
|
||||
title: string;
|
||||
/** 需求描述 */
|
||||
description?: string | null;
|
||||
/** 附件列表 */
|
||||
attachments?: AttachmentItem[] | null;
|
||||
/** 需求分类字典值 */
|
||||
category: string;
|
||||
/** 需求分类名称 */
|
||||
@@ -744,6 +746,7 @@ declare namespace Api {
|
||||
| 'reviewRequired'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'attachments'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
@@ -781,6 +784,7 @@ declare namespace Api {
|
||||
| 'reviewRequired'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'attachments'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
|
||||
@@ -125,7 +125,9 @@ function handleToggle() {
|
||||
<icon-mdi-folder-outline v-else class="text-16px" />
|
||||
</div>
|
||||
<div class="module-tree-item__content">
|
||||
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
|
||||
<ElTooltip v-if="!isEditing" :content="module.moduleName" placement="top" :show-after="500">
|
||||
<span class="module-tree-item__label">{{ module.moduleName }}</span>
|
||||
</ElTooltip>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="editingName"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
@@ -32,9 +35,11 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
@@ -44,7 +49,8 @@ const priorityOptions = computed(() => {
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
@@ -58,7 +64,6 @@ interface Model {
|
||||
const submitting = ref(false);
|
||||
const loading = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const memberUserOptions = computed(() => {
|
||||
@@ -102,10 +107,41 @@ const rules = {
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: props.defaultModuleId || '0',
|
||||
category: '功能需求',
|
||||
@@ -117,10 +153,6 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
@@ -132,6 +164,11 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const proposer = memberUserOptions.value.find(m => m.userId === model.value.proposerId);
|
||||
const proposerNickname = proposer?.userNickname || '';
|
||||
const handler = memberUserOptions.value.find(m => m.userId === model.value.currentHandlerUserId);
|
||||
@@ -142,7 +179,8 @@ async function handleSubmit() {
|
||||
moduleId: model.value.moduleId || '0',
|
||||
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
|
||||
attachments: [...model.value.attachments],
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
@@ -164,6 +202,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('需求新增成功');
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
@@ -175,8 +215,12 @@ async function loadModuleTree() {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
@@ -196,6 +240,8 @@ watch(
|
||||
await loadModuleTree();
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
@@ -205,112 +251,150 @@ watch(
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="新增需求"
|
||||
preset="lg"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="模块">
|
||||
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-operate-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
|
||||
<BusinessFormSection title="需求信息">
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="模块">
|
||||
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="所需工时" prop="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<DictSelect
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
filterable
|
||||
placeholder="请选择需求类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ElSelect 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">
|
||||
<ElSelect 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="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<DictSelect
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
filterable
|
||||
placeholder="请选择需求类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-operate-dialog__col-right">
|
||||
<BusinessFormSection title="需求内容">
|
||||
<ElFormItem class="requirement-operate-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.description"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="附件">
|
||||
<ElFormItem class="requirement-operate-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.requirement-operate-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-left,
|
||||
.requirement-operate-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__desc-item,
|
||||
.requirement-operate-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-operate-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import {
|
||||
fetchGetProjectListByProductId,
|
||||
fetchGetRequirement,
|
||||
@@ -8,7 +9,9 @@ import {
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
@@ -44,6 +47,9 @@ const { createRequiredRule } = useFormRules();
|
||||
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
|
||||
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
@@ -53,7 +59,8 @@ const priorityOptions = computed(() => {
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
@@ -72,7 +79,6 @@ const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
const projectOptions = ref<Api.Project.Project[]>([]);
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const isViewMode = computed(() => props.mode === 'view');
|
||||
@@ -81,6 +87,7 @@ const dialogTitle = computed(() => {
|
||||
if (isViewMode.value) {
|
||||
return '查看需求';
|
||||
}
|
||||
|
||||
return '编辑需求';
|
||||
});
|
||||
|
||||
@@ -152,10 +159,41 @@ const rules = computed(() => {
|
||||
return baseRules;
|
||||
});
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: '0',
|
||||
category: '',
|
||||
@@ -171,10 +209,6 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
@@ -186,6 +220,13 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const updatePayload: Api.Product.UpdateRequirementParams = {
|
||||
@@ -194,13 +235,14 @@ async function handleSubmit() {
|
||||
moduleId: model.value.moduleId || '0',
|
||||
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
|
||||
attachments: [...model.value.attachments],
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
proposerNickname: model.value.proposerNickname,
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
|
||||
currentHandlerUserNickname: handler?.userNickname || model.value.currentHandlerUserNickname,
|
||||
implementProjectId: model.value.implementProjectId,
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
@@ -214,6 +256,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('需求更新成功');
|
||||
closeDialog();
|
||||
emit('submitted', props.requirement.id);
|
||||
@@ -251,24 +295,11 @@ async function loadProjectOptions() {
|
||||
projectOptions.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 = {
|
||||
function transformRequirementData(data: Api.Product.Requirement): typeof model.value {
|
||||
return {
|
||||
title: data.title || '',
|
||||
description: data.description || '',
|
||||
description: data.description || null,
|
||||
attachments: data.attachments ? [...data.attachments] : [],
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
moduleId: data.moduleId || '0',
|
||||
category: data.category || '',
|
||||
@@ -284,6 +315,24 @@ async function loadRequirementDetail() {
|
||||
};
|
||||
}
|
||||
|
||||
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 = transformRequirementData(data);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
@@ -295,9 +344,13 @@ watch(
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
} else {
|
||||
model.value = createDefaultModel();
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
@@ -307,130 +360,172 @@ watch(
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="lg"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
: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">
|
||||
<ReadonlyField :value="model.title" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="模块">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
</template>
|
||||
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<template v-if="isViewMode">
|
||||
<div class="readonly-textarea" v-html="model.description || '--'"></div>
|
||||
</template>
|
||||
<BusinessRichTextEditor
|
||||
v-else
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
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="优先级" prop="priority">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField
|
||||
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
|
||||
<template v-if="isViewMode" #footer="{ close }">
|
||||
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||
</template>
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-operate-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
|
||||
<BusinessFormSection title="需求信息">
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ReadonlyField :value="model.title" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="模块">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
</template>
|
||||
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
|
||||
<ElFormItem label="所需工时">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
|
||||
</template>
|
||||
<ElInputNumber
|
||||
v-else
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</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="所需工时">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
|
||||
</template>
|
||||
<ElInputNumber
|
||||
v-else
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
</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"
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<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="请选择负责人"
|
||||
>
|
||||
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol v-if="isViewMode" :span="12">
|
||||
<ElFormItem label="实现项目">
|
||||
<ReadonlyField :value="projectOptionsMap.get(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>
|
||||
<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>
|
||||
|
||||
<ElFormItem v-if="isViewMode" label="实现项目">
|
||||
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
|
||||
<ElFormItem v-if="isViewMode && model.lastStatusReason" label="状态变更原因">
|
||||
<div class="requirement-operate-dialog__readonly-textarea">{{ model.lastStatusReason }}</div>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-operate-dialog__col-right">
|
||||
<BusinessFormSection title="需求内容">
|
||||
<ElFormItem class="requirement-operate-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.description"
|
||||
:disabled="isViewMode"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="附件">
|
||||
<ElFormItem class="requirement-operate-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement"
|
||||
:disabled="isViewMode"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.readonly-textarea {
|
||||
.requirement-operate-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-left,
|
||||
.requirement-operate-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__desc-item,
|
||||
.requirement-operate-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__readonly-textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
@@ -444,4 +539,10 @@ watch(
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-operate-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -268,10 +268,12 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirement-module-tree-wrapper">
|
||||
<div class="module-tree-header">
|
||||
<span class="module-tree-header__title">模块</span>
|
||||
</div>
|
||||
<ElCard class="requirement-module-tree-card card-wrapper">
|
||||
<template #header>
|
||||
<div class="module-tree-header">
|
||||
<span class="module-tree-header__title">模块</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="module-tree-list">
|
||||
<template v-for="data in moduleTree" :key="data.id">
|
||||
@@ -298,14 +300,23 @@ defineExpose({
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-module-tree-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
.requirement-module-tree-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.requirement-module-tree-card :deep(.el-card__header) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.requirement-module-tree-card :deep(.el-card__body) {
|
||||
padding: 0 16px 16px;
|
||||
height: calc(100% - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-tree-header {
|
||||
@@ -316,7 +327,7 @@ defineExpose({
|
||||
|
||||
.module-tree-header__title {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -325,6 +336,8 @@ defineExpose({
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.module-tree-item {
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import { fetchSplitRequirement } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
@@ -31,9 +34,11 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
@@ -43,7 +48,8 @@ const priorityOptions = computed(() => {
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
@@ -54,7 +60,6 @@ interface Model {
|
||||
|
||||
const submitting = ref(false);
|
||||
const loading = ref(false);
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const memberUserOptions = computed(() => {
|
||||
@@ -73,10 +78,41 @@ const rules = {
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
category: '',
|
||||
priority: 1,
|
||||
@@ -86,10 +122,6 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
@@ -101,18 +133,23 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const proposerNickname = props.parentRequirement.proposerNickname || '';
|
||||
const currentHandlerUserNickname = props.parentRequirement.currentHandlerUserNickname || '';
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
const payload: Api.Product.SplitRequirementParams = {
|
||||
parentId: props.parentRequirement.id,
|
||||
productId: props.productId,
|
||||
moduleId: props.parentRequirement.moduleId,
|
||||
proposerId: props.parentRequirement.proposerId,
|
||||
proposerNickname,
|
||||
currentHandlerUserNickname,
|
||||
proposerNickname: props.parentRequirement.proposerNickname || '',
|
||||
currentHandlerUserNickname: handler?.userNickname || '',
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
|
||||
attachments: [...model.value.attachments],
|
||||
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
|
||||
@@ -131,6 +168,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('需求拆分成功');
|
||||
closeDialog();
|
||||
emit('submitted');
|
||||
@@ -149,12 +188,13 @@ watch(
|
||||
model.value.category = props.parentRequirement.category;
|
||||
}
|
||||
|
||||
// 默认选中父需求的负责人
|
||||
if (props.parentRequirement?.currentHandlerUserId) {
|
||||
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
@@ -164,7 +204,8 @@ watch(
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="拆分需求"
|
||||
preset="lg"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
@@ -177,76 +218,116 @@ watch(
|
||||
class="mb-16px"
|
||||
/>
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="子需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-operate-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
|
||||
<BusinessFormSection title="子需求信息">
|
||||
<ElFormItem label="子需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="所需工时" prop="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect 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="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-operate-dialog__col-right">
|
||||
<BusinessFormSection title="需求内容">
|
||||
<ElFormItem class="requirement-operate-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.description"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="附件">
|
||||
<ElFormItem class="requirement-operate-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.requirement-operate-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-left,
|
||||
.requirement-operate-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__desc-item,
|
||||
.requirement-operate-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-operate-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -108,7 +108,9 @@ function handleToggle() {
|
||||
<icon-mdi-folder-outline v-else class="text-16px" />
|
||||
</div>
|
||||
<div class="module-tree-item__content">
|
||||
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
|
||||
<ElTooltip v-if="!isEditing" :content="module.moduleName" placement="top" :show-after="500">
|
||||
<span class="module-tree-item__label">{{ module.moduleName }}</span>
|
||||
</ElTooltip>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="editingName"
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import { fetchCreateProjectRequirement, fetchGetProjectRequirementModuleTree } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
@@ -34,6 +37,9 @@ const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
@@ -43,7 +49,8 @@ const priorityOptions = computed(() => {
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
@@ -55,6 +62,7 @@ interface Model {
|
||||
}
|
||||
|
||||
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
@@ -98,10 +106,41 @@ const rules = {
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: props.defaultModuleId || '0',
|
||||
category: '功能需求',
|
||||
@@ -113,18 +152,18 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!props.projectId) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProjectRequirementModuleTree(props.projectId);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
@@ -140,6 +179,11 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const proposer = memberUserOptions.value.find(item => item.userId === model.value.proposerId);
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
@@ -148,7 +192,8 @@ async function handleSubmit() {
|
||||
moduleId: model.value.moduleId || '0',
|
||||
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
|
||||
attachments: [...model.value.attachments],
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
@@ -169,6 +214,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('项目需求新增成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
@@ -184,6 +231,8 @@ watch(
|
||||
model.value = createDefaultModel();
|
||||
await loadModuleTree();
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
@@ -193,111 +242,150 @@ watch(
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="新增项目需求"
|
||||
preset="lg"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="模块">
|
||||
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-operate-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
|
||||
<BusinessFormSection title="需求信息">
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="模块">
|
||||
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="所需工时" prop="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<DictSelect
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
filterable
|
||||
placeholder="请选择需求类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ElSelect 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">
|
||||
<ElSelect 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="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<DictSelect
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
filterable
|
||||
placeholder="请选择需求类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-operate-dialog__col-right">
|
||||
<BusinessFormSection title="需求内容">
|
||||
<ElFormItem class="requirement-operate-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.description"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="附件">
|
||||
<ElFormItem class="requirement-operate-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.requirement-operate-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-left,
|
||||
.requirement-operate-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__desc-item,
|
||||
.requirement-operate-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-operate-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import {
|
||||
fetchGetProjectRequirement,
|
||||
fetchGetProjectRequirementModuleTree,
|
||||
@@ -7,7 +8,9 @@ import {
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
@@ -42,6 +45,9 @@ const { createRequiredRule } = useFormRules();
|
||||
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
|
||||
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
@@ -51,7 +57,8 @@ const priorityOptions = computed(() => {
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
@@ -133,10 +140,41 @@ const rules = computed(() => ({
|
||||
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
|
||||
}));
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: '0',
|
||||
category: '',
|
||||
@@ -151,10 +189,6 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!props.projectId) {
|
||||
moduleTree.value = [];
|
||||
@@ -188,7 +222,8 @@ async function loadRequirementDetail() {
|
||||
|
||||
model.value = {
|
||||
title: data.title || '',
|
||||
description: data.description || '',
|
||||
description: data.description || null,
|
||||
attachments: data.attachments ? [...data.attachments] : [],
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
moduleId: data.moduleId || '0',
|
||||
category: data.category || '',
|
||||
@@ -210,6 +245,13 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const payload: Api.Project.UpdateProjectRequirementParams = {
|
||||
@@ -218,13 +260,14 @@ async function handleSubmit() {
|
||||
moduleId: model.value.moduleId || '0',
|
||||
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
|
||||
attachments: [...model.value.attachments],
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
proposerNickname: model.value.proposerNickname,
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
|
||||
currentHandlerUserNickname: handler?.userNickname || model.value.currentHandlerUserNickname,
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
};
|
||||
@@ -237,6 +280,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('项目需求更新成功');
|
||||
visible.value = false;
|
||||
emit('submitted', props.requirement.id);
|
||||
@@ -253,9 +298,13 @@ watch(
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
} else {
|
||||
model.value = createDefaultModel();
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
@@ -265,115 +314,162 @@ watch(
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="lg"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
: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">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.title" />
|
||||
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="模块">
|
||||
<ReadonlyField v-if="isViewMode" :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<div v-if="isViewMode" class="readonly-textarea" v-html="model.description || '--'"></div>
|
||||
<BusinessRichTextEditor
|
||||
v-else
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ReadonlyField
|
||||
:value="reviewRequiredOptions.find(item => item.value === model.reviewRequired)?.label || '--'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ReadonlyField
|
||||
v-if="isViewMode"
|
||||
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
|
||||
/>
|
||||
<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="所需工时">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
|
||||
<ElInputNumber
|
||||
v-else
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ReadonlyField v-if="isViewMode" :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
|
||||
<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"
|
||||
<template v-if="isViewMode" #footer="{ close }">
|
||||
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||
</template>
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-operate-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
|
||||
<BusinessFormSection title="需求信息">
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.title" />
|
||||
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="模块">
|
||||
<ReadonlyField v-if="isViewMode" :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ReadonlyField
|
||||
:value="reviewRequiredOptions.find(item => item.value === model.reviewRequired)?.label || '--'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ReadonlyField
|
||||
v-if="isViewMode"
|
||||
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<ElFormItem label="所需工时">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
|
||||
<ElInputNumber
|
||||
v-else
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ReadonlyField v-if="isViewMode" :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
|
||||
<ElSelect
|
||||
v-else
|
||||
v-model="model.currentHandlerUserId"
|
||||
class="w-full"
|
||||
filterable
|
||||
placeholder="请选择负责人"
|
||||
>
|
||||
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="排序值">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.sort" />
|
||||
<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>
|
||||
<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>
|
||||
|
||||
<ElFormItem label="排序值">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.sort" />
|
||||
<ElInputNumber
|
||||
v-else
|
||||
v-model="model.sort"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
placeholder="请输入排序值"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-if="isViewMode && model.lastStatusReason" label="状态变更原因">
|
||||
<div class="requirement-operate-dialog__readonly-textarea">{{ model.lastStatusReason }}</div>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-operate-dialog__col-right">
|
||||
<BusinessFormSection title="需求内容">
|
||||
<ElFormItem class="requirement-operate-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.description"
|
||||
:disabled="isViewMode"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="附件">
|
||||
<ElFormItem class="requirement-operate-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement"
|
||||
:disabled="isViewMode"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.readonly-textarea {
|
||||
.requirement-operate-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-left,
|
||||
.requirement-operate-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__desc-item,
|
||||
.requirement-operate-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__readonly-textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
@@ -387,4 +483,10 @@ watch(
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-operate-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -251,10 +251,12 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirement-module-tree-wrapper">
|
||||
<div class="module-tree-header">
|
||||
<span class="module-tree-header__title">模块</span>
|
||||
</div>
|
||||
<ElCard class="requirement-module-tree-card card-wrapper">
|
||||
<template #header>
|
||||
<div class="module-tree-header">
|
||||
<span class="module-tree-header__title">模块</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="module-tree-list">
|
||||
<template v-for="item in moduleTree" :key="item.id">
|
||||
@@ -281,14 +283,23 @@ defineExpose({
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</ElCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-module-tree-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
.requirement-module-tree-card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.requirement-module-tree-card :deep(.el-card__header) {
|
||||
padding: 12px 16px;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.requirement-module-tree-card :deep(.el-card__body) {
|
||||
padding: 0 16px 16px;
|
||||
height: calc(100% - 48px);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-tree-header {
|
||||
@@ -299,7 +310,7 @@ defineExpose({
|
||||
|
||||
.module-tree-header__title {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -308,5 +319,7 @@ defineExpose({
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import { fetchSplitProjectRequirement } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
@@ -33,6 +36,9 @@ const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
@@ -42,7 +48,8 @@ const priorityOptions = computed(() => {
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
@@ -52,6 +59,7 @@ interface Model {
|
||||
}
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
|
||||
@@ -68,10 +76,41 @@ const rules = {
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
category: '',
|
||||
priority: 1,
|
||||
@@ -81,10 +120,6 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
@@ -92,6 +127,11 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
const payload: Api.Project.SplitProjectRequirementParams = {
|
||||
@@ -100,7 +140,8 @@ async function handleSubmit() {
|
||||
moduleId: props.parentRequirement.moduleId,
|
||||
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
|
||||
attachments: [...model.value.attachments],
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
|
||||
proposerId: props.parentRequirement.proposerId,
|
||||
@@ -121,6 +162,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('项目需求拆分成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
@@ -139,12 +182,13 @@ watch(
|
||||
model.value.category = props.parentRequirement.category;
|
||||
}
|
||||
|
||||
// 默认选中父需求的负责人
|
||||
if (props.parentRequirement?.currentHandlerUserId) {
|
||||
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
@@ -154,7 +198,9 @@ watch(
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="拆分项目需求"
|
||||
preset="lg"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
@@ -166,76 +212,116 @@ watch(
|
||||
class="mb-16px"
|
||||
/>
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="子需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-operate-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
|
||||
<BusinessFormSection title="子需求信息">
|
||||
<ElFormItem label="子需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="所需工时" prop="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect 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="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect 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="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect 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>
|
||||
|
||||
<ElFormItem label="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-operate-dialog__col-right">
|
||||
<BusinessFormSection title="需求内容">
|
||||
<ElFormItem class="requirement-operate-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.description"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="附件">
|
||||
<ElFormItem class="requirement-operate-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.requirement-operate-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-left,
|
||||
.requirement-operate-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-operate-dialog__desc-item,
|
||||
.requirement-operate-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-operate-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user