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< 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)
}; };
} }

View File

@@ -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)
}; };
} }

View File

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

View File

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

View File

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

View File

@@ -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(/&nbsp;/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>

View File

@@ -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(/&nbsp;/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>

View File

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

View File

@@ -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(/&nbsp;/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>

View File

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

View File

@@ -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(/&nbsp;/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>

View File

@@ -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(/&nbsp;/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>

View File

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

View File

@@ -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(/&nbsp;/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>