feat(project): 添加日报进度分析建议功能
All checks were successful
Lint Code / Lint Code (push) Successful in 3m0s

- 在项目详情页新增进度更新建议面板,展示AI分析的进度评估和具体建议
- 添加获取和应用日报建议的API接口及类型定义
- 支持批量选择和同意建议,自动更新项目状态
- 优化权限管理表格的树形选择配置,启用严格模式
- 更新.gitignore文件,排除.trae相关文件
This commit is contained in:
2026-04-01 11:02:04 +08:00
parent d698fae12a
commit dab86a40ff
5 changed files with 845 additions and 126 deletions

4
.gitignore vendored
View File

@@ -24,3 +24,7 @@ tsconfig.tsbuildinfo
#qoder
**.qoder**
#trae
**.trae**

View File

@@ -333,6 +333,64 @@ export const getProjectDetail = (projectId: string) => {
);
};
export type OverallProgressAssessment = {
status?: string;
deviationPercentage?: number;
description?: string;
keyIssues?: string[];
};
export type DailyReportUpdateSuggestionVO = {
suggestionId?: string;
targetType?: string;
targetId?: string;
targetName?: string;
currentStatus?: string;
currentProgress?: number;
suggestedStatus?: string;
suggestedProgress?: number;
reason?: string;
confidence?: number;
status?: string;
};
export type DailyReportAnalysisSuggestionsVO = {
analysisId?: string;
reportId?: string;
projectId?: string;
reportDate?: string;
overallProgressAssessment?: OverallProgressAssessment;
suggestions?: DailyReportUpdateSuggestionVO[];
};
export const getDailyReportAnalysisSuggestions = (params: {
projectId: string;
reportId?: string;
reportDate?: string;
submitterUsername?: string;
}) => {
return http.request<Result<DailyReportAnalysisSuggestionsVO>>(
"get",
"/api/v1/daily-report/analysis/suggestions",
{ params }
);
};
export type ApplyDailyReportSuggestionsRequest = {
projectId: string;
suggestionIds: string[];
};
export const applyDailyReportAnalysisSuggestions = (
data: ApplyDailyReportSuggestionsRequest
) => {
return http.request<Result<number>>(
"post",
"/api/v1/daily-report/analysis/suggestions/apply",
{ data }
);
};
// ==================== 项目初始化(复用 system.ts 中的定义) ====================
/** 项目信息 */

View File

