feat(产品需求、项目需求): 开发两种需求的富文本和附件功能。

This commit is contained in:
dk
2026-05-13 23:09:35 +08:00
parent e3a456debd
commit f634d21d2a
14 changed files with 1278 additions and 647 deletions

View File

@@ -183,7 +183,14 @@ const REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/product/requirement`;
type RequirementResponse = Omit<
Api.Product.Requirement,
'id' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'implementProjectId' | 'sourceBizId'
| 'id'
| 'parentId'
| 'moduleId'
| 'proposerId'
| 'currentHandlerUserId'
| 'implementProjectId'
| 'sourceBizId'
| 'attachments'
> & {
id: string | number;
parentId: string | number;
@@ -193,11 +200,32 @@ type RequirementResponse = Omit<
implementProjectId?: string | number | null;
implementProjectName?: string | null;
sourceBizId?: string | number | null;
attachments?: AttachmentItemResponse[] | null;
children?: RequirementResponse[];
};
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: string | number;
id?: string | number;
};
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
function normalizeRequirement(requirement: RequirementResponse): Api.Product.Requirement {
return {
...requirement,
@@ -209,6 +237,7 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
implementProjectName: requirement.implementProjectName ?? null,
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments),
children: requirement.children?.map(normalizeRequirement)
};
}

View File

@@ -744,7 +744,7 @@ const PROJECT_REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project/requir
type ProjectRequirementResponse = Omit<
Api.Project.ProjectRequirement,
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId'
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId' | 'attachments'
> & {
id: string | number;
projectId: string | number;
@@ -753,6 +753,7 @@ type ProjectRequirementResponse = Omit<
proposerId: string | number;
currentHandlerUserId?: string | number | null;
sourceBizId?: string | number | null;
attachments?: AttachmentItemResponse[] | null;
children?: ProjectRequirementResponse[];
};
@@ -765,6 +766,26 @@ type ProjectRequirementModuleResponse = Omit<Api.Project.ProjectRequirementModul
children?: ProjectRequirementModuleResponse[];
};
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: string | number;
id?: string | number;
};
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
function normalizeProjectRequirement(requirement: ProjectRequirementResponse): Api.Project.ProjectRequirement {
return {
...requirement,
@@ -775,6 +796,7 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
proposerId: normalizeStringId(requirement.proposerId),
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments),
children: requirement.children?.map(normalizeProjectRequirement)
};
}

View File

@@ -270,6 +270,8 @@ declare namespace Api {
title: string;
/** 需求内容(富文本) */
description?: string | null;
/** 附件列表 */
attachments?: Api.Project.AttachmentItem[] | null;
/** 需求类型字典值 */
category: string;
/** 需求类型名称 */
@@ -391,6 +393,7 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
@@ -430,6 +433,7 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'

View File

@@ -635,6 +635,8 @@ declare namespace Api {
title: string;
/** 需求描述 */
description?: string | null;
/** 附件列表 */
attachments?: AttachmentItem[] | null;
/** 需求分类字典值 */
category: string;
/** 需求分类名称 */
@@ -744,6 +746,7 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'
@@ -781,6 +784,7 @@ declare namespace Api {
| 'reviewRequired'
| 'title'
| 'description'
| 'attachments'
| 'category'
| 'priority'
| 'proposerId'

View File

@@ -125,7 +125,9 @@ function handleToggle() {
<icon-mdi-folder-outline v-else class="text-16px" />
</div>
<div class="module-tree-item__content">
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
<ElTooltip v-if="!isEditing" :content="module.moduleName" placement="top" :show-after="500">
<span class="module-tree-item__label">{{ module.moduleName }}</span>
</ElTooltip>
<ElInput
v-else
:model-value="editingName"

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -32,9 +35,11 @@ const visible = defineModel<boolean>('visible', {
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -44,7 +49,8 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
moduleId: string;
category: string;
@@ -58,7 +64,6 @@ interface Model {
const submitting = ref(false);
const loading = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => {
@@ -102,10 +107,41 @@ const rules = {
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
moduleId: props.defaultModuleId || '0',
category: '功能需求',
@@ -117,10 +153,6 @@ function createDefaultModel(): Model {
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
@@ -132,6 +164,11 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const proposer = memberUserOptions.value.find(m => m.userId === model.value.proposerId);
const proposerNickname = proposer?.userNickname || '';
const handler = memberUserOptions.value.find(m => m.userId === model.value.currentHandlerUserId);
@@ -142,7 +179,8 @@ async function handleSubmit() {
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
@@ -164,6 +202,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('需求新增成功');
closeDialog();
emit('submitted');
@@ -175,8 +215,12 @@ async function loadModuleTree() {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirementModuleTree(props.productId);
loading.value = false;
if (error || !data) {
moduleTree.value = [];
return;
@@ -196,6 +240,8 @@ watch(
await loadModuleTree();
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -205,112 +251,150 @@ watch(
<BusinessFormDialog
v-model="visible"
title="新增需求"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<BusinessRichTextEditor
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="需求信息">
<ElFormItem label="需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
<ElFormItem label="模块">
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="需求类型" prop="category">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择需求类型"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择需求类型"
/>
</ElFormItem>
<ElFormItem label="提出人" prop="proposerId">
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>
<style scoped>
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import {
fetchGetProjectListByProductId,
fetchGetRequirement,
@@ -8,7 +9,9 @@ import {
} from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import ReadonlyField from '@/components/custom/readonly-field.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -44,6 +47,9 @@ const { createRequiredRule } = useFormRules();
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -53,7 +59,8 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
moduleId: string;
category: string;
@@ -72,7 +79,6 @@ const loading = ref(false);
const submitting = ref(false);
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
const projectOptions = ref<Api.Project.Project[]>([]);
const model = ref<Model>(createDefaultModel());
const isViewMode = computed(() => props.mode === 'view');
@@ -81,6 +87,7 @@ const dialogTitle = computed(() => {
if (isViewMode.value) {
return '查看需求';
}
return '编辑需求';
});
@@ -152,10 +159,41 @@ const rules = computed(() => {
return baseRules;
});
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
moduleId: '0',
category: '',
@@ -171,10 +209,6 @@ function createDefaultModel(): Model {
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
@@ -186,6 +220,13 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
submitting.value = true;
const updatePayload: Api.Product.UpdateRequirementParams = {
@@ -194,13 +235,14 @@ async function handleSubmit() {
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
proposerId: model.value.proposerId,
proposerNickname: model.value.proposerNickname,
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
currentHandlerUserNickname: handler?.userNickname || model.value.currentHandlerUserNickname,
implementProjectId: model.value.implementProjectId,
workHours: model.value.workHours || 0,
sort: model.value.sort
@@ -214,6 +256,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('需求更新成功');
closeDialog();
emit('submitted', props.requirement.id);
@@ -251,24 +295,11 @@ async function loadProjectOptions() {
projectOptions.value = data;
}
async function loadRequirementDetail() {
if (!props.productId || !props.requirement?.id) {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirement(props.requirement.id, props.productId);
loading.value = false;
if (error || !data) {
return;
}
model.value = {
function transformRequirementData(data: Api.Product.Requirement): typeof model.value {
return {
title: data.title || '',
description: data.description || '',
description: data.description || null,
attachments: data.attachments ? [...data.attachments] : [],
reviewRequired: data.reviewRequired ?? 0,
moduleId: data.moduleId || '0',
category: data.category || '',
@@ -284,6 +315,24 @@ async function loadRequirementDetail() {
};
}
async function loadRequirementDetail() {
if (!props.productId || !props.requirement?.id) {
return;
}
loading.value = true;
const { error, data } = await fetchGetRequirement(props.requirement.id, props.productId);
loading.value = false;
if (error || !data) {
return;
}
model.value = transformRequirementData(data);
}
watch(
() => visible.value,
async value => {
@@ -295,9 +344,13 @@ watch(
if (props.requirement?.id) {
await loadRequirementDetail();
} else {
model.value = createDefaultModel();
}
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -307,130 +360,172 @@ watch(
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
:show-footer="isEditMode"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="需求名称" prop="title">
<ReadonlyField :value="model.title" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<template v-if="isViewMode">
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
</template>
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<template v-if="isViewMode">
<div class="readonly-textarea" v-html="model.description || '--'"></div>
</template>
<BusinessRichTextEditor
v-else
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<template v-if="isViewMode">
<ReadonlyField
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
<template v-if="isViewMode" #footer="{ close }">
<ElButton type="primary" @click="close">关闭</ElButton>
</template>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="需求信息">
<ElFormItem label="需求名称" prop="title">
<ReadonlyField :value="model.title" />
</ElFormItem>
<ElFormItem label="模块">
<template v-if="isViewMode">
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
</template>
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="是否需要评审">
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<template v-if="isViewMode">
<ReadonlyField
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
/>
</template>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时">
<template v-if="isViewMode">
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
</template>
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</template>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时">
<template v-if="isViewMode">
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
</template>
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="需求类型" prop="category">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<template v-if="isViewMode">
<ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
</template>
<ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</ElFormItem>
<ElFormItem label="提出人" prop="proposerId">
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<template v-if="isViewMode">
<ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
</template>
<ElSelect
v-else
v-model="model.currentHandlerUserId"
class="w-full"
filterable
placeholder="请选择负责人"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol v-if="isViewMode" :span="12">
<ElFormItem label="实现项目">
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<template v-if="isViewMode">
<ReadonlyField :value="model.sort" />
</template>
<ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
<ElCol v-if="isViewMode && model.lastStatusReason" :span="24">
<ElFormItem label="状态变更原因">
<div class="readonly-textarea">
{{ model.lastStatusReason }}
</div>
</ElFormItem>
</ElCol>
</ElRow>
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem v-if="isViewMode" label="实现项目">
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
</ElFormItem>
<ElFormItem label="排序值">
<template v-if="isViewMode">
<ReadonlyField :value="model.sort" />
</template>
<ElInputNumber
v-else
v-model="model.sort"
class="w-full"
:min="0"
:max="9999"
placeholder="请输入排序值"
/>
</ElFormItem>
<ElFormItem v-if="isViewMode && model.lastStatusReason" label="状态变更原因">
<div class="requirement-operate-dialog__readonly-textarea">{{ model.lastStatusReason }}</div>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:disabled="isViewMode"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
:disabled="isViewMode"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.readonly-textarea {
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
.requirement-operate-dialog__readonly-textarea {
box-sizing: border-box;
width: 100%;
min-height: 100px;
@@ -444,4 +539,10 @@ watch(
white-space: pre-wrap;
word-break: break-all;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -268,10 +268,12 @@ defineExpose({
</script>
<template>
<div class="requirement-module-tree-wrapper">
<div class="module-tree-header">
<span class="module-tree-header__title">模块</span>
</div>
<ElCard class="requirement-module-tree-card card-wrapper">
<template #header>
<div class="module-tree-header">
<span class="module-tree-header__title">模块</span>
</div>
</template>
<div class="module-tree-list">
<template v-for="data in moduleTree" :key="data.id">
@@ -298,14 +300,23 @@ defineExpose({
/>
</template>
</div>
</div>
</ElCard>
</template>
<style scoped>
.requirement-module-tree-wrapper {
display: flex;
flex-direction: column;
gap: 14px;
.requirement-module-tree-card {
height: 100%;
}
.requirement-module-tree-card :deep(.el-card__header) {
padding: 12px 16px;
border-bottom: none;
}
.requirement-module-tree-card :deep(.el-card__body) {
padding: 0 16px 16px;
height: calc(100% - 48px);
overflow: hidden;
}
.module-tree-header {
@@ -316,7 +327,7 @@ defineExpose({
.module-tree-header__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-size: 16px;
font-weight: 700;
}
@@ -325,6 +336,8 @@ defineExpose({
flex-direction: column;
gap: 10px;
min-height: 0;
height: 100%;
overflow-y: auto;
}
.module-tree-item {

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import { fetchSplitRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -31,9 +34,11 @@ const visible = defineModel<boolean>('visible', {
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -43,7 +48,8 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
category: string;
priority: number | null;
@@ -54,7 +60,6 @@ interface Model {
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
const memberUserOptions = computed(() => {
@@ -73,10 +78,41 @@ const rules = {
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
category: '',
priority: 1,
@@ -86,10 +122,6 @@ function createDefaultModel(): Model {
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
function closeDialog() {
visible.value = false;
}
@@ -101,18 +133,23 @@ async function handleSubmit() {
return;
}
const proposerNickname = props.parentRequirement.proposerNickname || '';
const currentHandlerUserNickname = props.parentRequirement.currentHandlerUserNickname || '';
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
const payload: Api.Product.SplitRequirementParams = {
parentId: props.parentRequirement.id,
productId: props.productId,
moduleId: props.parentRequirement.moduleId,
proposerId: props.parentRequirement.proposerId,
proposerNickname,
currentHandlerUserNickname,
proposerNickname: props.parentRequirement.proposerNickname || '',
currentHandlerUserNickname: handler?.userNickname || '',
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
category: model.value.category,
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
@@ -131,6 +168,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('需求拆分成功');
closeDialog();
emit('submitted');
@@ -149,12 +188,13 @@ watch(
model.value.category = props.parentRequirement.category;
}
// 默认选中父需求的负责人
if (props.parentRequirement?.currentHandlerUserId) {
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
}
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -164,7 +204,8 @@ watch(
<BusinessFormDialog
v-model="visible"
title="拆分需求"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
@@ -177,76 +218,116 @@ watch(
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="子需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="子需求信息">
<ElFormItem label="子需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<BusinessRichTextEditor
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>
<style scoped>
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -108,7 +108,9 @@ function handleToggle() {
<icon-mdi-folder-outline v-else class="text-16px" />
</div>
<div class="module-tree-item__content">
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
<ElTooltip v-if="!isEditing" :content="module.moduleName" placement="top" :show-after="500">
<span class="module-tree-item__label">{{ module.moduleName }}</span>
</ElTooltip>
<ElInput
v-else
:model-value="editingName"

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import { fetchCreateProjectRequirement, fetchGetProjectRequirementModuleTree } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -34,6 +37,9 @@ const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -43,7 +49,8 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
moduleId: string;
category: string;
@@ -55,6 +62,7 @@ interface Model {
}
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
const loading = ref(false);
const submitting = ref(false);
const model = ref<Model>(createDefaultModel());
@@ -98,10 +106,41 @@ const rules = {
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
moduleId: props.defaultModuleId || '0',
category: '功能需求',
@@ -113,18 +152,18 @@ function createDefaultModel(): Model {
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
async function loadModuleTree() {
if (!props.projectId) {
moduleTree.value = [];
return;
}
loading.value = true;
const { error, data } = await fetchGetProjectRequirementModuleTree(props.projectId);
loading.value = false;
if (error || !data) {
moduleTree.value = [];
return;
@@ -140,6 +179,11 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const proposer = memberUserOptions.value.find(item => item.userId === model.value.proposerId);
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
@@ -148,7 +192,8 @@ async function handleSubmit() {
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
proposerId: model.value.proposerId,
@@ -169,6 +214,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('项目需求新增成功');
visible.value = false;
emit('submitted');
@@ -184,6 +231,8 @@ watch(
model.value = createDefaultModel();
await loadModuleTree();
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -193,111 +242,150 @@ watch(
<BusinessFormDialog
v-model="visible"
title="新增项目需求"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<BusinessRichTextEditor
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="需求信息">
<ElFormItem label="需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
<ElFormItem label="模块">
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="需求类型" prop="category">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择需求类型"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<DictSelect
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择需求类型"
/>
</ElFormItem>
<ElFormItem label="提出人" prop="proposerId">
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>
<style scoped>
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import {
fetchGetProjectRequirement,
fetchGetProjectRequirementModuleTree,
@@ -7,7 +8,9 @@ import {
} from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import ReadonlyField from '@/components/custom/readonly-field.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -42,6 +45,9 @@ const { createRequiredRule } = useFormRules();
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -51,7 +57,8 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
moduleId: string;
category: string;
@@ -133,10 +140,41 @@ const rules = computed(() => ({
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
}));
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
moduleId: '0',
category: '',
@@ -151,10 +189,6 @@ function createDefaultModel(): Model {
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
async function loadModuleTree() {
if (!props.projectId) {
moduleTree.value = [];
@@ -188,7 +222,8 @@ async function loadRequirementDetail() {
model.value = {
title: data.title || '',
description: data.description || '',
description: data.description || null,
attachments: data.attachments ? [...data.attachments] : [],
reviewRequired: data.reviewRequired ?? 0,
moduleId: data.moduleId || '0',
category: data.category || '',
@@ -210,6 +245,13 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
submitting.value = true;
const payload: Api.Project.UpdateProjectRequirementParams = {
@@ -218,13 +260,14 @@ async function handleSubmit() {
moduleId: model.value.moduleId || '0',
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
proposerId: model.value.proposerId,
proposerNickname: model.value.proposerNickname,
currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
currentHandlerUserNickname: handler?.userNickname || model.value.currentHandlerUserNickname,
workHours: model.value.workHours || 0,
sort: model.value.sort
};
@@ -237,6 +280,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('项目需求更新成功');
visible.value = false;
emit('submitted', props.requirement.id);
@@ -253,9 +298,13 @@ watch(
if (props.requirement?.id) {
await loadRequirementDetail();
} else {
model.value = createDefaultModel();
}
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -265,115 +314,162 @@ watch(
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
:show-footer="isEditMode"
@confirm="handleSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="需求名称" prop="title">
<ReadonlyField v-if="isViewMode" :value="model.title" />
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="模块">
<ReadonlyField v-if="isViewMode" :value="moduleLabelMap.get(model.moduleId) || '--'" />
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<div v-if="isViewMode" class="readonly-textarea" v-html="model.description || '--'"></div>
<BusinessRichTextEditor
v-else
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ReadonlyField
:value="reviewRequiredOptions.find(item => item.value === model.reviewRequired)?.label || '--'"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ReadonlyField
v-if="isViewMode"
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
/>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时">
<ReadonlyField v-if="isViewMode" :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="需求类型" prop="category">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId">
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ReadonlyField v-if="isViewMode" :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
<ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
<template v-if="isViewMode" #footer="{ close }">
<ElButton type="primary" @click="close">关闭</ElButton>
</template>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="需求信息">
<ElFormItem label="需求名称" prop="title">
<ReadonlyField v-if="isViewMode" :value="model.title" />
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
<ElFormItem label="模块">
<ReadonlyField v-if="isViewMode" :value="moduleLabelMap.get(model.moduleId) || '--'" />
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="是否需要评审">
<ReadonlyField
:value="reviewRequiredOptions.find(item => item.value === model.reviewRequired)?.label || '--'"
/>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ReadonlyField
v-if="isViewMode"
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
/>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时">
<ReadonlyField v-if="isViewMode" :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
<ElFormItem label="需求类型" prop="category">
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</ElFormItem>
<ElFormItem label="提出人" prop="proposerId">
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ReadonlyField v-if="isViewMode" :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
<ElSelect
v-else
v-model="model.currentHandlerUserId"
class="w-full"
filterable
placeholder="请选择负责人"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ReadonlyField v-if="isViewMode" :value="model.sort" />
<ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
<ElCol v-if="isViewMode && model.lastStatusReason" :span="24">
<ElFormItem label="状态变更原因">
<div class="readonly-textarea">{{ model.lastStatusReason }}</div>
</ElFormItem>
</ElCol>
</ElRow>
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ReadonlyField v-if="isViewMode" :value="model.sort" />
<ElInputNumber
v-else
v-model="model.sort"
class="w-full"
:min="0"
:max="9999"
placeholder="请输入排序值"
/>
</ElFormItem>
<ElFormItem v-if="isViewMode && model.lastStatusReason" label="状态变更原因">
<div class="requirement-operate-dialog__readonly-textarea">{{ model.lastStatusReason }}</div>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:disabled="isViewMode"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
:disabled="isViewMode"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.readonly-textarea {
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
.requirement-operate-dialog__readonly-textarea {
box-sizing: border-box;
width: 100%;
min-height: 100px;
@@ -387,4 +483,10 @@ watch(
white-space: pre-wrap;
word-break: break-all;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -251,10 +251,12 @@ defineExpose({
</script>
<template>
<div class="requirement-module-tree-wrapper">
<div class="module-tree-header">
<span class="module-tree-header__title">模块</span>
</div>
<ElCard class="requirement-module-tree-card card-wrapper">
<template #header>
<div class="module-tree-header">
<span class="module-tree-header__title">模块</span>
</div>
</template>
<div class="module-tree-list">
<template v-for="item in moduleTree" :key="item.id">
@@ -281,14 +283,23 @@ defineExpose({
/>
</template>
</div>
</div>
</ElCard>
</template>
<style scoped>
.requirement-module-tree-wrapper {
display: flex;
flex-direction: column;
gap: 14px;
.requirement-module-tree-card {
height: 100%;
}
.requirement-module-tree-card :deep(.el-card__header) {
padding: 12px 16px;
border-bottom: none;
}
.requirement-module-tree-card :deep(.el-card__body) {
padding: 0 16px 16px;
height: calc(100% - 48px);
overflow: hidden;
}
.module-tree-header {
@@ -299,7 +310,7 @@ defineExpose({
.module-tree-header__title {
color: rgb(15 23 42 / 94%);
font-size: 15px;
font-size: 16px;
font-weight: 700;
}
@@ -308,5 +319,7 @@ defineExpose({
flex-direction: column;
gap: 10px;
min-height: 0;
height: 100%;
overflow-y: auto;
}
</style>

View File

@@ -1,9 +1,12 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import { fetchSplitProjectRequirement } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import MemberSelectOption from './member-select-option.vue';
@@ -33,6 +36,9 @@ const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({
label: item.label,
@@ -42,7 +48,8 @@ const priorityOptions = computed(() => {
interface Model {
title: string;
description: string;
description: string | null;
attachments: Api.Project.AttachmentItem[];
reviewRequired: number;
category: string;
priority: number | null;
@@ -52,6 +59,7 @@ interface Model {
}
const model = ref<Model>(createDefaultModel());
const loading = ref(false);
const submitting = ref(false);
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
@@ -68,10 +76,41 @@ const rules = {
workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>;
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const height = entries[0]?.contentRect.height;
if (height && height > 120) {
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
return true;
}
const text = html
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/g, '')
.trim();
if (text) {
return false;
}
return !/<img\b/i.test(html);
}
function createDefaultModel(): Model {
return {
title: '',
description: '',
description: null,
attachments: [],
reviewRequired: 0,
category: '',
priority: 1,
@@ -81,10 +120,6 @@ function createDefaultModel(): Model {
};
}
function getNullableText(value?: string | null) {
return value?.trim() || null;
}
async function handleSubmit() {
await validate();
@@ -92,6 +127,11 @@ async function handleSubmit() {
return;
}
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
const payload: Api.Project.SplitProjectRequirementParams = {
@@ -100,7 +140,8 @@ async function handleSubmit() {
moduleId: props.parentRequirement.moduleId,
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
title: model.value.title.trim(),
description: getNullableText(model.value.description),
description: isEmptyRichText(model.value.description) ? null : (model.value.description ?? null),
attachments: [...model.value.attachments],
category: model.value.category,
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
proposerId: props.parentRequirement.proposerId,
@@ -121,6 +162,8 @@ async function handleSubmit() {
return;
}
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
window.$message?.success('项目需求拆分成功');
visible.value = false;
emit('submitted');
@@ -139,12 +182,13 @@ watch(
model.value.category = props.parentRequirement.category;
}
// 默认选中父需求的负责人
if (props.parentRequirement?.currentHandlerUserId) {
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
}
await nextTick();
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
@@ -154,7 +198,9 @@ watch(
<BusinessFormDialog
v-model="visible"
title="拆分项目需求"
preset="lg"
width="1100px"
max-body-height="78vh"
:loading="loading"
:confirm-loading="submitting"
@confirm="handleSubmit"
>
@@ -166,76 +212,116 @@ watch(
class="mb-16px"
/>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="子需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<div class="requirement-operate-dialog__grid">
<div ref="leftColRef" class="requirement-operate-dialog__col-left">
<BusinessFormSection title="子需求信息">
<ElFormItem label="子需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
</ElFormItem>
<ElFormItem label="是否需要评审">
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
<ElOption
v-for="item in reviewRequiredOptions"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="内容">
<BusinessRichTextEditor
v-model="model.description"
height="240px"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="优先级" prop="priority">
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</ElCol>
</ElRow>
</ElFormItem>
<ElFormItem label="负责人" prop="currentHandlerUserId">
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem>
<ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
</ElFormItem>
</BusinessFormSection>
</div>
<div class="requirement-operate-dialog__col-right">
<BusinessFormSection title="需求内容">
<ElFormItem class="requirement-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.description"
:height="editorHeight"
upload-directory="requirement"
placeholder="请输入需求内容"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="requirement-operate-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
directory="requirement"
/>
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped></style>
<style scoped>
.requirement-operate-dialog__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.requirement-operate-dialog__col-left,
.requirement-operate-dialog__col-right {
min-width: 0;
}
.requirement-operate-dialog__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.requirement-operate-dialog__desc-item,
.requirement-operate-dialog__attachment-item {
margin-bottom: 0;
}
@media (width <= 1024px) {
.requirement-operate-dialog__grid {
grid-template-columns: 1fr;
}
}
</style>