feat:树形数据

This commit is contained in:
2026-01-20 16:16:55 +08:00
parent bbb42cd1e6
commit 53a3b96fd3
11 changed files with 581 additions and 27 deletions

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

View File

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