feat(项目需求): 开发项目需求的功能。
This commit is contained in:
@@ -91,7 +91,7 @@ function createProductActivityTimelinePageQuery(params: Api.Product.ProductActiv
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
/** 鑾峰彇浜у搧鍒嗛〉 */
|
||||
/** 获取产品分页 */
|
||||
export async function fetchGetProductPage(params?: Api.Product.ProductSearchParams) {
|
||||
const result = await request<ProductPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
@@ -115,7 +115,7 @@ export function fetchGetProductOverviewSummary() {
|
||||
});
|
||||
}
|
||||
|
||||
/** 鑾峰彇浜у搧璇︽儏 */
|
||||
/** 获取产品详情 */
|
||||
export async function fetchGetProduct(id: string) {
|
||||
const result = await request<ProductResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
@@ -127,7 +127,7 @@ export async function fetchGetProduct(id: string) {
|
||||
return mapServiceResult(result as ServiceRequestResult<ProductResponse>, normalizeProduct);
|
||||
}
|
||||
|
||||
/** 鍒涘缓浜у搧 */
|
||||
/** 新增产品 */
|
||||
export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
@@ -139,7 +139,7 @@ export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 鏇存柊浜у搧 */
|
||||
/** 更新产品 */
|
||||
export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
|
||||
return request<boolean>({
|
||||
url: `${PRODUCT_PREFIX}/update`,
|
||||
@@ -148,7 +148,7 @@ export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
|
||||
});
|
||||
}
|
||||
|
||||
/** 鍙樻洿浜у搧鐘舵€? */
|
||||
/** 改变产品状态 */
|
||||
export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusParams) {
|
||||
return request<boolean>({
|
||||
url: `${PRODUCT_PREFIX}/change-status`,
|
||||
@@ -157,7 +157,7 @@ export function fetchChangeProductStatus(data: Api.Product.ChangeProductStatusPa
|
||||
});
|
||||
}
|
||||
|
||||
/** 鍒犻櫎浜у搧 */
|
||||
/** 删除产品 */
|
||||
export function fetchDeleteProduct(data: Api.Product.DeleteProductParams) {
|
||||
return request<boolean>({
|
||||
url: `${PRODUCT_PREFIX}/delete`,
|
||||
@@ -353,6 +353,26 @@ export async function fetchGetRequirementTerminalStatusDict() {
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 判断产品需求是否已分流生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/has-dispatched`,
|
||||
method: 'get',
|
||||
params: { requirementId, productId }
|
||||
});
|
||||
}
|
||||
|
||||
/** 根据当前产品需求id获取对应地,所流转到项目侧的项目需求id */
|
||||
export async function fetchGetDispatchedProjectLink(productRequirementId: string) {
|
||||
return request<{ projectRequirementId: string; projectId: string }>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/dispatched-project-link`,
|
||||
method: 'get',
|
||||
params: { productRequirementId }
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 模块管理 API ==========
|
||||
type RequirementModuleResponse = Omit<Api.Product.RequirementModule, 'id' | 'parentId' | 'productId'> & {
|
||||
id: string | number;
|
||||
|
||||
@@ -542,3 +542,256 @@ export function fetchChangeProjectTaskStatus(
|
||||
data: payload.data
|
||||
});
|
||||
}
|
||||
|
||||
// ========== 项目需求 API ==========
|
||||
const PROJECT_REQUIREMENT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project/requirement`;
|
||||
|
||||
type ProjectRequirementResponse = Omit<
|
||||
Api.Project.ProjectRequirement,
|
||||
'id' | 'projectId' | 'parentId' | 'moduleId' | 'proposerId' | 'currentHandlerUserId' | 'sourceBizId'
|
||||
> & {
|
||||
id: string | number;
|
||||
projectId: string | number;
|
||||
parentId: string | number;
|
||||
moduleId: string | number;
|
||||
proposerId: string | number;
|
||||
currentHandlerUserId?: string | number | null;
|
||||
sourceBizId?: string | number | null;
|
||||
children?: ProjectRequirementResponse[];
|
||||
};
|
||||
|
||||
type ProjectRequirementPageResponse = Api.Project.PageResult<ProjectRequirementResponse>;
|
||||
|
||||
type ProjectRequirementModuleResponse = Omit<Api.Project.ProjectRequirementModule, 'id' | 'parentId' | 'projectId'> & {
|
||||
id: string | number;
|
||||
parentId: string | number;
|
||||
projectId: string | number;
|
||||
children?: ProjectRequirementModuleResponse[];
|
||||
};
|
||||
|
||||
function normalizeProjectRequirement(requirement: ProjectRequirementResponse): Api.Project.ProjectRequirement {
|
||||
return {
|
||||
...requirement,
|
||||
id: normalizeStringId(requirement.id),
|
||||
projectId: normalizeStringId(requirement.projectId),
|
||||
parentId: normalizeStringId(requirement.parentId),
|
||||
moduleId: normalizeStringId(requirement.moduleId),
|
||||
proposerId: normalizeStringId(requirement.proposerId),
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
children: requirement.children?.map(normalizeProjectRequirement)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectRequirementModule(
|
||||
module: ProjectRequirementModuleResponse
|
||||
): Api.Project.ProjectRequirementModule {
|
||||
return {
|
||||
...module,
|
||||
id: normalizeStringId(module.id),
|
||||
parentId: normalizeStringId(module.parentId),
|
||||
projectId: normalizeStringId(module.projectId),
|
||||
children: module.children?.map(normalizeProjectRequirementModule)
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取项目需求分页列表 */
|
||||
export async function fetchGetProjectRequirementPage(params?: Api.Project.ProjectRequirementSearchParams) {
|
||||
const result = await request<ProjectRequirementPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeProjectRequirement)
|
||||
}));
|
||||
}
|
||||
|
||||
/** 获取项目需求树形列表 */
|
||||
export async function fetchGetProjectRequirementTree(params?: Api.Project.ProjectRequirementSearchParams) {
|
||||
const result = await request<ProjectRequirementPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/tree`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeProjectRequirement)
|
||||
}));
|
||||
}
|
||||
|
||||
/** 获取项目需求详情 */
|
||||
export async function fetchGetProjectRequirement(id: string, projectId: string) {
|
||||
const result = await request<ProjectRequirementResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/get`,
|
||||
method: 'get',
|
||||
params: { id, projectId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementResponse>, normalizeProjectRequirement);
|
||||
}
|
||||
|
||||
/** 创建项目需求 */
|
||||
export async function fetchCreateProjectRequirement(data: Api.Project.SaveProjectRequirementParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 更新项目需求 */
|
||||
export function fetchUpdateProjectRequirement(data: Api.Project.UpdateProjectRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 变更项目需求状态 */
|
||||
export function fetchChangeProjectRequirementStatus(data: Api.Project.ChangeProjectRequirementStatusParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/change-status`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除项目需求 */
|
||||
export function fetchDeleteProjectRequirement(data: Api.Project.DeleteProjectRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/delete`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 拆分项目需求 */
|
||||
export async function fetchSplitProjectRequirement(data: Api.Project.SplitProjectRequirementParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/split`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 关闭项目需求 */
|
||||
export function fetchCloseProjectRequirement(data: Api.Project.CloseProjectRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/close`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取项目需求可执行状态动作列表 */
|
||||
export async function fetchGetProjectRequirementAllowedTransitions(requirementId: string, projectId: string) {
|
||||
const result = await request<Api.Project.ProjectRequirementLifecycleAction[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/allowed-transitions`,
|
||||
method: 'get',
|
||||
params: { requirementId, projectId }
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<Api.Project.ProjectRequirementLifecycleAction[]>,
|
||||
data => data
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取项目需求生命周期信息 */
|
||||
export async function fetchGetProjectRequirementLifecycle(requirementId: string, projectId: string) {
|
||||
const result = await request<Api.Project.ProjectRequirementLifecycleInfo>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/lifecycle`,
|
||||
method: 'get',
|
||||
params: { requirementId, projectId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementLifecycleInfo>, data => data);
|
||||
}
|
||||
|
||||
/** 获取项目需求状态字典 */
|
||||
export async function fetchGetProjectRequirementStatusDict() {
|
||||
const result = await request<Api.Project.ProjectRequirementStatusDict[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/status/dict`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 获取项目需求终态状态字典 */
|
||||
export async function fetchGetProjectRequirementTerminalStatusDict() {
|
||||
const result = await request<Api.Project.ProjectRequirementStatusDict[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/status/dict/terminal`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 获取项目需求模块树 */
|
||||
export async function fetchGetProjectRequirementModuleTree(projectId: string) {
|
||||
const result = await request<ProjectRequirementModuleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/module/tree`,
|
||||
method: 'get',
|
||||
params: { projectId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectRequirementModuleResponse[]>, data =>
|
||||
data.map(normalizeProjectRequirementModule)
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建项目需求模块 */
|
||||
export async function fetchCreateProjectRequirementModule(data: Api.Project.SaveProjectRequirementModuleParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/module/create`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 更新项目需求模块 */
|
||||
export function fetchUpdateProjectRequirementModule(data: Api.Project.SaveProjectRequirementModuleParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/module/update`,
|
||||
method: 'put',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除项目需求模块 */
|
||||
export function fetchDeleteProjectRequirementModule(data: Api.Project.DeleteProjectRequirementModuleParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/module/delete`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
214
src/typings/api/project.d.ts
vendored
214
src/typings/api/project.d.ts
vendored
@@ -446,5 +446,219 @@ declare namespace Api {
|
||||
interface InactiveProjectMemberParams {
|
||||
reason: string | null;
|
||||
}
|
||||
|
||||
// ========== 项目需求相关类型定义 ==========
|
||||
/** 项目需求状态编码 */
|
||||
type ProjectRequirementStatusCode =
|
||||
| 'pending_confirm'
|
||||
| 'pending_review'
|
||||
| 'implementing'
|
||||
| 'accepted'
|
||||
| 'closed'
|
||||
| 'rejected'
|
||||
| 'cancelled';
|
||||
|
||||
/** 项目需求来源类型 */
|
||||
type ProjectRequirementSourceType = 'manual' | 'work_order' | 'product_requirement';
|
||||
|
||||
/** 项目需求优先级 */
|
||||
type ProjectRequirementPriority = 0 | 1 | 2 | 3;
|
||||
|
||||
/** 是否需要评审 */
|
||||
type ProjectRequirementReviewRequired = 0 | 1;
|
||||
|
||||
interface ProjectRequirement {
|
||||
/** 需求 ID */
|
||||
id: string;
|
||||
/** 所属项目 ID */
|
||||
projectId: string;
|
||||
/** 父需求 ID,0 表示顶级需求 */
|
||||
parentId: string;
|
||||
/** 所属模块 ID */
|
||||
moduleId: string;
|
||||
/** 是否需要评审 */
|
||||
reviewRequired: ProjectRequirementReviewRequired;
|
||||
/** 需求标题 */
|
||||
title: string;
|
||||
/** 需求描述 */
|
||||
description?: string | null;
|
||||
/** 需求分类字典值 */
|
||||
category: string;
|
||||
/** 需求分类名称 */
|
||||
categoryName?: string | null;
|
||||
/** 需求来源类型 */
|
||||
sourceType: ProjectRequirementSourceType;
|
||||
/** 来源业务 ID */
|
||||
sourceBizId?: string | null;
|
||||
/** 优先级 */
|
||||
priority: ProjectRequirementPriority;
|
||||
/** 优先级名称 */
|
||||
priorityName?: string | null;
|
||||
/** 当前状态编码 */
|
||||
statusCode: ProjectRequirementStatusCode;
|
||||
/** 当前状态名称 */
|
||||
statusName?: string | null;
|
||||
/** 最近一次状态动作原因 */
|
||||
lastStatusReason?: string | null;
|
||||
/** 提出人用户 ID */
|
||||
proposerId: string;
|
||||
/** 提出人昵称 */
|
||||
proposerNickname?: string | null;
|
||||
/** 当前处理人用户 ID */
|
||||
currentHandlerUserId?: string | null;
|
||||
/** 当前处理人昵称 */
|
||||
currentHandlerUserNickname?: string | null;
|
||||
/** 所需工时 */
|
||||
workHours: number;
|
||||
/** 排序值 */
|
||||
sort: number;
|
||||
/** 创建时间 */
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 子需求列表 */
|
||||
children?: ProjectRequirement[];
|
||||
/** 是否终态 */
|
||||
terminal?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementModule {
|
||||
/** 模块 ID */
|
||||
id: string;
|
||||
/** 父模块 ID,0 表示顶级 */
|
||||
parentId: string;
|
||||
/** 所属项目 ID */
|
||||
projectId: string;
|
||||
/** 模块名称 */
|
||||
moduleName: string;
|
||||
/** 模块说明 */
|
||||
remark?: string | null;
|
||||
/** 图标 */
|
||||
icon?: string | null;
|
||||
/** 排序值 */
|
||||
sort: number;
|
||||
/** 子模块列表 */
|
||||
children?: ProjectRequirementModule[];
|
||||
}
|
||||
|
||||
interface ProjectRequirementStatusDict {
|
||||
/** 状态编码 */
|
||||
statusCode: string;
|
||||
/** 状态名称 */
|
||||
statusName: string;
|
||||
/** 排序值 */
|
||||
sort: number;
|
||||
/** 是否初始状态 */
|
||||
initialFlag: boolean;
|
||||
/** 是否终态 */
|
||||
terminalFlag: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementLifecycleAction {
|
||||
actionCode: string;
|
||||
actionName: string;
|
||||
toStatusCode: string;
|
||||
toStatusName: string;
|
||||
needReason: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementLifecycleInfo {
|
||||
statusCode: ProjectRequirementStatusCode;
|
||||
statusName?: string | null;
|
||||
lastStatusReason?: string | null;
|
||||
terminal: boolean;
|
||||
allowEdit: boolean;
|
||||
availableActions: ProjectRequirementLifecycleAction[];
|
||||
}
|
||||
|
||||
/** 项目需求分页查询参数 */
|
||||
type ProjectRequirementSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
Pick<
|
||||
ProjectRequirement,
|
||||
'moduleId' | 'parentId' | 'category' | 'priority' | 'statusCode' | 'currentHandlerUserId' | 'sourceType'
|
||||
> & {
|
||||
projectId: string;
|
||||
title: string;
|
||||
}
|
||||
>;
|
||||
|
||||
/** 创建项目需求参数 */
|
||||
type SaveProjectRequirementParams = Pick<
|
||||
ProjectRequirement,
|
||||
| 'projectId'
|
||||
| 'moduleId'
|
||||
| 'reviewRequired'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
| 'proposerNickname'
|
||||
| 'currentHandlerUserId'
|
||||
| 'currentHandlerUserNickname'
|
||||
| 'workHours'
|
||||
| 'sort'
|
||||
>;
|
||||
|
||||
/** 更新项目需求参数 */
|
||||
type UpdateProjectRequirementParams = { id: string } & SaveProjectRequirementParams;
|
||||
|
||||
/** 变更项目需求状态参数 */
|
||||
interface ChangeProjectRequirementStatusParams {
|
||||
id: string;
|
||||
projectId: string;
|
||||
actionCode: string;
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
/** 关闭项目需求参数 */
|
||||
interface CloseProjectRequirementParams {
|
||||
id: string;
|
||||
projectId: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 拆分项目需求参数 */
|
||||
type SplitProjectRequirementParams = Pick<
|
||||
ProjectRequirement,
|
||||
| 'parentId'
|
||||
| 'projectId'
|
||||
| 'moduleId'
|
||||
| 'reviewRequired'
|
||||
| 'title'
|
||||
| 'description'
|
||||
| 'category'
|
||||
| 'priority'
|
||||
| 'proposerId'
|
||||
| 'proposerNickname'
|
||||
| 'currentHandlerUserId'
|
||||
| 'currentHandlerUserNickname'
|
||||
| 'workHours'
|
||||
| 'sort'
|
||||
>;
|
||||
|
||||
/** 删除项目需求参数 */
|
||||
interface DeleteProjectRequirementParams {
|
||||
id: string;
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
/** 保存项目需求模块参数 */
|
||||
interface SaveProjectRequirementModuleParams {
|
||||
id?: string;
|
||||
projectId: string;
|
||||
parentId?: string | null;
|
||||
moduleName: string;
|
||||
remark?: string | null;
|
||||
icon?: string | null;
|
||||
sort?: number;
|
||||
}
|
||||
|
||||
/** 删除项目需求模块参数 */
|
||||
interface DeleteProjectRequirementModuleParams {
|
||||
id?: string;
|
||||
projectId: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, markRaw, onMounted, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { TableInstance } from 'element-plus';
|
||||
import { ElButton, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
@@ -17,7 +18,8 @@ import {
|
||||
fetchGetRequirementAllowedTransitions,
|
||||
fetchGetRequirementStatusDict,
|
||||
fetchGetRequirementTerminalStatusDict,
|
||||
fetchGetRequirementTree
|
||||
fetchGetRequirementTree,
|
||||
fetchHasDispatchedProjectRequirement
|
||||
} from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
@@ -25,6 +27,8 @@ import DictTag from '@/components/custom/dict-tag.vue';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import {
|
||||
ACTION_ICON_MAP,
|
||||
ACTION_TYPE_MAP,
|
||||
type RequirementStatusActionCode,
|
||||
getRequirementActionDisplayName,
|
||||
getRequirementStatusTagType,
|
||||
@@ -38,60 +42,14 @@ 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 router = useRouter();
|
||||
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[]>([]);
|
||||
@@ -154,6 +112,7 @@ const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
|
||||
2: 'warning',
|
||||
3: 'danger'
|
||||
};
|
||||
const hasDispatchedMap = ref<Record<string, boolean>>({});
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
@@ -168,6 +127,13 @@ function isTerminalStatus(statusCode: string) {
|
||||
}
|
||||
|
||||
function canSplitRequirement(row: Api.Product.Requirement) {
|
||||
if (row.implementProjectId) {
|
||||
return false;
|
||||
}
|
||||
const hasDispatched = hasDispatchedMap.value[row.id];
|
||||
if (hasDispatched) {
|
||||
return false;
|
||||
}
|
||||
return row.statusCode === 'pending_dispatch' || row.statusCode === 'implementing';
|
||||
}
|
||||
|
||||
@@ -268,17 +234,50 @@ function collectAllRequirementIds(nodes: Api.Product.Requirement[]): string[] {
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectRequirementIdsForActions(nodes: Api.Product.Requirement[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const node of nodes) {
|
||||
const isTerminal = isTerminalStatus(node.statusCode);
|
||||
const hasDispatched = Boolean(node.implementProjectId);
|
||||
if (!isTerminal && !hasDispatched) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
if (node.children?.length) {
|
||||
ids.push(...collectRequirementIdsForActions(node.children));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectRequirementIdsForSplitCheck(nodes: Api.Product.Requirement[]): string[] {
|
||||
const ids: string[] = [];
|
||||
for (const node of nodes) {
|
||||
if (!node.implementProjectId) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
if (node.children?.length) {
|
||||
ids.push(...collectRequirementIdsForSplitCheck(node.children));
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
async function loadAllowedTransitionsForAll() {
|
||||
if (!currentObjectId.value) {
|
||||
allowedTransitionsMap.value = new Map();
|
||||
return;
|
||||
}
|
||||
|
||||
const allIds = collectAllRequirementIds(treeData.value);
|
||||
const idsToQuery = collectRequirementIdsForActions(treeData.value);
|
||||
const newMap = new Map<string, Api.Product.RequirementLifecycleAction[]>();
|
||||
|
||||
if (idsToQuery.length === 0) {
|
||||
allowedTransitionsMap.value = newMap;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
allIds.map(async id => {
|
||||
idsToQuery.map(async id => {
|
||||
const { error, data } = await fetchGetRequirementAllowedTransitions(id, currentObjectId.value!);
|
||||
return { id, actions: error ? [] : data || [] };
|
||||
})
|
||||
@@ -291,6 +290,30 @@ async function loadAllowedTransitionsForAll() {
|
||||
allowedTransitionsMap.value = newMap;
|
||||
}
|
||||
|
||||
async function loadHasDispatchedForAll() {
|
||||
if (!currentObjectId.value) {
|
||||
hasDispatchedMap.value = {};
|
||||
return;
|
||||
}
|
||||
|
||||
const idsToQuery = collectRequirementIdsForSplitCheck(treeData.value);
|
||||
const newMap: Record<string, boolean> = {};
|
||||
|
||||
if (idsToQuery.length === 0) {
|
||||
hasDispatchedMap.value = newMap;
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
idsToQuery.map(async id => {
|
||||
const { data } = await fetchHasDispatchedProjectRequirement(id, currentObjectId.value!);
|
||||
newMap[id] = Boolean(data);
|
||||
})
|
||||
);
|
||||
|
||||
hasDispatchedMap.value = newMap;
|
||||
}
|
||||
|
||||
function getRowActions(row: Api.Product.Requirement): Api.Product.RequirementLifecycleAction[] {
|
||||
return allowedTransitionsMap.value.get(row.id) || [];
|
||||
}
|
||||
@@ -408,7 +431,12 @@ const columns = computed(() => [
|
||||
minWidth: 140,
|
||||
formatter: (row: Api.Product.Requirement) => {
|
||||
if (!row.implementProjectId) return '--';
|
||||
return projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
|
||||
const projectName = projectNameMap.value.get(row.implementProjectId) || row.implementProjectName || '--';
|
||||
return (
|
||||
<ElButton link type="primary" class="implement-project-link" onClick={() => handleImplementProjectClick(row)}>
|
||||
{projectName}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -433,7 +461,7 @@ const columns = computed(() => [
|
||||
onClick: () => void;
|
||||
}[] = [];
|
||||
|
||||
if (canSplitRequirement(row) && hasObjectAuth('project:product:status')) {
|
||||
if (canSplitRequirement(row) && hasObjectAuth('project:product:split')) {
|
||||
actions.push({
|
||||
key: 'split',
|
||||
label: '拆分',
|
||||
@@ -443,7 +471,12 @@ const columns = computed(() => [
|
||||
});
|
||||
}
|
||||
|
||||
if (hasObjectAuth('project:product:update') && !isTerminalStatus(row.statusCode)) {
|
||||
if (
|
||||
hasObjectAuth('project:product:update') &&
|
||||
!isTerminalStatus(row.statusCode) &&
|
||||
row.statusCode !== 'accepted' &&
|
||||
!row.implementProjectId
|
||||
) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
@@ -480,7 +513,7 @@ const columns = computed(() => [
|
||||
}
|
||||
}
|
||||
|
||||
if (hasStatusAuth && canDeleteRequirement(row)) {
|
||||
if (canDeleteRequirement(row) && hasObjectAuth('project:product:delete')) {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
@@ -566,8 +599,6 @@ async function loadTreeData() {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetRequirementTree({
|
||||
productId: currentObjectId.value,
|
||||
moduleId: selectedModuleId.value,
|
||||
@@ -581,8 +612,6 @@ async function loadTreeData() {
|
||||
sourceType: searchParams.sourceType
|
||||
});
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
treeData.value = [];
|
||||
pagination.total = 0;
|
||||
@@ -594,8 +623,14 @@ async function loadTreeData() {
|
||||
}
|
||||
|
||||
async function reloadTable() {
|
||||
await loadTreeData();
|
||||
await loadAllowedTransitionsForAll();
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadTreeData();
|
||||
await loadAllowedTransitionsForAll();
|
||||
await loadHasDispatchedForAll();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleModuleSelect(moduleId: string | undefined) {
|
||||
@@ -653,6 +688,17 @@ function openSplit(row: Api.Product.Requirement) {
|
||||
splitVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleImplementProjectClick(row: Api.Product.Requirement) {
|
||||
if (!row.implementProjectId) return;
|
||||
|
||||
router.push({
|
||||
path: '/project/project/requirement',
|
||||
query: {
|
||||
objectId: row.implementProjectId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function handleActionClick(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
|
||||
const actionCode = action.actionCode as RequirementStatusActionCode;
|
||||
|
||||
@@ -764,6 +810,7 @@ watch(
|
||||
if (id) {
|
||||
await Promise.all([loadMembers(), loadTreeData(), loadProjectOptions()]);
|
||||
await loadAllowedTransitionsForAll();
|
||||
await loadHasDispatchedForAll();
|
||||
} else {
|
||||
memberOptions.value = [];
|
||||
treeData.value = [];
|
||||
@@ -919,6 +966,11 @@ onMounted(async () => {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
:deep(.implement-project-link) {
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@@ -116,9 +116,15 @@ async function handleSubmit() {
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElFormItem :label="`需求名称:${requirementTitle}`"></ElFormItem>
|
||||
<ElAlert
|
||||
v-if="requirementTitle"
|
||||
:title="`需求名称:${requirementTitle}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElFormItem v-if="isClaimAction" label="是否需要评审" prop="reviewChoice">
|
||||
<ElRadioGroup v-model="model.reviewChoice" class="business-form-radio-group">
|
||||
<ElRadio v-for="option in reviewChoiceOptions" :key="option.value" :value="option.value">
|
||||
|
||||
@@ -404,21 +404,9 @@ watch(
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElCol v-if="isViewMode" :span="12">
|
||||
<ElFormItem label="实现项目">
|
||||
<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>
|
||||
<ReadonlyField :value="projectOptionsMap.get(model.implementProjectId || '') || '--'" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetRequirementStatusDict } from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
@@ -33,6 +34,17 @@ const model = defineModel<Api.Product.RequirementSearchParams>('model', { requir
|
||||
|
||||
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
|
||||
const { enabledDictData: sourceTypeDictData } = useDict(RDMS_REQ_SOURCE_TYPE_DICT_CODE);
|
||||
|
||||
const sourceTypeOptions = computed(() => {
|
||||
return sourceTypeDictData.value
|
||||
.filter(item => item.value !== 'product_requirement')
|
||||
.map(item => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}));
|
||||
});
|
||||
|
||||
async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetRequirementStatusDict();
|
||||
|
||||
@@ -112,12 +124,9 @@ onMounted(async () => {
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="需求来源">
|
||||
<DictSelect
|
||||
v-model="model.sourceType"
|
||||
:dict-code="RDMS_REQ_SOURCE_TYPE_DICT_CODE"
|
||||
clearable
|
||||
placeholder="筛选需求来源"
|
||||
/>
|
||||
<ElSelect v-model="model.sourceType" clearable placeholder="筛选需求来源">
|
||||
<ElOption v-for="item in sourceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
|
||||
@@ -149,6 +149,11 @@ watch(
|
||||
model.value.category = props.parentRequirement.category;
|
||||
}
|
||||
|
||||
// 默认选中父需求的负责人
|
||||
if (props.parentRequirement?.currentHandlerUserId) {
|
||||
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,17 @@
|
||||
import { markRaw } from 'vue';
|
||||
import { transformRecordToOption } from '@/utils/common';
|
||||
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 IconMdiShareVariant from '~icons/mdi/share-variant';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiGlasses from '~icons/mdi/glasses';
|
||||
import IconMdiClose from '~icons/mdi/close';
|
||||
import IconTablerSitemap from '~icons/tabler/sitemap';
|
||||
import IconTablerCircleX from '~icons/tabler/circle-x';
|
||||
import IconMaterialSymbolsDescriptionOutline from '~icons/material-symbols/description-outline';
|
||||
|
||||
export type RequirementStatusActionCode =
|
||||
| 'claim_to_review'
|
||||
@@ -34,6 +47,44 @@ export const requirementStatusActionRecord: Record<RequirementStatusActionCode,
|
||||
close: '关闭'
|
||||
};
|
||||
|
||||
/**
|
||||
* 操作按钮图标映射
|
||||
*
|
||||
* 将操作类型映射到对应的 Iconify 图标组件
|
||||
*/
|
||||
export const ACTION_ICON_MAP: Record<string, object> = {
|
||||
split: markRaw(IconTablerSitemap),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
claim_to_review: markRaw(IconMaterialSymbolsDescriptionOutline),
|
||||
claim_to_dispatch: markRaw(IconMdiCheckOutline),
|
||||
to_dispatch: markRaw(IconMdiGlasses),
|
||||
dispatch: markRaw(IconMdiShareVariant),
|
||||
accept: markRaw(IconMdiCheckCircleOutline),
|
||||
reject: markRaw(IconMdiClose),
|
||||
cancel: markRaw(IconTablerCircleX),
|
||||
close: markRaw(IconMdiPowerSettingsNew),
|
||||
delete: markRaw(IconMdiDeleteOutline)
|
||||
};
|
||||
|
||||
/**
|
||||
* 操作按钮颜色类型映射
|
||||
*
|
||||
* 审批/成功类操作 → success,危险操作 → danger,其他 → primary
|
||||
*/
|
||||
export 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'
|
||||
};
|
||||
|
||||
export function getRequirementStatusLabel(status: Api.Product.RequirementStatusCode) {
|
||||
return requirementStatusRecord[status];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,912 @@
|
||||
<script setup lang="ts">
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { ElButton, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
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 {
|
||||
fetchChangeProjectRequirementStatus,
|
||||
fetchDeleteProjectRequirement,
|
||||
fetchGetProjectMembers,
|
||||
fetchGetProjectRequirementAllowedTransitions,
|
||||
fetchGetProjectRequirementStatusDict,
|
||||
fetchGetProjectRequirementTerminalStatusDict,
|
||||
fetchGetProjectRequirementTree
|
||||
} from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import DictTag from '@/components/custom/dict-tag.vue';
|
||||
import DictText from '@/components/custom/dict-text.vue';
|
||||
import { useCurrentProject } from '../../shared/use-current-project';
|
||||
import {
|
||||
DEFAULT_ACTION_ICON,
|
||||
getProjectRequirementActionButtonType,
|
||||
getProjectRequirementActionDisplayName,
|
||||
getProjectRequirementActionIcon,
|
||||
getProjectRequirementStatusTagType,
|
||||
isProjectRequirementActionTerminal
|
||||
} from './shared/requirement-master-data';
|
||||
import RequirementActionDialog from './modules/requirement-action-dialog.vue';
|
||||
import RequirementCreateDialog from './modules/requirement-create-dialog.vue';
|
||||
import RequirementDetailDialog from './modules/requirement-detail-dialog.vue';
|
||||
import RequirementModuleTree from './modules/requirement-module-tree.vue';
|
||||
import RequirementSearch from './modules/requirement-search.vue';
|
||||
import RequirementSplitDialog from './modules/requirement-split-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirement' });
|
||||
|
||||
interface MemberUserOption {
|
||||
id: string;
|
||||
nickname: string;
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
function createSearchParams(): Api.Project.ProjectRequirementSearchParams {
|
||||
return {
|
||||
projectId: '',
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
title: '',
|
||||
moduleId: undefined,
|
||||
parentId: undefined,
|
||||
category: undefined,
|
||||
priority: undefined,
|
||||
statusCode: undefined,
|
||||
currentHandlerUserId: undefined,
|
||||
sourceType: undefined
|
||||
};
|
||||
}
|
||||
|
||||
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
|
||||
0: 'info',
|
||||
1: 'primary',
|
||||
2: 'warning',
|
||||
3: 'danger'
|
||||
};
|
||||
|
||||
const { currentObjectId } = useCurrentProject();
|
||||
const { hasObjectAuth } = useAuth();
|
||||
const { hasValue: canDeleteStatusHasValue } = useDict(RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE);
|
||||
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const terminalStatusOptions = ref<string[]>([]);
|
||||
const memberOptions = ref<Api.Project.ProjectMember[]>([]);
|
||||
const treeData = ref<Api.Project.ProjectRequirement[]>([]);
|
||||
const loading = ref(false);
|
||||
const selectedModuleId = ref<string | undefined>('');
|
||||
const searchParams = reactive(createSearchParams());
|
||||
const pagination = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
total: 0
|
||||
});
|
||||
|
||||
const createVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const detailMode = ref<'view' | 'edit'>('view');
|
||||
const selectedRequirement = ref<Api.Project.ProjectRequirement | null>(null);
|
||||
const splitVisible = ref(false);
|
||||
const splitParentRequirement = ref<Api.Project.ProjectRequirement | null>(null);
|
||||
const actionVisible = ref(false);
|
||||
const actionRequirement = ref<Api.Project.ProjectRequirement | null>(null);
|
||||
const currentAction = ref<Api.Project.ProjectRequirementLifecycleAction | null>(null);
|
||||
const allowedTransitionsMap = ref<Map<string, Api.Project.ProjectRequirementLifecycleAction[]>>(new Map());
|
||||
const columnChecks = ref<UI.TableColumnCheck[]>([]);
|
||||
|
||||
const memberUserOptions = computed<MemberUserOption[]>(() => {
|
||||
return memberOptions.value
|
||||
.filter(item => item.status === 0)
|
||||
.map(item => ({
|
||||
id: item.userId,
|
||||
nickname: item.userNickname,
|
||||
roleName: item.roleName
|
||||
}));
|
||||
});
|
||||
|
||||
const memberLabelMap = computed(() => {
|
||||
return new Map(memberUserOptions.value.map(item => [item.id, item.nickname]));
|
||||
});
|
||||
|
||||
function getMemberLabel(userId?: string | null) {
|
||||
if (!userId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return memberLabelMap.value.get(userId) || userId;
|
||||
}
|
||||
|
||||
function getStatusLabel(statusCode: string) {
|
||||
const item = statusOptions.value.find(option => option.value === statusCode);
|
||||
return item ? item.label : statusCode;
|
||||
}
|
||||
|
||||
function getPriorityTagType(priority?: number | null): UI.ThemeColor {
|
||||
if (priority === null || priority === undefined) {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
return priorityTagTypeMap[priority] || 'info';
|
||||
}
|
||||
|
||||
function isTerminalStatus(statusCode: string) {
|
||||
return terminalStatusOptions.value.includes(statusCode);
|
||||
}
|
||||
|
||||
function canSplitRequirement(row: Api.Project.ProjectRequirement) {
|
||||
return row.statusCode === 'implementing';
|
||||
}
|
||||
|
||||
function canDeleteRequirement(row: Api.Project.ProjectRequirement) {
|
||||
const isStatusAllowed = canDeleteStatusHasValue(row.statusCode);
|
||||
const hasNoChildren = !row.children || row.children.length === 0;
|
||||
return isStatusAllowed && hasNoChildren;
|
||||
}
|
||||
|
||||
function flattenTree(nodes: Api.Project.ProjectRequirement[]): Api.Project.ProjectRequirement[] {
|
||||
const result: Api.Project.ProjectRequirement[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
result.push(node);
|
||||
|
||||
if (node.children?.length) {
|
||||
result.push(...flattenTree(node.children));
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function collectAllRequirementIds(nodes: Api.Project.ProjectRequirement[]): string[] {
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
ids.push(node.id);
|
||||
|
||||
if (node.children?.length) {
|
||||
ids.push(...collectAllRequirementIds(node.children));
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function collectRequirementIdsForActions(nodes: Api.Project.ProjectRequirement[]): string[] {
|
||||
const ids: string[] = [];
|
||||
|
||||
for (const node of nodes) {
|
||||
if (!isTerminalStatus(node.statusCode)) {
|
||||
ids.push(node.id);
|
||||
}
|
||||
|
||||
if (node.children?.length) {
|
||||
ids.push(...collectRequirementIdsForActions(node.children));
|
||||
}
|
||||
}
|
||||
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getRowActions(row: Api.Project.ProjectRequirement) {
|
||||
return allowedTransitionsMap.value.get(row.id) || [];
|
||||
}
|
||||
|
||||
function buildRequirementActions(row: Api.Project.ProjectRequirement) {
|
||||
const actions: Array<{
|
||||
key: string;
|
||||
label: string;
|
||||
icon: object;
|
||||
type: 'primary' | 'success' | 'danger';
|
||||
onClick: () => void;
|
||||
}> = [];
|
||||
const hasUpdateAuth = hasObjectAuth('project:project:update');
|
||||
const hasDeleteAuth = hasObjectAuth('project:project:delete');
|
||||
const hasStatusAuth = hasObjectAuth('project:project:status');
|
||||
const hasSplitAuth = hasObjectAuth('project:project:split');
|
||||
|
||||
if (canSplitRequirement(row) && hasSplitAuth) {
|
||||
actions.push({
|
||||
key: 'split',
|
||||
label: '拆分',
|
||||
icon: getProjectRequirementActionIcon('split'),
|
||||
type: getProjectRequirementActionButtonType('split'),
|
||||
onClick: () => openSplit(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasUpdateAuth && !isTerminalStatus(row.statusCode) && row.statusCode !== 'accepted') {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: getProjectRequirementActionIcon('edit'),
|
||||
type: getProjectRequirementActionButtonType('edit'),
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasStatusAuth) {
|
||||
const lifecycleActions = getRowActions(row);
|
||||
const nonTerminalActions: Api.Project.ProjectRequirementLifecycleAction[] = [];
|
||||
const terminalActions: Api.Project.ProjectRequirementLifecycleAction[] = [];
|
||||
|
||||
lifecycleActions.forEach(action => {
|
||||
if (isProjectRequirementActionTerminal(action.actionCode)) {
|
||||
terminalActions.push(action);
|
||||
} else {
|
||||
nonTerminalActions.push(action);
|
||||
}
|
||||
});
|
||||
|
||||
[...nonTerminalActions, ...terminalActions].forEach(action => {
|
||||
actions.push({
|
||||
key: `action-${action.actionCode}`,
|
||||
label: getProjectRequirementActionDisplayName(action),
|
||||
icon: getProjectRequirementActionIcon(action.actionCode) ?? DEFAULT_ACTION_ICON,
|
||||
type: getProjectRequirementActionButtonType(action.actionCode),
|
||||
onClick: () => handleActionClick(row, action)
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (hasDeleteAuth && canDeleteRequirement(row)) {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: getProjectRequirementActionIcon('delete'),
|
||||
type: getProjectRequirementActionButtonType('delete'),
|
||||
onClick: () => handleDelete(row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
const columns = computed(() => [
|
||||
{
|
||||
type: 'index',
|
||||
label: '序号',
|
||||
width: 64,
|
||||
align: 'center',
|
||||
index: (index: number): number => {
|
||||
const flatList = flattenTree(treeData.value);
|
||||
const row = flatList[index];
|
||||
|
||||
if (!row || row.parentId !== '0') {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const parentIndex = treeData.value.findIndex(item => item.id === row.id);
|
||||
return parentIndex >= 0 ? (pagination.pageNo - 1) * pagination.pageSize + parentIndex + 1 : 0;
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'title',
|
||||
label: '需求名称',
|
||||
minWidth: 220,
|
||||
formatter: (row: Api.Project.ProjectRequirement) => (
|
||||
<ElButton link type="primary" class="requirement-title" onClick={() => openView(row)}>
|
||||
{row.title}
|
||||
</ElButton>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'priority',
|
||||
label: '优先级',
|
||||
width: 88,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Project.ProjectRequirement) => (
|
||||
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Project.ProjectRequirement) => (
|
||||
<ElTag type={getProjectRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'workHours',
|
||||
label: '工时',
|
||||
width: 88,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Project.ProjectRequirement) => (row.workHours !== null ? `${row.workHours}h` : '--')
|
||||
},
|
||||
{
|
||||
prop: 'category',
|
||||
label: '需求类型',
|
||||
minWidth: 120,
|
||||
formatter: (row: Api.Project.ProjectRequirement) => row.categoryName || row.category || '--'
|
||||
},
|
||||
{
|
||||
prop: 'sourceType',
|
||||
label: '需求来源',
|
||||
minWidth: 110,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Project.ProjectRequirement) => (
|
||||
<DictText dictCode={RDMS_REQ_SOURCE_TYPE_DICT_CODE} value={row.sourceType} />
|
||||
)
|
||||
},
|
||||
{
|
||||
prop: 'proposerNickname',
|
||||
label: '提出人',
|
||||
minWidth: 90,
|
||||
formatter: (row: Api.Project.ProjectRequirement) => row.proposerNickname || '--'
|
||||
},
|
||||
{
|
||||
prop: 'currentHandlerUserId',
|
||||
label: '负责人',
|
||||
minWidth: 90,
|
||||
formatter: (row: Api.Project.ProjectRequirement) => getMemberLabel(row.currentHandlerUserId)
|
||||
},
|
||||
{
|
||||
prop: 'sourceBizId',
|
||||
label: '来源业务编号',
|
||||
minWidth: 140,
|
||||
formatter: (row: Api.Project.ProjectRequirement) => {
|
||||
if (!row.sourceBizId || row.sourceType === 'manual') {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return row.sourceBizId;
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'createTime',
|
||||
label: '创建时间',
|
||||
minWidth: 180,
|
||||
formatter: (row: Api.Project.ProjectRequirement) => formatDateTime(row.createTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 200,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: (row: Api.Project.ProjectRequirement) => (
|
||||
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{buildRequirementActions(row).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>
|
||||
)
|
||||
}
|
||||
]);
|
||||
|
||||
watch(
|
||||
() => columns.value,
|
||||
value => {
|
||||
const existingMap = new Map(columnChecks.value.map(item => [item.prop, item.checked]));
|
||||
|
||||
columnChecks.value = value
|
||||
.filter(column => column.prop && column.prop !== 'operate')
|
||||
.map(column => ({
|
||||
prop: String(column.prop),
|
||||
label: String(column.label || ''),
|
||||
checked: existingMap.has(String(column.prop)) ? existingMap.get(String(column.prop))! : true,
|
||||
visible: true
|
||||
}));
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
const visibleColumns = computed(() => {
|
||||
if (!columnChecks.value.length) {
|
||||
return columns.value;
|
||||
}
|
||||
|
||||
const visibleSet = new Set(columnChecks.value.filter(item => item.checked).map(item => item.prop));
|
||||
|
||||
return columns.value.filter(column => {
|
||||
const prop = String(column.prop || '');
|
||||
|
||||
if (!prop || prop === 'operate') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return visibleSet.has(prop);
|
||||
});
|
||||
});
|
||||
|
||||
async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetProjectRequirementStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
statusOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
statusOptions.value = data.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTerminalStatusOptions() {
|
||||
const { error, data } = await fetchGetProjectRequirementTerminalStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
terminalStatusOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
terminalStatusOptions.value = data.map(item => item.statusCode);
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
if (!currentObjectId.value) {
|
||||
memberOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectMembers(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
memberOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
memberOptions.value = data;
|
||||
}
|
||||
|
||||
async function loadTreeData() {
|
||||
if (!currentObjectId.value) {
|
||||
treeData.value = [];
|
||||
pagination.total = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectRequirementTree({
|
||||
projectId: currentObjectId.value,
|
||||
moduleId: selectedModuleId.value,
|
||||
pageNo: pagination.pageNo,
|
||||
pageSize: pagination.pageSize,
|
||||
title: searchParams.title?.trim() || undefined,
|
||||
category: searchParams.category,
|
||||
priority: searchParams.priority,
|
||||
statusCode: searchParams.statusCode,
|
||||
currentHandlerUserId: searchParams.currentHandlerUserId,
|
||||
sourceType: searchParams.sourceType
|
||||
});
|
||||
|
||||
if (error || !data) {
|
||||
treeData.value = [];
|
||||
pagination.total = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
treeData.value = data.list;
|
||||
pagination.total = data.total;
|
||||
}
|
||||
|
||||
async function loadAllowedTransitionsForAll() {
|
||||
if (!currentObjectId.value) {
|
||||
allowedTransitionsMap.value = new Map();
|
||||
return;
|
||||
}
|
||||
|
||||
const idsToQuery = collectRequirementIdsForActions(treeData.value);
|
||||
const nextMap = new Map<string, Api.Project.ProjectRequirementLifecycleAction[]>();
|
||||
|
||||
if (idsToQuery.length === 0) {
|
||||
allowedTransitionsMap.value = nextMap;
|
||||
return;
|
||||
}
|
||||
|
||||
const results = await Promise.all(
|
||||
idsToQuery.map(async id => {
|
||||
const { error, data } = await fetchGetProjectRequirementAllowedTransitions(id, currentObjectId.value!);
|
||||
return { id, actions: error ? [] : data || [] };
|
||||
})
|
||||
);
|
||||
|
||||
results.forEach(({ id, actions }) => {
|
||||
nextMap.set(id, actions);
|
||||
});
|
||||
|
||||
allowedTransitionsMap.value = nextMap;
|
||||
}
|
||||
|
||||
async function reloadTable() {
|
||||
loading.value = true;
|
||||
|
||||
try {
|
||||
await loadTreeData();
|
||||
await loadAllowedTransitionsForAll();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleModuleSelect(moduleId: string | undefined) {
|
||||
selectedModuleId.value = moduleId;
|
||||
pagination.pageNo = 1;
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
pagination.pageNo = 1;
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function handleResetSearch() {
|
||||
Object.assign(searchParams, createSearchParams(), {
|
||||
projectId: currentObjectId.value || '',
|
||||
pageSize: pagination.pageSize
|
||||
});
|
||||
pagination.pageNo = 1;
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pagination.pageNo = page;
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function handleSizeChange(size: number) {
|
||||
pagination.pageNo = 1;
|
||||
pagination.pageSize = size;
|
||||
reloadTable();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
selectedRequirement.value = null;
|
||||
createVisible.value = true;
|
||||
}
|
||||
|
||||
function openView(row: Api.Project.ProjectRequirement) {
|
||||
selectedRequirement.value = row;
|
||||
detailMode.value = 'view';
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.Project.ProjectRequirement) {
|
||||
selectedRequirement.value = row;
|
||||
detailMode.value = 'edit';
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function openSplit(row: Api.Project.ProjectRequirement) {
|
||||
splitParentRequirement.value = row;
|
||||
splitVisible.value = true;
|
||||
}
|
||||
|
||||
function handleActionClick(row: Api.Project.ProjectRequirement, action: Api.Project.ProjectRequirementLifecycleAction) {
|
||||
if (!action.needReason) {
|
||||
handleDirectAction(row, action);
|
||||
return;
|
||||
}
|
||||
|
||||
actionRequirement.value = row;
|
||||
currentAction.value = action;
|
||||
actionVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDirectAction(
|
||||
row: Api.Project.ProjectRequirement,
|
||||
action: Api.Project.ProjectRequirementLifecycleAction
|
||||
) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.$messageBox?.confirm(`确定要执行“${action.actionName}”操作吗?`, '确认操作', {
|
||||
confirmButtonText: '确认',
|
||||
cancelButtonText: '取消',
|
||||
type: 'info'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchChangeProjectRequirementStatus({
|
||||
id: row.id,
|
||||
projectId: currentObjectId.value,
|
||||
actionCode: action.actionCode
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(`${action.actionName}成功`);
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleActionSubmitted(payload: { actionCode: string; reason?: string }) {
|
||||
if (!currentObjectId.value || !actionRequirement.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchChangeProjectRequirementStatus({
|
||||
id: actionRequirement.value.id,
|
||||
projectId: currentObjectId.value,
|
||||
actionCode: payload.actionCode,
|
||||
reason: payload.reason
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('操作成功');
|
||||
actionVisible.value = false;
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.Project.ProjectRequirement) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.$messageBox?.confirm('确定要删除该需求吗?删除后不可恢复。', '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteProjectRequirement({
|
||||
id: row.id,
|
||||
projectId: currentObjectId.value
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('需求删除成功');
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleCreateSubmitted() {
|
||||
createVisible.value = false;
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleDetailSubmitted() {
|
||||
detailVisible.value = false;
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleSplitSubmitted() {
|
||||
splitVisible.value = false;
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async id => {
|
||||
Object.assign(searchParams, createSearchParams(), {
|
||||
projectId: id || '',
|
||||
pageSize: pagination.pageSize
|
||||
});
|
||||
selectedModuleId.value = '';
|
||||
pagination.pageNo = 1;
|
||||
|
||||
if (!id) {
|
||||
memberOptions.value = [];
|
||||
treeData.value = [];
|
||||
allowedTransitionsMap.value = new Map();
|
||||
pagination.total = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadMembers(), loadTreeData()]);
|
||||
await loadAllowedTransitionsForAll();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="p-16px">
|
||||
<ElEmpty description="需求池功能开发中..." />
|
||||
<div v-if="currentObjectId" class="project-requirement-page">
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RequirementModuleTree :requirement-tree="treeData" @select="handleModuleSelect" @refresh="reloadTable" />
|
||||
</div>
|
||||
|
||||
<div class="flex-col-stretch gap-16px xl:min-h-0">
|
||||
<RequirementSearch
|
||||
v-model:model="searchParams"
|
||||
:member-options="memberUserOptions"
|
||||
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
|
||||
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
@reset="handleResetSearch"
|
||||
@search="handleSearch"
|
||||
/>
|
||||
|
||||
<ElCard class="card-wrapper xl:flex-1-hidden" body-class="requirement-table-card-body">
|
||||
<template #header>
|
||||
<div class="project-requirement-card-header">
|
||||
<div class="min-w-0 flex-1">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p class="truncate text-16px font-600">项目需求列表</p>
|
||||
<ElTag effect="plain">{{ pagination.total }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
<ElButton
|
||||
v-auth="{ code: 'project:project:create', source: 'object' }"
|
||||
plain
|
||||
type="primary"
|
||||
@click="openCreate"
|
||||
>
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
border
|
||||
lazy
|
||||
row-key="id"
|
||||
:indent="32"
|
||||
height="100%"
|
||||
:data="treeData"
|
||||
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
|
||||
>
|
||||
<ElTableColumn v-for="column in visibleColumns" :key="String(column.prop || 'index')" v-bind="column" />
|
||||
|
||||
<template #empty>
|
||||
<ElEmpty description="当前筛选条件下暂无项目需求" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-16px flex justify-end">
|
||||
<ElPagination
|
||||
v-model:current-page="pagination.pageNo"
|
||||
v-model:page-size="pagination.pageSize"
|
||||
:total="pagination.total"
|
||||
:page-sizes="[10, 15, 20, 25, 30]"
|
||||
layout="total, sizes, prev, pager, next, jumper"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
</div>
|
||||
|
||||
<RequirementCreateDialog
|
||||
v-model:visible="createVisible"
|
||||
:project-id="currentObjectId || ''"
|
||||
:default-module-id="selectedModuleId"
|
||||
:member-options="memberOptions"
|
||||
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
|
||||
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
@submitted="handleCreateSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementDetailDialog
|
||||
v-model:visible="detailVisible"
|
||||
:mode="detailMode"
|
||||
:requirement="selectedRequirement"
|
||||
:project-id="currentObjectId || ''"
|
||||
:member-options="memberOptions"
|
||||
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
|
||||
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
@submitted="handleDetailSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementSplitDialog
|
||||
v-model:visible="splitVisible"
|
||||
:parent-requirement="splitParentRequirement"
|
||||
:project-id="currentObjectId || ''"
|
||||
:member-options="memberOptions"
|
||||
:category-dict-code="RDMS_REQ_CATEGORY_DICT_CODE"
|
||||
:priority-dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
@submitted="handleSplitSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementActionDialog
|
||||
v-model:visible="actionVisible"
|
||||
:action="currentAction"
|
||||
:requirement-title="actionRequirement?.title || ''"
|
||||
@submitted="handleActionSubmitted"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="未获取到当前项目上下文,请返回项目列表重新选择项目" />
|
||||
</template>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.project-requirement-page {
|
||||
display: grid;
|
||||
min-height: 560px;
|
||||
gap: 16px;
|
||||
overflow: hidden;
|
||||
grid-template-columns: 280px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.project-requirement-card-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
:deep(.requirement-title) {
|
||||
padding: 0;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
:deep(.requirement-table-card-body) {
|
||||
height: calc(100% - 56px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
: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);
|
||||
}
|
||||
|
||||
:deep(.el-table__row[class*='el-table__row--level-']:not(.el-table__row--level-0) td:first-child .cell) {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.project-requirement-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.project-requirement-card-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
import { ElTag } from 'element-plus';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementMemberSelectOption' });
|
||||
|
||||
interface Props {
|
||||
nickname: string;
|
||||
roleName: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="member-select-option">
|
||||
<span class="member-select-option__name">{{ nickname }}</span>
|
||||
<ElTag type="info" size="small" class="member-select-option__role">{{ roleName }}</ElTag>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.member-select-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.member-select-option__name {
|
||||
flex: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.member-select-option__role {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,332 @@
|
||||
<script setup lang="ts">
|
||||
import { type Ref, computed, inject, ref } from 'vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementModuleTreeNode' });
|
||||
|
||||
interface Props {
|
||||
module: Api.Project.ProjectRequirementModule;
|
||||
level?: number;
|
||||
selectedModuleId?: string;
|
||||
editingNodeId?: string;
|
||||
editingName?: string;
|
||||
addingChildParentId?: string;
|
||||
newChildModuleName?: string;
|
||||
rootModuleId?: string;
|
||||
moduleRequirementCountMap?: Map<string, number>;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
level: 0
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
'select',
|
||||
'edit',
|
||||
'editConfirm',
|
||||
'editCancel',
|
||||
'delete',
|
||||
'addChild',
|
||||
'addChildConfirm',
|
||||
'addChildCancel',
|
||||
'updateEditingName',
|
||||
'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(() => Boolean(props.module.children?.length));
|
||||
const isCollapsed = computed(() =>
|
||||
hasChildren.value && props.module.id ? collapsedModuleIds.value.has(props.module.id) : false
|
||||
);
|
||||
|
||||
const hasRequirements = computed(() => {
|
||||
const moduleId = props.module.id;
|
||||
|
||||
if (!moduleId || !props.moduleRequirementCountMap) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (props.moduleRequirementCountMap.get(moduleId) || 0) > 0;
|
||||
});
|
||||
|
||||
const canDeleteModule = computed(() => !hasChildren.value && !hasRequirements.value);
|
||||
|
||||
const indentStyle = computed(() => {
|
||||
if (props.level === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const indent = 24 + (props.level - 1) * 24;
|
||||
|
||||
return {
|
||||
width: `calc(100% - ${indent}px)`,
|
||||
marginLeft: `${indent}px`
|
||||
};
|
||||
});
|
||||
|
||||
function handleClick() {
|
||||
if (props.editingNodeId || props.addingChildParentId) {
|
||||
return;
|
||||
}
|
||||
|
||||
emit('select', props.module.id);
|
||||
}
|
||||
|
||||
function handleToggle() {
|
||||
if (props.module.id) {
|
||||
toggleCollapse(props.module.id);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="module-tree-node">
|
||||
<div
|
||||
class="module-tree-item"
|
||||
:class="{
|
||||
'is-root': isRootModule,
|
||||
'is-active': isSelected,
|
||||
'is-editing': isEditing
|
||||
}"
|
||||
: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" />
|
||||
</div>
|
||||
<div class="module-tree-item__content">
|
||||
<span v-if="!isEditing" class="module-tree-item__label">{{ module.moduleName }}</span>
|
||||
<ElInput
|
||||
v-else
|
||||
:model-value="editingName"
|
||||
size="small"
|
||||
class="module-tree-item__input"
|
||||
placeholder="请输入模块名"
|
||||
@update:model-value="emit('updateEditingName', $event)"
|
||||
@blur="emit('editConfirm', module)"
|
||||
@keyup.enter="emit('editConfirm', module)"
|
||||
@keyup.esc="emit('editCancel')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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" />
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem
|
||||
v-auth="{ code: 'project:project:create', source: 'object' }"
|
||||
@click="emit('addChild', module)"
|
||||
>
|
||||
<div class="flex items-center gap-6px">
|
||||
<icon-ic-round-plus class="text-14px" />
|
||||
<span>新增子模块</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="!isRootModule"
|
||||
v-auth="{ code: 'project:project:update', source: 'object' }"
|
||||
@click="emit('edit', module)"
|
||||
>
|
||||
<div class="flex items-center gap-6px">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="!isRootModule && canDeleteModule"
|
||||
v-auth="{ code: 'project:project:delete', source: 'object' }"
|
||||
divided
|
||||
@click="emit('delete', module)"
|
||||
>
|
||||
<div class="flex items-center gap-6px text-error">
|
||||
<icon-mdi-delete-outline class="text-14px" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-if="hasChildren && !isCollapsed">
|
||||
<ModuleTreeNode
|
||||
v-for="child in module.children"
|
||||
:key="child.id"
|
||||
:module="child"
|
||||
:level="level + 1"
|
||||
:selected-module-id="selectedModuleId"
|
||||
:editing-node-id="editingNodeId"
|
||||
:editing-name="editingName"
|
||||
:adding-child-parent-id="addingChildParentId"
|
||||
:new-child-module-name="newChildModuleName"
|
||||
:root-module-id="rootModuleId"
|
||||
:module-requirement-count-map="moduleRequirementCountMap"
|
||||
@select="emit('select', $event)"
|
||||
@edit="emit('edit', $event)"
|
||||
@edit-confirm="emit('editConfirm', $event)"
|
||||
@edit-cancel="emit('editCancel')"
|
||||
@delete="emit('delete', $event)"
|
||||
@add-child="emit('addChild', $event)"
|
||||
@add-child-confirm="emit('addChildConfirm')"
|
||||
@add-child-cancel="emit('addChildCancel')"
|
||||
@update-editing-name="emit('updateEditingName', $event)"
|
||||
@update-new-child-module-name="emit('updateNewChildModuleName', $event)"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<div
|
||||
v-if="isAddingChild"
|
||||
class="module-tree-item module-tree-item--new"
|
||||
:style="{
|
||||
width: indentStyle.width,
|
||||
marginLeft: level === 0 ? '24px' : `calc(24px + ${level * 24}px)`
|
||||
}"
|
||||
>
|
||||
<div class="module-tree-item__icon">
|
||||
<icon-mdi-folder-plus-outline class="text-16px" />
|
||||
</div>
|
||||
<div class="module-tree-item__content">
|
||||
<ElInput
|
||||
:model-value="newChildModuleName"
|
||||
size="small"
|
||||
class="new-child-module-input module-tree-item__input"
|
||||
placeholder="请输入模块名"
|
||||
@update:model-value="emit('updateNewChildModuleName', $event)"
|
||||
@blur="emit('addChildConfirm')"
|
||||
@keyup.enter="emit('addChildConfirm')"
|
||||
@keyup.esc="emit('addChildCancel')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.module-tree-node {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.module-tree-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
min-height: 42px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 14px;
|
||||
background-color: rgb(248 250 252 / 96%);
|
||||
color: rgb(71 85 105 / 94%);
|
||||
font-size: 14px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.2s ease,
|
||||
background-color 0.2s ease,
|
||||
color 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.module-tree-item:hover {
|
||||
transform: translateY(-1px);
|
||||
border-color: rgb(148 163 184 / 56%);
|
||||
}
|
||||
|
||||
.module-tree-item.is-active {
|
||||
border-color: rgb(13 148 136 / 42%);
|
||||
background-color: rgb(240 253 250 / 98%);
|
||||
color: rgb(15 118 110 / 96%);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.module-tree-item.is-root:not(.is-active) .module-tree-item__icon {
|
||||
color: rgb(13 148 136 / 80%);
|
||||
}
|
||||
|
||||
.module-tree-item--new {
|
||||
border-style: dashed;
|
||||
border-color: rgb(148 163 184 / 56%);
|
||||
}
|
||||
|
||||
.module-tree-item__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
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;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.module-tree-item__label {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.module-tree-item__input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.module-tree-item__input :deep(.el-input__inner) {
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.module-tree-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.module-tree-item:hover .module-tree-item__actions {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.module-tree-item.is-editing .module-tree-item__actions {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.module-tree-item__more-btn {
|
||||
padding: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementActionDialog' });
|
||||
|
||||
interface Props {
|
||||
action: Api.Project.ProjectRequirementLifecycleAction | null;
|
||||
requirementTitle: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', payload: { actionCode: string; reason?: string }): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const model = ref({
|
||||
reason: ''
|
||||
});
|
||||
const submitting = ref(false);
|
||||
|
||||
const dialogTitle = computed(() => props.action?.actionName || '');
|
||||
const needReason = computed(() => Boolean(props.action?.needReason));
|
||||
|
||||
const rules = computed(() => ({
|
||||
reason: needReason.value ? [createRequiredRule('请输入状态变更原因')] : []
|
||||
}));
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
model.value.reason = '';
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.action) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
emit('submitted', {
|
||||
actionCode: props.action.actionCode,
|
||||
reason: needReason.value ? model.value.reason.trim() : undefined
|
||||
});
|
||||
|
||||
submitting.value = false;
|
||||
visible.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="sm"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="requirementTitle"
|
||||
:title="`需求名称:${requirementTitle}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElFormItem v-if="needReason" label="变更原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入状态变更原因(必填)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { fetchCreateProjectRequirement, fetchGetProjectRequirementModuleTree } 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 BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementCreateDialog' });
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
defaultModuleId?: string;
|
||||
memberOptions: Api.Project.ProjectMember[];
|
||||
categoryDictCode: string;
|
||||
priorityDictCode: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
proposerId: string;
|
||||
currentHandlerUserId: string;
|
||||
workHours: number | null;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
|
||||
const submitting = ref(false);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
|
||||
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
function walk(modules: Api.Project.ProjectRequirementModule[], 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) {
|
||||
walk(moduleTree.value, '');
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
{ label: '需要', value: 1 }
|
||||
];
|
||||
|
||||
const rules = {
|
||||
title: [createRequiredRule('请输入需求名称')],
|
||||
category: [createRequiredRule('请选择需求类型')],
|
||||
priority: [createRequiredRule('请选择优先级')],
|
||||
proposerId: [createRequiredRule('请选择提出人')],
|
||||
currentHandlerUserId: [createRequiredRule('请选择负责人')],
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
reviewRequired: 0,
|
||||
moduleId: props.defaultModuleId || '0',
|
||||
category: '功能需求',
|
||||
priority: 1,
|
||||
proposerId: '',
|
||||
currentHandlerUserId: '',
|
||||
workHours: null,
|
||||
sort: 0
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!props.projectId) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectRequirementModuleTree(props.projectId);
|
||||
|
||||
if (error || !data) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
moduleTree.value = data;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.projectId) {
|
||||
return;
|
||||
}
|
||||
|
||||
const proposer = memberUserOptions.value.find(item => item.userId === model.value.proposerId);
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
const payload: Api.Project.SaveProjectRequirementParams = {
|
||||
projectId: props.projectId,
|
||||
moduleId: model.value.moduleId || '0',
|
||||
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
proposerNickname: proposer?.userNickname || '',
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname: handler?.userNickname || '',
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const result = await fetchCreateProjectRequirement(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('项目需求新增成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
await loadModuleTree();
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="新增项目需求"
|
||||
preset="lg"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<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="模块">
|
||||
<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="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
|
||||
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</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="请选择提出人">
|
||||
<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>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ElSelect v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
|
||||
<ElOption
|
||||
v-for="item in memberUserOptions"
|
||||
:key="item.userId"
|
||||
:label="item.userNickname"
|
||||
:value="item.userId"
|
||||
>
|
||||
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,390 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import {
|
||||
fetchGetProjectRequirement,
|
||||
fetchGetProjectRequirementModuleTree,
|
||||
fetchUpdateProjectRequirement
|
||||
} 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 BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementDetailDialog' });
|
||||
|
||||
type DialogMode = 'view' | 'edit';
|
||||
|
||||
interface Props {
|
||||
mode: DialogMode;
|
||||
requirement: Api.Project.ProjectRequirement | null;
|
||||
projectId: string;
|
||||
memberOptions: Api.Project.ProjectMember[];
|
||||
categoryDictCode: string;
|
||||
priorityDictCode: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted', requirementId?: string): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
|
||||
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
proposerId: string;
|
||||
proposerNickname: string;
|
||||
currentHandlerUserId: string;
|
||||
currentHandlerUserNickname: string;
|
||||
workHours: number | null;
|
||||
sort: number;
|
||||
lastStatusReason: string;
|
||||
}
|
||||
|
||||
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const isViewMode = computed(() => props.mode === 'view');
|
||||
const isEditMode = computed(() => props.mode === 'edit');
|
||||
const dialogTitle = computed(() => (isViewMode.value ? '查看项目需求' : '编辑项目需求'));
|
||||
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
|
||||
|
||||
const memberLabelMap = computed(() => {
|
||||
return new Map(memberUserOptions.value.map(item => [item.userId, item.userNickname]));
|
||||
});
|
||||
|
||||
const moduleLabelMap = computed(() => {
|
||||
const map = new Map<string | undefined, string>();
|
||||
|
||||
function walk(modules: Api.Project.ProjectRequirementModule[]) {
|
||||
for (const module of modules) {
|
||||
map.set(module.id, module.moduleName);
|
||||
|
||||
if (module.children?.length) {
|
||||
walk(module.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walk(moduleTree.value);
|
||||
return map;
|
||||
});
|
||||
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
function walk(modules: Api.Project.ProjectRequirementModule[], 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) {
|
||||
walk(moduleTree.value, '');
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
{ label: '需要', value: 1 }
|
||||
];
|
||||
|
||||
const rules = computed(() => ({
|
||||
title: isEditMode.value ? [createRequiredRule('请输入需求名称')] : [],
|
||||
category: isEditMode.value ? [createRequiredRule('请选择需求类型')] : [],
|
||||
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
|
||||
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
|
||||
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
|
||||
}));
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
reviewRequired: 0,
|
||||
moduleId: '0',
|
||||
category: '',
|
||||
priority: 1,
|
||||
proposerId: '',
|
||||
proposerNickname: '',
|
||||
currentHandlerUserId: '',
|
||||
currentHandlerUserNickname: '',
|
||||
workHours: null,
|
||||
sort: 0,
|
||||
lastStatusReason: ''
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!props.projectId) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectRequirementModuleTree(props.projectId);
|
||||
|
||||
if (error || !data) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
moduleTree.value = data;
|
||||
}
|
||||
|
||||
async function loadRequirementDetail() {
|
||||
if (!props.projectId || !props.requirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProjectRequirement(props.requirement.id, props.projectId);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = {
|
||||
title: data.title || '',
|
||||
description: data.description || '',
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
moduleId: data.moduleId || '0',
|
||||
category: data.category || '',
|
||||
priority: data.priority ?? null,
|
||||
proposerId: data.proposerId || '',
|
||||
proposerNickname: data.proposerNickname || '',
|
||||
currentHandlerUserId: data.currentHandlerUserId || '',
|
||||
currentHandlerUserNickname: data.currentHandlerUserNickname || '',
|
||||
workHours: data.workHours ?? null,
|
||||
sort: data.sort ?? 0,
|
||||
lastStatusReason: data.lastStatusReason || ''
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.projectId || !props.requirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const payload: Api.Project.UpdateProjectRequirementParams = {
|
||||
id: props.requirement.id,
|
||||
projectId: props.projectId,
|
||||
moduleId: model.value.moduleId || '0',
|
||||
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
|
||||
proposerId: model.value.proposerId,
|
||||
proposerNickname: model.value.proposerNickname,
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname: model.value.currentHandlerUserNickname,
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
};
|
||||
|
||||
const { error } = await fetchUpdateProjectRequirement(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('项目需求更新成功');
|
||||
visible.value = false;
|
||||
emit('submitted', props.requirement.id);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
await loadModuleTree();
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="dialogTitle"
|
||||
preset="lg"
|
||||
:loading="loading"
|
||||
:confirm-loading="submitting"
|
||||
:show-footer="isEditMode"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="需求名称" prop="title">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.title" />
|
||||
<ElInput v-else v-model="model.title" clearable maxlength="256" placeholder="请输入需求名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="模块">
|
||||
<ReadonlyField v-if="isViewMode" :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
<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="内容">
|
||||
<div v-if="isViewMode" class="readonly-textarea" v-html="model.description || '--'"></div>
|
||||
<BusinessRichTextEditor
|
||||
v-else
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="是否需要评审">
|
||||
<ReadonlyField
|
||||
:value="reviewRequiredOptions.find(item => item.value === model.reviewRequired)?.label || '--'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ReadonlyField
|
||||
v-if="isViewMode"
|
||||
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
|
||||
/>
|
||||
<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" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="所需工时">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.workHours != null ? `${model.workHours}小时` : '--'" />
|
||||
<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">
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
<ReadonlyField v-if="isViewMode" :value="memberLabelMap.get(model.currentHandlerUserId) || '--'" />
|
||||
<ElSelect v-else v-model="model.currentHandlerUserId" class="w-full" filterable placeholder="请选择负责人">
|
||||
<ElOption
|
||||
v-for="item in memberUserOptions"
|
||||
:key="item.userId"
|
||||
:label="item.userNickname"
|
||||
:value="item.userId"
|
||||
>
|
||||
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="排序值">
|
||||
<ReadonlyField v-if="isViewMode" :value="model.sort" />
|
||||
<ElInputNumber v-else v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol v-if="isViewMode && model.lastStatusReason" :span="24">
|
||||
<ElFormItem label="状态变更原因">
|
||||
<div class="readonly-textarea">{{ model.lastStatusReason }}</div>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.readonly-textarea {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
min-height: 100px;
|
||||
padding: 8px 12px;
|
||||
border-radius: 4px;
|
||||
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
color: rgb(51 65 85 / 96%);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, provide, ref, watch } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
fetchCreateProjectRequirementModule,
|
||||
fetchDeleteProjectRequirementModule,
|
||||
fetchGetProjectRequirementModuleTree,
|
||||
fetchUpdateProjectRequirementModule
|
||||
} from '@/service/api';
|
||||
import { useCurrentProject } from '../../../shared/use-current-project';
|
||||
import ModuleTreeNode from './module-tree-node.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementModuleTree' });
|
||||
|
||||
interface Props {
|
||||
requirementTree?: Api.Project.ProjectRequirement[];
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
requirementTree: () => []
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', moduleId: string | undefined): void;
|
||||
(e: 'refresh'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const { currentObjectId } = useCurrentProject();
|
||||
|
||||
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
|
||||
const selectedModuleId = ref<string | undefined>(undefined);
|
||||
const editingNodeId = ref<string | undefined>(undefined);
|
||||
const editingName = ref('');
|
||||
const addingChildParentId = ref<string | undefined>(undefined);
|
||||
const newChildModuleName = ref('');
|
||||
const collapsedModuleIds = ref(new Set<string>());
|
||||
|
||||
provide('collapsedModuleIds', collapsedModuleIds);
|
||||
provide('toggleCollapse', (moduleId: string) => {
|
||||
const nextSet = new Set(collapsedModuleIds.value);
|
||||
|
||||
if (nextSet.has(moduleId)) {
|
||||
nextSet.delete(moduleId);
|
||||
} else {
|
||||
nextSet.add(moduleId);
|
||||
}
|
||||
|
||||
collapsedModuleIds.value = nextSet;
|
||||
});
|
||||
|
||||
const rootModule = computed<Api.Project.ProjectRequirementModule | null>(() => {
|
||||
return moduleTree.value.length ? moduleTree.value[0] : null;
|
||||
});
|
||||
|
||||
const moduleRequirementCountMap = computed(() => {
|
||||
const countMap = new Map<string, number>();
|
||||
|
||||
function countRequirementsByModule(nodes: Api.Project.ProjectRequirement[]) {
|
||||
for (const node of nodes) {
|
||||
countMap.set(node.moduleId, (countMap.get(node.moduleId) || 0) + 1);
|
||||
|
||||
if (node.children?.length) {
|
||||
countRequirementsByModule(node.children);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (props.requirementTree.length) {
|
||||
countRequirementsByModule(props.requirementTree);
|
||||
}
|
||||
|
||||
return countMap;
|
||||
});
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!currentObjectId.value) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectRequirementModuleTree(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
moduleTree.value = data;
|
||||
|
||||
if (data.length > 0 && !selectedModuleId.value) {
|
||||
selectedModuleId.value = data[0].id;
|
||||
emit('select', data[0].id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleNodeSelect(moduleId: string) {
|
||||
if (editingNodeId.value || addingChildParentId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModuleId.value = moduleId;
|
||||
emit('select', moduleId);
|
||||
}
|
||||
|
||||
function handleStartEdit(module: Api.Project.ProjectRequirementModule) {
|
||||
editingNodeId.value = module.id;
|
||||
editingName.value = module.moduleName;
|
||||
|
||||
nextTick(() => {
|
||||
const input = document.querySelector('.module-tree-item.is-editing .el-input__inner') as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
input?.select();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleEditConfirm(module: Api.Project.ProjectRequirementModule) {
|
||||
const moduleName = editingName.value.trim();
|
||||
editingNodeId.value = undefined;
|
||||
|
||||
if (!moduleName || moduleName === module.moduleName || !currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchUpdateProjectRequirementModule({
|
||||
id: module.id,
|
||||
projectId: currentObjectId.value,
|
||||
parentId: module.parentId,
|
||||
moduleName,
|
||||
remark: module.remark,
|
||||
icon: module.icon,
|
||||
sort: module.sort
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('模块名称更新成功');
|
||||
await loadModuleTree();
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
function handleEditCancel() {
|
||||
editingNodeId.value = undefined;
|
||||
}
|
||||
|
||||
function handleStartAddChild(module: Api.Project.ProjectRequirementModule) {
|
||||
if (addingChildParentId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
addingChildParentId.value = module.id;
|
||||
newChildModuleName.value = '';
|
||||
|
||||
nextTick(() => {
|
||||
const input = document.querySelector('.new-child-module-input input') as HTMLInputElement | null;
|
||||
input?.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function handleAddChildConfirm() {
|
||||
const moduleName = newChildModuleName.value.trim();
|
||||
const parentId = addingChildParentId.value;
|
||||
|
||||
addingChildParentId.value = undefined;
|
||||
newChildModuleName.value = '';
|
||||
|
||||
if (!moduleName || !parentId || !currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchCreateProjectRequirementModule({
|
||||
projectId: currentObjectId.value,
|
||||
parentId,
|
||||
moduleName,
|
||||
remark: null,
|
||||
icon: null,
|
||||
sort: 0
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('子模块新增成功');
|
||||
await loadModuleTree();
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
function handleAddChildCancel() {
|
||||
addingChildParentId.value = undefined;
|
||||
newChildModuleName.value = '';
|
||||
}
|
||||
|
||||
async function handleDeleteModule(module: Api.Project.ProjectRequirementModule) {
|
||||
if (!currentObjectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm(`确定要删除模块“${module.moduleName}”吗?该模块下的所有需求将一并删除。`, '删除确认', {
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteProjectRequirementModule({
|
||||
id: module.id,
|
||||
projectId: currentObjectId.value
|
||||
});
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('模块删除成功');
|
||||
|
||||
if (selectedModuleId.value === module.id) {
|
||||
const rootId = rootModule.value?.id || '';
|
||||
selectedModuleId.value = rootId;
|
||||
emit('select', rootId);
|
||||
}
|
||||
|
||||
await loadModuleTree();
|
||||
emit('refresh');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async id => {
|
||||
if (!id) {
|
||||
moduleTree.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
selectedModuleId.value = '';
|
||||
await loadModuleTree();
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
defineExpose({
|
||||
loadModuleTree,
|
||||
selectedModuleId
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="requirement-module-tree-wrapper">
|
||||
<div class="module-tree-header">
|
||||
<span class="module-tree-header__title">模块</span>
|
||||
</div>
|
||||
|
||||
<div class="module-tree-list">
|
||||
<template v-for="item in moduleTree" :key="item.id">
|
||||
<ModuleTreeNode
|
||||
:module="item"
|
||||
:level="0"
|
||||
:selected-module-id="selectedModuleId"
|
||||
:editing-node-id="editingNodeId"
|
||||
:editing-name="editingName"
|
||||
:adding-child-parent-id="addingChildParentId"
|
||||
:new-child-module-name="newChildModuleName"
|
||||
:root-module-id="rootModule?.id"
|
||||
:module-requirement-count-map="moduleRequirementCountMap"
|
||||
@select="handleNodeSelect"
|
||||
@edit="handleStartEdit"
|
||||
@edit-confirm="handleEditConfirm"
|
||||
@edit-cancel="handleEditCancel"
|
||||
@delete="handleDeleteModule"
|
||||
@add-child="handleStartAddChild"
|
||||
@add-child-confirm="handleAddChildConfirm"
|
||||
@add-child-cancel="handleAddChildCancel"
|
||||
@update-editing-name="editingName = $event"
|
||||
@update-new-child-module-name="newChildModuleName = $event"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-module-tree-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.module-tree-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.module-tree-header__title {
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.module-tree-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
min-height: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { RDMS_REQ_SOURCE_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetProjectRequirementStatusDict } from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
import TableSearchPanel from '@/components/custom/table-search-panel.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementSearch' });
|
||||
|
||||
interface MemberUserOption {
|
||||
id: string;
|
||||
nickname: string;
|
||||
roleName?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
memberOptions: MemberUserOption[];
|
||||
categoryDictCode: string;
|
||||
priorityDictCode: string;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'reset'): void;
|
||||
(e: 'search'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<Api.Project.ProjectRequirementSearchParams>('model', { required: true });
|
||||
|
||||
const requirementStatusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
|
||||
const { enabledDictData: sourceTypeDictData } = useDict(RDMS_REQ_SOURCE_TYPE_DICT_CODE);
|
||||
|
||||
const sourceTypeOptions = computed(() => {
|
||||
return sourceTypeDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
}));
|
||||
});
|
||||
|
||||
async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetProjectRequirementStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
requirementStatusOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
requirementStatusOptions.value = data.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
}
|
||||
|
||||
function reset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function search() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await loadStatusOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
<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>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="需求类型">
|
||||
<DictSelect
|
||||
v-model="model.category"
|
||||
:dict-code="categoryDictCode"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="筛选需求类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="优先级">
|
||||
<DictSelect v-model="model.priority" :dict-code="priorityDictCode" clearable placeholder="筛选优先级" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="状态">
|
||||
<ElSelect v-model="model.statusCode" clearable placeholder="筛选状态">
|
||||
<ElOption
|
||||
v-for="item in requirementStatusOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="负责人">
|
||||
<ElSelect
|
||||
v-model="model.currentHandlerUserId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="筛选负责人"
|
||||
:filter-method="(val: string) => val"
|
||||
>
|
||||
<ElOption v-for="item in memberOptions" :key="item.id" :label="item.nickname" :value="item.id">
|
||||
<MemberSelectOption :nickname="item.nickname" :role-name="item.roleName || ''" />
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :lg="6" :md="12" :sm="12">
|
||||
<ElFormItem label="需求来源">
|
||||
<ElSelect v-model="model.sourceType" clearable placeholder="筛选需求来源">
|
||||
<ElOption v-for="item in sourceTypeOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</TableSearchPanel>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,241 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { fetchSplitProjectRequirement } 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 BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementSplitDialog' });
|
||||
|
||||
interface Props {
|
||||
parentRequirement: Api.Project.ProjectRequirement | null;
|
||||
projectId: string;
|
||||
memberOptions: Api.Project.ProjectMember[];
|
||||
categoryDictCode: string;
|
||||
priorityDictCode: string;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string;
|
||||
reviewRequired: number;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
currentHandlerUserId: string;
|
||||
workHours: number | null;
|
||||
sort: number;
|
||||
}
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
const submitting = ref(false);
|
||||
|
||||
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
{ label: '需要', value: 1 }
|
||||
];
|
||||
|
||||
const rules = {
|
||||
title: [createRequiredRule('请输入子需求名称')],
|
||||
priority: [createRequiredRule('请选择优先级')],
|
||||
currentHandlerUserId: [createRequiredRule('请选择负责人')],
|
||||
workHours: [createRequiredRule('请输入所需工时')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
title: '',
|
||||
description: '',
|
||||
reviewRequired: 0,
|
||||
category: '',
|
||||
priority: 1,
|
||||
currentHandlerUserId: '',
|
||||
workHours: null,
|
||||
sort: 0
|
||||
};
|
||||
}
|
||||
|
||||
function getNullableText(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.projectId || !props.parentRequirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
const payload: Api.Project.SplitProjectRequirementParams = {
|
||||
parentId: props.parentRequirement.id,
|
||||
projectId: props.projectId,
|
||||
moduleId: props.parentRequirement.moduleId,
|
||||
reviewRequired: model.value.reviewRequired as Api.Project.ProjectRequirementReviewRequired,
|
||||
title: model.value.title.trim(),
|
||||
description: getNullableText(model.value.description),
|
||||
category: model.value.category,
|
||||
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
|
||||
proposerId: props.parentRequirement.proposerId,
|
||||
proposerNickname: props.parentRequirement.proposerNickname || '',
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname: handler?.userNickname || '',
|
||||
workHours: model.value.workHours || 0,
|
||||
sort: model.value.sort
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const result = await fetchSplitProjectRequirement(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('项目需求拆分成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
|
||||
if (props.parentRequirement?.category) {
|
||||
model.value.category = props.parentRequirement.category;
|
||||
}
|
||||
|
||||
// 默认选中父需求的负责人
|
||||
if (props.parentRequirement?.currentHandlerUserId) {
|
||||
model.value.currentHandlerUserId = props.parentRequirement.currentHandlerUserId;
|
||||
}
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="拆分项目需求"
|
||||
preset="lg"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElAlert
|
||||
v-if="parentRequirement"
|
||||
:title="`正在拆分需求:${parentRequirement.title}`"
|
||||
type="info"
|
||||
:closable="false"
|
||||
class="mb-16px"
|
||||
/>
|
||||
|
||||
<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="是否需要评审">
|
||||
<ElSelect v-model="model.reviewRequired" class="w-full" placeholder="请选择">
|
||||
<ElOption
|
||||
v-for="item in reviewRequiredOptions"
|
||||
:key="item.value"
|
||||
:label="item.label"
|
||||
:value="item.value"
|
||||
/>
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="内容">
|
||||
<BusinessRichTextEditor
|
||||
v-model="model.description"
|
||||
height="240px"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
|
||||
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</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="请选择负责人">
|
||||
<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>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="排序值">
|
||||
<ElInputNumber v-model="model.sort" class="w-full" :min="0" :max="9999" placeholder="请输入排序值" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,122 @@
|
||||
import { markRaw } from '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 IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiGlasses from '~icons/mdi/glasses';
|
||||
import IconMdiClose from '~icons/mdi/close';
|
||||
import IconTablerSitemap from '~icons/tabler/sitemap';
|
||||
import IconTablerCircleX from '~icons/tabler/circle-x';
|
||||
|
||||
/**
|
||||
* 项目需求状态记录
|
||||
*
|
||||
* 来源:rdms_object_status_model 表 object_type='project_requirement' 的 7 条记录
|
||||
*/
|
||||
export const projectRequirementStatusRecord: Record<Api.Project.ProjectRequirementStatusCode, string> = {
|
||||
pending_confirm: '待确认',
|
||||
pending_review: '待评审',
|
||||
implementing: '实施中',
|
||||
accepted: '已验收',
|
||||
closed: '已关闭',
|
||||
rejected: '已拒绝',
|
||||
cancelled: '已取消'
|
||||
};
|
||||
|
||||
/**
|
||||
* 终态状态码集合
|
||||
*
|
||||
* 来源:terminal_flag=1 的状态: closed, rejected, cancelled
|
||||
*/
|
||||
const TERMINAL_STATUS_SET = new Set<Api.Project.ProjectRequirementStatusCode>(['closed', 'rejected', 'cancelled']);
|
||||
|
||||
/**
|
||||
* 操作按钮图标映射
|
||||
*
|
||||
* 将操作类型映射到对应的 Iconify 图标组件
|
||||
*/
|
||||
export const ACTION_ICON_MAP: Record<string, object> = {
|
||||
split: markRaw(IconTablerSitemap),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
claim_to_review: markRaw(IconMdiCheckOutline),
|
||||
claim_to_implement: markRaw(IconMdiCheckCircleOutline),
|
||||
pass_review: markRaw(IconMdiGlasses),
|
||||
accept: markRaw(IconMdiCheckCircleOutline),
|
||||
reject: markRaw(IconMdiClose),
|
||||
cancel: markRaw(IconTablerCircleX),
|
||||
close: markRaw(IconMdiPowerSettingsNew),
|
||||
delete: markRaw(IconMdiDeleteOutline)
|
||||
};
|
||||
|
||||
export const DEFAULT_ACTION_ICON = markRaw(IconMdiSync);
|
||||
|
||||
function resolveActionKeyword(actionCode: string) {
|
||||
return Object.keys(ACTION_ICON_MAP).find(keyword => actionCode.includes(keyword));
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取项目需求状态的标签颜色
|
||||
*/
|
||||
export function getProjectRequirementStatusTagType(status: Api.Project.ProjectRequirementStatusCode): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<Api.Project.ProjectRequirementStatusCode, UI.ThemeColor> = {
|
||||
pending_confirm: 'info',
|
||||
pending_review: 'warning',
|
||||
implementing: 'primary',
|
||||
accepted: 'success',
|
||||
closed: 'info',
|
||||
rejected: 'danger',
|
||||
cancelled: 'danger'
|
||||
};
|
||||
|
||||
return statusTagTypeMap[status];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否为终态
|
||||
*/
|
||||
export function isProjectRequirementTerminal(statusCode: string) {
|
||||
return TERMINAL_STATUS_SET.has(statusCode as Api.Project.ProjectRequirementStatusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断动作是否为终态动作
|
||||
*
|
||||
* 终态动作定义:执行后需求进入终态(closed/rejected/cancelled)
|
||||
* 包括:reject, cancel, close
|
||||
*/
|
||||
export function isProjectRequirementActionTerminal(actionCode: string) {
|
||||
const terminalActions = new Set(['reject', 'cancel', 'close']);
|
||||
return terminalActions.has(actionCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作按钮的颜色类型
|
||||
*
|
||||
* 成功/审批类 → success,危险操作 → danger,其他 → primary
|
||||
*/
|
||||
export function getProjectRequirementActionButtonType(actionCode: string): 'primary' | 'success' | 'danger' {
|
||||
if (['reject', 'cancel', 'close', 'delete'].includes(actionCode)) {
|
||||
return 'danger';
|
||||
}
|
||||
// if (['accept', 'pass_review'].includes(actionCode)) {
|
||||
// return 'success';
|
||||
// }
|
||||
return 'primary';
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取操作动作的展示名称
|
||||
*/
|
||||
export function getProjectRequirementActionDisplayName(action: Api.Project.ProjectRequirementLifecycleAction) {
|
||||
return action.actionName || action.actionCode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据 actionCode 获取对应图标
|
||||
*/
|
||||
export function getProjectRequirementActionIcon(actionCode: string) {
|
||||
const actionKeyword = resolveActionKeyword(actionCode);
|
||||
return actionKeyword ? ACTION_ICON_MAP[actionKeyword] : DEFAULT_ACTION_ICON;
|
||||
}
|
||||
Reference in New Issue
Block a user