feat(execution): 实现执行模块视角切换和快捷过滤功能
- 添加执行视角切换功能(my/all),支持不同身份维度查看 - 实现逾期/本周到期快捷过滤功能,提升执行管理效率 - 重构执行区域UI布局,优化用户体验和界面结构 - 集成Element Plus表单验证,在用户选择器组件中使用 - 优化执行状态筛选和计数逻辑,提升数据展示准确性 - 实现执行视角切换时的数据同步刷新机制 - 添加执行完成操作的二次确认对话框 - 重构权限码检查逻辑,统一使用query权限码进行控制 - 移除auth store依赖,精简代码结构 - 优化执行状态看板和任务计数的加载机制 - 实现执行创建和编辑流程的状态同步更新 - 统一任务工作区的执行范围传递方式,提高性能 - 添加执行详情面板的操作按钮权限控制 - 优化执行删除后的数据刷新逻辑,确保视图一致性
This commit is contained in:
@@ -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() {
|
||||
|
||||
@@ -730,7 +730,7 @@ export async function fetchGetProjectTaskBoardPageCross(
|
||||
/**
|
||||
* 项目级"今日小条"汇总(4 个数字 + 服务器日期边界)。
|
||||
*
|
||||
* scope=all 必须有 project:task:list-all 权限,否则 403(PROJECT_OBJECT_PERMISSION_DENIED)。
|
||||
* scope=all 必须有 project:task:query 权限,否则 403(PROJECT_OBJECT_PERMISSION_DENIED)。
|
||||
* 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403,UI 应自动切回"我的"。
|
||||
*/
|
||||
export function fetchGetProjectTaskSummary(projectId: string, params?: Api.Project.ProjectTaskSummaryParams) {
|
||||
|
||||
37
src/typings/api/project.d.ts
vendored
37
src/typings/api/project.d.ts
vendored
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行截止时间范围(基于 plannedEndDate):overdue 逾期 / 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;
|
||||
/** 优先级筛选(字典 value:String "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(不传也走 mine);all 必须有 project:task:list-all 权限,否则 403 */
|
||||
/** 默认 mine(不传也走 mine);all 必须有 project:task:query 权限,否则 403 */
|
||||
scope?: 'mine' | 'all';
|
||||
}
|
||||
|
||||
|
||||
@@ -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() || '',
|
||||
|
||||
@@ -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(() =>
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -7,7 +7,7 @@ import { reactive } from 'vue';
|
||||
* 与本身份维度自由组合。
|
||||
*
|
||||
* - my: 我参与的(owner 或活跃协办)
|
||||
* - all: 所有任务(需 project:task:list-all 权限)
|
||||
* - all: 所有任务(需 project:task:query 权限)
|
||||
*/
|
||||
export type ViewContextType = 'my' | 'all';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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: '全部状态'
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user