feat(execution): 实现执行模块视角切换和快捷过滤功能

- 添加执行视角切换功能(my/all),支持不同身份维度查看
- 实现逾期/本周到期快捷过滤功能,提升执行管理效率
- 重构执行区域UI布局,优化用户体验和界面结构
- 集成Element Plus表单验证,在用户选择器组件中使用
- 优化执行状态筛选和计数逻辑,提升数据展示准确性
- 实现执行视角切换时的数据同步刷新机制
- 添加执行完成操作的二次确认对话框
- 重构权限码检查逻辑,统一使用query权限码进行控制
- 移除auth store依赖,精简代码结构
- 优化执行状态看板和任务计数的加载机制
- 实现执行创建和编辑流程的状态同步更新
- 统一任务工作区的执行范围传递方式,提高性能
- 添加执行详情面板的操作按钮权限控制
- 优化执行删除后的数据刷新逻辑,确保视图一致性
This commit is contained in:
2026-05-29 16:40:25 +08:00
parent 4ed4b537ad
commit b2da882b31
13 changed files with 822 additions and 278 deletions

View File

@@ -1,5 +1,6 @@
<script setup lang="ts">
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useFormItem } from 'element-plus';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import { usePickerSelection } from './business-user-picker/composables/use-picker-selection';
import { useDeptSource } from './business-user-picker/composables/use-dept-source';
@@ -51,6 +52,8 @@ const emit = defineEmits<Emits>();
const model = defineModel<string | string[] | null>({ default: null });
const visible = defineModel<boolean>('visible', { default: false });
const { formItem } = useFormItem();
const source = ref<Source>(props.sources[0] ?? 'all');
const currentNodeId = ref<string | null>(null);
const treeSearch = ref('');
@@ -75,10 +78,22 @@ const showTabs = computed(() => props.sources.length > 1);
const userByIdMap = computed(() => new Map(props.userOptions.map(u => [String(u.id), u])));
const committedIds = computed<string[]>(() => {
const value = model.value;
if (Array.isArray(value)) {
return value.map(String);
}
if (typeof value === 'string' && value) {
return [value];
}
return [];
});
const selectedUsers = computed(() =>
selection.selectedIds.value
.map(id => userByIdMap.value.get(id))
.filter((u): u is Api.SystemManage.UserSimple => Boolean(u))
committedIds.value.map(id => userByIdMap.value.get(id)).filter((u): u is Api.SystemManage.UserSimple => Boolean(u))
);
const lockedSelectedIds = computed(() => selection.selectedIds.value.filter(id => disabledUserIdSet.value.has(id)));
@@ -203,6 +218,9 @@ function handleConfirm() {
emit('change', value);
emit('confirm', { userIds: selection.selectedIds.value });
visible.value = false;
nextTick(() => {
formItem?.validate?.('change').catch(() => {});
});
}
function handleCancel() {

View File

@@ -730,7 +730,7 @@ export async function fetchGetProjectTaskBoardPageCross(
/**
* 项目级"今日小条"汇总4 个数字 + 服务器日期边界)。
*
* scope=all 必须有 project:task:list-all 权限,否则 403PROJECT_OBJECT_PERMISSION_DENIED
* scope=all 必须有 project:task:query 权限,否则 403PROJECT_OBJECT_PERMISSION_DENIED
* 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403UI 应自动切回"我的"。
*/
export function fetchGetProjectTaskSummary(projectId: string, params?: Api.Project.ProjectTaskSummaryParams) {

View File

@@ -65,7 +65,7 @@ declare namespace Api {
type ProjectExecutionStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
/** 执行动作编码 */
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel';
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel' | 'complete';
/** 任务状态编码 */
type ProjectTaskStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
@@ -263,14 +263,31 @@ declare namespace Api {
updateTime: string;
}
/**
* 执行截止时间范围(基于 plannedEndDateoverdue 逾期 / today 今天到期 / thisWeek 本周到期。
* 与任务侧 dueRange 同口径,后端三档均排除终态执行(已完成 / 已取消);未知值 = 不过滤。
*/
type ProjectExecutionDueRange = 'overdue' | 'today' | 'thisWeek';
/**
* 项目执行分页入参(`GET /project/project/{projectId}/executions/page`)。
*
* - `involveUserId` / `ownerId` 互斥:同传后端不报错但语义变 AND前端切视角时务必清另一字段。
* - 不传 `involveUserId` 且不传 `ownerId` = 项目下全部执行。
* - `dueRange` 按计划结束日期过滤,与其它参数 AND详见 ProjectExecutionDueRange。
*/
type ProjectExecutionSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
executionType: string;
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
involveUserId: string;
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
ownerId: string;
statusCode: string;
/** 优先级筛选(字典 valueString "0"~"3"),不传 = 全部档位 */
priority: string;
dueRange: ProjectExecutionDueRange;
updateTime: string[];
}
>;
@@ -278,7 +295,12 @@ declare namespace Api {
type ProjectExecutionStatusBoardParams = CommonType.RecordNullable<{
keyword: string;
executionType: string;
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
involveUserId: string;
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
ownerId: string;
/** 截止时间范围过滤,传入后各状态分组计数均在该范围内统计(口径同 page */
dueRange: ProjectExecutionDueRange;
updateTime: string[];
}>;
@@ -392,15 +414,22 @@ declare namespace Api {
* 项目级跨执行任务分页入参(`GET /project/project/{projectId}/tasks/page`)。
*
* - `involveUserId` / `ownerId` 互斥:同传只 `ownerId` 生效(后端 SQL 双重过滤)。
* - `executionIds` 不传 = 项目内全部执行。
* - `executionIds` 不传 = 项目内全部执行;空数组 `[]` = 明确返空
* - `executionInvolveUserId` = 限定到"该用户参与的执行"owner 或活跃执行协办);未参与任何执行时返空;
* 与 `executionIds` 同传为 AND。用它表达"我参与的执行"范围,无需前端先查执行 id 再回传。
* - `executionStatusCodes` 在任务可见性之上叠加"任务所属执行状态 ∈ 白名单"过滤;多值 OR
* 与 `executionIds` 同传时为 AND。详见 `docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html`。
* - 不传 `involveUserId / ownerId` 且无 `project:task:list-all` 权限时,后端静默降级为"自己有身份的范围",不抛 403。
* - 不传 `involveUserId / ownerId` 且无 `project:task:query` 权限时,后端静默降级为"自己有身份的范围",不抛 403。
*/
type ProjectTaskCrossSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
keyword: string;
executionIds: string[];
/**
* 执行成员过滤:该用户作为执行 owner 或活跃执行协办人的执行 → 其下任务;未参与任何执行时返空。
* 与 `involveUserId`(任务成员)正交,可同传取交集。
*/
executionInvolveUserId: string;
/** 任务所属执行的状态白名单(用于左侧执行池按状态 chip 切换时的任务范围过滤) */
executionStatusCodes: ProjectExecutionStatusCode[];
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
@@ -430,7 +459,7 @@ declare namespace Api {
/** 项目级"今日小条"汇总入参 */
interface ProjectTaskSummaryParams {
/** 默认 mine不传也走 mineall 必须有 project:task:list-all 权限,否则 403 */
/** 默认 mine不传也走 mineall 必须有 project:task:query 权限,否则 403 */
scope?: 'mine' | 'all';
}

View File

@@ -22,6 +22,11 @@ export interface ProductActivityTextPart {
strong?: boolean;
}
interface ActivitySummaryResult {
text: string;
parts: ProductActivityTextPart[];
}
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
tagLabel: string;
timeText: string;
@@ -265,34 +270,46 @@ function buildMemberChangeSummary(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null,
operatorText: string
) {
): ActivitySummaryResult | null {
const memberName = getActivityTargetUserName(item, detailsRecord);
const roleName = getActivityTargetRoleName(item, detailsRecord);
if (!memberName) {
return '';
return null;
}
const memberDetail = roleName ? `${memberName}${roleName}` : memberName;
const prefix =
operatorText === '--' ? `执行了【${item.actionName}】:` : `${operatorText}执行了【${item.actionName}】:`;
const roleSuffix = roleName ? `${roleName}` : '';
const text = `${prefix}${memberName}${roleSuffix}`;
const parts: ProductActivityTextPart[] = [{ text: prefix }, { text: memberName, strong: true }];
return operatorText === '--'
? `执行了【${item.actionName}】:${memberDetail}`
: `${operatorText}执行了【${item.actionName}】:${memberDetail}`;
if (roleSuffix) {
parts.push({ text: roleSuffix });
}
return { text, parts };
}
function buildMemberUpdateSummary(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null,
operatorText: string
) {
): ActivitySummaryResult {
const memberName = getActivityTargetUserName(item, detailsRecord);
const roleTransitionText = getRoleTransitionText(detailsRecord);
const memberText = memberName || '成员';
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
const prefix =
operatorText === '--' ? `执行了【${item.actionName}】:` : `${operatorText}执行了【${item.actionName}】:`;
const text = `${prefix}${memberText}${roleText}`;
const parts: ProductActivityTextPart[] = [{ text: prefix }, { text: memberText, strong: Boolean(memberName) }];
return operatorText === '--'
? `执行了【${item.actionName}】:${memberText}${roleText}`
: `${operatorText}执行了【${item.actionName}】:${memberText}${roleText}`;
if (roleText) {
parts.push({ text: roleText });
}
return { text, parts };
}
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
@@ -319,16 +336,20 @@ function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, o
return operatorText === '--' ? `变更产品经理:${transitionText}` : `${operatorText}变更产品经理:${transitionText}`;
}
function plainSummary(text: string): ActivitySummaryResult {
return { text, parts: [{ text }] };
}
function resolveDetailedSummary(
item: Api.Product.ProductActivityTimelineItem,
detailsRecord: ActivityDetailRecord | null,
texts: { operatorText: string; actionText: string }
) {
): ActivitySummaryResult {
const { operatorText, actionText } = texts;
const summaryText = item.summary?.trim() || '';
if (item.actionType === 'add_member' || item.actionType === 'remove_member') {
return buildMemberChangeSummary(item, detailsRecord, operatorText) || summaryText || actionText;
return buildMemberChangeSummary(item, detailsRecord, operatorText) || plainSummary(summaryText || actionText);
}
if (item.actionType === 'update_member') {
@@ -336,29 +357,16 @@ function resolveDetailedSummary(
}
if (!isGenericActivitySummary(summaryText, actionText)) {
return summaryText;
return plainSummary(summaryText);
}
if (item.actionType === 'change_manager') {
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
const managerSummary = buildManagerChangeSummary(detailsRecord, operatorText);
return plainSummary(managerSummary || summaryText || actionText);
}
return summaryText || actionText;
}
function buildProductActivityTextParts(text: string, subjectText: string): ProductActivityTextPart[] {
const normalizedSubject = subjectText.trim();
const subjectIndex = normalizedSubject ? text.indexOf(normalizedSubject) : -1;
if (subjectIndex < 0) {
return [{ text }];
}
return [
{ text: text.slice(0, subjectIndex) },
{ text: normalizedSubject, strong: true },
{ text: text.slice(subjectIndex + normalizedSubject.length) }
].filter(part => part.text);
return plainSummary(summaryText || actionText);
}
export function buildProductActivityDisplayItem(
@@ -369,18 +377,19 @@ export function buildProductActivityDisplayItem(
operatorText === '--' ? `执行了【${item.actionName}` : `${operatorText}执行了【${item.actionName}`;
const detailsRecord = parseActivityDetails(item.details);
const subjectText = isMemberActivityAction(item.actionType) ? getActivityTargetUserName(item, detailsRecord) : '';
const displaySummary =
item.type === 'status' ? actionText : resolveDetailedSummary(item, detailsRecord, { operatorText, actionText });
const compactText = displaySummary;
const summary =
item.type === 'status'
? plainSummary(actionText)
: resolveDetailedSummary(item, detailsRecord, { operatorText, actionText });
return {
...item,
tagLabel: activityTypeLabelMap[item.type],
timeText: formatProductActivityTime(item.occurredAt) || '--',
actionText,
displaySummary,
compactText,
compactTextParts: buildProductActivityTextParts(compactText, subjectText),
displaySummary: summary.text,
compactText: summary.text,
compactTextParts: summary.parts.filter(part => part.text),
operatorText,
subjectText,
reasonText: item.reason?.trim() || '',

View File

@@ -17,7 +17,6 @@ import {
fetchUpdateProductMember,
fetchUpdateProductSettingBaseInfo
} from '@/service/api';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useThemeStore } from '@/store/modules/theme';
import { useRouterPush } from '@/hooks/common/router';
@@ -46,7 +45,6 @@ import {
defineOptions({ name: 'ProductSetting' });
const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const themeStore = useThemeStore();
const { routerPush } = useRouterPush();
@@ -97,9 +95,7 @@ const baseInfo = computed(() => settings.value?.baseInfo || null);
const lifecycle = computed(() => settings.value?.lifecycle || null);
const canManageTeam = computed(() =>
canManageProductTeam({
buttonCodes: objectContextStore.buttonCodes,
loginUserId: authStore.userInfo.userId,
currentManagerUserId: currentManager.value?.userId
buttonCodes: objectContextStore.buttonCodes
})
);
const visibleSectionKeys = computed(() =>

View File

@@ -6,8 +6,6 @@ export interface ProductManagerMemberLike {
interface ProductTeamManageContext {
buttonCodes: readonly string[];
loginUserId: string | null | undefined;
currentManagerUserId: string | null | undefined;
}
interface ProductLifecycleStatusSummary {
@@ -203,13 +201,5 @@ export function getProductLifecycleActionCardMeta(actionCode: Api.Product.Produc
}
export function canManageProductTeam(context: ProductTeamManageContext) {
const hasUpdateAuth = context.buttonCodes.includes('project:product:update');
const loginUserId = String(context.loginUserId || '');
const currentManagerUserId = String(context.currentManagerUserId || '');
if (!hasUpdateAuth || !loginUserId || !currentManagerUserId) {
return false;
}
return loginUserId === currentManagerUserId;
return context.buttonCodes.includes('project:product:update');
}

View File

@@ -204,6 +204,19 @@ defineExpose({ validate: runValidate });
<ElInput v-model="model.projectCode" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所属产品" prop="productId">
<ElSelect
v-model="model.productId"
clearable
filterable
placeholder="选择所属产品(可选),选择后将锁定项目方向"
@change="onProductChange"
>
<ElOption v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目方向" prop="directionCode">
<DictSelect
@@ -232,19 +245,6 @@ defineExpose({ validate: runValidate });
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所属产品" prop="productId">
<ElSelect
v-model="model.productId"
clearable
filterable
placeholder="选择所属产品(可选),选择后将锁定项目方向"
@change="onProductChange"
>
<ElOption v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目经理" prop="managerUserId">
<BusinessUserPicker

View File

@@ -439,6 +439,21 @@ watch(visible, async value => {
<ElInput v-model="editModel.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所属产品">
<ElInput
:model-value="
productOptions.find(p => p.id === editModel.productId)?.name ||
props.rowData?.productName ||
editModel.productId ||
'未关联产品'
"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未关联产品"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目方向" prop="directionCode">
<DictSelect
@@ -467,21 +482,6 @@ watch(visible, async value => {
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="所属产品">
<ElInput
:model-value="
productOptions.find(p => p.id === editModel.productId)?.name ||
props.rowData?.productName ||
editModel.productId ||
'未关联产品'
"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未关联产品"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem>
<template #label>

View File

@@ -7,7 +7,7 @@ import { reactive } from 'vue';
* 与本身份维度自由组合。
*
* - my: 我参与的(owner 或活跃协办)
* - all: 所有任务(需 project:task:list-all 权限)
* - all: 所有任务(需 project:task:query 权限)
*/
export type ViewContextType = 'my' | 'all';

View File

@@ -44,6 +44,7 @@ function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParam
ownerId: undefined,
statusCode: undefined,
priority: undefined,
dueRange: undefined,
updateTime: undefined
};
}
@@ -61,6 +62,18 @@ const authStore = useAuthStore();
const { context: viewContext, switchToMine, switchToAll } = useTaskViewContext();
// 执行域独立视角:跟任务域 viewContext 完全独立,切换互不影响。
// 用 inline reactive 即可,不抽 composable(只在本页用,YAGNI)。
const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'my' });
function switchExecutionToMine() {
executionViewContext.type = 'my';
}
function switchExecutionToAll() {
executionViewContext.type = 'all';
}
const searchParams = reactive(getInitExecutionSearchParams());
// 默认"全部":右侧任务列表也对应"项目全部执行下的我参与/所有任务",不预先把范围收窄
const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = null;
@@ -93,6 +106,14 @@ const allProjectExecutions = ref<Api.Project.ProjectExecution[]>([]);
const allTasksCount = ref(0);
const myTasksCount = ref(0);
// 执行视角 chip 计数:对应当前搜索条件下"我参与的执行" / "所有执行"总数
const executionAllCount = ref(0);
const executionMyCount = ref(0);
// "我参与的"执行视角下的快捷过滤计数(逾期 / 本周到期),仅 my 视角有意义
const executionOverdueCount = ref(0);
const executionThisWeekCount = ref(0);
const projectId = computed(() => currentObjectId.value || '');
const currentUserId = computed(() => authStore.userInfo.userId || '');
@@ -101,25 +122,46 @@ const statusActionTitle = computed(() =>
);
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
/** 「所有任务」视角按钮可见度:权限码 project:task:list-all */
const showAllPerspective = computed(() => buttonCodeSet.value.has('project:task:list-all'));
/**
* 「所有任务」视角按钮可见度:权限码 project:task:query(基础读权限)。
* list-all 系列权限码已废弃,统一用 query 系列。常规链路恒为 true,
* 留判断只是与权限模型对齐,不出现"按钮渲染却 403"的状态。
*/
const showAllPerspective = computed(() => buttonCodeSet.value.has('project:task:query'));
/**
* 执行视角切换可见度:跟着 project:execution:query(基础读权限)。
* 用户决策:执行没有独立的 list-all 权限码,有 query 就能在"我参与/所有"间切换。
* 实际能进项目页的用户必然有 query 码,所以这个 computed 在常规链路里恒为 true,
* 留判断只是与权限模型对齐,不出现"按钮渲染却 403"的状态。
*/
const showExecutionPerspectiveSwitch = computed(() => buttonCodeSet.value.has('project:execution:query'));
/**
* 当前左侧锚定的"执行范围"——同时供 task-workspace 的任务列表/状态看板入参,以及视角按钮上的计数。
*
* 范围维度拆成个独立字段下传,由后端组合:
* - scopedExecutionIdsForTasks:仅在锚定具体执行时下发 [id];其它场景 undefined
* - scopedExecutionStatusCodesForTasks:仅在左侧选了某状态 chip 时下发 [statusCode];其它场景 undefined
* 范围维度拆成个独立字段下传,由后端组合:
* - scopedExecutionIds:仅在"锚定到某具体执行"时为 [id];否则 undefined
* - scopedExecutionInvolveUserId:执行视角 = my 且未锚定具体执行时 = 当前用户,直接表达"我参与的执行"范围;
* all 视角 / 锚定具体执行时 undefined。改用后端 executionInvolveUserId 后,无需再"先拉我参与的执行 ids 再 map 回传",
* 用户未参与任何执行时后端直接返空(不会再退化成查全部,前端也不必空集合短路)。
* - scopedExecutionStatusCodesForTasks:仅在左侧选了某状态 chip 时下发 [statusCode];其它场景 undefined。
*
* 历史上"按状态过滤"用前端 filter 出 ids 中转的方式实现,会把"对执行无成员权限但对其下任务有 owner/协办权限"
* 的任务漏掉(详见 docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html)。现改为把状态码直接下传给任务接口,
* 由后端在任务可见性之上叠加"任务所属执行的状态 ∈ 白名单"过滤。
*/
const scopedExecutionIdsForTasks = computed<string[] | undefined>(() => {
const scopedExecutionIds = computed<string[] | undefined>(() => {
if (selectedExecution.value) return [selectedExecution.value.id];
return undefined;
});
const scopedExecutionInvolveUserId = computed<string | undefined>(() => {
if (selectedExecution.value) return undefined;
if (executionViewContext.type === 'my') return currentUserId.value || undefined;
return undefined;
});
const scopedExecutionStatusCodesForTasks = computed<Api.Project.ProjectExecutionStatusCode[] | undefined>(() => {
if (selectedExecution.value) return undefined;
if (!selectedStatus.value) return undefined;
@@ -136,15 +178,13 @@ const workspaceTitle = computed(() => {
return viewContext.type === 'my' ? '我参与的' : '所有任务';
});
const workspaceSubtitle = computed(() =>
viewContext.type === 'my' ? `${myTasksCount.value}` : `${allTasksCount.value}`
);
function createRequestParams(): Api.Project.ProjectExecutionSearchParams {
return {
...searchParams,
keyword: searchParams.keyword?.trim() || undefined,
statusCode: selectedStatus.value || undefined
statusCode: selectedStatus.value || undefined,
// 执行视角:my → 带当前用户 ID 走"我参与";all → 不传(项目全部)
involveUserId: executionViewContext.type === 'my' ? currentUserId.value || undefined : undefined
};
}
@@ -153,7 +193,11 @@ function createStatusBoardParams(): Api.Project.ProjectExecutionStatusBoardParam
keyword: searchParams.keyword?.trim() || undefined,
executionType: searchParams.executionType,
ownerId: searchParams.ownerId,
updateTime: searchParams.updateTime
// dueRange 与列表同口径下传,状态 chip 数字随"逾期/本周到期"快捷过滤联动
dueRange: searchParams.dueRange,
updateTime: searchParams.updateTime,
// 执行视角与列表保持同一身份维度,确保状态 chip 数字与列表对得上
involveUserId: executionViewContext.type === 'my' ? currentUserId.value || undefined : undefined
};
}
@@ -223,7 +267,12 @@ async function loadExecutionStatusBoard() {
executionStatusBoard.value = error || !board ? null : board;
}
/** 拉取项目下"全部执行简明列表"。pageSize=-1 是后端"不分页全量"约定,见 CLAUDE.md §9 */
/**
* 拉取项目下执行简明列表(pageSize=-1,后端"不分页全量"约定见 CLAUDE.md §9)。
* 按当前执行视角带 involveUserId:
* - my 视角 → 只拉"我参与的执行",作为任务 scope 和"所属执行"下拉的来源
* - all 视角 → 项目下全部执行
*/
async function loadAllProjectExecutions() {
if (!projectId.value) {
allProjectExecutions.value = [];
@@ -231,7 +280,8 @@ async function loadAllProjectExecutions() {
}
const { error, data: pageData } = await fetchGetProjectExecutionPage(projectId.value, {
pageNo: 1,
pageSize: -1
pageSize: -1,
involveUserId: executionViewContext.type === 'my' ? currentUserId.value || undefined : undefined
});
allProjectExecutions.value = error || !pageData ? [] : pageData.list;
}
@@ -243,14 +293,18 @@ async function loadCrossExecutionCounts() {
return;
}
// 视角按钮计数 = 当前左侧 chip / 执行 锚定范围内的"我参与/所有"总数
const scopedIds = scopedExecutionIdsForTasks.value;
// 视角按钮计数 = 当前左侧 chip / 执行 锚定范围内的"我参与/所有"总数
// my 执行视角下范围用 executionInvolveUserId 表达,我未参与任何执行时后端直接返空 → 计数自然为 0,无需空集合短路。
const scopedIds = scopedExecutionIds.value;
const scopedInvolveUserId = scopedExecutionInvolveUserId.value;
const scopedStatusCodes = scopedExecutionStatusCodesForTasks.value;
// 短路:锚定到具体执行但 ids=[] 不会发生(scopedExecutionIdsForTasks 要么 undefined 要么 [id]);
// 状态码维度交给后端处理(空数组语义统一返空,不在前端短路)
const scopeParams: Pick<Api.Project.ProjectTaskCrossStatusBoardParams, 'executionIds' | 'executionStatusCodes'> = {};
const scopeParams: Pick<
Api.Project.ProjectTaskCrossStatusBoardParams,
'executionIds' | 'executionInvolveUserId' | 'executionStatusCodes'
> = {};
if (scopedIds !== undefined) scopeParams.executionIds = scopedIds;
if (scopedInvolveUserId !== undefined) scopeParams.executionInvolveUserId = scopedInvolveUserId;
if (scopedStatusCodes !== undefined) scopeParams.executionStatusCodes = scopedStatusCodes;
const [allRes, myRes] = await Promise.all([
@@ -262,13 +316,89 @@ async function loadCrossExecutionCounts() {
myTasksCount.value = myRes.error || !myRes.data ? 0 : myRes.data.total;
}
/**
* 执行视角 chip 计数:对当前搜索条件(关键字 / 类型 / 负责人 / 更新时间)叠加,并发拉两次:
* - 不带 involveUserId → "所有"格子的总数
* - 带 involveUserId = 当前用户 → "我参与的"格子的总数
*
* 跟 loadCrossExecutionCounts 对称,只是接口换成执行的 status-board。
*/
async function loadExecutionPerspectiveCounts() {
if (!projectId.value || !currentUserId.value) {
executionAllCount.value = 0;
executionMyCount.value = 0;
return;
}
const baseParams: Api.Project.ProjectExecutionStatusBoardParams = {
keyword: searchParams.keyword?.trim() || undefined,
executionType: searchParams.executionType,
ownerId: searchParams.ownerId,
updateTime: searchParams.updateTime
};
const [allRes, myRes] = await Promise.all([
fetchGetProjectExecutionStatusBoard(projectId.value, baseParams),
fetchGetProjectExecutionStatusBoard(projectId.value, { ...baseParams, involveUserId: currentUserId.value })
]);
executionAllCount.value = allRes.error || !allRes.data ? 0 : allRes.data.total;
executionMyCount.value = myRes.error || !myRes.data ? 0 : myRes.data.total;
}
/**
* "逾期 / 本周到期"快捷过滤计数:仅"我参与的"执行视角有意义,非 my 视角直接清 0。
* 复用 page 接口 pageSize=1 读 total(口径同任务侧 loadQuickFilterCounts),
* 用 involveUserId=me + 基础搜索条件(关键字/类型/更新时间),不叠加 statusCode 或已选中的 dueRange——
* 计数表达的是该范围内的总量指示,不随当前选中的快捷过滤变化。
*/
async function loadExecutionDueCounts() {
if (!projectId.value || !currentUserId.value || executionViewContext.type !== 'my') {
executionOverdueCount.value = 0;
executionThisWeekCount.value = 0;
return;
}
const baseParams: Api.Project.ProjectExecutionSearchParams = {
pageNo: 1,
pageSize: 1,
keyword: searchParams.keyword?.trim() || undefined,
executionType: searchParams.executionType,
updateTime: searchParams.updateTime,
involveUserId: currentUserId.value
};
const [overdueRes, thisWeekRes] = await Promise.all([
fetchGetProjectExecutionPage(projectId.value, { ...baseParams, dueRange: 'overdue' }),
fetchGetProjectExecutionPage(projectId.value, { ...baseParams, dueRange: 'thisWeek' })
]);
executionOverdueCount.value = overdueRes.error || !overdueRes.data ? 0 : overdueRes.data.total;
executionThisWeekCount.value = thisWeekRes.error || !thisWeekRes.data ? 0 : thisWeekRes.data.total;
}
/** 执行视角 chip 计数 + 快捷过滤计数统一刷新入口(各操作后调它,避免两处计数散落) */
async function refreshExecutionCounts() {
await Promise.all([loadExecutionPerspectiveCounts(), loadExecutionDueCounts()]);
}
/**
* 刷新"我参与的执行"集合(供「所属执行」下拉 / 左侧执行列表来源) + 跨执行任务计数。
*
* 任务 scope 已改用 executionInvolveUserId 直接表达"我参与的执行",不再依赖 allProjectExecutions,
* 故两者无顺序依赖,可并行。
*/
async function refreshTaskScopeAndCounts() {
await Promise.all([loadAllProjectExecutions(), loadCrossExecutionCounts()]);
}
async function refreshPageData() {
await Promise.all([
loadProjectMemberOptions(),
reloadExecutionData(),
loadExecutionStatusBoard(),
loadAllProjectExecutions(),
loadCrossExecutionCounts()
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
}
@@ -281,15 +411,23 @@ async function handleExecutionStatusFilter(status: ExecutionStatusFilter) {
await reloadExecutionData(1);
}
async function handleExecutionDueRangeChange(range: Api.Project.ProjectExecutionDueRange | null) {
// 再点已选中的 chip → 取消(回到不限截止时间)
searchParams.dueRange = range ?? undefined;
// dueRange 影响列表与状态看板(状态 chip 数字随之联动);快捷过滤计数是范围总量,不随选中变,无需重算
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function handleExecutionSearch() {
await reloadExecutionData(1);
// 视角 chip 数字依赖搜索条件(keyword/executionType/ownerId/updateTime),搜索后需同步刷新
await Promise.all([reloadExecutionData(1), refreshExecutionCounts()]);
}
async function handleExecutionResetSearch() {
Object.assign(searchParams, getInitExecutionSearchParams());
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
if (selectedExecution.value) selectedExecution.value = null;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard(), refreshExecutionCounts()]);
}
async function getExecutionDetail(row: Api.Project.ProjectExecution) {
@@ -310,6 +448,34 @@ function handleSelectPerspective(type: 'my' | 'all') {
else switchToAll();
}
async function handleSelectExecutionPerspective(type: 'my' | 'all') {
if (executionViewContext.type === type) return;
if (type === 'my') switchExecutionToMine();
else switchExecutionToAll();
// 切视角时:清掉锚定执行(避免"我参与"下锚定执行不在新范围内的空高亮);
// 状态 chip 回"全部"(回到该视角下的项目级总览);
// 然后重拉所有跟执行视角相关的数据。
// 同步清空"我参与的执行"旧集合(供「所属执行」下拉),避免切视角瞬间下拉仍显示上一视角的执行,
// 随后 loadAllProjectExecutions 按新视角重填。任务 scope 已改用 executionInvolveUserId(同步随
// executionViewContext.type 变),不再有"读到旧执行 ids 算出陈旧 scope"的竞态。
allProjectExecutions.value = [];
selectedExecution.value = null;
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
// 快捷过滤(逾期/本周到期)仅"我参与的"视角可见,切视角时一并清掉,避免残留过滤被带到下一视角
searchParams.dueRange = undefined;
searchParams.pageNo = 1;
// 视角切换 → "我参与的执行"集合本身变了 → 任务 scope/cross counts 都要重算
await Promise.all([
reloadExecutionData(1),
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
}
function openCreateExecution() {
editingExecution.value = null;
editingExecutionAssignees.value = [];
@@ -354,6 +520,28 @@ async function openExecutionStatus(row: Api.Project.ProjectExecution, action: Ex
}
statusExecution.value = detail;
statusAction.value = targetAction;
// 完成动作:二次确认后直接提交(完成无需填原因,但需让用户确认这一状态变更)
if (targetAction.actionCode === 'complete') {
try {
await window.$messageBox?.confirm(`确定要完成执行“${detail.executionName}”吗?`, '完成确认', {
confirmButtonText: '完成执行',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
await handleExecutionStatusSubmit(null);
return;
}
// 其他非必填原因的动作(开始/暂停/恢复)直接提交,不弹原因弹层
if (!targetAction.needReason) {
await handleExecutionStatusSubmit(null);
return;
}
statusVisible.value = true;
}
@@ -387,9 +575,12 @@ async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionPa
if (!result.error) {
operateVisible.value = false;
// 执行集合变化 → 视角 chip 数字 + 任务 scope/cross counts 都要刷
await Promise.all([
reloadExecutionData(editingExecution.value ? (searchParams.pageNo ?? 1) : 1),
loadExecutionStatusBoard()
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
}
}
@@ -399,7 +590,13 @@ async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams
const result = await fetchChangeProjectExecutionOwner(projectId.value, selectedExecution.value.id, payload);
if (!result.error) {
selectedExecution.value = await getExecutionDetail(selectedExecution.value);
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
// 改 owner 会影响"我参与的"身份判定,视角 chip + 任务 scope/cross counts 都要刷
await Promise.all([
reloadExecutionData(searchParams.pageNo ?? 1),
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
}
}
@@ -471,7 +668,13 @@ async function handleDeleteExecution(row: Api.Project.ProjectExecution) {
}
window.$message?.success('删除成功');
selectedExecution.value = null;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
// 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷
await Promise.all([
reloadExecutionData(1),
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
}
async function confirmDeleteExecution(payload: { name: string; confirmText: string; reason: string }) {
@@ -485,12 +688,23 @@ async function confirmDeleteExecution(payload: { name: string; confirmText: stri
window.$message?.success('删除成功');
deleteDialogVisible.value = false;
selectedExecution.value = null;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
// 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷
await Promise.all([
reloadExecutionData(1),
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
}
async function handleExecutionChangedByTask() {
if (!selectedExecution.value) {
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
await Promise.all([
reloadExecutionData(searchParams.pageNo ?? 1),
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
return;
}
@@ -499,14 +713,24 @@ async function handleExecutionChangedByTask() {
if (selectedStatus.value && latestExecution.statusCode !== selectedStatus.value) {
selectedStatus.value = latestExecution.statusCode;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
await Promise.all([
reloadExecutionData(1),
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
return;
}
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
await Promise.all([
reloadExecutionData(searchParams.pageNo ?? 1),
loadExecutionStatusBoard(),
refreshExecutionCounts(),
refreshTaskScopeAndCounts()
]);
}
// 左侧 chip / 执行 选择变化 → 视角按钮上的「我参与的 / 所有任务」计数跟着刷,保持与 task-workspace 任务列表口径一致
watch([scopedExecutionIdsForTasks, scopedExecutionStatusCodesForTasks], () => {
watch([scopedExecutionIds, scopedExecutionInvolveUserId, scopedExecutionStatusCodesForTasks], () => {
loadCrossExecutionCounts();
});
@@ -537,6 +761,12 @@ watch(
:selected-status="selectedStatus"
:can-create="canCreateExecution"
:owner-options="projectMemberOptions"
:view-context-type="executionViewContext.type"
:my-count="executionMyCount"
:all-count="executionAllCount"
:overdue-count="executionOverdueCount"
:this-week-count="executionThisWeekCount"
:show-perspective-switch="showExecutionPerspectiveSwitch"
@select="handleSelectExecution"
@status-change="handleExecutionStatusFilter"
@search="handleExecutionSearch"
@@ -547,6 +777,8 @@ watch(
@members="openMemberDialog"
@status-action="openExecutionStatus"
@delete="handleDeleteExecution"
@select-perspective="handleSelectExecutionPerspective"
@due-range-change="handleExecutionDueRangeChange"
/>
</aside>
@@ -556,11 +788,11 @@ watch(
:view-context="viewContext"
:execution="selectedExecution"
:execution-options="executionOptionsForFilter"
:scoped-execution-ids="scopedExecutionIdsForTasks"
:scoped-execution-ids="scopedExecutionIds"
:scoped-execution-involve-user-id="scopedExecutionInvolveUserId"
:scoped-execution-status-codes="scopedExecutionStatusCodesForTasks"
:can-create="canCreateTask"
:title="workspaceTitle"
:subtitle="workspaceSubtitle"
:my-count="myTasksCount"
:all-count="allTasksCount"
:show-all="showAllPerspective"

View File

@@ -2,7 +2,7 @@
import { computed, markRaw } from 'vue';
import { useRouter } from 'vue-router';
import type { PaginationProps } from 'element-plus';
import { Calendar, Flag, Link, Plus, User } from '@element-plus/icons-vue';
import { Calendar, Flag, Link, List, Plus, Star, User } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType } from '../shared';
@@ -21,6 +21,8 @@ defineOptions({ name: 'ProjectTaskExecutionSection' });
type ExecutionStatusFilter = string | null;
type ExecutionViewContextType = 'my' | 'all';
interface Props {
data: Api.Project.ProjectExecution[];
loading: boolean;
@@ -31,6 +33,18 @@ interface Props {
selectedStatus: ExecutionStatusFilter;
canCreate: boolean;
ownerOptions: Api.SystemManage.UserSimple[];
/** 当前执行视角(身份维度):my=我参与的 / all=所有 */
viewContextType: ExecutionViewContextType;
/** "我参与的"chip 数字 */
myCount: number;
/** "所有"chip 数字 */
allCount: number;
/** 快捷过滤计数:逾期执行数(仅"我参与的"视角展示 chip) */
overdueCount: number;
/** 快捷过滤计数:本周到期执行数 */
thisWeekCount: number;
/** 是否展示视角切换 chip 行;无 project:execution:query 权限时为 false */
showPerspectiveSwitch: boolean;
}
interface Emits {
@@ -48,6 +62,10 @@ interface Emits {
row: Api.Project.ProjectExecution,
action: Api.Project.LifecycleAction<Api.Project.ProjectExecutionActionCode> | null
): void;
/** 切换执行视角(身份维度) */
(e: 'select-perspective', type: ExecutionViewContextType): void;
/** 切换"逾期/本周到期"快捷过滤(再点已选中 → 传 null 取消) */
(e: 'due-range-change', range: Api.Project.ProjectExecutionDueRange | null): void;
}
const props = defineProps<Props>();
@@ -80,18 +98,45 @@ function handleReset() {
const router = useRouter();
const { canEditExecution, canDeleteExecution, canSeeExecutionAssigneeEntry } = useTaskPermissions();
const totalCount = computed(() => props.statusBoard?.total ?? 0);
const statusChips = computed(() => [
{ key: null as ExecutionStatusFilter, label: '全部', count: totalCount.value },
...(props.statusBoard?.items ?? []).map(item => ({
const statusChips = computed(() =>
(props.statusBoard?.items ?? []).map(item => ({
key: item.statusCode as ExecutionStatusFilter,
label: item.statusName,
count: item.count
count: item.count,
// 状态色调 → 复用业务对象状态色注册表(src/constants/status-tag.ts),保证与右侧执行卡内的 ElTag 同源
tone: getExecutionStatusTagType(item.statusCode)
}))
);
// 状态下拉:选中某状态 → 该状态;clearable 清空(value 为 '' / undefined) → null,回到"不限状态"
function handleStatusSelect(value: ExecutionStatusFilter) {
emit('status-change', value || null);
}
// 跟右侧任务侧 perspectiveCards 保持顺序一致:所有(List) 在前、我参与的(Star) 在后,icon 一致
const perspectiveChips = computed(() => [
{ key: 'all' as ExecutionViewContextType, label: '所有', count: props.allCount, icon: markRaw(List) },
{ key: 'my' as ExecutionViewContextType, label: '我参与的', count: props.myCount, icon: markRaw(Star) }
]);
const paginationVisible = computed(() => Number(props.pagination.total || 0) > 0);
// "我参与的"视角下的快捷过滤:逾期(标红) / 本周到期。"已完成"复用下方状态 chip,不在此重复
const dueRangeChips = computed(() => [
{ key: 'overdue' as Api.Project.ProjectExecutionDueRange, label: '逾期', count: props.overdueCount, danger: true },
{
key: 'thisWeek' as Api.Project.ProjectExecutionDueRange,
label: '本周到期',
count: props.thisWeekCount,
danger: false
}
]);
// 再点已选中的 chip → 传 null 取消(回到不限截止时间)
function handleDueRangeChipClick(key: Api.Project.ProjectExecutionDueRange) {
emit('due-range-change', searchModel.value.dueRange === key ? null : key);
}
const totalCount = computed(() => Number(props.pagination.total || 0));
const paginationVisible = computed(() => totalCount.value > 0);
function handlePageChange(page: number) {
props.pagination['current-change']?.(page);
@@ -189,14 +234,56 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
<section class="execution-section">
<header class="execution-section__header">
<h3 class="execution-section__title">执行池</h3>
<ElButton v-if="canCreate" type="primary" size="small" :icon="Plus" @click="emit('create')">新增</ElButton>
<div
v-if="showPerspectiveSwitch"
class="execution-section__perspective"
role="tablist"
aria-label="执行视角与快捷过滤切换"
>
<ElTooltip
v-for="item in perspectiveChips"
:key="item.key"
:content="item.label"
placement="top"
:show-after="120"
>
<button
type="button"
role="tab"
class="perspective-compact"
:class="{ 'is-active': viewContextType === item.key }"
:aria-label="`${item.label}${item.count}`"
:aria-pressed="viewContextType === item.key"
@click="emit('select-perspective', item.key)"
>
<ElIcon><component :is="item.icon" /></ElIcon>
<span class="perspective-compact__value">{{ item.count }}</span>
</button>
</ElTooltip>
<button
v-for="chip in viewContextType === 'my' ? dueRangeChips : []"
:key="chip.key"
type="button"
class="perspective-compact"
:class="{
'is-active': searchModel.dueRange === chip.key,
'perspective-compact--danger': chip.danger
}"
:aria-pressed="searchModel.dueRange === chip.key"
@click="handleDueRangeChipClick(chip.key)"
>
<span class="perspective-compact__label">{{ chip.label }}</span>
<span class="perspective-compact__value">{{ chip.count }}</span>
</button>
</div>
</header>
<div class="execution-section__search">
<div class="execution-section__search-row">
<ElInput
:model-value="searchModel.keyword ?? ''"
class="execution-section__search-input"
placeholder="搜索执行名称"
class="execution-section__keyword-input"
placeholder="执行名称"
@update:model-value="handleKeywordInput"
@keyup.enter="handleSearch"
>
@@ -217,36 +304,44 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
>
<ElOption v-for="item in ownerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
</ElSelect>
</div>
<div class="execution-section__search-icons">
<div class="execution-section__filter-row">
<ElSelect
:model-value="selectedStatus"
class="execution-section__status-select"
placeholder="状态"
clearable
@change="handleStatusSelect"
>
<ElOption v-for="item in statusChips" :key="item.key ?? ''" :label="item.label" :value="item.key ?? ''">
<span class="execution-section__status-option">
<span class="execution-section__status-option-dot" :data-tone="item.tone" aria-hidden="true" />
<span class="execution-section__status-option-label">{{ item.label }}</span>
<span class="execution-section__status-option-count">{{ item.count }}</span>
</span>
</ElOption>
</ElSelect>
<div class="execution-section__filter-actions">
<ElTooltip content="重置" placement="top">
<ElButton link class="execution-section__search-btn" @click="handleReset">
<icon-mdi-refresh class="text-15px" />
<ElButton link class="execution-section__action-btn" @click="handleReset">
<icon-mdi-refresh class="text-16px" />
</ElButton>
</ElTooltip>
<ElTooltip content="搜索" placement="top">
<ElButton link type="primary" class="execution-section__search-btn" @click="handleSearch">
<icon-ic-round-search class="text-15px" />
<ElTooltip content="查询" placement="top">
<ElButton link type="primary" class="execution-section__action-btn" @click="handleSearch">
<icon-ic-round-search class="text-16px" />
</ElButton>
</ElTooltip>
<ElTooltip v-if="canCreate" content="新增" placement="top">
<ElButton link type="primary" class="execution-section__action-btn" @click="emit('create')">
<ElIcon><Plus /></ElIcon>
</ElButton>
</ElTooltip>
</div>
</div>
<div class="execution-section__grid" aria-label="执行状态筛选">
<button
v-for="item in statusChips"
:key="item.key || 'all'"
type="button"
class="execution-status-cell"
:class="{ 'is-active': selectedStatus === item.key }"
:aria-pressed="selectedStatus === item.key"
@click="emit('status-change', item.key)"
>
<span>{{ item.label }}</span>
<strong>{{ item.count }}</strong>
</button>
</div>
<ElScrollbar class="execution-section__scrollbar">
<ElSkeleton v-if="loading" :rows="5" animated />
<ElEmpty v-else-if="data.length === 0" class="execution-section__empty" description="暂无执行项" />
@@ -349,16 +444,15 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
flex-direction: column;
min-height: 0;
flex: 1;
gap: 12px;
gap: 8px;
padding: 12px;
}
.execution-section__header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 32px;
flex-direction: column;
align-items: flex-start;
gap: 8px;
}
.execution-section__title {
@@ -369,15 +463,27 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
line-height: 1.4;
}
.execution-section__search {
.execution-section__count {
color: var(--el-text-color-secondary);
font-size: 12.5px;
font-weight: 600;
font-variant-numeric: tabular-nums;
}
.execution-section__search-row,
.execution-section__filter-row {
display: flex;
align-items: center;
gap: 8px;
}
.execution-section__search-input {
width: 140px;
flex: 0 0 auto;
.execution-section__keyword-input {
flex: 1;
min-width: 0;
}
.execution-section__search-icon {
color: var(--el-text-color-placeholder);
}
.execution-section__search-clear {
@@ -390,36 +496,158 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
color: rgb(144 147 153);
}
.execution-section__owner-select {
width: 140px;
flex: 0 0 auto;
.execution-section__owner-select,
.execution-section__status-select {
flex: 1;
min-width: 96px;
}
.execution-section__search-icons {
.execution-section__filter-actions {
display: flex;
flex: 1;
align-items: center;
justify-content: flex-end;
gap: 6px;
flex: 0 0 auto;
margin-left: auto;
min-width: 96px;
}
.execution-section__search-icons :deep(.el-button + .el-button) {
.execution-section__filter-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.execution-section__search-btn) {
width: 24px;
min-width: 24px;
height: 24px;
:deep(.execution-section__action-btn) {
width: 28px;
min-width: 28px;
height: 28px;
padding: 0;
}
.execution-section__grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
/* 状态下拉项:色点 + 状态名 + 数量 */
.execution-section__status-option {
display: flex;
align-items: center;
gap: 8px;
}
.execution-section__status-option-dot {
width: 7px;
height: 7px;
flex: 0 0 auto;
border-radius: 50%;
background-color: var(--el-color-info);
}
.execution-section__status-option-dot[data-tone='primary'] {
background-color: var(--el-color-primary);
}
.execution-section__status-option-dot[data-tone='success'] {
background-color: var(--el-color-success);
}
.execution-section__status-option-dot[data-tone='warning'] {
background-color: var(--el-color-warning);
}
.execution-section__status-option-dot[data-tone='danger'] {
background-color: var(--el-color-danger);
}
.execution-section__status-option-dot[data-tone='info'] {
background-color: var(--el-color-info);
}
.execution-section__status-option-label {
flex: 1;
}
.execution-section__status-option-count {
color: var(--el-text-color-secondary);
font-variant-numeric: tabular-nums;
}
/*
* 视角切换 + 快捷过滤:横排 pill,与右侧任务区视角行同一控件语言。
* 视角(所有 / 我参与的)始终在,图标 + 文字 + 数字;逾期 / 本周到期仅"我参与的"视角追加,同一行,窄屏自动折行。
*/
.execution-section__perspective {
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: 6px;
}
.perspective-compact {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
height: 26px;
padding: 0 8px;
border: 1px solid rgb(226 232 240);
border-radius: 6px;
background: #fff;
color: rgb(100 116 139);
font-size: 12px;
font-weight: 700;
cursor: pointer;
transition:
background 0.16s ease,
border-color 0.16s ease,
color 0.16s ease;
}
.perspective-compact__label {
line-height: 1;
}
.perspective-compact:not(.is-active):hover {
color: var(--el-color-primary);
border-color: var(--el-color-primary);
}
.perspective-compact.is-active {
background-color: var(--el-color-primary);
border-color: var(--el-color-primary);
color: #fff;
box-shadow: 0 2px 6px rgb(64 158 255 / 28%);
}
.perspective-compact__value {
padding: 1px 5px;
border-radius: 6px;
background-color: rgb(255 255 255 / 70%);
font-size: 11px;
}
.perspective-compact.is-active .perspective-compact__value {
background-color: #fff;
color: var(--el-color-primary);
}
/* 逾期 chip:未选中红字红边,选中红底(与任务区快捷过滤的 danger 款一致) */
.perspective-compact--danger:not(.is-active) {
color: var(--el-color-danger);
border-color: rgb(252 165 165 / 70%);
}
.perspective-compact--danger:not(.is-active):hover {
color: var(--el-color-danger);
border-color: var(--el-color-danger);
}
.perspective-compact--danger.is-active {
background-color: var(--el-color-danger);
border-color: var(--el-color-danger);
color: #fff;
box-shadow: 0 2px 6px rgb(245 108 108 / 28%);
}
.perspective-compact--danger.is-active .perspective-compact__value {
background-color: #fff;
color: var(--el-color-danger);
}
.execution-status-cell {
display: flex;
align-items: center;
@@ -466,6 +694,8 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
.execution-section__scrollbar {
flex: 1;
min-height: 0;
padding-top: 8px;
border-top: 1px solid var(--el-border-color-lighter);
}
.execution-section__empty {

View File

@@ -1,7 +1,8 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, h } from 'vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
import { getTaskStatusTagType } from '../shared';
import type { TaskWorkspaceSearchModel } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskSearch' });
@@ -39,6 +40,32 @@ const dueRangeOptions = [
{ label: '本周到期', value: 'thisWeek' }
];
// 状态下拉项:色点(任务状态色) + 状态名 + 数量,与执行侧状态下拉口径一致。
// 渲染进的是通用 TableSearchFields 内部的 ElOption,scoped 样式不跨组件,故用内联 style。
const STATUS_TONE_COLOR: Record<string, string> = {
primary: 'var(--el-color-primary)',
success: 'var(--el-color-success)',
warning: 'var(--el-color-warning)',
danger: 'var(--el-color-danger)',
info: 'var(--el-color-info)'
};
function renderStatusOption(opt: { label: string; value: string | number }) {
const item = props.statusOptions.find(status => status.statusCode === opt.value);
const toneColor = STATUS_TONE_COLOR[getTaskStatusTagType(String(opt.value))] ?? STATUS_TONE_COLOR.info;
return h('span', { style: 'display:flex;align-items:center;gap:8px;width:100%' }, [
h('span', {
style: `width:7px;height:7px;flex:0 0 auto;border-radius:50%;background-color:${toneColor}`
}),
h('span', { style: 'flex:1' }, opt.label),
h(
'span',
{ style: 'color:var(--el-text-color-secondary);font-variant-numeric:tabular-nums' },
String(item?.count ?? 0)
)
]);
}
const fields = computed<SearchField[]>(() => {
const list: SearchField[] = [
{ key: 'keyword', label: '关键词', type: 'input', placeholder: '任务名称/说明' },
@@ -54,7 +81,9 @@ const fields = computed<SearchField[]>(() => {
key: 'statusCode',
label: '状态',
type: 'select',
// 选项 label 仅状态名(回显用);下拉项的色点+数量由 renderOption 渲染,与执行侧一致
options: props.statusOptions.map(item => ({ label: item.statusName, value: item.statusCode })),
renderOption: renderStatusOption,
placeholder: '全部状态'
},
{

View File

@@ -49,12 +49,17 @@ interface Props {
execution: Api.Project.ProjectExecution | null;
executionOptions: { id: string; name: string }[];
/**
* 当前左侧执行池锚定的"执行 id 范围"(由 index 算):
* - undefined → 不限定具体执行(由 scopedExecutionStatusCodes 或后端默认范围决定)
* 当前左侧执行池锚定的"具体执行 id"(由 index 算):
* - [id] → 锚定到这个具体执行
* - [] → 防御兜底,本组件短路不发请求(理论上现已不会发生)
* - undefined → 未锚定具体执行(范围由 scopedExecutionInvolveUserId / scopedExecutionStatusCodes 或后端默认决定)
*/
scopedExecutionIds?: string[];
/**
* "我参与的执行"范围(由 index 算:执行视角 = 我参与 且未锚定具体执行时 = 当前用户 id):
* 直接下传后端 executionInvolveUserId,限定到该用户参与(owner / 活跃协办)的执行;未参与任何执行时后端返空。
* undefined → 不按执行成员收窄(all 视角 / 已锚定具体执行)。
*/
scopedExecutionInvolveUserId?: string;
/**
* 当前左侧 chip 选中的"执行状态白名单"(由 index 算):
* - undefined → 左侧选了"全部" / 锚定具体执行,不按执行状态过滤
@@ -67,11 +72,10 @@ interface Props {
/** 跨执行视角下不展示新建任务入口;单执行视角下根据该 prop 决定 */
canCreate: boolean;
title: string;
subtitle?: string;
/** 视角按钮计数:从 index 拉的项目层级 status-board total */
myCount: number;
allCount: number;
/** 「所有任务」视角按钮可见度(权限码 project:task:list-all) */
/** 「所有任务」视角按钮可见度(权限码 project:task:query) */
showAll: boolean;
}
@@ -142,7 +146,7 @@ const perspectiveCards = computed<PerspectiveCard[]>(() => {
return [my];
});
type QuickFilterKey = 'overdue' | 'thisWeek' | 'completed';
type QuickFilterKey = 'overdue' | 'thisWeek';
interface QuickFilterChip {
key: QuickFilterKey;
@@ -152,13 +156,12 @@ interface QuickFilterChip {
}
// 快速过滤 chip 数字:对齐当前 scoped+viewContext,不带搜索框其他字段
// 个数字分别拉:逾期/本周到期 dueRange,已完成 用 statusCodes=['completed']
const quickFilterCounts = ref({ overdue: 0, thisWeek: 0, completed: 0 });
// 个数字:逾期 / 本周到期,均按 dueRange
const quickFilterCounts = ref({ overdue: 0, thisWeek: 0 });
const quickFilterChips = computed<QuickFilterChip[]>(() => [
{ key: 'overdue', label: '逾期', count: quickFilterCounts.value.overdue, emphasis: 'danger' },
{ key: 'thisWeek', label: '本周到期', count: quickFilterCounts.value.thisWeek },
{ key: 'completed', label: '完成', count: quickFilterCounts.value.completed }
{ key: 'thisWeek', label: '本周到期', count: quickFilterCounts.value.thisWeek }
]);
// 「我参与的」视角下展示 chip,数字跟着 scope(全部/状态/锚定执行)变
@@ -184,54 +187,31 @@ const searchModel = reactive<TaskWorkspaceSearchModel>(buildInitialSearchModel()
const executionId = computed(() => props.execution?.id || '');
const cascade = useTaskCompletionCascade({
projectId: computed(() => props.projectId),
executionId,
openStatusActionDialog: (task, action, fromCascade) => {
currentTask.value = task;
currentStatusAction.value = action;
pendingCascade.value = fromCascade;
statusActionVisible.value = true;
},
resolveCompleteAction: task => task.availableActions.find(item => item.actionCode === 'complete') ?? null
});
const canLoadAnyTasks = computed(() => Boolean(props.projectId));
const statusActionTitle = computed(() =>
currentStatusAction.value ? `任务状态变更:${currentStatusAction.value.actionName}` : '任务状态变更'
);
const totalText = computed(() => {
const total = taskStatusBoard.value?.total;
if (typeof total !== 'number') return '';
return `${total} 条任务`;
});
function resolveCrossExecutionIds(): string[] | undefined {
// 搜索栏「所属执行」选了某个,优先用它(此时无视左侧 chip 的状态过滤)
// 搜索栏「所属执行」选了某个,优先用它(精确锁定到该执行)
if (searchModel.executionId) return [searchModel.executionId];
// 全部 → 不带 executionIds(后端语义:不传 = 项目内全部)
if (props.scopedExecutionIds === undefined) return undefined;
// 空数组场景已由 shouldShortCircuit 拦截,不会走到这里;非空 → 下发
// 否则跟随左侧锚定:[id] 锚定具体执行 / undefined 未锚定(范围交给 executionInvolveUserId 或后端默认)
return props.scopedExecutionIds;
}
function resolveCrossExecutionInvolveUserId(): string | undefined {
// 搜索栏「所属执行」选了具体执行 → 精确锁定,不叠加"我参与的执行"范围
if (searchModel.executionId) return undefined;
return props.scopedExecutionInvolveUserId;
}
function resolveCrossExecutionStatusCodes(): Api.Project.ProjectExecutionStatusCode[] | undefined {
// 搜索栏「所属执行」选了某个具体执行 → 优先精确锁定,不再叠加状态过滤
if (searchModel.executionId) return undefined;
return props.scopedExecutionStatusCodes;
}
/**
* 短路判定:左侧锚定了"某状态但该状态下 0 个可选执行"。
* 后端的 executionIds 是 Long[],不接受 '__none__' 之类的哨兵,所以前端在这种场景下不发请求,直接展示空状态。
* 注意:搜索栏「所属执行」选了具体值时,优先用它,不算短路。
*/
const shouldShortCircuit = computed(
() => !searchModel.executionId && props.scopedExecutionIds !== undefined && props.scopedExecutionIds.length === 0
);
// 「本周到期」语义统一:dueRange=thisWeek 且未显式选状态时,排除终态(completed/cancelled)
function resolveEffectiveStatusCodes(): Api.Project.ProjectTaskStatusCode[] | undefined {
if (searchModel.statusCode) {
@@ -249,6 +229,7 @@ function buildCrossSearchParams(): Api.Project.ProjectTaskCrossSearchParams {
pageSize: pageSize.value,
keyword: searchModel.keyword?.trim() || undefined,
executionIds: resolveCrossExecutionIds(),
executionInvolveUserId: resolveCrossExecutionInvolveUserId(),
executionStatusCodes: resolveCrossExecutionStatusCodes(),
involveUserId: props.viewContext.type === 'my' ? currentUserId.value || undefined : undefined,
ownerId: searchModel.ownerId || undefined,
@@ -265,6 +246,7 @@ function buildCrossStatusBoardParams(): Api.Project.ProjectTaskCrossStatusBoardP
return {
keyword: searchModel.keyword?.trim() || undefined,
executionIds: resolveCrossExecutionIds(),
executionInvolveUserId: resolveCrossExecutionInvolveUserId(),
executionStatusCodes: resolveCrossExecutionStatusCodes(),
involveUserId: props.viewContext.type === 'my' ? currentUserId.value || undefined : undefined,
ownerId: searchModel.ownerId || undefined,
@@ -295,7 +277,7 @@ const { data, loading, getData, getDataByPage, mobilePagination } = useUIPaginat
pageSize: pageSize.value
},
api: () => {
if (!canLoadAnyTasks.value || shouldShortCircuit.value) {
if (!canLoadAnyTasks.value) {
return Promise.resolve({
data: { total: 0, list: [] },
error: null
@@ -319,41 +301,34 @@ const taskOptions = computed(() => data.value);
async function loadQuickFilterCounts() {
// 仅在「我 + 跨执行视角」下有意义,其他时机直接清 0
if (!showQuickFilters.value || !canLoadAnyTasks.value) {
quickFilterCounts.value = { overdue: 0, thisWeek: 0, completed: 0 };
return;
}
// scope 是空集(左侧选了某状态但 0 个执行) → 0
if (props.scopedExecutionIds !== undefined && props.scopedExecutionIds.length === 0) {
quickFilterCounts.value = { overdue: 0, thisWeek: 0, completed: 0 };
quickFilterCounts.value = { overdue: 0, thisWeek: 0 };
return;
}
// scope 跟随左侧锚定:具体执行用 executionIds,"我参与的执行"用 executionInvolveUserId;
// 我未参与任何执行时后端直接返空 → 计数自然为 0,无需空集合短路。
const baseScope: Api.Project.ProjectTaskCrossSearchParams = {
pageNo: 1,
pageSize: 1,
executionIds: props.scopedExecutionIds,
executionInvolveUserId: props.scopedExecutionInvolveUserId,
executionStatusCodes: props.scopedExecutionStatusCodes,
involveUserId: currentUserId.value || undefined
};
const [overdueRes, thisWeekRes, completedRes] = await Promise.all([
const [overdueRes, thisWeekRes] = await Promise.all([
fetchGetProjectTaskPageCross(props.projectId, { ...baseScope, dueRange: 'overdue' }),
fetchGetProjectTaskPageCross(props.projectId, {
...baseScope,
dueRange: 'thisWeek',
// 「本周到期」= 本周需要做完且尚未完成,排除 completed/cancelled 两个终态
statusCodes: ['pending', 'active', 'paused'] as Api.Project.ProjectTaskStatusCode[]
}),
fetchGetProjectTaskPageCross(props.projectId, {
...baseScope,
statusCodes: ['completed' as Api.Project.ProjectTaskStatusCode]
})
]);
quickFilterCounts.value = {
overdue: overdueRes.error || !overdueRes.data ? 0 : overdueRes.data.total,
thisWeek: thisWeekRes.error || !thisWeekRes.data ? 0 : thisWeekRes.data.total,
completed: completedRes.error || !completedRes.data ? 0 : completedRes.data.total
thisWeek: thisWeekRes.error || !thisWeekRes.data ? 0 : thisWeekRes.data.total
};
}
@@ -362,10 +337,6 @@ async function loadTaskStatusBoard() {
taskStatusBoard.value = null;
return;
}
if (shouldShortCircuit.value) {
taskStatusBoard.value = { total: 0, items: [] };
return;
}
const { error, data: board } = await fetchGetProjectTaskStatusBoardCross(
props.projectId,
buildCrossStatusBoardParams()
@@ -374,12 +345,6 @@ async function loadTaskStatusBoard() {
}
async function boardFetcher(params: BoardFetcherParams) {
if (shouldShortCircuit.value) {
return {
data: { items: [] },
error: null
} as unknown as Awaited<ReturnType<typeof fetchGetProjectTaskBoardPageCross>>;
}
// statusCodes 只在"单列加载更多"时下传(对应那一列);首屏(params.statusCode 缺省)不下传,
// 让后端返完整 5 列骨架——避免点 chip 后列消失。
// chip 隐含的状态白名单(resolveEffectiveStatusCodes)在响应后客户端过滤:不在白名单的列保留列头但
@@ -390,6 +355,7 @@ async function boardFetcher(params: BoardFetcherParams) {
statusCodes: params.statusCode as Api.Project.ProjectTaskStatusCode[] | undefined,
keyword: searchModel.keyword?.trim() || undefined,
executionIds: resolveCrossExecutionIds(),
executionInvolveUserId: resolveCrossExecutionInvolveUserId(),
executionStatusCodes: resolveCrossExecutionStatusCodes(),
involveUserId: props.viewContext.type === 'my' ? currentUserId.value || undefined : undefined,
ownerId: searchModel.ownerId || undefined,
@@ -475,22 +441,43 @@ async function handleStatusAction(row: Api.Project.ProjectTask, action: TaskStat
currentTask.value = detail;
currentStatusAction.value = targetAction;
// 完成动作:二次确认后直接提交(完成无需填原因,但需让用户确认这一状态变更)
if (targetAction.actionCode === 'complete') {
try {
await window.$messageBox?.confirm(`确定要完成任务“${detail.taskTitle}”吗?`, '完成确认', {
confirmButtonText: '完成任务',
cancelButtonText: '取消',
type: 'warning'
});
} catch {
return;
}
await handleStatusSubmit(null);
return;
}
// 其他非必填原因的动作(开始/暂停/恢复)直接提交,不弹原因弹层
if (!targetAction.needReason) {
await handleStatusSubmit(null);
return;
}
statusActionVisible.value = true;
}
async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
if (!props.execution) {
return;
}
if (operateMode.value !== 'create' && !currentTask.value) {
// 新建必须锚定到具体执行;编辑跟随任务自身的 executionId跨执行视角下 props.execution 为 null
if (operateMode.value === 'create') {
if (!props.execution) return;
} else if (!currentTask.value) {
return;
}
const result =
operateMode.value === 'create'
? await fetchCreateProjectTask(props.projectId, props.execution.id, payload)
: await fetchUpdateProjectTask(props.projectId, props.execution.id, {
? await fetchCreateProjectTask(props.projectId, props.execution!.id, payload)
: await fetchUpdateProjectTask(props.projectId, currentTask.value!.executionId, {
taskId: currentTask.value!.id,
data: payload
});
@@ -500,9 +487,28 @@ async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
window.$message?.success(operateMode.value === 'create' ? '任务创建成功' : '任务更新成功');
await taskOperateDialogRef.value?.commit();
operateVisible.value = false;
await refreshTableData();
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
// 任务条数变了 → 通知父级刷新跨执行任务计数(右上角"我参与的 / 所有任务"数字)
emit('executionChanged');
}
const cascade = useTaskCompletionCascade({
projectId: computed(() => props.projectId),
executionId,
openStatusActionDialog: async (task, action, fromCascade) => {
currentTask.value = task;
currentStatusAction.value = action;
pendingCascade.value = fromCascade;
// 级联入口已在前置 confirm 里确认过;完成 / 无需原因的动作直接提交,不再弹原因弹层
if (action.actionCode === 'complete' || !action.needReason) {
await handleStatusSubmit(null);
return;
}
statusActionVisible.value = true;
},
resolveCompleteAction: task => task.availableActions.find(item => item.actionCode === 'complete') ?? null
});
async function handleStatusSubmit(reason: string | null) {
if (!currentTask.value || !currentStatusAction.value) return;
@@ -713,30 +719,20 @@ async function handleReset() {
}
function isQuickFilterActive(key: QuickFilterKey) {
if (key === 'overdue') return searchModel.dueRange === 'overdue';
if (key === 'thisWeek') return searchModel.dueRange === 'thisWeek';
return searchModel.statusCode === 'completed';
return searchModel.dueRange === key;
}
async function toggleQuickFilter(key: QuickFilterKey) {
const active = isQuickFilterActive(key);
// 3 个 chip 互斥:统一先清,再按需置位
searchModel.dueRange = undefined;
if (searchModel.statusCode === 'completed') searchModel.statusCode = undefined;
if (!active) {
if (key === 'completed') searchModel.statusCode = 'completed';
else searchModel.dueRange = key;
}
// 两个 chip 互斥:再点已选中 → 取消;否则切到目标 dueRange
searchModel.dueRange = isQuickFilterActive(key) ? undefined : key;
await handleSearch();
}
async function handlePerspectiveClick(type: 'my' | 'all') {
// 同一视角再点:手动清掉 quick filter chip(切到不同视角由 watch viewContext.type 触发 reset)
if (props.viewContext.type === type) {
const hasQuickFilter = Boolean(searchModel.dueRange) || searchModel.statusCode === 'completed';
if (!hasQuickFilter) return;
if (!searchModel.dueRange) return;
searchModel.dueRange = undefined;
if (searchModel.statusCode === 'completed') searchModel.statusCode = undefined;
await handleSearch();
return;
}
@@ -770,16 +766,38 @@ watch(
}
);
// 范围维度变化(scopedExecutionIds / scopedExecutionStatusCodes 引用变:左侧 chip 切换 / execution 锚定切换) → 重拉任务列表 + status-board
watch([() => props.scopedExecutionIds, () => props.scopedExecutionStatusCodes], async () => {
if (!canLoadAnyTasks.value) return;
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
});
// 范围维度变化(scopedExecutionIds / scopedExecutionInvolveUserId / scopedExecutionStatusCodes 引用变:
// 左侧 chip 切换 / 执行视角切换 / execution 锚定切换) → 重拉任务列表 + status-board
watch(
[() => props.scopedExecutionIds, () => props.scopedExecutionInvolveUserId, () => props.scopedExecutionStatusCodes],
async () => {
if (!canLoadAnyTasks.value) return;
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
}
);
// 切换项目(导航区换对象上下文) → projectId 变。父级把 selectedExecution/selectedStatus 重置回默认态时,
// scoped* 计算属性的输出值往往不变(都回到 undefined),上面那条 watcher 不会触发,任务列表/状态看板会停留在
// 上一个项目的旧数据。这里独立盯 projectId:清掉本组件内部搜索条件并重拉,确保任务内容跟着项目切换刷新。
// 非 immediate:首屏初始加载由 viewContext 那条 immediate watch 负责,避免挂载时重复请求。
watch(
() => props.projectId,
async () => {
resetSearchModel(undefined);
if (!canLoadAnyTasks.value) {
data.value = [];
taskStatusBoard.value = null;
return;
}
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
}
);
// quick filter chip 数字:跟着 scope / viewContext / 锚定执行 / projectId 变,独立于搜索框其他字段
watch(
[
() => props.scopedExecutionIds,
() => props.scopedExecutionInvolveUserId,
() => props.scopedExecutionStatusCodes,
() => props.viewContext.type,
() => props.execution?.id,
@@ -836,8 +854,6 @@ defineExpose({
<span class="perspective-compact__value">{{ chip.count }}</span>
</button>
</div>
<span v-if="subtitle" class="task-workspace__subtitle">{{ subtitle }}</span>
<span v-else-if="totalText" class="task-workspace__subtitle">{{ totalText }}</span>
</div>
<div class="task-workspace__actions">
@@ -988,11 +1004,6 @@ defineExpose({
line-height: 1.4;
}
.task-workspace__subtitle {
color: rgb(100 116 139);
font-size: 12.5px;
}
.task-workspace__perspective {
display: inline-flex;
align-items: center;