feat(新增需求评审功能): 新增需求评审功能。
feat(动态切换对象域下的对象):对象域下的对象可以动态切换。 fix(产品需求、项目需求): 按照会议意见修改诸多细节。 fix(产品对象域的概览界面): 把假数据换成真实的需求统计数据。
This commit is contained in:
1018
src/components/custom/attendee-user-picker.vue
Normal file
1018
src/components/custom/attendee-user-picker.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,399 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProductPage, fetchGetProjectPage } from '@/service/api';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
|
||||
defineOptions({ name: 'ObjectContextSwitcher' });
|
||||
|
||||
interface Props {
|
||||
domainConfig: App.ObjectContext.DomainConfig;
|
||||
}
|
||||
|
||||
type ObjectOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
createTime?: string | null;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
const visible = ref(false);
|
||||
const keyword = ref('');
|
||||
const expanded = ref(false);
|
||||
const loading = ref(false);
|
||||
const switchingId = ref('');
|
||||
const options = ref<ObjectOption[]>([]);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const OBJECT_SWITCHER_PAGE_SIZE = 100;
|
||||
|
||||
const isProductDomain = computed(() => props.domainConfig.domainKey === 'product');
|
||||
const domainLabel = computed(() => (isProductDomain.value ? '产品' : '项目'));
|
||||
const allLabel = computed(() => `全部${domainLabel.value}`);
|
||||
const placeholder = computed(() => `搜索${domainLabel.value}`);
|
||||
const previewOptions = computed(() => options.value.slice(0, 3));
|
||||
const displayOptions = computed(() => {
|
||||
if (keyword.value.trim() || expanded.value) {
|
||||
return options.value;
|
||||
}
|
||||
|
||||
return previewOptions.value;
|
||||
});
|
||||
const hiddenCount = computed(() => Math.max(options.value.length - previewOptions.value.length, 0));
|
||||
const showAllEntry = computed(() => !keyword.value.trim() && !expanded.value && hiddenCount.value > 0);
|
||||
|
||||
function sortByCreateTimeDesc(list: ObjectOption[]) {
|
||||
return list.slice().sort((left, right) => {
|
||||
const leftTime = left.createTime ? new Date(left.createTime).getTime() : 0;
|
||||
const rightTime = right.createTime ? new Date(right.createTime).getTime() : 0;
|
||||
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchObjectOptionsPage(pageNo: number, keywordValue?: string) {
|
||||
const result =
|
||||
props.domainConfig.domainKey === 'product'
|
||||
? await fetchGetProductPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue })
|
||||
: await fetchGetProjectPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue });
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return {
|
||||
total: 0,
|
||||
list: []
|
||||
};
|
||||
}
|
||||
|
||||
const list = result.data.list.map(item => {
|
||||
if (props.domainConfig.domainKey === 'product') {
|
||||
const product = item as Api.Product.Product;
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
createTime: product.createTime
|
||||
};
|
||||
}
|
||||
|
||||
const project = item as Api.Project.Project;
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.projectName,
|
||||
code: project.projectCode,
|
||||
createTime: project.createTime
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
total: result.data.total,
|
||||
list
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
|
||||
const keywordValue = keyword.value.trim() || undefined;
|
||||
const firstPage = await fetchObjectOptionsPage(1, keywordValue);
|
||||
const pageCount = Math.ceil(firstPage.total / OBJECT_SWITCHER_PAGE_SIZE);
|
||||
const restPages =
|
||||
pageCount > 1
|
||||
? await Promise.all(
|
||||
Array.from({ length: pageCount - 1 }, (_, index) => fetchObjectOptionsPage(index + 2, keywordValue))
|
||||
)
|
||||
: [];
|
||||
const list = [firstPage, ...restPages].flatMap(page => page.list);
|
||||
|
||||
loading.value = false;
|
||||
options.value = sortByCreateTimeDesc(list);
|
||||
}
|
||||
|
||||
function handleVisibleChange(value: boolean) {
|
||||
visible.value = value;
|
||||
|
||||
if (value) {
|
||||
expanded.value = false;
|
||||
loadOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(option: ObjectOption) {
|
||||
if (option.id === objectContextStore.objectId) {
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
switchingId.value = option.id;
|
||||
const result = await objectContextStore.switchContext(props.domainConfig, option.id);
|
||||
switchingId.value = '';
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
const query = {
|
||||
...route.query,
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: option.id
|
||||
};
|
||||
const targetLocation = route.name ? { name: route.name, query } : { path: route.path, query };
|
||||
|
||||
await router.push(targetLocation);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => keyword.value,
|
||||
() => {
|
||||
if (!visible.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
expanded.value = Boolean(keyword.value.trim());
|
||||
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
|
||||
searchTimer = setTimeout(() => {
|
||||
loadOptions();
|
||||
}, 250);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
:visible="visible"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="300"
|
||||
popper-class="object-context-switcher__popper"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<template #reference>
|
||||
<button type="button" class="object-context-switcher__trigger" :class="{ 'is-open': visible }">
|
||||
<span class="object-context-switcher__trigger-label">{{ objectContextStore.objectName }}</span>
|
||||
<icon-ep:sort class="object-context-switcher__trigger-icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="object-context-switcher__panel">
|
||||
<ElInput v-model="keyword" clearable :placeholder="placeholder" class="object-context-switcher__search">
|
||||
<template #suffix>
|
||||
<ElIcon>
|
||||
<Search />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<div v-loading="loading" class="object-context-switcher__list">
|
||||
<button
|
||||
v-for="item in displayOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="object-context-switcher__item"
|
||||
:class="{ 'is-active': item.id === objectContextStore.objectId }"
|
||||
:disabled="switchingId === item.id"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<span class="object-context-switcher__item-icon">
|
||||
<icon-ep:box v-if="isProductDomain" />
|
||||
<icon-ep:folder v-else />
|
||||
</span>
|
||||
<span class="object-context-switcher__item-main">
|
||||
<span class="object-context-switcher__item-name">{{ item.name }}</span>
|
||||
<span v-if="item.code" class="object-context-switcher__item-code">{{ item.code }}</span>
|
||||
</span>
|
||||
<icon-ep:check v-if="item.id === objectContextStore.objectId" class="object-context-switcher__check" />
|
||||
</button>
|
||||
|
||||
<ElEmpty v-if="!loading && !displayOptions.length" :description="`暂无可选${domainLabel}`" :image-size="54" />
|
||||
</div>
|
||||
|
||||
<button v-if="showAllEntry" type="button" class="object-context-switcher__all" @click="expanded = true">
|
||||
<span>{{ allLabel }}</span>
|
||||
<span class="object-context-switcher__all-meta">{{ hiddenCount }} 个更多</span>
|
||||
<icon-ep:arrow-right class="object-context-switcher__all-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.object-context-switcher__trigger {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 16rem;
|
||||
height: 32px;
|
||||
gap: 6px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger:hover,
|
||||
.object-context-switcher__trigger.is-open {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-context-switcher__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.object-context-switcher__search {
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
.object-context-switcher__list {
|
||||
min-height: 84px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.object-context-switcher__item {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.object-context-switcher__item:hover,
|
||||
.object-context-switcher__item.is-active {
|
||||
background: rgb(59 130 246 / 10%);
|
||||
}
|
||||
|
||||
.object-context-switcher__item:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.object-context-switcher__item-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-name,
|
||||
.object-context-switcher__item-code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-code {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.object-context-switcher__check {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.object-context-switcher__all {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% + 24px);
|
||||
height: 38px;
|
||||
gap: 8px;
|
||||
margin: 0 -12px -12px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: transparent;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.object-context-switcher__all:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.object-context-switcher__all-meta {
|
||||
flex: 1;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.object-context-switcher__all-arrow {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.object-context-switcher__popper.el-popover) {
|
||||
padding: 12px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 12px 28px rgb(15 23 42 / 10%),
|
||||
0 2px 8px rgb(15 23 42 / 6%);
|
||||
}
|
||||
</style>
|
||||
@@ -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)]"
|
||||
></div>
|
||||
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
||||
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
|
||||
<ObjectContextSwitcher v-if="currentObjectContextDomain" :domain-config="currentObjectContextDomain" />
|
||||
</div>
|
||||
<div
|
||||
v-if="showObjectContextInfo && headerMenus.length"
|
||||
@@ -208,28 +209,6 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-object-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-object-tag__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 14rem;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(148 163 184 / 26%);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-nav-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -205,6 +205,41 @@ type RequirementResponse = Omit<
|
||||
};
|
||||
|
||||
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
||||
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<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
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<RequirementPageResponse>({
|
||||
@@ -337,17 +417,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 关闭需求 */
|
||||
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/close`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取需求可执行的状态动作列表 */
|
||||
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
||||
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
||||
@@ -379,16 +448,43 @@ export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Produ
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取需求生命周期信息 */
|
||||
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
|
||||
const result = await request<Api.Product.RequirementLifecycleInfo>({
|
||||
/** 提交产品需求评审 */
|
||||
export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/lifecycle`,
|
||||
method: 'get',
|
||||
params: { requirementId, productId }
|
||||
url: `${REQUIREMENT_PREFIX}/review/submit`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleInfo>, data => data);
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 获取产品需求评审记录 */
|
||||
export async function fetchGetProductRequirementReview(productId: string, requirementId: string) {
|
||||
const result = await request<RequirementReviewResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/review/get`,
|
||||
method: 'get',
|
||||
params: { productId, requirementId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RequirementReviewResponse>, normalizeRequirementReview);
|
||||
}
|
||||
|
||||
/** 获取产品概览需求池实时看板 */
|
||||
export async function fetchGetProductRequirementDashboard(productId: string) {
|
||||
const result = await request<ProductRequirementDashboardResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/dashboard`,
|
||||
method: 'get',
|
||||
params: { productId }
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<ProductRequirementDashboardResponse>,
|
||||
normalizeProductRequirementDashboard
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取需求所有状态字典 */
|
||||
@@ -402,18 +498,7 @@ export async function fetchGetRequirementStatusDict() {
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 获取需求终止态状态字典 */
|
||||
export async function fetchGetRequirementTerminalStatusDict() {
|
||||
const result = await request<Api.Product.RequirementStatusDict[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 判断产品需求是否已分流生成项目需求 */
|
||||
/** 判断产品需求是否已指派并生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
@@ -423,7 +508,7 @@ export async function fetchHasDispatchedProjectRequirement(requirementId: string
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量判断产品需求是否已分流生成项目需求 */
|
||||
/** 批量判断产品需求是否已指派并生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
|
||||
...safeJsonRequestConfig,
|
||||
|
||||
@@ -832,6 +832,19 @@ type ProjectRequirementResponse = Omit<
|
||||
};
|
||||
|
||||
type ProjectRequirementPageResponse = Api.Project.PageResult<ProjectRequirementResponse>;
|
||||
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<Api.Project.ProjectRequirementModule, 'id' | 'parentId' | 'projectId'> & {
|
||||
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<Api.Project.ProjectRequirementLifecycleInfo>({
|
||||
/** 提交项目需求评审 */
|
||||
export async function fetchSubmitProjectRequirementReview(data: Api.Project.ProjectRequirementReviewSubmitParams) {
|
||||
const result = await request<string | number>({
|
||||
...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<Api.Project.ProjectRequirementLifecycleInfo>, data => data);
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 获取项目需求评审记录 */
|
||||
export async function fetchGetProjectRequirementReview(projectId: string, requirementId: string) {
|
||||
const result = await request<ProjectRequirementReviewResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/review/get`,
|
||||
method: 'get',
|
||||
params: { projectId, requirementId }
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<ProjectRequirementReviewResponse>,
|
||||
normalizeProjectRequirementReview
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取项目需求状态字典 */
|
||||
@@ -1053,17 +1097,6 @@ export async function fetchGetProjectRequirementStatusDict() {
|
||||
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[]>({
|
||||
|
||||
@@ -445,7 +445,7 @@ export function fetchBatchDeletePost(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 获取用户简单列表(用于用户选择下拉框) */
|
||||
export function fetchGetUserSimpleList() {
|
||||
export async function fetchGetUserSimpleList() {
|
||||
return request<UserSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_PREFIX}/simple-list`,
|
||||
|
||||
6
src/typings/api/dict.d.ts
vendored
6
src/typings/api/dict.d.ts
vendored
@@ -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<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
||||
|
||||
/** dict data save params */
|
||||
type SaveDictDataParams = Pick<DictData, 'label' | 'sign' | 'value' | 'dictType' | 'sort' | 'status'> & {
|
||||
type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status'> & {
|
||||
remark?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
105
src/typings/api/product.d.ts
vendored
105
src/typings/api/product.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
// ========== 请求参数类型 ==========
|
||||
|
||||
/** 需求分页查询参数 */
|
||||
|
||||
65
src/typings/api/project.d.ts
vendored
65
src/typings/api/project.d.ts
vendored
@@ -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<PageParams, 'pageNo' | 'pageSize'> &
|
||||
|
||||
5
src/typings/components.d.ts
vendored
5
src/typings/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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: '最近动态时间',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
|
||||
import {
|
||||
fetchGetProduct,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProductRequirementDashboard,
|
||||
fetchGetProductSettings
|
||||
} from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import ProductActivityTimelinePanel from './modules/product-activity-timeline-panel.vue';
|
||||
@@ -11,7 +16,7 @@ import {
|
||||
buildRequirementPoolSummary,
|
||||
getProductHomepageExtensionModules
|
||||
} from './homepage';
|
||||
import { productHomepageExtensionMock, productRequirementPoolMock } from './mock';
|
||||
import { productHomepageExtensionMock } from './mock';
|
||||
|
||||
defineOptions({ name: 'ProductDashboard' });
|
||||
|
||||
@@ -22,11 +27,12 @@ const pageLoading = ref(false);
|
||||
const productDetail = ref<Api.Product.Product | null>(null);
|
||||
const settings = ref<Api.Product.ProductSettings | null>(null);
|
||||
const members = ref<Api.Product.ProductMember[]>([]);
|
||||
const requirementDashboard = ref<Api.Product.ProductRequirementDashboard | null>(null);
|
||||
const latestActivityTime = ref('');
|
||||
|
||||
const requirementPoolSummary = computed(() => buildRequirementPoolSummary(productRequirementPoolMock.summary));
|
||||
const requirementPoolSummary = computed(() => buildRequirementPoolSummary(requirementDashboard.value?.summary));
|
||||
const requirementPoolRecentChanges = computed(() =>
|
||||
buildRequirementPoolRecentChanges(productRequirementPoolMock.recentChanges)
|
||||
buildRequirementPoolRecentChanges(requirementDashboard.value?.recentChanges)
|
||||
);
|
||||
const homepageBanner = computed(() =>
|
||||
buildProductHomepageBanner({
|
||||
@@ -84,26 +90,33 @@ const kpiCards = computed<KpiCard[]>(() =>
|
||||
);
|
||||
|
||||
const distributionIcons: Record<string, string> = {
|
||||
待处理: 'mdi:timer-sand',
|
||||
分析中: 'mdi:magnify-scan',
|
||||
已规划: 'mdi:calendar-check-outline',
|
||||
已完成: 'mdi:check-circle-outline'
|
||||
等待处理: 'mdi:timer-sand',
|
||||
等待认领: 'mdi:magnify-scan',
|
||||
等待评审: 'mdi:calendar-check-outline',
|
||||
等待指派: 'mdi:check-circle-outline'
|
||||
};
|
||||
|
||||
const distributionHints: Record<string, string> = {
|
||||
等待处理: '需要执行认领、评审、指派动作的重要需求。',
|
||||
等待认领: '需要执行认领动作的需求,一般来自工单流转。',
|
||||
等待评审: '需要执行评审动作的需求。',
|
||||
等待指派: '需要执行指派动作的需求,包括“待指派”和“已评审”两种状态。'
|
||||
};
|
||||
|
||||
const distributionWithIcons = computed(() =>
|
||||
requirementPoolSummary.value.distribution.map((item, index) => ({
|
||||
...item,
|
||||
icon: distributionIcons[item.label] || 'mdi:circle-outline',
|
||||
hint: distributionHints[item.label] || '',
|
||||
tone: ['amber', 'sky', 'violet', 'emerald'][index] || 'slate'
|
||||
}))
|
||||
);
|
||||
|
||||
const poolCompletionRate = computed(() => {
|
||||
const { total, distribution } = requirementPoolSummary.value;
|
||||
const done = Number(distribution.find(item => item.label === '已完成')?.value || 0);
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.max(0, Math.round((done / total) * 100)));
|
||||
});
|
||||
function getRecentChangeClass(actionType: Api.Product.ProductRequirementDashboardRecentChangeActionType) {
|
||||
return `product-overview__change--${actionType.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
const poolCompletionRate = computed(() => requirementPoolSummary.value.completionRate);
|
||||
|
||||
const poolProgressColor = computed(() => [
|
||||
{ color: '#f59e0b', percentage: 30 },
|
||||
@@ -129,15 +142,17 @@ async function loadDashboardData(objectId: string) {
|
||||
pageLoading.value = true;
|
||||
|
||||
try {
|
||||
const [productResult, settingsResult, membersResult] = await Promise.all([
|
||||
const [productResult, settingsResult, membersResult, requirementDashboardResult] = await Promise.all([
|
||||
fetchGetProduct(objectId),
|
||||
fetchGetProductSettings(objectId),
|
||||
fetchGetProductMembers(objectId)
|
||||
fetchGetProductMembers(objectId),
|
||||
fetchGetProductRequirementDashboard(objectId)
|
||||
]);
|
||||
|
||||
productDetail.value = productResult.error ? null : productResult.data || null;
|
||||
settings.value = settingsResult.error ? null : settingsResult.data || null;
|
||||
members.value = membersResult.error ? [] : membersResult.data || [];
|
||||
requirementDashboard.value = requirementDashboardResult.error ? null : requirementDashboardResult.data || null;
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
@@ -150,6 +165,7 @@ watch(
|
||||
productDetail.value = null;
|
||||
settings.value = null;
|
||||
members.value = [];
|
||||
requirementDashboard.value = null;
|
||||
latestActivityTime.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -291,17 +307,15 @@ watch(
|
||||
</div>
|
||||
|
||||
<ul class="product-overview__pool-distribution">
|
||||
<li
|
||||
v-for="item in distributionWithIcons"
|
||||
:key="item.label"
|
||||
:class="`product-overview__pool-distribution-item--${item.tone}`"
|
||||
>
|
||||
<span class="product-overview__pool-distribution-icon">
|
||||
<SvgIcon :icon="item.icon" />
|
||||
</span>
|
||||
<span class="product-overview__pool-distribution-label">{{ item.label }}</span>
|
||||
<strong class="product-overview__pool-distribution-value">{{ item.value }}</strong>
|
||||
</li>
|
||||
<ElTooltip v-for="item in distributionWithIcons" :key="item.label" :content="item.hint" placement="top">
|
||||
<li :class="`product-overview__pool-distribution-item--${item.tone}`">
|
||||
<span class="product-overview__pool-distribution-icon">
|
||||
<SvgIcon :icon="item.icon" />
|
||||
</span>
|
||||
<span class="product-overview__pool-distribution-label">{{ item.label }}</span>
|
||||
<strong class="product-overview__pool-distribution-value">{{ item.value }}</strong>
|
||||
</li>
|
||||
</ElTooltip>
|
||||
</ul>
|
||||
</ElCard>
|
||||
</section>
|
||||
@@ -315,14 +329,19 @@ watch(
|
||||
<SvgIcon icon="mdi:swap-horizontal-circle-outline" />
|
||||
</span>
|
||||
<div class="product-overview__panel-head-text">
|
||||
<h3>需求池最近变化</h3>
|
||||
<p>需求新增、状态流转、关闭情况</p>
|
||||
<h3>需求池最新重要变化</h3>
|
||||
<p>需求新增、需求删除、状态流转</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="requirementPoolRecentChanges.length" class="product-overview__changes-list">
|
||||
<article v-for="item in requirementPoolRecentChanges" :key="item.id" class="product-overview__change">
|
||||
<article
|
||||
v-for="item in requirementPoolRecentChanges"
|
||||
:key="item.id"
|
||||
class="product-overview__change"
|
||||
:class="getRecentChangeClass(item.actionType)"
|
||||
>
|
||||
<div class="product-overview__change-meta">
|
||||
<span class="product-overview__change-action">{{ item.actionLabel }}</span>
|
||||
<time class="product-overview__change-time">{{ item.time }}</time>
|
||||
@@ -330,7 +349,7 @@ watch(
|
||||
<strong class="product-overview__change-title">{{ item.title }}</strong>
|
||||
<span class="product-overview__change-status">
|
||||
<SvgIcon icon="mdi:circle-medium" />
|
||||
当前状态 · {{ item.statusLabel }}
|
||||
{{ item.content }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1107,12 +1126,30 @@ watch(
|
||||
background: linear-gradient(180deg, #14b8a6, #10b981);
|
||||
}
|
||||
|
||||
.product-overview__change--delete::before {
|
||||
background: linear-gradient(180deg, #b91c1c, #991b1b);
|
||||
}
|
||||
|
||||
.product-overview__change--status-terminal::before {
|
||||
background: linear-gradient(180deg, #1d4ed8, #1e40af);
|
||||
}
|
||||
|
||||
.product-overview__change:hover {
|
||||
transform: translateX(2px);
|
||||
border-color: rgb(20 184 166 / 40%);
|
||||
box-shadow: 0 10px 20px -16px rgb(15 118 110 / 35%);
|
||||
}
|
||||
|
||||
.product-overview__change--delete:hover {
|
||||
border-color: rgb(185 28 28 / 34%);
|
||||
box-shadow: 0 10px 20px -16px rgb(153 27 27 / 30%);
|
||||
}
|
||||
|
||||
.product-overview__change--status-terminal:hover {
|
||||
border-color: rgb(29 78 216 / 34%);
|
||||
box-shadow: 0 10px 20px -16px rgb(30 64 175 / 30%);
|
||||
}
|
||||
|
||||
.product-overview__change-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1131,6 +1168,16 @@ watch(
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.product-overview__change--delete .product-overview__change-action {
|
||||
background: rgb(185 28 28 / 10%);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.product-overview__change--status-terminal .product-overview__change-action {
|
||||
background: rgb(29 78 216 / 10%);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.product-overview__change-time {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,42 +1,4 @@
|
||||
import type {
|
||||
ProductHomepageExtensionModule,
|
||||
ProductRequirementPoolRecentChangeSource,
|
||||
ProductRequirementPoolSummarySource
|
||||
} from './homepage';
|
||||
|
||||
export const productRequirementPoolMock = {
|
||||
summary: {
|
||||
total: 18,
|
||||
todo: 3,
|
||||
analyzing: 5,
|
||||
planned: 6,
|
||||
done: 4,
|
||||
highPriorityTodo: 2
|
||||
} satisfies ProductRequirementPoolSummarySource,
|
||||
recentChanges: [
|
||||
{
|
||||
id: 'req-1001',
|
||||
title: '支持产品资料标签归档',
|
||||
actionLabel: '新增需求',
|
||||
time: '2026-04-22 16:20:00',
|
||||
statusLabel: '待处理'
|
||||
},
|
||||
{
|
||||
id: 'req-1002',
|
||||
title: '统一需求池状态颜色',
|
||||
actionLabel: '状态流转',
|
||||
time: '2026-04-23 11:00:00',
|
||||
statusLabel: '分析中'
|
||||
},
|
||||
{
|
||||
id: 'req-1003',
|
||||
title: '补充对象首页需求池统计接口',
|
||||
actionLabel: '关闭需求',
|
||||
time: '2026-04-23 14:30:00',
|
||||
statusLabel: '已完成'
|
||||
}
|
||||
] satisfies ProductRequirementPoolRecentChangeSource[]
|
||||
};
|
||||
import type { ProductHomepageExtensionModule } from './homepage';
|
||||
|
||||
export const productHomepageExtensionMock = [
|
||||
{
|
||||
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
fetchChangeRequirementStatus,
|
||||
fetchDeleteRequirement,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProductRequirementDashboard,
|
||||
fetchGetProjectListByProductId,
|
||||
fetchGetRequirementAllowedTransitionsBatch,
|
||||
fetchGetRequirementStatusDict,
|
||||
fetchGetRequirementTerminalStatusDict,
|
||||
fetchGetRequirementTree,
|
||||
fetchHasDispatchedProjectRequirementBatch
|
||||
} from '@/service/api';
|
||||
@@ -42,7 +42,11 @@ 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 RequirementReviewDialog from './modules/requirement-review-dialog.vue';
|
||||
import RequirementReviewRecordDialog from './modules/requirement-review-record-dialog.vue';
|
||||
import IconMdiSync from '~icons/mdi/sync';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'ProductRequirement' });
|
||||
|
||||
@@ -50,10 +54,20 @@ const router = useRouter();
|
||||
const { currentObjectId } = useCurrentProduct();
|
||||
const { hasObjectAuth } = useAuth();
|
||||
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const terminalStatusOptions = ref<string[]>([]);
|
||||
const statusDict = ref<Api.Product.RequirementStatusDict[]>([]);
|
||||
const projectOptions = ref<Api.Project.Project[]>([]);
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
return statusDict.value.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
});
|
||||
|
||||
const statusMetaMap = computed(() => {
|
||||
return new Map(statusDict.value.map(item => [item.statusCode, item]));
|
||||
});
|
||||
|
||||
const projectNameMap = computed(() => {
|
||||
return new Map(projectOptions.value.map(item => [item.id, item.projectName]));
|
||||
});
|
||||
@@ -64,25 +78,11 @@ async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetRequirementStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
statusOptions.value = [];
|
||||
statusDict.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
statusOptions.value = data.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTerminalStatusOptions() {
|
||||
const { error, data } = await fetchGetRequirementTerminalStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
terminalStatusOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
terminalStatusOptions.value = data.map(item => item.statusCode);
|
||||
statusDict.value = data;
|
||||
}
|
||||
|
||||
async function loadProjectOptions() {
|
||||
@@ -106,12 +106,6 @@ function getStatusLabel(statusCode: string) {
|
||||
return item ? item.label : statusCode;
|
||||
}
|
||||
|
||||
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
|
||||
0: 'danger',
|
||||
1: 'warning',
|
||||
2: 'primary',
|
||||
3: 'info'
|
||||
};
|
||||
const hasDispatchedMap = ref<Record<string, boolean>>({});
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
@@ -131,7 +125,23 @@ function formatDate(value?: string | null) {
|
||||
}
|
||||
|
||||
function isTerminalStatus(statusCode: string) {
|
||||
return terminalStatusOptions.value.includes(statusCode);
|
||||
return Boolean(statusMetaMap.value.get(statusCode)?.terminalFlag);
|
||||
}
|
||||
|
||||
function canEditRequirement(row: Api.Product.Requirement) {
|
||||
return Boolean(statusMetaMap.value.get(row.statusCode)?.allowEdit);
|
||||
}
|
||||
|
||||
function isReviewAction(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
|
||||
return row.statusCode === 'pending_review' && ['pass_review', 'reject_review'].includes(action.actionCode);
|
||||
}
|
||||
|
||||
function isReviewTransitionAction(actionCode: string) {
|
||||
return ['pass_review', 'reject_review'].includes(actionCode);
|
||||
}
|
||||
|
||||
function canViewReviewRecord(row: Api.Product.Requirement) {
|
||||
return row.reviewRequired === 1 && !['pending_claim', 'pending_review'].includes(row.statusCode);
|
||||
}
|
||||
|
||||
function canSplitRequirement(row: Api.Product.Requirement) {
|
||||
@@ -142,7 +152,7 @@ function canSplitRequirement(row: Api.Product.Requirement) {
|
||||
if (hasDispatched) {
|
||||
return false;
|
||||
}
|
||||
return row.statusCode === 'pending_dispatch' || row.statusCode === 'implementing';
|
||||
return ['pending_dispatch', 'reviewed', 'implementing'].includes(row.statusCode);
|
||||
}
|
||||
|
||||
function canDeleteRequirement(row: Api.Product.Requirement) {
|
||||
@@ -156,6 +166,7 @@ const memberOptions = ref<Api.Product.ProductMember[]>([]);
|
||||
const requirementTableRef = ref<TableInstance>();
|
||||
const loading = ref(false);
|
||||
const treeData = ref<Api.Product.Requirement[]>([]);
|
||||
const requirementDisplayTotal = ref(0);
|
||||
const pagination = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
@@ -183,6 +194,10 @@ const splitParentRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
const actionVisible = ref(false);
|
||||
const actionRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
const currentAction = ref<Api.Product.RequirementLifecycleAction | null>(null);
|
||||
const reviewVisible = ref(false);
|
||||
const reviewRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
const reviewRecordVisible = ref(false);
|
||||
const reviewRecordRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
|
||||
interface MemberUserOption {
|
||||
id: string;
|
||||
@@ -212,14 +227,6 @@ function getMemberLabel(userId?: string | null) {
|
||||
return memberLabelMap.value.get(String(userId)) || String(userId);
|
||||
}
|
||||
|
||||
function getPriorityTagType(priority?: number | null): UI.ThemeColor {
|
||||
if (priority === null || priority === undefined) {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
return priorityTagTypeMap[priority] || 'info';
|
||||
}
|
||||
|
||||
function flattenTree(nodes: Api.Product.Requirement[]): Api.Product.Requirement[] {
|
||||
const result: Api.Product.Requirement[] = [];
|
||||
for (const node of nodes) {
|
||||
@@ -360,14 +367,12 @@ const columns = computed(() => [
|
||||
label: '优先级',
|
||||
width: 75,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (
|
||||
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
|
||||
)
|
||||
formatter: (row: Api.Product.Requirement) => <DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} />
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
width: 90,
|
||||
width: 100,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (
|
||||
<ElTag type={getRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
|
||||
@@ -376,13 +381,13 @@ const columns = computed(() => [
|
||||
{
|
||||
prop: 'category',
|
||||
label: '需求类型',
|
||||
minWidth: 100,
|
||||
minWidth: 80,
|
||||
formatter: (row: Api.Product.Requirement) => row.category
|
||||
},
|
||||
{
|
||||
prop: 'sourceType',
|
||||
label: '需求来源',
|
||||
minWidth: 100,
|
||||
minWidth: 80,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (
|
||||
<DictText dictCode={RDMS_REQ_SOURCE_TYPE_DICT_CODE} value={row.sourceType} />
|
||||
@@ -463,25 +468,32 @@ const columns = computed(() => [
|
||||
onClick: () => void;
|
||||
}[] = [];
|
||||
|
||||
if (hasObjectAuth('project:product:split')) {
|
||||
if (hasObjectAuth('project:product:query') && canViewReviewRecord(row)) {
|
||||
actions.push({
|
||||
key: 'reviewRecord',
|
||||
label: '查看评审记录',
|
||||
icon: markRaw(IconMdiEyeOutline),
|
||||
type: 'primary',
|
||||
onClick: () => handleViewReviewRecord(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasObjectAuth('project:product:split') && canSplitRequirement(row)) {
|
||||
actions.push({
|
||||
key: 'split',
|
||||
label: '拆分',
|
||||
icon: ACTION_ICON_MAP.split,
|
||||
type: ACTION_TYPE_MAP.split,
|
||||
disabled: !canSplitRequirement(row),
|
||||
onClick: () => openSplit(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasObjectAuth('project:product:update')) {
|
||||
const canEdit = !isTerminalStatus(row.statusCode) && row.statusCode !== 'accepted' && !row.implementProjectId;
|
||||
if (hasObjectAuth('project:product:update') && canEditRequirement(row)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: ACTION_ICON_MAP.edit,
|
||||
type: ACTION_TYPE_MAP.edit,
|
||||
disabled: !canEdit,
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
}
|
||||
@@ -489,7 +501,7 @@ const columns = computed(() => [
|
||||
if (row.implementProjectId) {
|
||||
actions.push({
|
||||
key: 'forward',
|
||||
label: '前往项目侧',
|
||||
label: '跳转',
|
||||
icon: ACTION_ICON_MAP.forward,
|
||||
type: 'primary',
|
||||
onClick: () => handleForwardToProjectRequirement(row)
|
||||
@@ -497,13 +509,24 @@ const columns = computed(() => [
|
||||
}
|
||||
|
||||
const lifecycleActions = getRowActions(row);
|
||||
const hasReviewAuth = hasObjectAuth('project:product:review');
|
||||
const hasStatusAuth = hasObjectAuth('project:product:status');
|
||||
|
||||
if (hasReviewAuth && lifecycleActions.some(action => isReviewAction(row, action))) {
|
||||
actions.push({
|
||||
key: 'review',
|
||||
label: '评审',
|
||||
icon: ACTION_ICON_MAP.pass_review,
|
||||
type: 'primary',
|
||||
onClick: () => openReview(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasStatusAuth) {
|
||||
const nonTerminalActions: Api.Product.RequirementLifecycleAction[] = [];
|
||||
const terminalActions: Api.Product.RequirementLifecycleAction[] = [];
|
||||
|
||||
for (const action of lifecycleActions) {
|
||||
for (const action of lifecycleActions.filter(item => !isReviewTransitionAction(item.actionCode))) {
|
||||
const code = action.actionCode as RequirementStatusActionCode;
|
||||
if (isRequirementActionTerminal(code)) {
|
||||
terminalActions.push(action);
|
||||
@@ -535,23 +558,29 @@ const columns = computed(() => [
|
||||
|
||||
return (
|
||||
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{actions.map(action => {
|
||||
const IconComponent = action.icon as any;
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
class="requirement-action-icon-btn"
|
||||
type={action.type}
|
||||
disabled={action.disabled}
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
<IconComponent class="text-18px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
);
|
||||
})}
|
||||
{actions.length === 0 ? (
|
||||
<ElButton link size="small" class="requirement-action-icon-btn" type="primary" disabled>
|
||||
<IconMdiPencilOutline class="text-18px" />
|
||||
</ElButton>
|
||||
) : (
|
||||
actions.map(action => {
|
||||
const IconComponent = action.icon as any;
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
class="requirement-action-icon-btn"
|
||||
type={action.type}
|
||||
disabled={action.disabled}
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
<IconComponent class="text-18px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -633,11 +662,27 @@ async function loadTreeData() {
|
||||
pagination.total = data.total;
|
||||
}
|
||||
|
||||
async function loadRequirementDisplayTotal() {
|
||||
if (!currentObjectId.value) {
|
||||
requirementDisplayTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProductRequirementDashboard(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
requirementDisplayTotal.value = pagination.total;
|
||||
return;
|
||||
}
|
||||
|
||||
requirementDisplayTotal.value = data.summary.total;
|
||||
}
|
||||
|
||||
async function reloadTable() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadTreeData();
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll()]);
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll(), loadRequirementDisplayTotal()]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -698,6 +743,20 @@ function openSplit(row: Api.Product.Requirement) {
|
||||
splitVisible.value = true;
|
||||
}
|
||||
|
||||
function openReview(row: Api.Product.Requirement) {
|
||||
reviewRequirement.value = row;
|
||||
reviewVisible.value = true;
|
||||
}
|
||||
|
||||
function handleViewReviewRecord(row: Api.Product.Requirement) {
|
||||
if (!canViewReviewRecord(row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reviewRecordRequirement.value = row;
|
||||
reviewRecordVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleForwardToProjectRequirement(row: Api.Product.Requirement) {
|
||||
if (!row.implementProjectId) return;
|
||||
|
||||
@@ -715,7 +774,7 @@ function handleActionClick(row: Api.Product.Requirement, action: Api.Product.Req
|
||||
if (
|
||||
!isRequirementActionNeedReviewChoice(actionCode) &&
|
||||
!isRequirementActionNeedProject(actionCode) &&
|
||||
!isRequirementActionTerminal(actionCode)
|
||||
!action.needReason
|
||||
) {
|
||||
handleDirectAction(row, action);
|
||||
return;
|
||||
@@ -814,24 +873,30 @@ async function handleSplitSubmitted() {
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleReviewSubmitted() {
|
||||
reviewVisible.value = false;
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async id => {
|
||||
if (id) {
|
||||
await Promise.all([loadMembers(), loadTreeData(), loadProjectOptions()]);
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll()]);
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll(), loadRequirementDisplayTotal()]);
|
||||
} else {
|
||||
memberOptions.value = [];
|
||||
treeData.value = [];
|
||||
projectOptions.value = [];
|
||||
allowedTransitionsMap.value = new Map();
|
||||
requirementDisplayTotal.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
await loadStatusOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -858,7 +923,7 @@ onMounted(async () => {
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p>需求列表</p>
|
||||
<ElTag effect="plain">{{ pagination.total }} 条</ElTag>
|
||||
<ElTag effect="plain">{{ requirementDisplayTotal }} 条</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
@@ -950,6 +1015,21 @@ onMounted(async () => {
|
||||
:project-options="projectOptions"
|
||||
@submitted="handleActionSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementReviewDialog
|
||||
v-model:visible="reviewVisible"
|
||||
:product-id="currentObjectId || ''"
|
||||
:requirement="reviewRequirement"
|
||||
:member-options="memberOptions"
|
||||
@submitted="handleReviewSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementReviewRecordDialog
|
||||
v-model:visible="reviewRecordVisible"
|
||||
:product-id="currentObjectId || ''"
|
||||
:requirement="reviewRecordRequirement"
|
||||
:member-options="memberOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -967,6 +1047,8 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:deep(.requirement-title--terminal) {
|
||||
|
||||
@@ -156,37 +156,31 @@ function handleToggle() {
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditing && hasAnyActionPermission" 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" />
|
||||
<ElTooltip v-if="hasObjectAuth('project:product:create')" content="新增子模块" placement="top">
|
||||
<ElButton link type="primary" class="module-tree-item__action-btn" @click="handleStartAddChild">
|
||||
<icon-mdi-plus class="text-14px" />
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem v-if="hasObjectAuth('project:product:create')" @click="handleStartAddChild">
|
||||
<div class="flex items-center gap-6px">
|
||||
<icon-ic-round-plus class="text-14px" />
|
||||
<span>新增子模块</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem v-if="!isRootModule && hasObjectAuth('project:product:update')" @click="handleStartEdit">
|
||||
<div class="flex items-center gap-6px">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:product:delete')"
|
||||
divided
|
||||
@click="handleDelete"
|
||||
>
|
||||
<div class="flex items-center gap-6px text-error">
|
||||
</ElTooltip>
|
||||
<ElTooltip v-if="!isRootModule && hasObjectAuth('project:product:update')" content="编辑" placement="top">
|
||||
<ElButton link type="primary" class="module-tree-item__action-btn" @click="handleStartEdit">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm
|
||||
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:product:delete')"
|
||||
title="确定删除该模块吗?"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="inline-flex" @click.stop>
|
||||
<ElTooltip content="删除" placement="top">
|
||||
<ElButton link type="danger" class="module-tree-item__action-btn">
|
||||
<icon-mdi-delete-outline class="text-14px" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,12 +384,15 @@ function handleToggle() {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.module-tree-item__more-btn {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
.module-tree-item__action-btn {
|
||||
padding: 2px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
margin-left: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.module-tree-item__more-btn:hover {
|
||||
background-color: #e2e8f0;
|
||||
.module-tree-item__action-btn:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,7 @@ import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
type RequirementStatusActionCode,
|
||||
isRequirementActionNeedProject,
|
||||
isRequirementActionNeedReviewChoice,
|
||||
isRequirementActionTerminal
|
||||
isRequirementActionNeedReviewChoice
|
||||
} from '../shared/requirement-master-data';
|
||||
|
||||
defineOptions({ name: 'RequirementActionDialog' });
|
||||
@@ -45,7 +44,7 @@ const isClaimAction = computed(() =>
|
||||
actionCode.value ? isRequirementActionNeedReviewChoice(actionCode.value) : false
|
||||
);
|
||||
const isDispatchAction = computed(() => (actionCode.value ? isRequirementActionNeedProject(actionCode.value) : false));
|
||||
const isTerminalAction = computed(() => (actionCode.value ? isRequirementActionTerminal(actionCode.value) : false));
|
||||
const needReason = computed(() => Boolean(props.action?.needReason));
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (!props.action) return '';
|
||||
@@ -55,7 +54,7 @@ const dialogTitle = computed(() => {
|
||||
|
||||
const reviewChoiceOptions = [
|
||||
{ label: '需要评审', value: 'claim_to_review', description: '认领后进入评审流程' },
|
||||
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入分流' }
|
||||
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入指派' }
|
||||
];
|
||||
|
||||
const rules = computed(() => {
|
||||
@@ -69,7 +68,7 @@ const rules = computed(() => {
|
||||
baseRules.implementProjectId = [createRequiredRule('请选择关联项目')];
|
||||
}
|
||||
|
||||
if (isTerminalAction.value) {
|
||||
if (needReason.value) {
|
||||
baseRules.reason = [createRequiredRule('请输入状态变更原因')];
|
||||
}
|
||||
|
||||
@@ -98,7 +97,7 @@ async function handleSubmit() {
|
||||
payload.implementProjectId = model.value.implementProjectId;
|
||||
}
|
||||
|
||||
if (isTerminalAction.value) {
|
||||
if (needReason.value) {
|
||||
payload.reason = model.value.reason.trim();
|
||||
}
|
||||
|
||||
@@ -142,7 +141,7 @@ async function handleSubmit() {
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-if="isTerminalAction" label="变更原因" prop="reason">
|
||||
<ElFormItem v-if="needReason" label="变更原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service/api';
|
||||
import { fetchCreateRequirement, fetchGetRequirementModuleTree, fetchGetUserSimpleList } 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 RequirementTreePicker, {
|
||||
type RequirementTreePickerNode
|
||||
} from '@/views/project/project/execution/components/requirement-tree-picker.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementCreateDialog' });
|
||||
@@ -36,18 +37,10 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | 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,9 +71,9 @@ interface Model {
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
moduleId: string | null;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
proposerId: string;
|
||||
currentHandlerUserId: string;
|
||||
@@ -90,34 +83,14 @@ interface Model {
|
||||
const submitting = ref(false);
|
||||
const loading = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
const allUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const memberUserOptions = computed(() => {
|
||||
return props.memberOptions.filter(m => m.status === 0);
|
||||
});
|
||||
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
|
||||
for (const module of modules) {
|
||||
const currentPath = `${parentPath}/${module.moduleName}`;
|
||||
options.push({
|
||||
label: currentPath,
|
||||
value: module.id || ''
|
||||
});
|
||||
if (module.children?.length) {
|
||||
walk(module.children, currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleTree.value.length > 0) {
|
||||
walk(moduleTree.value, '');
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
const moduleTreeOptions = computed<RequirementTreePickerNode[]>(() => mapModuleTree(moduleTree.value));
|
||||
|
||||
const rules = {
|
||||
title: [createRequiredRule('请输入需求名称')],
|
||||
@@ -163,9 +136,9 @@ function createDefaultModel(): Model {
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: props.defaultModuleId || '0',
|
||||
moduleId: props.defaultModuleId || null,
|
||||
category: '功能需求',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
proposerId: '',
|
||||
currentHandlerUserId: '',
|
||||
@@ -173,6 +146,16 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function mapModuleTree(modules: Api.Product.RequirementModule[]): RequirementTreePickerNode[] {
|
||||
return modules
|
||||
.filter(item => Boolean(item.id))
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
title: item.moduleName,
|
||||
children: item.children?.length ? mapModuleTree(item.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
@@ -189,8 +172,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const proposer = memberUserOptions.value.find(m => m.userId === model.value.proposerId);
|
||||
const proposerNickname = proposer?.userNickname || '';
|
||||
const proposer = allUserOptions.value.find(u => u.id === model.value.proposerId);
|
||||
const proposerNickname = proposer?.nickname || '';
|
||||
const handler = memberUserOptions.value.find(m => m.userId === model.value.currentHandlerUserId);
|
||||
const currentHandlerUserNickname = handler?.userNickname || '';
|
||||
|
||||
@@ -249,6 +232,13 @@ async function loadModuleTree() {
|
||||
moduleTree.value = data;
|
||||
}
|
||||
|
||||
async function loadAllUsers() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
if (!error && data) {
|
||||
allUserOptions.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
@@ -257,7 +247,7 @@ watch(
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
await loadModuleTree();
|
||||
await Promise.all([loadModuleTree(), loadAllUsers()]);
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
@@ -286,9 +276,11 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
<RequirementTreePicker
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTreeOptions"
|
||||
placeholder="搜索或选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
@@ -306,9 +298,12 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
@@ -322,14 +317,7 @@ watch(
|
||||
|
||||
<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>
|
||||
<ElOption v-for="item in allUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fetchGetProjectListByProductId,
|
||||
fetchGetRequirement,
|
||||
fetchGetRequirementModuleTree,
|
||||
fetchGetUserSimpleList,
|
||||
fetchUpdateRequirement
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
@@ -14,7 +15,11 @@ import BusinessAttachmentUploader from '@/components/custom/business-attachment-
|
||||
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 ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import RequirementTreePicker, {
|
||||
type RequirementTreePickerNode
|
||||
} from '@/views/project/project/execution/components/requirement-tree-picker.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementDetailDialog' });
|
||||
@@ -46,26 +51,19 @@ const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
|
||||
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
const { getLabel: getPriorityLabel } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
moduleId: string | null;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
proposerId: string;
|
||||
proposerNickname: string;
|
||||
@@ -80,6 +78,7 @@ const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
const projectOptions = ref<Api.Project.Project[]>([]);
|
||||
const allUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const isViewMode = computed(() => props.mode === 'view');
|
||||
@@ -100,6 +99,10 @@ const memberLabelMap = computed(() => {
|
||||
return new Map(memberUserOptions.value.map(item => [String(item.userId), item.userNickname]));
|
||||
});
|
||||
|
||||
const allUserLabelMap = computed(() => {
|
||||
return new Map(allUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
const moduleLabelMap = computed(() => {
|
||||
const map = new Map<string | undefined, string>();
|
||||
|
||||
@@ -120,28 +123,7 @@ const projectOptionsMap = computed(() => {
|
||||
return new Map(projectOptions.value.map(item => [String(item.id), item.projectName]));
|
||||
});
|
||||
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
|
||||
for (const module of modules) {
|
||||
const currentPath = `${parentPath}/${module.moduleName}`;
|
||||
options.push({
|
||||
label: currentPath,
|
||||
value: module.id || ''
|
||||
});
|
||||
if (module.children?.length) {
|
||||
walk(module.children, currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleTree.value.length > 0) {
|
||||
walk(moduleTree.value, '');
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
const moduleTreeOptions = computed<RequirementTreePickerNode[]>(() => mapModuleTree(moduleTree.value));
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
@@ -216,9 +198,9 @@ function createDefaultModel(): Model {
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: '0',
|
||||
moduleId: null,
|
||||
category: '',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
proposerId: '',
|
||||
proposerNickname: '',
|
||||
@@ -230,6 +212,16 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function mapModuleTree(modules: Api.Product.RequirementModule[]): RequirementTreePickerNode[] {
|
||||
return modules
|
||||
.filter(item => Boolean(item.id))
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
title: item.moduleName,
|
||||
children: item.children?.length ? mapModuleTree(item.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
@@ -322,9 +314,9 @@ function transformRequirementData(data: Api.Product.Requirement): typeof model.v
|
||||
description: data.description || null,
|
||||
attachments: data.attachments ? [...data.attachments] : [],
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
moduleId: data.moduleId || '0',
|
||||
moduleId: data.moduleId || null,
|
||||
category: data.category || '',
|
||||
priority: data.priority ?? null,
|
||||
priority: data.priority === null || data.priority === undefined ? null : String(data.priority),
|
||||
expectedTime: formatExpectedTime(data.expectedTime),
|
||||
proposerId: data.proposerId || '',
|
||||
proposerNickname: data.proposerNickname || '',
|
||||
@@ -367,6 +359,13 @@ async function loadRequirementDetail() {
|
||||
model.value = transformRequirementData(data);
|
||||
}
|
||||
|
||||
async function loadAllUsers() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
if (!error && data) {
|
||||
allUserOptions.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
@@ -374,7 +373,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadModuleTree(), loadProjectOptions()]);
|
||||
await Promise.all([loadModuleTree(), loadProjectOptions(), loadAllUsers()]);
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
@@ -414,11 +413,14 @@ watch(
|
||||
|
||||
<ElFormItem label="模块">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId || undefined) || '--'" />
|
||||
</template>
|
||||
<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>
|
||||
<RequirementTreePicker
|
||||
v-else
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTreeOptions"
|
||||
placeholder="搜索或选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
@@ -431,9 +433,13 @@ watch(
|
||||
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
|
||||
/>
|
||||
</template>
|
||||
<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>
|
||||
<DictSelect
|
||||
v-else
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
@@ -441,7 +447,7 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
<ReadonlyField :value="allUserLabelMap.get(model.proposerId) || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
@@ -512,7 +518,7 @@ watch(
|
||||
:disabled="isViewMode"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
:placeholder="isViewMode && isEmptyRichText(model.description) ? '--' : '请输入需求内容'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchSubmitProductRequirementReview } from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
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 ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import AttendeeUserPicker from '@/components/custom/attendee-user-picker.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementReviewDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
requirement: Api.Product.Requirement | null;
|
||||
memberOptions: Api.Product.ProductMember[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
interface Model {
|
||||
conclusion: Api.Product.RequirementReviewConclusion;
|
||||
attendees: Api.Product.RequirementReviewAttendeeItem[];
|
||||
requirementEstimatedHours: number | null;
|
||||
reviewTime: string | null;
|
||||
reviewContent: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
}
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
const submitting = ref(false);
|
||||
|
||||
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
|
||||
|
||||
const reviewConclusionOptions = [
|
||||
{ label: '通过评审', value: 0 as Api.Product.RequirementReviewConclusion },
|
||||
{ label: '不通过评审', value: 1 as Api.Product.RequirementReviewConclusion }
|
||||
];
|
||||
|
||||
const rules = {
|
||||
conclusion: [createRequiredRule('请选择评审结论')],
|
||||
attendees: [createRequiredRule('请选择参会人')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
conclusion: 0,
|
||||
attendees: [],
|
||||
requirementEstimatedHours: null,
|
||||
reviewTime: dayjs().format('YYYY-MM-DD'),
|
||||
reviewContent: null,
|
||||
attachments: []
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.productId || !props.requirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.userInfo.userId) {
|
||||
window.$message?.warning('未获取到当前登录用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Api.Product.RequirementReviewSubmitParams = {
|
||||
productId: props.productId,
|
||||
requirementId: props.requirement.id,
|
||||
operatorId: authStore.userInfo.userId,
|
||||
conclusion: model.value.conclusion,
|
||||
reviewContent: isEmptyRichText(model.value.reviewContent) ? null : (model.value.reviewContent ?? null),
|
||||
requirementEstimatedHours: model.value.requirementEstimatedHours,
|
||||
attendees: [...model.value.attendees],
|
||||
attachments: [...model.value.attachments],
|
||||
reviewTime: model.value.reviewTime
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const result = await fetchSubmitProductRequirementReview(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('评审提交成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="评审需求"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-review-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-review-dialog__col-left">
|
||||
<BusinessFormSection title="评审信息">
|
||||
<ElFormItem label="需求名称">
|
||||
<ReadonlyField :value="requirement?.title || '--'" />
|
||||
<!-- <ElInput :model-value="requirement?.title || ''" readonly placeholder="--" />-->
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审结论" prop="conclusion">
|
||||
<ElRadioGroup v-model="model.conclusion">
|
||||
<ElRadio
|
||||
v-for="item in reviewConclusionOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
border
|
||||
style="width: 165px"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="参会人" prop="attendees">
|
||||
<AttendeeUserPicker
|
||||
v-model="model.attendees"
|
||||
:team-options="memberUserOptions"
|
||||
team-tab-label="产品团队"
|
||||
:show-dept-tab="true"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求预估工时">
|
||||
<ElInputNumber
|
||||
v-model="model.requirementEstimatedHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
:precision="1"
|
||||
placeholder="请输入需求预估工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="实际评审日期">
|
||||
<ElDatePicker
|
||||
v-model="model.reviewTime"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择实际评审日期"
|
||||
class="requirement-review-dialog__date-picker"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-review-dialog__col-right">
|
||||
<BusinessFormSection title="评审内容">
|
||||
<ElFormItem class="requirement-review-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.reviewContent"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement-review"
|
||||
placeholder="请输入评审内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="会议资料">
|
||||
<ElFormItem class="requirement-review-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement-review"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-review-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__col-left,
|
||||
.requirement-review-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__desc-item,
|
||||
.requirement-review-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-review-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.requirement-review-dialog__date-picker.el-date-editor.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetProductRequirementReview } from '@/service/api';
|
||||
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 ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementReviewRecordDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
requirement: Api.Product.Requirement | null;
|
||||
memberOptions: Api.Product.ProductMember[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const reviewRecord = ref<Api.Product.RequirementReview | null>(null);
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('47vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
const ATTENDEE_VISIBLE_COUNT = 5;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
const operatorLabelMap = computed(() => {
|
||||
return new Map(props.memberOptions.map(item => [item.userId, item.userNickname]));
|
||||
});
|
||||
|
||||
const conclusionText = computed(() => {
|
||||
if (!reviewRecord.value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return reviewRecord.value.conclusion === 0 ? '通过评审' : '不通过评审';
|
||||
});
|
||||
|
||||
const operatorText = computed(() => {
|
||||
if (!reviewRecord.value?.operatorId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return operatorLabelMap.value.get(reviewRecord.value.operatorId) || reviewRecord.value.operatorId;
|
||||
});
|
||||
|
||||
const visibleAttendees = computed(() => reviewRecord.value?.attendees?.slice(0, ATTENDEE_VISIBLE_COUNT) ?? []);
|
||||
const overflowAttendees = computed(() => reviewRecord.value?.attendees?.slice(ATTENDEE_VISIBLE_COUNT) ?? []);
|
||||
|
||||
function formatExpectedTime(value?: string | number[] | null): string {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const [year, month, day] = value;
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
async function loadReviewRecord() {
|
||||
if (!props.productId || !props.requirement?.id) {
|
||||
reviewRecord.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProductRequirementReview(props.productId, props.requirement.id);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
reviewRecord.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
reviewRecord.value = data;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadReviewRecord();
|
||||
} else {
|
||||
reviewRecord.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="查看评审记录"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:loading="loading"
|
||||
:show-footer="true"
|
||||
>
|
||||
<template #footer="{ close }">
|
||||
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-if="!loading && !reviewRecord" description="暂无评审记录" />
|
||||
|
||||
<div v-else class="requirement-review-record-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-review-record-dialog__col-left">
|
||||
<BusinessFormSection title="评审信息">
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="需求名称">
|
||||
<ReadonlyField :value="requirement?.title || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审结论">
|
||||
<ReadonlyField :value="conclusionText" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审提交人">
|
||||
<ReadonlyField :value="operatorText" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="参会人">
|
||||
<div v-if="reviewRecord?.attendees?.length" class="requirement-review-record-dialog__tags">
|
||||
<ElTag v-for="item in visibleAttendees" :key="item.userId" effect="light">
|
||||
{{ item.nickname }}
|
||||
</ElTag>
|
||||
<ElPopover
|
||||
v-if="overflowAttendees.length"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="280"
|
||||
popper-class="requirement-review-record-dialog__attendee-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<button type="button" class="requirement-review-record-dialog__tag-more">
|
||||
+{{ overflowAttendees.length }} 更多
|
||||
</button>
|
||||
</template>
|
||||
<div class="requirement-review-record-dialog__attendee-overflow">
|
||||
<div class="requirement-review-record-dialog__attendee-overflow-head">
|
||||
另外
|
||||
<strong>{{ overflowAttendees.length }}</strong>
|
||||
人
|
||||
</div>
|
||||
<div class="requirement-review-record-dialog__attendee-overflow-tags">
|
||||
<ElTag v-for="item in overflowAttendees" :key="item.userId" effect="light">
|
||||
{{ item.nickname }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
<ReadonlyField v-else value="--" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求预估工时">
|
||||
<ReadonlyField
|
||||
:value="
|
||||
reviewRecord?.requirementEstimatedHours !== null &&
|
||||
reviewRecord?.requirementEstimatedHours !== undefined &&
|
||||
reviewRecord?.requirementEstimatedHours !== ''
|
||||
? String(reviewRecord.requirementEstimatedHours)
|
||||
: '--'
|
||||
"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="实际评审日期">
|
||||
<ReadonlyField :value="formatExpectedTime(reviewRecord?.reviewTime)" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提交时间">
|
||||
<ReadonlyField :value="formatDateTime(reviewRecord?.createTime)" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-review-record-dialog__col-right">
|
||||
<BusinessFormSection title="评审内容">
|
||||
<ElFormItem class="requirement-review-record-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
:model-value="reviewRecord?.reviewContent || ''"
|
||||
disabled
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement-review"
|
||||
placeholder="--"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="会议资料">
|
||||
<ElFormItem class="requirement-review-record-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
:model-value="reviewRecord?.attachments || []"
|
||||
disabled
|
||||
directory="requirement-review"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-review-record-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__col-left,
|
||||
.requirement-review-record-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__desc-item,
|
||||
.requirement-review-record-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tag-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 11.5px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tag-more:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-head {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-head strong {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-review-record-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -124,7 +124,7 @@ const fields = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="3" @search="emit('search')" @reset="emit('reset')" />
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @search="emit('search')" @reset="emit('reset')" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -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<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | 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(
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
|
||||
@@ -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<Api.Product.RequirementStatusCode, string> = {
|
||||
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<RequirementStatusActionCode, string> = {
|
||||
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<string, object> = {
|
||||
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<string, 'primary' | 'success' | 'danger'> =
|
||||
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<string, 'primary' | 'success' | 'danger'> =
|
||||
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<Api.Product.RequirementStatusCode, UI.ThemeColor> = {
|
||||
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<RequirementStatusActionCode, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
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);
|
||||
|
||||
@@ -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<string, unknown>) {
|
||||
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 => {
|
||||
<template #default="{ data: nodeData }">
|
||||
<div
|
||||
class="requirement-tree-picker__node"
|
||||
@dblclick.stop="handleSelect(nodeData as ProjectRequirementTreeNode)"
|
||||
@dblclick.stop="handleSelect(nodeData as RequirementTreePickerNode)"
|
||||
>
|
||||
<span
|
||||
class="requirement-tree-picker__node-label"
|
||||
@@ -173,7 +179,7 @@ watch(visible, async value => {
|
||||
'is-active': nodeData.id === modelValue,
|
||||
'is-animating': nodeData.id === animatingNodeId
|
||||
}"
|
||||
@click.stop="handleSelect(nodeData as ProjectRequirementTreeNode)"
|
||||
@click.stop="handleSelect(nodeData as RequirementTreePickerNode)"
|
||||
>
|
||||
<svg
|
||||
class="requirement-tree-picker__check-svg"
|
||||
|
||||
@@ -79,7 +79,7 @@ export function useTaskPermissions() {
|
||||
);
|
||||
}
|
||||
|
||||
// —— 任务侧(按一级 / 子任务分流) ——
|
||||
// —— 任务侧(按一级 / 子任务指派) ——
|
||||
|
||||
function isTopLevelTask(task: Api.Project.ProjectTask): boolean {
|
||||
return task.parentTaskId === null || task.parentTaskId === undefined;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { computed, markRaw, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { ElButton, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
fetchGetProjectMembers,
|
||||
fetchGetProjectRequirementAllowedTransitionsBatch,
|
||||
fetchGetProjectRequirementStatusDict,
|
||||
fetchGetProjectRequirementTerminalStatusDict,
|
||||
fetchGetProjectRequirementTree
|
||||
} from '@/service/api';
|
||||
import { useAuth } from '@/hooks/business/auth';
|
||||
@@ -38,6 +37,10 @@ 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';
|
||||
import RequirementReviewDialog from './modules/requirement-review-dialog.vue';
|
||||
import RequirementReviewRecordDialog from './modules/requirement-review-record-dialog.vue';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirement' });
|
||||
|
||||
@@ -79,23 +82,15 @@ function createSearchParams(): Api.Project.ProjectRequirementSearchParams {
|
||||
};
|
||||
}
|
||||
|
||||
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
|
||||
0: 'info',
|
||||
1: 'primary',
|
||||
2: 'warning',
|
||||
3: 'danger'
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { currentObjectId, currentProject } = 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 statusDict = ref<Api.Project.ProjectRequirementStatusDict[]>([]);
|
||||
const memberOptions = ref<Api.Project.ProjectMember[]>([]);
|
||||
const treeData = ref<Api.Project.ProjectRequirement[]>([]);
|
||||
const requirementDisplayTotal = ref(0);
|
||||
const loading = ref(false);
|
||||
const selectedModuleId = ref<string | undefined>('');
|
||||
const searchParams = reactive(createSearchParams());
|
||||
@@ -116,6 +111,21 @@ 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 reviewVisible = ref(false);
|
||||
const reviewRequirement = ref<Api.Project.ProjectRequirement | null>(null);
|
||||
const reviewRecordVisible = ref(false);
|
||||
const reviewRecordRequirement = ref<Api.Project.ProjectRequirement | null>(null);
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
return statusDict.value.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
});
|
||||
|
||||
const statusMetaMap = computed(() => {
|
||||
return new Map(statusDict.value.map(item => [item.statusCode, item]));
|
||||
});
|
||||
|
||||
const memberUserOptions = computed<MemberUserOption[]>(() => {
|
||||
return memberOptions.value
|
||||
@@ -144,20 +154,32 @@ function getStatusLabel(statusCode: string) {
|
||||
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 Boolean(statusMetaMap.value.get(statusCode)?.terminalFlag);
|
||||
}
|
||||
|
||||
function isTerminalStatus(statusCode: string) {
|
||||
return terminalStatusOptions.value.includes(statusCode);
|
||||
// 要么是后端数据库中项目需求的某状态的allowEdit字段是true,要么是需求来源是产品需求、且为父需求、状态是implementing
|
||||
function canEditRequirement(row: Api.Project.ProjectRequirement) {
|
||||
return (
|
||||
Boolean(statusMetaMap.value.get(row.statusCode)?.allowEdit) ||
|
||||
(row.sourceType === 'product_requirement' && row.parentId === '0' && row.statusCode === 'implementing')
|
||||
);
|
||||
}
|
||||
|
||||
function isReviewAction(row: Api.Project.ProjectRequirement, action: Api.Project.ProjectRequirementLifecycleAction) {
|
||||
return row.statusCode === 'pending_review' && ['pass_review', 'reject_review'].includes(action.actionCode);
|
||||
}
|
||||
|
||||
function isReviewTransitionAction(actionCode: string) {
|
||||
return ['pass_review', 'reject_review'].includes(actionCode);
|
||||
}
|
||||
|
||||
function canViewReviewRecord(row: Api.Project.ProjectRequirement) {
|
||||
return row.reviewRequired === 1 && !['pending_claim', 'pending_review'].includes(row.statusCode);
|
||||
}
|
||||
|
||||
function canSplitRequirement(row: Api.Project.ProjectRequirement) {
|
||||
return row.statusCode === 'implementing';
|
||||
return ['reviewed', 'implementing'].includes(row.statusCode);
|
||||
}
|
||||
|
||||
function canDeleteRequirement(row: Api.Project.ProjectRequirement) {
|
||||
@@ -180,6 +202,10 @@ function flattenTree(nodes: Api.Project.ProjectRequirement[]): Api.Project.Proje
|
||||
return result;
|
||||
}
|
||||
|
||||
function countRequirementTreeNodes(nodes: Api.Project.ProjectRequirement[]) {
|
||||
return flattenTree(nodes).length;
|
||||
}
|
||||
|
||||
function collectRequirementIdsForActions(nodes: Api.Project.ProjectRequirement[]): string[] {
|
||||
const ids: string[] = [];
|
||||
|
||||
@@ -209,30 +235,41 @@ function buildRequirementActions(row: Api.Project.ProjectRequirement) {
|
||||
disabled?: boolean;
|
||||
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');
|
||||
const hasQueryAuth = hasObjectAuth('project:project:query');
|
||||
const hasReviewAuth = hasObjectAuth('project:project:review');
|
||||
const lifecycleActions = getRowActions(row);
|
||||
|
||||
if (hasSplitAuth) {
|
||||
if (hasQueryAuth && canViewReviewRecord(row)) {
|
||||
actions.push({
|
||||
key: 'reviewRecord',
|
||||
label: '查看评审记录',
|
||||
icon: markRaw(IconMdiEyeOutline),
|
||||
type: 'primary',
|
||||
onClick: () => handleViewReviewRecord(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasSplitAuth && canSplitRequirement(row)) {
|
||||
actions.push({
|
||||
key: 'split',
|
||||
label: '拆分',
|
||||
icon: getProjectRequirementActionIcon('split'),
|
||||
type: getProjectRequirementActionButtonType('split'),
|
||||
disabled: !canSplitRequirement(row),
|
||||
onClick: () => openSplit(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasUpdateAuth) {
|
||||
const canEdit = !isTerminalStatus(row.statusCode) && row.statusCode !== 'accepted';
|
||||
if (hasUpdateAuth && canEditRequirement(row)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: getProjectRequirementActionIcon('edit'),
|
||||
type: getProjectRequirementActionButtonType('edit'),
|
||||
disabled: !canEdit,
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
}
|
||||
@@ -240,25 +277,36 @@ function buildRequirementActions(row: Api.Project.ProjectRequirement) {
|
||||
if (row.sourceType === 'product_requirement' && row.parentId === '0' && currentProject.value?.productId) {
|
||||
actions.push({
|
||||
key: 'back',
|
||||
label: '返回产品侧',
|
||||
label: '回溯',
|
||||
icon: ACTION_ICON_MAP.back,
|
||||
type: 'primary',
|
||||
onClick: () => handleBackToProductRequirement(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasReviewAuth && lifecycleActions.some(action => isReviewAction(row, action))) {
|
||||
actions.push({
|
||||
key: 'review',
|
||||
label: '评审',
|
||||
icon: ACTION_ICON_MAP.pass_review,
|
||||
type: 'primary',
|
||||
onClick: () => openReview(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);
|
||||
}
|
||||
});
|
||||
lifecycleActions
|
||||
.filter(action => !isReviewTransitionAction(action.actionCode))
|
||||
.forEach(action => {
|
||||
if (isProjectRequirementActionTerminal(action.actionCode)) {
|
||||
terminalActions.push(action);
|
||||
} else {
|
||||
nonTerminalActions.push(action);
|
||||
}
|
||||
});
|
||||
|
||||
[...nonTerminalActions, ...terminalActions].forEach(action => {
|
||||
actions.push({
|
||||
@@ -320,7 +368,7 @@ const columns = computed(() => [
|
||||
width: 88,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Project.ProjectRequirement) => (
|
||||
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
|
||||
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} />
|
||||
)
|
||||
},
|
||||
{
|
||||
@@ -390,28 +438,38 @@ const columns = computed(() => [
|
||||
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;
|
||||
formatter: (row: Api.Project.ProjectRequirement) => {
|
||||
const actions = buildRequirementActions(row);
|
||||
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
class="requirement-action-icon-btn"
|
||||
type={action.type}
|
||||
disabled={action.disabled}
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
<IconComponent class="text-18px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
return (
|
||||
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{actions.length === 0 ? (
|
||||
<ElButton link size="small" class="requirement-action-icon-btn" type="primary" disabled>
|
||||
<IconMdiPencilOutline class="text-18px" />
|
||||
</ElButton>
|
||||
) : (
|
||||
actions.map(action => {
|
||||
const IconComponent = action.icon as any;
|
||||
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
class="requirement-action-icon-btn"
|
||||
type={action.type}
|
||||
disabled={action.disabled}
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
<IconComponent class="text-18px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
]);
|
||||
|
||||
@@ -454,25 +512,11 @@ async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetProjectRequirementStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
statusOptions.value = [];
|
||||
statusDict.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);
|
||||
statusDict.value = data;
|
||||
}
|
||||
|
||||
async function loadMembers() {
|
||||
@@ -521,6 +565,46 @@ async function loadTreeData() {
|
||||
pagination.total = data.total;
|
||||
}
|
||||
|
||||
async function loadRequirementDisplayTotal() {
|
||||
if (!currentObjectId.value) {
|
||||
requirementDisplayTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseParams = {
|
||||
projectId: currentObjectId.value,
|
||||
pageNo: 1
|
||||
};
|
||||
const rootTotalResult = await fetchGetProjectRequirementTree({
|
||||
...baseParams,
|
||||
pageSize: 1
|
||||
});
|
||||
|
||||
if (rootTotalResult.error || !rootTotalResult.data) {
|
||||
requirementDisplayTotal.value = pagination.total;
|
||||
return;
|
||||
}
|
||||
|
||||
const rootTotal = rootTotalResult.data.total;
|
||||
|
||||
if (rootTotal <= 0) {
|
||||
requirementDisplayTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const allTreeResult = await fetchGetProjectRequirementTree({
|
||||
...baseParams,
|
||||
pageSize: rootTotal
|
||||
});
|
||||
|
||||
if (allTreeResult.error || !allTreeResult.data) {
|
||||
requirementDisplayTotal.value = rootTotal;
|
||||
return;
|
||||
}
|
||||
|
||||
requirementDisplayTotal.value = countRequirementTreeNodes(allTreeResult.data.list);
|
||||
}
|
||||
|
||||
async function loadAllowedTransitionsForAll() {
|
||||
if (!currentObjectId.value) {
|
||||
allowedTransitionsMap.value = new Map();
|
||||
@@ -557,7 +641,7 @@ async function reloadTable() {
|
||||
|
||||
try {
|
||||
await loadTreeData();
|
||||
await loadAllowedTransitionsForAll();
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadRequirementDisplayTotal()]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -616,6 +700,20 @@ function openSplit(row: Api.Project.ProjectRequirement) {
|
||||
splitVisible.value = true;
|
||||
}
|
||||
|
||||
function openReview(row: Api.Project.ProjectRequirement) {
|
||||
reviewRequirement.value = row;
|
||||
reviewVisible.value = true;
|
||||
}
|
||||
|
||||
function handleViewReviewRecord(row: Api.Project.ProjectRequirement) {
|
||||
if (!canViewReviewRecord(row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reviewRecordRequirement.value = row;
|
||||
reviewRecordVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleBackToProductRequirement(row: Api.Project.ProjectRequirement) {
|
||||
const productId = currentProject.value?.productId;
|
||||
if (!productId || row.sourceType !== 'product_requirement') return;
|
||||
@@ -735,6 +833,11 @@ async function handleSplitSubmitted() {
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleReviewSubmitted() {
|
||||
reviewVisible.value = false;
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async id => {
|
||||
@@ -750,34 +853,17 @@ watch(
|
||||
treeData.value = [];
|
||||
allowedTransitionsMap.value = new Map();
|
||||
pagination.total = 0;
|
||||
requirementDisplayTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadMembers(), loadTreeData()]);
|
||||
await loadAllowedTransitionsForAll();
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadRequirementDisplayTotal()]);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [route.query.requirementId, treeData.value] as const,
|
||||
([targetId]) => {
|
||||
if (!targetId) return;
|
||||
const idStr = String(targetId);
|
||||
const flat = flattenTree(treeData.value);
|
||||
const found = flat.find(item => item.id === idStr);
|
||||
if (found) {
|
||||
openView(found);
|
||||
// 清掉 requirementId query,保留其它参数
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { requirementId: _rid, ...restQuery } = route.query;
|
||||
router.replace({ query: restQuery });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
Promise.all([loadStatusOptions()]);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -802,7 +888,7 @@ Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
<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>
|
||||
<ElTag effect="plain">{{ requirementDisplayTotal }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
@@ -893,6 +979,21 @@ Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
:requirement-title="actionRequirement?.title || ''"
|
||||
@submitted="handleActionSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementReviewDialog
|
||||
v-model:visible="reviewVisible"
|
||||
:project-id="currentObjectId || ''"
|
||||
:requirement="reviewRequirement"
|
||||
:member-options="memberOptions"
|
||||
@submitted="handleReviewSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementReviewRecordDialog
|
||||
v-model:visible="reviewRecordVisible"
|
||||
:project-id="currentObjectId || ''"
|
||||
:requirement="reviewRecordRequirement"
|
||||
:member-options="memberOptions"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ElEmpty v-else description="未获取到当前项目上下文,请返回项目列表重新选择项目" />
|
||||
@@ -921,6 +1022,8 @@ Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:deep(.requirement-table-card-body) {
|
||||
|
||||
@@ -139,40 +139,31 @@ function handleToggle() {
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditing && hasAnyActionPermission" 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" />
|
||||
<ElTooltip v-if="hasObjectAuth('project:project:create')" content="新增子模块" placement="top">
|
||||
<ElButton link type="primary" class="module-tree-item__action-btn" @click="emit('addChild', module)">
|
||||
<icon-mdi-plus class="text-14px" />
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem v-if="hasObjectAuth('project:project:create')" @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 && hasObjectAuth('project:project:update')"
|
||||
@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 && hasObjectAuth('project:project:delete')"
|
||||
divided
|
||||
@click="emit('delete', module)"
|
||||
>
|
||||
<div class="flex items-center gap-6px text-error">
|
||||
</ElTooltip>
|
||||
<ElTooltip v-if="!isRootModule && hasObjectAuth('project:project:update')" content="编辑" placement="top">
|
||||
<ElButton link type="primary" class="module-tree-item__action-btn" @click="emit('edit', module)">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm
|
||||
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:project:delete')"
|
||||
title="确定删除该模块吗?"
|
||||
@confirm="emit('delete', module)"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="inline-flex" @click.stop>
|
||||
<ElTooltip content="删除" placement="top">
|
||||
<ElButton link type="danger" class="module-tree-item__action-btn">
|
||||
<icon-mdi-delete-outline class="text-14px" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -376,12 +367,15 @@ function handleToggle() {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.module-tree-item__more-btn {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
.module-tree-item__action-btn {
|
||||
padding: 2px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
margin-left: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.module-tree-item__more-btn:hover {
|
||||
background-color: #e2e8f0;
|
||||
.module-tree-item__action-btn:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchCreateProjectRequirement, fetchGetProjectRequirementModuleTree } from '@/service/api';
|
||||
import {
|
||||
fetchCreateProjectRequirement,
|
||||
fetchGetProjectRequirementModuleTree,
|
||||
fetchGetUserSimpleList
|
||||
} 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 RequirementTreePicker, {
|
||||
type RequirementTreePickerNode
|
||||
} from '@/views/project/project/execution/components/requirement-tree-picker.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementCreateDialog' });
|
||||
@@ -36,18 +41,10 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
function buildExpectedTimeShortcut(text: string, mutator: (date: Date) => void) {
|
||||
return {
|
||||
text,
|
||||
@@ -73,9 +70,9 @@ interface Model {
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
moduleId: string | null;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
proposerId: string;
|
||||
currentHandlerUserId: string;
|
||||
@@ -85,33 +82,12 @@ interface Model {
|
||||
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const allUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
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 moduleTreeOptions = computed<RequirementTreePickerNode[]>(() => mapModuleTree(moduleTree.value));
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
@@ -162,9 +138,9 @@ function createDefaultModel(): Model {
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: props.defaultModuleId || '0',
|
||||
moduleId: props.defaultModuleId || null,
|
||||
category: '功能需求',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
proposerId: '',
|
||||
currentHandlerUserId: '',
|
||||
@@ -172,6 +148,16 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function mapModuleTree(modules: Api.Project.ProjectRequirementModule[]): RequirementTreePickerNode[] {
|
||||
return modules
|
||||
.filter(item => Boolean(item.id))
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
title: item.moduleName,
|
||||
children: item.children?.length ? mapModuleTree(item.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!props.projectId) {
|
||||
moduleTree.value = [];
|
||||
@@ -204,7 +190,7 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const proposer = memberUserOptions.value.find(item => item.userId === model.value.proposerId);
|
||||
const proposer = allUserOptions.value.find(item => item.id === model.value.proposerId);
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
const payload: Api.Project.SaveProjectRequirementParams = {
|
||||
@@ -218,7 +204,7 @@ async function handleSubmit() {
|
||||
priority: Number(model.value.priority) as Api.Project.ProjectRequirementPriority,
|
||||
expectedTime: model.value.expectedTime,
|
||||
proposerId: model.value.proposerId,
|
||||
proposerNickname: proposer?.userNickname || '',
|
||||
proposerNickname: proposer?.nickname || '',
|
||||
currentHandlerUserId: model.value.currentHandlerUserId,
|
||||
currentHandlerUserNickname: handler?.userNickname || '',
|
||||
sort: model.value.sort
|
||||
@@ -241,6 +227,13 @@ async function handleSubmit() {
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
async function loadAllUsers() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
if (!error && data) {
|
||||
allUserOptions.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
@@ -249,7 +242,7 @@ watch(
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
await loadModuleTree();
|
||||
await Promise.all([loadModuleTree(), loadAllUsers()]);
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
@@ -277,9 +270,11 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
<RequirementTreePicker
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTreeOptions"
|
||||
placeholder="搜索或选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
@@ -297,9 +292,12 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
@@ -313,14 +311,7 @@ watch(
|
||||
|
||||
<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>
|
||||
<ElOption v-for="item in allUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchGetProjectRequirement,
|
||||
fetchGetProjectRequirementModuleTree,
|
||||
fetchGetUserSimpleList,
|
||||
fetchUpdateProjectRequirement
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
@@ -13,7 +14,11 @@ import BusinessAttachmentUploader from '@/components/custom/business-attachment-
|
||||
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 ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import RequirementTreePicker, {
|
||||
type RequirementTreePickerNode
|
||||
} from '@/views/project/project/execution/components/requirement-tree-picker.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementDetailDialog' });
|
||||
@@ -44,26 +49,19 @@ const visible = defineModel<boolean>('visible', {
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
|
||||
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
const { getLabel: getPriorityLabel } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
moduleId: string | null;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
proposerId: string;
|
||||
proposerNickname: string;
|
||||
@@ -76,6 +74,7 @@ interface Model {
|
||||
const moduleTree = ref<Api.Project.ProjectRequirementModule[]>([]);
|
||||
const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const allUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const isViewMode = computed(() => props.mode === 'view');
|
||||
@@ -87,6 +86,10 @@ const memberLabelMap = computed(() => {
|
||||
return new Map(memberUserOptions.value.map(item => [item.userId, item.userNickname]));
|
||||
});
|
||||
|
||||
const allUserLabelMap = computed(() => {
|
||||
return new Map(allUserOptions.value.map(item => [item.id, item.nickname]));
|
||||
});
|
||||
|
||||
const moduleLabelMap = computed(() => {
|
||||
const map = new Map<string | undefined, string>();
|
||||
|
||||
@@ -104,29 +107,7 @@ const moduleLabelMap = computed(() => {
|
||||
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 moduleTreeOptions = computed<RequirementTreePickerNode[]>(() => mapModuleTree(moduleTree.value));
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
@@ -157,8 +138,8 @@ const rules = computed(() => ({
|
||||
title: isEditMode.value ? [createRequiredRule('请输入需求名称')] : [],
|
||||
category: isEditMode.value ? [createRequiredRule('请选择需求类型')] : [],
|
||||
priority: isEditMode.value ? [createRequiredRule('请选择优先级')] : [],
|
||||
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : [],
|
||||
currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
|
||||
proposerId: isEditMode.value ? [createRequiredRule('请选择提出人')] : []
|
||||
// currentHandlerUserId: isEditMode.value ? [createRequiredRule('请选择负责人')] : []
|
||||
}));
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
@@ -197,9 +178,9 @@ function createDefaultModel(): Model {
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: '0',
|
||||
moduleId: null,
|
||||
category: '',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
proposerId: '',
|
||||
proposerNickname: '',
|
||||
@@ -210,6 +191,16 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function mapModuleTree(modules: Api.Project.ProjectRequirementModule[]): RequirementTreePickerNode[] {
|
||||
return modules
|
||||
.filter(item => Boolean(item.id))
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
title: item.moduleName,
|
||||
children: item.children?.length ? mapModuleTree(item.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadModuleTree() {
|
||||
if (!props.projectId) {
|
||||
moduleTree.value = [];
|
||||
@@ -246,9 +237,9 @@ async function loadRequirementDetail() {
|
||||
description: data.description || null,
|
||||
attachments: data.attachments ? [...data.attachments] : [],
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
moduleId: data.moduleId || '0',
|
||||
moduleId: data.moduleId || null,
|
||||
category: data.category || '',
|
||||
priority: data.priority ?? null,
|
||||
priority: data.priority === null || data.priority === undefined ? null : String(data.priority),
|
||||
expectedTime: formatExpectedTime(data.expectedTime),
|
||||
proposerId: data.proposerId || '',
|
||||
proposerNickname: data.proposerNickname || '',
|
||||
@@ -284,10 +275,10 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
if (props.requirement.parentId === '0' && props.requirement.sourceType === 'product_requirement') {
|
||||
window.$message?.warning('来自产品需求的数据不允许编辑');
|
||||
return;
|
||||
}
|
||||
// if (props.requirement.parentId === '0' && props.requirement.sourceType === 'product_requirement') {
|
||||
// window.$message?.warning('来自产品需求的数据不允许编辑');
|
||||
// return;
|
||||
// }
|
||||
|
||||
const handler = memberUserOptions.value.find(item => item.userId === model.value.currentHandlerUserId);
|
||||
|
||||
@@ -326,6 +317,13 @@ async function handleSubmit() {
|
||||
emit('submitted', props.requirement.id);
|
||||
}
|
||||
|
||||
async function loadAllUsers() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
if (!error && data) {
|
||||
allUserOptions.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
@@ -333,7 +331,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
await loadModuleTree();
|
||||
await Promise.all([loadModuleTree(), loadAllUsers()]);
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
@@ -372,10 +370,13 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
<ReadonlyField v-if="isViewMode" :value="moduleLabelMap.get(model.moduleId || undefined) || '--'" />
|
||||
<RequirementTreePicker
|
||||
v-else
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTreeOptions"
|
||||
placeholder="搜索或选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
@@ -387,9 +388,13 @@ watch(
|
||||
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>
|
||||
<DictSelect
|
||||
v-else
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
@@ -397,7 +402,7 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
<ReadonlyField :value="allUserLabelMap.get(model.proposerId) || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
@@ -460,7 +465,7 @@ watch(
|
||||
:disabled="isViewMode"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
:placeholder="isViewMode && isEmptyRichText(model.description) ? '--' : '请输入需求内容'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchSubmitProjectRequirementReview } from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
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 ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import AttendeeUserPicker from '@/components/custom/attendee-user-picker.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementReviewDialog' });
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
requirement: Api.Project.ProjectRequirement | null;
|
||||
memberOptions: Api.Project.ProjectMember[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
interface Model {
|
||||
conclusion: Api.Project.ProjectRequirementReviewConclusion;
|
||||
attendees: Api.Project.ProjectRequirementReviewAttendeeItem[];
|
||||
requirementEstimatedHours: number | null;
|
||||
reviewTime: string | null;
|
||||
reviewContent: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
}
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
const submitting = ref(false);
|
||||
|
||||
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
|
||||
|
||||
const reviewConclusionOptions = [
|
||||
{ label: '通过评审', value: 0 as Api.Project.ProjectRequirementReviewConclusion },
|
||||
{ label: '不通过评审', value: 1 as Api.Project.ProjectRequirementReviewConclusion }
|
||||
];
|
||||
|
||||
const rules = {
|
||||
conclusion: [createRequiredRule('请选择评审结论')],
|
||||
attendees: [createRequiredRule('请选择参会人')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
conclusion: 0,
|
||||
attendees: [],
|
||||
requirementEstimatedHours: null,
|
||||
reviewTime: dayjs().format('YYYY-MM-DD'),
|
||||
reviewContent: null,
|
||||
attachments: []
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.projectId || !props.requirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.userInfo.userId) {
|
||||
window.$message?.warning('未获取到当前登录用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Api.Project.ProjectRequirementReviewSubmitParams = {
|
||||
projectId: props.projectId,
|
||||
requirementId: props.requirement.id,
|
||||
operatorId: authStore.userInfo.userId,
|
||||
conclusion: model.value.conclusion,
|
||||
reviewContent: isEmptyRichText(model.value.reviewContent) ? null : (model.value.reviewContent ?? null),
|
||||
requirementEstimatedHours: model.value.requirementEstimatedHours,
|
||||
attendees: [...model.value.attendees],
|
||||
attachments: [...model.value.attachments],
|
||||
reviewTime: model.value.reviewTime
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const result = await fetchSubmitProjectRequirementReview(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('评审提交成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="评审项目需求"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-review-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-review-dialog__col-left">
|
||||
<BusinessFormSection title="评审信息">
|
||||
<ElFormItem label="需求名称">
|
||||
<ReadonlyField :value="requirement?.title || '--'" />
|
||||
<!-- <ElInput :model-value="requirement?.title || ''" readonly placeholder="--" />-->
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审结论" prop="conclusion">
|
||||
<ElRadioGroup v-model="model.conclusion">
|
||||
<ElRadio
|
||||
v-for="item in reviewConclusionOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
border
|
||||
style="width: 165px"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="参会人" prop="attendees">
|
||||
<AttendeeUserPicker
|
||||
v-model="model.attendees"
|
||||
:team-options="memberUserOptions"
|
||||
team-tab-label="项目团队"
|
||||
:show-dept-tab="true"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求预估工时">
|
||||
<ElInputNumber
|
||||
v-model="model.requirementEstimatedHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
:precision="1"
|
||||
placeholder="请输入需求预估工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="实际评审日期">
|
||||
<ElDatePicker
|
||||
v-model="model.reviewTime"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择实际评审日期"
|
||||
class="requirement-review-dialog__date-picker"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-review-dialog__col-right">
|
||||
<BusinessFormSection title="评审内容">
|
||||
<ElFormItem class="requirement-review-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.reviewContent"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement-review"
|
||||
placeholder="请输入评审内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="会议资料">
|
||||
<ElFormItem class="requirement-review-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement-review"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-review-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__col-left,
|
||||
.requirement-review-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__desc-item,
|
||||
.requirement-review-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-review-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.requirement-review-dialog__date-picker.el-date-editor.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetProjectRequirementReview } from '@/service/api';
|
||||
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 ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectRequirementReviewRecordDialog' });
|
||||
|
||||
interface Props {
|
||||
projectId: string;
|
||||
requirement: Api.Project.ProjectRequirement | null;
|
||||
memberOptions: Api.Project.ProjectMember[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const reviewRecord = ref<Api.Project.ProjectRequirementReview | null>(null);
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('47vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
const ATTENDEE_VISIBLE_COUNT = 5;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
const operatorLabelMap = computed(() => {
|
||||
return new Map(props.memberOptions.map(item => [item.userId, item.userNickname]));
|
||||
});
|
||||
|
||||
const conclusionText = computed(() => {
|
||||
if (!reviewRecord.value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return reviewRecord.value.conclusion === 0 ? '通过评审' : '不通过评审';
|
||||
});
|
||||
|
||||
const operatorText = computed(() => {
|
||||
if (!reviewRecord.value?.operatorId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return operatorLabelMap.value.get(reviewRecord.value.operatorId) || reviewRecord.value.operatorId;
|
||||
});
|
||||
|
||||
const visibleAttendees = computed(() => reviewRecord.value?.attendees?.slice(0, ATTENDEE_VISIBLE_COUNT) ?? []);
|
||||
const overflowAttendees = computed(() => reviewRecord.value?.attendees?.slice(ATTENDEE_VISIBLE_COUNT) ?? []);
|
||||
|
||||
function formatExpectedTime(value?: string | number[] | null): string {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const [year, month, day] = value;
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
async function loadReviewRecord() {
|
||||
if (!props.projectId || !props.requirement?.id) {
|
||||
reviewRecord.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProjectRequirementReview(props.projectId, props.requirement.id);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
reviewRecord.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
reviewRecord.value = data;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadReviewRecord();
|
||||
} else {
|
||||
reviewRecord.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="查看评审记录"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:loading="loading"
|
||||
:show-footer="true"
|
||||
>
|
||||
<template #footer="{ close }">
|
||||
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-if="!loading && !reviewRecord" description="暂无评审记录" />
|
||||
|
||||
<div v-else class="requirement-review-record-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-review-record-dialog__col-left">
|
||||
<BusinessFormSection title="评审信息">
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="需求名称">
|
||||
<ReadonlyField :value="requirement?.title || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审结论">
|
||||
<ReadonlyField :value="conclusionText" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审提交人">
|
||||
<ReadonlyField :value="operatorText" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="参会人">
|
||||
<div v-if="reviewRecord?.attendees?.length" class="requirement-review-record-dialog__tags">
|
||||
<ElTag v-for="item in visibleAttendees" :key="item.userId" effect="light">
|
||||
{{ item.nickname }}
|
||||
</ElTag>
|
||||
<ElPopover
|
||||
v-if="overflowAttendees.length"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="280"
|
||||
popper-class="requirement-review-record-dialog__attendee-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<button type="button" class="requirement-review-record-dialog__tag-more">
|
||||
+{{ overflowAttendees.length }} 更多
|
||||
</button>
|
||||
</template>
|
||||
<div class="requirement-review-record-dialog__attendee-overflow">
|
||||
<div class="requirement-review-record-dialog__attendee-overflow-head">
|
||||
另外
|
||||
<strong>{{ overflowAttendees.length }}</strong>
|
||||
人
|
||||
</div>
|
||||
<div class="requirement-review-record-dialog__attendee-overflow-tags">
|
||||
<ElTag v-for="item in overflowAttendees" :key="item.userId" effect="light">
|
||||
{{ item.nickname }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
<ReadonlyField v-else value="--" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求预估工时">
|
||||
<ReadonlyField
|
||||
:value="
|
||||
reviewRecord?.requirementEstimatedHours !== null &&
|
||||
reviewRecord?.requirementEstimatedHours !== undefined &&
|
||||
reviewRecord?.requirementEstimatedHours !== ''
|
||||
? String(reviewRecord.requirementEstimatedHours)
|
||||
: '--'
|
||||
"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="实际评审日期">
|
||||
<ReadonlyField :value="formatExpectedTime(reviewRecord?.reviewTime)" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提交时间">
|
||||
<ReadonlyField :value="formatDateTime(reviewRecord?.createTime)" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-review-record-dialog__col-right">
|
||||
<BusinessFormSection title="评审内容">
|
||||
<ElFormItem class="requirement-review-record-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
:model-value="reviewRecord?.reviewContent || ''"
|
||||
disabled
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement-review"
|
||||
placeholder="--"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="会议资料">
|
||||
<ElFormItem class="requirement-review-record-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
:model-value="reviewRecord?.attachments || []"
|
||||
disabled
|
||||
directory="requirement-review"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-review-record-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__col-left,
|
||||
.requirement-review-record-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__desc-item,
|
||||
.requirement-review-record-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tag-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 11.5px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tag-more:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-head {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-head strong {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-review-record-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -122,7 +122,7 @@ const fields = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="3" @search="emit('search')" @reset="emit('reset')" />
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @search="emit('search')" @reset="emit('reset')" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -4,11 +4,11 @@ import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchSplitProjectRequirement } 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: 'ProjectRequirementSplitDialog' });
|
||||
@@ -35,18 +35,10 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
function buildExpectedTimeShortcut(text: string, mutator: (date: Date) => void) {
|
||||
return {
|
||||
text,
|
||||
@@ -73,7 +65,7 @@ interface Model {
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
currentHandlerUserId: string;
|
||||
sort: number;
|
||||
@@ -133,7 +125,7 @@ function createDefaultModel(): Model {
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
category: '',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
currentHandlerUserId: '',
|
||||
sort: 0
|
||||
@@ -259,9 +251,12 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<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>
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
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';
|
||||
@@ -10,15 +11,24 @@ import IconMdiClose from '~icons/mdi/close';
|
||||
import IconTablerSitemap from '~icons/tabler/sitemap';
|
||||
import IconTablerCircleX from '~icons/tabler/circle-x';
|
||||
import IconMingcuteBack2Line from '~icons/mingcute/back-2-line';
|
||||
import IconMingcutePlayLine from '~icons/mingcute/play-line';
|
||||
|
||||
export type ProjectRequirementStatusActionCode =
|
||||
| 'claim_to_review'
|
||||
| 'claim_to_implement'
|
||||
| 'pass_review'
|
||||
| 'reject_review'
|
||||
| 'start_implement'
|
||||
| 'accept'
|
||||
| 'reject'
|
||||
| 'cancel'
|
||||
| 'close';
|
||||
|
||||
/**
|
||||
* 项目需求状态记录
|
||||
*
|
||||
* 来源:rdms_object_status_model 表 object_type='project_requirement' 的 7 条记录
|
||||
*/
|
||||
export const projectRequirementStatusRecord: Record<Api.Project.ProjectRequirementStatusCode, string> = {
|
||||
pending_confirm: '待确认',
|
||||
pending_claim: '待认领',
|
||||
pending_review: '待评审',
|
||||
reviewed: '已评审',
|
||||
review_rejected: '评审未过',
|
||||
implementing: '实施中',
|
||||
accepted: '已验收',
|
||||
closed: '已关闭',
|
||||
@@ -26,12 +36,19 @@ export const projectRequirementStatusRecord: Record<Api.Project.ProjectRequireme
|
||||
cancelled: '已取消'
|
||||
};
|
||||
|
||||
/**
|
||||
* 终态状态码集合
|
||||
*
|
||||
* 来源:terminal_flag=1 的状态: closed, rejected, cancelled
|
||||
*/
|
||||
const TERMINAL_STATUS_SET = new Set<Api.Project.ProjectRequirementStatusCode>(['closed', 'rejected', 'cancelled']);
|
||||
transformRecordToOption(projectRequirementStatusRecord);
|
||||
|
||||
export const projectRequirementStatusActionRecord: Record<ProjectRequirementStatusActionCode, string> = {
|
||||
claim_to_review: '认领',
|
||||
claim_to_implement: '认领',
|
||||
pass_review: '评审通过',
|
||||
reject_review: '评审不通过',
|
||||
start_implement: '开始实施',
|
||||
accept: '验收通过',
|
||||
reject: '拒绝',
|
||||
cancel: '取消',
|
||||
close: '关闭'
|
||||
};
|
||||
|
||||
/**
|
||||
* 操作按钮图标映射
|
||||
@@ -43,8 +60,10 @@ export const ACTION_ICON_MAP: Record<string, object> = {
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
back: markRaw(IconMingcuteBack2Line),
|
||||
claim_to_review: markRaw(IconMdiCheckOutline),
|
||||
claim_to_implement: markRaw(IconMdiCheckCircleOutline),
|
||||
claim_to_implement: markRaw(IconMingcutePlayLine),
|
||||
pass_review: markRaw(IconMdiGlasses),
|
||||
reject_review: markRaw(IconMdiGlasses),
|
||||
start_implement: markRaw(IconMingcutePlayLine),
|
||||
accept: markRaw(IconMdiCheckCircleOutline),
|
||||
reject: markRaw(IconMdiClose),
|
||||
cancel: markRaw(IconTablerCircleX),
|
||||
@@ -63,8 +82,10 @@ function resolveActionKeyword(actionCode: string) {
|
||||
*/
|
||||
export function getProjectRequirementStatusTagType(status: Api.Project.ProjectRequirementStatusCode): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<Api.Project.ProjectRequirementStatusCode, UI.ThemeColor> = {
|
||||
pending_confirm: 'info',
|
||||
pending_review: 'warning',
|
||||
pending_claim: 'info',
|
||||
pending_review: 'info',
|
||||
reviewed: 'success',
|
||||
review_rejected: 'danger',
|
||||
implementing: 'primary',
|
||||
accepted: 'success',
|
||||
closed: 'danger',
|
||||
@@ -74,14 +95,6 @@ export function getProjectRequirementStatusTagType(status: Api.Project.ProjectRe
|
||||
|
||||
return statusTagTypeMap[status];
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断状态是否为终态
|
||||
*/
|
||||
export function isProjectRequirementTerminal(statusCode: string) {
|
||||
return TERMINAL_STATUS_SET.has(statusCode as Api.Project.ProjectRequirementStatusCode);
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断动作是否为终态动作
|
||||
*
|
||||
@@ -112,6 +125,10 @@ export function getProjectRequirementActionButtonType(actionCode: string): 'prim
|
||||
* 获取操作动作的展示名称
|
||||
*/
|
||||
export function getProjectRequirementActionDisplayName(action: Api.Project.ProjectRequirementLifecycleAction) {
|
||||
if (action.actionCode === 'claim_to_review' || action.actionCode === 'claim_to_implement') {
|
||||
return '认领';
|
||||
}
|
||||
|
||||
return action.actionName || action.actionCode;
|
||||
}
|
||||
|
||||
|
||||
@@ -177,7 +177,6 @@ const {
|
||||
{ prop: 'index', type: 'index', label: $t('common.index'), width: 64 },
|
||||
{ prop: 'label', label: $t('page.system.dict.dictLabel'), minWidth: 160 },
|
||||
{ prop: 'value', label: $t('page.system.dict.dictValue'), minWidth: 180 },
|
||||
{ prop: 'sign', label: '标志', minWidth: 140, showOverflowTooltip: true },
|
||||
{ prop: 'sort', label: $t('page.system.dict.sort'), width: 90, align: 'center' },
|
||||
{
|
||||
prop: 'status',
|
||||
|
||||
@@ -51,7 +51,6 @@ const currentTypeCode = computed(() => props.currentType?.type ?? model.value.di
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
label: '',
|
||||
sign: '',
|
||||
value: '',
|
||||
dictType: '',
|
||||
sort: 0,
|
||||
@@ -80,7 +79,6 @@ function handleInitModel() {
|
||||
// 编辑时直接使用表格行数据回填,保持弹框打开速度。
|
||||
Object.assign(model.value, {
|
||||
label: props.rowData.label,
|
||||
sign: props.rowData.sign ?? '',
|
||||
value: props.rowData.value,
|
||||
dictType: props.rowData.dictType,
|
||||
sort: props.rowData.sort,
|
||||
@@ -170,11 +168,6 @@ watch(visible, value => {
|
||||
<ElInput v-model="model.value" :placeholder="$t('page.system.dict.form.dictValue')" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="标志" prop="sign">
|
||||
<ElInput v-model="model.sign" placeholder="请输入标志" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem :label="$t('page.system.dict.sort')" prop="sort">
|
||||
<ElInputNumber
|
||||
|
||||
Reference in New Issue
Block a user