@@ -0,0 +1,295 @@
{
"openapi": "3.0.1",
"info": {
"title": "默认模块",
"description": "",
"version": "1.0.0"
},
"tags": [],
"paths": {
"/api/v1/daily-report/analysis/suggestions": {
"get": {
"summary": "获取日报进度更新建议",
"deprecated": false,
"description": "",
"tags": [],
"parameters": [
{
"name": "projectId",
"in": "query",
"description": "项目ID",
"required": true,
"schema": {
"type": "integer"
}
},
{
"name": "reportId",
"in": "query",
"description": "日报ID",
"required": false,
"schema": {
"type": "integer"
}
},
{
"name": "reportDate",
"in": "query",
"description": "日报日期",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "submitterUsername",
"in": "query",
"description": "日报提交人用户名",
"required": false,
"schema": {
"type": "string"
}
},
{
"name": "Authorization",
"in": "header",
"description": "",
"example": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95",
"schema": {
"type": "string",
"default": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95"
}
}
],
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BaseResponseDailyReportAnalysisSuggestionsVO"
}
}
}
}
},
"security": []
}
},
"/api/v1/daily-report/analysis/suggestions/apply": {
"post": {
"summary": "应用日报进度回写建议",
"deprecated": false,
"description": "",
"tags": [],
"parameters": [
{
"name": "Authorization",
"in": "header",
"description": "",
"example": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95",
"schema": {
"type": "string",
"default": "Bearer b35c6f5b-bc0b-4652-bef2-eca04a5cdd95"
}
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/ApplyDailyReportSuggestionsRequest",
"description": "建议ID列表"
}
}
}
},
"responses": {
"200": {
"description": "",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/BaseResponseInteger",
"description": "应用结果"
}
}
}
}
},
"security": []
}
}
},
"components": {
"schemas": {
"OverallProgressAssessment": {
"type": "object",
"properties": {
"status": {
"type": "string",
"description": "进度状态ahead-提前on_track-正常delayed-滞后"
},
"deviationPercentage": {
"type": "number",
"description": "进度偏差百分比 (正数表示提前,负数表示滞后)"
},
"description": {
"type": "string",
"description": "评估说明"
},
"keyIssues": {
"type": "array",
"items": {
"type": "string"
},
"description": "关键问题"
}
}
},
"DailyReportUpdateSuggestionVO": {
"type": "object",
"properties": {
"suggestionId": {
"type": "integer",
"description": "",
"format": "int64"
},
"targetType": {
"type": "string",
"description": ""
},
"targetId": {
"type": "integer",
"description": "",
"format": "int64"
},
"targetName": {
"type": "string",
"description": ""
},
"currentStatus": {
"type": "string",
"description": ""
},
"currentProgress": {
"type": "integer",
"description": ""
},
"suggestedStatus": {
"type": "string",
"description": ""
},
"suggestedProgress": {
"type": "integer",
"description": ""
},
"reason": {
"type": "string",
"description": ""
},
"confidence": {
"type": "number",
"description": ""
},
"status": {
"type": "string",
"description": ""
}
}
},
"DailyReportAnalysisSuggestionsVO": {
"type": "object",
"properties": {
"analysisId": {
"type": "integer",
"description": "",
"format": "int64"
},
"reportId": {
"type": "integer",
"description": "",
"format": "int64"
},
"projectId": {
"type": "integer",
"description": "",
"format": "int64"
},
"reportDate": {
"type": "string",
"description": ""
},
"overallProgressAssessment": {
"$ref": "#/components/schemas/OverallProgressAssessment",
"description": ""
},
"suggestions": {
"type": "array",
"items": {
"$ref": "#/components/schemas/DailyReportUpdateSuggestionVO",
"description": "cn.yinlihupo.domain.vo.DailyReportUpdateSuggestionVO"
},
"description": ""
}
}
},
"BaseResponseDailyReportAnalysisSuggestionsVO": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"description": ""
},
"data": {
"$ref": "#/components/schemas/DailyReportAnalysisSuggestionsVO",
"description": ""
},
"message": {
"type": "string",
"description": ""
}
}
},
"BaseResponseInteger": {
"type": "object",
"properties": {
"code": {
"type": "integer",
"description": ""
},
"data": {
"type": "integer",
"description": ""
},
"message": {
"type": "string",
"description": ""
}
}
},
"ApplyDailyReportSuggestionsRequest": {
"type": "object",
"properties": {
"projectId": {
"type": "integer",
"description": "",
"format": "int64"
},
"suggestionIds": {
"type": "array",
"items": {
"type": "integer"
},
"description": ""
}
},
"required": ["projectId", "suggestionIds"]
}
},
"responses": {},
"securitySchemes": {}
},
"servers": [],
"security": []
}

View File

