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