Compare commits
3 Commits
824392b564
...
28c47b14a3
| Author | SHA1 | Date | |
|---|---|---|---|
| 28c47b14a3 | |||
| 5947157f89 | |||
| f0ea903d59 |
@@ -75,3 +75,11 @@ export const RDMS_PROJECT_TYPE_DICT_CODE = 'rdms_project_type';
|
||||
* 来源口径:`rdms-project-boot-执行任务接口API文档.md` 明确 executionType 来自字典 rdms_project_execution_type
|
||||
*/
|
||||
export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_type';
|
||||
|
||||
/**
|
||||
* 需求允许删除的状态字典编码
|
||||
*
|
||||
* 对应业务字段:需求删除功能中判断 statusCode 是否允许删除
|
||||
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
|
||||
*/
|
||||
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
|
||||
|
||||
@@ -179,6 +179,7 @@ type RequirementResponse = Omit<
|
||||
proposerId: string | number;
|
||||
currentHandlerUserId?: string | number | null;
|
||||
implementProjectId?: string | number | null;
|
||||
implementProjectName?: string | null;
|
||||
sourceBizId?: string | number | null;
|
||||
children?: RequirementResponse[];
|
||||
};
|
||||
@@ -194,6 +195,7 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
|
||||
proposerId: normalizeStringId(requirement.proposerId),
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
implementProjectId: normalizeNullableStringId(requirement.implementProjectId),
|
||||
implementProjectName: requirement.implementProjectName ?? null,
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
children: requirement.children?.map(normalizeRequirement)
|
||||
};
|
||||
|
||||
@@ -135,6 +135,18 @@ export async function fetchGetProject(id: string) {
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectResponse>, normalizeProject);
|
||||
}
|
||||
|
||||
/** 根据产品ID获取产品下的所有项目 */
|
||||
export async function fetchGetProjectListByProductId(productId: string) {
|
||||
const result = await request<ProjectResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_PREFIX}/list-by-product`,
|
||||
method: 'get',
|
||||
params: { productId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectResponse[]>, data => data.map(normalizeProject));
|
||||
}
|
||||
|
||||
/** 创建项目 */
|
||||
export async function fetchCreateProject(data: Api.Project.SaveProjectParams) {
|
||||
const result = await request<string | number>({
|
||||
|
||||
@@ -669,7 +669,7 @@ export function fetchAssignUserRoles(data: Api.SystemManage.AssignUserRoleParams
|
||||
* - 中间节点:有上级也有下级
|
||||
* - 叶子节点:基层员工,没有下级
|
||||
*/
|
||||
export function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
|
||||
export async function fetchGetUserManagementRelationTree(query: UserManagementRelationQueryReqVO) {
|
||||
return request<UserManagementRelationTreeResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/tree`,
|
||||
@@ -686,7 +686,7 @@ export function fetchGetUserManagementRelationTree(query: UserManagementRelation
|
||||
* 通过搜索框的查询条件,获取用户管理链路树形结构
|
||||
* 用于树形控件展示,包含用户的上下级层级关系
|
||||
*/
|
||||
export function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
|
||||
export async function fetchGetUserManagementRelationQuery(query: UserManagementRelationQueryReqVO) {
|
||||
return request<UserManagementRelationTreeResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/query`,
|
||||
@@ -706,7 +706,7 @@ export function fetchGetUserManagementRelationQuery(query: UserManagementRelatio
|
||||
*
|
||||
* @param id 关系记录主键 ID
|
||||
*/
|
||||
export function fetchGetUserManagementRelation(id: string) {
|
||||
export async function fetchGetUserManagementRelation(id: string) {
|
||||
return request<UserManagementRelationResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/get`,
|
||||
@@ -724,7 +724,7 @@ export function fetchGetUserManagementRelation(id: string) {
|
||||
*
|
||||
* @param data 创建请求参数
|
||||
*/
|
||||
export function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
|
||||
export async function fetchCreateUserManagementRelation(data: Api.SystemManage.UserManagementRelationSaveReqVO) {
|
||||
return request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_MANAGEMENT_RELATION_PREFIX}/create`,
|
||||
@@ -778,3 +778,20 @@ export function fetchBatchDeleteUserManagementRelation(ids: string[]) {
|
||||
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))
|
||||
);
|
||||
}
|
||||
|
||||
26
src/typings/api/product.d.ts
vendored
26
src/typings/api/product.d.ts
vendored
@@ -256,17 +256,17 @@ declare namespace Api {
|
||||
moduleId: string;
|
||||
/** 是否需要评审(0不需要;1需要) */
|
||||
reviewRequired: RequirementReviewRequired;
|
||||
/** 需求标题 */
|
||||
/** 需求名称 */
|
||||
title: string;
|
||||
/** 需求描述(富文本) */
|
||||
/** 需求内容(富文本) */
|
||||
description?: string | null;
|
||||
/** 需求分类字典值 */
|
||||
/** 需求类型字典值 */
|
||||
category: string;
|
||||
/** 需求分类名称 */
|
||||
/** 需求类型名称 */
|
||||
categoryName?: string | null;
|
||||
/** 来源类型 */
|
||||
/** 需求来源类型 */
|
||||
sourceType: RequirementSourceType;
|
||||
/** 来源业务ID */
|
||||
/** 需求来源业务ID */
|
||||
sourceBizId?: string | null;
|
||||
/** 优先级(0低 1中 2高 3紧急) */
|
||||
priority: RequirementPriority;
|
||||
@@ -288,10 +288,10 @@ declare namespace Api {
|
||||
currentHandlerUserNickname?: string | null;
|
||||
/** 默认实现项目编号 */
|
||||
implementProjectId?: string | null;
|
||||
/** 实现项目名称 */
|
||||
/** 默认实现项目名称 */
|
||||
implementProjectName?: string | null;
|
||||
/** 预期完成时间 */
|
||||
completionDate: string;
|
||||
/** 所需工时(小时) */
|
||||
workHours: number;
|
||||
/** 排序值 */
|
||||
sort: number;
|
||||
/** 创建时间 */
|
||||
@@ -384,9 +384,11 @@ declare namespace Api {
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
| 'proposerNickname'
|
||||
| 'currentHandlerUserId'
|
||||
| 'currentHandlerUserNickname'
|
||||
| 'implementProjectId'
|
||||
| 'completionDate'
|
||||
| 'workHours'
|
||||
| 'sort'
|
||||
>;
|
||||
|
||||
@@ -421,8 +423,10 @@ declare namespace Api {
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
| 'proposerNickname'
|
||||
| 'currentHandlerUserId'
|
||||
| 'completionDate'
|
||||
| 'currentHandlerUserNickname'
|
||||
| 'workHours'
|
||||
| 'sort'
|
||||
>;
|
||||
|
||||
|
||||
1
src/typings/components.d.ts
vendored
1
src/typings/components.d.ts
vendored
@@ -108,6 +108,7 @@ declare module 'vue' {
|
||||
IconGridiconsFullscreenExit: typeof import('~icons/gridicons/fullscreen-exit')['default']
|
||||
'IconIc:roundPlus': typeof import('~icons/ic/round-plus')['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']
|
||||
IconIcRoundEdit: typeof import('~icons/ic/round-edit')['default']
|
||||
IconIcRoundPlus: typeof import('~icons/ic/round-plus')['default']
|
||||
|
||||
@@ -1,26 +1,32 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { computed, markRaw, onMounted, reactive, ref, watch } from 'vue';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import { ElButton, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_REQ_CATEGORY_DICT_CODE, RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
||||
import {
|
||||
RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE,
|
||||
RDMS_REQ_CATEGORY_DICT_CODE,
|
||||
RDMS_REQ_PRIORITY_DICT_CODE,
|
||||
RDMS_REQ_SOURCE_TYPE_DICT_CODE
|
||||
} from '@/constants/dict';
|
||||
import {
|
||||
fetchChangeRequirementStatus,
|
||||
fetchDeleteRequirement,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProjectListByProductId,
|
||||
fetchGetRequirementAllowedTransitions,
|
||||
fetchGetRequirementStatusDict,
|
||||
fetchGetRequirementTerminalStatusDict,
|
||||
fetchGetRequirementTree
|
||||
} from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import DictTag from '@/components/custom/dict-tag.vue';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import {
|
||||
type RequirementStatusActionCode,
|
||||
getRequirementActionDisplayName,
|
||||
getRequirementActionTagType,
|
||||
getRequirementStatusTagType,
|
||||
isRequirementActionNeedProject,
|
||||
isRequirementActionNeedReviewChoice,
|
||||
@@ -32,14 +38,69 @@ import RequirementCreateDialog from './modules/requirement-create-dialog.vue';
|
||||
import RequirementDetailDialog from './modules/requirement-detail-dialog.vue';
|
||||
import RequirementSplitDialog from './modules/requirement-split-dialog.vue';
|
||||
import RequirementActionDialog from './modules/requirement-action-dialog.vue';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiCheckOutline from '~icons/mdi/check-outline';
|
||||
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
|
||||
import IconMdiSync from '~icons/mdi/sync';
|
||||
import IconMdiPowerSettingsNew from '~icons/mdi/power-settings-new';
|
||||
import IconMdiArrowSplitVertical from '~icons/mdi/arrow-split-vertical';
|
||||
import IconMdiBookOpenPageVariantOutline from '~icons/mdi/book-open-page-variant-outline';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconTablerSitemap from '~icons/tabler/sitemap';
|
||||
|
||||
defineOptions({ name: 'ProductRequirement' });
|
||||
|
||||
const { currentObjectId } = useCurrentProduct();
|
||||
const { hasObjectAuth } = useAuth();
|
||||
|
||||
/**
|
||||
* 操作按钮图标映射
|
||||
*
|
||||
* 将操作类型映射到对应的 Iconify 图标组件
|
||||
*/
|
||||
const ACTION_ICON_MAP: Record<string, object> = {
|
||||
split: markRaw(IconTablerSitemap),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
claim_to_review: markRaw(IconMdiCheckOutline),
|
||||
claim_to_dispatch: markRaw(IconMdiCheckOutline),
|
||||
to_dispatch: markRaw(IconMdiBookOpenPageVariantOutline),
|
||||
dispatch: markRaw(IconMdiArrowSplitVertical),
|
||||
accept: markRaw(IconMdiCheckCircleOutline),
|
||||
reject: markRaw(IconMdiCloseCircleOutline),
|
||||
cancel: markRaw(IconMdiCloseCircleOutline),
|
||||
close: markRaw(IconMdiPowerSettingsNew),
|
||||
delete: markRaw(IconMdiDeleteOutline)
|
||||
};
|
||||
|
||||
/**
|
||||
* 操作按钮颜色类型映射
|
||||
*
|
||||
* 审批/成功类操作 → success,危险操作 → danger,其他 → primary
|
||||
*/
|
||||
const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> = {
|
||||
split: 'primary',
|
||||
edit: 'primary',
|
||||
claim_to_review: 'primary',
|
||||
claim_to_dispatch: 'primary',
|
||||
to_dispatch: 'primary',
|
||||
dispatch: 'primary',
|
||||
accept: 'primary',
|
||||
reject: 'danger',
|
||||
cancel: 'danger',
|
||||
close: 'danger',
|
||||
delete: 'danger'
|
||||
};
|
||||
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const terminalStatusOptions = ref<string[]>([]);
|
||||
const projectOptions = ref<Api.Project.Project[]>([]);
|
||||
|
||||
const projectNameMap = computed(() => {
|
||||
return new Map(projectOptions.value.map(item => [item.id, item.projectName]));
|
||||
});
|
||||
|
||||
const { hasValue: canDeleteStatusHasValue } = useDict(RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE);
|
||||
|
||||
async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetRequirementStatusDict();
|
||||
@@ -66,6 +127,22 @@ async function loadTerminalStatusOptions() {
|
||||
terminalStatusOptions.value = data.map(item => item.statusCode);
|
||||
}
|
||||
|
||||
async function loadProjectOptions() {
|
||||
if (!currentObjectId.value) {
|
||||
projectOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectListByProductId(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
projectOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
projectOptions.value = data;
|
||||
}
|
||||
|
||||
function getStatusLabel(statusCode: string) {
|
||||
const item = statusOptions.value.find(opt => opt.value === statusCode);
|
||||
return item ? item.label : statusCode;
|
||||
@@ -95,12 +172,7 @@ function canSplitRequirement(row: Api.Product.Requirement) {
|
||||
}
|
||||
|
||||
function canDeleteRequirement(row: Api.Product.Requirement) {
|
||||
const allowedStatusCodes: Api.Product.RequirementStatusCode[] = [
|
||||
'pending_confirm',
|
||||
'pending_review',
|
||||
'pending_dispatch'
|
||||
];
|
||||
const isStatusAllowed = allowedStatusCodes.includes(row.statusCode);
|
||||
const isStatusAllowed = canDeleteStatusHasValue(row.statusCode);
|
||||
const hasNoChildren = !row.children || row.children.length === 0;
|
||||
return isStatusAllowed && hasNoChildren;
|
||||
}
|
||||
@@ -241,38 +313,22 @@ const columns = computed(() => [
|
||||
},
|
||||
{
|
||||
prop: 'title',
|
||||
label: '标题',
|
||||
label: '需求名称',
|
||||
minWidth: 200,
|
||||
formatter: (row: Api.Product.Requirement) => {
|
||||
const isTerminal = isTerminalStatus(row.statusCode);
|
||||
const className = 'requirement-title';
|
||||
|
||||
return (
|
||||
<ElButton link type={isTerminal ? 'info' : 'primary'} class={className} onClick={() => openView(row)}>
|
||||
<ElButton link type="primary" class={className} onClick={() => openView(row)}>
|
||||
{row.title}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'category',
|
||||
label: '分类',
|
||||
minWidth: 120,
|
||||
formatter: (row: Api.Product.Requirement) => row.category
|
||||
},
|
||||
// {
|
||||
// prop: 'description',
|
||||
// label: '描述',
|
||||
// minWidth: 200,
|
||||
// showOverflowTooltip: true,
|
||||
// formatter: (row: Api.Product.Requirement) => {
|
||||
// return row.description?.replace(/<[^>]+>/g, '').trim() || '--';
|
||||
// }
|
||||
// },
|
||||
{
|
||||
prop: 'priority',
|
||||
label: '优先级',
|
||||
width: 100,
|
||||
width: 75,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (
|
||||
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
|
||||
@@ -287,6 +343,43 @@ const columns = computed(() => [
|
||||
<ElTag type={getRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'workHours',
|
||||
label: '所需工时',
|
||||
width: 75,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (row.workHours !== null ? `${row.workHours}h` : '--')
|
||||
},
|
||||
{
|
||||
prop: 'category',
|
||||
label: '需求类型',
|
||||
minWidth: 100,
|
||||
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',
|
||||
// label: '内容',
|
||||
// minWidth: 200,
|
||||
// showOverflowTooltip: true,
|
||||
// formatter: (row: Api.Product.Requirement) => {
|
||||
// return row.description?.replace(/<[^>]+>/g, '').trim() || '--';
|
||||
// }
|
||||
// },
|
||||
{
|
||||
prop: 'proposerNickname',
|
||||
label: '提出人',
|
||||
minWidth: 70,
|
||||
formatter: (row: Api.Product.Requirement) => row.proposerNickname || '--'
|
||||
},
|
||||
{
|
||||
prop: 'currentHandlerUserId',
|
||||
label: '负责人',
|
||||
@@ -310,15 +403,18 @@ const columns = computed(() => [
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'implementProjectName',
|
||||
prop: 'implementProjectId',
|
||||
label: '实现项目',
|
||||
minWidth: 140,
|
||||
formatter: (row: Api.Product.Requirement) => row.implementProjectName || '--'
|
||||
formatter: (row: Api.Product.Requirement) => {
|
||||
if (!row.implementProjectId) return '--';
|
||||
return projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: '创建时间',
|
||||
width: 170,
|
||||
minWidth: 180,
|
||||
formatter: (row: Api.Product.Requirement) => formatDateTime(row.createTime)
|
||||
},
|
||||
{
|
||||
@@ -331,7 +427,8 @@ const columns = computed(() => [
|
||||
const actions: {
|
||||
key: string;
|
||||
label: string;
|
||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
icon: object;
|
||||
type: 'primary' | 'success' | 'danger';
|
||||
disabled?: boolean;
|
||||
onClick: () => void;
|
||||
}[] = [];
|
||||
@@ -340,17 +437,18 @@ const columns = computed(() => [
|
||||
actions.push({
|
||||
key: 'split',
|
||||
label: '拆分',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.split,
|
||||
type: ACTION_TYPE_MAP.split,
|
||||
onClick: () => openSplit(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasObjectAuth('project:product:update')) {
|
||||
if (hasObjectAuth('project:product:update') && !isTerminalStatus(row.statusCode)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
buttonType: 'info',
|
||||
disabled: isTerminalStatus(row.statusCode),
|
||||
icon: ACTION_ICON_MAP.edit,
|
||||
type: ACTION_TYPE_MAP.edit,
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
}
|
||||
@@ -375,7 +473,8 @@ const columns = computed(() => [
|
||||
actions.push({
|
||||
key: `action-${action.actionCode}`,
|
||||
label: getRequirementActionDisplayName(action),
|
||||
buttonType: getRequirementActionTagType(action.actionCode as RequirementStatusActionCode),
|
||||
icon: ACTION_ICON_MAP[action.actionCode] ?? markRaw(IconMdiSync),
|
||||
type: ACTION_TYPE_MAP[action.actionCode] ?? 'primary',
|
||||
onClick: () => handleActionClick(row, action)
|
||||
});
|
||||
}
|
||||
@@ -385,16 +484,65 @@ const columns = computed(() => [
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.delete,
|
||||
type: ACTION_TYPE_MAP.delete,
|
||||
onClick: () => handleDelete(row)
|
||||
});
|
||||
}
|
||||
|
||||
return <BusinessTableActionCell actions={actions} />;
|
||||
return (
|
||||
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{actions.map(action => {
|
||||
const IconComponent = action.icon as any;
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
class="requirement-action-icon-btn"
|
||||
type={action.type}
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
<IconComponent class="text-18px" />
|
||||
</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() {
|
||||
if (!currentObjectId.value) {
|
||||
memberOptions.value = [];
|
||||
@@ -614,11 +762,12 @@ watch(
|
||||
() => currentObjectId.value,
|
||||
async id => {
|
||||
if (id) {
|
||||
await Promise.all([loadMembers(), loadTreeData()]);
|
||||
await Promise.all([loadMembers(), loadTreeData(), loadProjectOptions()]);
|
||||
await loadAllowedTransitionsForAll();
|
||||
} else {
|
||||
memberOptions.value = [];
|
||||
treeData.value = [];
|
||||
projectOptions.value = [];
|
||||
allowedTransitionsMap.value = new Map();
|
||||
}
|
||||
},
|
||||
@@ -655,7 +804,7 @@ onMounted(async () => {
|
||||
<p>需求列表</p>
|
||||
<ElTag effect="plain">{{ pagination.total }} 条</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation :loading="loading" @refresh="reloadTable">
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:product:create', source: 'object' }"
|
||||
@@ -685,7 +834,7 @@ onMounted(async () => {
|
||||
:data="treeData"
|
||||
: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>
|
||||
<ElEmpty description="当前模块下暂无需求" />
|
||||
@@ -742,6 +891,7 @@ onMounted(async () => {
|
||||
v-model:visible="actionVisible"
|
||||
:action="currentAction"
|
||||
:requirement-title="actionRequirement?.title || ''"
|
||||
:project-options="projectOptions"
|
||||
@submitted="handleActionSubmitted"
|
||||
/>
|
||||
</div>
|
||||
@@ -772,4 +922,20 @@ onMounted(async () => {
|
||||
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
|
||||
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>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type Ref, computed, inject, ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'ModuleTreeNode' });
|
||||
|
||||
@@ -32,15 +32,17 @@ const emit = defineEmits([
|
||||
'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 isSelected = computed(() => props.selectedModuleId === props.module.id);
|
||||
|
||||
const isEditing = computed(() => props.editingNodeId === props.module.id);
|
||||
|
||||
const isAddingChild = computed(() => props.addingChildParentId === props.module.id);
|
||||
|
||||
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 moduleId = props.module.id;
|
||||
@@ -91,6 +93,12 @@ function handleAddChildConfirm() {
|
||||
function handleAddChildCancel() {
|
||||
emit('addChildCancel');
|
||||
}
|
||||
|
||||
function handleToggle() {
|
||||
if (props.module.id) {
|
||||
toggleCollapse(props.module.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -105,6 +113,13 @@ function handleAddChildCancel() {
|
||||
:style="indentStyle"
|
||||
@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">
|
||||
<icon-mdi-folder-open v-if="isRootModule" class="text-16px" />
|
||||
<icon-mdi-folder-outline v-else class="text-16px" />
|
||||
@@ -124,7 +139,7 @@ function handleAddChildCancel() {
|
||||
/>
|
||||
</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">
|
||||
<ElButton text size="small" class="module-tree-item__more-btn">
|
||||
<icon-mdi-dots-horizontal class="text-14px" />
|
||||
@@ -140,14 +155,18 @@ function handleAddChildCancel() {
|
||||
<span>新增子模块</span>
|
||||
</div>
|
||||
</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">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="canDeleteModule"
|
||||
v-if="!isRootModule && canDeleteModule"
|
||||
v-auth="{ code: 'project:product:delete', source: 'object' }"
|
||||
divided
|
||||
@click="handleDelete"
|
||||
@@ -163,7 +182,7 @@ function handleAddChildCancel() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="hasChildren">
|
||||
<template v-if="hasChildren && !isCollapsed">
|
||||
<ModuleTreeNode
|
||||
v-for="child in module.children"
|
||||
:key="child.id"
|
||||
@@ -270,6 +289,23 @@ function handleAddChildCancel() {
|
||||
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 {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
|
||||
@@ -14,6 +14,7 @@ defineOptions({ name: 'RequirementActionDialog' });
|
||||
interface Props {
|
||||
action: Api.Product.RequirementLifecycleAction | null;
|
||||
requirementTitle: string;
|
||||
projectOptions: Api.Project.Project[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
@@ -57,8 +58,6 @@ const reviewChoiceOptions = [
|
||||
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入分流' }
|
||||
];
|
||||
|
||||
const projectOptions = [{ label: 'NPQS-10086', value: '202642910086' }];
|
||||
|
||||
const rules = computed(() => {
|
||||
const baseRules: Record<string, App.Global.FormRule[]> = {};
|
||||
|
||||
@@ -118,9 +117,7 @@ async function handleSubmit() {
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElFormItem label="需求标题">
|
||||
<span class="text-14px">{{ requirementTitle }}</span>
|
||||
</ElFormItem>
|
||||
<ElFormItem :label="`需求名称:${requirementTitle}`"></ElFormItem>
|
||||
|
||||
<ElFormItem v-if="isClaimAction" label="是否需要评审" prop="reviewChoice">
|
||||
<ElRadioGroup v-model="model.reviewChoice" class="business-form-radio-group">
|
||||
@@ -135,7 +132,7 @@ async function handleSubmit() {
|
||||
|
||||
<ElFormItem v-if="isDispatchAction" label="实现项目" prop="implementProjectId">
|
||||
<ElSelect v-model="model.implementProjectId" class="w-full" filterable placeholder="请选择实现项目(必选)">
|
||||
<ElOption v-for="item in projectOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<ElOption v-for="item in projectOptions" :key="item.id" :label="item.projectName" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
@@ -45,12 +46,12 @@ interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
reviewRequired: number;
|
||||
completionDate: string;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
proposerId: string;
|
||||
currentHandlerUserId: string;
|
||||
workHours: number | null;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
@@ -64,11 +65,28 @@ const memberUserOptions = computed(() => {
|
||||
return props.memberOptions.filter(m => m.status === 0);
|
||||
});
|
||||
|
||||
const moduleTreeProps = {
|
||||
label: 'moduleName',
|
||||
value: 'id',
|
||||
children: 'children'
|
||||
};
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
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 = [
|
||||
{ label: '不需要', value: 0 },
|
||||
@@ -76,12 +94,12 @@ const reviewRequiredOptions = [
|
||||
];
|
||||
|
||||
const rules = {
|
||||
title: [createRequiredRule('请输入需求标题')],
|
||||
category: [createRequiredRule('请选择分类')],
|
||||
title: [createRequiredRule('请输入需求名称')],
|
||||
category: [createRequiredRule('请选择需求类型')],
|
||||
priority: [createRequiredRule('请选择优先级')],
|
||||
proposerId: [createRequiredRule('请选择提出人')],
|
||||
currentHandlerUserId: [createRequiredRule('请选择负责人')],
|
||||
completionDate: [createRequiredRule('请选择预期完成时间')]
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
@@ -89,12 +107,12 @@ function createDefaultModel(): Model {
|
||||
title: '',
|
||||
description: '',
|
||||
reviewRequired: 0,
|
||||
completionDate: '',
|
||||
moduleId: props.defaultModuleId || '0',
|
||||
category: '功能需求',
|
||||
priority: 1,
|
||||
proposerId: '',
|
||||
currentHandlerUserId: '',
|
||||
workHours: null,
|
||||
sort: 0
|
||||
};
|
||||
}
|
||||
@@ -114,6 +132,11 @@ async function handleSubmit() {
|
||||
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 = {
|
||||
productId: props.productId,
|
||||
moduleId: model.value.moduleId || '0',
|
||||
@@ -123,9 +146,11 @@ async function handleSubmit() {
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
proposerNickname,
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname,
|
||||
implementProjectId: null,
|
||||
completionDate: model.value.completionDate,
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
};
|
||||
|
||||
@@ -188,31 +213,24 @@ watch(
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="标题" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" />
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<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 label="模块">
|
||||
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="描述">
|
||||
<ElInput
|
||||
<ElFormItem label="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="请输入需求描述"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
@@ -228,24 +246,6 @@ watch(
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="模块">
|
||||
<ElTreeSelect
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTree"
|
||||
:props="moduleTreeProps"
|
||||
class="w-full"
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
placeholder="请选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="分类" prop="category">
|
||||
<DictSelect v-model="model.category" :dict-code="categoryDictCode" filterable placeholder="请选择分类" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
|
||||
@@ -253,6 +253,28 @@ watch(
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="所需工时" prop="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<DictSelect
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
filterable
|
||||
placeholder="请选择需求类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
|
||||
|
||||
@@ -1,11 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetRequirement, fetchGetRequirementModuleTree, fetchUpdateRequirement } from '@/service/api';
|
||||
import {
|
||||
fetchGetProjectListByProductId,
|
||||
fetchGetRequirement,
|
||||
fetchGetRequirementModuleTree,
|
||||
fetchUpdateRequirement
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
@@ -51,13 +55,15 @@ interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
reviewRequired: number;
|
||||
completionDate: string;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
proposerId: string;
|
||||
proposerNickname: string;
|
||||
currentHandlerUserId: string;
|
||||
currentHandlerUserNickname: string;
|
||||
implementProjectId: string | null;
|
||||
workHours: number | null;
|
||||
sort: number;
|
||||
lastStatusReason: string;
|
||||
}
|
||||
@@ -65,6 +71,7 @@ interface Model {
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
const projectOptions = ref<Api.Project.Project[]>([]);
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
@@ -87,6 +94,7 @@ const memberLabelMap = computed(() => {
|
||||
|
||||
const moduleLabelMap = computed(() => {
|
||||
const map = new Map<string | undefined, string>();
|
||||
|
||||
function traverse(modules: Api.Product.RequirementModule[]) {
|
||||
for (const module of modules) {
|
||||
map.set(module.id, module.moduleName);
|
||||
@@ -95,15 +103,37 @@ const moduleLabelMap = computed(() => {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
traverse(moduleTree.value);
|
||||
return map;
|
||||
});
|
||||
|
||||
const moduleTreeProps = {
|
||||
label: 'moduleName',
|
||||
value: 'id',
|
||||
children: 'children'
|
||||
};
|
||||
const projectOptionsMap = computed(() => {
|
||||
return new Map(projectOptions.value.map(item => [String(item.id), item.projectName]));
|
||||
});
|
||||
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
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 = [
|
||||
{ label: '不需要', value: 0 },
|
||||
@@ -112,12 +142,11 @@ const reviewRequiredOptions = [
|
||||
|
||||
const rules = computed(() => {
|
||||
const baseRules: Record<string, App.Global.FormRule[]> = {
|
||||
title: isEditMode.value ? [createRequiredRule('请输入需求标题')] : [],
|
||||
category: isEditMode.value ? [createRequiredRule('请选择分类')] : [],
|
||||
title: isEditMode.value ? [createRequiredRule('请输入需求名称')] : [],
|
||||
category: isEditMode.value ? [createRequiredRule('请选择需求类型')] : [],
|
||||
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
|
||||
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
|
||||
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : [],
|
||||
completionDate: isEditMode.value ? [createRequiredRule('请选择预期完成时间')] : []
|
||||
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
|
||||
};
|
||||
|
||||
return baseRules;
|
||||
@@ -128,13 +157,15 @@ function createDefaultModel(): Model {
|
||||
title: '',
|
||||
description: '',
|
||||
reviewRequired: 0,
|
||||
completionDate: '',
|
||||
moduleId: '0',
|
||||
category: '',
|
||||
priority: 1,
|
||||
proposerId: '',
|
||||
proposerNickname: '',
|
||||
currentHandlerUserId: '',
|
||||
currentHandlerUserNickname: '',
|
||||
implementProjectId: null,
|
||||
workHours: null,
|
||||
sort: 0,
|
||||
lastStatusReason: ''
|
||||
};
|
||||
@@ -167,9 +198,11 @@ async function handleSubmit() {
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
proposerNickname: model.value.proposerNickname,
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
|
||||
implementProjectId: model.value.implementProjectId,
|
||||
completionDate: model.value.completionDate,
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
};
|
||||
|
||||
@@ -202,6 +235,22 @@ async function loadModuleTree() {
|
||||
moduleTree.value = data;
|
||||
}
|
||||
|
||||
async function loadProjectOptions() {
|
||||
if (!props.productId) {
|
||||
projectOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectListByProductId(props.productId);
|
||||
|
||||
if (error || !data) {
|
||||
projectOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
projectOptions.value = data;
|
||||
}
|
||||
|
||||
async function loadRequirementDetail() {
|
||||
if (!props.productId || !props.requirement?.id) {
|
||||
return;
|
||||
@@ -221,13 +270,15 @@ async function loadRequirementDetail() {
|
||||
title: data.title || '',
|
||||
description: data.description || '',
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
completionDate: data.completionDate || '',
|
||||
moduleId: data.moduleId || '0',
|
||||
category: data.category || '',
|
||||
priority: data.priority ?? null,
|
||||
proposerId: data.proposerId || '',
|
||||
proposerNickname: data.proposerNickname || '',
|
||||
currentHandlerUserId: data.currentHandlerUserId || '',
|
||||
currentHandlerUserNickname: data.currentHandlerUserNickname || '',
|
||||
implementProjectId: data.implementProjectId || null,
|
||||
workHours: data.workHours ?? null,
|
||||
sort: data.sort ?? 0,
|
||||
lastStatusReason: data.lastStatusReason || ''
|
||||
};
|
||||
@@ -240,7 +291,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
await loadModuleTree();
|
||||
await Promise.all([loadModuleTree(), loadProjectOptions()]);
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
@@ -265,52 +316,8 @@ watch(
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="标题" prop="title">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="model.title" />
|
||||
</template>
|
||||
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求标题" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="预期完成时间" prop="completionDate" style="width: 100%">
|
||||
<template v-if="isViewMode">
|
||||
<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>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="描述">
|
||||
<template v-if="isViewMode">
|
||||
<div class="readonly-textarea">
|
||||
{{ model.description || '--' }}
|
||||
</div>
|
||||
</template>
|
||||
<ElInput
|
||||
v-else
|
||||
v-model="model.description"
|
||||
type="textarea"
|
||||
:rows="6"
|
||||
maxlength="2000"
|
||||
show-word-limit
|
||||
placeholder="请输入需求描述"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ReadonlyField :value="model.title" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
@@ -318,30 +325,28 @@ watch(
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
</template>
|
||||
<ElTreeSelect
|
||||
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<template v-if="isViewMode">
|
||||
<div class="readonly-textarea" v-html="model.description || '--'"></div>
|
||||
</template>
|
||||
<BusinessRichTextEditor
|
||||
v-else
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTree"
|
||||
:props="moduleTreeProps"
|
||||
class="w-full"
|
||||
check-strictly
|
||||
:render-after-expand="false"
|
||||
placeholder="请选择所属模块"
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="分类" prop="category">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
|
||||
</template>
|
||||
<DictSelect
|
||||
v-else
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
filterable
|
||||
placeholder="请选择分类"
|
||||
/>
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ReadonlyField :value="reviewRequiredOptions.find(opt => opt.value === model.reviewRequired)?.label" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
@@ -357,20 +362,29 @@ watch(
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ElFormItem label="所需工时">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
<ReadonlyField :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
|
||||
</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>
|
||||
<ElInputNumber
|
||||
v-else
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
<ReadonlyField :value="getCategoryLabel(model.category) || '--'" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
@@ -392,7 +406,19 @@ watch(
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="实现项目">
|
||||
<ReadonlyField :value="model.implementProjectId || '--'" />
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
|
||||
</template>
|
||||
<ElSelect
|
||||
v-else
|
||||
v-model="model.implementProjectId"
|
||||
class="w-full"
|
||||
filterable
|
||||
clearable
|
||||
placeholder="请选择实现项目"
|
||||
>
|
||||
<ElOption v-for="item in projectOptions" :key="item.id" :label="item.projectName" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<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 {
|
||||
fetchCreateRequirementModule,
|
||||
@@ -41,12 +41,24 @@ const rootModule = computed<Api.Product.RequirementModule | null>(() => {
|
||||
const editingNodeId = ref<string | undefined>(undefined);
|
||||
const editingName = ref('');
|
||||
|
||||
const addingTopModule = ref(false);
|
||||
const newModuleName = ref('');
|
||||
|
||||
const addingChildParentId = ref<string | undefined>(undefined);
|
||||
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 countMap = new Map<string, number>();
|
||||
|
||||
@@ -98,59 +110,6 @@ function handleNodeSelect(moduleId: string) {
|
||||
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) {
|
||||
editingNodeId.value = module.id;
|
||||
editingName.value = module.moduleName;
|
||||
@@ -199,7 +158,7 @@ async function handleUpdateModuleName(module: Api.Product.RequirementModule, nam
|
||||
}
|
||||
|
||||
function handleStartAddChild(module: Api.Product.RequirementModule) {
|
||||
if (addingTopModule.value || addingChildParentId.value) return;
|
||||
if (addingChildParentId.value) return;
|
||||
|
||||
addingChildParentId.value = module.id;
|
||||
newChildModuleName.value = '';
|
||||
@@ -312,19 +271,6 @@ defineExpose({
|
||||
<div class="requirement-module-tree-wrapper">
|
||||
<div class="module-tree-header">
|
||||
<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 class="module-tree-list">
|
||||
@@ -351,23 +297,6 @@ defineExpose({
|
||||
@update-new-child-module-name="newChildModuleName = $event"
|
||||
/>
|
||||
</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>
|
||||
</template>
|
||||
|
||||
@@ -63,18 +63,18 @@ onMounted(async () => {
|
||||
<template>
|
||||
<TableSearchPanel :model="model" :action-col-lg="6" @reset="reset" @search="search">
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="标题">
|
||||
<ElInput v-model="model.title" clearable placeholder="输入需求标题" />
|
||||
<ElFormItem label="需求名称">
|
||||
<ElInput v-model="model.title" clearable placeholder="输入需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="分类">
|
||||
<ElFormItem label="需求类型">
|
||||
<DictSelect
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="筛选分类"
|
||||
placeholder="筛选需求类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
@@ -111,12 +111,12 @@ onMounted(async () => {
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="来源类型">
|
||||
<ElFormItem label="需求来源">
|
||||
<DictSelect
|
||||
v-model="model.sourceType"
|
||||
:dict-code="RDMS_REQ_SOURCE_TYPE_DICT_CODE"
|
||||
clearable
|
||||
placeholder="筛选来源类型"
|
||||
placeholder="筛选需求来源"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { fetchSplitRequirement } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementSplitDialog' });
|
||||
@@ -48,7 +48,7 @@ interface Model {
|
||||
category: string;
|
||||
priority: number | null;
|
||||
currentHandlerUserId: string;
|
||||
completionDate: string;
|
||||
workHours: number | null;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
@@ -67,11 +67,10 @@ const reviewRequiredOptions = [
|
||||
];
|
||||
|
||||
const rules = {
|
||||
title: [createRequiredRule('请输入子需求标题')],
|
||||
category: [createRequiredRule('请选择分类')],
|
||||
title: [createRequiredRule('请输入子需求名称')],
|
||||
priority: [createRequiredRule('请选择优先级')],
|
||||
currentHandlerUserId: [createRequiredRule('请选择负责人')],
|
||||
completionDate: [createRequiredRule('请选择预期完成时间')]
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
@@ -82,7 +81,7 @@ function createDefaultModel(): Model {
|
||||
category: '',
|
||||
priority: 1,
|
||||
currentHandlerUserId: '',
|
||||
completionDate: '',
|
||||
workHours: null,
|
||||
sort: 0
|
||||
};
|
||||
}
|
||||
@@ -102,23 +101,26 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const proposerNickname = props.parentRequirement.proposerNickname || '';
|
||||
const currentHandlerUserNickname = props.parentRequirement.currentHandlerUserNickname || '';
|
||||
|
||||
const payload: Api.Product.SplitRequirementParams = {
|
||||
parentId: props.parentRequirement.id,
|
||||
productId: props.productId,
|
||||
moduleId: props.parentRequirement.moduleId,
|
||||
proposerId: props.parentRequirement.proposerId,
|
||||
proposerNickname,
|
||||
currentHandlerUserNickname,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
reviewRequired: model.value.reviewRequired as Api.Product.RequirementReviewRequired,
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Product.RequirementPriority,
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
completionDate: model.value.completionDate,
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
};
|
||||
|
||||
console.log('payload', payload);
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const result = await fetchSplitRequirement(payload);
|
||||
@@ -143,6 +145,10 @@ watch(
|
||||
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (props.parentRequirement?.category) {
|
||||
model.value.category = props.parentRequirement.category;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
@@ -169,32 +175,8 @@ watch(
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="子需求标题" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求标题" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="预期完成时间" 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 label="子需求名称" prop="title">
|
||||
<ElInput v-model="model.title" clearable maxlength="256" placeholder="请输入子需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
@@ -209,9 +191,14 @@ watch(
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="分类" prop="category">
|
||||
<DictSelect v-model="model.category" :dict-code="categoryDictCode" filterable placeholder="请选择分类" />
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
@@ -221,6 +208,18 @@ watch(
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="所需工时" prop="workHours">
|
||||
<ElInputNumber
|
||||
v-model="model.workHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:max="9999"
|
||||
:precision="1"
|
||||
placeholder="请输入所需工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
fetchBatchDeleteUserManagementRelation,
|
||||
fetchDeleteUserManagementRelation,
|
||||
fetchGetUserListByDeptId,
|
||||
fetchGetUserManagementRelation,
|
||||
fetchGetUserManagementRelationQuery,
|
||||
fetchGetUserManagementRelationTree
|
||||
} from '@/service/api';
|
||||
@@ -60,7 +61,7 @@ const { fromUserIndex = false, deptId = 100, orgType = 'company' } = defineProps
|
||||
* 当组织类型为部门、方向或团队时,隐藏母节点的编辑按钮
|
||||
*/
|
||||
const shouldHideRootEdit = computed(() => {
|
||||
return fromUserIndex && orgType !== 'company';
|
||||
return fromUserIndex && orgType;
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -134,10 +135,6 @@ async function loadTreeData() {
|
||||
|
||||
if (!error) {
|
||||
treeData.value = data || [];
|
||||
|
||||
// 数据加载完成后,展开前两层节点
|
||||
await nextTick();
|
||||
expandFirstTwoLevels();
|
||||
}
|
||||
} finally {
|
||||
loading.value = false;
|
||||
@@ -168,7 +165,7 @@ async function loadTreeDataByQuery(query: Api.SystemManage.UserManagementRelatio
|
||||
/**
|
||||
* 刷新树形数据
|
||||
*
|
||||
* 清空选中状态并重新加载数据
|
||||
* 从后端重新加载整个树数据
|
||||
*/
|
||||
async function reloadTreeData() {
|
||||
// 保存当前展开状态
|
||||
@@ -176,10 +173,161 @@ async function reloadTreeData() {
|
||||
|
||||
checkedNodeKeys.value = [];
|
||||
await loadTreeData();
|
||||
|
||||
// 等待 Vue 渲染树节点
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
// 数据更新后恢复展开状态
|
||||
await restoreExpandedState();
|
||||
|
||||
await nextTick();
|
||||
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,18 +352,31 @@ async function handleSearch() {
|
||||
await loadTreeData();
|
||||
}
|
||||
|
||||
// 搜索后展开所有节点
|
||||
await expandAllNodes();
|
||||
|
||||
await nextTick();
|
||||
relationTreeRef.value?.setCheckedKeys([]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前展开的节点 ID 列表
|
||||
* 用于在刷新数据后恢复展开状态
|
||||
*/
|
||||
const expandedNodeKeys = ref<string[]>([]);
|
||||
|
||||
/**
|
||||
* 重置搜索
|
||||
*
|
||||
* 清空搜索条件并重新加载数据
|
||||
*/
|
||||
function resetSearchParams() {
|
||||
async function resetSearchParams() {
|
||||
Object.assign(searchParams, getInitSearchParams());
|
||||
reloadTreeData();
|
||||
|
||||
// 清空保存的展开状态,让 reloadTreeData 后展开前两层
|
||||
expandedNodeKeys.value = [];
|
||||
|
||||
await reloadTreeData();
|
||||
}
|
||||
|
||||
// 对话框相关状态
|
||||
@@ -291,7 +452,9 @@ async function handleDelete(item: Api.SystemManage.UserManagementRelationTreeRes
|
||||
}
|
||||
|
||||
window.$message?.success('删除成功');
|
||||
await reloadTreeData();
|
||||
|
||||
// 使用局部删除,保持树的展开状态
|
||||
removeNodeLocally(item);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -304,6 +467,23 @@ async function handleBatchDelete() {
|
||||
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);
|
||||
|
||||
if (error) {
|
||||
@@ -311,7 +491,11 @@ async function handleBatchDelete() {
|
||||
}
|
||||
|
||||
window.$message?.success('删除成功');
|
||||
await reloadTreeData();
|
||||
|
||||
// 使用局部删除,保持树的展开状态
|
||||
for (const node of nodesToDelete) {
|
||||
removeNodeLocally(node);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -326,12 +510,6 @@ function handleNodeCheck(checkedData: any, checkedInfo: any) {
|
||||
.filter((id: string | null): id is string => Boolean(id));
|
||||
}
|
||||
|
||||
/**
|
||||
* 保存当前展开的节点 ID 列表
|
||||
* 用于在刷新数据后恢复展开状态
|
||||
*/
|
||||
const expandedNodeKeys = ref<string[]>([]);
|
||||
|
||||
/**
|
||||
* 保存当前展开的节点状态
|
||||
*/
|
||||
@@ -361,42 +539,21 @@ function saveExpandedState() {
|
||||
*
|
||||
* @param relationId 提交后的关系 ID
|
||||
*/
|
||||
async function handleSubmitted(_relationId: string) {
|
||||
async function handleSubmitted(relationId: string) {
|
||||
closeOperateModal();
|
||||
await reloadTreeData();
|
||||
|
||||
// 操作完成后恢复树节点的展开状态
|
||||
await restoreExpandedState();
|
||||
// 如果是从树节点新增,使用局部插入
|
||||
if (operateType.value === 'add' && isAddFromTreeNode.value && editingData.value?.managerUserId) {
|
||||
await insertNodeLocally(editingData.value.managerUserId, relationId);
|
||||
} else {
|
||||
// 其他情况(头部新增、编辑)重新加载整棵树
|
||||
await reloadTreeData();
|
||||
}
|
||||
|
||||
// 重置标记
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 展开树的前两层节点
|
||||
*
|
||||
@@ -434,18 +591,24 @@ function expandFirstTwoLevels() {
|
||||
*/
|
||||
async function restoreExpandedState() {
|
||||
await nextTick();
|
||||
await nextTick();
|
||||
|
||||
const tree = relationTreeRef.value;
|
||||
if (!tree || !expandedNodeKeys.value.length) {
|
||||
if (!tree) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 恢复之前展开的节点
|
||||
for (const key of expandedNodeKeys.value) {
|
||||
const node = tree.getNode(key);
|
||||
if (node) {
|
||||
node.expand();
|
||||
if (expandedNodeKeys.value.length > 0) {
|
||||
// 恢复之前展开的节点
|
||||
for (const key of expandedNodeKeys.value) {
|
||||
const node = tree.getNode(key);
|
||||
if (node && !node.expanded) {
|
||||
node.expand();
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// 如果是首次加载或没有保存的状态,展开前两层
|
||||
expandFirstTwoLevels();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import {
|
||||
fetchCreateUserManagementRelation,
|
||||
fetchGetCandidateSubordinateUsers,
|
||||
fetchGetUserManagementRelation,
|
||||
fetchUpdateUserManagementRelation
|
||||
} from '@/service/api';
|
||||
@@ -73,9 +74,40 @@ const authStore = useAuthStore();
|
||||
const detailLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
|
||||
// 候选下级用户列表
|
||||
const candidateSubordinateUsers = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
|
||||
// 计算属性:是否为编辑模式
|
||||
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) {
|
||||
initModel();
|
||||
|
||||
// 如果是新增模式,加载候选下级用户(每次打开都重新加载,避免缓存)
|
||||
if (!isEdit.value) {
|
||||
await loadCandidateSubordinateUsers();
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
@@ -324,7 +361,7 @@ watch(visible, value => {
|
||||
filterable
|
||||
: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>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
|
||||
Reference in New Issue
Block a user