@@ -4,6 +4,8 @@ import { useRoute, useRouter } from "vue-router";
import { useRenderIcon } from "@/components/ReIcon/src/hooks";
import {
getProjectDetail,
getDailyReportAnalysisSuggestions,
applyDailyReportAnalysisSuggestions,
createTask,
updateTask,
deleteTask,
@@ -19,6 +21,8 @@ import {
type ProjectTask,
type ProjectResource,
type ProjectRisk,
type DailyReportAnalysisSuggestionsVO,
type DailyReportUpdateSuggestionVO,
type Resource,
type ResourceUpdateRequest
} from "@/api/project";
@@ -88,6 +92,13 @@ const marginStyle = computed(() => ({
// 项目详情数据
const projectDetail = ref<ProjectDetail | null>(null);
const suggestionsLoading = ref(false);
const suggestionsApplying = ref(false);
const dailyReportSuggestions = ref<DailyReportAnalysisSuggestionsVO | null>(
null
);
const selectedSuggestionIds = ref<string[]>([]);
// 成员详情模态框
const memberDetailModal = ref(false);
const selectedMember = ref<ProjectMember | null>(null);
@@ -548,6 +559,140 @@ async function fetchProjectDetail() {
}
}
async function fetchDailyReportSuggestions() {
if (!projectId.value) return;
suggestionsLoading.value = true;
try {
const res = await getDailyReportAnalysisSuggestions({
projectId: projectId.value
});
const result = res as any;
if (result.code === 200) {
dailyReportSuggestions.value = result.data || null;
selectedSuggestionIds.value = [];
}
} catch (error) {
console.error("获取进度更新建议失败:", error);
message("获取进度更新建议失败", { type: "error" });
} finally {
suggestionsLoading.value = false;
}
}
function handleSuggestionSelectionChange(
rows: DailyReportUpdateSuggestionVO[]
) {
selectedSuggestionIds.value = rows
.map(r => String(r.suggestionId || ""))
.filter(id => id.length > 0);
}
function getOverallAssessmentType(
status?: string
): "success" | "warning" | "info" | "primary" | "danger" {
switch (status) {
case "ahead":
return "success";
case "on_track":
return "primary";
case "delayed":
return "danger";
default:
return "info";
}
}
function getOverallAssessmentText(status?: string) {
switch (status) {
case "ahead":
return "提前";
case "on_track":
return "正常";
case "delayed":
return "滞后";
default:
return status || "未知";
}
}
function formatDeviation(deviationPercentage?: number) {
if (deviationPercentage === undefined || deviationPercentage === null)
return "--";
const value = Number(deviationPercentage);
if (!Number.isFinite(value)) return "--";
const sign = value > 0 ? "+" : "";
return `${sign}${value}%`;
}
function isSuggestionApplied(status?: string) {
const normalized = (status || "").toLowerCase();
return (
normalized === "applied" ||
normalized === "accepted" ||
normalized === "done" ||
normalized === "completed"
);
}
function getSuggestionStatusType(
status?: string
): "success" | "warning" | "info" | "primary" | "danger" {
if (isSuggestionApplied(status)) return "success";
if (!status) return "info";
const normalized = status.toLowerCase();
if (normalized.includes("reject") || normalized.includes("fail"))
return "danger";
if (normalized.includes("pending") || normalized.includes("new"))
return "warning";
return "info";
}
function getSuggestionStatusText(status?: string) {
if (isSuggestionApplied(status)) return "已应用";
if (!status) return "待处理";
const normalized = status.toLowerCase();
if (normalized.includes("reject")) return "已拒绝";
if (normalized.includes("fail")) return "失败";
if (normalized.includes("pending") || normalized.includes("new"))
return "待处理";
return status;
}
async function handleApplySuggestions(
ids: Array<string | number | undefined | null>
) {
if (!projectId.value) return;
const suggestionIds = Array.from(
new Set(ids.map(i => String(i || "")).filter(Boolean))
);
if (suggestionIds.length === 0) return;
if (suggestionsApplying.value) return;
suggestionsApplying.value = true;
try {
const res = await applyDailyReportAnalysisSuggestions({
projectId: projectId.value,
suggestionIds
});
const result = res as any;
if (result.code === 200) {
message("已应用进度更新建议", { type: "success" });
await fetchProjectDetail();
await fetchDailyReportSuggestions();
} else {
message(result.message || "应用建议失败", { type: "error" });
}
} catch (error) {
console.error("应用进度更新建议失败:", error);
message("应用建议失败", { type: "error" });
} finally {
suggestionsApplying.value = false;
}
}
async function handleApplySelectedSuggestions() {
await handleApplySuggestions(selectedSuggestionIds.value);
}
// 获取状态文本
function getStatusText(status?: string): string {
const statusMap: Record<string, string> = {
@@ -914,6 +1059,7 @@ async function handleDeleteResource(resourceId: string) {
onMounted(() => {
fetchProjectDetail();
fetchDailyReportSuggestions();
fetchGanttData();
});
</script>
@@ -1076,9 +1222,8 @@ onMounted(() => {
</el-col>
</el-row>
<!-- 主要内容区 -->
<el-row :gutter="16">
<!-- 左侧甘特图和物料清单 -->
<!-- 左侧甘特图 -->
<el-col :xs="24" :lg="24">
<!-- 甘特图 -->
<el-card shadow="hover" class="mb-4">
@@ -1244,141 +1389,354 @@ onMounted(() => {
</el-table-column>
</el-table>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 里程碑时间线 -->
<div class="milestone-section">
<div class="flex-bc mb-4">
<div class="flex items-center gap-2">
<el-icon :size="18" color="#f56c6c">
<component :is="useRenderIcon('ri/flag-line')" />
</el-icon>
<span class="font-medium text-base">项目里程碑</span>
<el-tag size="small" type="info"
>{{ milestoneList.length }} </el-tag
>
</div>
<el-button
v-if="canCreateMilestone"
type="primary"
size="small"
@click="openAddMilestoneModal"
<el-row :gutter="16" class="mb-4">
<el-col :xs="24" :lg="12" class="mb-4 lg:mb-0">
<el-card shadow="hover">
<template #header>
<div class="flex-bc">
<div class="flex items-center gap-2">
<el-icon :size="18" color="#f56c6c">
<component :is="useRenderIcon('ri/flag-line')" />
</el-icon>
<span class="font-medium">项目里程碑</span>
<el-tag size="small" type="info"
>{{ milestoneList.length }} </el-tag
>
<template #icon>
<component :is="useRenderIcon('ri/add-line')" />
</template>
新增里程碑
</el-button>
</div>
<div class="milestone-timeline">
<el-button
v-if="canCreateMilestone"
type="primary"
size="small"
@click="openAddMilestoneModal"
>
<template #icon>
<component :is="useRenderIcon('ri/add-line')" />
</template>
新增里程碑
</el-button>
</div>
</template>
<el-empty
v-if="milestoneList.length === 0"
description="暂无里程碑"
/>
<div v-else class="milestone-timeline">
<div
v-for="(milestone, index) in sortedMilestones"
:key="milestone.id"
class="milestone-item"
:class="{
'is-completed': milestone.status === 'completed',
'is-key': milestone.isKey === 1
}"
>
<div class="milestone-marker">
<div
v-for="(milestone, index) in sortedMilestones"
:key="milestone.id"
class="milestone-item"
:class="{
'is-completed': milestone.status === 'completed',
'is-key': milestone.isKey === 1
class="milestone-dot"
:style="{
backgroundColor: getMilestoneColor(
milestone.status,
milestone.isKey
)
}"
>
<div class="milestone-marker">
<div
class="milestone-dot"
:style="{
backgroundColor: getMilestoneColor(
milestone.status,
milestone.isKey
)
}"
>
<el-icon
v-if="milestone.status === 'completed'"
:size="12"
color="#fff"
<el-icon
v-if="milestone.status === 'completed'"
:size="12"
color="#fff"
>
<component :is="useRenderIcon('ri/check-line')" />
</el-icon>
<el-icon
v-else-if="milestone.isKey === 1"
:size="12"
color="#fff"
>
<component :is="useRenderIcon('ri/star-fill')" />
</el-icon>
</div>
<div
v-if="index < sortedMilestones.length - 1"
class="milestone-line"
/>
</div>
<div class="milestone-content">
<div class="milestone-header">
<span class="milestone-name">{{
milestone.milestoneName
}}</span>
<el-tag
size="small"
:type="
milestone.status === 'completed'
? 'success'
: milestone.status === 'in_progress'
? 'primary'
: 'info'
"
>
{{ getMilestoneStatusText(milestone.status) }}
</el-tag>
<el-tag
v-if="milestone.isKey === 1"
size="small"
type="danger"
effect="dark"
>
关键
</el-tag>
</div>
<div class="milestone-date">
<el-icon :size="12">
<component :is="useRenderIcon('ri/calendar-line')" />
</el-icon>
<span>计划日期: {{ milestone.planDate }}</span>
<span v-if="milestone.actualDate" class="actual-date">
(实际: {{ milestone.actualDate }})
</span>
</div>
<div v-if="milestone.description" class="milestone-desc">
{{ milestone.description }}
</div>
<div
v-if="canUpdateMilestone || canDeleteMilestone"
class="milestone-actions"
>
<el-button
v-if="canUpdateMilestone"
link
type="primary"
size="small"
@click="openEditMilestoneModal(milestone)"
>
编辑
</el-button>
<el-popconfirm
v-if="canDeleteMilestone"
title="确定要删除该里程碑吗?"
@confirm="handleDeleteMilestone(milestone.id)"
>
<template #reference>
<el-button link type="danger" size="small"
>删除</el-button
>
<component :is="useRenderIcon('ri/check-line')" />
</el-icon>
<el-icon
v-else-if="milestone.isKey === 1"
:size="12"
color="#fff"
>
<component :is="useRenderIcon('ri/star-fill')" />
</el-icon>
</div>
<div
v-if="index < sortedMilestones.length - 1"
class="milestone-line"
/>
</div>
<div class="milestone-content">
<div class="milestone-header">
<span class="milestone-name">{{
milestone.milestoneName
}}</span>
<el-tag
size="small"
:type="
milestone.status === 'completed'
? 'success'
: milestone.status === 'in_progress'
? 'primary'
: 'info'
"
>
{{ getMilestoneStatusText(milestone.status) }}
</el-tag>
<el-tag
v-if="milestone.isKey === 1"
size="small"
type="danger"
effect="dark"
>关键</el-tag
>
</div>
<div class="milestone-date">
<el-icon :size="12"
><component :is="useRenderIcon('ri/calendar-line')"
/></el-icon>
<span>计划日期: {{ milestone.planDate }}</span>
<span v-if="milestone.actualDate" class="actual-date">
(实际: {{ milestone.actualDate }})
</span>
</div>
<div v-if="milestone.description" class="milestone-desc">
{{ milestone.description }}
</div>
<!-- 里程碑操作按钮 -->
<div
v-if="canUpdateMilestone || canDeleteMilestone"
class="milestone-actions"
>
<el-button
v-if="canUpdateMilestone"
link
type="primary"
size="small"
@click="openEditMilestoneModal(milestone)"
>
编辑
</el-button>
<el-popconfirm
v-if="canDeleteMilestone"
title="确定要删除该里程碑吗?"
@confirm="handleDeleteMilestone(milestone.id)"
>
<template #reference>
<el-button link type="danger" size="small">
删除
</el-button>
</template>
</el-popconfirm>
</div>
</div>
</template>
</el-popconfirm>
</div>
</div>
</div>
</div>
</el-card>
</el-col>
<el-col :xs="24" :lg="12">
<el-card shadow="hover">
<template #header>
<div class="flex-bc">
<div class="flex items-center gap-2">
<component :is="useRenderIcon(RobotIcon)" />
<span class="font-medium">进度更新建议</span>
<el-tag
v-if="dailyReportSuggestions?.suggestions?.length"
size="small"
type="info"
>
{{ dailyReportSuggestions.suggestions.length }}
</el-tag>
</div>
<div class="flex gap-2">
<el-popconfirm
title="确认同意并应用所选建议吗?"
@confirm="handleApplySelectedSuggestions"
>
<template #reference>
<el-button
type="primary"
size="small"
:disabled="selectedSuggestionIds.length === 0"
:loading="suggestionsApplying"
>
同意所选
</el-button>
</template>
</el-popconfirm>
<el-button
link
:disabled="suggestionsLoading"
@click="fetchDailyReportSuggestions"
>
<component :is="useRenderIcon(RefreshIcon)" />
</el-button>
</div>
</div>
</template>
<div v-loading="suggestionsLoading">
<el-empty
v-if="!dailyReportSuggestions?.suggestions?.length"
description="暂无进度更新建议"
/>
<div v-else>
<div
v-if="dailyReportSuggestions.overallProgressAssessment"
class="mb-4"
>
<div class="flex items-center gap-2 mb-2">
<span class="font-medium">总体评估</span>
<el-tag
size="small"
:type="
getOverallAssessmentType(
dailyReportSuggestions.overallProgressAssessment
.status
)
"
>
{{
getOverallAssessmentText(
dailyReportSuggestions.overallProgressAssessment
.status
)
}}
</el-tag>
<span class="text-sm text-gray-500">
偏差
{{
formatDeviation(
dailyReportSuggestions.overallProgressAssessment
.deviationPercentage
)
}}
</span>
</div>
<div
v-if="
dailyReportSuggestions.overallProgressAssessment
.description
"
class="text-sm text-gray-600"
>
{{
dailyReportSuggestions.overallProgressAssessment
.description
}}
</div>
<div
v-if="
dailyReportSuggestions.overallProgressAssessment.keyIssues
?.length
"
class="mt-2 flex flex-wrap gap-2"
>
<el-tag
v-for="(issue, index) in dailyReportSuggestions
.overallProgressAssessment.keyIssues"
:key="index"
size="small"
type="warning"
>
{{ issue }}
</el-tag>
</div>
</div>
<el-table
:data="dailyReportSuggestions.suggestions || []"
style="width: 100%"
@selection-change="handleSuggestionSelectionChange"
>
<el-table-column type="selection" width="50" />
<el-table-column label="目标" min-width="220">
<template #default="{ row }">
<div class="font-medium">
{{ row.targetName || "--" }}
</div>
<div class="text-xs text-gray-400">
{{ row.targetType || "--" }}
</div>
</template>
</el-table-column>
<el-table-column label="当前" width="140">
<template #default="{ row }">
<div class="text-sm">{{ row.currentStatus || "--" }}</div>
<div class="text-xs text-gray-400">
{{ row.currentProgress ?? "--" }}%
</div>
</template>
</el-table-column>
<el-table-column label="建议" width="140">
<template #default="{ row }">
<div class="text-sm">
{{ row.suggestedStatus || "--" }}
</div>
<div class="text-xs text-gray-400">
{{ row.suggestedProgress ?? "--" }}%
</div>
</template>
</el-table-column>
<el-table-column label="原因" min-width="260" prop="reason" />
<el-table-column label="置信度" width="100">
<template #default="{ row }">
<span
v-if="
row.confidence !== undefined &&
row.confidence !== null
"
>
{{ Math.round(Number(row.confidence) * 100) }}%
</span>
<span v-else>--</span>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag
size="small"
:type="getSuggestionStatusType(row.status)"
>
{{ getSuggestionStatusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="120" fixed="right">
<template #default="{ row }">
<el-popconfirm
title="确认同意并应用该建议吗?"
@confirm="handleApplySuggestions([row.suggestionId])"
>
<template #reference>
<el-button
link
type="primary"
size="small"
:disabled="
!row.suggestionId ||
isSuggestionApplied(row.status)
"
:loading="suggestionsApplying"
>
同意
</el-button>
</template>
</el-popconfirm>
</template>
</el-table-column>
</el-table>
</div>
</div>
</el-card>
</el-col>
</el-row>
<!-- 主要内容区 -->
<el-row :gutter="16">
<!-- 左侧:物料清单 -->
<el-col :xs="24" :lg="24">
<!-- 物料清单 -->
<el-card shadow="hover">
<template #header>

View File

@@ -137,7 +137,11 @@ const {
background: 'var(--el-fill-color-light)',
color: 'var(--el-text-color-primary)'
}"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
:tree-props="{
children: 'children',
hasChildren: 'hasChildren',
checkStrictly: true
}"
row-class-name="permission-table-row"
@selection-change="handleSelectionChange"
@page-size-change="handleSizeChange"