feat:树形数据
This commit is contained in:
312
nl-vue/src/views/pmm/project/request/addRequestDialog.vue
Normal file
312
nl-vue/src/views/pmm/project/request/addRequestDialog.vue
Normal file
@@ -0,0 +1,312 @@
|
||||
<template>
|
||||
<xn-form-container
|
||||
:title="formData.detailId ? '编辑项目需求' : '增加项目需求'"
|
||||
:width="700"
|
||||
v-model:open="open"
|
||||
:destroy-on-close="true"
|
||||
@close="onClose"
|
||||
>
|
||||
<a-form
|
||||
ref="formRef"
|
||||
class="request-form"
|
||||
:model="formData"
|
||||
:rules="formRules"
|
||||
layout="horizontal"
|
||||
:label-col="{ flex: '96px' }"
|
||||
:wrapper-col="{ flex: 1 }"
|
||||
:label-align="'right'"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="功能模块:" name="modelName">
|
||||
<a-input v-model:value="formData.modelName" placeholder="请输入功能模块" allow-clear :maxlength="50" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="功能明细:" name="modelDetail">
|
||||
<a-input v-model:value="formData.modelDetail" placeholder="请输入功能明细" allow-clear :maxlength="50" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="描述:" name="modelDescription">
|
||||
<a-textarea
|
||||
v-model:value="formData.modelDescription"
|
||||
placeholder="请输入描述"
|
||||
:auto-size="{ minRows: 3, maxRows: 6 }"
|
||||
:maxlength="500"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="优先级:" name="priority">
|
||||
<a-select v-model:value="formData.priority" placeholder="请选择优先级" allow-clear>
|
||||
<a-select-option v-for="item in priorityOptions" :key="item.value" :value="item.value">
|
||||
{{ item.label }}
|
||||
</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="计划天数:" name="days">
|
||||
<a-input-number v-model:value="formData.days" :min="1" :precision="0" style="width: 100%" placeholder="请输入计划天数" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="序号:" name="seq">
|
||||
<a-input-number v-model:value="formData.seq" :min="1" :precision="0" style="width: 100%" placeholder="请输入序号" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="计划开始:" name="startTime">
|
||||
<a-date-picker v-model:value="formData.startTime" value-format="YYYY-MM-DD" style="width: 100%" placeholder="请选择开始日期" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="计划结束:" name="endTime">
|
||||
<a-date-picker v-model:value="formData.endTime" value-format="YYYY-MM-DD" style="width: 100%" placeholder="自动计算" disabled />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="顶级类目:" name="isTop">
|
||||
<a-radio-group v-model:value="formData.isTop" @change="onTopChange">
|
||||
<a-radio :value="true">是</a-radio>
|
||||
<a-radio :value="false">否</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12" v-if="formData.isTop === false">
|
||||
<a-form-item label="上级类目:" name="parentDetailId">
|
||||
<a-tree-select
|
||||
v-model:value="formData.parentDetailId"
|
||||
:tree-data="parentTreeData"
|
||||
placeholder="请选择上级类目"
|
||||
allow-clear
|
||||
tree-default-expand-all
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="24">
|
||||
<a-form-item label="备注:" name="remark">
|
||||
<a-textarea
|
||||
v-model:value="formData.remark"
|
||||
placeholder="请输入备注"
|
||||
:auto-size="{ minRows: 2, maxRows: 4 }"
|
||||
:maxlength="200"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-form>
|
||||
<template #footer>
|
||||
<a-button style="margin-right: 8px" @click="onClose">关闭</a-button>
|
||||
<a-button type="primary" @click="onSubmit" :loading="submitLoading">保存</a-button>
|
||||
</template>
|
||||
</xn-form-container>
|
||||
</template>
|
||||
|
||||
<script setup name="addRequestDialog">
|
||||
import dayjs from 'dayjs'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
import { required } from '@/utils/formRules'
|
||||
import projectApi from '@/api/pmm/projectApi'
|
||||
import stageDetailApi from '@/api/pmm/stageDetailApi'
|
||||
|
||||
const open = ref(false)
|
||||
const emit = defineEmits({ successful: null })
|
||||
const formRef = ref()
|
||||
const submitLoading = ref(false)
|
||||
const parentTreeData = ref([])
|
||||
|
||||
const priorityOptions = [
|
||||
{ value: 'P0', label: 'P0(紧急)' },
|
||||
{ value: 'P1', label: 'P1(高)' },
|
||||
{ value: 'P2', label: 'P2(中)' },
|
||||
{ value: 'P3', label: 'P3(低)' }
|
||||
]
|
||||
|
||||
const defaultForm = () => ({
|
||||
detailId: '',
|
||||
projectId: '',
|
||||
stageId: '',
|
||||
modelName: '',
|
||||
modelDetail: '',
|
||||
modelDescription: '',
|
||||
seq: 99,
|
||||
priority: undefined,
|
||||
remark: '',
|
||||
days: undefined,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
isTop: true,
|
||||
parentDetailId: 0
|
||||
})
|
||||
|
||||
const formData = ref(defaultForm())
|
||||
|
||||
const formRules = {
|
||||
modelDetail: [required('请输入功能明细')],
|
||||
modelDescription: [required('请输入描述')],
|
||||
priority: [required('请选择优先级')],
|
||||
seq: [required('请输入序号')],
|
||||
days: [required('请输入计划天数')],
|
||||
startTime: [required('请选择计划开始时间')],
|
||||
parentDetailId: [
|
||||
{
|
||||
validator: async (_rule, value) => {
|
||||
if (formData.value.isTop === false && !value) {
|
||||
return Promise.reject(new Error('请选择上级类目'))
|
||||
}
|
||||
return Promise.resolve()
|
||||
},
|
||||
trigger: 'change'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const buildTree = (nodes) => {
|
||||
const list = Array.isArray(nodes) ? nodes : []
|
||||
const byId = new Map()
|
||||
const roots = []
|
||||
list.forEach((item) => {
|
||||
const id = item?.detailId
|
||||
if (!id) return
|
||||
const titleParts = []
|
||||
if (item.modelName) titleParts.push(item.modelName)
|
||||
if (item.modelDetail) titleParts.push(item.modelDetail)
|
||||
const title = titleParts.length ? titleParts.join(' - ') : String(id)
|
||||
byId.set(id, { title, value: id, key: id, children: [], raw: item })
|
||||
})
|
||||
byId.forEach((node) => {
|
||||
const parentId = node.raw?.parentDetailId
|
||||
if (parentId && byId.has(parentId)) {
|
||||
byId.get(parentId).children.push(node)
|
||||
} else {
|
||||
roots.push(node)
|
||||
}
|
||||
})
|
||||
return roots
|
||||
}
|
||||
|
||||
const loadParentTree = async (projectId) => {
|
||||
if (!projectId) {
|
||||
parentTreeData.value = []
|
||||
return
|
||||
}
|
||||
const data = await projectApi.requestDetail({ projectId })
|
||||
const stages = Array.isArray(data) ? data : []
|
||||
const flat = stages.flatMap((s) => (Array.isArray(s?.descriptions) ? s.descriptions : []))
|
||||
parentTreeData.value = buildTree(flat)
|
||||
}
|
||||
|
||||
const recomputeEndTime = () => {
|
||||
const { startTime, days } = formData.value
|
||||
if (!startTime || !days) {
|
||||
formData.value.endTime = ''
|
||||
return
|
||||
}
|
||||
formData.value.endTime = dayjs(startTime).add(Number(days), 'day').format('YYYY-MM-DD')
|
||||
}
|
||||
|
||||
watch(
|
||||
() => [formData.value.startTime, formData.value.days],
|
||||
() => {
|
||||
recomputeEndTime()
|
||||
}
|
||||
)
|
||||
|
||||
const onTopChange = () => {
|
||||
if (formData.value.isTop === true) {
|
||||
formData.value.parentDetailId = 0
|
||||
}
|
||||
}
|
||||
|
||||
// 打开
|
||||
const onOpen = async (payload = {}) => {
|
||||
open.value = true
|
||||
formData.value = Object.assign(defaultForm(), cloneDeep(payload || {}))
|
||||
if (typeof formData.value.isTop !== 'boolean') {
|
||||
formData.value.isTop = !formData.value.parentDetailId
|
||||
}
|
||||
if (formData.value.isTop) {
|
||||
formData.value.parentDetailId = 0
|
||||
}
|
||||
await loadParentTree(formData.value.projectId)
|
||||
recomputeEndTime()
|
||||
}
|
||||
|
||||
// 关闭
|
||||
const onClose = () => {
|
||||
formRef.value?.resetFields?.()
|
||||
formData.value = defaultForm()
|
||||
parentTreeData.value = []
|
||||
open.value = false
|
||||
}
|
||||
|
||||
const onSubmit = () => {
|
||||
formRef.value
|
||||
.validate()
|
||||
.then(() => {
|
||||
submitLoading.value = true
|
||||
const formDataParam = cloneDeep(formData.value)
|
||||
// 顶级 parentDetailId 置 0
|
||||
if (formDataParam.isTop) {
|
||||
formDataParam.parentDetailId = 0
|
||||
}
|
||||
delete formDataParam.isTop
|
||||
|
||||
// 后端期望可解析的 Date 格式,补全时分秒
|
||||
const formatDateTime = (val) => (val ? dayjs(val).format('YYYY-MM-DD HH:mm:ss') : '')
|
||||
formDataParam.startTime = formatDateTime(formDataParam.startTime)
|
||||
formDataParam.endTime = formatDateTime(formDataParam.endTime)
|
||||
|
||||
stageDetailApi
|
||||
.stageDetailSubmitForm(formDataParam, formDataParam.detailId)
|
||||
.then(() => {
|
||||
onClose()
|
||||
emit('successful')
|
||||
})
|
||||
.finally(() => {
|
||||
submitLoading.value = false
|
||||
})
|
||||
})
|
||||
.catch(() => {})
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
onOpen
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 让横向表单在各种控件(含 textarea)下都保持统一对齐 */
|
||||
.request-form :deep(.ant-form-item-label > label) {
|
||||
height: 32px;
|
||||
line-height: 32px;
|
||||
}
|
||||
.request-form :deep(.ant-form-item-control) {
|
||||
line-height: 32px;
|
||||
}
|
||||
.request-form :deep(.ant-form-item-control-input) {
|
||||
min-height: 32px;
|
||||
}
|
||||
</style>
|
||||
@@ -75,7 +75,7 @@
|
||||
<template #title>
|
||||
<div class="stage-title-wrapper">
|
||||
<span>第{{ getStageNumber(stage.stageSeq) }}阶段 {{ stage.stageName }}</span>
|
||||
<a-button type="primary" class="create-requirement-btn">创建需求</a-button>
|
||||
<a-button type="primary" class="create-requirement-btn" @click="openCreateRequest(stage)">创建需求</a-button>
|
||||
</div>
|
||||
</template>
|
||||
<a-table
|
||||
@@ -116,16 +116,16 @@
|
||||
{{ record.days }}
|
||||
</template>
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" class="action-btn">
|
||||
<a-button type="link" size="small" class="action-btn" @click="openAddSibling(record)">
|
||||
新增同级
|
||||
</a-button>
|
||||
<a-button type="link" size="small" class="action-btn">
|
||||
<a-button type="link" size="small" class="action-btn" @click="openAddChild(record)">
|
||||
新增子级
|
||||
</a-button>
|
||||
<a-button type="link" size="small" class="action-btn">
|
||||
<a-button type="link" size="small" class="action-btn" @click="openEditRequest(record)">
|
||||
修改需求
|
||||
</a-button>
|
||||
<a-button type="link" size="small" class="action-btn">
|
||||
<a-button type="link" size="small" class="action-btn" @click="deleteRequest(record)">
|
||||
删除需求
|
||||
</a-button>
|
||||
</template>
|
||||
@@ -183,6 +183,7 @@
|
||||
<stage-add-dialog ref="formRef" @successful="fetchDetail"/>
|
||||
<fj-upload-form ref="uploadFormRef" @successful="fetchAttachments"/>
|
||||
<batch-stage-add-dialog ref="form2Ref" @successful="fetchDetail"/>
|
||||
<add-request-dialog ref="requestFormRef" @successful="fetchDetail" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -191,8 +192,10 @@
|
||||
import { useRoute } from 'vue-router'
|
||||
import projectApi from '@/api/pmm/projectApi'
|
||||
import projectFileApi from '@/api/pmm/projectFileApi'
|
||||
import stageDetailApi from '@/api/pmm/stageDetailApi'
|
||||
import { Modal, message } from 'ant-design-vue'
|
||||
import StageAddDialog from "@/views/pmm/project/request/stageAddDialog.vue";
|
||||
import AddRequestDialog from "@/views/pmm/project/request/addRequestDialog.vue";
|
||||
import Preview from '@/views/dev/file/preview.vue'
|
||||
import FjUploadForm from "@/views/pmm/project/request/fjUploadForm.vue";
|
||||
import BatchStageAddDialog from "@/views/pmm/project/request/batchStageAddDialog.vue";
|
||||
@@ -213,6 +216,7 @@
|
||||
const plannedHours = ref(0)
|
||||
const formRef = ref()
|
||||
const form2Ref = ref()
|
||||
const requestFormRef = ref()
|
||||
const previewRef = ref()
|
||||
const uploadFormRef = ref()
|
||||
const previewVisible = ref(false)
|
||||
@@ -360,8 +364,15 @@ const attachmentList = ref<AttachmentItem[]>([])
|
||||
.requestDetail({ projectId: projectId.value })
|
||||
.then((data) => {
|
||||
if (Array.isArray(data)) {
|
||||
stageList.value = data
|
||||
plannedHours.value = calculatePlannedHours(data)
|
||||
const normalized = data.map((stage: any) => {
|
||||
const descriptions = Array.isArray(stage?.descriptions) ? stage.descriptions : []
|
||||
return {
|
||||
...stage,
|
||||
descriptions: descriptions.map((d: any) => ({ ...d, stageId: stage.stageId }))
|
||||
}
|
||||
})
|
||||
stageList.value = normalized
|
||||
plannedHours.value = calculatePlannedHours(normalized)
|
||||
} else {
|
||||
stageList.value = []
|
||||
message.warning('数据格式错误')
|
||||
@@ -549,6 +560,69 @@ watch(projectId, () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// 创建需求(不携带 parentDetailId)
|
||||
const openCreateRequest = (stage: any) => {
|
||||
if (!projectId.value) return
|
||||
requestFormRef.value?.onOpen?.({
|
||||
projectId: projectId.value,
|
||||
stageId: stage?.stageId,
|
||||
isTop: true
|
||||
})
|
||||
}
|
||||
|
||||
// 新增同级:带过去 parentDetailId(等同 record.parentDetailId)
|
||||
const openAddSibling = (record: any) => {
|
||||
if (!projectId.value) return
|
||||
const parentDetailId = record?.parentDetailId || ''
|
||||
requestFormRef.value?.onOpen?.({
|
||||
projectId: projectId.value,
|
||||
stageId: record?.stageId,
|
||||
isTop: !parentDetailId,
|
||||
parentDetailId
|
||||
})
|
||||
}
|
||||
|
||||
// 新增子级:把自己的 detailId 作为 parentDetailId
|
||||
const openAddChild = (record: any) => {
|
||||
if (!projectId.value) return
|
||||
requestFormRef.value?.onOpen?.({
|
||||
projectId: projectId.value,
|
||||
stageId: record?.stageId,
|
||||
isTop: false,
|
||||
parentDetailId: record?.detailId
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑
|
||||
const openEditRequest = (record: any) => {
|
||||
if (!projectId.value) return
|
||||
const parentDetailId = record?.parentDetailId || ''
|
||||
requestFormRef.value?.onOpen?.({
|
||||
...record,
|
||||
projectId: projectId.value,
|
||||
isTop: !parentDetailId,
|
||||
parentDetailId
|
||||
})
|
||||
}
|
||||
|
||||
// 删除
|
||||
const deleteRequest = (record: any) => {
|
||||
if (!record?.detailId) return
|
||||
Modal.confirm({
|
||||
title: '确认删除该需求?',
|
||||
content: `需求:${record?.modelDetail || record?.modelName || record?.detailId}`,
|
||||
okText: '确认',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
const params = [{ detailId: record.detailId }]
|
||||
return stageDetailApi.stageDetailDelete(params).then(() => {
|
||||
message.success('删除成功')
|
||||
fetchDetail()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
Reference in New Issue
Block a user