diff --git a/src/components/custom/attendee-user-picker.vue b/src/components/custom/attendee-user-picker.vue new file mode 100644 index 0000000..2750f11 --- /dev/null +++ b/src/components/custom/attendee-user-picker.vue @@ -0,0 +1,1018 @@ + + + + + diff --git a/src/layouts/modules/global-menu/components/object-context-switcher.vue b/src/layouts/modules/global-menu/components/object-context-switcher.vue new file mode 100644 index 0000000..7cb0c83 --- /dev/null +++ b/src/layouts/modules/global-menu/components/object-context-switcher.vue @@ -0,0 +1,399 @@ + + + + + diff --git a/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue b/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue index dae7519..c8697fa 100644 --- a/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue +++ b/src/layouts/modules/global-menu/modules/horizontal-mix-menu.vue @@ -8,6 +8,7 @@ import { useObjectContextStore } from '@/store/modules/object-context'; import { useThemeStore } from '@/store/modules/theme'; import { useRouterPush } from '@/hooks/common/router'; import FirstLevelMenu from '../components/first-level-menu.vue'; +import ObjectContextSwitcher from '../components/object-context-switcher.vue'; import { useMenu, useMixMenuContext } from '../../../context'; defineOptions({ @@ -108,7 +109,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean { class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]" >
- {{ objectContextStore.objectName }} +
; +type RequirementReviewResponse = Omit< + Api.Product.RequirementReview, + 'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments' +> & { + id: string | number; + requirementId: string | number; + operatorId: string | number; + attendees?: Array<{ + userId: string | number; + nickname: string; + }>; + attachments?: AttachmentItemResponse[] | null; +}; +type ProductRequirementDashboardSummaryResponse = { + total?: number | string | null; + todo?: number | string | null; + pendingClaim?: number | string | null; + pendingReview?: number | string | null; + pendingDispatch?: number | string | null; + completed?: number | string | null; + completionRate?: number | string | null; + highPriorityTodo?: number | string | null; +}; +type ProductRequirementDashboardRecentChangeResponse = Omit< + Api.Product.ProductRequirementDashboardRecentChange, + 'id' | 'requirementId' | 'operatorUserId' +> & { + id: string | number; + requirementId?: string | number | null; + operatorUserId?: string | number | null; +}; +type ProductRequirementDashboardResponse = { + summary?: ProductRequirementDashboardSummaryResponse | null; + recentChanges?: ProductRequirementDashboardRecentChangeResponse[] | null; +}; type AttachmentItemResponse = Omit & { fileId?: string | number; @@ -242,6 +277,51 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req }; } +function normalizeRequirementReview(review: RequirementReviewResponse): Api.Product.RequirementReview { + return { + ...review, + id: normalizeStringId(review.id), + requirementId: normalizeStringId(review.requirementId), + operatorId: normalizeStringId(review.operatorId), + attendees: review.attendees?.map(item => ({ + ...item, + userId: normalizeStringId(item.userId) + })), + attachments: normalizeAttachments(review.attachments) + }; +} + +function normalizeDashboardCount(value: number | string | null | undefined) { + const count = Number(value ?? 0); + + return Number.isFinite(count) ? Math.max(0, count) : 0; +} + +function normalizeProductRequirementDashboard( + data: ProductRequirementDashboardResponse +): Api.Product.ProductRequirementDashboard { + const summary = data.summary ?? {}; + + return { + summary: { + total: normalizeDashboardCount(summary.total), + todo: normalizeDashboardCount(summary.todo), + pendingClaim: normalizeDashboardCount(summary.pendingClaim), + pendingReview: normalizeDashboardCount(summary.pendingReview), + pendingDispatch: normalizeDashboardCount(summary.pendingDispatch), + completed: normalizeDashboardCount(summary.completed), + completionRate: Math.min(100, normalizeDashboardCount(summary.completionRate)), + highPriorityTodo: normalizeDashboardCount(summary.highPriorityTodo) + }, + recentChanges: (data.recentChanges ?? []).map(item => ({ + ...item, + id: normalizeStringId(item.id), + requirementId: normalizeNullableStringId(item.requirementId), + operatorUserId: normalizeNullableStringId(item.operatorUserId) + })) + }; +} + /** 获取需求分页列表 */ export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) { const result = await request({ @@ -337,17 +417,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa return mapServiceResult(result as ServiceRequestResult, normalizeStringId); } - -/** 关闭需求 */ -export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) { - return request({ - ...safeJsonRequestConfig, - url: `${REQUIREMENT_PREFIX}/close`, - method: 'post', - data - }); -} - /** 获取需求可执行的状态动作列表 */ export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) { const result = await request({ @@ -379,16 +448,43 @@ export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Produ ); } -/** 获取需求生命周期信息 */ -export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) { - const result = await request({ +/** 提交产品需求评审 */ +export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) { + const result = await request({ ...safeJsonRequestConfig, - url: `${REQUIREMENT_PREFIX}/lifecycle`, - method: 'get', - params: { requirementId, productId } + url: `${REQUIREMENT_PREFIX}/review/submit`, + method: 'post', + data }); - return mapServiceResult(result as ServiceRequestResult, data => data); + return mapServiceResult(result as ServiceRequestResult, normalizeStringId); +} + +/** 获取产品需求评审记录 */ +export async function fetchGetProductRequirementReview(productId: string, requirementId: string) { + const result = await request({ + ...safeJsonRequestConfig, + url: `${REQUIREMENT_PREFIX}/review/get`, + method: 'get', + params: { productId, requirementId } + }); + + return mapServiceResult(result as ServiceRequestResult, normalizeRequirementReview); +} + +/** 获取产品概览需求池实时看板 */ +export async function fetchGetProductRequirementDashboard(productId: string) { + const result = await request({ + ...safeJsonRequestConfig, + url: `${REQUIREMENT_PREFIX}/dashboard`, + method: 'get', + params: { productId } + }); + + return mapServiceResult( + result as ServiceRequestResult, + normalizeProductRequirementDashboard + ); } /** 获取需求所有状态字典 */ @@ -402,18 +498,7 @@ export async function fetchGetRequirementStatusDict() { return mapServiceResult(result as ServiceRequestResult, data => data); } -/** 获取需求终止态状态字典 */ -export async function fetchGetRequirementTerminalStatusDict() { - const result = await request({ - ...safeJsonRequestConfig, - url: `${REQUIREMENT_PREFIX}/status/dict/terminal`, - method: 'get' - }); - - return mapServiceResult(result as ServiceRequestResult, data => data); -} - -/** 判断产品需求是否已分流生成项目需求 */ +/** 判断产品需求是否已指派并生成项目需求 */ export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) { return request({ ...safeJsonRequestConfig, @@ -423,7 +508,7 @@ export async function fetchHasDispatchedProjectRequirement(requirementId: string }); } -/** 批量判断产品需求是否已分流生成项目需求 */ +/** 批量判断产品需求是否已指派并生成项目需求 */ export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) { const result = await request({ ...safeJsonRequestConfig, diff --git a/src/service/api/project.ts b/src/service/api/project.ts index 2ad3abb..fc521b7 100644 --- a/src/service/api/project.ts +++ b/src/service/api/project.ts @@ -832,6 +832,19 @@ type ProjectRequirementResponse = Omit< }; type ProjectRequirementPageResponse = Api.Project.PageResult; +type ProjectRequirementReviewResponse = Omit< + Api.Project.ProjectRequirementReview, + 'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments' +> & { + id: string | number; + requirementId: string | number; + operatorId: string | number; + attendees?: Array<{ + userId: string | number; + nickname: string; + }>; + attachments?: AttachmentItemResponse[] | null; +}; type ProjectRequirementModuleResponse = Omit & { id: string | number; @@ -876,6 +889,22 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A }; } +function normalizeProjectRequirementReview( + review: ProjectRequirementReviewResponse +): Api.Project.ProjectRequirementReview { + return { + ...review, + id: normalizeStringId(review.id), + requirementId: normalizeStringId(review.requirementId), + operatorId: normalizeStringId(review.operatorId), + attendees: review.attendees?.map(item => ({ + ...item, + userId: normalizeStringId(item.userId) + })), + attachments: normalizeAttachments(review.attachments) + }; +} + function normalizeProjectRequirementModule( module: ProjectRequirementModuleResponse ): Api.Project.ProjectRequirementModule { @@ -1030,16 +1059,31 @@ export async function fetchGetProjectRequirementAllowedTransitionsBatch( ); } -/** 获取项目需求生命周期信息 */ -export async function fetchGetProjectRequirementLifecycle(requirementId: string, projectId: string) { - const result = await request({ +/** 提交项目需求评审 */ +export async function fetchSubmitProjectRequirementReview(data: Api.Project.ProjectRequirementReviewSubmitParams) { + const result = await request({ ...safeJsonRequestConfig, - url: `${PROJECT_REQUIREMENT_PREFIX}/lifecycle`, - method: 'get', - params: { requirementId, projectId } + url: `${PROJECT_REQUIREMENT_PREFIX}/review/submit`, + method: 'post', + data }); - return mapServiceResult(result as ServiceRequestResult, data => data); + return mapServiceResult(result as ServiceRequestResult, normalizeStringId); +} + +/** 获取项目需求评审记录 */ +export async function fetchGetProjectRequirementReview(projectId: string, requirementId: string) { + const result = await request({ + ...safeJsonRequestConfig, + url: `${PROJECT_REQUIREMENT_PREFIX}/review/get`, + method: 'get', + params: { projectId, requirementId } + }); + + return mapServiceResult( + result as ServiceRequestResult, + normalizeProjectRequirementReview + ); } /** 获取项目需求状态字典 */ @@ -1053,17 +1097,6 @@ export async function fetchGetProjectRequirementStatusDict() { return mapServiceResult(result as ServiceRequestResult, data => data); } -/** 获取项目需求终态状态字典 */ -export async function fetchGetProjectRequirementTerminalStatusDict() { - const result = await request({ - ...safeJsonRequestConfig, - url: `${PROJECT_REQUIREMENT_PREFIX}/status/dict/terminal`, - method: 'get' - }); - - return mapServiceResult(result as ServiceRequestResult, data => data); -} - /** 获取项目需求模块树 */ export async function fetchGetProjectRequirementModuleTree(projectId: string) { const result = await request({ diff --git a/src/service/api/system-manage.ts b/src/service/api/system-manage.ts index 5aed769..07af3e8 100644 --- a/src/service/api/system-manage.ts +++ b/src/service/api/system-manage.ts @@ -445,7 +445,7 @@ export function fetchBatchDeletePost(ids: number[]) { } /** 获取用户简单列表(用于用户选择下拉框) */ -export function fetchGetUserSimpleList() { +export async function fetchGetUserSimpleList() { return request({ ...safeJsonRequestConfig, url: `${USER_PREFIX}/simple-list`, diff --git a/src/typings/api/dict.d.ts b/src/typings/api/dict.d.ts index e0dc1c2..c145cce 100644 --- a/src/typings/api/dict.d.ts +++ b/src/typings/api/dict.d.ts @@ -47,8 +47,6 @@ declare namespace Api { id: number; /** dict label */ label: string; - /** sign */ - sign?: string | null; /** dict value */ value: string; /** dict type code */ @@ -69,8 +67,6 @@ declare namespace Api { interface FrontendDictData { /** dict label */ label: string; - /** sign */ - sign?: string | null; /** dict value */ value: string; /** display order */ @@ -92,7 +88,7 @@ declare namespace Api { type DictDataSearchParams = CommonType.RecordNullable> & PageParams; /** dict data save params */ - type SaveDictDataParams = Pick & { + type SaveDictDataParams = Pick & { remark?: string | null; }; } diff --git a/src/typings/api/product.d.ts b/src/typings/api/product.d.ts index 556ebea..5326a1f 100644 --- a/src/typings/api/product.d.ts +++ b/src/typings/api/product.d.ts @@ -256,15 +256,29 @@ declare namespace Api { // ========== 产品需求相关类型定义 ========== /** 需求状态编码 */ type RequirementStatusCode = - | 'pending_confirm' + | 'pending_claim' | 'pending_review' | 'pending_dispatch' + | 'reviewed' + | 'review_rejected' | 'implementing' | 'accepted' | 'closed' | 'rejected' | 'cancelled'; + /** 需求状态动作编码 */ + type RequirementStatusActionCode = + | 'claim_to_review' + | 'claim_to_dispatch' + | 'pass_review' + | 'reject_review' + | 'dispatch' + | 'cancel' + | 'accept' + | 'close' + | 'reject'; + /** 需求来源类型 */ type RequirementSourceType = 'manual' | 'work_order'; @@ -333,8 +347,6 @@ declare namespace Api { updateTime: string; /** 子需求列表(树形结构) */ children?: Requirement[]; - /** 是否为终态 */ - terminal?: boolean; } // ========== 需求模块实体 ========== @@ -371,27 +383,18 @@ declare namespace Api { initialFlag: boolean; /** 是否终态 */ terminalFlag: boolean; + /** 是否允许编辑 */ + allowEdit: boolean; } - // ========== 需求生命周期 ========== - interface RequirementLifecycleAction { - actionCode: string; + actionCode: RequirementStatusActionCode; actionName: string; toStatusCode: string; toStatusName: string; needReason: boolean; } - interface RequirementLifecycleInfo { - statusCode: RequirementStatusCode; - statusName?: string | null; - lastStatusReason?: string | null; - terminal: boolean; - allowEdit: boolean; - availableActions: RequirementLifecycleAction[]; - } - interface RequirementBatchReqVO { productId: string; requirementIds: string[]; @@ -407,6 +410,78 @@ declare namespace Api { hasDispatched: boolean; } + type ProductRequirementDashboardRecentChangeActionType = 'create' | 'delete' | 'status_terminal'; + + interface ProductRequirementDashboardSummary { + /** 当前产品下所有未删除需求数,包括根需求和子需求 */ + total: number; + /** 待认领、待评审、待指派的需求数 */ + todo: number; + /** 待认领需求数 */ + pendingClaim: number; + /** 待评审需求数 */ + pendingReview: number; + /** 待指派需求数 */ + pendingDispatch: number; + /** 已验收或已关闭需求数 */ + completed: number; + /** 完成率,0-100 */ + completionRate: number; + /** P0/P1 且待处理的需求数 */ + highPriorityTodo: number; + } + + interface ProductRequirementDashboardRecentChange { + id: string; + requirementId?: string | null; + title: string; + actionType: ProductRequirementDashboardRecentChangeActionType; + actionLabel: string; + content: string; + occurredAt: string; + operatorUserId?: string | null; + operatorName?: string | null; + } + + interface ProductRequirementDashboard { + summary: ProductRequirementDashboardSummary; + recentChanges: ProductRequirementDashboardRecentChange[]; + } + + type RequirementReviewConclusion = 0 | 1; + + interface RequirementReviewAttendeeItem { + userId: string; + nickname: string; + } + + interface RequirementReview { + id: string; + objectType: 'product_requirement'; + requirementId: string; + operatorId: string; + conclusion: RequirementReviewConclusion; + reviewContent?: string | null; + requirementEstimatedHours?: number | string | null; + attendees?: RequirementReviewAttendeeItem[]; + attachments?: Api.Project.AttachmentItem[] | null; + reviewTime?: string | null; + createTime?: string; + updateTime?: string; + } + + interface RequirementReviewSubmitParams { + productId: string; + requirementId: string; + operatorId: string; + conclusion: RequirementReviewConclusion; + reviewContent?: string | null; + requirementEstimatedHours?: number | string | null; + attendees?: RequirementReviewAttendeeItem[]; + attachments?: Api.Project.AttachmentItem[] | null; + reviewTime?: string | null; + } + // ========== 请求参数类型 ========== /** 需求分页查询参数 */ diff --git a/src/typings/api/project.d.ts b/src/typings/api/project.d.ts index cf1dc76..f1a8101 100644 --- a/src/typings/api/project.d.ts +++ b/src/typings/api/project.d.ts @@ -713,14 +713,28 @@ declare namespace Api { // ========== 项目需求相关类型定义 ========== /** 项目需求状态编码 */ type ProjectRequirementStatusCode = - | 'pending_confirm' + | 'pending_claim' | 'pending_review' + | 'reviewed' + | 'review_rejected' | 'implementing' | 'accepted' | 'closed' | 'rejected' | 'cancelled'; + /** 项目需求状态动作编码 */ + type ProjectRequirementStatusActionCode = + | 'claim_to_review' + | 'claim_to_implement' + | 'pass_review' + | 'reject_review' + | 'start_implement' + | 'accept' + | 'cancel' + | 'close' + | 'reject'; + /** 项目需求来源类型 */ type ProjectRequirementSourceType = 'manual' | 'work_order' | 'product_requirement'; @@ -785,8 +799,6 @@ declare namespace Api { updateTime: string; /** 子需求列表 */ children?: ProjectRequirement[]; - /** 是否终态 */ - terminal?: boolean; } interface ProjectRequirementModule { @@ -819,25 +831,18 @@ declare namespace Api { initialFlag: boolean; /** 是否终态 */ terminalFlag: boolean; + /** 是否允许编辑 */ + allowEdit: boolean; } interface ProjectRequirementLifecycleAction { - actionCode: string; + actionCode: ProjectRequirementStatusActionCode; actionName: string; toStatusCode: string; toStatusName: string; needReason: boolean; } - interface ProjectRequirementLifecycleInfo { - statusCode: ProjectRequirementStatusCode; - statusName?: string | null; - lastStatusReason?: string | null; - terminal: boolean; - allowEdit: boolean; - availableActions: ProjectRequirementLifecycleAction[]; - } - interface ProjectRequirementBatchReqVO { projectId: string; requirementIds: string[]; @@ -848,6 +853,40 @@ declare namespace Api { transitions: ProjectRequirementLifecycleAction[]; } + type ProjectRequirementReviewConclusion = 0 | 1; + + interface ProjectRequirementReviewAttendeeItem { + userId: string; + nickname: string; + } + + interface ProjectRequirementReview { + id: string; + objectType: 'project_requirement'; + requirementId: string; + operatorId: string; + conclusion: ProjectRequirementReviewConclusion; + reviewContent?: string | null; + requirementEstimatedHours?: number | string | null; + attendees?: ProjectRequirementReviewAttendeeItem[]; + attachments?: AttachmentItem[] | null; + reviewTime?: string | null; + createTime?: string; + updateTime?: string; + } + + interface ProjectRequirementReviewSubmitParams { + projectId: string; + requirementId: string; + operatorId: string; + conclusion: ProjectRequirementReviewConclusion; + reviewContent?: string | null; + requirementEstimatedHours?: number | string | null; + attendees?: ProjectRequirementReviewAttendeeItem[]; + attachments?: AttachmentItem[] | null; + reviewTime?: string | null; + } + /** 项目需求分页查询参数 */ type ProjectRequirementSearchParams = CommonType.RecordNullable< Pick & diff --git a/src/typings/components.d.ts b/src/typings/components.d.ts index 9af0628..093b12a 100644 --- a/src/typings/components.d.ts +++ b/src/typings/components.d.ts @@ -9,6 +9,7 @@ export {} declare module 'vue' { export interface GlobalComponents { AppProvider: typeof import('./../components/common/app-provider.vue')['default'] + AttendeeUserPicker: typeof import('./../components/custom/attendee-user-picker.vue')['default'] BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default'] BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default'] BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default'] @@ -100,10 +101,14 @@ declare module 'vue' { IconCarbonStop: typeof import('~icons/carbon/stop')['default'] 'IconCharm:download': typeof import('~icons/charm/download')['default'] 'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default'] + 'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default'] 'IconEp:box': typeof import('~icons/ep/box')['default'] + 'IconEp:check': typeof import('~icons/ep/check')['default'] 'IconEp:files': typeof import('~icons/ep/files')['default'] + 'IconEp:folder': typeof import('~icons/ep/folder')['default'] 'IconEp:infoFilled': typeof import('~icons/ep/info-filled')['default'] 'IconEp:plus': typeof import('~icons/ep/plus')['default'] + 'IconEp:sort': typeof import('~icons/ep/sort')['default'] IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default'] IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default'] 'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default'] diff --git a/src/views/product/dashboard/homepage.ts b/src/views/product/dashboard/homepage.ts index 7e1bb7b..aa5d729 100644 --- a/src/views/product/dashboard/homepage.ts +++ b/src/views/product/dashboard/homepage.ts @@ -49,15 +49,6 @@ export interface ProductHomepageTimelineItem { tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate'; } -export interface ProductRequirementPoolSummarySource { - total: number; - todo: number; - analyzing: number; - planned: number; - done: number; - highPriorityTodo: number; -} - export interface ProductRequirementPoolSummary { metrics: ProductHomepageMetric[]; distribution: Array<{ @@ -67,22 +58,16 @@ export interface ProductRequirementPoolSummary { total: number; todo: number; highPriorityTodo: number; -} - -export interface ProductRequirementPoolRecentChangeSource { - id: string; - title: string; - actionLabel: string; - time: string; - statusLabel: string; + completionRate: number; } export interface ProductRequirementPoolRecentChange { id: string; title: string; + actionType: Api.Product.ProductRequirementDashboardRecentChangeActionType; actionLabel: string; time: string; - statusLabel: string; + content: string; } export interface ProductHomepageExtensionModule { @@ -182,19 +167,20 @@ function resolveLatestTimelineTime( } export function buildRequirementPoolSummary( - source: ProductRequirementPoolSummarySource | null | undefined + source: Api.Product.ProductRequirementDashboardSummary | null | undefined ): ProductRequirementPoolSummary { const total = normalizeCount(source?.total); const todo = normalizeCount(source?.todo); - const analyzing = normalizeCount(source?.analyzing); - const planned = normalizeCount(source?.planned); - const done = normalizeCount(source?.done); + const pendingClaim = normalizeCount(source?.pendingClaim); + const pendingReview = normalizeCount(source?.pendingReview); + const pendingDispatch = normalizeCount(source?.pendingDispatch); + const completionRate = Math.min(100, normalizeCount(source?.completionRate)); const highPriorityTodo = normalizeCount(source?.highPriorityTodo); const distribution = [ - { label: '待处理', value: String(todo) }, - { label: '分析中', value: String(analyzing) }, - { label: '已规划', value: String(planned) }, - { label: '已完成', value: String(done) } + { label: '等待处理', value: String(todo) }, + { label: '等待认领', value: String(pendingClaim) }, + { label: '等待评审', value: String(pendingReview) }, + { label: '等待指派', value: String(pendingDispatch) } ]; return { @@ -212,30 +198,35 @@ export function buildRequirementPoolSummary( { label: '待处理', value: String(todo), - hint: '等待进入分析或分派的需求数量' + hint: '等待认领、评审、指派的需求,这些需求应该着重关注' }, { label: '高优先级待处理', value: String(highPriorityTodo), - hint: '需要优先推进的待处理需求数量' + hint: '需要优先推进的待处理需求数量,P0、P1类型的需求' } ], distribution, total, todo, - highPriorityTodo + highPriorityTodo, + completionRate }; } export function buildRequirementPoolRecentChanges( - source: readonly ProductRequirementPoolRecentChangeSource[] | null | undefined + source: readonly Api.Product.ProductRequirementDashboardRecentChange[] | null | undefined ) { return [...(source || [])] - .filter(item => getTimeValue(item.time) > 0) - .sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time)) + .filter(item => getTimeValue(item.occurredAt) > 0) + .sort((left, right) => getTimeValue(right.occurredAt) - getTimeValue(left.occurredAt)) .map(item => ({ - ...item, - time: formatDateTime(item.time) + id: item.id, + title: item.title, + actionType: item.actionType, + actionLabel: item.actionLabel, + content: item.content, + time: formatDateTime(item.occurredAt) })) satisfies ProductRequirementPoolRecentChange[]; } @@ -368,7 +359,7 @@ function buildProductHomepageBannerMetrics(source: ProductHomepageBannerSource) { label: '待处理需求', value: String(requirementSummary.todo), - hint: '等待进入分析或分派的需求数量' + hint: '需要进行认领、评审、指派的需求,这些需求应该着重关注' }, { label: '最近动态时间', diff --git a/src/views/product/dashboard/index.vue b/src/views/product/dashboard/index.vue index 8b8f00e..61dcac8 100644 --- a/src/views/product/dashboard/index.vue +++ b/src/views/product/dashboard/index.vue @@ -1,7 +1,12 @@ @@ -858,7 +923,7 @@ onMounted(async () => {

需求列表

- {{ pagination.total }} 条 + {{ requirementDisplayTotal }} 条
@@ -967,6 +1047,8 @@ onMounted(async () => { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; + line-height: 1.5; + height: auto; } :deep(.requirement-title--terminal) { diff --git a/src/views/product/requirement/modules/module-tree-node.vue b/src/views/product/requirement/modules/module-tree-node.vue index 0161029..dede9bd 100644 --- a/src/views/product/requirement/modules/module-tree-node.vue +++ b/src/views/product/requirement/modules/module-tree-node.vue @@ -156,37 +156,31 @@ function handleToggle() {
- - - + + + - - - - + @@ -441,7 +447,7 @@ watch( - + @@ -512,7 +518,7 @@ watch( :disabled="isViewMode" :height="editorHeight" upload-directory="requirement" - placeholder="请输入需求内容" + :placeholder="isViewMode && isEmptyRichText(model.description) ? '--' : '请输入需求内容'" /> diff --git a/src/views/product/requirement/modules/requirement-review-dialog.vue b/src/views/product/requirement/modules/requirement-review-dialog.vue new file mode 100644 index 0000000..a378295 --- /dev/null +++ b/src/views/product/requirement/modules/requirement-review-dialog.vue @@ -0,0 +1,295 @@ + + + + + diff --git a/src/views/product/requirement/modules/requirement-review-record-dialog.vue b/src/views/product/requirement/modules/requirement-review-record-dialog.vue new file mode 100644 index 0000000..48d0655 --- /dev/null +++ b/src/views/product/requirement/modules/requirement-review-record-dialog.vue @@ -0,0 +1,312 @@ + + + + + diff --git a/src/views/product/requirement/modules/requirement-search.vue b/src/views/product/requirement/modules/requirement-search.vue index 791b5f3..fa03c1e 100644 --- a/src/views/product/requirement/modules/requirement-search.vue +++ b/src/views/product/requirement/modules/requirement-search.vue @@ -124,7 +124,7 @@ const fields = computed(() => [ diff --git a/src/views/product/requirement/modules/requirement-split-dialog.vue b/src/views/product/requirement/modules/requirement-split-dialog.vue index 755ed96..968a857 100644 --- a/src/views/product/requirement/modules/requirement-split-dialog.vue +++ b/src/views/product/requirement/modules/requirement-split-dialog.vue @@ -4,11 +4,11 @@ import { useResizeObserver } from '@vueuse/core'; import dayjs from 'dayjs'; import { fetchSplitRequirement } from '@/service/api'; import { useForm, useFormRules } from '@/hooks/common/form'; -import { useDict } from '@/hooks/business/dict'; import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormSection from '@/components/custom/business-form-section.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: 'RequirementSplitDialog' }); @@ -35,18 +35,10 @@ const visible = defineModel('visible', { const { formRef, validate } = useForm(); const { createRequiredRule } = useFormRules(); -const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode); const attachmentUploaderRef = ref | null>(null); const richTextEditorRef = ref | null>(null); -const priorityOptions = computed(() => { - return priorityDictData.value.map(item => ({ - label: item.label, - value: Number(item.value) - })); -}); - const reviewRequiredOptions = [ { label: '不需要', value: 0 }, { label: '需要', value: 1 } @@ -78,7 +70,7 @@ interface Model { attachments: Api.Project.AttachmentItem[]; reviewRequired: number; category: string; - priority: number | null; + priority: string | null; expectedTime: string | null; currentHandlerUserId: string; sort: number; @@ -135,7 +127,7 @@ function createDefaultModel(): Model { attachments: [], reviewRequired: 0, category: '', - priority: 1, + priority: '3', expectedTime: null, currentHandlerUserId: '', sort: 0 @@ -265,9 +257,12 @@ watch( - - - + diff --git a/src/views/product/requirement/shared/requirement-master-data.ts b/src/views/product/requirement/shared/requirement-master-data.ts index a185ba2..40a7e7c 100644 --- a/src/views/product/requirement/shared/requirement-master-data.ts +++ b/src/views/product/requirement/shared/requirement-master-data.ts @@ -16,35 +16,38 @@ import IconMingcuteForward2Line from '~icons/mingcute/forward-2-line'; export type RequirementStatusActionCode = | 'claim_to_review' | 'claim_to_dispatch' - | 'reject' - | 'to_dispatch' + | 'pass_review' + | 'reject_review' | 'dispatch' | 'cancel' | 'accept' - | 'close'; + | 'close' + | 'reject'; export const requirementStatusRecord: Record = { - pending_confirm: '待确认', + pending_claim: '待认领', pending_review: '待评审', - pending_dispatch: '待分流', + pending_dispatch: '待指派', + reviewed: '已评审', + review_rejected: '评审未过', implementing: '实施中', accepted: '已验收', closed: '已关闭', rejected: '已拒绝', cancelled: '已取消' }; - -export const requirementStatusOptions = transformRecordToOption(requirementStatusRecord); +transformRecordToOption(requirementStatusRecord); export const requirementStatusActionRecord: Record = { claim_to_review: '认领', claim_to_dispatch: '认领', - reject: '拒绝', - to_dispatch: '评审通过', - dispatch: '分流', + pass_review: '评审通过', + reject_review: '评审不通过', + dispatch: '指派', cancel: '取消', accept: '验收通过', - close: '关闭' + close: '关闭', + reject: '拒绝' }; /** @@ -58,7 +61,8 @@ export const ACTION_ICON_MAP: Record = { forward: markRaw(IconMingcuteForward2Line), claim_to_review: markRaw(IconMaterialSymbolsDescriptionOutline), claim_to_dispatch: markRaw(IconMdiCheckOutline), - to_dispatch: markRaw(IconMdiGlasses), + pass_review: markRaw(IconMdiGlasses), + reject_review: markRaw(IconMdiGlasses), dispatch: markRaw(IconMdiShareVariant), accept: markRaw(IconMdiCheckCircleOutline), reject: markRaw(IconMdiClose), @@ -77,7 +81,8 @@ export const ACTION_TYPE_MAP: Record = edit: 'primary', claim_to_review: 'primary', claim_to_dispatch: 'primary', - to_dispatch: 'primary', + pass_review: 'primary', + reject_review: 'danger', dispatch: 'primary', accept: 'primary', reject: 'danger', @@ -85,16 +90,13 @@ export const ACTION_TYPE_MAP: Record = close: 'danger', delete: 'danger' }; - -export function getRequirementStatusLabel(status: Api.Product.RequirementStatusCode) { - return requirementStatusRecord[status]; -} - export function getRequirementStatusTagType(status: Api.Product.RequirementStatusCode): UI.ThemeColor { const statusTagTypeMap: Record = { - pending_confirm: 'info', - pending_review: 'warning', + pending_claim: 'info', + pending_review: 'info', pending_dispatch: 'primary', + reviewed: 'success', + review_rejected: 'danger', implementing: 'primary', accepted: 'success', closed: 'danger', @@ -104,28 +106,6 @@ export function getRequirementStatusTagType(status: Api.Product.RequirementStatu return statusTagTypeMap[status]; } - -export function getRequirementActionLabel(actionCode: RequirementStatusActionCode) { - return requirementStatusActionRecord[actionCode]; -} - -export function getRequirementActionTagType( - actionCode: RequirementStatusActionCode -): 'primary' | 'success' | 'warning' | 'danger' | 'info' { - const actionTagTypeMap: Record = { - claim_to_review: 'primary', - claim_to_dispatch: 'primary', - reject: 'danger', - to_dispatch: 'success', - dispatch: 'primary', - cancel: 'danger', - accept: 'success', - close: 'info' - }; - - return actionTagTypeMap[actionCode]; -} - export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) { const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close']; return terminalActions.includes(actionCode); diff --git a/src/views/project/project/execution/components/requirement-tree-picker.vue b/src/views/project/project/execution/components/requirement-tree-picker.vue index f68b43c..45b5758 100644 --- a/src/views/project/project/execution/components/requirement-tree-picker.vue +++ b/src/views/project/project/execution/components/requirement-tree-picker.vue @@ -3,12 +3,18 @@ import { computed, nextTick, ref, watch } from 'vue'; import { useElementSize } from '@vueuse/core'; import type { InputInstance, TreeInstance } from 'element-plus'; import { ArrowDown, Close, Search } from '@element-plus/icons-vue'; -import type { ProjectRequirementTreeNode } from '../composables/use-project-requirement-options'; defineOptions({ name: 'RequirementTreePicker' }); +export interface RequirementTreePickerNode { + id: string; + title: string; + statusCode?: string; + children?: RequirementTreePickerNode[]; +} + interface Props { - data: ProjectRequirementTreeNode[]; + data: RequirementTreePickerNode[]; /** 编辑模式回显:modelValue 在 data 中找不到(已删除/不在当前可见范围)时显示这个文本 */ selectedName?: string | null; placeholder?: string; @@ -39,7 +45,7 @@ const popoverWidth = computed(() => triggerWidth.value || 300); const treeProps = { value: 'id', label: 'title', children: 'children' }; -function findNodeTitle(tree: ProjectRequirementTreeNode[], id: string): string | null { +function findNodeTitle(tree: RequirementTreePickerNode[], id: string): string | null { for (const node of tree) { if (node.id === id) return node.title; if (node.children) { @@ -69,7 +75,7 @@ function filterNode(value: string, data: Record) { return title.includes(value); } -function handleSelect(node: ProjectRequirementTreeNode) { +function handleSelect(node: RequirementTreePickerNode) { // 先播 200ms 弹性动画再关弹层;中途又点别的节点会让动画 id 转到新节点上 animatingNodeId.value = node.id; window.setTimeout(() => { @@ -153,7 +159,7 @@ watch(visible, async value => {