fix(产品需求): 修复产品需求在测试后存在的问题。

This commit is contained in:
dk
2026-05-09 13:42:04 +08:00
parent f4f43814b3
commit f0ea903d59
13 changed files with 706 additions and 384 deletions

View File

@@ -667,7 +667,7 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
* - 中间节点:有上级也有下级 * - 中间节点:有上级也有下级
* - 叶子节点:基层员工,没有下级 * - 叶子节点:基层员工,没有下级
*/ */
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) { export async function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({ return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
@@ -684,7 +684,7 @@ export function fetchGetUserManagementRelationTree(query: UserManagementRelation
* 通过搜索框的查询条件,获取用户管理链路树形结构 * 通过搜索框的查询条件,获取用户管理链路树形结构
* 用于树形控件展示,包含用户的上下级层级关系 * 用于树形控件展示,包含用户的上下级层级关系
*/ */
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) { export async function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
return request<UserManagementRelationTreeResponse[]>({ return request<UserManagementRelationTreeResponse[]>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
@@ -704,7 +704,7 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
* *
* @param id 关系记录主键 ID * @param id 关系记录主键 ID
*/ */
export function fetchGetUserManagementRelation(id: string) { export async function fetchGetUserManagementRelation(id: string) {
return request<UserManagementRelationResponse>({ return request<UserManagementRelationResponse>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
@@ -722,7 +722,7 @@ export function fetchGetUserManagementRelation(id: string) {
* *
* @param data 创建请求参数 * @param data 创建请求参数
*/ */
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) { export async function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
return request<string | number>({ return request<string | number>({
...safeJsonRequestConfig, ...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`, url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
@@ -776,3 +776,22 @@ export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
method: 'delete' method: 'delete'
}); });
} }
/**
* 获取未绑定直属上级的候选下级用户列表
*
* 用于获取尚未绑定直属上级的用户列表,供选择使用
*
* @returns 候选下级用户列表
*/
export async function fetchGetCandidateSubordinateUsers() {
return request<UserSimpleResponse[]>({
...safeJsonRequestConfig,
url: `${USER_MANAGEMENT_RELATION_PREFIX}/candidate-users`,
method: 'get'
}).then(result =>
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse[]>, data => data.map(normalizeUserSimple))
);
}

View File

@@ -250,17 +250,17 @@ declare namespace Api {
moduleId: string; moduleId: string;
/** 是否需要评审0不需要1需要 */ /** 是否需要评审0不需要1需要 */
reviewRequired: RequirementReviewRequired; reviewRequired: RequirementReviewRequired;
/** 需求标题 */ /** 需求名称 */
title: string; title: string;
/** 需求描述(富文本) */ /** 需求内容(富文本) */
description?: string | null; description?: string | null;
/** 需求类字典值 */ /** 需求类字典值 */
category: string; category: string;
/** 需求类名称 */ /** 需求类名称 */
categoryName?: string | null; categoryName?: string | null;
/** 来源类型 */ /** 需求来源类型 */
sourceType: RequirementSourceType; sourceType: RequirementSourceType;
/** 来源业务ID */ /** 需求来源业务ID */
sourceBizId?: string | null; sourceBizId?: string | null;
/** 优先级0低 1中 2高 3紧急 */ /** 优先级0低 1中 2高 3紧急 */
priority: RequirementPriority; priority: RequirementPriority;
@@ -282,10 +282,8 @@ declare namespace Api {
currentHandlerUserNickname?: string | null; currentHandlerUserNickname?: string | null;
/** 默认实现项目编号 */ /** 默认实现项目编号 */
implementProjectId?: string | null; implementProjectId?: string | null;
/** 实现项目名称 */ /** 所需工时(小时) */
implementProjectName?: string | null; workHours: number;
/** 预期完成时间 */
completionDate: string;
/** 排序值 */ /** 排序值 */
sort: number; sort: number;
/** 创建时间 */ /** 创建时间 */
@@ -378,9 +376,11 @@ declare namespace Api {
| 'category' | 'category'
| 'priority' | 'priority'
| 'proposerId' | 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'
| 'currentHandlerUserNickname'
| 'implementProjectId' | 'implementProjectId'
| 'completionDate' | 'workHours'
| 'sort' | 'sort'
>; >;
@@ -415,8 +415,10 @@ declare namespace Api {
| 'category' | 'category'
| 'priority' | 'priority'
| 'proposerId' | 'proposerId'
| 'proposerNickname'
| 'currentHandlerUserId' | 'currentHandlerUserId'
| 'completionDate' | 'currentHandlerUserNickname'
| 'workHours'
| 'sort' | 'sort'
>; >;

View File

@@ -102,6 +102,7 @@ declare module 'vue' {
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default'] IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default'] 'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['default']
'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default'] 'IconIconParkOutline:equalRatio': typeof import('~icons/icon-park-outline/equal-ratio')['default']
IconIcRoundChevronRight: typeof import('~icons/ic/round-chevron-right')['default']
IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default'] IconIcRoundDelete: typeof import('~icons/ic/round-delete')['default']
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default'] IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default'] IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']

View File

@@ -1,11 +1,12 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { computed, onMounted, reactive, ref, watch } from 'vue'; import { computed, onMounted, reactive, ref, watch } from 'vue';
import type { TableInstance } from 'element-plus'; import type { TableInstance } from 'element-plus';
import { ElButton, ElTag } from 'element-plus'; import { ElButton, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
RDMS_REQ_CATEGORY_DICT_CODE, RDMS_REQ_CATEGORY_DICT_CODE,
RDMS_REQ_PRIORITY_DICT_CODE RDMS_REQ_PRIORITY_DICT_CODE,
RDMS_REQ_SOURCE_TYPE_DICT_CODE
} from '@/constants/dict'; } from '@/constants/dict';
import { import {
fetchChangeRequirementStatus, fetchChangeRequirementStatus,
@@ -17,13 +18,13 @@ import {
fetchGetRequirementTree fetchGetRequirementTree
} from '@/service/api'; } from '@/service/api';
import { useAuth } from '@/hooks/business/auth'; import { useAuth } from '@/hooks/business/auth';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import DictTag from '@/components/custom/dict-tag.vue'; import DictTag from '@/components/custom/dict-tag.vue';
import DictText from '@/components/custom/dict-text.vue';
import SvgIcon from '@/components/custom/svg-icon.vue';
import { useCurrentProduct } from '../shared/use-current-product'; import { useCurrentProduct } from '../shared/use-current-product';
import { import {
type RequirementStatusActionCode, type RequirementStatusActionCode,
getRequirementActionDisplayName, getRequirementActionDisplayName,
getRequirementActionTagType,
getRequirementStatusTagType, getRequirementStatusTagType,
isRequirementActionNeedProject, isRequirementActionNeedProject,
isRequirementActionNeedReviewChoice, isRequirementActionNeedReviewChoice,
@@ -41,6 +42,44 @@ defineOptions({ name: 'ProductRequirement' });
const { currentObjectId } = useCurrentProduct(); const { currentObjectId } = useCurrentProduct();
const { hasObjectAuth } = useAuth(); const { hasObjectAuth } = useAuth();
/**
* 操作按钮图标映射
*
* 将操作类型映射到对应的 Iconify 图标
*/
const actionIconMap: Record<string, string> = {
split: 'ic:round-call-split',
edit: 'ic:round-edit',
claim_to_review: 'ic:round-check',
claim_to_dispatch: 'ic:round-check',
to_dispatch: 'ic:round-verified',
dispatch: 'ic:round-merge-type',
accept: 'ic:round-check-circle',
reject: 'ic:round-cancel',
cancel: 'ic:round-close',
close: 'ic:round-power-settings-new',
delete: 'ic:round-delete'
};
/**
* 操作按钮颜色映射
*
* 将操作类型映射到对应的图标颜色Element Plus 主题色)
*/
const actionColorMap: Record<string, string> = {
split: 'var(--el-color-primary)',
edit: 'var(--el-color-info)',
claim_to_review: 'var(--el-color-primary)',
claim_to_dispatch: 'var(--el-color-primary)',
to_dispatch: 'var(--el-color-success)',
dispatch: 'var(--el-color-primary)',
accept: 'var(--el-color-success)',
reject: 'var(--el-color-danger)',
cancel: 'var(--el-color-danger)',
close: 'var(--el-color-danger)',
delete: 'var(--el-color-danger)'
};
const statusOptions = ref<Array<{ label: string; value: string }>>([]); const statusOptions = ref<Array<{ label: string; value: string }>>([]);
const terminalStatusOptions = ref<string[]>([]); const terminalStatusOptions = ref<string[]>([]);
@@ -244,14 +283,13 @@ const columns = computed(() => [
}, },
{ {
prop: 'title', prop: 'title',
label: '标题', label: '需求名称',
minWidth: 200, minWidth: 200,
formatter: (row: Api.Product.Requirement) => { formatter: (row: Api.Product.Requirement) => {
const isTerminal = isTerminalStatus(row.statusCode);
const className = 'requirement-title'; const className = 'requirement-title';
return ( return (
<ElButton link type={isTerminal ? 'info' : 'primary'} class={className} onClick={() => openView(row)}> <ElButton link type='primary' class={className} onClick={() => openView(row)}>
{row.title} {row.title}
</ElButton> </ElButton>
); );
@@ -259,13 +297,22 @@ const columns = computed(() => [
}, },
{ {
prop: 'category', prop: 'category',
label: '分类', label: '需求类型',
minWidth: 120, minWidth: 120,
formatter: (row: Api.Product.Requirement) => row.category formatter: (row: Api.Product.Requirement) => row.category
}, },
{
prop: 'sourceType',
label: '需求来源',
minWidth: 100,
align: 'center',
formatter: (row: Api.Product.Requirement) => (
<DictText dictCode={RDMS_REQ_SOURCE_TYPE_DICT_CODE} value={row.sourceType} />
)
},
// { // {
// prop: 'description', // prop: 'description',
// label: '描述', // label: '内容',
// minWidth: 200, // minWidth: 200,
// showOverflowTooltip: true, // showOverflowTooltip: true,
// formatter: (row: Api.Product.Requirement) => { // formatter: (row: Api.Product.Requirement) => {
@@ -281,6 +328,13 @@ const columns = computed(() => [
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} /> <DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
) )
}, },
{
prop: 'workHours',
label: '所需工时',
width: 75,
align: 'center',
formatter: (row: Api.Product.Requirement) => (row.workHours != null ? `${row.workHours}h` : '--')
},
{ {
prop: 'statusCode', prop: 'statusCode',
label: '状态', label: '状态',
@@ -292,6 +346,12 @@ const columns = computed(() => [
</ElTag> </ElTag>
) )
}, },
{
prop: 'proposerNickname',
label: '提出人',
minWidth: 70,
formatter: (row: Api.Product.Requirement) => row.proposerNickname || '--'
},
{ {
prop: 'currentHandlerUserId', prop: 'currentHandlerUserId',
label: '负责人', label: '负责人',
@@ -315,15 +375,15 @@ const columns = computed(() => [
} }
}, },
{ {
prop: 'implementProjectName', prop: 'implementProjectId',
label: '实现项目', label: '实现项目',
minWidth: 140, minWidth: 140,
formatter: (row: Api.Product.Requirement) => row.implementProjectName || '--' formatter: (row: Api.Product.Requirement) => row.implementProjectId || '--'
}, },
{ {
prop: 'createTime', prop: 'createTime',
label: '创建时间', label: '创建时间',
width: 170, minWidth: 180,
formatter: (row: Api.Product.Requirement) => formatDateTime(row.createTime) formatter: (row: Api.Product.Requirement) => formatDateTime(row.createTime)
}, },
{ {
@@ -336,7 +396,8 @@ const columns = computed(() => [
const actions: { const actions: {
key: string; key: string;
label: string; label: string;
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info'; icon: string;
color: string;
disabled?: boolean; disabled?: boolean;
onClick: () => void; onClick: () => void;
}[] = []; }[] = [];
@@ -345,17 +406,18 @@ const columns = computed(() => [
actions.push({ actions.push({
key: 'split', key: 'split',
label: '拆分', label: '拆分',
buttonType: 'primary', icon: actionIconMap.split,
color: actionColorMap.split,
onClick: () => openSplit(row) onClick: () => openSplit(row)
}); });
} }
if (hasObjectAuth('project:product:update')) { if (hasObjectAuth('project:product:update') && !isTerminalStatus(row.statusCode)) {
actions.push({ actions.push({
key: 'edit', key: 'edit',
label: '编辑', label: '编辑',
buttonType: 'info', icon: actionIconMap.edit,
disabled: isTerminalStatus(row.statusCode), color: actionColorMap.edit,
onClick: () => openEdit(row) onClick: () => openEdit(row)
}); });
} }
@@ -380,7 +442,8 @@ const columns = computed(() => [
actions.push({ actions.push({
key: `action-${action.actionCode}`, key: `action-${action.actionCode}`,
label: getRequirementActionDisplayName(action), label: getRequirementActionDisplayName(action),
buttonType: getRequirementActionTagType(action.actionCode as RequirementStatusActionCode), icon: actionIconMap[action.actionCode] || 'ic:round-play-arrow',
color: actionColorMap[action.actionCode] || 'var(--el-color-primary)',
onClick: () => handleActionClick(row, action) onClick: () => handleActionClick(row, action)
}); });
} }
@@ -390,16 +453,61 @@ const columns = computed(() => [
actions.push({ actions.push({
key: 'delete', key: 'delete',
label: '删除', label: '删除',
buttonType: 'danger', icon: actionIconMap.delete,
color: actionColorMap.delete,
onClick: () => handleDelete(row) onClick: () => handleDelete(row)
}); });
} }
return <BusinessTableActionCell actions={actions} />; return (
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
{actions.map(action => (
<ElTooltip key={action.key} content={action.label} placement="top">
<ElButton
text
size="small"
class="requirement-action-icon-btn"
onClick={() => action.onClick()}
>
<SvgIcon icon={action.icon} class="text-18px" style={{ color: action.color }} />
</ElButton>
</ElTooltip>
))}
</div>
);
} }
} }
]); ]);
const columnChecks = ref<UI.TableColumnCheck[]>([]);
watch(
() => columns.value,
cols => {
const existingMap = new Map(columnChecks.value.map(c => [c.prop, c.checked]));
columnChecks.value = cols
.filter(col => col.prop && col.prop !== 'operate')
.map(col => ({
prop: String(col.prop),
label: String(col.label || ''),
checked: existingMap.has(String(col.prop)) ? existingMap.get(String(col.prop))! : true,
visible: true
}));
},
{ immediate: true }
);
const visibleColumns = computed(() => {
if (columnChecks.value.length === 0) return columns.value;
const visibleSet = new Set(columnChecks.value.filter(c => c.checked).map(c => c.prop));
return columns.value.filter(col => {
const prop = String(col.prop || '');
if (!prop) return true;
if (prop === 'operate') return true;
return visibleSet.has(prop);
});
});
async function loadMembers() { async function loadMembers() {
if (!currentObjectId.value) { if (!currentObjectId.value) {
memberOptions.value = []; memberOptions.value = [];
@@ -660,7 +768,7 @@ onMounted(async () => {
<p>需求列表</p> <p>需求列表</p>
<ElTag effect="plain">{{ pagination.total }} </ElTag> <ElTag effect="plain">{{ pagination.total }} </ElTag>
</div> </div>
<TableHeaderOperation :loading="loading" @refresh="reloadTable"> <TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
<template #default> <template #default>
<ElButton <ElButton
v-auth="{ code: 'project:product:create', source: 'object' }" v-auth="{ code: 'project:product:create', source: 'object' }"
@@ -690,7 +798,7 @@ onMounted(async () => {
:data="treeData" :data="treeData"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }" :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
> >
<ElTableColumn v-for="col in columns" :key="String(col.prop || 'index')" v-bind="col" /> <ElTableColumn v-for="col in visibleColumns" :key="String(col.prop || 'index')" v-bind="col" />
<template #empty> <template #empty>
<ElEmpty description="当前模块下暂无需求" /> <ElEmpty description="当前模块下暂无需求" />
@@ -777,4 +885,20 @@ onMounted(async () => {
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) { :deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
color: transparent; color: transparent;
} }
:deep(.requirement-action-cell) {
display: inline-flex;
align-items: center;
gap: 1px;
}
:deep(.requirement-action-icon-btn) {
padding: 1px;
height: auto;
min-width: auto;
}
:deep(.requirement-action-icon-btn:hover) {
background-color: var(--el-fill-color-light);
}
</style> </style>

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed, inject, ref, type Ref } from 'vue';
defineOptions({ name: 'ModuleTreeNode' }); defineOptions({ name: 'ModuleTreeNode' });
@@ -32,15 +32,15 @@ const emit = defineEmits([
'updateNewChildModuleName' 'updateNewChildModuleName'
]); ]);
const collapsedModuleIds = inject<Ref<Set<string>>>('collapsedModuleIds', ref(new Set()));
const toggleCollapse = inject<(id: string) => void>('toggleCollapse', () => {});
const isRootModule = computed(() => props.module.id === props.rootModuleId); const isRootModule = computed(() => props.module.id === props.rootModuleId);
const isSelected = computed(() => props.selectedModuleId === props.module.id); const isSelected = computed(() => props.selectedModuleId === props.module.id);
const isEditing = computed(() => props.editingNodeId === props.module.id); const isEditing = computed(() => props.editingNodeId === props.module.id);
const isAddingChild = computed(() => props.addingChildParentId === props.module.id); const isAddingChild = computed(() => props.addingChildParentId === props.module.id);
const hasChildren = computed(() => props.module.children && props.module.children.length > 0); const hasChildren = computed(() => props.module.children && props.module.children.length > 0);
const isCollapsed = computed(() => hasChildren.value && props.module.id ? collapsedModuleIds.value.has(props.module.id) : false);
const hasRequirements = computed(() => { const hasRequirements = computed(() => {
const moduleId = props.module.id; const moduleId = props.module.id;
@@ -91,6 +91,12 @@ function handleAddChildConfirm() {
function handleAddChildCancel() { function handleAddChildCancel() {
emit('addChildCancel'); emit('addChildCancel');
} }
function handleToggle() {
if (props.module.id) {
toggleCollapse(props.module.id);
}
}
</script> </script>
<template> <template>
@@ -105,6 +111,9 @@ function handleAddChildCancel() {
:style="indentStyle" :style="indentStyle"
@click="handleClick" @click="handleClick"
> >
<div class="module-tree-item__toggle" :class="{ 'is-expanded': hasChildren && !isCollapsed }" @click.stop="handleToggle">
<icon-ic-round-chevron-right v-if="hasChildren" class="text-14px" />
</div>
<div class="module-tree-item__icon"> <div class="module-tree-item__icon">
<icon-mdi-folder-open v-if="isRootModule" class="text-16px" /> <icon-mdi-folder-open v-if="isRootModule" class="text-16px" />
<icon-mdi-folder-outline v-else class="text-16px" /> <icon-mdi-folder-outline v-else class="text-16px" />
@@ -124,7 +133,7 @@ function handleAddChildCancel() {
/> />
</div> </div>
<div v-if="!isRootModule && !isEditing" class="module-tree-item__actions"> <div v-if="!isEditing" class="module-tree-item__actions" @click.stop>
<ElDropdown trigger="click"> <ElDropdown trigger="click">
<ElButton text size="small" class="module-tree-item__more-btn"> <ElButton text size="small" class="module-tree-item__more-btn">
<icon-mdi-dots-horizontal class="text-14px" /> <icon-mdi-dots-horizontal class="text-14px" />
@@ -140,14 +149,18 @@ function handleAddChildCancel() {
<span>新增子模块</span> <span>新增子模块</span>
</div> </div>
</ElDropdownItem> </ElDropdownItem>
<ElDropdownItem v-auth="{ code: 'project:product:update', source: 'object' }" @click="handleStartEdit"> <ElDropdownItem
v-if="!isRootModule"
v-auth="{ code: 'project:product:update', source: 'object' }"
@click="handleStartEdit"
>
<div class="flex items-center gap-6px"> <div class="flex items-center gap-6px">
<icon-mdi-pencil-outline class="text-14px" /> <icon-mdi-pencil-outline class="text-14px" />
<span>编辑</span> <span>编辑</span>
</div> </div>
</ElDropdownItem> </ElDropdownItem>
<ElDropdownItem <ElDropdownItem
v-if="canDeleteModule" v-if="!isRootModule && canDeleteModule"
v-auth="{ code: 'project:product:delete', source: 'object' }" v-auth="{ code: 'project:product:delete', source: 'object' }"
divided divided
@click="handleDelete" @click="handleDelete"
@@ -163,7 +176,7 @@ function handleAddChildCancel() {
</div> </div>
</div> </div>
<template v-if="hasChildren"> <template v-if="hasChildren && !isCollapsed">
<ModuleTreeNode <ModuleTreeNode
v-for="child in module.children" v-for="child in module.children"
:key="child.id" :key="child.id"
@@ -270,6 +283,23 @@ function handleAddChildCancel() {
color: rgb(100 116 139 / 80%); color: rgb(100 116 139 / 80%);
} }
.module-tree-item__toggle {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
flex-shrink: 0;
cursor: pointer;
user-select: none;
transition: transform 0.2s ease;
color: rgb(148 163 184);
}
.module-tree-item__toggle.is-expanded svg {
transform: rotate(90deg);
}
.module-tree-item__content { .module-tree-item__content {
flex: 1; flex: 1;
min-width: 0; min-width: 0;

View File

@@ -118,8 +118,7 @@ async function handleSubmit() {
@confirm="handleSubmit" @confirm="handleSubmit"
> >
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElFormItem label="需求标题"> <ElFormItem :label="`需求名称:${requirementTitle}`">
<span class="text-14px">{{ requirementTitle }}</span>
</ElFormItem> </ElFormItem>
<ElFormItem v-if="isClaimAction" label="是否需要评审" prop="reviewChoice"> <ElFormItem v-if="isClaimAction" label="是否需要评审" prop="reviewChoice">

View File

@@ -45,12 +45,12 @@ interface Model {
title: string; title: string;
description: string; description: string;
reviewRequired: number; reviewRequired: number;
completionDate: string;
moduleId: string; moduleId: string;
category: string; category: string;
priority: number | null; priority: number | null;
proposerId: string; proposerId: string;
currentHandlerUserId: string; currentHandlerUserId: string;
workHours: number | null;
sort: number; sort: number;
} }
@@ -64,11 +64,28 @@ const memberUserOptions = computed(() => {
return props.memberOptions.filter(m => m.status === 0); return props.memberOptions.filter(m => m.status === 0);
}); });
const moduleTreeProps = { const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
label: 'moduleName', const options: Array<{ label: string; value: string }> = [];
value: 'id',
children: 'children' function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
}; for (const module of modules) {
const currentPath = `${parentPath}/${module.moduleName}`;
options.push({
label: currentPath,
value: module.id || ''
});
if (module.children?.length) {
walk(module.children, currentPath);
}
}
}
if (moduleTree.value.length > 0) {
walk(moduleTree.value, '');
}
return options;
});
const reviewRequiredOptions = [ const reviewRequiredOptions = [
{ label: '不需要', value: 0 }, { label: '不需要', value: 0 },
@@ -76,12 +93,12 @@ const reviewRequiredOptions = [
]; ];
const rules = { const rules = {
title: [createRequiredRule('请输入需求标题')], title: [createRequiredRule('请输入需求名称')],
category: [createRequiredRule('请选择分类')], category: [createRequiredRule('请选择需求类型')],
priority: [createRequiredRule('请选择优先级')], priority: [createRequiredRule('请选择优先级')],
proposerId: [createRequiredRule('请选择提出人')], proposerId: [createRequiredRule('请选择提出人')],
currentHandlerUserId: [createRequiredRule('请选择负责人')], currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')] workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>; } satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model { function createDefaultModel(): Model {
@@ -89,12 +106,12 @@ function createDefaultModel(): Model {
title: '', title: '',
description: '', description: '',
reviewRequired: 0, reviewRequired: 0,
completionDate: '',
moduleId: props.defaultModuleId || '0', moduleId: props.defaultModuleId || '0',
category: '功能需求', category: '功能需求',
priority: 1, priority: 1,
proposerId: '', proposerId: '',
currentHandlerUserId: '', currentHandlerUserId: '',
workHours: null,
sort: 0 sort: 0
}; };
} }
@@ -114,6 +131,11 @@ async function handleSubmit() {
return; return;
} }
const proposer = memberUserOptions.value.find(m => m.userId === model.value.proposerId);
const proposerNickname = proposer?.userNickname || '';
const handler = memberUserOptions.value.find(m => m.userId === model.value.currentHandlerUserId);
const currentHandlerUserNickname = handler?.userNickname || '';
const payload: Api.Product.SaveRequirementParams = { const payload: Api.Product.SaveRequirementParams = {
productId: props.productId, productId: props.productId,
moduleId: model.value.moduleId || '0', moduleId: model.value.moduleId || '0',
@@ -123,9 +145,11 @@ async function handleSubmit() {
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,
currentHandlerUserId: model.value.currentHandlerUserId, currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname,
implementProjectId: null, implementProjectId: null,
completionDate: model.value.completionDate, workHours: model.value.workHours || 0,
sort: model.value.sort sort: model.value.sort
}; };
@@ -188,32 +212,8 @@ watch(
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16"> <ElRow :gutter="16">
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="标题" prop="title"> <ElFormItem label="需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" /> <ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
<ElDatePicker
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<ElInput
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
placeholder="请输入需求描述"
/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
@@ -228,22 +228,33 @@ watch(
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="24">
<ElFormItem label="模块"> <ElFormItem label="内容">
<ElTreeSelect <ElInput
v-model="model.moduleId" v-model="model.description"
:data="moduleTree" type="textarea"
:props="moduleTreeProps" :rows="6"
class="w-full" maxlength="2000"
check-strictly show-word-limit
:render-after-expand="false" placeholder="请输入需求内容"
placeholder="请选择所属模块"
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="分类" prop="category"> <ElFormItem label="模块">
<DictSelect v-model="model.category" :dict-code="categoryDictCode" 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"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="需求类型" prop="category">
<DictSelect v-model="model.category" :dict-code="categoryDictCode" filterable placeholder="请选择需求类型" />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
@@ -281,6 +292,18 @@ watch(
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="排序值"> <ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" /> <ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />

View File

@@ -1,15 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import {computed, nextTick, ref, watch} from 'vue';
import dayjs from 'dayjs'; import {fetchGetRequirement, fetchGetRequirementModuleTree, fetchUpdateRequirement} from '@/service/api';
import { fetchGetRequirement, fetchGetRequirementModuleTree, fetchUpdateRequirement } 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 BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.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';
defineOptions({ name: 'RequirementDetailDialog' }); defineOptions({name: 'RequirementDetailDialog'});
type DialogMode = 'view' | 'edit'; type DialogMode = 'view' | 'edit';
@@ -34,11 +32,11 @@ const visible = defineModel<boolean>('visible', {
default: false default: false
}); });
const { formRef, validate } = useForm(); const {formRef, validate} = useForm();
const { createRequiredRule } = useFormRules(); 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 priorityOptions = computed(() => { const priorityOptions = computed(() => {
return priorityDictData.value.map(item => ({ return priorityDictData.value.map(item => ({
@@ -51,13 +49,15 @@ interface Model {
title: string; title: string;
description: string; description: string;
reviewRequired: number; reviewRequired: number;
completionDate: string;
moduleId: string; moduleId: string;
category: string; category: string;
priority: number | null; priority: number | null;
proposerId: string; proposerId: string;
proposerNickname: string;
currentHandlerUserId: string; currentHandlerUserId: string;
currentHandlerUserNickname: string;
implementProjectId: string | null; implementProjectId: string | null;
workHours: number | null;
sort: number; sort: number;
lastStatusReason: string; lastStatusReason: string;
} }
@@ -87,6 +87,7 @@ const memberLabelMap = computed(() => {
const moduleLabelMap = computed(() => { const moduleLabelMap = computed(() => {
const map = new Map<string | undefined, string>(); const map = new Map<string | undefined, string>();
function traverse(modules: Api.Product.RequirementModule[]) { function traverse(modules: Api.Product.RequirementModule[]) {
for (const module of modules) { for (const module of modules) {
map.set(module.id, module.moduleName); map.set(module.id, module.moduleName);
@@ -95,29 +96,46 @@ const moduleLabelMap = computed(() => {
} }
} }
} }
traverse(moduleTree.value); traverse(moduleTree.value);
return map; return map;
}); });
const moduleTreeProps = { const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
label: 'moduleName', const options: Array<{ label: string; value: string }> = [];
value: 'id',
children: 'children' function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
}; for (const module of modules) {
const currentPath = `${parentPath}/${module.moduleName}`;
options.push({
label: currentPath,
value: module.id || ''
});
if (module.children?.length) {
walk(module.children, currentPath);
}
}
}
if (moduleTree.value.length > 0) {
walk(moduleTree.value, '');
}
return options;
});
const reviewRequiredOptions = [ const reviewRequiredOptions = [
{ label: '不需要', value: 0 }, {label: '不需要', value: 0},
{ label: '需要', value: 1 } {label: '需要', value: 1}
]; ];
const rules = computed(() => { const rules = computed(() => {
const baseRules: Record<string, App.Global.FormRule[]> = { const baseRules: Record<string, App.Global.FormRule[]> = {
title: isEditMode.value ? [createRequiredRule('请输入需求标题')] : [], title: isEditMode.value ? [createRequiredRule('请输入需求名称')] : [],
category: isEditMode.value ? [createRequiredRule('请选择分类')] : [], category: isEditMode.value ? [createRequiredRule('请选择需求类型')] : [],
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [], priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [], proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : [], currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
completionDate: isEditMode.value ? [createRequiredRule('请选择预期完成时间')] : []
}; };
return baseRules; return baseRules;
@@ -128,13 +146,15 @@ function createDefaultModel(): Model {
title: '', title: '',
description: '', description: '',
reviewRequired: 0, reviewRequired: 0,
completionDate: '',
moduleId: '0', moduleId: '0',
category: '', category: '',
priority: 1, priority: 1,
proposerId: '', proposerId: '',
proposerNickname: '',
currentHandlerUserId: '', currentHandlerUserId: '',
currentHandlerUserNickname: '',
implementProjectId: null, implementProjectId: null,
workHours: null,
sort: 0, sort: 0,
lastStatusReason: '' lastStatusReason: ''
}; };
@@ -167,13 +187,15 @@ async function handleSubmit() {
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,
currentHandlerUserId: model.value.currentHandlerUserId, currentHandlerUserId: model.value.currentHandlerUserId,
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
implementProjectId: model.value.implementProjectId, implementProjectId: model.value.implementProjectId,
completionDate: model.value.completionDate, workHours: model.value.workHours || 0,
sort: model.value.sort sort: model.value.sort
}; };
const { error } = await fetchUpdateRequirement(updatePayload); const {error} = await fetchUpdateRequirement(updatePayload);
submitting.value = false; submitting.value = false;
@@ -192,7 +214,7 @@ async function loadModuleTree() {
return; return;
} }
const { error, data } = await fetchGetRequirementModuleTree(props.productId); const {error, data} = await fetchGetRequirementModuleTree(props.productId);
if (error || !data) { if (error || !data) {
moduleTree.value = []; moduleTree.value = [];
@@ -209,7 +231,7 @@ async function loadRequirementDetail() {
loading.value = true; loading.value = true;
const { error, data } = await fetchGetRequirement(props.requirement.id, props.productId); const {error, data} = await fetchGetRequirement(props.requirement.id, props.productId);
loading.value = false; loading.value = false;
@@ -221,13 +243,15 @@ async function loadRequirementDetail() {
title: data.title || '', title: data.title || '',
description: data.description || '', description: data.description || '',
reviewRequired: data.reviewRequired ?? 0, reviewRequired: data.reviewRequired ?? 0,
completionDate: data.completionDate || '',
moduleId: data.moduleId || '0', moduleId: data.moduleId || '0',
category: data.category || '', category: data.category || '',
priority: data.priority ?? null, priority: data.priority ?? null,
proposerId: data.proposerId || '', proposerId: data.proposerId || '',
proposerNickname: data.proposerNickname || '',
currentHandlerUserId: data.currentHandlerUserId || '', currentHandlerUserId: data.currentHandlerUserId || '',
currentHandlerUserNickname: data.currentHandlerUserNickname || '',
implementProjectId: data.implementProjectId || null, implementProjectId: data.implementProjectId || null,
workHours: data.workHours ?? null,
sort: data.sort ?? 0, sort: data.sort ?? 0,
lastStatusReason: data.lastStatusReason || '' lastStatusReason: data.lastStatusReason || ''
}; };
@@ -265,33 +289,17 @@ watch(
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16"> <ElRow :gutter="16">
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="标题" prop="title"> <ElFormItem label="需求名称" prop="title">
<template v-if="isViewMode"> <ReadonlyField :value="model.title" />
<ReadonlyField :value="model.title" />
</template>
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%"> <ElFormItem label="是否需要评审">
<template v-if="isViewMode"> <ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label"/>
<ReadonlyField
:value="model.completionDate ? dayjs(Number(model.completionDate)).format('YYYY-MM-DD HH:mm:ss') : '--'"
/>
</template>
<ElDatePicker
v-else
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem label="描述"> <ElFormItem label="内容">
<template v-if="isViewMode"> <template v-if="isViewMode">
<div class="readonly-textarea"> <div class="readonly-textarea">
{{ model.description || '--' }} {{ model.description || '--' }}
@@ -304,44 +312,28 @@ watch(
:rows="6" :rows="6"
maxlength="2000" maxlength="2000"
show-word-limit show-word-limit
placeholder="请输入需求描述" placeholder="请输入需求内容"
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12">
<ElFormItem label="是否需要评审">
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
</ElFormItem>
</ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="模块"> <ElFormItem label="模块">
<template v-if="isViewMode"> <template v-if="isViewMode">
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" /> <ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
</template> </template>
<ElTreeSelect <ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
v-else <ElOption
v-model="model.moduleId" v-for="item in flatModuleOptions"
:data="moduleTree" :key="item.value"
:props="moduleTreeProps" :label="item.label"
class="w-full" :value="item.value"
check-strictly />
:render-after-expand="false" </ElSelect>
placeholder="请选择所属模块"
/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="分类" prop="category"> <ElFormItem label="需求类型" prop="category">
<template v-if="isViewMode"> <ReadonlyField :value="getCategoryLabel(model.category) || '--'"/>
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
</template>
<DictSelect
v-else
v-model="model.category"
:dict-code="categoryDictCode"
filterable
placeholder="请选择分类"
/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
@@ -352,31 +344,19 @@ watch(
/> />
</template> </template>
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级"> <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" /> <ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value"/>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="提出人" prop="proposerId"> <ElFormItem label="提出人" prop="proposerId">
<template v-if="isViewMode"> <ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'"/>
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
</template>
<ElSelect v-else v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
<ElOption
v-for="item in memberUserOptions"
:key="item.userId"
:label="item.userNickname"
:value="item.userId"
>
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
</ElOption>
</ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="负责人" prop="currentHandlerUserId"> <ElFormItem label="负责人" prop="currentHandlerUserId">
<template v-if="isViewMode"> <template v-if="isViewMode">
<ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" /> <ReadonlyField :value="memberLabelMap.get(model.currentHandlerUserId) || '--'"/>
</template> </template>
<ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人"> <ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
<ElOption <ElOption
@@ -385,22 +365,38 @@ watch(
:label="item.userNickname" :label="item.userNickname"
:value="item.userId" :value="item.userId"
> >
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" /> <MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName"/>
</ElOption> </ElOption>
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="所需工时">
<template v-if="isViewMode">
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'"/>
</template>
<ElInputNumber
v-else
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol v-if="isViewMode" :span="12">
<ElFormItem label="实现项目"> <ElFormItem label="实现项目">
<ReadonlyField :value="model.implementProjectId || '--'" /> <ReadonlyField :value="model.implementProjectId || '--'"/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="排序值"> <ElFormItem label="排序值">
<template v-if="isViewMode"> <template v-if="isViewMode">
<ReadonlyField :value="model.sort" /> <ReadonlyField :value="model.sort"/>
</template> </template>
<ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" /> <ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值"/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol v-if="isViewMode && model.lastStatusReason" :span="24"> <ElCol v-if="isViewMode && model.lastStatusReason" :span="24">

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, provide, ref, watch } from 'vue';
import { ElMessageBox } from 'element-plus'; import { ElMessageBox } from 'element-plus';
import { import {
fetchCreateRequirementModule, fetchCreateRequirementModule,
@@ -41,12 +41,24 @@ const rootModule = computed<Api.Product.RequirementModule | null>(() => {
const editingNodeId = ref<string | undefined>(undefined); const editingNodeId = ref<string | undefined>(undefined);
const editingName = ref(''); const editingName = ref('');
const addingTopModule = ref(false);
const newModuleName = ref('');
const addingChildParentId = ref<string | undefined>(undefined); const addingChildParentId = ref<string | undefined>(undefined);
const newChildModuleName = ref(''); const newChildModuleName = ref('');
const collapsedModuleIds = ref(new Set<string>());
function handleToggleCollapse(moduleId: string) {
const set = collapsedModuleIds.value;
if (set.has(moduleId)) {
set.delete(moduleId);
} else {
set.add(moduleId);
}
collapsedModuleIds.value = new Set(set);
}
provide('collapsedModuleIds', collapsedModuleIds);
provide('toggleCollapse', handleToggleCollapse);
const moduleRequirementCountMap = computed(() => { const moduleRequirementCountMap = computed(() => {
const countMap = new Map<string, number>(); const countMap = new Map<string, number>();
@@ -98,59 +110,6 @@ function handleNodeSelect(moduleId: string) {
emit('select', moduleId); emit('select', moduleId);
} }
function startAddTopModule() {
if (addingTopModule.value || addingChildParentId.value) return;
addingTopModule.value = true;
newModuleName.value = '';
nextTick(() => {
const input = document.querySelector('.new-module-input input') as HTMLInputElement;
input?.focus();
});
}
async function handleAddTopModuleConfirm() {
const name = newModuleName.value.trim();
if (!name) {
addingTopModule.value = false;
newModuleName.value = '';
return;
}
if (!currentObjectId.value || !rootModule.value?.id) {
addingTopModule.value = false;
return;
}
const { error } = await fetchCreateRequirementModule({
id: undefined,
productId: currentObjectId.value,
parentId: rootModule.value.id,
moduleName: name,
remark: null,
icon: null,
sort: 0
});
if (error) {
addingTopModule.value = false;
return;
}
window.$message?.success('模块新增成功');
addingTopModule.value = false;
newModuleName.value = '';
await loadModuleTree();
emit('refresh');
}
function handleAddTopModuleCancel() {
addingTopModule.value = false;
newModuleName.value = '';
}
function handleStartEdit(module: Api.Product.RequirementModule) { function handleStartEdit(module: Api.Product.RequirementModule) {
editingNodeId.value = module.id; editingNodeId.value = module.id;
editingName.value = module.moduleName; editingName.value = module.moduleName;
@@ -199,7 +158,7 @@ async function handleUpdateModuleName(module: Api.Product.RequirementModule, nam
} }
function handleStartAddChild(module: Api.Product.RequirementModule) { function handleStartAddChild(module: Api.Product.RequirementModule) {
if (addingTopModule.value || addingChildParentId.value) return; if (addingChildParentId.value) return;
addingChildParentId.value = module.id; addingChildParentId.value = module.id;
newChildModuleName.value = ''; newChildModuleName.value = '';
@@ -312,19 +271,6 @@ defineExpose({
<div class="requirement-module-tree-wrapper"> <div class="requirement-module-tree-wrapper">
<div class="module-tree-header"> <div class="module-tree-header">
<span class="module-tree-header__title">模块</span> <span class="module-tree-header__title">模块</span>
<ElSpace>
<ElButton
v-auth="{ code: 'project:product:create', source: 'object' }"
circle
text
size="small"
@click="startAddTopModule"
>
<template #icon>
<icon-ic-round-plus class="text-16px" />
</template>
</ElButton>
</ElSpace>
</div> </div>
<div class="module-tree-list"> <div class="module-tree-list">
@@ -351,23 +297,6 @@ defineExpose({
@update-new-child-module-name="newChildModuleName = $event" @update-new-child-module-name="newChildModuleName = $event"
/> />
</template> </template>
<div v-if="addingTopModule" class="module-tree-item module-tree-item--new">
<div class="module-tree-item__icon">
<icon-mdi-folder-plus-outline class="text-16px" />
</div>
<div class="module-tree-item__content">
<ElInput
v-model="newModuleName"
size="small"
class="new-module-input module-tree-item__input"
placeholder="请输入模块名"
@blur="handleAddTopModuleConfirm"
@keyup.enter="handleAddTopModuleConfirm"
@keyup.esc="handleAddTopModuleCancel"
/>
</div>
</div>
</div> </div>
</div> </div>
</template> </template>

View File

@@ -63,18 +63,18 @@ onMounted(async () => {
<template> <template>
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search"> <TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
<ElCol :lg="6" :md="12" :sm="12"> <ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="标题"> <ElFormItem label="需求名称">
<ElInput v-model="model.title" clearable placeholder="输入需求标题" /> <ElInput v-model="model.title" clearable placeholder="输入需求名称" />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :lg="6" :md="12" :sm="12"> <ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="分类"> <ElFormItem label="需求类型">
<DictSelect <DictSelect
v-model="model.category" v-model="model.category"
:dict-code="categoryDictCode" :dict-code="categoryDictCode"
clearable clearable
filterable filterable
placeholder="筛选分类" placeholder="筛选需求类型"
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
@@ -111,12 +111,12 @@ onMounted(async () => {
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :lg="6" :md="12" :sm="12"> <ElCol :lg="6" :md="12" :sm="12">
<ElFormItem label="来源类型"> <ElFormItem label="需求来源">
<DictSelect <DictSelect
v-model="model.sourceType" v-model="model.sourceType"
:dict-code="RDMS_REQ_SOURCE_TYPE_DICT_CODE" :dict-code="RDMS_REQ_SOURCE_TYPE_DICT_CODE"
clearable clearable
placeholder="筛选来源类型" placeholder="筛选需求来源"
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>

View File

@@ -4,7 +4,6 @@ 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 BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import MemberSelectOption from './member-select-option.vue'; import MemberSelectOption from './member-select-option.vue';
defineOptions({ name: 'RequirementSplitDialog' }); defineOptions({ name: 'RequirementSplitDialog' });
@@ -48,7 +47,7 @@ interface Model {
category: string; category: string;
priority: number | null; priority: number | null;
currentHandlerUserId: string; currentHandlerUserId: string;
completionDate: string; workHours: number | null;
sort: number; sort: number;
} }
@@ -67,11 +66,10 @@ const reviewRequiredOptions = [
]; ];
const rules = { const rules = {
title: [createRequiredRule('请输入子需求标题')], title: [createRequiredRule('请输入子需求名称')],
category: [createRequiredRule('请选择分类')],
priority: [createRequiredRule('请选择优先级')], priority: [createRequiredRule('请选择优先级')],
currentHandlerUserId: [createRequiredRule('请选择负责人')], currentHandlerUserId: [createRequiredRule('请选择负责人')],
completionDate: [createRequiredRule('请选择预期完成时间')] workHours: [createRequiredRule('请输入所需工时')]
} satisfies Record<string, App.Global.FormRule[]>; } satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model { function createDefaultModel(): Model {
@@ -82,7 +80,7 @@ function createDefaultModel(): Model {
category: '', category: '',
priority: 1, priority: 1,
currentHandlerUserId: '', currentHandlerUserId: '',
completionDate: '', workHours: null,
sort: 0 sort: 0
}; };
} }
@@ -102,23 +100,26 @@ async function handleSubmit() {
return; return;
} }
const proposerNickname = props.parentRequirement.proposerNickname || '';
const currentHandlerUserNickname = props.parentRequirement.currentHandlerUserNickname || '';
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,
currentHandlerUserNickname,
title: model.value.title.trim(), title: model.value.title.trim(),
description: getNullableText(model.value.description), description: getNullableText(model.value.description),
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,
currentHandlerUserId: model.value.currentHandlerUserId, currentHandlerUserId: model.value.currentHandlerUserId,
completionDate: model.value.completionDate, workHours: model.value.workHours || 0,
sort: model.value.sort sort: model.value.sort
}; };
console.log('payload', payload);
submitting.value = true; submitting.value = true;
const result = await fetchSplitRequirement(payload); const result = await fetchSplitRequirement(payload);
@@ -143,6 +144,10 @@ watch(
model.value = createDefaultModel(); model.value = createDefaultModel();
if (props.parentRequirement?.category) {
model.value.category = props.parentRequirement.category;
}
await nextTick(); await nextTick();
formRef.value?.clearValidate(); formRef.value?.clearValidate();
} }
@@ -169,32 +174,8 @@ watch(
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top"> <ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16"> <ElRow :gutter="16">
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="子需求标题" prop="title"> <ElFormItem label="子需求名称" prop="title">
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求标题" /> <ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
<ElDatePicker
v-model="model.completionDate"
type="datetime"
class="w-full"
placeholder="选择预期完成时间"
value-format="x"
style="width: 100%"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="描述">
<ElInput
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
placeholder="请输入需求描述"
/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
@@ -209,9 +190,16 @@ watch(
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="24">
<ElFormItem label="分类" prop="category"> <ElFormItem label="内容">
<DictSelect v-model="model.category" :dict-code="categoryDictCode" filterable placeholder="请选择分类" /> <ElInput
v-model="model.description"
type="textarea"
:rows="6"
maxlength="2000"
show-word-limit
placeholder="请输入需求内容"
/>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="12">
@@ -235,6 +223,18 @@ watch(
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12">
<ElFormItem label="所需工时" prop="workHours">
<ElInputNumber
v-model="model.workHours"
class="w-full"
:min="0"
:max="9999"
:precision="1"
placeholder="请输入所需工时"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12"> <ElCol :span="12">
<ElFormItem label="排序值"> <ElFormItem label="排序值">
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" /> <ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />

View File

@@ -22,7 +22,7 @@ import { useBoolean } from '@sa/hooks';
import { import {
fetchBatchDeleteUserManagementRelation, fetchBatchDeleteUserManagementRelation,
fetchDeleteUserManagementRelation, fetchDeleteUserManagementRelation,
fetchGetUserListByDeptId, fetchGetUserListByDeptId, fetchGetUserManagementRelation,
fetchGetUserManagementRelationQuery, fetchGetUserManagementRelationQuery,
fetchGetUserManagementRelationTree fetchGetUserManagementRelationTree
} from '@/service/api'; } from '@/service/api';
@@ -60,7 +60,7 @@ const { fromUserIndex = false, deptId = 100, orgType = 'company' } = defineProps
* 当组织类型为部门、方向或团队时,隐藏母节点的编辑按钮 * 当组织类型为部门、方向或团队时,隐藏母节点的编辑按钮
*/ */
const shouldHideRootEdit = computed(() => { const shouldHideRootEdit = computed(() => {
return fromUserIndex && orgType !== 'company'; return fromUserIndex && orgType;
}); });
/** /**
@@ -134,10 +134,6 @@ async function loadTreeData() {
if (!error) { if (!error) {
treeData.value = data || []; treeData.value = data || [];
// 数据加载完成后,展开前两层节点
await nextTick();
expandFirstTwoLevels();
} }
} finally { } finally {
loading.value = false; loading.value = false;
@@ -168,18 +164,169 @@ async function loadTreeDataByQuery(query: Api.SystemManage.UserManagementRelatio
/** /**
* 刷新树形数据 * 刷新树形数据
* *
* 清空选中状态并重新加载数据 * 从后端重新加载整个树数据
*/ */
async function reloadTreeData() { async function reloadTreeData() {
// 保存当前展开状态 // 保存当前展开状态
saveExpandedState(); saveExpandedState();
checkedNodeKeys.value = []; checkedNodeKeys.value = [];
await loadTreeData(); await loadTreeData();
// 等待 Vue 渲染树节点
await nextTick();
await nextTick();
await nextTick();
// 数据更新后恢复展开状态
await restoreExpandedState();
await nextTick(); await nextTick();
relationTreeRef.value?.setCheckedKeys([]); relationTreeRef.value?.setCheckedKeys([]);
} }
/**
* 在本地树数据中插入新节点
*
* 当从树节点新增时,直接在对应父节点下插入新节点,避免整棵树重新加载
*
* @param managerUserId 上级用户 ID父节点
* @param newRelationId 新增的关系 ID
*/
async function insertNodeLocally(managerUserId: string, newRelationId: string) {
// 从后端获取新增节点的详情
const { data: relationDetail, error } = await fetchGetUserManagementRelation(newRelationId);
if (error || !relationDetail) {
// 如果获取详情失败,降级为重新加载整棵树
await reloadTreeData();
return;
}
// 构建树节点数据
const newNode: Api.SystemManage.UserManagementRelationTreeRespVO = {
id: relationDetail.id,
userId: relationDetail.subordinateUserId || '',
userNickname: '',
managerUserId: relationDetail.managerUserId || '',
managerNickname: '',
children: []
};
// 在用户列表中查找下级用户的昵称
const subordinateUser = userList.value.find(u => u.id === relationDetail.subordinateUserId);
if (subordinateUser) {
newNode.userNickname = subordinateUser.nickname;
}
// 在用户列表中查找上级用户的昵称
const managerUser = userList.value.find(u => u.id === relationDetail.managerUserId);
if (managerUser) {
newNode.managerNickname = managerUser.nickname;
}
// 递归查找并插入节点
function insertIntoTree(nodes: Api.SystemManage.UserManagementRelationTreeRespVO[]): boolean {
for (const node of nodes) {
if (node.userId === managerUserId) {
// 找到父节点,插入新节点
if (!node.children) {
node.children = [];
}
node.children.push(newNode);
return true;
}
// 递归查找子节点
if (node.children && node.children.length > 0) {
if (insertIntoTree(node.children)) {
return true;
}
}
}
return false;
}
const inserted = insertIntoTree(treeData.value);
if (!inserted) {
// 如果没有找到父节点,说明新增的是根节点,重新加载整棵树
await reloadTreeData();
return;
}
// 展开父节点
await nextTick();
const tree = relationTreeRef.value;
if (tree) {
const treeNode = tree.getNode(managerUserId);
if (treeNode && !treeNode.expanded) {
treeNode.expand();
}
}
}
/**
* 从本地树数据中删除节点
*
* 当删除节点时,直接从本地数据中移除,避免整棵树重新加载
*
* @param nodeToDelete 要删除的节点
*/
function removeNodeLocally(nodeToDelete: Api.SystemManage.UserManagementRelationTreeRespVO) {
// 递归查找并删除节点
function removeFromTree(nodes: Api.SystemManage.UserManagementRelationTreeRespVO[]): boolean {
const index = nodes.findIndex(n => n.userId === nodeToDelete.userId);
if (index !== -1) {
nodes.splice(index, 1);
return true;
}
// 递归查找子节点
for (const node of nodes) {
if (node.children && node.children.length > 0) {
if (removeFromTree(node.children)) {
return true;
}
}
}
return false;
}
removeFromTree(treeData.value);
}
/**
* 展开树的所有节点
*/
async function expandAllNodes() {
await nextTick();
await nextTick();
const tree = relationTreeRef.value;
if (!tree || !treeData.value.length) {
return;
}
// 递归展开所有节点
function expandNodes(nodes: Api.SystemManage.UserManagementRelationTreeRespVO[]) {
for (const node of nodes) {
const treeNode = tree?.getNode(node.userId);
if (treeNode && !treeNode.expanded) {
treeNode.expand();
}
if (node.children && node.children.length > 0) {
expandNodes(node.children);
}
}
}
expandNodes(treeData.value);
}
/** /**
* 处理搜索 * 处理搜索
* *
@@ -204,6 +351,9 @@ async function handleSearch() {
await loadTreeData(); await loadTreeData();
} }
// 搜索后展开所有节点
await expandAllNodes();
await nextTick(); await nextTick();
relationTreeRef.value?.setCheckedKeys([]); relationTreeRef.value?.setCheckedKeys([]);
} }
@@ -213,9 +363,13 @@ async function handleSearch() {
* *
* 清空搜索条件并重新加载数据 * 清空搜索条件并重新加载数据
*/ */
function resetSearchParams() { async function resetSearchParams() {
Object.assign(searchParams, getInitSearchParams()); Object.assign(searchParams, getInitSearchParams());
reloadTreeData();
// 清空保存的展开状态,让 reloadTreeData 后展开前两层
expandedNodeKeys.value = [];
await reloadTreeData();
} }
// 对话框相关状态 // 对话框相关状态
@@ -236,10 +390,10 @@ const isAddFromTreeNode = ref(false);
*/ */
function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) { function openAdd(item?: Api.SystemManage.UserManagementRelationTreeRespVO) {
operateType.value = 'add'; operateType.value = 'add';
// 如果是从树节点点击的新增按钮,标记为来自树节点 // 如果是从树节点点击的新增按钮,标记为来自树节点
isAddFromTreeNode.value = Boolean(item); isAddFromTreeNode.value = Boolean(item);
// 如果是从某一行的新增按钮触发,则默认上级为当前节点用户 // 如果是从某一行的新增按钮触发,则默认上级为当前节点用户
// 否则默认上级为当前登录用户(在对话框组件中处理) // 否则默认上级为当前登录用户(在对话框组件中处理)
editingData.value = item editingData.value = item
@@ -291,7 +445,9 @@ async function handleDelete(item: Api.SystemManage.UserManagementRelationTreeRes
} }
window.$message?.success('删除成功'); window.$message?.success('删除成功');
await reloadTreeData();
// 使用局部删除,保持树的展开状态
removeNodeLocally(item);
} }
/** /**
@@ -304,6 +460,23 @@ async function handleBatchDelete() {
return; return;
} }
// 保存要删除的节点数据(用于局部删除)
const nodesToDelete: Api.SystemManage.UserManagementRelationTreeRespVO[] = [];
// 递归查找选中的节点
function collectCheckedNodes(nodes: Api.SystemManage.UserManagementRelationTreeRespVO[], keys: string[]) {
for (const node of nodes) {
if (keys.includes(node.userId)) {
nodesToDelete.push(node);
}
if (node.children && node.children.length > 0) {
collectCheckedNodes(node.children, keys);
}
}
}
collectCheckedNodes(treeData.value, checkedNodeKeys.value);
const { error } = await fetchBatchDeleteUserManagementRelation(checkedNodeKeys.value); const { error } = await fetchBatchDeleteUserManagementRelation(checkedNodeKeys.value);
if (error) { if (error) {
@@ -311,7 +484,11 @@ async function handleBatchDelete() {
} }
window.$message?.success('删除成功'); window.$message?.success('删除成功');
await reloadTreeData();
// 使用局部删除,保持树的展开状态
for (const node of nodesToDelete) {
removeNodeLocally(node);
}
} }
/** /**
@@ -361,45 +538,24 @@ function saveExpandedState() {
* *
* @param relationId 提交后的关系 ID * @param relationId 提交后的关系 ID
*/ */
async function handleSubmitted(_relationId: string) { async function handleSubmitted(relationId: string) {
closeOperateModal(); closeOperateModal();
await reloadTreeData();
// 如果是从树节点新增,使用局部插入
// 操作完成后恢复树节点的展开状态 if (operateType.value === 'add' && isAddFromTreeNode.value && editingData.value?.managerUserId) {
await restoreExpandedState(); await insertNodeLocally(editingData.value.managerUserId, relationId);
} else {
// 其他情况(头部新增、编辑)重新加载整棵树
await reloadTreeData();
}
// 重置标记 // 重置标记
isAddFromTreeNode.value = false; isAddFromTreeNode.value = false;
} }
/**
* 展开所有子节点(递归)
*
* @param tree 树形组件实例
* @param nodes 节点数据数组
*/
function expandNodes(tree: InstanceType<typeof ElTree>, nodes: Api.SystemManage.UserManagementRelationTreeRespVO[]) {
if (!tree || !nodes || !nodes.length) {
return;
}
for (const node of nodes) {
// 展开当前节点
const treeNode = tree.getNode(node.userId);
if (treeNode) {
treeNode.expand();
}
// 递归展开子节点
if (node.children && node.children.length > 0) {
expandNodes(tree, node.children);
}
}
}
/** /**
* 展开树的前两层节点 * 展开树的前两层节点
* *
* 只展开根节点和它们的直接子节点,第三层及更深层保持折叠 * 只展开根节点和它们的直接子节点,第三层及更深层保持折叠
*/ */
function expandFirstTwoLevels() { function expandFirstTwoLevels() {
@@ -414,7 +570,7 @@ function expandFirstTwoLevels() {
if (treeNode) { if (treeNode) {
treeNode.expand(); treeNode.expand();
} }
// 展开第二层(根节点的直接子节点) // 展开第二层(根节点的直接子节点)
if (rootNode.children && rootNode.children.length > 0) { if (rootNode.children && rootNode.children.length > 0) {
for (const childNode of rootNode.children) { for (const childNode of rootNode.children) {
@@ -429,23 +585,29 @@ function expandFirstTwoLevels() {
/** /**
* 恢复树节点的展开状态 * 恢复树节点的展开状态
* *
* 根据之前保存的展开状态,恢复对应的节点展开 * 根据之前保存的展开状态,恢复对应的节点展开
*/ */
async function restoreExpandedState() { async function restoreExpandedState() {
await nextTick(); await nextTick();
await nextTick();
const tree = relationTreeRef.value; const tree = relationTreeRef.value;
if (!tree || !expandedNodeKeys.value.length) { if (!tree) {
return; return;
} }
// 恢复之前展开的节点 if (expandedNodeKeys.value.length > 0) {
for (const key of expandedNodeKeys.value) { // 恢复之前展开的节点
const node = tree.getNode(key); for (const key of expandedNodeKeys.value) {
if (node) { const node = tree.getNode(key);
node.expand(); if (node && !node.expanded) {
node.expand();
}
} }
} else {
// 如果是首次加载或没有保存的状态,展开前两层
expandFirstTwoLevels();
} }
} }

View File

@@ -20,6 +20,7 @@
import { computed, nextTick, ref, watch } from 'vue'; import { computed, nextTick, ref, watch } from 'vue';
import { import {
fetchCreateUserManagementRelation, fetchCreateUserManagementRelation,
fetchGetCandidateSubordinateUsers,
fetchGetUserManagementRelation, fetchGetUserManagementRelation,
fetchUpdateUserManagementRelation fetchUpdateUserManagementRelation
} from '@/service/api'; } from '@/service/api';
@@ -73,9 +74,40 @@ const authStore = useAuthStore();
const detailLoading = ref(false); const detailLoading = ref(false);
const submitting = ref(false); const submitting = ref(false);
// 候选下级用户列表
const candidateSubordinateUsers = ref<Api.SystemManage.UserSimple[]>([]);
// 计算属性:是否为编辑模式 // 计算属性:是否为编辑模式
const isEdit = computed(() => props.operateType === 'edit'); const isEdit = computed(() => props.operateType === 'edit');
/**
* 加载候选下级用户列表
*
* 获取所有还未绑定上级的用户,用于新增时的下级用户下拉框
*/
async function loadCandidateSubordinateUsers() {
const { error, data } = await fetchGetCandidateSubordinateUsers();
if (error) {
candidateSubordinateUsers.value = [];
return;
}
candidateSubordinateUsers.value = data || [];
}
/**
* 计算下级用户下拉框选项
*
* 新增模式使用候选下级用户列表,编辑模式使用所有用户列表
*/
const subordinateUserOptions = computed<Api.SystemManage.UserSimple[]>(() => {
if (isEdit.value) {
return props.userList;
}
return candidateSubordinateUsers.value;
});
/** /**
* 计算对话框标题 * 计算对话框标题
*/ */
@@ -281,11 +313,16 @@ async function handleSubmit() {
/** /**
* 监听对话框可见性 * 监听对话框可见性
* *
* 打开时初始化表单 * 打开时初始化表单并加载候选用户
*/ */
watch(visible, value => { watch(visible, async value => {
if (value) { if (value) {
initModel(); initModel();
// 如果是新增模式,加载候选下级用户(每次打开都重新加载,避免缓存)
if (!isEdit.value) {
await loadCandidateSubordinateUsers();
}
} }
}); });
</script> </script>
@@ -324,7 +361,7 @@ watch(visible, value => {
filterable filterable
:disabled="isEdit" :disabled="isEdit"
> >
<ElOption v-for="user in props.userList" :key="user.id" :label="user.nickname" :value="user.id" /> <ElOption v-for="user in subordinateUserOptions" :key="user.id" :label="user.nickname" :value="user.id" />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>