feat(新增需求评审功能): 新增需求评审功能。

feat(动态切换对象域下的对象):对象域下的对象可以动态切换。
fix(产品需求、项目需求): 按照会议意见修改诸多细节。
fix(产品对象域的概览界面): 把假数据换成真实的需求统计数据。
This commit is contained in:
dk
2026-05-22 14:05:25 +08:00
parent ab882e085b
commit 13b74cfe97
36 changed files with 3764 additions and 771 deletions

View File

@@ -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: '最近动态时间',

View File

@@ -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;

View File

@@ -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 = [
{

View File

@@ -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) {

View File

@@ -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>

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>

View File

@@ -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(/&nbsp;/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="&#45;&#45;" />-->
</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>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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">

View File

@@ -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);