# Conflicts:
#	src/service/api/product.ts
#	src/service/api/project.ts
#	src/typings/api/project.d.ts
This commit is contained in:
dk
2026-05-13 21:20:59 +08:00
59 changed files with 8046 additions and 919 deletions

View File

@@ -10,8 +10,7 @@ import { fetchGetProductOverviewSummary, fetchGetProductPage, fetchGetUserSimple
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { getProductStatusLabel, getProductStatusTagType, isProductEditable } from '../shared/product-master-data';
import { getProductStatusLabel, getProductStatusTagType } from '../shared/product-master-data';
import ProductOperateDialog from './modules/product-operate-dialog.vue';
import ProductSearch from './modules/product-search.vue';
@@ -235,26 +234,6 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 108,
align: 'center',
fixed: 'right',
formatter: row => (
<BusinessTableActionCell
actions={[
{
key: 'edit',
label: '编辑',
buttonType: 'primary',
disabled: !isProductEditable(row.statusCode),
onClick: () => openEdit(row)
}
]}
/>
)
}
],
immediate: false
@@ -317,11 +296,6 @@ function openCreate() {
operateVisible.value = true;
}
function openEdit(row: Api.Product.Product) {
editingRow.value = row;
operateVisible.value = true;
}
async function enterProductContext(row: Api.Product.Product) {
await routerPush({
path: PRODUCT_ENTRY_ROUTE_PATH,

View File

@@ -0,0 +1,96 @@
<script setup lang="ts">
import { computed } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProductCreateBaseForm' });
export interface ProductCreateBaseForm {
code: string;
name: string;
directionCode: string;
managerUserId: string | null;
description: string;
}
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
const model = defineModel<ProductCreateBaseForm>('modelValue', { required: true });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const rules = computed(
() =>
({
name: [createRequiredRule('请输入产品名称')],
directionCode: [createRequiredRule('请选择产品方向')],
managerUserId: [createRequiredRule('请选择产品经理')]
}) satisfies Record<string, App.Global.FormRule[]>
);
async function runValidate(): Promise<boolean> {
try {
await validate();
return true;
} catch {
return false;
}
}
defineExpose({ validate: runValidate });
</script>
<template>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品编码" prop="code">
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-model="model.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择产品方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="产品经理" prop="managerUserId">
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择产品经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品描述" prop="description">
<ElInput
v-model="model.description"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入产品描述"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</template>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProductCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput {
userId: string;
roleId: string;
remark: string;
}
interface Props {
mode: OperateMode;
initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
}
interface Emits {
(e: 'submit', payload: DraftMemberInput): void;
}
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PRODUCT_MANAGER_ROLE_CODE;
}
async function handleConfirm() {
await validate();
emit('submit', {
userId: model.userId,
roleId: model.roleId,
remark: model.remark.trim()
});
}
watch(visible, async value => {
if (!value) {
return;
}
model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || '';
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''"
readonly
class="product-create-team-member-dialog__readonly-input"
placeholder="未获取到成员用户"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
:disabled="isManagerRole(role)"
>
<span>{{ role.name }}</span>
<span v-if="isManagerRole(role)" class="product-create-team-member-dialog__role-hint">
已由第 1 步指定
</span>
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注">
<ElInput
v-model="model.remark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.product-create-team-member-dialog__role-hint {
margin-left: 8px;
color: rgb(148 163 184 / 96%);
font-size: 12px;
}
:deep(.product-create-team-member-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.product-create-team-member-dialog__readonly-input .el-input__wrapper:hover),
:deep(.product-create-team-member-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.product-create-team-member-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
-webkit-text-fill-color: rgb(51 65 85 / 96%);
cursor: default;
}
</style>

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { PRODUCT_MANAGER_ROLE_CODE } from '@/constants/business';
import { fetchGetRoleSimpleList } from '@/service/api';
import { getProductTeamTableHeight } from '../../setting/shared';
import ProductCreateTeamMemberDialog from './product-create-team-member-dialog.vue';
import type { ProductCreateBaseForm } from './product-create-base-form.vue';
defineOptions({ name: 'ProductCreateTeamStep' });
interface DraftMember {
/** 客户端临时主键,仅用于 v-for 稳定 */
key: string;
userId: string;
roleId: string;
remark: string;
/** true 表示由产品经理自动派生的锁定行 */
locked: boolean;
}
interface Props {
baseInfo: ProductCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:members', members: Api.Product.CreateProductMemberParams[]): void;
}>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const members = ref<DraftMember[]>([]);
const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const teamTableHeight = getProductTeamTableHeight(5);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const managerRole = computed(() => roleOptions.value.find(item => item.code === PRODUCT_MANAGER_ROLE_CODE) ?? null);
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
const dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) {
return null;
}
const target = members.value.find(item => item.key === editingKey.value);
if (!target) {
return null;
}
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
});
function getUserNickname(userId: string) {
return userLabelMap.value.get(String(userId)) || userId;
}
function getRoleName(roleId: string) {
return roleOptions.value.find(item => item.id === roleId)?.name || '--';
}
function generateKey() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'product' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到产品经理角色,请联系管理员';
return;
}
refreshManagerRow();
}
function refreshManagerRow() {
const managerUserId = props.baseInfo.managerUserId;
if (!managerUserId || !managerRole.value) {
members.value = members.value.filter(item => !item.locked);
emitMembers();
return;
}
const lockedIndex = members.value.findIndex(item => item.locked);
const lockedRow: DraftMember = {
key: lockedIndex >= 0 ? members.value[lockedIndex].key : generateKey(),
userId: managerUserId,
roleId: managerRole.value.id,
remark: lockedIndex >= 0 ? members.value[lockedIndex].remark : '',
locked: true
};
if (lockedIndex >= 0) {
members.value[lockedIndex] = lockedRow;
} else {
members.value = [lockedRow, ...members.value];
}
emitMembers();
}
function openCreate() {
memberDialogMode.value = 'create';
editingKey.value = null;
memberDialogVisible.value = true;
}
function openEdit(row: DraftMember) {
memberDialogMode.value = 'edit';
editingKey.value = row.key;
memberDialogVisible.value = true;
}
function removeMember(key: string) {
members.value = members.value.filter(item => item.key !== key);
emitMembers();
}
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') {
members.value.push({
key: generateKey(),
userId: payload.userId,
roleId: payload.roleId,
remark: payload.remark,
locked: false
});
} else if (editingKey.value) {
const idx = members.value.findIndex(item => item.key === editingKey.value);
if (idx >= 0) {
members.value[idx] = {
...members.value[idx],
roleId: payload.roleId,
remark: payload.remark
};
}
}
memberDialogVisible.value = false;
emitMembers();
}
function emitMembers() {
emit(
'update:members',
members.value.map(item => ({
userId: item.userId,
roleId: item.roleId,
remark: item.remark.trim() || null,
previousManagerUserId: null,
previousManagerRoleId: null
}))
);
}
async function runValidate(): Promise<boolean> {
if (managerRoleError.value) {
window.$message?.error(managerRoleError.value);
return false;
}
for (const item of members.value) {
if (!item.userId || !item.roleId) {
window.$message?.error('请补全所有成员的用户和角色');
return false;
}
}
const userIdSet = new Set<string>();
for (const item of members.value) {
if (userIdSet.has(item.userId)) {
window.$message?.error(`成员「${getUserNickname(item.userId)}」重复,请检查`);
return false;
}
userIdSet.add(item.userId);
}
return true;
}
onMounted(loadRoles);
watch(
() => props.baseInfo.managerUserId,
() => {
if (!managerRoleError.value && managerRole.value) {
refreshManagerRow();
}
}
);
defineExpose({ validate: runValidate });
</script>
<template>
<div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
</div>
<ElAlert
v-if="managerRoleError"
:title="managerRoleError"
type="error"
:closable="false"
show-icon
class="team-step__alert"
/>
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<ProductCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds"
@submit="handleMemberSubmit"
/>
</div>
</template>
<style scoped>
.team-step {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.team-step__toolbar {
display: flex;
justify-content: flex-end;
}
.team-step__alert {
margin: 0;
}
.team-step__actions {
display: inline-flex;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -1,11 +1,14 @@
<script setup lang="ts">
import { computed, nextTick, ref, watch } from 'vue';
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
import { fetchCreateProduct, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { fetchCreateProductWithTeam, fetchGetProduct, fetchUpdateProduct } from '@/service/api';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import ProductCreateBaseForm, {
type ProductCreateBaseForm as ProductCreateBaseFormModel
} from './product-create-base-form.vue';
import ProductCreateTeamStep from './product-create-team-step.vue';
defineOptions({ name: 'ProductOperateDialog' });
@@ -22,11 +25,10 @@ interface Emits {
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const visible = defineModel<boolean>('visible', { default: false });
interface Model {
// === 编辑模式(单步) ===
interface EditModel {
code: string;
directionCode: string;
name: string;
@@ -34,17 +36,26 @@ interface Model {
description: string;
}
const { formRef, validate } = useForm();
const { formRef: editFormRef, validate: editValidate } = useForm();
const { createRequiredRule } = useFormRules();
const editModel = ref<EditModel>(createEditModel());
const editLoading = ref(false);
const submitting = ref(false);
const isEditMode = computed(() => Boolean(props.rowData?.id));
const dialogTitle = computed(() => (isEditMode.value ? '编辑产品' : '新增产品'));
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
const editRules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')],
managerUserId: [createRequiredRule('请选择产品经理')]
} satisfies Record<string, App.Global.FormRule[]>;
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
const managerUserId = editModel.value.managerUserId;
if (!managerUserId) {
return '';
@@ -53,20 +64,8 @@ const managerDisplayName = computed(() => {
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
const rules = {
directionCode: [createRequiredRule('请选择产品方向')],
name: [createRequiredRule('请输入产品名称')],
managerUserId: [createRequiredRule('请选择产品经理')]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
return {
code: '',
directionCode: '',
name: '',
managerUserId: null,
description: ''
};
function createEditModel(): EditModel {
return { code: '', directionCode: '', name: '', managerUserId: null, description: '' };
}
function getNullableText(value?: string | null) {
@@ -77,80 +76,132 @@ function closeDialog() {
visible.value = false;
}
async function handleSubmit() {
await validate();
async function handleEditSubmit() {
await editValidate();
const managerUserId = model.value.managerUserId;
const managerUserId = editModel.value.managerUserId;
if (!managerUserId) {
if (!managerUserId || !props.rowData?.id) {
return;
}
const payload: Api.Product.SaveProductParams = {
code: getNullableText(model.value.code),
directionCode: model.value.directionCode,
name: model.value.name.trim(),
// Long ID 必须以 string 提交,禁止再转成 number。
managerUserId,
description: getNullableText(model.value.description)
};
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const result = await fetchUpdateProduct({
id: props.rowData.id,
...payload
});
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('产品编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
return;
}
const result = await fetchCreateProduct(payload);
const { error } = await fetchUpdateProduct({
id: props.rowData.id,
code: getNullableText(editModel.value.code),
directionCode: editModel.value.directionCode,
name: editModel.value.name.trim(),
managerUserId,
description: getNullableText(editModel.value.description)
});
submitting.value = false;
if (result.error) {
if (error) {
return;
}
window.$message?.success('产品编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
}
// === 新增模式(两步向导) ===
const baseFormRef = ref<InstanceType<typeof ProductCreateBaseForm> | null>(null);
const teamStepRef = ref<InstanceType<typeof ProductCreateTeamStep> | null>(null);
const currentStep = ref<1 | 2>(1);
const createBaseModel = ref<ProductCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Product.CreateProductMemberParams[]>([]);
function createBaseInfo(): ProductCreateBaseFormModel {
return { code: '', name: '', directionCode: '', managerUserId: null, description: '' };
}
async function goNext() {
const valid = await baseFormRef.value?.validate();
if (!valid) {
return;
}
currentStep.value = 2;
}
function goPrev() {
currentStep.value = 1;
}
async function handleCreateSubmit() {
const baseValid = await baseFormRef.value?.validate();
if (!baseValid) {
currentStep.value = 1;
return;
}
const teamValid = await teamStepRef.value?.validate();
if (!teamValid) {
return;
}
submitting.value = true;
const payload: Api.Product.CreateProductWithTeamParams = {
product: {
code: getNullableText(createBaseModel.value.code),
name: createBaseModel.value.name.trim(),
directionCode: createBaseModel.value.directionCode,
managerUserId: createBaseModel.value.managerUserId as string,
description: getNullableText(createBaseModel.value.description)
},
members: draftMembers.value
};
const { error, data } = await fetchCreateProductWithTeam(payload);
submitting.value = false;
if (error) {
return;
}
window.$message?.success('产品新增成功');
closeDialog();
emit('submitted', result.data);
emit('submitted', data);
}
// === 公共:弹框可见性变化时重置 / 加载数据 ===
watch(visible, async value => {
if (!value) {
return;
}
submitting.value = false;
currentStep.value = 1;
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
await nextTick();
formRef.value?.clearValidate();
editFormRef.value?.clearValidate();
return;
}
loading.value = true;
editLoading.value = true;
const { error, data } = await fetchGetProduct(props.rowData.id);
loading.value = false;
editLoading.value = false;
if (error || !data) {
return;
}
model.value = {
editModel.value = {
code: data.code || '',
directionCode: data.directionCode || '',
name: data.name || '',
@@ -159,43 +210,42 @@ watch(visible, async value => {
};
await nextTick();
formRef.value?.clearValidate();
editFormRef.value?.clearValidate();
});
</script>
<template>
<!-- 编辑模式单步表单与改造前一致 -->
<BusinessFormDialog
v-if="isEditMode"
v-model="visible"
:title="dialogTitle"
preset="sm"
:loading="loading"
:loading="editLoading"
:confirm-loading="submitting"
@confirm="handleSubmit"
@confirm="handleEditSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElForm ref="editFormRef" :model="editModel" :rules="editRules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="isEditMode" label="产品编码" prop="code">
<ElFormItem label="产品编码" prop="code">
<ElInput
:model-value="model.code"
:model-value="editModel.code"
readonly
class="product-operate-dialog__readonly-input"
placeholder="未获取到产品编码"
/>
</ElFormItem>
<ElFormItem v-else label="产品编码" prop="code">
<ElInput v-model="model.code" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品名称" prop="name">
<ElInput v-model="model.name" clearable maxlength="128" placeholder="请输入产品名称" />
<ElInput v-model="editModel.name" clearable maxlength="128" placeholder="请输入产品名称" />
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品方向" prop="directionCode">
<DictSelect
v-model="model.directionCode"
v-model="editModel.directionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择产品方向"
@@ -203,7 +253,7 @@ watch(visible, async value => {
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem v-if="isEditMode">
<ElFormItem>
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
@@ -225,18 +275,11 @@ watch(visible, async value => {
placeholder="未配置产品经理"
/>
</ElFormItem>
<ElFormItem v-else label="产品经理" prop="managerUserId">
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择产品经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="产品描述" prop="description">
<ElInput
v-model="model.description"
v-model="editModel.description"
type="textarea"
:rows="4"
maxlength="500"
@@ -248,6 +291,63 @@ watch(visible, async value => {
</ElRow>
</ElForm>
</BusinessFormDialog>
<!-- 新增模式两步向导复合内容特例自定义 ElDialog 880px -->
<ElDialog
v-else
v-model="visible"
class="product-create-dialog"
:title="dialogTitle"
:close-on-click-modal="false"
destroy-on-close
align-center
width="760px"
>
<div class="product-create-dialog__stepbar">
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }">
<span class="product-create-dialog__step-index">1</span>
<span class="product-create-dialog__step-text">
<strong>基础资料</strong>
<small>定义产品身份和负责人</small>
</span>
</div>
<div class="product-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
<span class="product-create-dialog__step-index">2</span>
<span class="product-create-dialog__step-text">
<strong>初始化团队</strong>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="product-create-dialog__body">
<div v-show="currentStep === 1" class="product-create-dialog__panel">
<ProductCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" />
</div>
<div v-show="currentStep === 2" class="product-create-dialog__panel">
<ProductCreateTeamStep
ref="teamStepRef"
:base-info="createBaseModel"
:user-options="managerUserOptions"
@update:members="draftMembers = $event"
/>
</div>
</div>
<template #footer>
<div class="product-create-dialog__footer">
<span class="product-create-dialog__footer-meta"> {{ currentStep }} 2 </span>
<ElSpace :size="10">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
<ElButton v-if="currentStep === 1" type="primary" @click="goNext">下一步</ElButton>
<ElButton v-if="currentStep === 2" type="primary" :loading="submitting" @click="handleCreateSubmit">
确定
</ElButton>
</ElSpace>
</div>
</template>
</ElDialog>
</template>
<style scoped>
@@ -267,4 +367,86 @@ watch(visible, async value => {
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
.product-create-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
padding: 0;
}
.product-create-dialog__stepbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 14px 24px;
border-bottom: 1px solid rgb(229 233 242 / 96%);
background: #fbfcfe;
}
.product-create-dialog__step {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.product-create-dialog__step-index {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 1px solid rgb(215 222 235 / 96%);
border-radius: 999px;
background: #fff;
color: rgb(119 129 150 / 96%);
font-size: 13px;
font-weight: 650;
}
.product-create-dialog__step.is-active .product-create-dialog__step-index,
.product-create-dialog__step.is-done .product-create-dialog__step-index {
border-color: var(--el-color-primary);
background: var(--el-color-primary);
color: #fff;
}
.product-create-dialog__step-text {
min-width: 0;
}
.product-create-dialog__step-text strong {
display: block;
font-size: 14px;
font-weight: 650;
}
.product-create-dialog__step-text small {
display: block;
margin-top: 2px;
color: rgb(119 129 150 / 96%);
font-size: 12px;
}
.product-create-dialog__body {
min-height: 0;
max-height: min(560px, calc(100vh - 240px));
overflow: auto;
}
.product-create-dialog__panel {
padding: 24px;
}
.product-create-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.product-create-dialog__footer-meta {
color: rgb(119 129 150 / 96%);
font-size: 13px;
}
</style>

View File

@@ -423,9 +423,8 @@ watch(
:member="selectedMember"
:current-manager="currentManager"
:role-options="roleOptions"
:user-options="
userOptions.filter(user => !members.some(member => member.status === 0 && member.userId === user.id))
"
:user-options="userOptions"
:disabled-user-ids="members.filter(member => member.status === 0).map(member => member.userId)"
@submit="handleSubmitMemberOperate"
/>
<MemberRemoveDialog

View File

@@ -16,6 +16,8 @@ interface Props {
currentManager: Api.Product.ProductMember | null;
roleOptions: Api.SystemManage.RoleSimple[];
userOptions: Api.SystemManage.UserSimple[];
/** 已是有效成员、需在下拉中禁选并标记"已添加"的 userId 集合 */
disabledUserIds?: readonly string[];
}
interface SubmitPayload {
@@ -29,7 +31,9 @@ interface Emits {
(e: 'submit', payload: SubmitPayload): void;
}
const props = defineProps<Props>();
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
@@ -143,7 +147,13 @@ watch(
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect v-model="model.userId" :options="userOptions" placeholder="请选择成员用户" />
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="props.disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput

View File

@@ -78,18 +78,6 @@ const productLifecycleActionCardMetaMap: Record<Api.Product.ProductStatusActionC
}
};
const productSettingErrorMessageMap: Record<string, string> = {
'1008001002': '产品名称已存在,请更换名称',
'1008001007': '当前产品状态不允许编辑基础信息',
'1008001008': '当前产品已暂停,基础信息仅支持查看,不可编辑。',
'1008001013': '请选择原产品经理交接后的角色',
'1008001014': '当前产品经理不能直接移出,请先完成经理交接',
'1008001015': '当前产品经理不能直接调整为非经理角色,请先完成经理转交',
'1008001004': '当前状态不支持该动作',
'1008001005': '当前动作必须填写原因',
'1008001006': '删除确认名称与当前产品名称不一致'
};
const productTeamTableHeaderHeight = 40;
const productTeamTableRowHeight = 40;
@@ -225,9 +213,3 @@ export function canManageProductTeam(context: ProductTeamManageContext) {
return loginUserId === currentManagerUserId;
}
export function getProductSettingErrorMessage(code: string | number | null | undefined, backendMessage: string) {
const normalizedCode = String(code || '');
return productSettingErrorMessageMap[normalizedCode] || backendMessage;
}

View File

@@ -8,8 +8,7 @@ import { fetchGetProjectOverviewSummary, fetchGetProjectPage, fetchGetUserSimple
import { useDict } from '@/hooks/business/dict';
import { useRouterPush } from '@/hooks/common/router';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessTableActionCell from '@/components/custom/business-table-action-cell';
import { getProjectStatusLabel, getProjectStatusTagType, isProjectEditable } from '../shared/project-master-data';
import { getProjectStatusLabel, getProjectStatusTagType } from '../shared/project-master-data';
import ProjectOperateDialog from './modules/project-operate-dialog.vue';
import ProjectOverviewCard from './modules/project-overview-card.vue';
import ProjectSearch from './modules/project-search.vue';
@@ -190,26 +189,6 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
width: 170,
align: 'center',
formatter: row => formatDateTime(row.updateTime)
},
{
prop: 'operate',
label: '操作',
width: 108,
align: 'center',
fixed: 'right',
formatter: row => (
<BusinessTableActionCell
actions={[
{
key: 'edit',
label: '编辑',
buttonType: 'primary',
disabled: !isProjectEditable(row.statusCode),
onClick: () => openEdit(row)
}
]}
/>
)
}
],
immediate: false
@@ -272,11 +251,6 @@ function openCreate() {
operateVisible.value = true;
}
function openEdit(row: Api.Project.Project) {
editingRow.value = row;
operateVisible.value = true;
}
async function enterProjectContext(row: Api.Project.Project) {
await routerPush({
path: PROJECT_ENTRY_ROUTE_PATH,

View File

@@ -0,0 +1,317 @@
<script setup lang="ts">
import { computed, nextTick, onMounted, ref } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchGetProductPage } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
defineOptions({ name: 'ProjectCreateBaseForm' });
export interface ProjectCreateBaseForm {
projectCode: string;
projectName: string;
directionCode: string;
projectType: string;
productId: string | null;
managerUserId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
projectDesc: string;
}
interface ProductOption {
id: string;
name: string;
directionCode: string;
}
interface Props {
managerUserOptions: Api.SystemManage.UserSimple[];
}
defineProps<Props>();
const model = defineModel<ProjectCreateBaseForm>('modelValue', { required: true });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const productOptions = ref<ProductOption[]>([]);
const hasAssociatedProduct = computed(() => Boolean(model.value.productId));
const directionReadonly = computed(() => hasAssociatedProduct.value);
const selectedProductDirection = computed(() => {
if (!model.value.productId) {
return '';
}
return productOptions.value.find(p => p.id === model.value.productId)?.directionCode || '';
});
const effectiveDirectionCode = computed({
get: () => {
if (hasAssociatedProduct.value) {
return selectedProductDirection.value || model.value.directionCode;
}
return model.value.directionCode;
},
set: (val: string) => {
if (!hasAssociatedProduct.value) {
model.value.directionCode = val;
}
}
});
const directionDisplayName = computed(() => {
const directionCode = effectiveDirectionCode.value;
if (!directionCode) {
return '';
}
return getDirectionLabel(directionCode, directionCode);
});
function parsePlannedDate(value: string | null | undefined) {
if (!value) {
return null;
}
const date = new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isPlannedDateRangeValid(startDate: string | null, endDate: string | null) {
if (!startDate || !endDate) {
return true;
}
return !dayjs(endDate).isBefore(dayjs(startDate), 'day');
}
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
let startDate = parsePlannedDate(model.value.plannedStartDate);
if (!startDate) {
startDate = new Date();
model.value.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
}
const endDate = new Date(startDate.getTime());
mutator(endDate);
return endDate;
}
};
}
const plannedEndDateShortcuts = [
buildEndDateShortcut('一星期', date => date.setDate(date.getDate() + 7)),
buildEndDateShortcut('两星期', date => date.setDate(date.getDate() + 14)),
buildEndDateShortcut('一个月', date => date.setMonth(date.getMonth() + 1)),
buildEndDateShortcut('三个月', date => date.setMonth(date.getMonth() + 3)),
buildEndDateShortcut('半年', date => date.setMonth(date.getMonth() + 6)),
buildEndDateShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
const rules = computed(
() =>
({
projectName: [createRequiredRule('请输入项目名称')],
directionCode: [createRequiredRule('请选择项目方向')],
projectType: [createRequiredRule('请选择项目类型')],
managerUserId: [createRequiredRule('请选择项目经理')],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(model.value.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
callback();
},
trigger: 'change'
}
]
}) satisfies Record<string, App.Global.FormRule[]>
);
async function loadProductOptions() {
const { error, data } = await fetchGetProductPage({ pageNo: 1, pageSize: 200 });
if (error || !data) {
productOptions.value = [];
return;
}
productOptions.value = data.list.map(item => ({
id: item.id,
name: item.name || item.code || item.id,
directionCode: item.directionCode || ''
}));
}
function onProductChange(newProductId: string | null) {
if (!newProductId) {
return;
}
const product = productOptions.value.find(p => p.id === newProductId);
if (product) {
model.value.directionCode = product.directionCode;
}
}
async function runValidate(): Promise<boolean> {
try {
await validate();
return true;
} catch {
return false;
}
}
onMounted(loadProductOptions);
defineExpose({ validate: runValidate });
</script>
<template>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="项目名称" prop="projectName">
<ElInput v-model="model.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目编码" prop="projectCode">
<ElInput v-model="model.projectCode" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目方向" prop="directionCode">
<DictSelect
v-if="!directionReadonly"
v-model="effectiveDirectionCode"
:dict-code="RDMS_OBJECT_DIRECTION_DICT_CODE"
filterable
placeholder="请选择项目方向"
/>
<ElInput
v-else
:model-value="directionDisplayName"
readonly
class="project-create-base-form__readonly-input"
placeholder="未获取到项目方向"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目类型" prop="projectType">
<DictSelect
v-model="model.projectType"
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
filterable
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="managerUserId">
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择项目经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择开始日期"
class="project-create-base-form__date-picker"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择结束日期"
:shortcuts="plannedEndDateShortcuts"
class="project-create-base-form__date-picker"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="项目说明" prop="projectDesc">
<ElInput
v-model="model.projectDesc"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入项目说明"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</template>
<style scoped>
:deep(.project-create-base-form__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.project-create-base-form__readonly-input .el-input__wrapper:hover),
:deep(.project-create-base-form__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.project-create-base-form__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
cursor: default;
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
:deep(.project-create-base-form__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -0,0 +1,169 @@
<script setup lang="ts">
import { computed, nextTick, reactive, watch } from 'vue';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProjectCreateTeamMemberDialog' });
type OperateMode = 'create' | 'edit';
interface DraftMemberInput {
userId: string;
roleId: string;
remark: string;
}
interface Props {
mode: OperateMode;
initial: DraftMemberInput | null;
userOptions: Api.SystemManage.UserSimple[];
roleOptions: Api.SystemManage.RoleSimple[];
/** 已使用且不可选的 userId编辑模式应当排除当前行自身 */
disabledUserIds?: readonly string[];
}
interface Emits {
(e: 'submit', payload: DraftMemberInput): void;
}
const props = withDefaults(defineProps<Props>(), {
disabledUserIds: () => []
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const model = reactive<DraftMemberInput>({
userId: '',
roleId: '',
remark: ''
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新增团队成员' : '调整成员角色'));
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const rules = computed(
() =>
({
userId: props.mode === 'create' ? [createRequiredRule('请选择成员用户')] : [],
roleId: [createRequiredRule('请选择角色')]
}) satisfies Record<string, App.Global.FormRule[]>
);
function isManagerRole(role: Api.SystemManage.RoleSimple) {
return role.code === PROJECT_MANAGER_ROLE_CODE;
}
async function handleConfirm() {
await validate();
emit('submit', {
userId: model.userId,
roleId: model.roleId,
remark: model.remark.trim()
});
}
watch(visible, async value => {
if (!value) {
return;
}
model.userId = props.initial?.userId || '';
model.roleId = props.initial?.roleId || '';
model.remark = props.initial?.remark || '';
await nextTick();
formRef.value?.clearValidate();
});
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="sm" @confirm="handleConfirm">
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElRow :gutter="16">
<ElCol :span="24">
<ElFormItem v-if="mode === 'create'" label="成员用户" prop="userId">
<BusinessUserSelect
v-model="model.userId"
:options="userOptions"
:disabled-user-ids="disabledUserIds"
disabled-label="已添加"
placeholder="请选择成员用户"
/>
</ElFormItem>
<ElFormItem v-else label="成员用户">
<ElInput
:model-value="userLabelMap.get(String(model.userId)) || ''"
readonly
class="project-create-team-member-dialog__readonly-input"
placeholder="未获取到成员用户"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="目标角色" prop="roleId">
<ElSelect v-model="model.roleId" class="w-full" placeholder="请选择角色">
<ElOption
v-for="role in roleOptions"
:key="role.id"
:label="role.name"
:value="role.id"
:disabled="isManagerRole(role)"
>
<span>{{ role.name }}</span>
<span v-if="isManagerRole(role)" class="project-create-team-member-dialog__role-hint">
已由第 1 步指定
</span>
</ElOption>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="备注">
<ElInput
v-model="model.remark"
type="textarea"
:rows="3"
maxlength="200"
show-word-limit
placeholder="请输入备注"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
</BusinessFormDialog>
</template>
<style scoped>
.project-create-team-member-dialog__role-hint {
margin-left: 8px;
color: rgb(148 163 184 / 96%);
font-size: 12px;
}
:deep(.project-create-team-member-dialog__readonly-input .el-input__wrapper) {
background: linear-gradient(180deg, rgb(241 245 249 / 98%), rgb(226 232 240 / 94%)), rgb(241 245 249);
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
cursor: default;
}
:deep(.project-create-team-member-dialog__readonly-input .el-input__wrapper:hover),
:deep(.project-create-team-member-dialog__readonly-input.is-focus .el-input__wrapper) {
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
}
:deep(.project-create-team-member-dialog__readonly-input .el-input__inner) {
color: rgb(51 65 85 / 96%);
-webkit-text-fill-color: rgb(51 65 85 / 96%);
cursor: default;
}
</style>

View File

@@ -0,0 +1,298 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue';
import { PROJECT_MANAGER_ROLE_CODE } from '@/constants/business';
import { fetchGetRoleSimpleList } from '@/service/api';
import { getProjectTeamTableHeight } from '@/views/project/project/setting/shared';
import ProjectCreateTeamMemberDialog from './project-create-team-member-dialog.vue';
import type { ProjectCreateBaseForm } from './project-create-base-form.vue';
defineOptions({ name: 'ProjectCreateTeamStep' });
interface DraftMember {
/** 客户端临时主键,仅用于 v-for 稳定 */
key: string;
userId: string;
roleId: string;
remark: string;
/** true 表示由项目经理自动派生的锁定行 */
locked: boolean;
}
interface Props {
baseInfo: ProjectCreateBaseForm;
userOptions: Api.SystemManage.UserSimple[];
}
const props = defineProps<Props>();
const emit = defineEmits<{
(e: 'update:members', members: Api.Project.CreateProjectMemberParams[]): void;
}>();
const roleOptions = ref<Api.SystemManage.RoleSimple[]>([]);
const roleLoading = ref(false);
const managerRoleError = ref('');
const members = ref<DraftMember[]>([]);
const memberDialogVisible = ref(false);
const memberDialogMode = ref<'create' | 'edit'>('create');
const editingKey = ref<string | null>(null);
const teamTableHeight = getProjectTeamTableHeight(5);
const userLabelMap = computed(() => new Map(props.userOptions.map(item => [String(item.id), item.nickname])));
const managerRole = computed(() => roleOptions.value.find(item => item.code === PROJECT_MANAGER_ROLE_CODE) ?? null);
// 弹框传入的禁选用户列表:新增时排除所有已选;编辑时排除自身以外的已选
const dialogDisabledUserIds = computed(() => {
return members.value
.filter(item => !editingKey.value || item.key !== editingKey.value)
.map(item => item.userId)
.filter(Boolean);
});
const dialogInitial = computed(() => {
if (memberDialogMode.value === 'create' || !editingKey.value) {
return null;
}
const target = members.value.find(item => item.key === editingKey.value);
if (!target) {
return null;
}
return { userId: target.userId, roleId: target.roleId, remark: target.remark };
});
function getUserNickname(userId: string) {
return userLabelMap.value.get(String(userId)) || userId;
}
function getRoleName(roleId: string) {
return roleOptions.value.find(item => item.id === roleId)?.name || '--';
}
function generateKey() {
return `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
async function loadRoles() {
roleLoading.value = true;
managerRoleError.value = '';
const { data } = await fetchGetRoleSimpleList({ scopeType: 'object', objectType: 'project' });
roleLoading.value = false;
roleOptions.value = data ?? [];
if (!managerRole.value) {
managerRoleError.value = '未找到项目经理角色,请联系管理员';
return;
}
refreshManagerRow();
}
function refreshManagerRow() {
const managerUserId = props.baseInfo.managerUserId;
if (!managerUserId || !managerRole.value) {
members.value = members.value.filter(item => !item.locked);
emitMembers();
return;
}
const lockedIndex = members.value.findIndex(item => item.locked);
const lockedRow: DraftMember = {
key: lockedIndex >= 0 ? members.value[lockedIndex].key : generateKey(),
userId: managerUserId,
roleId: managerRole.value.id,
remark: lockedIndex >= 0 ? members.value[lockedIndex].remark : '',
locked: true
};
if (lockedIndex >= 0) {
members.value[lockedIndex] = lockedRow;
} else {
members.value = [lockedRow, ...members.value];
}
emitMembers();
}
function openCreate() {
memberDialogMode.value = 'create';
editingKey.value = null;
memberDialogVisible.value = true;
}
function openEdit(row: DraftMember) {
memberDialogMode.value = 'edit';
editingKey.value = row.key;
memberDialogVisible.value = true;
}
function removeMember(key: string) {
members.value = members.value.filter(item => item.key !== key);
emitMembers();
}
function handleMemberSubmit(payload: { userId: string; roleId: string; remark: string }) {
if (memberDialogMode.value === 'create') {
members.value.push({
key: generateKey(),
userId: payload.userId,
roleId: payload.roleId,
remark: payload.remark,
locked: false
});
} else if (editingKey.value) {
const idx = members.value.findIndex(item => item.key === editingKey.value);
if (idx >= 0) {
members.value[idx] = {
...members.value[idx],
roleId: payload.roleId,
remark: payload.remark
};
}
}
memberDialogVisible.value = false;
emitMembers();
}
function emitMembers() {
emit(
'update:members',
members.value.map(item => ({
userId: item.userId,
roleId: item.roleId,
remark: item.remark.trim() || null,
previousManagerUserId: null,
previousManagerRoleId: null
}))
);
}
async function runValidate(): Promise<boolean> {
if (managerRoleError.value) {
window.$message?.error(managerRoleError.value);
return false;
}
for (const item of members.value) {
if (!item.userId || !item.roleId) {
window.$message?.error('请补全所有成员的用户和角色');
return false;
}
}
const userIdSet = new Set<string>();
for (const item of members.value) {
if (userIdSet.has(item.userId)) {
window.$message?.error(`成员「${getUserNickname(item.userId)}」重复,请检查`);
return false;
}
userIdSet.add(item.userId);
}
return true;
}
onMounted(loadRoles);
watch(
() => props.baseInfo.managerUserId,
() => {
if (!managerRoleError.value && managerRole.value) {
refreshManagerRow();
}
}
);
defineExpose({ validate: runValidate });
</script>
<template>
<div v-loading="roleLoading" class="team-step">
<div class="team-step__toolbar">
<ElButton type="primary" plain :disabled="Boolean(managerRoleError)" @click="openCreate">新增成员</ElButton>
</div>
<ElAlert
v-if="managerRoleError"
:title="managerRoleError"
type="error"
:closable="false"
show-icon
class="team-step__alert"
/>
<ElTable :data="members" :height="teamTableHeight" border row-key="key" empty-text="点击右上角新增成员添加">
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="成员姓名" min-width="120">
<template #default="{ row }">
{{ getUserNickname(row.userId) }}
</template>
</ElTableColumn>
<ElTableColumn label="当前角色" min-width="140">
<template #default="{ row }">
{{ getRoleName(row.roleId) }}
</template>
</ElTableColumn>
<ElTableColumn label="备注" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
{{ row.remark || '--' }}
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="150" fixed="right" align="center">
<template #default="{ row }">
<div class="team-step__actions">
<ElButton link type="primary" :disabled="row.locked" @click="openEdit(row)">编辑</ElButton>
<ElButton link type="danger" :disabled="row.locked" @click="removeMember(row.key)">移除</ElButton>
</div>
</template>
</ElTableColumn>
</ElTable>
<ProjectCreateTeamMemberDialog
v-model:visible="memberDialogVisible"
:mode="memberDialogMode"
:initial="dialogInitial"
:user-options="userOptions"
:role-options="roleOptions"
:disabled-user-ids="dialogDisabledUserIds"
@submit="handleMemberSubmit"
/>
</div>
</template>
<style scoped>
.team-step {
display: flex;
flex-direction: column;
gap: 14px;
min-height: 0;
}
.team-step__toolbar {
display: flex;
justify-content: flex-end;
}
.team-step__alert {
margin: 0;
}
.team-step__actions {
display: inline-flex;
align-items: center;
gap: 12px;
}
</style>

View File

@@ -1,15 +1,18 @@
<script setup lang="tsx">
import { computed, nextTick, ref, watch } from 'vue';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElOption, ElRow, ElSelect } from 'element-plus';
import { ElCol, ElDatePicker, ElFormItem, ElInput, ElRow } from 'element-plus';
import dayjs from 'dayjs';
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
import { fetchCreateProject, fetchGetProductPage, fetchUpdateProject } from '@/service/api';
import { fetchCreateProjectWithTeam, fetchGetProductPage, fetchUpdateProject } from '@/service/api';
import { useDict } from '@/hooks/business/dict';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import ProjectCreateBaseForm, {
type ProjectCreateBaseForm as ProjectCreateBaseFormModel
} from './project-create-base-form.vue';
import ProjectCreateTeamStep from './project-create-team-step.vue';
defineOptions({ name: 'ProjectOperateDialog' });
@@ -26,11 +29,10 @@ interface Emits {
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const visible = defineModel<boolean>('visible', { default: false });
interface Model {
// === 编辑模式(单步) ===
interface EditModel {
projectCode: string;
projectName: string;
directionCode: string;
@@ -42,17 +44,17 @@ interface Model {
projectDesc: string;
}
const { formRef, validate } = useForm();
const { formRef: editFormRef, validate: editValidate } = useForm();
const { createRequiredRule } = useFormRules();
const { getLabel: getDirectionLabel } = useDict(RDMS_OBJECT_DIRECTION_DICT_CODE);
const editModel = ref<EditModel>(createEditModel());
const editLoading = ref(false);
const submitting = ref(false);
const isEditMode = computed(() => Boolean(props.rowData?.id));
const dialogTitle = computed(() => (isEditMode.value ? '编辑项目' : '新增项目'));
const submitting = ref(false);
const loading = ref(false);
const model = ref<Model>(createDefaultModel());
// 产品选项,包含 ID、名称、方向
interface ProductOption {
id: string;
name: string;
@@ -62,41 +64,39 @@ interface ProductOption {
const productOptions = ref<ProductOption[]>([]);
const managerLabelMap = computed(() => new Map(props.managerUserOptions.map(item => [String(item.id), item.nickname])));
const managerDisplayName = computed(() => {
const managerUserId = model.value.managerUserId;
const managerUserId = editModel.value.managerUserId;
if (!managerUserId) {
return '';
}
return managerLabelMap.value.get(String(managerUserId)) || String(managerUserId);
});
// 当前选中产品的方向
const selectedProductDirection = computed(() => {
if (!model.value.productId) {
return '';
}
const product = productOptions.value.find(p => p.id === model.value.productId);
return product?.directionCode || '';
});
// 判断是否关联了产品(创建/编辑模式都适用)
const hasAssociatedProduct = computed(() => Boolean(model.value.productId));
// 方向字段是否只读:关联了产品时只读,未关联时可编辑
const hasAssociatedProduct = computed(() => Boolean(editModel.value.productId));
const directionReadonly = computed(() => hasAssociatedProduct.value);
// 当前生效的方向:关联产品则用产品方向,否则用用户选择的方向
const selectedProductDirection = computed(() => {
if (!editModel.value.productId) {
return '';
}
return productOptions.value.find(p => p.id === editModel.value.productId)?.directionCode || '';
});
const effectiveDirectionCode = computed({
get: () => {
if (hasAssociatedProduct.value) {
// 编辑/创建模式下,关联产品时使用产品方向
return selectedProductDirection.value || model.value.directionCode;
return selectedProductDirection.value || editModel.value.directionCode;
}
return model.value.directionCode;
return editModel.value.directionCode;
},
set: (val: string) => {
if (!hasAssociatedProduct.value) {
model.value.directionCode = val;
editModel.value.directionCode = val;
}
}
});
@@ -132,13 +132,13 @@ function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
return {
text,
value: () => {
let startDate = parsePlannedDate(model.value.plannedStartDate);
let startDate = parsePlannedDate(editModel.value.plannedStartDate);
if (!startDate) {
startDate = new Date();
model.value.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
editModel.value.plannedStartDate = dayjs(startDate).format('YYYY-MM-DD');
window.$message?.info('未选择计划开始日期,已按今日为基准计算');
nextTick(() => formRef.value?.clearValidate('plannedStartDate'));
nextTick(() => editFormRef.value?.clearValidate('plannedStartDate'));
}
const endDate = new Date(startDate.getTime());
@@ -157,12 +157,7 @@ const plannedEndDateShortcuts = [
buildEndDateShortcut('一年', date => date.setFullYear(date.getFullYear() + 1))
];
// 产品下拉的标签,显示产品名称 + 方向
const productOptionLabel = (item: ProductOption) => {
return `${item.name}`;
};
const rules = {
const editRules = {
projectName: [createRequiredRule('请输入项目名称')],
directionCode: [createRequiredRule('请选择项目方向')],
projectType: [createRequiredRule('请选择项目类型')],
@@ -171,7 +166,7 @@ const rules = {
createRequiredRule('请选择计划结束日期'),
{
validator: (_rule, value: string | null, callback) => {
if (!isPlannedDateRangeValid(model.value.plannedStartDate, value)) {
if (!isPlannedDateRangeValid(editModel.value.plannedStartDate, value)) {
callback(new Error('计划结束日期不能早于计划开始日期'));
return;
}
@@ -183,7 +178,7 @@ const rules = {
]
} satisfies Record<string, App.Global.FormRule[]>;
function createDefaultModel(): Model {
function createEditModel(): EditModel {
return {
projectCode: '',
projectName: '',
@@ -220,97 +215,157 @@ async function loadProductOptions() {
}));
}
// 监听产品选择变化,联动方向(创建模式)
watch(
() => model.value.productId,
(newProductId, oldProductId) => {
if (isEditMode.value) {
return; // 编辑模式下不处理,产品字段只读
}
async function handleEditSubmit() {
await editValidate();
if (newProductId && newProductId !== oldProductId) {
// 选择了产品,自动填充方向
const product = productOptions.value.find(p => p.id === newProductId);
if (product) {
model.value.directionCode = product.directionCode;
}
}
// 取消选择产品时directionCode 保留,用户可重新选择
}
);
async function handleSubmit() {
await validate();
// 提交时,如果关联了产品,使用产品方向
const finalDirectionCode = hasAssociatedProduct.value
? selectedProductDirection.value || model.value.directionCode
: model.value.directionCode;
const payload: Api.Project.SaveProjectParams = {
projectCode: getNullableText(model.value.projectCode),
projectName: model.value.projectName.trim(),
directionCode: finalDirectionCode,
projectType: model.value.projectType,
productId: model.value.productId,
managerUserId: model.value.managerUserId || '',
plannedStartDate: model.value.plannedStartDate,
plannedEndDate: model.value.plannedEndDate,
actualStartDate: isEditMode.value ? props.rowData?.actualStartDate || null : undefined,
actualEndDate: isEditMode.value ? props.rowData?.actualEndDate || null : undefined,
projectDesc: getNullableText(model.value.projectDesc)
};
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const updateParams: Api.Project.UpdateProjectParams = {
id: props.rowData.id,
...payload
};
const result = await fetchUpdateProject(updateParams);
submitting.value = false;
if (result.error) {
return;
}
window.$message?.success('项目编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
if (!props.rowData?.id) {
return;
}
const result = await fetchCreateProject(payload);
const managerUserId = editModel.value.managerUserId;
if (!managerUserId) {
return;
}
const finalDirectionCode = hasAssociatedProduct.value
? selectedProductDirection.value || editModel.value.directionCode
: editModel.value.directionCode;
submitting.value = true;
const { error } = await fetchUpdateProject({
id: props.rowData.id,
projectCode: getNullableText(editModel.value.projectCode),
projectName: editModel.value.projectName.trim(),
directionCode: finalDirectionCode,
projectType: editModel.value.projectType,
productId: editModel.value.productId,
managerUserId,
plannedStartDate: editModel.value.plannedStartDate,
plannedEndDate: editModel.value.plannedEndDate,
actualStartDate: props.rowData.actualStartDate || null,
actualEndDate: props.rowData.actualEndDate || null,
projectDesc: getNullableText(editModel.value.projectDesc)
});
submitting.value = false;
if (result.error) {
if (error) {
return;
}
window.$message?.success('项目编辑成功');
closeDialog();
emit('submitted', props.rowData.id);
}
// === 新增模式(两步向导) ===
const baseFormRef = ref<InstanceType<typeof ProjectCreateBaseForm> | null>(null);
const teamStepRef = ref<InstanceType<typeof ProjectCreateTeamStep> | null>(null);
const currentStep = ref<1 | 2>(1);
const createBaseModel = ref<ProjectCreateBaseFormModel>(createBaseInfo());
const draftMembers = ref<Api.Project.CreateProjectMemberParams[]>([]);
function createBaseInfo(): ProjectCreateBaseFormModel {
return {
projectCode: '',
projectName: '',
directionCode: '',
projectType: '',
productId: null,
managerUserId: null,
plannedStartDate: null,
plannedEndDate: null,
projectDesc: ''
};
}
async function goNext() {
const valid = await baseFormRef.value?.validate();
if (!valid) {
return;
}
currentStep.value = 2;
}
function goPrev() {
currentStep.value = 1;
}
async function handleCreateSubmit() {
const baseValid = await baseFormRef.value?.validate();
if (!baseValid) {
currentStep.value = 1;
return;
}
const teamValid = await teamStepRef.value?.validate();
if (!teamValid) {
return;
}
submitting.value = true;
const payload: Api.Project.CreateProjectWithTeamParams = {
project: {
projectCode: getNullableText(createBaseModel.value.projectCode),
projectName: createBaseModel.value.projectName.trim(),
directionCode: createBaseModel.value.directionCode,
projectType: createBaseModel.value.projectType,
productId: createBaseModel.value.productId,
managerUserId: createBaseModel.value.managerUserId as string,
plannedStartDate: createBaseModel.value.plannedStartDate,
plannedEndDate: createBaseModel.value.plannedEndDate,
projectDesc: getNullableText(createBaseModel.value.projectDesc)
},
members: draftMembers.value
};
const { error, data } = await fetchCreateProjectWithTeam(payload);
submitting.value = false;
if (error) {
return;
}
window.$message?.success('项目新增成功');
closeDialog();
emit('submitted', result.data);
emit('submitted', data);
}
// === 公共:弹框可见性变化时重置 / 加载数据 ===
watch(visible, async value => {
if (!value) {
return;
}
await loadProductOptions();
submitting.value = false;
currentStep.value = 1;
if (!isEditMode.value || !props.rowData?.id) {
model.value = createDefaultModel();
editModel.value = createEditModel();
createBaseModel.value = createBaseInfo();
draftMembers.value = [];
await nextTick();
formRef.value?.clearValidate();
editFormRef.value?.clearValidate();
return;
}
model.value = {
editLoading.value = true;
// 编辑模式继续在主弹框拉产品选项(用于回显所属产品名称)
await loadProductOptions();
editLoading.value = false;
editModel.value = {
projectCode: props.rowData.projectCode || '',
projectName: props.rowData.projectName || '',
directionCode: props.rowData.directionCode || '',
@@ -323,38 +378,37 @@ watch(visible, async value => {
};
await nextTick();
formRef.value?.clearValidate();
editFormRef.value?.clearValidate();
});
</script>
<template>
<!-- 编辑模式单步表单与改造前一致 -->
<BusinessFormDialog
v-if="isEditMode"
v-model="visible"
:title="dialogTitle"
preset="md"
:loading="loading"
:loading="editLoading"
:confirm-loading="submitting"
@confirm="handleSubmit"
@confirm="handleEditSubmit"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top">
<ElForm ref="editFormRef" :model="editModel" :rules="editRules" label-position="top">
<BusinessFormSection title="项目信息">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="项目编码" prop="projectCode">
<ElFormItem label="项目编码" prop="projectCode">
<ElInput
:model-value="model.projectCode"
:model-value="editModel.projectCode"
readonly
class="project-operate-dialog__readonly-input"
placeholder="未获取到项目编码"
/>
</ElFormItem>
<ElFormItem v-else label="项目编码" prop="projectCode">
<ElInput v-model="model.projectCode" clearable placeholder="不填则由后端自动生成" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="项目名称" prop="projectName">
<ElInput v-model="model.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
<ElInput v-model="editModel.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
@@ -378,7 +432,7 @@ watch(visible, async value => {
<ElCol :span="12">
<ElFormItem label="项目类型" prop="projectType">
<DictSelect
v-model="model.projectType"
v-model="editModel.projectType"
:dict-code="RDMS_PROJECT_TYPE_DICT_CODE"
filterable
placeholder="请选择项目类型"
@@ -386,12 +440,12 @@ watch(visible, async value => {
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode" label="所属产品">
<ElFormItem label="所属产品">
<ElInput
:model-value="
productOptions.find(p => p.id === model.productId)?.name ||
productOptions.find(p => p.id === editModel.productId)?.name ||
props.rowData?.productName ||
model.productId ||
editModel.productId ||
'未关联产品'
"
readonly
@@ -399,24 +453,9 @@ watch(visible, async value => {
placeholder="未关联产品"
/>
</ElFormItem>
<ElFormItem v-else label="所属产品" prop="productId">
<ElSelect
v-model="model.productId"
clearable
filterable
placeholder="选择所属产品可选选择后将锁定项目方向"
>
<ElOption
v-for="item in productOptions"
:key="item.id"
:label="productOptionLabel(item)"
:value="item.id"
/>
</ElSelect>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem v-if="isEditMode">
<ElFormItem>
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
@@ -438,18 +477,11 @@ watch(visible, async value => {
placeholder="未获取到项目经理"
/>
</ElFormItem>
<ElFormItem v-else label="项目经理" prop="managerUserId">
<BusinessUserSelect
v-model="model.managerUserId"
:options="managerUserOptions"
placeholder="请选择项目经理"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
v-model="model.plannedStartDate"
v-model="editModel.plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择开始日期"
@@ -460,7 +492,7 @@ watch(visible, async value => {
<ElCol :span="12">
<ElFormItem label="计划结束日期" prop="plannedEndDate">
<ElDatePicker
v-model="model.plannedEndDate"
v-model="editModel.plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择结束日期"
@@ -472,7 +504,7 @@ watch(visible, async value => {
<ElCol :span="24">
<ElFormItem label="项目说明" prop="projectDesc">
<ElInput
v-model="model.projectDesc"
v-model="editModel.projectDesc"
type="textarea"
:rows="4"
maxlength="500"
@@ -485,6 +517,63 @@ watch(visible, async value => {
</BusinessFormSection>
</ElForm>
</BusinessFormDialog>
<!-- 新增模式:两步向导(复合内容特例,自定义 ElDialog 880px -->
<ElDialog
v-else
v-model="visible"
class="project-create-dialog"
:title="dialogTitle"
:close-on-click-modal="false"
destroy-on-close
align-center
width="760px"
>
<div class="project-create-dialog__stepbar">
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 1, 'is-done': currentStep > 1 }">
<span class="project-create-dialog__step-index">1</span>
<span class="project-create-dialog__step-text">
<strong>基础资料</strong>
<small>定义项目身份和负责人</small>
</span>
</div>
<div class="project-create-dialog__step" :class="{ 'is-active': currentStep === 2 }">
<span class="project-create-dialog__step-index">2</span>
<span class="project-create-dialog__step-text">
<strong>初始化团队</strong>
<small>配置对象域成员角色</small>
</span>
</div>
</div>
<div class="project-create-dialog__body">
<div v-show="currentStep === 1" class="project-create-dialog__panel">
<ProjectCreateBaseForm ref="baseFormRef" v-model="createBaseModel" :manager-user-options="managerUserOptions" />
</div>
<div v-show="currentStep === 2" class="project-create-dialog__panel">
<ProjectCreateTeamStep
ref="teamStepRef"
:base-info="createBaseModel"
:user-options="managerUserOptions"
@update:members="draftMembers = $event"
/>
</div>
</div>
<template #footer>
<div class="project-create-dialog__footer">
<span class="project-create-dialog__footer-meta">第 {{ currentStep }} 步,共 2 步</span>
<ElSpace :size="10">
<ElButton @click="closeDialog">取消</ElButton>
<ElButton v-if="currentStep === 2" @click="goPrev">上一步</ElButton>
<ElButton v-if="currentStep === 1" type="primary" @click="goNext">下一步</ElButton>
<ElButton v-if="currentStep === 2" type="primary" :loading="submitting" @click="handleCreateSubmit">
确定
</ElButton>
</ElSpace>
</div>
</template>
</ElDialog>
</template>
<style scoped>
@@ -508,4 +597,86 @@ watch(visible, async value => {
:deep(.project-operate-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
.project-create-dialog :deep(.el-dialog__body) {
display: flex;
flex-direction: column;
padding: 0;
}
.project-create-dialog__stepbar {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 14px 24px;
border-bottom: 1px solid rgb(229 233 242 / 96%);
background: #fbfcfe;
}
.project-create-dialog__step {
display: flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.project-create-dialog__step-index {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 1px solid rgb(215 222 235 / 96%);
border-radius: 999px;
background: #fff;
color: rgb(119 129 150 / 96%);
font-size: 13px;
font-weight: 650;
}
.project-create-dialog__step.is-active .project-create-dialog__step-index,
.project-create-dialog__step.is-done .project-create-dialog__step-index {
border-color: var(--el-color-primary);
background: var(--el-color-primary);
color: #fff;
}
.project-create-dialog__step-text {
min-width: 0;
}
.project-create-dialog__step-text strong {
display: block;
font-size: 14px;
font-weight: 650;
}
.project-create-dialog__step-text small {
display: block;
margin-top: 2px;
color: rgb(119 129 150 / 96%);
font-size: 12px;
}
.project-create-dialog__body {
min-height: 0;
max-height: min(560px, calc(100vh - 240px));
overflow: auto;
}
.project-create-dialog__panel {
padding: 24px;
}
.project-create-dialog__footer {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
}
.project-create-dialog__footer-meta {
color: rgb(119 129 150 / 96%);
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,190 @@
import type { ComputedRef } from 'vue';
import { ElMessageBox } from 'element-plus';
import { fetchGetProjectTask, fetchGetProjectTaskPage, fetchGetProjectTaskWorklogPage } from '@/service/api/project';
type ProjectTask = Api.Project.ProjectTask;
type TaskAction = Api.Project.LifecycleAction<Api.Project.ProjectTaskActionCode>;
export interface CascadeTriggerPayload {
task: ProjectTask;
submittedProgress: number;
}
export interface UseTaskCompletionCascadeOptions {
projectId: ComputedRef<string>;
executionId: ComputedRef<string>;
/** 由调用方提供:打开 StatusActionDialog 的钩子composable 不持有 dialog 实例 */
openStatusActionDialog: (task: ProjectTask, action: TaskAction, fromCascade: boolean) => void;
/** 从 task 的 availableActions 里找出"完成"动作;找不到返回 null */
resolveCompleteAction: (task: ProjectTask) => TaskAction | null;
}
interface AssigneeProgress {
userId: string;
nickname: string;
/** null = 该协办人从未填过 worklog */
latestProgress: number | null;
}
const TASK_COMPLETED_STATUS_CODE: Api.Project.ProjectTaskStatusCode = 'completed';
const NO_WARNING_CONFIRM_MESSAGE = '任务进度已达 100%,是否完成当前任务?';
const PARENT_CONFIRM_MESSAGE = '所有子任务已完成,是否完成父任务?';
function buildAssigneeWarningMessage(under100: AssigneeProgress[]): string {
const names = under100.map(item => item.nickname).join('、');
return `存在协办人进度未达 100%${names}),是否仍要完成当前任务?`;
}
export function useTaskCompletionCascade(options: UseTaskCompletionCascadeOptions) {
/**
* worklog 提交后命中 owner + progress=100 + 非删除时由 workspace 调用。
* 内部:拉协办人进度 → 构造 confirm 文案 → 用户确认 → 打开完成弹层
*/
async function triggerAfterWorklog(payload: CascadeTriggerPayload): Promise<void> {
const { task } = payload;
const completeAction = options.resolveCompleteAction(task);
if (!completeAction) {
window.$message?.warning('当前任务暂无可用完成动作');
return;
}
const under100 = await loadAssigneesUnder100(task);
const message = under100.length > 0 ? buildAssigneeWarningMessage(under100) : NO_WARNING_CONFIRM_MESSAGE;
const messageBoxType = under100.length > 0 ? 'warning' : 'info';
try {
await ElMessageBox.confirm(message, '完成确认', {
confirmButtonText: '完成任务',
cancelButtonText: '仅保留工时',
type: messageBoxType
});
} catch {
return;
}
options.openStatusActionDialog(task, completeAction, true);
}
/**
* 由 workspace 在 handleStatusSubmit 完成成功 + pendingCascade=true 时调用。
* 内部:判断当前任务有无父任务 → 拉同级子任务 → 全 completed → 拉父任务详情 → owner 一致 → 弹完成提示。
*/
async function onTaskCompleted(completedTask: ProjectTask): Promise<void> {
if (!completedTask.parentTaskId) {
return;
}
const siblings = await loadSiblings(completedTask.parentTaskId);
if (siblings === null) {
window.$message?.warning('父任务级联检查失败');
return;
}
const allCompleted = siblings.every(item => item.statusCode === TASK_COMPLETED_STATUS_CODE);
if (!allCompleted) {
return;
}
const parent = await fetchTaskDetail(completedTask.parentTaskId);
if (!parent) {
window.$message?.warning('父任务级联检查失败');
return;
}
if (parent.ownerId !== completedTask.ownerId) {
// owner 不一致:本期不做主动通知,留待通知功能上线后由父任务负责人决策
return;
}
const completeAction = options.resolveCompleteAction(parent);
if (!completeAction) {
window.$message?.warning('父任务暂无可用完成动作');
return;
}
try {
await ElMessageBox.confirm(PARENT_CONFIRM_MESSAGE, '完成父任务确认', {
confirmButtonText: '完成父任务',
cancelButtonText: '暂不完成',
type: 'info'
});
} catch {
return;
}
options.openStatusActionDialog(parent, completeAction, false);
}
/** 拉协办人进度并筛出 < 100% 的项;接口失败时返回空数组(降级到普通文案) */
async function loadAssigneesUnder100(task: ProjectTask): Promise<AssigneeProgress[]> {
const assignees = task.assignees ?? [];
if (assignees.length === 0) {
return [];
}
try {
const result = await fetchGetProjectTaskWorklogPage(options.projectId.value, options.executionId.value, task.id, {
pageNo: 1,
pageSize: -1
});
if (result.error || !result.data) {
return [];
}
// worklog 接口默认按 endDate DESC 排序,相同 userId 第一次出现的即为该用户最新一条
const latestByUser = new Map<string, number>();
for (const log of result.data.list) {
if (!latestByUser.has(log.userId)) {
latestByUser.set(log.userId, log.progressRate);
}
}
const under100: AssigneeProgress[] = [];
for (const assignee of assignees) {
const progress = latestByUser.get(assignee.userId);
if (progress === undefined) {
under100.push({ userId: assignee.userId, nickname: assignee.nickname, latestProgress: null });
} else if (progress < 100) {
under100.push({ userId: assignee.userId, nickname: assignee.nickname, latestProgress: progress });
}
}
return under100;
} catch {
return [];
}
}
/** 拉父级下所有子任务;接口失败返回 null与"空列表"区分,由调用方降级处理) */
async function loadSiblings(parentTaskId: string): Promise<ProjectTask[] | null> {
try {
const result = await fetchGetProjectTaskPage(options.projectId.value, options.executionId.value, {
pageNo: 1,
pageSize: -1,
parentTaskId
});
if (result.error || !result.data) {
return null;
}
return result.data.list;
} catch {
return null;
}
}
/** 拉父任务详情;失败返回 null */
async function fetchTaskDetail(taskId: string): Promise<ProjectTask | null> {
try {
const result = await fetchGetProjectTask(options.projectId.value, options.executionId.value, taskId);
if (result.error || !result.data) {
return null;
}
return result.data;
} catch {
return null;
}
}
return {
triggerAfterWorklog,
onTaskCompleted
};
}

View File

@@ -0,0 +1,132 @@
import { computed } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useObjectContextStore } from '@/store/modules/object-context';
/**
* 任务 / 执行按钮可见度集中判定
*
* 关键领域规则:
* - 任务负责人本人不能编辑 / 删除自己负责的任务(增删改归上级 / 项目负责人裁决)
* - 本人能做的:状态推进(含 cancel "退出"任务)、加协办人、在自己任务下新增子任务
* - 执行负责人对子任务无编辑 / 删除权(子任务归父任务 owner 管)
* - 父任务负责人能改 / 删子任务,但不能给子任务加协办人 / 建孙任务 / 推进状态
*
* 权限码来源:`project:*` / `project:execution:*` / `project:task:*` 是**对象域权限码**
* 挂在项目对象上下文里(项目负责人 / 项目协作者等角色),从 objectContextStore.buttonCodes 取,
* **不在** authStore.userInfo.buttons那是全局站点级权限
*/
export function useTaskPermissions() {
const authStore = useAuthStore();
const objectContextStore = useObjectContextStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
function hasPermission(code: string): boolean {
return buttonCodeSet.value.has(code);
}
/**
* 判定对象是否处于可编辑/可操作状态。
*
* 按按钮可见度矩阵spec §4.2 / §4.3 注释 `allowEdit === true 即 pending / active 状态`
* 可编辑状态严格 = `pending` OR `active`**不含 paused / completed / cancelled**。
*
* 不用 `record.allowEdit === true`:列表 VO 后端不一定下发该字段,
* 经 `normalizeProjectExecution` 的 `Boolean(undefined) === false` 会让列表所有行误判为不可编辑。
* 直接读 `statusCode` 字段(列表 / 详情 VO 都必下发,状态机核心字段)。
*/
function isMutable(record: { statusCode: string }): boolean {
return record.statusCode === 'pending' || record.statusCode === 'active';
}
// —— 执行侧 ——
function canEditExecution(execution: Api.Project.ProjectExecution): boolean {
return (
isMutable(execution) && (hasPermission('project:execution:update') || currentUserId.value === execution.ownerId)
);
}
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean {
return execution.statusCode === 'pending' && hasPermission('project:execution:delete');
}
function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean {
return isMutable(execution) && hasPermission('project:execution:owner');
}
function canManageExecutionAssignee(execution: Api.Project.ProjectExecution): boolean {
return (
isMutable(execution) && (hasPermission('project:execution:assignee') || currentUserId.value === execution.ownerId)
);
}
/**
* 协办人入口按钮(列表面板)是否显示。
*
* 仅"项目负责人 / 项目创建人 / 执行负责人"可见,普通登录用户隐藏(去"查看"对话框里看团队)。
* 不含状态前置——任何状态下身份匹配都给入口dialog 内的"加 / 移 / 换 owner"写按钮自己再判 isMutable。
*/
function canSeeExecutionAssigneeEntry(execution: Api.Project.ProjectExecution): boolean {
return (
hasPermission('project:execution:assignee') ||
hasPermission('project:execution:owner') ||
currentUserId.value === execution.ownerId
);
}
// —— 任务侧(按一级 / 子任务分流) ——
function isTopLevelTask(task: Api.Project.ProjectTask): boolean {
return task.parentTaskId === null || task.parentTaskId === undefined;
}
function canEditTask(task: Api.Project.ProjectTask): boolean {
if (!isMutable(task)) return false;
if (hasPermission('project:task:update')) return true;
return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId
: currentUserId.value === task.parentTaskOwnerId;
}
function canDeleteTask(task: Api.Project.ProjectTask): boolean {
if (task.statusCode !== 'pending') return false;
if (hasPermission('project:task:delete')) return true;
return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId
: currentUserId.value === task.parentTaskOwnerId;
}
function canCreateTopLevelTask(execution: Api.Project.ProjectExecution): boolean {
return isMutable(execution) && (hasPermission('project:task:create') || currentUserId.value === execution.ownerId);
}
function canCreateSubTask(task: Api.Project.ProjectTask): boolean {
return isMutable(task) && (hasPermission('project:task:create') || currentUserId.value === task.ownerId);
}
function canManageTaskAssignee(task: Api.Project.ProjectTask): boolean {
return isMutable(task) && (hasPermission('project:task:assignee') || currentUserId.value === task.ownerId);
}
function canReportTaskWorklog(): boolean {
return hasPermission('project:task:worklog');
}
return {
// execution
canEditExecution,
canDeleteExecution,
canChangeExecutionOwner,
canManageExecutionAssignee,
canSeeExecutionAssigneeEntry,
// task
canEditTask,
canDeleteTask,
canCreateTopLevelTask,
canCreateSubTask,
canManageTaskAssignee,
canReportTaskWorklog
};
}

View File

@@ -4,21 +4,24 @@ import {
fetchChangeProjectExecutionOwner,
fetchChangeProjectExecutionStatus,
fetchCreateProjectExecution,
fetchCreateProjectExecutionMember,
fetchCreateProjectExecutionAssignee,
fetchDeleteProjectExecution,
fetchGetProjectExecution,
fetchGetProjectExecutionMembers,
fetchGetProjectExecutionAssignees,
fetchGetProjectExecutionPage,
fetchGetProjectExecutionStatusBoard,
fetchGetProjectMembers,
fetchInactiveProjectExecutionMember,
fetchInactiveProjectExecutionAssignee,
fetchUpdateProjectExecution
} from '@/service/api';
import { useObjectContextStore } from '@/store/modules/object-context';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { useCurrentProject } from '../../shared/use-current-project';
import { useTaskPermissions } from './composables/use-task-permissions';
import ExecutionListPanel from './modules/execution-list-panel.vue';
import ExecutionMemberDialog from './modules/execution-member-dialog.vue';
import ExecutionAssigneeDialog from './modules/execution-assignee-dialog.vue';
import ExecutionOperateDialog from './modules/execution-operate-dialog.vue';
import ObjectDeleteDialog from './modules/object-delete-dialog.vue';
import StatusActionDialog from './modules/status-action-dialog.vue';
import TaskWorkspace from './modules/task-workspace.vue';
@@ -69,14 +72,14 @@ const projectMembers = ref<Api.Project.ProjectMember[]>([]);
const projectMemberOptions = ref<Api.SystemManage.UserSimple[]>([]);
const operateVisible = ref(false);
const operateMode = ref<'create' | 'edit' | 'view'>('create');
const memberVisible = ref(false);
const assigneeDialogVisible = ref(false);
const statusVisible = ref(false);
const editingExecution = ref<Api.Project.ProjectExecution | null>(null);
const editingExecutionMembers = ref<Api.Project.ExecutionMember[]>([]);
const editingExecutionAssignees = ref<Api.Project.ExecutionAssignee[]>([]);
const statusExecution = ref<Api.Project.ProjectExecution | null>(null);
const statusAction = ref<ExecutionAction | null>(null);
const executionMembers = ref<Api.Project.ExecutionMember[]>([]);
const memberLoading = ref(false);
const executionAssignees = ref<Api.Project.ExecutionAssignee[]>([]);
const assigneeLoading = ref(false);
const executionStatusBoard = ref<Api.Project.StatusBoard | null>(null);
const projectId = computed(() => currentObjectId.value || '');
@@ -86,14 +89,13 @@ const statusActionTitle = computed(() =>
);
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
const canUpdateExecution = computed(() => buttonCodeSet.value.has('project:execution:update'));
const canChangeExecutionOwner = computed(() => buttonCodeSet.value.has('project:execution:owner'));
const canManageExecutionMember = computed(() => buttonCodeSet.value.has('project:execution:member'));
const canChangeExecutionStatus = computed(() => buttonCodeSet.value.has('project:execution:status'));
const canDeleteExecution = computed(() => buttonCodeSet.value.has('project:execution:delete'));
const canCreateTask = computed(() => buttonCodeSet.value.has('project:task:create'));
const canUpdateTask = computed(() => buttonCodeSet.value.has('project:task:update'));
const canChangeTaskStatus = computed(() => buttonCodeSet.value.has('project:task:status'));
const deleteDialogVisible = ref(false);
const { canCreateTopLevelTask } = useTaskPermissions();
// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置
// 选中的执行 = null 时按钮隐藏(无对象上下文可判)
const canCreateTask = computed(() =>
selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : false
);
function createRequestParams(): Api.Project.ProjectExecutionSearchParams {
return {
@@ -218,7 +220,7 @@ async function getExecutionDetail(row: Api.Project.ProjectExecution) {
function openCreateExecution() {
editingExecution.value = null;
editingExecutionMembers.value = [];
editingExecutionAssignees.value = [];
operateMode.value = 'create';
operateVisible.value = true;
}
@@ -232,8 +234,8 @@ async function openEditExecution(row: Api.Project.ProjectExecution) {
}
editingExecution.value = detail;
const memberResult = await fetchGetProjectExecutionMembers(projectId.value, detail.id);
editingExecutionMembers.value = memberResult.error || !memberResult.data ? [] : memberResult.data;
const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
operateMode.value = 'edit';
operateVisible.value = true;
}
@@ -242,16 +244,16 @@ async function openViewExecution(row: Api.Project.ProjectExecution) {
const detail = await getExecutionDetail(row);
editingExecution.value = detail;
const memberResult = await fetchGetProjectExecutionMembers(projectId.value, detail.id);
editingExecutionMembers.value = memberResult.error || !memberResult.data ? [] : memberResult.data;
const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
operateMode.value = 'view';
operateVisible.value = true;
}
async function openMemberDialog(row: Api.Project.ProjectExecution) {
selectedExecution.value = await getExecutionDetail(row);
memberVisible.value = true;
await loadExecutionMembers(selectedExecution.value.id);
assigneeDialogVisible.value = true;
await loadExecutionAssignees(selectedExecution.value.id);
}
async function openExecutionStatus(row: Api.Project.ProjectExecution, action: ExecutionAction | null) {
@@ -268,19 +270,19 @@ async function openExecutionStatus(row: Api.Project.ProjectExecution, action: Ex
statusVisible.value = true;
}
async function loadExecutionMembers(executionId: string) {
async function loadExecutionAssignees(executionId: string) {
if (!projectId.value) {
executionMembers.value = [];
executionAssignees.value = [];
return;
}
memberLoading.value = true;
assigneeLoading.value = true;
try {
const { error, data: members } = await fetchGetProjectExecutionMembers(projectId.value, executionId);
executionMembers.value = error || !members ? [] : members;
const { error, data: assignees } = await fetchGetProjectExecutionAssignees(projectId.value, executionId);
executionAssignees.value = error || !assignees ? [] : assignees;
} finally {
memberLoading.value = false;
assigneeLoading.value = false;
}
}
@@ -290,7 +292,14 @@ async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionPa
}
const result = editingExecution.value
? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, payload)
? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, {
executionName: payload.executionName,
executionType: payload.executionType,
projectRequirementId: payload.projectRequirementId,
plannedStartDate: payload.plannedStartDate,
plannedEndDate: payload.plannedEndDate,
executionDesc: payload.executionDesc
})
: await fetchCreateProjectExecution(projectId.value, payload);
if (!result.error) {
@@ -331,38 +340,71 @@ async function handleExecutionStatusSubmit(reason: string | null) {
}
}
async function handleAddExecutionMember(payload: Api.Project.CreateExecutionMemberParams) {
async function handleAddExecutionAssignee(payload: Api.Project.CreateExecutionAssigneeParams) {
if (!projectId.value || !selectedExecution.value) {
return;
}
const result = await fetchCreateProjectExecutionMember(projectId.value, selectedExecution.value.id, payload);
const result = await fetchCreateProjectExecutionAssignee(projectId.value, selectedExecution.value.id, payload);
if (!result.error) {
await loadExecutionMembers(selectedExecution.value.id);
await loadExecutionAssignees(selectedExecution.value.id);
}
}
async function handleInactiveExecutionMember(
member: Api.Project.ExecutionMember,
payload: Api.Project.InactiveExecutionMemberParams
async function handleInactiveExecutionAssignee(
assignee: Api.Project.ExecutionAssignee,
payload: Api.Project.InactiveExecutionAssigneeParams
) {
if (!projectId.value || !selectedExecution.value) {
return;
}
const result = await fetchInactiveProjectExecutionMember(projectId.value, selectedExecution.value.id, {
memberId: member.id,
const result = await fetchInactiveProjectExecutionAssignee(projectId.value, selectedExecution.value.id, {
assigneeId: assignee.id,
data: payload
});
if (!result.error) {
await loadExecutionMembers(selectedExecution.value.id);
await loadExecutionAssignees(selectedExecution.value.id);
}
}
function handleDeleteExecution(_row: Api.Project.ProjectExecution) {
window.$message?.warning('删除接口暂未开放,请等待后端发布');
function handleDeleteExecution(row: Api.Project.ProjectExecution) {
selectedExecution.value = row;
deleteDialogVisible.value = true;
}
async function confirmDeleteExecution(payload: { name: string; confirmText: string; reason: string }) {
if (!projectId.value || !selectedExecution.value) return;
const { error } = await fetchDeleteProjectExecution(projectId.value, selectedExecution.value.id, {
executionName: payload.name,
confirmText: payload.confirmText,
reason: payload.reason
});
if (error) return;
window.$message?.success('删除成功');
deleteDialogVisible.value = false;
selectedExecution.value = null;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function handleExecutionChangedByTask() {
if (!selectedExecution.value) {
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
return;
}
const latestExecution = await getExecutionDetail(selectedExecution.value);
selectedExecution.value = latestExecution;
if (selectedStatus.value && latestExecution.statusCode !== selectedStatus.value) {
selectedStatus.value = latestExecution.statusCode;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
return;
}
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
}
watch(
@@ -390,11 +432,6 @@ watch(
:selected-status="selectedStatus"
:owner-options="projectMemberOptions"
:can-create="canCreateExecution"
:can-update="canUpdateExecution"
:can-change-owner="canChangeExecutionOwner"
:can-manage-member="canManageExecutionMember"
:can-change-status="canChangeExecutionStatus"
:can-delete="canDeleteExecution"
@select="selectedExecution = $event"
@status-change="handleStatusChange"
@search="handleSearch"
@@ -411,10 +448,8 @@ watch(
class="project-execution-page__main"
:project-id="projectId"
:execution="selectedExecution"
:owner-options="projectMemberOptions"
:can-create="canCreateTask"
:can-update="canUpdateTask"
:can-change-status="canChangeTaskStatus"
@execution-changed="handleExecutionChangedByTask"
/>
<ExecutionOperateDialog
@@ -422,20 +457,18 @@ watch(
:mode="operateMode"
:row-data="editingExecution"
:user-options="projectMemberOptions"
:current-members="editingExecutionMembers"
:current-assignees="editingExecutionAssignees"
@submit="handleExecutionSubmit"
/>
<ExecutionMemberDialog
v-model:visible="memberVisible"
<ExecutionAssigneeDialog
v-model:visible="assigneeDialogVisible"
:execution="selectedExecution"
:members="executionMembers"
:assignees="executionAssignees"
:user-options="projectMemberOptions"
:loading="memberLoading"
:can-manage-member="canManageExecutionMember"
:can-change-owner="canChangeExecutionOwner"
@add="handleAddExecutionMember"
@inactive="handleInactiveExecutionMember"
:loading="assigneeLoading"
@add="handleAddExecutionAssignee"
@inactive="handleInactiveExecutionAssignee"
@change-owner="handleChangeOwner"
/>
@@ -445,6 +478,13 @@ watch(
:action="statusAction"
@submit="handleExecutionStatusSubmit"
/>
<ObjectDeleteDialog
v-model:visible="deleteDialogVisible"
object-type="execution"
:object-name="selectedExecution?.executionName ?? ''"
:on-confirm="confirmDeleteExecution"
/>
</div>
<ElEmpty v-else description="未获取到当前项目上下文,请返回项目列表重新选择项目" />

View File

@@ -4,40 +4,54 @@ import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime, isActiveExecutionMember } from '../shared';
import { formatDateTime, isActiveExecutionAssignee, withVirtualOwnerAssignee } from '../shared';
defineOptions({ name: 'ProjectExecutionMemberCurrentPanel' });
defineOptions({ name: 'ProjectExecutionAssigneeCurrentPanel' });
interface Props {
execution: Api.Project.ProjectExecution | null;
members: Api.Project.ExecutionMember[];
assignees: Api.Project.ExecutionAssignee[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManageMember: boolean;
canManageAssignee: boolean;
canChangeOwner: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateExecutionMemberParams): void;
(e: 'inactive', member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams): void;
(e: 'add', payload: Api.Project.CreateExecutionAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.ExecutionAssignee, payload: Api.Project.InactiveExecutionAssigneeParams): void;
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const newMemberId = ref('');
const newAssigneeId = ref('');
const PAGE_SIZE = 5;
const currentPage = ref(1);
const pagedMembers = computed(() => {
const displayAssignees = computed<Api.Project.ExecutionAssignee[]>(() => {
const ownerId = props.execution?.ownerId;
if (!ownerId) {
return props.assignees;
}
const ownerNickname =
props.execution?.ownerNickname?.trim() ||
props.userOptions.find(item => item.id === ownerId)?.nickname?.trim() ||
ownerId;
return withVirtualOwnerAssignee(props.assignees, ownerId, ownerNickname, props.execution?.id ?? '');
});
const pagedAssignees = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return props.members.slice(start, start + PAGE_SIZE);
return displayAssignees.value.slice(start, start + PAGE_SIZE);
});
watch(
() => props.members.length,
() => displayAssignees.value.length,
total => {
const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (currentPage.value > maxPage) {
@@ -48,7 +62,7 @@ watch(
const { createRequiredRule } = useFormRules();
const inactiveTarget = ref<Api.Project.ExecutionMember | null>(null);
const inactiveTarget = ref<Api.Project.ExecutionAssignee | null>(null);
const inactiveModel = reactive({ reason: '' });
const { formRef: inactiveFormRef, validate: validateInactive } = useForm();
const inactiveRules = {
@@ -63,7 +77,7 @@ const inactiveVisible = computed({
}
});
const ownerTarget = ref<Api.Project.ExecutionMember | null>(null);
const ownerTarget = ref<Api.Project.ExecutionAssignee | null>(null);
const ownerModel = reactive({ reason: '' });
const ownerVisible = computed({
get: () => Boolean(ownerTarget.value),
@@ -76,17 +90,23 @@ const ownerVisible = computed({
const currentOwnerId = computed(() => props.execution?.ownerId || '');
function isOwner(member: Api.Project.ExecutionMember) {
function isOwner(member: Api.Project.ExecutionAssignee) {
return Boolean(currentOwnerId.value) && member.userId === currentOwnerId.value;
}
function isActiveMember(member: Api.Project.ExecutionMember) {
return isActiveExecutionMember(member);
function isActiveAssignee(assignee: Api.Project.ExecutionAssignee) {
return isActiveExecutionAssignee(assignee);
}
const activeMemberUserIds = computed(() =>
props.members.filter(item => isActiveExecutionMember(item)).map(item => item.userId)
);
const activeAssigneeUserIds = computed(() => {
const list = props.assignees.filter(item => isActiveExecutionAssignee(item)).map(item => item.userId);
const ownerId = props.execution?.ownerId;
//
if (ownerId && !list.includes(ownerId)) {
list.push(ownerId);
}
return list;
});
const userNicknameMap = computed(() => {
const map = new Map<string, string>();
@@ -98,16 +118,16 @@ const userNicknameMap = computed(() => {
return map;
});
function getMemberIndex(index: number) {
function getAssigneeIndex(index: number) {
return (currentPage.value - 1) * PAGE_SIZE + index + 1;
}
function getMemberDisplayName(member: Api.Project.ExecutionMember | null) {
if (!member) return '';
return member.userNickname?.trim() || userNicknameMap.value.get(member.userId) || member.userId || '--';
function getAssigneeDisplayName(assignee: Api.Project.ExecutionAssignee | null) {
if (!assignee) return '';
return assignee.userNickname?.trim() || userNicknameMap.value.get(assignee.userId) || assignee.userId || '--';
}
function buildMemberActions(row: Api.Project.ExecutionMember): BusinessTableAction[] {
function buildAssigneeActions(row: Api.Project.ExecutionAssignee): BusinessTableAction[] {
const actions: BusinessTableAction[] = [];
if (props.canChangeOwner) {
@@ -119,7 +139,7 @@ function buildMemberActions(row: Api.Project.ExecutionMember): BusinessTableActi
});
}
if (props.canManageMember) {
if (props.canManageAssignee) {
actions.push({
key: 'inactive',
label: '失效',
@@ -132,17 +152,17 @@ function buildMemberActions(row: Api.Project.ExecutionMember): BusinessTableActi
}
function handleAdd() {
if (!newMemberId.value) {
window.$message?.warning('请选择成员用户');
if (!newAssigneeId.value) {
window.$message?.warning('请选择协办人');
return;
}
emit('add', { userId: newMemberId.value });
newMemberId.value = '';
emit('add', { userId: newAssigneeId.value });
newAssigneeId.value = '';
}
async function openInactive(member: Api.Project.ExecutionMember) {
inactiveTarget.value = member;
async function openInactive(assignee: Api.Project.ExecutionAssignee) {
inactiveTarget.value = assignee;
inactiveModel.reason = '';
await nextTick();
inactiveFormRef.value?.clearValidate();
@@ -159,8 +179,8 @@ async function confirmInactive() {
inactiveTarget.value = null;
}
function openOwner(member: Api.Project.ExecutionMember) {
ownerTarget.value = member;
function openOwner(assignee: Api.Project.ExecutionAssignee) {
ownerTarget.value = assignee;
ownerModel.reason = '';
}
@@ -177,7 +197,7 @@ function confirmOwner() {
}
function reset() {
newMemberId.value = '';
newAssigneeId.value = '';
inactiveTarget.value = null;
inactiveModel.reason = '';
ownerTarget.value = null;
@@ -189,30 +209,30 @@ defineExpose({ reset });
</script>
<template>
<div v-loading="loading" class="member-current-panel">
<div v-if="canManageMember" class="member-current-panel__toolbar">
<div v-loading="loading" class="assignee-current-panel">
<div v-if="canManageAssignee" class="assignee-current-panel__toolbar">
<BusinessUserSelect
v-model="newMemberId"
v-model="newAssigneeId"
:options="userOptions"
:exclude-user-ids="activeMemberUserIds"
:exclude-user-ids="activeAssigneeUserIds"
no-data-text="所有项目成员已加入执行"
placeholder="选择用户加入执行"
class="member-current-panel__user-select"
class="assignee-current-panel__user-select"
/>
<ElButton type="primary" @click="handleAdd">新增成员</ElButton>
<ElButton type="primary" @click="handleAdd">新增协办人</ElButton>
</div>
<ElTable :data="pagedMembers" :height="247" border row-key="id" size="default">
<ElTableColumn type="index" :index="getMemberIndex" label="序号" width="64" align="center" />
<ElTableColumn label="成员" width="200" show-overflow-tooltip>
<ElTable :data="pagedAssignees" :height="247" border row-key="id" size="default">
<ElTableColumn type="index" :index="getAssigneeIndex" label="序号" width="64" align="center" />
<ElTableColumn label="协办人" width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="member-current-panel__name">{{ getMemberDisplayName(row) }}</span>
<span class="assignee-current-panel__name">{{ getAssigneeDisplayName(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="角色" width="140" align="center">
<template #default="{ row }">
<ElTag v-if="isOwner(row)" type="warning" effect="light">负责人</ElTag>
<ElTag v-else type="info" effect="plain">成员</ElTag>
<ElTag v-else type="info" effect="plain">协办人</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="加入时间" min-width="200" align="center">
@@ -222,22 +242,22 @@ defineExpose({ reset });
</ElTableColumn>
<ElTableColumn label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<BusinessTableActionCell v-if="!isOwner(row) && isActiveMember(row)" :actions="buildMemberActions(row)" />
<span v-else class="member-current-panel__actions-empty">--</span>
<BusinessTableActionCell v-if="!isOwner(row) && isActiveAssignee(row)" :actions="buildAssigneeActions(row)" />
<span v-else class="assignee-current-panel__actions-empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="当前执行暂无成员" :image-size="80" />
<ElEmpty description="当前执行暂无协办人" :image-size="80" />
</template>
</ElTable>
<div class="member-current-panel__pagination">
<div class="assignee-current-panel__pagination">
<ElPagination
v-if="members.length > PAGE_SIZE"
v-if="displayAssignees.length > PAGE_SIZE"
v-model:current-page="currentPage"
:page-size="PAGE_SIZE"
:total="members.length"
:total="displayAssignees.length"
layout="total, prev, pager, next"
background
small
@@ -246,7 +266,7 @@ defineExpose({ reset });
<BusinessFormDialog
v-model="inactiveVisible"
:title="`失效成员${getMemberDisplayName(inactiveTarget)}`"
:title="`失效协办人${getAssigneeDisplayName(inactiveTarget)}`"
preset="sm"
append-to-body
@confirm="confirmInactive"
@@ -273,7 +293,7 @@ defineExpose({ reset });
<BusinessFormDialog
v-model="ownerVisible"
:title="`设为负责人:${getMemberDisplayName(ownerTarget)}`"
:title="`设为负责人:${getAssigneeDisplayName(ownerTarget)}`"
preset="sm"
append-to-body
@confirm="confirmOwner"
@@ -295,24 +315,24 @@ defineExpose({ reset });
</template>
<style scoped lang="scss">
.member-current-panel {
.assignee-current-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-current-panel__toolbar {
.assignee-current-panel__toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.member-current-panel__user-select {
.assignee-current-panel__user-select {
width: 280px;
}
.member-current-panel__name {
.assignee-current-panel__name {
display: inline-block;
max-width: 100%;
overflow: hidden;
@@ -322,11 +342,11 @@ defineExpose({ reset });
white-space: nowrap;
}
.member-current-panel__actions-empty {
.assignee-current-panel__actions-empty {
color: var(--el-text-color-placeholder);
}
.member-current-panel__pagination {
.assignee-current-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;

View File

@@ -1,23 +1,22 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import MemberCurrentPanel from './member-current-panel.vue';
import MemberLogPanel from './member-log-panel.vue';
import { useTaskPermissions } from '../composables/use-task-permissions';
import AssigneeCurrentPanel from './execution-assignee-current-panel.vue';
import AssigneeLogPanel from './execution-assignee-log-panel.vue';
defineOptions({ name: 'ProjectExecutionMemberDialog' });
defineOptions({ name: 'ProjectExecutionAssigneeDialog' });
interface Props {
execution: Api.Project.ProjectExecution | null;
members: Api.Project.ExecutionMember[];
assignees: Api.Project.ExecutionAssignee[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManageMember: boolean;
canChangeOwner: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateExecutionMemberParams): void;
(e: 'inactive', member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams): void;
(e: 'add', payload: Api.Project.CreateExecutionAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.ExecutionAssignee, payload: Api.Project.InactiveExecutionAssigneeParams): void;
(e: 'change-owner', payload: Api.Project.ChangeExecutionOwnerParams): void;
}
@@ -28,25 +27,32 @@ const visible = defineModel<boolean>('visible', {
default: false
});
const { canManageExecutionAssignee, canChangeExecutionOwner } = useTaskPermissions();
const resolvedCanManageAssignee = computed(() =>
props.execution ? canManageExecutionAssignee(props.execution) : false
);
const resolvedCanChangeOwner = computed(() => (props.execution ? canChangeExecutionOwner(props.execution) : false));
type TabName = 'current' | 'log';
const activeTab = ref<TabName>('current');
const currentPanelRef = ref<InstanceType<typeof MemberCurrentPanel> | null>(null);
const currentPanelRef = ref<InstanceType<typeof AssigneeCurrentPanel> | null>(null);
const dialogTitle = computed(() =>
props.execution ? `执行成员管理:${props.execution.executionName}` : '执行成员管理'
props.execution ? `执行协办人管理:${props.execution.executionName}` : '执行协办人管理'
);
const projectId = computed(() => props.execution?.projectId || '');
const executionId = computed(() => props.execution?.id || '');
function handleAdd(payload: Api.Project.CreateExecutionMemberParams) {
function handleAdd(payload: Api.Project.CreateExecutionAssigneeParams) {
emit('add', payload);
}
function handleInactive(member: Api.Project.ExecutionMember, payload: Api.Project.InactiveExecutionMemberParams) {
emit('inactive', member, payload);
function handleInactive(assignee: Api.Project.ExecutionAssignee, payload: Api.Project.InactiveExecutionAssigneeParams) {
emit('inactive', assignee, payload);
}
function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) {
@@ -68,23 +74,23 @@ watch(
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" :show-footer="false" :scrollbar="false">
<ElTabs v-model="activeTab" class="execution-member-dialog__tabs">
<ElTabPane label="当前成员" name="current">
<MemberCurrentPanel
<ElTabs v-model="activeTab" class="execution-assignee-dialog__tabs">
<ElTabPane label="当前协办人" name="current">
<AssigneeCurrentPanel
ref="currentPanelRef"
:execution="execution"
:members="members"
:assignees="assignees"
:user-options="userOptions"
:loading="loading"
:can-manage-member="canManageMember"
:can-change-owner="canChangeOwner"
:can-manage-assignee="resolvedCanManageAssignee"
:can-change-owner="resolvedCanChangeOwner"
@add="handleAdd"
@inactive="handleInactive"
@change-owner="handleChangeOwner"
/>
</ElTabPane>
<ElTabPane label="变更历史" name="log" lazy>
<MemberLogPanel
<AssigneeLogPanel
v-if="projectId && executionId"
:project-id="projectId"
:execution-id="executionId"
@@ -97,7 +103,7 @@ watch(
</template>
<style scoped lang="scss">
.execution-member-dialog__tabs {
.execution-assignee-dialog__tabs {
--el-tabs-header-height: 40px;
}
</style>

View File

@@ -1,11 +1,11 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { fetchGetProjectExecutionMemberLogPage } from '@/service/api';
import { fetchGetProjectExecutionAssigneeLogPage } from '@/service/api';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime, getExecutionMemberActionName, getExecutionMemberActionTagType } from '../shared';
import { formatDateTime, getExecutionAssigneeActionName, getExecutionAssigneeActionTagType } from '../shared';
defineOptions({ name: 'ProjectExecutionMemberLogPanel' });
defineOptions({ name: 'ProjectExecutionAssigneeLogPanel' });
interface Props {
projectId: string;
@@ -16,13 +16,13 @@ interface Props {
const props = defineProps<Props>();
type ActionType = Api.Project.ExecutionMemberActionType;
type ActionType = Api.Project.ExecutionAssigneeActionType;
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: ActionType }> = [
{ label: getExecutionMemberActionName('join'), value: 'join' },
{ label: getExecutionMemberActionName('inactive'), value: 'inactive' },
{ label: getExecutionMemberActionName('owner_transfer_in'), value: 'owner_transfer_in' },
{ label: getExecutionMemberActionName('owner_transfer_out'), value: 'owner_transfer_out' }
{ label: getExecutionAssigneeActionName('join'), value: 'join' },
{ label: getExecutionAssigneeActionName('inactive'), value: 'inactive' },
{ label: getExecutionAssigneeActionName('owner_transfer_in'), value: 'owner_transfer_in' },
{ label: getExecutionAssigneeActionName('owner_transfer_out'), value: 'owner_transfer_out' }
];
const searchParams = reactive<{
@@ -39,9 +39,9 @@ const searchParams = reactive<{
const canLoad = computed(() => Boolean(props.projectId && props.executionId));
type LogPageResponse = Awaited<ReturnType<typeof fetchGetProjectExecutionMemberLogPage>>;
type LogPageResponse = Awaited<ReturnType<typeof fetchGetProjectExecutionAssigneeLogPage>>;
function buildRequestParams(): Api.Project.ExecutionMemberLogSearchParams {
function buildRequestParams(): Api.Project.ExecutionAssigneeLogSearchParams {
return {
pageNo: searchParams.pageNo,
pageSize: searchParams.pageSize,
@@ -70,7 +70,7 @@ function transformLogPage(response: LogPageResponse, pageNo: number, pageSize: n
const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
LogPageResponse,
Api.Project.ExecutionMemberLog
Api.Project.ExecutionAssigneeLog
>({
paginationProps: {
currentPage: searchParams.pageNo,
@@ -84,7 +84,7 @@ const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
} as unknown as LogPageResponse);
}
return fetchGetProjectExecutionMemberLogPage(props.projectId, props.executionId, buildRequestParams());
return fetchGetProjectExecutionAssigneeLogPage(props.projectId, props.executionId, buildRequestParams());
},
transform: response => transformLogPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 5),
onPaginationParamsChange: params => {
@@ -129,11 +129,11 @@ async function handleReset() {
await getDataByPage(1);
}
function getMemberDisplay(row: Api.Project.ExecutionMemberLog) {
function getAssigneeDisplay(row: Api.Project.ExecutionAssigneeLog) {
return row.userNicknameSnapshot?.trim() || row.userId || '--';
}
function getOperatorDisplay(row: Api.Project.ExecutionMemberLog) {
function getOperatorDisplay(row: Api.Project.ExecutionAssigneeLog) {
return row.operatorNicknameSnapshot?.trim() || row.operatorUserId || '--';
}
@@ -145,8 +145,8 @@ defineExpose({ refresh });
</script>
<template>
<div class="member-log-panel">
<div class="member-log-panel__toolbar">
<div class="assignee-log-panel">
<div class="assignee-log-panel__toolbar">
<ElSelect
v-model="searchParams.actionTypes"
multiple
@@ -154,18 +154,18 @@ defineExpose({ refresh });
collapse-tags-tooltip
clearable
placeholder="全部事件"
class="member-log-panel__action-select"
class="assignee-log-panel__action-select"
>
<ElOption v-for="item in ACTION_TYPE_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
<BusinessUserSelect
v-model="searchParams.userId"
:options="userOptions"
placeholder="全部成员"
placeholder="全部协办人"
clearable
class="member-log-panel__user-select"
class="assignee-log-panel__user-select"
/>
<div class="member-log-panel__actions">
<div class="assignee-log-panel__actions">
<ElButton @click="handleReset">重置</ElButton>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
</div>
@@ -179,14 +179,14 @@ defineExpose({ refresh });
</ElTableColumn>
<ElTableColumn label="事件类型" width="130" align="center">
<template #default="{ row }">
<ElTag :type="getExecutionMemberActionTagType(row.actionType)" effect="light">
{{ getExecutionMemberActionName(row.actionType) }}
<ElTag :type="getExecutionAssigneeActionTagType(row.actionType)" effect="light">
{{ getExecutionAssigneeActionName(row.actionType) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="成员" min-width="120" show-overflow-tooltip>
<ElTableColumn label="协办人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">
{{ getMemberDisplay(row) }}
{{ getAssigneeDisplay(row) }}
</template>
</ElTableColumn>
<ElTableColumn label="操作人" min-width="120" show-overflow-tooltip>
@@ -197,7 +197,7 @@ defineExpose({ refresh });
<ElTableColumn label="原因" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.reason">{{ row.reason }}</span>
<span v-else class="member-log-panel__empty">--</span>
<span v-else class="assignee-log-panel__empty">--</span>
</template>
</ElTableColumn>
@@ -206,7 +206,7 @@ defineExpose({ refresh });
</template>
</ElTable>
<div class="member-log-panel__pagination">
<div class="assignee-log-panel__pagination">
<ElPagination
v-if="mobilePagination.total"
background
@@ -221,38 +221,38 @@ defineExpose({ refresh });
</template>
<style scoped lang="scss">
.member-log-panel {
.assignee-log-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.member-log-panel__toolbar {
.assignee-log-panel__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.member-log-panel__action-select {
.assignee-log-panel__action-select {
width: 200px;
}
.member-log-panel__user-select {
.assignee-log-panel__user-select {
width: 200px;
}
.member-log-panel__actions {
.assignee-log-panel__actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.member-log-panel__empty {
.assignee-log-panel__empty {
color: var(--el-text-color-placeholder);
}
.member-log-panel__pagination {
.assignee-log-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;

View File

@@ -2,10 +2,11 @@
import { computed, markRaw } from 'vue';
import type { PaginationProps } from 'element-plus';
import { Calendar, Flag, Plus, TrendCharts, User } from '@element-plus/icons-vue';
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType, getProgressText } from '../shared';
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType } from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions';
import IconMdiAccountMultipleOutline from '~icons/mdi/account-multiple-outline';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiPlay from '~icons/mdi/play';
@@ -25,13 +26,10 @@ interface Props {
selectedStatus: ExecutionStatusFilter;
ownerOptions: Api.SystemManage.UserSimple[];
canCreate: boolean;
canUpdate: boolean;
canChangeOwner: boolean;
canManageMember: boolean;
canChangeStatus: boolean;
canDelete: boolean;
}
const { canEditExecution, canDeleteExecution, canSeeExecutionAssigneeEntry } = useTaskPermissions();
const props = defineProps<Props>();
interface Emits {
@@ -118,7 +116,7 @@ interface ExecutionAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'danger';
type: 'primary' | 'success' | 'danger' | 'warning';
onClick: () => void;
}
@@ -126,25 +124,35 @@ const STATUS_ACTION_ICON_MAP: Record<string, object> = {
start: markRaw(IconMdiPlay),
pause: markRaw(IconMdiPause),
resume: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline)
cancel: markRaw(IconMdiCloseCircleOutline),
complete: markRaw(IconMdiCheckCircleOutline)
};
// 状态推进按钮 type 映射cancel 破坏性=红pause 中断=橙complete 完结=绿resume/start 主动作=蓝
const STATUS_ACTION_TYPE_MAP: Record<string, ExecutionAction['type']> = {
cancel: 'danger',
pause: 'warning',
complete: 'success',
resume: 'primary',
start: 'primary'
};
// 同一状态下多个推进按钮的展示顺序:暂停 → 取消 → 完成 → 恢复 → 开始
const STATUS_ACTION_ORDER: Record<string, number> = {
pause: 1,
cancel: 2,
complete: 3,
resume: 4,
start: 5
};
function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
const actions: ExecutionAction[] = [];
const isCancelled = row.statusCode === 'cancelled';
if (isCancelled) {
actions.push({
key: 'view',
tooltip: '查看',
icon: markRaw(IconMdiEyeOutline),
type: 'primary',
onClick: () => emit('view', row)
});
return actions;
}
// 查看入口已收到执行名称(点击名称触发 view操作区不再放眼睛按钮。
if (props.canUpdate && !isCancelled) {
// 编辑执行pending/active + (权限码 OR 字段身份)
if (canEditExecution(row)) {
actions.push({
key: 'edit',
tooltip: '编辑',
@@ -154,44 +162,29 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
});
}
if ((props.canManageMember || props.canChangeOwner) && !isCancelled) {
// 协办人入口:仅项目负责人 / 项目创建人 / 执行负责人可见,无状态前置
// 普通登录用户通过"查看"对话框看团队信息dialog 内"加 / 移 / 换 owner"再自判 isMutable
if (canSeeExecutionAssigneeEntry(row)) {
actions.push({
key: 'members',
tooltip: '成员管理',
tooltip: '协办人',
icon: markRaw(IconMdiAccountMultipleOutline),
type: 'primary',
onClick: () => emit('members', row)
});
}
if (!props.canChangeStatus) {
return actions;
}
if (!row.availableActions.length) {
if (row.statusCode === 'pending') {
actions.push({
key: 'cancel',
tooltip: '取消',
icon: markRaw(IconMdiCloseCircleOutline),
type: 'danger',
onClick: () =>
emit('status-action', row, {
actionCode: 'cancel',
actionName: '取消',
needReason: true
})
});
}
return actions;
}
row.availableActions.forEach(action => {
// 状态推进按钮完全依赖 availableActionsowner-only 字段硬卡spec §3.4.1
// 前端只控制展示顺序与 type/icon不参与判定哪些动作可见
const sortedActions = [...row.availableActions].sort(
(a, b) => (STATUS_ACTION_ORDER[a.actionCode] ?? 99) - (STATUS_ACTION_ORDER[b.actionCode] ?? 99)
);
sortedActions.forEach(action => {
actions.push({
key: action.actionCode,
tooltip: action.actionName,
icon: markRaw(STATUS_ACTION_ICON_MAP[action.actionCode] ?? IconMdiSync),
type: action.actionCode === 'cancel' ? 'danger' : 'success',
type: STATUS_ACTION_TYPE_MAP[action.actionCode] ?? 'primary',
onClick: () => emit('status-action', row, action)
});
});
@@ -275,7 +268,15 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
>
<div class="execution-item__main">
<div class="execution-item__top">
<strong class="execution-item__name">{{ row.executionName || '未命名执行' }}</strong>
<strong
class="execution-item__name"
role="button"
tabindex="0"
@click.stop="emit('view', row)"
@keydown.enter.stop.prevent="emit('view', row)"
>
{{ row.executionName || '未命名执行' }}
</strong>
<ElTag
class="execution-item__status-tag"
:type="getExecutionStatusTagType(row.statusCode)"
@@ -284,6 +285,19 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
>
{{ getExecutionStatusName(row) }}
</ElTag>
<div class="execution-item__actions" @click.stop>
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="execution-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-14px" />
</ElButton>
</ElTooltip>
<ElTooltip v-if="canDeleteExecution(row)" content="删除">
<ElButton link type="danger" class="execution-action-btn" @click="emit('delete', row)">
<icon-mdi-delete-outline class="text-14px" />
</ElButton>
</ElTooltip>
</div>
</div>
<div class="execution-item__meta">
@@ -305,32 +319,6 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
</span>
</div>
</div>
<div class="execution-item__actions" @click.stop>
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="execution-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-14px" />
</ElButton>
</ElTooltip>
<ElPopconfirm
v-if="canDelete && (row.statusCode === 'pending' || row.statusCode === 'cancelled')"
title="确认删除该执行?删除后不可恢复"
confirm-button-text="删除"
cancel-button-text="取消"
confirm-button-type="danger"
@confirm="emit('delete', row)"
>
<template #reference>
<span class="inline-flex">
<ElTooltip content="删除">
<ElButton link type="danger" class="execution-action-btn">
<icon-mdi-delete-outline class="text-14px" />
</ElButton>
</ElTooltip>
</span>
</template>
</ElPopconfirm>
</div>
</article>
</div>
</ElScrollbar>
@@ -496,9 +484,6 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
}
.execution-item {
display: flex;
align-items: flex-start;
gap: 10px;
min-width: 0;
padding: 10px;
border: 1px solid rgb(226 232 240 / 92%);
@@ -546,6 +531,14 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
line-height: 1.5;
text-overflow: ellipsis;
white-space: nowrap;
cursor: pointer;
transition: color 0.16s ease;
}
.execution-item__name:hover,
.execution-item__name:focus-visible {
color: var(--el-color-primary);
outline: none;
}
.execution-item__meta {
@@ -579,6 +572,7 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
align-items: center;
gap: 6px;
flex: 0 0 auto;
margin-left: auto;
}
.execution-item__actions :deep(.el-button + .el-button) {
@@ -593,12 +587,12 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
}
@media (width <= 1280px) {
.execution-item {
flex-direction: column;
.execution-item__top {
align-items: flex-start;
flex-wrap: wrap;
}
.execution-item__actions {
width: 100%;
justify-content: flex-end;
}
}

View File

@@ -9,7 +9,7 @@ import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue';
import { isActiveExecutionMember } from '../shared';
import { isActiveExecutionAssignee, withVirtualOwnerAssignee } from '../shared';
function isEmptyRichText(html: string | null | undefined) {
if (!html) {
@@ -35,7 +35,7 @@ interface Props {
mode: OperateMode;
rowData: Api.Project.ProjectExecution | null;
userOptions: Api.SystemManage.UserSimple[];
currentMembers?: Api.Project.ExecutionMember[];
currentAssignees?: Api.Project.ExecutionAssignee[];
}
interface Emits {
@@ -45,8 +45,6 @@ interface Emits {
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const activeMembers = computed(() => (props.currentMembers ?? []).filter(member => isActiveExecutionMember(member)));
function resolveUserLabel(userId: string | null | undefined, fallbackNickname?: string | null) {
if (!userId) {
return '';
@@ -55,13 +53,20 @@ function resolveUserLabel(userId: string | null | undefined, fallbackNickname?:
return fallbackNickname || props.userOptions.find(item => item.id === userId)?.nickname || userId;
}
function resolveMemberLabel(member: Api.Project.ExecutionMember) {
return resolveUserLabel(member.userId, member.userNickname);
function resolveAssigneeLabel(assignee: Api.Project.ExecutionAssignee) {
return resolveUserLabel(assignee.userId, assignee.userNickname);
}
const ownerDisplayName = computed(() => resolveUserLabel(props.rowData?.ownerId, props.rowData?.ownerNickname));
const activeMemberIds = computed(() => activeMembers.value.map(member => member.userId));
// view / edit 模式下协办人 select 的展示数据:先过滤掉失效项,再兜底前置一行虚拟负责人
// 让用户视觉上感知"负责人也在团队里";虚拟行不会发到后端(这里 select 是 disabled 仅展示)
const activeAssignees = computed(() => {
const filtered = (props.currentAssignees ?? []).filter(assignee => isActiveExecutionAssignee(assignee));
return withVirtualOwnerAssignee(filtered, props.rowData?.ownerId, ownerDisplayName.value, props.rowData?.id ?? '');
});
const activeAssigneeIds = computed(() => activeAssignees.value.map(assignee => assignee.userId));
const visible = defineModel<boolean>('visible', {
default: false
@@ -69,7 +74,7 @@ const visible = defineModel<boolean>('visible', {
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const autoOwnerMemberId = ref<string | null>(null);
const autoOwnerAssigneeId = ref<string | null>(null);
/** 左栏容器 ref用其高度动态驱动右侧富文本让两栏视觉等高 */
const leftColRef = ref<HTMLElement>();
@@ -108,7 +113,7 @@ const model = reactive<Api.Project.SaveProjectExecutionParams>({
plannedStartDate: null,
plannedEndDate: null,
executionDesc: null,
memberUserIds: []
assigneeUserIds: []
});
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
@@ -156,8 +161,8 @@ const rules = computed(
({
executionName: [createRequiredRule('请输入执行名称')],
executionType: [createRequiredRule('请选择执行类型')],
ownerId: [createRequiredRule('请选择执行负责人')],
memberUserIds: props.mode === 'create' ? [createRequiredRule('请选择执行成员')] : [],
ownerId: props.mode === 'create' ? [createRequiredRule('请选择执行负责人')] : [],
assigneeUserIds: props.mode === 'create' ? [createRequiredRule('请选择执行协办人')] : [],
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [
createRequiredRule('请选择计划结束日期'),
@@ -176,38 +181,38 @@ const rules = computed(
}) satisfies Record<string, App.Global.FormRule[]>
);
function normalizeMemberUserIds(memberUserIds?: string[]) {
return Array.from(new Set(memberUserIds?.filter(Boolean) ?? []));
function normalizeAssigneeUserIds(assigneeUserIds?: string[]) {
return Array.from(new Set(assigneeUserIds?.filter(Boolean) ?? []));
}
function getUserRoleName(item: Api.SystemManage.UserSimple) {
return item.deptName || '';
}
function syncOwnerMember(ownerId: string | null, previousOwnerId: string | null = autoOwnerMemberId.value) {
function syncOwnerAssignee(ownerId: string | null, previousOwnerId: string | null = autoOwnerAssigneeId.value) {
if (props.mode !== 'create') {
return;
}
const currentMemberUserIds = normalizeMemberUserIds(model.memberUserIds);
const memberUserIds = previousOwnerId
? currentMemberUserIds.filter(userId => userId !== previousOwnerId)
: currentMemberUserIds;
const currentAssigneeUserIds = normalizeAssigneeUserIds(model.assigneeUserIds);
const assigneeUserIds = previousOwnerId
? currentAssigneeUserIds.filter(userId => userId !== previousOwnerId)
: currentAssigneeUserIds;
model.memberUserIds = ownerId ? normalizeMemberUserIds([...memberUserIds, ownerId]) : memberUserIds;
autoOwnerMemberId.value = ownerId;
model.assigneeUserIds = ownerId ? normalizeAssigneeUserIds([...assigneeUserIds, ownerId]) : assigneeUserIds;
autoOwnerAssigneeId.value = ownerId;
}
function ensureOwnerInMembers() {
function ensureOwnerInAssignees() {
if (props.mode !== 'create' || !model.ownerId) {
return;
}
model.memberUserIds = normalizeMemberUserIds([...(model.memberUserIds || []), model.ownerId]);
model.assigneeUserIds = normalizeAssigneeUserIds([...(model.assigneeUserIds || []), model.ownerId]);
}
async function handleConfirm() {
ensureOwnerInMembers();
ensureOwnerInAssignees();
await validate();
emit('submit', {
@@ -218,16 +223,16 @@ async function handleConfirm() {
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
executionDesc: isEmptyRichText(model.executionDesc) ? null : (model.executionDesc ?? null),
memberUserIds: props.mode === 'create' ? normalizeMemberUserIds(model.memberUserIds) : undefined
assigneeUserIds: props.mode === 'create' ? normalizeAssigneeUserIds(model.assigneeUserIds) : undefined
});
}
function handleMemberChange(value: string[]) {
function handleAssigneeChange(value: string[]) {
if (props.mode !== 'create') {
return;
}
model.memberUserIds = normalizeMemberUserIds(model.ownerId ? [...value, model.ownerId] : value);
model.assigneeUserIds = normalizeAssigneeUserIds(model.ownerId ? [...value, model.ownerId] : value);
}
watch(
@@ -244,8 +249,8 @@ watch(
model.plannedStartDate = props.rowData?.plannedStartDate || null;
model.plannedEndDate = props.rowData?.plannedEndDate || null;
model.executionDesc = props.rowData?.executionDesc || null;
model.memberUserIds = [];
autoOwnerMemberId.value = null;
model.assigneeUserIds = [];
autoOwnerAssigneeId.value = null;
await nextTick();
formRef.value?.clearValidate();
@@ -255,7 +260,7 @@ watch(
watch(
() => model.ownerId,
(ownerId, previousOwnerId) => {
syncOwnerMember(ownerId || null, previousOwnerId || null);
syncOwnerAssignee(ownerId || null, previousOwnerId || null);
}
);
</script>
@@ -324,17 +329,17 @@ watch(
/>
</ElFormItem>
<ElFormItem v-if="mode === 'create'" label="执行成员" prop="memberUserIds">
<ElFormItem v-if="mode === 'create'" label="执行协办人" prop="assigneeUserIds">
<ElSelect
v-model="model.memberUserIds"
v-model="model.assigneeUserIds"
multiple
filterable
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="请选择执行成员"
@change="handleMemberChange"
placeholder="请选择执行协办人"
@change="handleAssigneeChange"
>
<ElOption
v-for="item in userOptions"
@@ -343,12 +348,12 @@ watch(
:value="item.id"
:disabled="item.id === model.ownerId"
>
<div class="execution-member-option">
<span class="execution-member-option__name">
<div class="execution-assignee-option">
<span class="execution-assignee-option__name">
{{ item.nickname }}
<span v-if="item.id === model.ownerId" class="execution-member-option__owner">负责人</span>
<span v-if="item.id === model.ownerId" class="execution-assignee-option__owner">负责人</span>
</span>
<span v-if="getUserRoleName(item)" class="execution-member-option__role">
<span v-if="getUserRoleName(item)" class="execution-assignee-option__role">
{{ getUserRoleName(item) }}
</span>
</div>
@@ -357,10 +362,10 @@ watch(
</ElFormItem>
<ElFormItem v-else>
<template #label>
<template v-if="isView">执行成员</template>
<template v-if="isView">执行协办人</template>
<span v-else class="business-form-label-with-tip">
<ElTooltip
content="如需调整成员,请关闭此弹层后点击列表「成员」按钮。"
content="如需调整协办人,请关闭此弹层后点击列表「协办人」按钮。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
@@ -368,24 +373,24 @@ watch(
<icon-fe:question />
</span>
</ElTooltip>
<span>执行成员</span>
<span>执行协办人</span>
</span>
</template>
<ElSelect
:model-value="activeMemberIds"
:model-value="activeAssigneeIds"
multiple
disabled
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="暂无在岗成员"
placeholder="暂无在岗协办人"
>
<ElOption
v-for="member in activeMembers"
:key="member.id"
:label="resolveMemberLabel(member)"
:value="member.userId"
v-for="assignee in activeAssignees"
:key="assignee.id"
:label="resolveAssigneeLabel(assignee)"
:value="assignee.userId"
/>
</ElSelect>
</ElFormItem>
@@ -509,7 +514,7 @@ watch(
-webkit-text-fill-color: rgb(51 65 85 / 96%);
}
.execution-member-option {
.execution-assignee-option {
display: flex;
align-items: center;
justify-content: space-between;
@@ -517,7 +522,7 @@ watch(
min-width: 0;
}
.execution-member-option__name {
.execution-assignee-option__name {
display: inline-flex;
align-items: center;
min-width: 0;
@@ -529,14 +534,14 @@ watch(
white-space: nowrap;
}
.execution-member-option__owner {
.execution-assignee-option__owner {
flex: 0 0 auto;
color: var(--el-color-primary);
font-size: 12px;
font-weight: 500;
}
.execution-member-option__role {
.execution-assignee-option__role {
flex: 0 0 auto;
max-width: 48%;
overflow: hidden;

View File

@@ -0,0 +1,97 @@
<script setup lang="ts">
import { computed, reactive, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ObjectDeleteDialog' });
interface Props {
/** 是否显示v-model:visible */
visible: boolean;
/** 对象类型:影响标题、字段标签、警示文案 */
objectType: 'execution' | 'task';
/** 当前对象的名称,用作输入框 placeholder 参照;提交时校验完全一致 */
objectName: string;
/** 删除确认回调async接收三个字段resolve 后由调用方决定刷新/关闭 */
onConfirm: (payload: { name: string; confirmText: string; reason: string }) => Promise<void>;
}
interface Emits {
(e: 'update:visible', value: boolean): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const VALID_CONFIRM_TEXTS = new Set(['删除', 'DELETE']);
const dialogVisible = computed({
get: () => props.visible,
set: val => emit('update:visible', val)
});
const objectTypeLabel = computed(() => (props.objectType === 'execution' ? '执行' : '任务'));
const form = reactive({
name: '',
confirmText: '',
reason: ''
});
const submitting = ref(false);
watch(
() => props.visible,
val => {
if (val) {
form.name = '';
form.confirmText = '';
form.reason = '';
submitting.value = false;
}
}
);
const canSubmit = computed(
() => form.name === props.objectName && VALID_CONFIRM_TEXTS.has(form.confirmText) && form.reason.trim().length > 0
);
async function handleConfirm() {
submitting.value = true;
try {
await props.onConfirm({
name: form.name,
confirmText: form.confirmText,
reason: form.reason
});
} finally {
submitting.value = false;
}
}
</script>
<template>
<BusinessFormDialog
v-model="dialogVisible"
:title="`删除${objectTypeLabel}`"
preset="sm"
:confirm-loading="submitting"
:confirm-disabled="!canSubmit"
@confirm="handleConfirm"
>
<ElAlert type="error" :closable="false" show-icon>
此操作不可撤销删除后{{ objectTypeLabel }}下挂数据将不可见
</ElAlert>
<ElForm label-position="top" class="mt-3">
<ElFormItem :label="`请再次输入${objectTypeLabel}名称(与当前名称完全一致)`" required>
<ElInput v-model="form.name" :placeholder="objectName" />
</ElFormItem>
<ElFormItem label="删除确认口令" required>
<ElInput v-model="form.confirmText" placeholder='请输入"删除"以确认' />
</ElFormItem>
<ElFormItem label="删除原因" required>
<ElInput v-model="form.reason" type="textarea" :rows="3" maxlength="500" show-word-limit />
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,261 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskAssigneeCurrentPanel' });
interface Props {
task: Api.Project.ProjectTask | null;
assignees: Api.Project.TaskAssigneeRef[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManage: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateTaskAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.TaskAssigneeRef, payload: Api.Project.InactiveTaskAssigneeParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const newUserId = ref('');
const PAGE_SIZE = 5;
const currentPage = ref(1);
const pagedAssignees = computed(() => {
const start = (currentPage.value - 1) * PAGE_SIZE;
return props.assignees.slice(start, start + PAGE_SIZE);
});
watch(
() => props.assignees.length,
total => {
const maxPage = Math.max(1, Math.ceil(total / PAGE_SIZE));
if (currentPage.value > maxPage) {
currentPage.value = maxPage;
}
}
);
const { createRequiredRule } = useFormRules();
const inactiveTarget = ref<Api.Project.TaskAssigneeRef | null>(null);
const inactiveModel = reactive({ reason: '' });
const { formRef: inactiveFormRef, validate: validateInactive } = useForm();
const inactiveRules = {
reason: [createRequiredRule('请输入失效原因')]
} satisfies Record<string, App.Global.FormRule[]>;
const inactiveVisible = computed({
get: () => Boolean(inactiveTarget.value),
set: value => {
if (!value) {
inactiveTarget.value = null;
}
}
});
const ownerId = computed(() => props.task?.ownerId || '');
const activeAssigneeUserIds = computed(() => props.assignees.map(item => item.userId));
const excludeUserIds = computed(() => {
const ids = [...activeAssigneeUserIds.value];
if (ownerId.value) {
ids.push(ownerId.value);
}
return ids;
});
const userNicknameMap = computed(() => {
const map = new Map<string, string>();
props.userOptions.forEach(item => {
if (item.id) {
map.set(item.id, item.nickname?.trim() || item.id);
}
});
return map;
});
function getAssigneeIndex(index: number) {
return (currentPage.value - 1) * PAGE_SIZE + index + 1;
}
function getAssigneeDisplayName(assignee: Api.Project.TaskAssigneeRef | null) {
if (!assignee) return '';
return assignee.nickname?.trim() || userNicknameMap.value.get(assignee.userId) || assignee.userId || '--';
}
function buildAssigneeActions(row: Api.Project.TaskAssigneeRef): BusinessTableAction[] {
if (!props.canManage) {
return [];
}
return [
{
key: 'inactive',
label: '失效',
buttonType: 'danger',
onClick: () => openInactive(row)
}
];
}
function handleAdd() {
if (!newUserId.value) {
window.$message?.warning('请选择协办人用户');
return;
}
emit('add', { userId: newUserId.value });
newUserId.value = '';
}
async function openInactive(assignee: Api.Project.TaskAssigneeRef) {
inactiveTarget.value = assignee;
inactiveModel.reason = '';
await nextTick();
inactiveFormRef.value?.clearValidate();
}
async function confirmInactive() {
await validateInactive();
if (!inactiveTarget.value) {
return;
}
emit('inactive', inactiveTarget.value, { reason: inactiveModel.reason.trim() });
inactiveTarget.value = null;
}
function reset() {
newUserId.value = '';
inactiveTarget.value = null;
inactiveModel.reason = '';
currentPage.value = 1;
}
defineExpose({ reset });
</script>
<template>
<div v-loading="loading" class="task-assignee-current-panel">
<div v-if="canManage" class="task-assignee-current-panel__toolbar">
<BusinessUserSelect
v-model="newUserId"
:options="userOptions"
:exclude-user-ids="excludeUserIds"
no-data-text="暂无可选成员"
placeholder="选择协办人"
class="task-assignee-current-panel__user-select"
/>
<ElButton type="primary" @click="handleAdd">新增协办人</ElButton>
</div>
<ElTable :data="pagedAssignees" :height="247" border row-key="id" size="default">
<ElTableColumn type="index" :index="getAssigneeIndex" label="序号" width="64" align="center" />
<ElTableColumn label="协办人" width="200" show-overflow-tooltip>
<template #default="{ row }">
<span class="task-assignee-current-panel__name">{{ getAssigneeDisplayName(row) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="加入时间" min-width="200" align="center">
<template #default="{ row }">{{ formatDateTime(row.joinedAt) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<BusinessTableActionCell v-if="canManage" :actions="buildAssigneeActions(row)" />
<span v-else class="task-assignee-current-panel__actions-empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="当前任务暂无活跃协办人" :image-size="80" />
</template>
</ElTable>
<div class="task-assignee-current-panel__pagination">
<ElPagination
v-if="assignees.length > PAGE_SIZE"
v-model:current-page="currentPage"
:page-size="PAGE_SIZE"
:total="assignees.length"
layout="total, prev, pager, next"
background
small
/>
</div>
<BusinessFormDialog
v-model="inactiveVisible"
:title="`失效协办人:${getAssigneeDisplayName(inactiveTarget)}`"
preset="sm"
append-to-body
@confirm="confirmInactive"
>
<ElForm
ref="inactiveFormRef"
:model="inactiveModel"
:rules="inactiveRules"
label-position="top"
:validate-on-rule-change="false"
>
<ElFormItem label="失效原因" prop="reason">
<ElInput
v-model="inactiveModel.reason"
type="textarea"
:rows="4"
maxlength="500"
show-word-limit
placeholder="请输入失效原因"
/>
</ElFormItem>
</ElForm>
</BusinessFormDialog>
</div>
</template>
<style scoped lang="scss">
.task-assignee-current-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-assignee-current-panel__toolbar {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.task-assignee-current-panel__user-select {
width: 280px;
}
.task-assignee-current-panel__name {
display: inline-block;
max-width: 100%;
overflow: hidden;
font-variant-numeric: tabular-nums;
text-overflow: ellipsis;
vertical-align: middle;
white-space: nowrap;
}
.task-assignee-current-panel__actions-empty {
color: var(--el-text-color-placeholder);
}
.task-assignee-current-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -0,0 +1,95 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import TaskAssigneeCurrentPanel from './task-assignee-current-panel.vue';
import TaskAssigneeLogPanel from './task-assignee-log-panel.vue';
defineOptions({ name: 'ProjectExecutionTaskAssigneeDialog' });
interface Props {
task: Api.Project.ProjectTask | null;
assignees: Api.Project.TaskAssigneeRef[];
userOptions: Api.SystemManage.UserSimple[];
loading: boolean;
canManage: boolean;
}
interface Emits {
(e: 'add', payload: Api.Project.CreateTaskAssigneeParams): void;
(e: 'inactive', assignee: Api.Project.TaskAssigneeRef, payload: Api.Project.InactiveTaskAssigneeParams): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
type TabName = 'current' | 'log';
const activeTab = ref<TabName>('current');
const currentPanelRef = ref<InstanceType<typeof TaskAssigneeCurrentPanel> | null>(null);
const dialogTitle = computed(() => (props.task ? `协办人管理:${props.task.taskTitle}` : '协办人管理'));
const projectId = computed(() => props.task?.projectId || '');
const executionId = computed(() => props.task?.executionId || '');
const taskId = computed(() => props.task?.id || '');
function handleAdd(payload: Api.Project.CreateTaskAssigneeParams) {
emit('add', payload);
}
function handleInactive(assignee: Api.Project.TaskAssigneeRef, payload: Api.Project.InactiveTaskAssigneeParams) {
emit('inactive', assignee, payload);
}
watch(
() => visible.value,
value => {
if (value) {
activeTab.value = 'current';
return;
}
currentPanelRef.value?.reset();
}
);
</script>
<template>
<BusinessFormDialog v-model="visible" :title="dialogTitle" preset="lg" :show-footer="false" :scrollbar="false">
<ElTabs v-model="activeTab" class="task-assignee-dialog__tabs">
<ElTabPane label="当前协办人" name="current">
<TaskAssigneeCurrentPanel
ref="currentPanelRef"
:task="task"
:assignees="assignees"
:user-options="userOptions"
:loading="loading"
:can-manage="canManage"
@add="handleAdd"
@inactive="handleInactive"
/>
</ElTabPane>
<ElTabPane label="变更历史" name="log" lazy>
<TaskAssigneeLogPanel
v-if="projectId && executionId && taskId"
:project-id="projectId"
:execution-id="executionId"
:task-id="taskId"
:user-options="userOptions"
:active="activeTab === 'log'"
/>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped lang="scss">
.task-assignee-dialog__tabs {
--el-tabs-header-height: 40px;
}
</style>

View File

@@ -0,0 +1,245 @@
<script setup lang="ts">
import { computed, reactive, watch } from 'vue';
import { fetchGetProjectTaskAssigneeLogPage } from '@/service/api/project';
import { useUIPaginatedTable } from '@/hooks/common/table';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import { formatDateTime, getTaskAssigneeActionName, getTaskAssigneeActionTagType } from '../shared';
defineOptions({ name: 'ProjectExecutionTaskAssigneeLogPanel' });
interface Props {
projectId: string;
executionId: string;
taskId: string;
userOptions: Api.SystemManage.UserSimple[];
active: boolean;
}
const props = defineProps<Props>();
type ActionType = Api.Project.TaskAssigneeActionType;
const ACTION_TYPE_OPTIONS: Array<{ label: string; value: ActionType }> = [
{ label: getTaskAssigneeActionName('join'), value: 'join' },
{ label: getTaskAssigneeActionName('inactive'), value: 'inactive' }
];
const searchParams = reactive<{
pageNo: number;
pageSize: number;
actionTypes?: ActionType[];
userId?: string;
}>({
pageNo: 1,
pageSize: 5,
actionTypes: undefined,
userId: undefined
});
const canLoad = computed(() => Boolean(props.projectId && props.executionId && props.taskId));
type LogPageResponse = Awaited<ReturnType<typeof fetchGetProjectTaskAssigneeLogPage>>;
function buildRequestParams(): Api.Project.TaskAssigneeLogSearchParams {
return {
pageNo: searchParams.pageNo,
pageSize: searchParams.pageSize,
actionTypes: searchParams.actionTypes?.length ? searchParams.actionTypes : undefined,
userId: searchParams.userId || undefined
};
}
function transformLogPage(response: LogPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
data: response.data.list,
pageNum: pageNo,
pageSize,
total: response.data.total
};
}
return {
data: [],
pageNum: pageNo,
pageSize,
total: 0
};
}
const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
LogPageResponse,
Api.Project.TaskAssigneeLog
>({
paginationProps: {
currentPage: searchParams.pageNo,
pageSize: searchParams.pageSize
},
api: () => {
if (!canLoad.value) {
return Promise.resolve({
data: { total: 0, list: [] },
error: null
} as unknown as LogPageResponse);
}
return fetchGetProjectTaskAssigneeLogPage(props.projectId, props.executionId, props.taskId, buildRequestParams());
},
transform: response => transformLogPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 5),
onPaginationParamsChange: params => {
searchParams.pageNo = params.currentPage ?? 1;
searchParams.pageSize = params.pageSize ?? 5;
},
immediate: false,
columns: () => [{ prop: 'actionTime', label: '时间' }]
});
watch(
() => props.active,
active => {
if (active && canLoad.value) {
getDataByPage(1);
}
},
{ immediate: true }
);
watch(
() => props.taskId,
() => {
resetSearchParams();
}
);
function resetSearchParams() {
searchParams.pageNo = 1;
searchParams.actionTypes = undefined;
searchParams.userId = undefined;
}
async function handleSearch() {
await getDataByPage(1);
}
async function handleReset() {
resetSearchParams();
await getDataByPage(1);
}
function getAssigneeDisplay(row: Api.Project.TaskAssigneeLog) {
return row.userNicknameSnapshot?.trim() || row.userId || '--';
}
function getOperatorDisplay(row: Api.Project.TaskAssigneeLog) {
return row.operatorNicknameSnapshot?.trim() || row.operatorUserId || '--';
}
</script>
<template>
<div class="task-assignee-log-panel">
<div class="task-assignee-log-panel__toolbar">
<ElSelect
v-model="searchParams.actionTypes"
multiple
collapse-tags
collapse-tags-tooltip
clearable
placeholder="全部事件"
class="task-assignee-log-panel__action-select"
>
<ElOption v-for="item in ACTION_TYPE_OPTIONS" :key="item.value" :label="item.label" :value="item.value" />
</ElSelect>
<BusinessUserSelect
v-model="searchParams.userId"
:options="userOptions"
placeholder="全部协办人"
clearable
class="task-assignee-log-panel__user-select"
/>
<div class="task-assignee-log-panel__actions">
<ElButton @click="handleReset">重置</ElButton>
<ElButton type="primary" @click="handleSearch">查询</ElButton>
</div>
</div>
<ElTable v-loading="loading" :data="data" :height="247" border size="default">
<ElTableColumn label="时间" width="170" align="center">
<template #default="{ row }">{{ formatDateTime(row.actionTime) }}</template>
</ElTableColumn>
<ElTableColumn label="事件类型" width="130" align="center">
<template #default="{ row }">
<ElTag :type="getTaskAssigneeActionTagType(row.actionType)" effect="light">
{{ getTaskAssigneeActionName(row.actionType) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="协办人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ getAssigneeDisplay(row) }}</template>
</ElTableColumn>
<ElTableColumn label="操作人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ getOperatorDisplay(row) }}</template>
</ElTableColumn>
<ElTableColumn label="原因" min-width="200" show-overflow-tooltip>
<template #default="{ row }">
<span v-if="row.reason">{{ row.reason }}</span>
<span v-else class="task-assignee-log-panel__empty">--</span>
</template>
</ElTableColumn>
<template #empty>
<ElEmpty description="暂无变更记录" :image-size="80" />
</template>
</ElTable>
<div class="task-assignee-log-panel__pagination">
<ElPagination
v-if="mobilePagination.total"
background
layout="total, prev, pager, next"
small
v-bind="mobilePagination"
@current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']"
/>
</div>
</div>
</template>
<style scoped lang="scss">
.task-assignee-log-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-assignee-log-panel__toolbar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
}
.task-assignee-log-panel__action-select {
width: 200px;
}
.task-assignee-log-panel__user-select {
width: 200px;
}
.task-assignee-log-panel__actions {
display: flex;
gap: 12px;
margin-left: auto;
}
.task-assignee-log-panel__empty {
color: var(--el-text-color-placeholder);
}
.task-assignee-log-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
</style>

View File

@@ -2,6 +2,7 @@
import { computed } from 'vue';
import { Edit, Flag, User } from '@element-plus/icons-vue';
import { formatDate, getProgressText, getTaskStatusName } from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions';
defineOptions({ name: 'ProjectExecutionTaskBoardView' });
@@ -9,10 +10,10 @@ interface Props {
data: Api.Project.ProjectTask[];
loading: boolean;
statusBoard: Api.Project.StatusBoard | null;
canUpdate: boolean;
canChangeStatus: boolean;
}
const { canEditTask } = useTaskPermissions();
interface Emits {
(e: 'detail', row: Api.Project.ProjectTask): void;
(e: 'edit', row: Api.Project.ProjectTask): void;
@@ -51,7 +52,8 @@ const groupedTasks = computed(() => {
});
function getFirstAction(row: Api.Project.ProjectTask) {
return row.availableActions[0] || null;
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染按钮
return row.availableActions.find(item => item.actionCode !== 'auto_start') || null;
}
</script>
@@ -96,9 +98,11 @@ function getFirstAction(row: Api.Project.ProjectTask) {
</div>
<div class="task-board-card-item__actions" @click.stop>
<ElButton v-if="canUpdate" size="small" plain :icon="Edit" @click="emit('edit', task)">编辑</ElButton>
<ElButton v-if="canEditTask(task)" size="small" plain :icon="Edit" @click="emit('edit', task)">
编辑
</ElButton>
<ElButton
v-if="canChangeStatus && getFirstAction(task)"
v-if="getFirstAction(task)"
size="small"
type="primary"
plain
@@ -107,7 +111,7 @@ function getFirstAction(row: Api.Project.ProjectTask) {
{{ getFirstAction(task)!.actionName }}
</ElButton>
<ElButton
v-else-if="canChangeStatus"
v-else-if="task.availableActions.length === 0 && task.statusCode !== 'cancelled'"
size="small"
type="primary"
plain

View File

@@ -1,55 +1,79 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import { formatDate, formatDateTime, getProgressText, getTaskStatusName } from '../shared';
import type { WorklogChangedPayload } from '../shared';
import TaskInfoReadonly from './task-info-readonly.vue';
import TaskWorklogContent from './task-worklog-content.vue';
defineOptions({ name: 'ProjectExecutionTaskDetailDialog' });
interface Props {
rowData: Api.Project.ProjectTask | null;
task: Api.Project.ProjectTask | null;
userOptions?: Api.SystemManage.UserSimple[];
taskOptions?: Api.Project.ProjectTask[];
/** 弹层打开时默认激活的 tab'info' = 任务信息,'worklog' = 工作日志 */
defaultTab?: 'info' | 'worklog';
}
const props = defineProps<Props>();
interface Emits {
(e: 'worklog-changed', payload: WorklogChangedPayload): void;
}
const props = withDefaults(defineProps<Props>(), {
userOptions: () => [],
taskOptions: () => [],
defaultTab: 'info'
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const detailItems = computed(() => {
const row = props.rowData;
type TabName = 'info' | 'worklog';
const activeTab = ref<TabName>('info');
return [
{ label: '任务名称', value: row?.taskTitle || '--' },
{ label: '状态', value: row ? getTaskStatusName(row) : '--' },
{ label: '负责人', value: row?.ownerNickname || row?.ownerId || '--' },
{ label: '进度', value: getProgressText(row?.progressRate) },
{ label: '计划开始日期', value: formatDate(row?.plannedStartDate) },
{ label: '计划结束日期', value: formatDate(row?.plannedEndDate) },
{ label: '实际开始日期', value: formatDate(row?.actualStartDate) },
{ label: '实际结束日期', value: formatDate(row?.actualEndDate) },
{ label: '最近更新', value: formatDateTime(row?.updateTime) },
{ label: '状态原因', value: row?.lastStatusReason || '--', span: 2 },
{ label: '任务说明', value: row?.taskDesc || '--', span: 2 }
];
const dialogTitle = computed(() => '任务详情');
watch(visible, val => {
if (val) {
activeTab.value = props.defaultTab;
}
});
</script>
<template>
<BusinessFormDialog v-model="visible" title="任务详情" preset="md" :show-footer="false">
<BusinessFormSection title="任务信息">
<ElDescriptions :column="2" border>
<ElDescriptionsItem v-for="item in detailItems" :key="item.label" :label="item.label" :span="item.span || 1">
<span class="task-detail-text">{{ item.value }}</span>
</ElDescriptionsItem>
</ElDescriptions>
</BusinessFormSection>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
width="1100px"
max-body-height="78vh"
:show-footer="false"
:scrollbar="false"
>
<ElTabs v-model="activeTab" class="task-detail-dialog__tabs">
<ElTabPane label="任务信息" name="info">
<TaskInfoReadonly :task="task" :user-options="userOptions" :task-options="taskOptions" />
</ElTabPane>
<ElTabPane label="工作日志" name="worklog" lazy>
<TaskWorklogContent
:task="task"
:active="activeTab === 'worklog' && visible"
@changed="payload => emit('worklog-changed', payload)"
/>
</ElTabPane>
</ElTabs>
</BusinessFormDialog>
</template>
<style scoped>
.task-detail-text {
white-space: pre-wrap;
word-break: break-word;
<style scoped lang="scss">
.task-detail-dialog__tabs {
--el-tabs-header-height: 40px;
}
// 任务信息 tab 自然高度较大;给 tab 内容一个最小高度(超过两个 tab 的自然高度),切换时弹层不缩水
.task-detail-dialog__tabs :deep(.el-tabs__content),
.task-detail-dialog__tabs :deep(.el-tab-pane) {
min-height: 640px;
}
</style>

View File

@@ -0,0 +1,149 @@
<script setup lang="ts">
import { computed } from 'vue';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProjectExecutionTaskInfoReadonly' });
interface Props {
task: Api.Project.ProjectTask | null;
userOptions?: Api.SystemManage.UserSimple[];
taskOptions?: Api.Project.ProjectTask[];
}
const props = withDefaults(defineProps<Props>(), {
userOptions: () => [],
taskOptions: () => []
});
const taskTitle = computed(() => props.task?.taskTitle ?? '');
const taskDesc = computed(() => props.task?.taskDesc ?? '');
const ownerId = computed(() => props.task?.ownerId ?? null);
const parentTaskId = computed(() => props.task?.parentTaskId ?? null);
const plannedStartDate = computed(() => props.task?.plannedStartDate ?? null);
const plannedEndDate = computed(() => props.task?.plannedEndDate ?? null);
const attachments = computed(() => props.task?.attachments ?? []);
const assigneeIds = computed(() => props.task?.assignees?.map(a => a.userId) ?? []);
const assigneeOptions = computed(() => props.task?.assignees ?? []);
// 父任务在当前页 taskOptions 中找;找不到(跨页)回退用 ID 当 label避免显示空
const parentTaskOptions = computed(() => {
if (!parentTaskId.value) return [];
const found = props.taskOptions.find(t => t.id === parentTaskId.value);
if (found) return [{ id: found.id, taskTitle: found.taskTitle }];
return [{ id: parentTaskId.value, taskTitle: parentTaskId.value }];
});
</script>
<template>
<ElForm label-position="top" class="task-info-readonly">
<div class="task-info-readonly__grid">
<div class="task-info-readonly__col-left">
<BusinessFormSection title="任务信息">
<ElFormItem label="任务名称">
<ElInput :model-value="taskTitle" readonly placeholder="--" />
</ElFormItem>
<ElFormItem label="父任务">
<ElSelect :model-value="parentTaskId" disabled clearable filterable class="w-full" placeholder="无">
<ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" />
</ElSelect>
</ElFormItem>
<ElFormItem label="负责人">
<BusinessUserSelect :model-value="ownerId" :options="userOptions" disabled placeholder="--" />
</ElFormItem>
<ElFormItem label="协办人">
<ElSelect
:model-value="assigneeIds"
multiple
disabled
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="暂无协办人"
>
<ElOption
v-for="item in assigneeOptions"
:key="item.userId"
:label="item.nickname"
:value="item.userId"
/>
</ElSelect>
</ElFormItem>
<ElFormItem label="计划开始日期">
<ElDatePicker
:model-value="plannedStartDate"
type="date"
value-format="YYYY-MM-DD"
disabled
placeholder="--"
class="task-info-readonly__date-picker"
/>
</ElFormItem>
<ElFormItem label="计划结束日期">
<ElDatePicker
:model-value="plannedEndDate"
type="date"
value-format="YYYY-MM-DD"
disabled
placeholder="--"
class="task-info-readonly__date-picker"
/>
</ElFormItem>
</BusinessFormSection>
</div>
<div class="task-info-readonly__col-right">
<BusinessFormSection title="任务说明">
<ElFormItem class="task-info-readonly__desc-item">
<BusinessRichTextEditor
:model-value="taskDesc"
disabled
:height="320"
upload-directory="task"
placeholder="--"
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="task-info-readonly__attachment-item">
<BusinessAttachmentUploader :model-value="attachments" disabled directory="task" />
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
</template>
<style scoped>
.task-info-readonly__grid {
display: grid;
grid-template-columns: 360px 1fr;
gap: 24px;
align-items: start;
}
.task-info-readonly__col-left,
.task-info-readonly__col-right {
min-width: 0;
}
.task-info-readonly__col-right {
display: flex;
flex-direction: column;
gap: 16px;
}
.task-info-readonly__desc-item,
.task-info-readonly__attachment-item {
margin-bottom: 0;
}
:deep(.task-info-readonly__date-picker.el-date-editor.el-input) {
width: 100%;
}
</style>

View File

@@ -3,11 +3,11 @@ import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue';
defineOptions({ name: 'ProjectExecutionTaskOperateDialog' });
type OperateMode = 'create' | 'edit';
@@ -22,6 +22,8 @@ export interface PlannedEndShortcutOffset {
interface Props {
mode: OperateMode;
rowData: Api.Project.ProjectTask | null;
/** 创建模式下的父任务预填;编辑/查看模式忽略 */
defaultParentTaskId?: string | null;
userOptions: Api.SystemManage.UserSimple[];
taskOptions: Api.Project.ProjectTask[];
plannedEndShortcuts?: PlannedEndShortcutOffset[];
@@ -32,6 +34,7 @@ interface Emits {
}
const props = withDefaults(defineProps<Props>(), {
defaultParentTaskId: null,
plannedEndShortcuts: () => [
{ text: '三天', days: 3 },
{ text: '一星期', days: 7 },
@@ -49,6 +52,9 @@ const visible = defineModel<boolean>('visible', {
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
interface FormModel {
parentTaskId: string | null;
taskTitle: string;
@@ -57,6 +63,7 @@ interface FormModel {
plannedEndDate: string | null;
taskDesc: string | null;
assigneeUserIds: string[];
attachments: Api.Project.AttachmentItem[];
}
const model = reactive<FormModel>({
@@ -66,10 +73,16 @@ const model = reactive<FormModel>({
plannedStartDate: null,
plannedEndDate: null,
taskDesc: null,
assigneeUserIds: []
assigneeUserIds: [],
attachments: []
});
const dialogTitle = computed(() => (props.mode === 'create' ? '新建任务' : '编辑任务'));
const dialogTitle = computed(() => {
if (props.mode === 'create') {
return '新建任务';
}
return props.rowData?.taskTitle ? `编辑任务:${props.rowData.taskTitle}` : '编辑任务';
});
const selectableParentTasks = computed(() => props.taskOptions.filter(item => item.id !== props.rowData?.id));
@@ -77,10 +90,13 @@ const selectableParentTasks = computed(() => props.taskOptions.filter(item => it
const leftColRef = ref<HTMLElement>();
const editorHeight = ref<string>('45vh');
// 右栏:富文本 + 附件区。预留附件区粗略高度(标题 + 按钮行 + 列表起始空间)
const ATTACHMENT_SECTION_RESERVE_PX = 140;
useResizeObserver(leftColRef, entries => {
const h = entries[0]?.contentRect.height;
if (h && h > 120) {
editorHeight.value = `${Math.max(h - 60, 240)}px`;
editorHeight.value = `${Math.max(h - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
}
});
@@ -172,20 +188,50 @@ const plannedEndDateShortcuts = computed(() =>
}))
);
/**
* 提交用:过滤掉 ownerId后端契约任务协办人不能等于 owner+ 去重
*/
function normalizeAssigneeIds(ids: string[]) {
return Array.from(new Set(ids.filter(id => id && id !== model.ownerId)));
}
/**
* 自动加进 model.assigneeUserIds 的 owner跟踪它以便 owner 切换时正确移除旧值。
* 防止用户先选了某 A 作为 owner自动加入再换成 B 作为 owner 时A 仍残留在协办人里。
*/
const autoOwnerAssigneeId = ref<string | null>(null);
/**
* UI 层把 owner 也加进 model.assigneeUserIds让协办人 select 视觉上显示 owner
* (体验上让用户感知"负责人也在团队里")。提交时由 normalizeAssigneeIds 过滤掉 owner。
*/
function syncOwnerAssignee(ownerId: string | null, previousOwnerId: string | null = autoOwnerAssigneeId.value) {
if (props.mode !== 'create') {
return;
}
const current = Array.from(new Set((model.assigneeUserIds ?? []).filter(Boolean)));
const withoutPrevious = previousOwnerId ? current.filter(userId => userId !== previousOwnerId) : current;
model.assigneeUserIds = ownerId ? Array.from(new Set([...withoutPrevious, ownerId])) : withoutPrevious;
autoOwnerAssigneeId.value = ownerId;
}
async function handleConfirm() {
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const payload: Api.Project.SaveProjectTaskParams = {
parentTaskId: model.parentTaskId || null,
taskTitle: model.taskTitle.trim(),
ownerId: model.ownerId || null,
plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate,
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null)
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null),
attachments: [...model.attachments]
};
if (props.mode === 'create') {
@@ -196,7 +242,24 @@ async function handleConfirm() {
}
function handleAssigneeChange(value: string[]) {
model.assigneeUserIds = normalizeAssigneeIds(value);
// UI 层保持 owner 不掉队;提交时再由 normalizeAssigneeIds 过滤
const cleaned = Array.from(new Set(value.filter(Boolean)));
if (props.mode === 'create' && model.ownerId && !cleaned.includes(model.ownerId)) {
cleaned.push(model.ownerId);
}
model.assigneeUserIds = cleaned;
}
function applyRowDataToModel() {
model.parentTaskId =
props.mode === 'create' ? (props.defaultParentTaskId ?? null) : props.rowData?.parentTaskId || null;
model.taskTitle = props.rowData?.taskTitle || '';
model.ownerId = props.rowData?.ownerId || null;
model.plannedStartDate = props.rowData?.plannedStartDate || null;
model.plannedEndDate = props.rowData?.plannedEndDate || null;
model.taskDesc = props.rowData?.taskDesc || null;
model.assigneeUserIds = [];
model.attachments = props.rowData?.attachments ? [...props.rowData.attachments] : [];
}
watch(
@@ -206,27 +269,30 @@ watch(
return;
}
model.parentTaskId = props.rowData?.parentTaskId || null;
model.taskTitle = props.rowData?.taskTitle || '';
model.ownerId = props.rowData?.ownerId || null;
model.plannedStartDate = props.rowData?.plannedStartDate || null;
model.plannedEndDate = props.rowData?.plannedEndDate || null;
model.taskDesc = props.rowData?.taskDesc || null;
model.assigneeUserIds = [];
applyRowDataToModel();
autoOwnerAssigneeId.value = null;
await nextTick();
// 让附件组件把当前 model 视作 original必须在 model 填充之后
attachmentUploaderRef.value?.initSession();
richTextEditorRef.value?.initSession();
formRef.value?.clearValidate();
}
);
watch(
() => model.ownerId,
() => {
if (props.mode === 'create') {
model.assigneeUserIds = normalizeAssigneeIds(model.assigneeUserIds);
}
(ownerId, previousOwnerId) => {
syncOwnerAssignee(ownerId || null, previousOwnerId || null);
}
);
defineExpose({
/** 父组件在业务保存成功后调用,触发删除被标记的附件 + 已被删的富文本图片 */
async commit() {
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
}
});
</script>
<template>
@@ -288,6 +354,32 @@ watch(
/>
</ElSelect>
</ElFormItem>
<ElFormItem v-else>
<template #label>
<span class="business-form-label-with-tip">
<ElTooltip
content="如需调整协办人,请关闭此弹层后点击列表「协办人」按钮。"
popper-class="business-form-label-tooltip"
placement="top-start"
>
<span class="business-form-label-tip">
<icon-fe:question />
</span>
</ElTooltip>
<span>协办人</span>
</span>
</template>
<ElSelect
:model-value="model.assigneeUserIds"
multiple
disabled
collapse-tags
collapse-tags-tooltip
:max-collapse-tags="2"
class="w-full"
placeholder="暂无协办人"
/>
</ElFormItem>
<ElFormItem label="计划开始日期" prop="plannedStartDate">
<ElDatePicker
@@ -316,6 +408,7 @@ watch(
<BusinessFormSection title="任务说明">
<ElFormItem class="task-operate-dialog__desc-item">
<BusinessRichTextEditor
ref="richTextEditorRef"
v-model="model.taskDesc"
:height="editorHeight"
upload-directory="task"
@@ -323,6 +416,12 @@ watch(
/>
</ElFormItem>
</BusinessFormSection>
<BusinessFormSection title="附件">
<ElFormItem class="task-operate-dialog__attachment-item">
<BusinessAttachmentUploader ref="attachmentUploaderRef" v-model="model.attachments" directory="task" />
</ElFormItem>
</BusinessFormSection>
</div>
</div>
</ElForm>
@@ -348,7 +447,8 @@ watch(
gap: 16px;
}
.task-operate-dialog__desc-item {
.task-operate-dialog__desc-item,
.task-operate-dialog__attachment-item {
margin-bottom: 0;
}

View File

@@ -1,8 +1,23 @@
<script setup lang="ts">
import { computed } from 'vue';
import { computed, markRaw } from 'vue';
import type { PaginationProps } from 'element-plus';
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useAuthStore } from '@/store/modules/auth';
import {
canReportTaskWorklog,
formatDateRange,
formatDateTime,
getTaskStatusName,
getTaskStatusTagType
} from '../shared';
import { useTaskPermissions } from '../composables/use-task-permissions';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
import IconMdiClipboardEditOutline from '~icons/mdi/clipboard-edit-outline';
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiPause from '~icons/mdi/pause';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
import IconMdiRestart from '~icons/mdi/restart';
import IconMdiSync from '~icons/mdi/sync';
defineOptions({ name: 'ProjectExecutionTaskTableView' });
@@ -10,13 +25,13 @@ interface Props {
data: Api.Project.ProjectTask[];
loading: boolean;
pagination: Partial<PaginationProps & Record<string, any>>;
canUpdate: boolean;
canChangeStatus: boolean;
}
interface Emits {
(e: 'detail', row: Api.Project.ProjectTask): void;
(e: 'edit', row: Api.Project.ProjectTask): void;
(e: 'report', row: Api.Project.ProjectTask): void;
(e: 'delete', row: Api.Project.ProjectTask): void;
(
e: 'status-action',
row: Api.Project.ProjectTask,
@@ -27,6 +42,11 @@ interface Emits {
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const { canEditTask, canDeleteTask, canReportTaskWorklog: hasReportWorklogPermission } = useTaskPermissions();
const paginationVisible = computed(() => Boolean(props.pagination.total));
const taskTitleMap = computed(() => {
@@ -46,48 +66,78 @@ function getParentTaskLabel(parentTaskId: string | null) {
return taskTitleMap.value.get(parentTaskId) || '--';
}
function createActions(row: Api.Project.ProjectTask): BusinessTableAction[] {
const actions: BusinessTableAction[] = [
{
key: 'detail',
label: '详情',
onClick: () => emit('detail', row)
}
];
interface TaskAction {
key: string;
tooltip: string;
icon: object;
type: 'primary' | 'success' | 'danger';
onClick: () => void;
}
if (props.canUpdate) {
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
pause: markRaw(IconMdiPause),
complete: markRaw(IconMdiCheckCircleOutline),
resume: markRaw(IconMdiRestart),
cancel: markRaw(IconMdiCloseCircleOutline)
};
function createActions(row: Api.Project.ProjectTask): TaskAction[] {
const actions: TaskAction[] = [];
// 填报:权限码门槛 AND 业务规则(叶子/身份/状态)双重判定
if (hasReportWorklogPermission() && canReportTaskWorklog(row, props.data, currentUserId.value)) {
actions.push({
key: 'report',
tooltip: '填报',
icon: markRaw(IconMdiClipboardEditOutline),
type: 'primary',
onClick: () => emit('report', row)
});
}
if (canEditTask(row)) {
actions.push({
key: 'edit',
label: '编辑',
tooltip: '编辑',
icon: markRaw(IconMdiPencilOutline),
type: 'primary',
onClick: () => emit('edit', row)
});
}
if (!props.canChangeStatus) {
return actions;
if (canDeleteTask(row)) {
actions.push({
key: 'delete',
tooltip: '删除',
icon: markRaw(IconMdiDeleteOutline),
type: 'danger',
onClick: () => emit('delete', row)
});
}
if (!row.availableActions.length) {
return [
...actions,
{
key: 'status',
label: '状态',
buttonType: 'primary',
onClick: () => emit('status-action', row, null)
}
];
return actions;
}
return [
...actions,
...row.availableActions.map(action => ({
row.availableActions.forEach(action => {
// auto_start 由后端在填工时时自动触发,无手工开始通道;防御后端误下发,前端不渲染
if (action.actionCode === 'auto_start') {
return;
}
// 完成任务至少要求任务进度达到 100%;父级提交入口仍保留同样兜底校验。
if (action.actionCode === 'complete' && row.progressRate < 100) {
return;
}
actions.push({
key: `status-${action.actionCode}`,
label: action.actionName,
buttonType: 'primary' as const,
tooltip: action.actionName,
icon: markRaw(STATUS_ACTION_ICON_MAP[action.actionCode] ?? IconMdiSync),
type: action.actionCode === 'cancel' ? 'danger' : 'success',
onClick: () => emit('status-action', row, action)
}))
];
});
});
return actions;
}
function handlePageChange(page: number) {
@@ -102,21 +152,21 @@ function handleSizeChange(pageSize: number) {
<template>
<ElCard class="task-table-card" body-class="business-table-card-body">
<div class="flex-1">
<ElTable
v-loading="loading"
:data="data"
height="100%"
border
row-key="id"
highlight-current-row
@row-dblclick="row => emit('detail', row)"
>
<ElTable v-loading="loading" :data="data" height="100%" border row-key="id" highlight-current-row>
<ElTableColumn type="index" label="序号" width="64" align="center" />
<ElTableColumn label="任务名称" min-width="220" show-overflow-tooltip>
<template #default="{ row }">
<ElButton link type="primary" class="task-title-link" @click="emit('detail', row)">
{{ row.taskTitle || '--' }}
</ElButton>
<span
v-if="row.taskTitle"
class="task-table-title"
role="button"
tabindex="0"
@click.stop="emit('detail', row)"
@keydown.enter.prevent="emit('detail', row)"
>
{{ row.taskTitle }}
</span>
<span v-else>--</span>
</template>
</ElTableColumn>
<ElTableColumn label="状态" width="100" align="center">
@@ -130,10 +180,10 @@ function handleSizeChange(pageSize: number) {
<ElTableColumn label="父任务" min-width="140" show-overflow-tooltip>
<template #default="{ row }">{{ getParentTaskLabel(row.parentTaskId) }}</template>
</ElTableColumn>
<ElTableColumn label="进度" width="150">
<ElTableColumn label="进度" width="160">
<template #default="{ row }">
<div class="task-table-progress">
<ElProgress :percentage="row.progressRate" :stroke-width="6" />
<ElProgress :percentage="row.progressRate" :stroke-width="18" text-inside />
</div>
</template>
</ElTableColumn>
@@ -146,9 +196,15 @@ function handleSizeChange(pageSize: number) {
<ElTableColumn label="最近更新" width="170">
<template #default="{ row }">{{ formatDateTime(row.updateTime) }}</template>
</ElTableColumn>
<ElTableColumn label="操作" width="220" fixed="right" align="center" class-name="task-operate-column">
<ElTableColumn label="操作" width="210" fixed="right" align="center" class-name="task-operate-column">
<template #default="{ row }">
<BusinessTableActionCell :actions="createActions(row)" />
<div class="task-action-cell" @click.stop>
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
<ElButton link :type="action.type" class="task-action-btn" @click="action.onClick()">
<component :is="action.icon" class="text-15px" />
</ElButton>
</ElTooltip>
</div>
</template>
</ElTableColumn>
</ElTable>
@@ -172,16 +228,34 @@ function handleSizeChange(pageSize: number) {
flex: 1;
}
.task-title-link {
max-width: 100%;
padding: 0;
overflow: hidden;
text-overflow: ellipsis;
vertical-align: top;
.task-table-title {
color: var(--el-color-primary);
cursor: pointer;
&:hover {
text-decoration: underline;
}
}
.task-table-progress {
width: 120px;
padding: 0 8px;
}
.task-action-cell {
display: inline-flex;
align-items: center;
gap: 6px;
}
.task-action-cell :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.task-action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
.task-table-pagination {

View File

@@ -0,0 +1,322 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
import { formatDate, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import type { WorklogChangedPayload } from '../shared';
import TaskWorklogPanel from './task-worklog-panel.vue';
defineOptions({ name: 'ProjectExecutionTaskWorklogContent' });
interface Props {
task: Api.Project.ProjectTask | null;
/** 是否激活;放进 tab 时由父级控制按需加载 */
active?: boolean;
}
interface Emits {
(e: 'changed', payload: WorklogChangedPayload): void;
}
const props = withDefaults(defineProps<Props>(), {
active: true
});
const emit = defineEmits<Emits>();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.task?.ownerId && props.task.ownerId === currentUserId.value));
const records = ref<Api.Project.TaskWorklog[]>([]);
const recordsLoading = ref(false);
const ownerName = computed(() => props.task?.ownerNickname?.trim() || props.task?.ownerId || '--');
const statusName = computed(() => (props.task ? getTaskStatusName(props.task) : ''));
const statusTagType = computed(() => (props.task ? getTaskStatusTagType(props.task.statusCode) : 'info'));
const progressText = computed(() => getProgressText(props.task?.progressRate));
const plannedStartText = computed(() =>
props.task?.plannedStartDate ? formatDate(props.task.plannedStartDate) : '--'
);
const plannedEndText = computed(() => (props.task?.plannedEndDate ? formatDate(props.task.plannedEndDate) : '--'));
const actualStartText = computed(() => (props.task?.actualStartDate ? formatDate(props.task.actualStartDate) : '--'));
const actualEndText = computed(() => (props.task?.actualEndDate ? formatDate(props.task.actualEndDate) : '--'));
// 协办人视角 records 只含自身;责任人视角 records 含全员
const totalHours = computed(() => records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0));
const totalHoursText = computed(() => {
if (recordsLoading.value) return '...';
return `${totalHours.value.toFixed(1)} h`;
});
// 责任人视角下"总工时" hover 展示按用户分组的明细;协办人视角不计算
// 候选范围:责任人 + 所有协办人 + records 中出现过的用户(兜底已退出协办人);
// 没填过工时的显示 0h
const hoursByUserDetail = computed(() => {
if (!isOwner.value) return [];
const sumMap = new Map<string, number>();
for (const item of records.value) {
sumMap.set(item.userId, (sumMap.get(item.userId) ?? 0) + (item.durationHours ?? 0));
}
const nicknameMap = new Map<string, string>();
const userIds: string[] = [];
const pushUser = (userId: string | null | undefined, name: string | null | undefined) => {
if (!userId || nicknameMap.has(userId)) return;
nicknameMap.set(userId, name?.trim() || userId);
userIds.push(userId);
};
pushUser(props.task?.ownerId, props.task?.ownerNickname);
for (const assignee of props.task?.assignees ?? []) {
pushUser(assignee.userId, assignee.nickname);
}
// records 中可能存在已退出协办人,按 worklog 自身昵称回填
for (const item of records.value) {
pushUser(item.userId, item.userNickname);
}
const arr = userIds.map(userId => ({
userId,
name: nicknameMap.get(userId) || userId,
hours: sumMap.get(userId) ?? 0
}));
// 责任人置顶其余按工时降序0h 自然落在最后)
arr.sort((a, b) => {
if (a.userId === props.task?.ownerId) return -1;
if (b.userId === props.task?.ownerId) return 1;
return b.hours - a.hours;
});
return arr;
});
async function loadRecords() {
if (!props.task) {
records.value = [];
return;
}
if (!currentUserId.value) {
records.value = [];
return;
}
recordsLoading.value = true;
const params: Api.Project.TaskWorklogSearchParams = {
pageNo: 1,
pageSize: -1
};
// 协办人视角:只看自己的 worklogowner 视角:全量加载
if (!isOwner.value) {
params.userId = currentUserId.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage(
props.task.projectId,
props.task.executionId,
props.task.id,
params
);
recordsLoading.value = false;
records.value = error || !data ? [] : data.list;
}
function handleWorklogChanged(payload: WorklogChangedPayload) {
loadRecords();
emit('changed', payload);
}
watch(
() => [props.active, props.task?.id] as const,
([isActive]) => {
if (isActive) {
loadRecords();
}
},
{ immediate: true }
);
</script>
<template>
<div class="task-worklog-content">
<div v-if="task" class="task-worklog-content__cards">
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">负责人</span>
<span class="task-worklog-content__card-value" :title="ownerName">{{ ownerName }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">任务状态</span>
<ElTag :type="statusTagType" size="small" effect="light" class="task-worklog-content__card-tag">
{{ statusName || '--' }}
</ElTag>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">计划开始</span>
<span class="task-worklog-content__card-value">{{ plannedStartText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">计划结束</span>
<span class="task-worklog-content__card-value">{{ plannedEndText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">当前进度</span>
<span class="task-worklog-content__card-value">{{ progressText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">总工时</span>
<ElTooltip
v-if="isOwner && hoursByUserDetail.length > 0"
placement="top"
effect="light"
popper-class="task-worklog-content__hours-popper"
>
<span
class="task-worklog-content__card-value task-worklog-content__card-value--accent task-worklog-content__card-value--hoverable"
>
{{ totalHoursText }}
</span>
<template #content>
<div class="task-worklog-content__hours-detail">
<div v-for="item in hoursByUserDetail" :key="item.userId" class="task-worklog-content__hours-detail-row">
<span
class="task-worklog-content__hours-detail-name"
:class="{ 'is-owner': item.userId === task?.ownerId }"
:title="item.name"
>
{{ item.name }}
</span>
<span class="task-worklog-content__hours-detail-hours">{{ item.hours.toFixed(1) }}h</span>
</div>
</div>
</template>
</ElTooltip>
<span v-else class="task-worklog-content__card-value task-worklog-content__card-value--accent">
{{ totalHoursText }}
</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">实际开始</span>
<span class="task-worklog-content__card-value">{{ actualStartText }}</span>
</div>
<div class="task-worklog-content__card">
<span class="task-worklog-content__card-label">实际结束</span>
<span class="task-worklog-content__card-value">{{ actualEndText }}</span>
</div>
</div>
<TaskWorklogPanel
v-if="task"
:project-id="task.projectId"
:execution-id="task.executionId"
:task-id="task.id"
:task-owner-id="task.ownerId"
:owner-nickname="task.ownerNickname"
:assignees="task.assignees"
:task-progress-rate="task.progressRate"
:can-submit="true"
:external-list="records"
:show-assignee-column="isOwner"
@changed="handleWorklogChanged"
/>
</div>
</template>
<style scoped lang="scss">
.task-worklog-content {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-worklog-content__cards {
display: grid;
grid-template-columns: repeat(4, 1fr); // 统一 8 卡 4×2 布局
gap: 12px;
}
.task-worklog-content__card {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
padding: 12px 14px;
background: var(--el-fill-color-light);
border: 1px solid var(--el-border-color-lighter);
border-radius: 6px;
}
.task-worklog-content__card-label {
color: var(--el-text-color-secondary);
font-size: 12px;
line-height: 1.2;
}
.task-worklog-content__card-value {
color: var(--el-text-color-primary);
font-size: 15px;
font-weight: 600;
line-height: 1.3;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.task-worklog-content__card-value--accent {
color: var(--el-color-primary);
}
.task-worklog-content__card-tag {
align-self: flex-start;
}
.task-worklog-content__card-value--hoverable {
cursor: default;
border-bottom: 1px dashed currentColor;
align-self: flex-start;
}
</style>
<style lang="scss">
// tooltip popper 走 teleport必须用全局样式
.task-worklog-content__hours-popper.el-popper {
max-width: 280px;
padding: 8px 10px;
}
.task-worklog-content__hours-detail {
display: flex;
flex-direction: column;
gap: 4px;
min-width: 160px;
}
.task-worklog-content__hours-detail-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
line-height: 1.4;
font-variant-numeric: tabular-nums;
}
.task-worklog-content__hours-detail-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--el-text-color-primary);
&.is-owner {
font-weight: 700;
}
}
.task-worklog-content__hours-detail-hours {
flex: 0 0 auto;
color: var(--el-color-primary);
font-weight: 500;
}
</style>

View File

@@ -0,0 +1,61 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import type { WorklogChangedPayload } from '../shared';
import TaskWorklogContent from './task-worklog-content.vue';
defineOptions({ name: 'ProjectExecutionTaskWorklogDialog' });
interface Props {
task: Api.Project.ProjectTask | null;
}
interface Emits {
(e: 'changed', payload: WorklogChangedPayload): void;
(e: 'closedAfterChange'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', {
default: false
});
const changedDuringOpen = ref(false);
const dialogTitle = computed(() => (props.task ? `工作日志 - ${props.task.taskTitle}` : '工作日志'));
function handleChanged(payload: WorklogChangedPayload) {
changedDuringOpen.value = true;
emit('changed', payload);
}
function handleClosed() {
if (!changedDuringOpen.value) {
return;
}
changedDuringOpen.value = false;
emit('closedAfterChange');
}
watch(visible, value => {
if (value) {
changedDuringOpen.value = false;
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="lg"
:show-footer="false"
:scrollbar="false"
@closed="handleClosed"
>
<TaskWorklogContent :task="task" :active="visible" @changed="handleChanged" />
</BusinessFormDialog>
</template>

View File

@@ -0,0 +1,449 @@
<script setup lang="ts">
import { computed, nextTick, reactive, ref, watch } from 'vue';
import dayjs from 'dayjs';
import { fetchGetProjectTaskWorklogPage } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
import { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
defineOptions({ name: 'ProjectExecutionTaskWorklogFormDialog' });
type Mode = 'create' | 'edit' | 'view';
type Granularity = 'day' | 'week';
interface Props {
mode: Mode;
rowData: Api.Project.TaskWorklog | null;
projectId: string;
executionId: string;
taskId: string;
taskOwnerId: string | null;
/** 创建模式下的进度兜底默认值owner 路径会传 task.progressRate */
defaultOwnerProgressRate?: number;
/** 提交中HTTP 进行中),由父组件控制 */
confirmLoading?: boolean;
}
interface Emits {
(e: 'submit', payload: Api.Project.SaveTaskWorklogParams): void;
}
const props = withDefaults(defineProps<Props>(), {
defaultOwnerProgressRate: 0,
confirmLoading: false
});
const emit = defineEmits<Emits>();
const visible = defineModel<boolean>('visible', { default: false });
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.taskOwnerId && props.taskOwnerId === currentUserId.value));
const isView = computed(() => props.mode === 'view');
const { formRef, validate } = useForm();
const { createRequiredRule } = useFormRules();
interface FormModel {
granularity: Granularity;
/** 'day' 时使用YYYY-MM-DD */
workDate: string | null;
/** 'week' 时使用ElDatePicker type='week' 返回 Date(周一) */
weekDate: Date | null;
/** 0.5 颗粒小时数 */
durationHours: number | null;
progressRate: number;
workContent: string | null;
attachments: Api.Project.AttachmentItem[];
}
const granularityOptions = [
{ label: '按天', value: 'day' as const },
{ label: '按周', value: 'week' as const }
];
const model = reactive<FormModel>({
granularity: 'day',
workDate: null,
weekDate: null,
durationHours: null,
progressRate: 0,
workContent: null,
attachments: []
});
const dialogTitle = computed(() => {
if (props.mode === 'create') return '填报';
if (props.mode === 'view') return '查看填报';
return '修改填报';
});
const dateFieldLabel = computed(() => (model.granularity === 'day' ? '工作日期' : '工作周次'));
const durationPlaceholder = computed(() => (model.granularity === 'day' ? '如 1.5' : '如 40'));
const workDateShortcuts = [
{ text: '今天', value: () => new Date() },
{ text: '昨天', value: () => dayjs().subtract(1, 'day').toDate() },
{ text: '前天', value: () => dayjs().subtract(2, 'day').toDate() }
];
const weekDateShortcuts = [
{ text: '本周', value: () => dayjs().startOf('isoWeek').toDate() },
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() }
];
// 选中后鼠标悬浮 input 显示该周的起止日期input 里默认只显示 "YYYY年第W周"
const weekRangeTooltip = computed(() => {
if (!model.weekDate) return '';
const start = dayjs(model.weekDate);
if (!start.isValid()) return '';
const end = start.add(6, 'day');
return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`;
});
const rules = computed(
() =>
({
granularity: [createRequiredRule('请选择填报粒度')],
workDate: [
{
required: true,
validator: (_rule, value: string | null, callback) => {
if (model.granularity !== 'day') {
callback();
return;
}
if (!value) {
callback(new Error('请选择工作日期'));
return;
}
callback();
},
trigger: 'change'
}
],
weekDate: [
{
required: true,
validator: (_rule, value: Date | null, callback) => {
if (model.granularity !== 'week') {
callback();
return;
}
if (!value) {
callback(new Error('请选择工作周次'));
return;
}
callback();
},
trigger: 'change'
}
],
durationHours: [
{
required: true,
validator: (_rule, value: number | null, callback) => {
if (value === null || value === undefined) {
callback(new Error('请输入时长'));
return;
}
if (value <= 0) {
callback(new Error('时长必须大于 0'));
return;
}
// 0.5 小时颗粒(避免浮点误差,乘 10 后判 5 的整数倍)
if (Math.round(value * 10) % 5 !== 0) {
callback(new Error('时长必须是 0.5 小时的整数倍'));
return;
}
callback();
},
trigger: 'change'
}
],
progressRate: [
{
required: true,
validator: (_rule, value: number, callback) => {
if (value < 0 || value > 100) {
callback(new Error('进度需在 0 到 100 之间'));
return;
}
callback();
},
trigger: 'change'
}
],
workContent: [
{
required: true,
validator: (_rule, value: string | null, callback) => {
if (!value || !value.trim()) {
callback(new Error('请输入工作内容'));
return;
}
callback();
},
trigger: 'blur'
}
]
}) satisfies Record<string, App.Global.FormRule[]>
);
async function loadAssigneeLatestProgress(): Promise<number> {
if (!props.projectId || !props.executionId || !props.taskId || !currentUserId.value) {
return 0;
}
const { error, data } = await fetchGetProjectTaskWorklogPage(props.projectId, props.executionId, props.taskId, {
pageNo: 1,
pageSize: 1,
userId: currentUserId.value
});
if (error || !data?.list?.length) {
return 0;
}
return data.list[0]?.progressRate ?? 0;
}
async function resolveDefaultProgressRate(): Promise<number> {
// edit / view 都从已有 rowData 取,避免多发一次 latest-progress 请求
if (props.mode === 'edit' || props.mode === 'view') {
return props.rowData?.progressRate ?? 0;
}
if (isOwner.value) {
return props.defaultOwnerProgressRate;
}
return loadAssigneeLatestProgress();
}
function detectGranularityFromRow(row: Api.Project.TaskWorklog): Granularity {
if (!row.startDate || !row.endDate) {
return 'day';
}
if (row.startDate === row.endDate) {
return 'day';
}
const start = dayjs(row.startDate);
const end = dayjs(row.endDate);
if (start.isoWeekday() === 1 && end.isoWeekday() === 7 && end.diff(start, 'day') === 6) {
return 'week';
}
return 'day';
}
function getStartEndFromModel(): { startDate: string; endDate: string } {
if (model.granularity === 'day') {
return { startDate: model.workDate!, endDate: model.workDate! };
}
const weekStart = dayjs(model.weekDate!).startOf('isoWeek');
return {
startDate: weekStart.format('YYYY-MM-DD'),
endDate: weekStart.add(6, 'day').format('YYYY-MM-DD')
};
}
watch(
() => model.granularity,
() => {
// 切换粒度会让日期 ElFormItem 的 :prop 在 workDate/weekDate 之间切换,
// ElForm 内部对所有已挂载字段触发一轮校验;这里整张表清一次校验提示,避免误报
formRef.value?.clearValidate();
}
);
async function handleConfirm() {
if (isView.value) {
visible.value = false;
return;
}
await validate();
if (attachmentUploaderRef.value?.hasUploading) {
window.$message?.warning('附件正在上传中,请稍候');
return;
}
const { startDate, endDate } = getStartEndFromModel();
const payload: Api.Project.SaveTaskWorklogParams = {
startDate,
endDate,
durationHours: Number(model.durationHours!.toFixed(1)),
progressRate: Number(model.progressRate.toFixed(2)),
workContent: model.workContent?.trim() || null,
attachments: [...model.attachments]
};
emit('submit', payload);
}
watch(
() => visible.value,
async value => {
if (!value) {
return;
}
const row = props.rowData;
if (row) {
const detected = detectGranularityFromRow(row);
model.granularity = detected;
if (detected === 'week') {
model.workDate = null;
// 用 dayjs 解析为本地日期 0 点,避免 new Date('YYYY-MM-DD') 按 UTC 解析在负时区误移一周
model.weekDate = dayjs(row.startDate).toDate();
} else {
model.workDate = row.startDate || dayjs().format('YYYY-MM-DD');
model.weekDate = null;
}
model.durationHours = typeof row.durationHours === 'number' ? row.durationHours : null;
model.workContent = row.workContent || null;
model.attachments = row.attachments ? [...row.attachments] : [];
} else {
model.granularity = 'day';
model.workDate = dayjs().format('YYYY-MM-DD');
model.weekDate = null;
model.durationHours = null;
model.workContent = null;
model.attachments = [];
}
// 异步取默认值期间先复位为 0避免闪现上一次的旧值
model.progressRate = 0;
const defaultProgress = await resolveDefaultProgressRate();
model.progressRate = defaultProgress;
await nextTick();
attachmentUploaderRef.value?.initSession();
formRef.value?.clearValidate();
}
);
defineExpose({
/** 父组件在业务保存成功后调用 */
async commit() {
await attachmentUploaderRef.value?.commit();
}
});
</script>
<template>
<BusinessFormDialog
v-model="visible"
:title="dialogTitle"
preset="md"
:confirm-loading="props.confirmLoading"
@confirm="handleConfirm"
>
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
<ElRow :gutter="16">
<ElCol :span="12">
<ElFormItem label="填报粒度" prop="granularity">
<ElSegmented v-model="model.granularity" :options="granularityOptions" :disabled="isView" />
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem :label="dateFieldLabel" :prop="model.granularity === 'day' ? 'workDate' : 'weekDate'">
<ElDatePicker
v-if="model.granularity === 'day'"
v-model="model.workDate"
type="date"
value-format="YYYY-MM-DD"
placeholder="选择工作日期"
:shortcuts="isView ? undefined : workDateShortcuts"
:disabled="isView"
class="task-worklog-form-dialog__date-picker"
/>
<ElTooltip v-else :content="weekRangeTooltip" :disabled="!weekRangeTooltip" placement="top">
<span class="task-worklog-form-dialog__week-wrapper">
<ElDatePicker
v-model="model.weekDate"
type="week"
format="YYYY[年第]ww[周]"
placeholder="选择工作周次"
:shortcuts="isView ? undefined : weekDateShortcuts"
:disabled="isView"
class="task-worklog-form-dialog__date-picker"
/>
</span>
</ElTooltip>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="时长(小时)" prop="durationHours">
<ElInputNumber
v-model="model.durationHours"
:min="0.5"
:step="0.5"
:precision="1"
:placeholder="durationPlaceholder"
:disabled="isView"
controls-position="right"
class="w-full"
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="进度(%" prop="progressRate">
<ElInputNumber
v-model="model.progressRate"
:min="0"
:max="100"
:step="1"
:precision="2"
:disabled="isView"
controls-position="right"
class="w-full"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="工作内容" prop="workContent">
<ElInput
v-model="model.workContent"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:maxlength="isView ? undefined : 2000"
:show-word-limit="!isView"
:disabled="isView"
placeholder="简述本次填报的工作内容"
/>
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="附件">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
:disabled="isView"
directory="task-worklog"
/>
</ElFormItem>
</ElCol>
</ElRow>
</ElForm>
<template v-if="isView" #footer="{ close }">
<ElButton type="primary" @click="close">关闭</ElButton>
</template>
</BusinessFormDialog>
</template>
<style scoped>
:deep(.task-worklog-form-dialog__date-picker.el-date-editor.el-input) {
width: 100%;
}
.task-worklog-form-dialog__week-wrapper {
display: block;
width: 100%;
}
</style>

View File

@@ -0,0 +1,860 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import {
fetchCreateProjectTaskWorklog,
fetchDeleteProjectTaskWorklog,
fetchGetProjectTaskWorklogPage,
fetchUpdateProjectTaskWorklog
} from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import { formatWorklogPeriod, getWorklogGranularityName } from '../shared';
import type { WorklogChangedPayload } from '../shared';
import TaskWorklogFormDialog from './task-worklog-form-dialog.vue';
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
import IconMdiFilterVariant from '~icons/mdi/filter-variant';
import IconMdiPaperclip from '~icons/mdi/paperclip';
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
defineOptions({ name: 'ProjectExecutionTaskWorklogPanel' });
interface Props {
projectId: string;
executionId: string;
taskId: string;
taskOwnerId: string | null;
/** 当前用户是否被允许填报owner 或活跃协办人) */
canSubmit: boolean;
/** 用于 form-dialog 在 owner 路径下显示默认进度task.progressRate */
taskProgressRate?: number;
/** 在岗协办人列表(来自 task.assignees。owner 视角下用于渲染顶部最新填报汇总条 */
assignees?: Api.Project.TaskAssigneeRef[] | null;
/** 外部传入的全量记录。提供后 panel 跳过自身分页接口使用前端假分页CRUD 后只 emit changed由父级重拉数据 */
externalList?: Api.Project.TaskWorklog[] | null;
/** owner 昵称,用于构造「填报人」筛选下拉项 */
ownerNickname?: string | null;
/** 是否展示「填报人」列与列头筛选;只在 owner 视角下展示 */
showAssigneeColumn?: boolean;
}
interface Emits {
(e: 'changed', payload: WorklogChangedPayload): void;
}
const props = withDefaults(defineProps<Props>(), {
taskProgressRate: 0,
showAssigneeColumn: false
});
const emit = defineEmits<Emits>();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
const isOwner = computed(() => Boolean(props.taskOwnerId && props.taskOwnerId === currentUserId.value));
const PAGE_SIZE = 10;
// 表头 ~40px + 7 行 × ~50px含 ElTag/icon 按钮)= 约 390px超出 7 行走表格内部滚动
const TABLE_HEIGHT = 390;
const pageNo = ref(1);
const internalTotal = ref(0);
const internalList = ref<Api.Project.TaskWorklog[]>([]);
const loading = ref(false);
const usingExternal = computed(() => Array.isArray(props.externalList));
const userFilter = ref<string[]>([]);
const userFilterPopoverVisible = ref(false);
const pendingUserFilter = ref<string[]>([]);
interface UserFilterRichOption {
value: string;
name: string;
isOwner: boolean;
hoursText: string;
progressText: string;
/** true 表示没有任何 worklogUI 上隐藏工时/进度,只显示"未填报"灰字 */
empty: boolean;
}
// 每个 userId 在 externalList 中"最近一条" worklog 的 progressRate
// externalList 已由后端按 end_date desc, id desc 排序,第一条即为最近
const latestProgressByUser = computed(() => {
const map = new Map<string, number>();
for (const item of props.externalList ?? []) {
if (!map.has(item.userId)) {
map.set(item.userId, item.progressRate);
}
}
return map;
});
// 每个 userId 的累计工时durationHours 之和)
const totalHoursByUser = computed(() => {
const map = new Map<string, number>();
for (const item of props.externalList ?? []) {
map.set(item.userId, (map.get(item.userId) ?? 0) + (item.durationHours ?? 0));
}
return map;
});
const userFilterRichOptions = computed<UserFilterRichOption[]>(() => {
const options: UserFilterRichOption[] = [];
if (props.taskOwnerId) {
// 责任人的"进度"= 任务整体进度(因为责任人填报本就会回写任务进度)
// 工时仍取该用户实际累计 worklog 时长
options.push({
value: props.taskOwnerId,
name: props.ownerNickname?.trim() || props.taskOwnerId,
isOwner: true,
hoursText: formatHours(totalHoursByUser.value.get(props.taskOwnerId) ?? 0),
progressText: formatProgress(props.taskProgressRate),
empty: false
});
}
for (const assignee of props.assignees ?? []) {
// 防止 owner 同时也是 assignee 时重复
if (assignee.userId !== props.taskOwnerId) {
const latest = latestProgressByUser.value.get(assignee.userId);
const hours = totalHoursByUser.value.get(assignee.userId) ?? 0;
const empty = latest === undefined;
options.push({
value: assignee.userId,
name: assignee.nickname?.trim() || assignee.userId,
isOwner: false,
hoursText: empty ? '' : formatHours(hours),
progressText: empty ? '未填报' : formatProgress(latest!),
empty
});
}
}
return options;
});
function handleUserFilterConfirm() {
userFilter.value = [...pendingUserFilter.value];
userFilterPopoverVisible.value = false;
}
function handleUserFilterReset() {
pendingUserFilter.value = [];
}
// popover 打开瞬间从 userFilter 初始化 pending让 ElPopover 自己控开关,
// 避免 reference 上挂自定义 click handler 与 trigger="click" 互相 toggle 导致一开就关
watch(userFilterPopoverVisible, value => {
if (value) {
pendingUserFilter.value = [...userFilter.value];
}
});
const filteredExternalList = computed<Api.Project.TaskWorklog[]>(() => {
const all = props.externalList ?? [];
if (!props.showAssigneeColumn || userFilter.value.length === 0) {
return all;
}
return all.filter(item => userFilter.value.includes(item.userId));
});
const total = computed(() => (usingExternal.value ? filteredExternalList.value.length : internalTotal.value));
const list = computed<Api.Project.TaskWorklog[]>(() => {
if (!usingExternal.value) {
return internalList.value;
}
const start = (pageNo.value - 1) * PAGE_SIZE;
return filteredExternalList.value.slice(start, start + PAGE_SIZE);
});
const formVisible = ref(false);
const formMode = ref<'create' | 'edit' | 'view'>('create');
const editingWorklog = ref<Api.Project.TaskWorklog | null>(null);
const submitting = ref(false);
const worklogFormDialogRef = ref<InstanceType<typeof TaskWorklogFormDialog> | null>(null);
function getRowIndex(index: number) {
return (pageNo.value - 1) * PAGE_SIZE + index + 1;
}
const canCreate = computed(() => Boolean(props.canSubmit && props.taskId));
function canEditRow(row: Api.Project.TaskWorklog) {
return Boolean(currentUserId.value && row.userId === currentUserId.value);
}
// 编辑 / 删除均仅本人;非本人按钮渲染为 disabled让责任人也能感知"这条不归我管"
function canDeleteRow(row: Api.Project.TaskWorklog) {
return Boolean(currentUserId.value && row.userId === currentUserId.value);
}
function formatHours(hours: number | null | undefined) {
if (typeof hours !== 'number' || !Number.isFinite(hours)) {
return '0h';
}
return `${hours.toFixed(1)}h`;
}
function formatProgress(value: number | null | undefined) {
if (typeof value !== 'number' || !Number.isFinite(value)) {
return '0%';
}
// 保留至多 2 位小数,去掉尾部 035 → "35%"35.5 → "35.5%"35.75 → "35.75%"
const clamped = Math.min(100, Math.max(0, value));
const rounded = Math.round(clamped * 100) / 100;
return `${rounded}%`;
}
async function loadList() {
// 父级提供了全量数据:跳过自身请求
if (usingExternal.value) {
return;
}
if (!props.projectId || !props.executionId || !props.taskId) {
internalList.value = [];
internalTotal.value = 0;
return;
}
loading.value = true;
const params: Api.Project.TaskWorklogSearchParams = {
pageNo: pageNo.value,
pageSize: PAGE_SIZE
};
// owner 看全部;协作人/旁观者:协作人按身份过滤;旁观者沿用旧逻辑(不传 userId
if (!isOwner.value && props.canSubmit && currentUserId.value) {
params.userId = currentUserId.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage(
props.projectId,
props.executionId,
props.taskId,
params
);
loading.value = false;
if (error || !data) {
internalList.value = [];
internalTotal.value = 0;
return;
}
internalList.value = data.list;
internalTotal.value = data.total;
}
function handlePageChange(page: number) {
pageNo.value = page;
// 外部数据模式下list 是 computed 切片pageNo 改变会自动反映;无需触发请求
if (!usingExternal.value) {
loadList();
}
}
function handleCreate() {
formMode.value = 'create';
editingWorklog.value = null;
formVisible.value = true;
}
function handleEdit(row: Api.Project.TaskWorklog) {
formMode.value = 'edit';
editingWorklog.value = row;
formVisible.value = true;
}
function handleView(row: Api.Project.TaskWorklog) {
formMode.value = 'view';
editingWorklog.value = row;
formVisible.value = true;
}
async function handleSubmit(payload: Api.Project.SaveTaskWorklogParams) {
submitting.value = true;
try {
const result =
formMode.value === 'create'
? await fetchCreateProjectTaskWorklog(props.projectId, props.executionId, props.taskId, payload)
: await fetchUpdateProjectTaskWorklog(props.projectId, props.executionId, props.taskId, {
worklogId: editingWorklog.value!.id,
data: payload
});
if (result.error) {
return;
}
window.$message?.success(formMode.value === 'create' ? '填报成功' : '填报已更新');
// 业务保存成功才 commit删除用户在弹层里标记删除的附件
await worklogFormDialogRef.value?.commit();
formVisible.value = false;
// 外部数据模式:父级监听 changed 后重拉,自身不再触发请求
if (!usingExternal.value) {
await loadList();
}
emit('changed', {
mode: formMode.value === 'create' ? 'create' : 'edit',
taskId: props.taskId,
progressRate: payload.progressRate
});
} finally {
submitting.value = false;
}
}
async function handleDelete(row: Api.Project.TaskWorklog) {
const { error } = await fetchDeleteProjectTaskWorklog(props.projectId, props.executionId, props.taskId, row.id);
if (error) {
return;
}
window.$message?.success('工时已删除');
if (!usingExternal.value) {
// 删完最后一页若空了,回退一页(仅 server-side 路径手动处理;外部模式下由 watch(total) 兜底)
if (list.value.length === 1 && pageNo.value > 1) {
pageNo.value -= 1;
}
await loadList();
}
emit('changed', {
mode: 'delete',
taskId: props.taskId
});
}
// 外部模式下数据缩短后把 pageNo 夹回合法范围
watch(total, value => {
if (!usingExternal.value) return;
const maxPage = Math.max(1, Math.ceil(value / PAGE_SIZE));
if (pageNo.value > maxPage) {
pageNo.value = maxPage;
}
});
watch(userFilter, () => {
pageNo.value = 1;
});
watch(
() => props.taskId,
() => {
pageNo.value = 1;
userFilter.value = [];
userFilterPopoverVisible.value = false;
loadList();
},
{ immediate: true }
);
</script>
<template>
<div class="task-worklog-panel">
<header v-if="canCreate" class="task-worklog-panel__header">
<ElButton type="primary" :icon="Plus" size="small" @click="handleCreate">填报</ElButton>
</header>
<ElTable
v-loading="loading"
:data="list"
:height="TABLE_HEIGHT"
border
empty-text="暂无工作日志"
class="task-worklog-panel__table"
>
<ElTableColumn type="index" :index="getRowIndex" label="序号" width="60" align="center" />
<ElTableColumn label="粒度" width="70" align="center">
<template #default="{ row }">
<ElTag
:type="formatWorklogPeriod(row.startDate, row.endDate).granularity === 'week' ? 'warning' : 'info'"
size="small"
effect="plain"
>
{{ getWorklogGranularityName(formatWorklogPeriod(row.startDate, row.endDate).granularity) }}
</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="日期" width="180" align="center">
<template #default="{ row }">
<ElTooltip
v-if="formatWorklogPeriod(row.startDate, row.endDate).tooltip"
:content="formatWorklogPeriod(row.startDate, row.endDate).tooltip ?? ''"
placement="top"
>
<span>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
</ElTooltip>
<span v-else>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
</template>
</ElTableColumn>
<ElTableColumn v-if="showAssigneeColumn" label="填报人" width="120" align="center">
<template #header>
<div class="task-worklog-panel__user-header">
<span>填报人</span>
<ElPopover
v-model:visible="userFilterPopoverVisible"
trigger="click"
placement="bottom"
:width="260"
popper-class="task-worklog-panel__user-filter-popper"
>
<template #reference>
<span
class="task-worklog-panel__user-filter-trigger"
:class="{ 'is-active': userFilter.length > 0 }"
@click.stop
>
<IconMdiFilterVariant />
</span>
</template>
<div class="task-worklog-panel__user-filter">
<ElCheckboxGroup v-model="pendingUserFilter" class="task-worklog-panel__user-filter-list">
<label
v-for="option in userFilterRichOptions"
:key="option.value"
class="task-worklog-panel__user-filter-row"
>
<ElCheckbox :value="option.value">
<span class="task-worklog-panel__user-filter-name-cell">
<span
class="task-worklog-panel__user-filter-name"
:class="{ 'is-owner': option.isOwner }"
:title="option.name"
>
{{ option.name }}
</span>
<ElTag
v-if="option.isOwner"
type="warning"
size="small"
effect="plain"
class="task-worklog-panel__user-filter-owner-tag"
>
责任人
</ElTag>
</span>
</ElCheckbox>
<span class="task-worklog-panel__user-filter-meta">
<template v-if="!option.empty">
<span class="task-worklog-panel__user-filter-hours">{{ option.hoursText }}</span>
<span class="task-worklog-panel__user-filter-meta-sep">·</span>
<span class="task-worklog-panel__user-filter-progress">{{ option.progressText }}</span>
</template>
<span v-else class="task-worklog-panel__user-filter-progress is-empty">
{{ option.progressText }}
</span>
</span>
</label>
</ElCheckboxGroup>
<div class="task-worklog-panel__user-filter-footer">
<ElButton size="small" link @click="handleUserFilterReset">重置</ElButton>
<ElButton size="small" type="primary" @click="handleUserFilterConfirm">确定</ElButton>
</div>
</div>
</ElPopover>
</div>
</template>
<template #default="{ row }">
<span :title="row.userNickname || row.userId">{{ row.userNickname || row.userId }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="工作内容" min-width="280">
<template #default="{ row }">
<ElPopover
v-if="row.workContent || (row.attachments && row.attachments.length)"
trigger="hover"
placement="top"
:width="360"
:show-after="200"
popper-class="task-worklog-panel__content-popover"
>
<template #reference>
<span class="task-worklog-panel__content-cell">
{{ row.workContent || '附件 ' + (row.attachments?.length ?? 0) + ' 个' }}
</span>
</template>
<div class="task-worklog-panel__content-card">
<div class="task-worklog-panel__content-card-header">
<span>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
<span class="task-worklog-panel__content-card-meta">
{{ formatHours(row.durationHours) }} · {{ formatProgress(row.progressRate) }}
</span>
</div>
<div v-if="row.workContent" class="task-worklog-panel__content-card-body">
{{ row.workContent }}
</div>
<div class="task-worklog-panel__content-card-attachments">
<div class="task-worklog-panel__content-card-section-title">
<ElIcon><IconMdiPaperclip /></ElIcon>
<span v-if="row.attachments && row.attachments.length">附件{{ row.attachments.length }}</span>
<span v-else class="task-worklog-panel__content-card-attachment-empty">无附件</span>
</div>
<div
v-if="row.attachments && row.attachments.length"
class="task-worklog-panel__content-card-attachments-scroll"
>
<BusinessAttachmentUploader :model-value="row.attachments" disabled flat />
</div>
</div>
</div>
</ElPopover>
<span v-else class="task-worklog-panel__content-cell-empty">--</span>
</template>
</ElTableColumn>
<ElTableColumn label="时长" width="100" align="center">
<template #default="{ row }">
<span class="task-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="进度" width="100" align="center">
<template #default="{ row }">
<span class="task-worklog-panel__progress">{{ formatProgress(row.progressRate) }}</span>
</template>
</ElTableColumn>
<ElTableColumn label="操作" width="120" align="center" fixed="right">
<template #default="{ row }">
<div class="task-worklog-panel__actions" @click.stop>
<ElTooltip content="查看">
<ElButton link type="primary" class="task-worklog-panel__action-btn" @click="handleView(row)">
<IconMdiEyeOutline class="text-15px" />
</ElButton>
</ElTooltip>
<ElTooltip :content="canEditRow(row) ? '编辑' : '仅可编辑本人填报'">
<span class="inline-flex">
<ElButton
link
type="primary"
class="task-worklog-panel__action-btn"
:disabled="!canEditRow(row)"
@click="handleEdit(row)"
>
<IconMdiPencilOutline class="text-15px" />
</ElButton>
</span>
</ElTooltip>
<ElPopconfirm
v-if="canDeleteRow(row)"
title="确认删除该条工时记录?"
confirm-button-text="删除"
cancel-button-text="取消"
confirm-button-type="danger"
@confirm="handleDelete(row)"
>
<template #reference>
<span class="inline-flex">
<ElTooltip content="删除">
<ElButton link type="danger" class="task-worklog-panel__action-btn">
<IconMdiDeleteOutline class="text-15px" />
</ElButton>
</ElTooltip>
</span>
</template>
</ElPopconfirm>
<ElTooltip v-else content="仅可删除本人填报">
<span class="inline-flex">
<ElButton link type="danger" class="task-worklog-panel__action-btn" disabled>
<IconMdiDeleteOutline class="text-15px" />
</ElButton>
</span>
</ElTooltip>
</div>
</template>
</ElTableColumn>
</ElTable>
<div class="task-worklog-panel__pagination">
<ElPagination
v-if="total > 0"
small
background
layout="total, prev, pager, next"
:current-page="pageNo"
:page-size="PAGE_SIZE"
:total="total"
@current-change="handlePageChange"
/>
</div>
<TaskWorklogFormDialog
ref="worklogFormDialogRef"
v-model:visible="formVisible"
:mode="formMode"
:row-data="editingWorklog"
:project-id="projectId"
:execution-id="executionId"
:task-id="taskId"
:task-owner-id="taskOwnerId"
:default-owner-progress-rate="taskProgressRate"
:confirm-loading="submitting"
@submit="handleSubmit"
/>
</div>
</template>
<style scoped lang="scss">
.task-worklog-panel {
display: flex;
flex-direction: column;
gap: 12px;
}
.task-worklog-panel__header {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
}
.task-worklog-panel__duration {
color: var(--el-color-primary);
font-weight: 500;
}
.task-worklog-panel__actions {
display: inline-flex;
align-items: center;
gap: 4px;
}
.task-worklog-panel__actions :deep(.el-button + .el-button) {
margin-left: 0;
}
:deep(.task-worklog-panel__action-btn) {
padding: 3px;
min-width: auto;
height: auto;
line-height: 1;
}
.task-worklog-panel__pagination {
display: flex;
justify-content: flex-end;
min-height: 32px;
}
.task-worklog-panel__progress {
color: var(--el-color-primary);
font-weight: 500;
}
.task-worklog-panel__content-cell {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
white-space: pre-wrap;
word-break: break-word;
line-height: 1.5;
cursor: default;
}
.task-worklog-panel__content-cell-empty {
color: var(--el-text-color-placeholder);
}
.task-worklog-panel__content-card {
display: flex;
flex-direction: column;
gap: 10px;
padding: 4px 2px;
}
.task-worklog-panel__content-card-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--el-text-color-secondary);
padding-bottom: 8px;
border-bottom: 1px solid var(--el-border-color-lighter);
}
.task-worklog-panel__content-card-meta {
color: var(--el-color-primary);
font-weight: 500;
}
.task-worklog-panel__content-card-body {
font-size: 13px;
line-height: 1.65;
color: var(--el-text-color-primary);
white-space: pre-wrap;
word-break: break-word;
max-height: 220px;
overflow-y: auto;
}
.task-worklog-panel__content-card-attachments {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-worklog-panel__content-card-attachments-scroll {
// 约 3 项高度,再多就内部纵向滚动,避免 popover 被撑高
max-height: 144px;
overflow-y: auto;
padding-right: 4px;
}
.task-worklog-panel__content-card-section-title {
display: inline-flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: var(--el-text-color-secondary);
}
.task-worklog-panel__content-card-attachment-empty {
color: var(--el-text-color-placeholder);
}
.task-worklog-panel__user-header {
display: inline-flex;
align-items: center;
gap: 4px;
line-height: 1;
}
.task-worklog-panel__user-filter-trigger {
display: inline-flex;
align-items: center;
justify-content: center;
width: 18px;
height: 18px;
font-size: 14px;
color: var(--el-text-color-secondary);
cursor: pointer;
border-radius: 3px;
transition:
background-color 0.15s ease,
color 0.15s ease;
&:hover {
background-color: var(--el-fill-color-light);
color: var(--el-text-color-primary);
}
&.is-active {
color: var(--el-color-primary);
}
}
</style>
<style lang="scss">
// popper 走 teleport 出去,必须用全局样式
.task-worklog-panel__user-filter-popper.el-popover.el-popper {
padding: 8px 0;
}
.task-worklog-panel__user-filter {
display: flex;
flex-direction: column;
gap: 4px;
}
.task-worklog-panel__user-filter-list {
display: flex;
flex-direction: column;
max-height: 280px;
overflow-y: auto;
padding: 0 8px;
}
.task-worklog-panel__user-filter-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 4px 4px;
margin: 0;
border-radius: 4px;
cursor: pointer;
&:hover {
background-color: var(--el-fill-color-light);
}
.el-checkbox {
flex: 1;
min-width: 0;
margin-right: 0;
height: auto;
.el-checkbox__label {
padding-left: 6px;
min-width: 0;
flex: 1;
}
}
}
.task-worklog-panel__user-filter-name-cell {
display: inline-flex;
align-items: center;
gap: 6px;
min-width: 0;
max-width: 100%;
}
.task-worklog-panel__user-filter-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
min-width: 0;
color: var(--el-text-color-primary);
&.is-owner {
font-weight: 700;
}
}
.task-worklog-panel__user-filter-owner-tag {
flex: 0 0 auto;
}
.task-worklog-panel__user-filter-meta {
display: inline-flex;
align-items: baseline;
gap: 4px;
flex: 0 0 auto;
font-size: 12px;
color: var(--el-text-color-secondary);
font-variant-numeric: tabular-nums;
}
.task-worklog-panel__user-filter-hours {
color: var(--el-color-primary);
font-weight: 500;
}
.task-worklog-panel__user-filter-meta-sep {
color: var(--el-text-color-placeholder);
}
.task-worklog-panel__user-filter-progress {
color: var(--el-text-color-secondary);
&.is-empty {
color: var(--el-text-color-placeholder);
}
}
.task-worklog-panel__user-filter-footer {
display: flex;
justify-content: flex-end;
gap: 8px;
padding: 6px 8px 0;
border-top: 1px solid var(--el-border-color-lighter);
margin-top: 4px;
}
</style>

View File

@@ -4,19 +4,31 @@ import { Plus } from '@element-plus/icons-vue';
import {
fetchChangeProjectTaskStatus,
fetchCreateProjectTask,
fetchCreateProjectTaskAssignee,
fetchDeleteProjectTask,
fetchGetProjectExecutionAssignees,
fetchGetProjectTask,
fetchGetProjectTaskAssignees,
fetchGetProjectTaskPage,
fetchGetProjectTaskStatusBoard,
fetchInactiveProjectTaskAssignee,
fetchUpdateProjectTask
} from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
import { useUIPaginatedTable } from '@/hooks/common/table';
import { shouldRequireTaskProgressBeforeComplete } from '../shared';
import type { WorklogChangedPayload } from '../shared';
import { useTaskCompletionCascade } from '../composables/use-task-completion-cascade';
import { useTaskPermissions } from '../composables/use-task-permissions';
import ObjectDeleteDialog from './object-delete-dialog.vue';
import StatusActionDialog from './status-action-dialog.vue';
import TaskAssigneeDialog from './task-assignee-dialog.vue';
import TaskBoardView from './task-board-view.vue';
import TaskDetailDialog from './task-detail-dialog.vue';
import TaskOperateDialog from './task-operate-dialog.vue';
import TaskSearch from './task-search.vue';
import TaskTableView from './task-table-view.vue';
import TaskWorklogDialog from './task-worklog-dialog.vue';
import IconMdiViewColumnOutline from '~icons/mdi/view-column-outline';
import IconMdiTableLarge from '~icons/mdi/table-large';
@@ -30,13 +42,23 @@ type TaskStatusAction = Api.Project.LifecycleAction<Api.Project.ProjectTaskActio
interface Props {
projectId: string;
execution: Api.Project.ProjectExecution | null;
ownerOptions: Api.SystemManage.UserSimple[];
canCreate: boolean;
canUpdate: boolean;
canChangeStatus: boolean;
}
interface Emits {
(e: 'executionChanged'): void;
}
const props = defineProps<Props>();
const emit = defineEmits<Emits>();
const { canManageTaskAssignee } = useTaskPermissions();
const authStore = useAuthStore();
const currentUserId = computed(() => authStore.userInfo.userId || '');
// 当前执行的活跃协办人owner / 协办人 / 搜索过滤都用这份)
const executionAssigneeOptions = ref<Api.SystemManage.UserSimple[]>([]);
const viewMode = ref<ViewMode>('table');
const viewModeOptions = [
@@ -44,13 +66,31 @@ const viewModeOptions = [
{ label: '看板', value: 'board', icon: markRaw(IconMdiViewColumnOutline) }
];
const operateVisible = ref(false);
const detailVisible = ref(false);
const taskOperateDialogRef = ref<InstanceType<typeof TaskOperateDialog> | null>(null);
const statusActionVisible = ref(false);
const operateMode = ref<OperateMode>('create');
const currentTask = ref<Api.Project.ProjectTask | null>(null);
const presetParentTaskId = ref<string | null>(null);
const currentStatusAction = ref<TaskStatusAction | null>(null);
const taskStatusBoard = ref<Api.Project.StatusBoard | null>(null);
const pendingCascade = ref(false);
const assigneeDialogVisible = ref(false);
const currentAssigneeTask = ref<Api.Project.ProjectTask | null>(null);
const currentAssignees = ref<Api.Project.TaskAssigneeRef[]>([]);
const assigneesLoading = ref(false);
const worklogDialogVisible = ref(false);
const worklogDialogTask = ref<Api.Project.ProjectTask | null>(null);
const detailDialogVisible = ref(false);
const detailDialogTask = ref<Api.Project.ProjectTask | null>(null);
const detailDialogDefaultTab = ref<'info' | 'worklog'>('info');
const deleteTaskDialogVisible = ref(false);
const deleteTaskTarget = ref<Api.Project.ProjectTask | null>(null);
const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
pageNo: 1,
pageSize: 10,
@@ -63,6 +103,18 @@ const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
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 canLoadTasks = computed(() => Boolean(props.projectId && executionId.value));
const statusActionTitle = computed(() =>
@@ -157,6 +209,7 @@ function handleCreate() {
operateMode.value = 'create';
currentTask.value = null;
presetParentTaskId.value = null;
operateVisible.value = true;
}
@@ -184,8 +237,12 @@ async function handleEdit(row: Api.Project.ProjectTask) {
}
async function handleDetail(row: Api.Project.ProjectTask) {
currentTask.value = await getTaskDetail(row);
detailVisible.value = true;
const detail = await getTaskDetail(row);
detailDialogDefaultTab.value = 'info';
detailDialogTask.value = detail;
detailDialogVisible.value = true;
// 同步到 currentTask让 worklog tab 内提交后 handleWorklogChanged 能据此触发 cascade
currentTask.value = detail;
}
async function handleStatusAction(row: Api.Project.ProjectTask, action: TaskStatusAction | null) {
@@ -234,6 +291,8 @@ async function handleOperateSubmit(payload: Api.Project.SaveProjectTaskParams) {
window.$message?.success('任务更新成功');
}
// 业务保存成功才 commit删除用户在弹层里标记删除的附件
await taskOperateDialogRef.value?.commit();
operateVisible.value = false;
await getData();
}
@@ -257,6 +316,173 @@ async function handleStatusSubmit(reason: string | null) {
window.$message?.success('任务状态已更新');
statusActionVisible.value = false;
const wasCascade = pendingCascade.value;
const completedTask = currentTask.value;
pendingCascade.value = false;
await Promise.all([getData(), loadTaskStatusBoard()]);
emit('executionChanged');
if (wasCascade && completedTask) {
await cascade.onTaskCompleted(completedTask);
}
}
async function handleReport(row: Api.Project.ProjectTask) {
if (!props.execution) {
window.$message?.warning('请先选择执行项');
return;
}
const detail = await getTaskDetail(row);
currentTask.value = detail;
// 责任人 / 协办人统一走轻量弹层;内部按身份切换"全员记录 vs 仅自己"
// 责任人想看任务全貌请改走【任务名】打开的详情弹框
worklogDialogTask.value = detail;
worklogDialogVisible.value = true;
}
async function handleWorklogChanged(payload: WorklogChangedPayload) {
await Promise.all([getData(), loadTaskStatusBoard()]);
// 工时变化可能触发后端联动改任务状态、实际开始、进度等;
// 同步刷新当前打开弹层的 task 快照,避免用户必须关弹层重开才能看到新值
if (props.execution && currentTask.value?.id === payload.taskId) {
const refreshed = await getTaskDetail(currentTask.value);
if (refreshed.id === payload.taskId) {
currentTask.value = refreshed;
if (worklogDialogTask.value?.id === payload.taskId) {
worklogDialogTask.value = refreshed;
}
if (detailDialogTask.value?.id === payload.taskId) {
detailDialogTask.value = refreshed;
}
}
}
if (payload.mode === 'delete' || payload.progressRate !== 100) {
return;
}
const task = currentTask.value;
// 防御currentTask 不对应当前 worklog 任务(理论不会,但兜底)
if (!task || task.id !== payload.taskId || task.ownerId !== currentUserId.value) {
return;
}
await cascade.triggerAfterWorklog({ task, submittedProgress: payload.progressRate });
}
async function handleDetailWorklogChanged(payload: WorklogChangedPayload) {
await handleWorklogChanged(payload);
emit('executionChanged');
}
function handleWorklogDialogClosedAfterChange() {
emit('executionChanged');
}
async function handleAssigneeAdd(payload: Api.Project.CreateTaskAssigneeParams) {
if (!props.execution || !currentAssigneeTask.value) {
return;
}
const { error } = await fetchCreateProjectTaskAssignee(
props.projectId,
props.execution.id,
currentAssigneeTask.value.id,
payload
);
if (error) {
return;
}
window.$message?.success('协办人已加入');
await refreshAssigneesAfterMutation();
}
async function handleAssigneeInactive(
assignee: Api.Project.TaskAssigneeRef,
payload: Api.Project.InactiveTaskAssigneeParams
) {
if (!props.execution || !currentAssigneeTask.value) {
return;
}
const { error } = await fetchInactiveProjectTaskAssignee(
props.projectId,
props.execution.id,
currentAssigneeTask.value.id,
assignee.id,
payload
);
if (error) {
return;
}
window.$message?.success('协办人已失效');
await refreshAssigneesAfterMutation();
}
async function refreshAssigneesAfterMutation() {
if (!props.execution || !currentAssigneeTask.value) {
return;
}
assigneesLoading.value = true;
const { error, data: assigneeData } = await fetchGetProjectTaskAssignees(
props.projectId,
props.execution.id,
currentAssigneeTask.value.id
);
assigneesLoading.value = false;
currentAssignees.value = error || !assigneeData ? [] : assigneeData;
await getData();
}
async function loadExecutionAssigneeOptions() {
if (!canLoadTasks.value) {
executionAssigneeOptions.value = [];
return;
}
const { error, data: assignees } = await fetchGetProjectExecutionAssignees(props.projectId, executionId.value);
if (error || !assignees) {
executionAssigneeOptions.value = [];
return;
}
executionAssigneeOptions.value = assignees.map(item => ({
id: item.userId,
nickname: item.userNickname || item.userId,
username: null,
deptName: null
}));
}
function openDeleteTaskDialog(task: Api.Project.ProjectTask) {
deleteTaskTarget.value = task;
deleteTaskDialogVisible.value = true;
}
async function confirmDeleteTask(payload: { name: string; confirmText: string; reason: string }) {
const target = deleteTaskTarget.value;
if (!target) return;
const { error } = await fetchDeleteProjectTask(target.projectId, target.executionId, target.id, {
taskName: payload.name,
confirmText: payload.confirmText,
reason: payload.reason
});
if (error) return;
window.$message?.success('删除成功');
deleteTaskDialogVisible.value = false;
deleteTaskTarget.value = null;
await Promise.all([getData(), loadTaskStatusBoard()]);
}
@@ -283,10 +509,11 @@ watch(
if (!value) {
data.value = [];
taskStatusBoard.value = null;
executionAssigneeOptions.value = [];
return;
}
await Promise.all([getDataByPage(1), loadTaskStatusBoard()]);
await Promise.all([getDataByPage(1), loadTaskStatusBoard(), loadExecutionAssigneeOptions()]);
},
{ immediate: true }
);
@@ -314,7 +541,7 @@ watch(
<TaskSearch
:model="searchParams"
:user-options="ownerOptions"
:user-options="executionAssigneeOptions"
:status-options="taskStatusBoard?.items || []"
:disabled="!execution"
@search="handleSearch"
@@ -327,19 +554,17 @@ watch(
:data="data"
:loading="loading"
:pagination="mobilePagination"
:can-update="canUpdate"
:can-change-status="canChangeStatus"
@detail="handleDetail"
@edit="handleEdit"
@report="handleReport"
@status-action="handleStatusAction"
@delete="openDeleteTaskDialog"
/>
<TaskBoardView
v-else
:data="data"
:loading="loading"
:status-board="taskStatusBoard"
:can-update="canUpdate"
:can-change-status="canChangeStatus"
@detail="handleDetail"
@edit="handleEdit"
@status-action="handleStatusAction"
@@ -356,21 +581,60 @@ watch(
</div>
<TaskOperateDialog
ref="taskOperateDialogRef"
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="currentTask"
:user-options="ownerOptions"
:default-parent-task-id="presetParentTaskId"
:user-options="executionAssigneeOptions"
:task-options="taskOptions"
@submit="handleOperateSubmit"
/>
<TaskDetailDialog v-model:visible="detailVisible" :row-data="currentTask" />
<StatusActionDialog
v-model:visible="statusActionVisible"
:title="statusActionTitle"
:action="currentStatusAction"
@submit="handleStatusSubmit"
@update:visible="
value => {
if (!value) pendingCascade = false;
}
"
/>
<TaskAssigneeDialog
v-model:visible="assigneeDialogVisible"
:task="currentAssigneeTask"
:assignees="currentAssignees"
:user-options="executionAssigneeOptions"
:loading="assigneesLoading"
:can-manage="currentAssigneeTask ? canManageTaskAssignee(currentAssigneeTask) : false"
@add="handleAssigneeAdd"
@inactive="handleAssigneeInactive"
/>
<TaskWorklogDialog
v-model:visible="worklogDialogVisible"
:task="worklogDialogTask"
@changed="handleWorklogChanged"
@closed-after-change="handleWorklogDialogClosedAfterChange"
/>
<TaskDetailDialog
v-model:visible="detailDialogVisible"
:task="detailDialogTask"
:user-options="executionAssigneeOptions"
:task-options="taskOptions"
:default-tab="detailDialogDefaultTab"
@worklog-changed="handleDetailWorklogChanged"
/>
<ObjectDeleteDialog
v-model:visible="deleteTaskDialogVisible"
object-type="task"
:object-name="deleteTaskTarget?.taskTitle ?? ''"
:on-confirm="confirmDeleteTask"
/>
</section>
</template>

View File

@@ -3,9 +3,9 @@ import { getStatusTagType } from '@/constants/status-tag';
type ExecutionStatusCode = Api.Project.ProjectExecutionStatusCode;
type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
type ExecutionMemberActionType = Api.Project.ExecutionMemberActionType;
type ExecutionAssigneeActionType = Api.Project.ExecutionAssigneeActionType;
export const executionMemberActionNameMap: Record<ExecutionMemberActionType, string> = {
export const executionAssigneeActionNameMap: Record<ExecutionAssigneeActionType, string> = {
join: '加入',
inactive: '失效',
owner_transfer_in: '转入负责人',
@@ -23,7 +23,7 @@ export const EXECUTION_STATUS_ORDER = [
export const TASK_STATUS_ORDER = [
'pending',
'active',
'blocked',
'paused',
'completed',
'cancelled'
] as const satisfies readonly TaskStatusCode[];
@@ -39,7 +39,7 @@ export const executionStatusFallbackNameMap: Record<ExecutionStatusCode, string>
export const taskStatusFallbackNameMap: Record<TaskStatusCode, string> = {
pending: '待开始',
active: '进行中',
blocked: '已阻塞',
paused: '已暂停',
completed: '已完成',
cancelled: '已取消'
};
@@ -52,12 +52,12 @@ export function getTaskStatusTagType(statusCode: TaskStatusCode | string) {
return getStatusTagType('projectTask', statusCode);
}
export function getExecutionMemberActionTagType(actionType: ExecutionMemberActionType | string) {
return getStatusTagType('executionMember', actionType);
export function getExecutionAssigneeActionTagType(actionType: ExecutionAssigneeActionType | string) {
return getStatusTagType('executionAssignee', actionType);
}
export function getExecutionMemberActionName(actionType: ExecutionMemberActionType | string) {
return executionMemberActionNameMap[actionType as ExecutionMemberActionType] || actionType;
export function getExecutionAssigneeActionName(actionType: ExecutionAssigneeActionType | string) {
return executionAssigneeActionNameMap[actionType as ExecutionAssigneeActionType] || actionType;
}
export function formatDate(value: string | null | undefined) {
@@ -83,6 +83,59 @@ export function formatDateRange(startDate: string | null | undefined, endDate: s
return `${startText}${endText}`;
}
export function formatWorklogDateRange(startDate: string | null | undefined, endDate: string | null | undefined) {
if (!startDate || !endDate) {
return '--';
}
if (startDate === endDate) {
return formatDate(startDate);
}
return `${formatDate(startDate)} ~ ${formatDate(endDate)}`;
}
export type WorklogGranularity = 'day' | 'week';
/**
* 根据 worklog 段判定填报粒度前端规则startDate === endDate 视为日,否则视为周),并返回展示文案 + 悬浮提示。
* - day单日display = YYYY-MM-DDtooltip 为空
* - week跨日display = YYYY年第W周按 startDate 所在 ISO 周tooltip = 实际起止
*/
export function formatWorklogPeriod(
startDate: string | null | undefined,
endDate: string | null | undefined
): { granularity: WorklogGranularity | null; display: string; tooltip: string | null } {
if (!startDate || !endDate) {
return { granularity: null, display: '--', tooltip: null };
}
// 后端可能返回带时间段的字符串(如 "2026-05-10T00:00:00"),不能直接 `===`,先归一到 YYYY-MM-DD
const startKey = formatDate(startDate);
const endKey = formatDate(endDate);
if (startKey === endKey) {
const dayDate = dayjs(startDate);
const weekSuffix = dayDate.isValid() ? `(第${dayDate.isoWeek()}周)` : '';
return { granularity: 'day', display: `${startKey}${weekSuffix}`, tooltip: null };
}
const start = dayjs(startDate);
return {
granularity: 'week',
display: start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}` : `${startKey} ~ ${endKey}`,
tooltip: `${startKey} ~ ${endKey}`
};
}
const worklogGranularityNameMap: Record<WorklogGranularity, string> = {
day: '日',
week: '周'
};
export function getWorklogGranularityName(granularity: WorklogGranularity | null) {
return granularity ? worklogGranularityNameMap[granularity] : '--';
}
export function getExecutionStatusName(execution: Pick<Api.Project.ProjectExecution, 'statusCode' | 'statusName'>) {
return execution.statusName?.trim() || executionStatusFallbackNameMap[execution.statusCode] || execution.statusCode;
}
@@ -99,16 +152,51 @@ export function getProgressText(progressRate: number | null | undefined) {
return `${Math.min(100, Math.max(0, progressRate))}%`;
}
export function isActiveExecutionMember(member: Pick<Api.Project.ExecutionMember, 'joinedAt' | 'removedAt'>) {
if (!member.removedAt) {
export function isActiveExecutionAssignee(assignee: Pick<Api.Project.ExecutionAssignee, 'joinedAt' | 'removedAt'>) {
if (!assignee.removedAt) {
return true;
}
if (!member.joinedAt) {
if (!assignee.joinedAt) {
return false;
}
return dayjs(member.joinedAt).isAfter(dayjs(member.removedAt));
return dayjs(assignee.joinedAt).isAfter(dayjs(assignee.removedAt));
}
export const VIRTUAL_OWNER_ASSIGNEE_ID_PREFIX = 'virtual-owner-';
/**
* 如果真实协办人列表里没有执行负责人(即负责人未作为协办人入库),
* 在列表前置一条"虚拟负责人行",仅用于 UI 上让用户感知"负责人也在团队里"。
* 该行不会被发到后端,也不会被失效 / 设为负责人等真实操作选中。
*/
// eslint-disable-next-line max-params
export function withVirtualOwnerAssignee(
assignees: Api.Project.ExecutionAssignee[],
ownerId: string | null | undefined,
ownerNickname: string,
executionId: string
): Api.Project.ExecutionAssignee[] {
if (!ownerId) {
return assignees;
}
const ownerInList = assignees.some(item => item.userId === ownerId && isActiveExecutionAssignee(item));
if (ownerInList) {
return assignees;
}
const virtualOwner: Api.Project.ExecutionAssignee = {
id: `${VIRTUAL_OWNER_ASSIGNEE_ID_PREFIX}${ownerId}`,
executionId,
userId: ownerId,
userNickname: ownerNickname,
joinedAt: null,
removedAt: null,
removedReason: null
};
return [virtualOwner, ...assignees];
}
export function shouldRequireTaskProgressBeforeComplete(
@@ -117,3 +205,74 @@ export function shouldRequireTaskProgressBeforeComplete(
) {
return action.actionCode === 'complete' && task.progressRate !== 100;
}
/**
* 在当前页数据集合内判定是否叶子任务:当前页中没有任何任务把它当父任务。
* 跨页未覆盖时按"叶子"处理;后端 5.11 / 5.14 兜底拒错。
*/
export function isTaskLeafInList(row: Api.Project.ProjectTask, allRows: Api.Project.ProjectTask[]) {
return !allRows.some(item => item.parentTaskId === row.id);
}
/**
* 是否对该任务行展示「填报」入口(与后端 2026-05-11 工时填报矩阵对齐):
* - 已登录、当前页叶子、且当前用户是 owner / 活跃协作人为基础门槛
* - 待开始 / 进行中:负责人、协办人均可
* - 已暂停 / 已取消:双方均拒
* - 已完成:仅协办人可补登历史工时;负责人拦截(避免负责人填工时把进度改回低值)
*/
export function canReportTaskWorklog(
row: Api.Project.ProjectTask,
allRows: Api.Project.ProjectTask[],
currentUserId: string
) {
if (!currentUserId) {
return false;
}
if (!isTaskLeafInList(row, allRows)) {
return false;
}
const isOwner = row.ownerId === currentUserId;
const isActiveAssignee = Boolean(row.assignees?.some(item => item.userId === currentUserId));
if (!isOwner && !isActiveAssignee) {
return false;
}
switch (row.statusCode) {
case 'pending':
case 'active':
return true;
case 'completed':
return !isOwner && isActiveAssignee;
case 'paused':
case 'cancelled':
default:
return false;
}
}
type TaskAssigneeActionType = Api.Project.TaskAssigneeActionType;
export const taskAssigneeActionNameMap: Record<TaskAssigneeActionType, string> = {
join: '加入',
inactive: '失效'
};
export function getTaskAssigneeActionName(actionType: TaskAssigneeActionType | string) {
return taskAssigneeActionNameMap[actionType as TaskAssigneeActionType] || actionType;
}
export function getTaskAssigneeActionTagType(actionType: TaskAssigneeActionType | string) {
return getStatusTagType('taskAssigneeMember', actionType);
}
/** worklog 提交后通过 emit 链路向上透传的 payloadworkspace 据此判定是否触发完成级联 */
export interface WorklogChangedPayload {
/** 本次操作类型create / edit / delete */
mode: 'create' | 'edit' | 'delete';
/** 任务 idworklog 所属 task */
taskId: string;
/** 本次填报的进度0~100delete 模式不传 */
progressRate?: number;
}

View File

@@ -14,7 +14,7 @@ export const projectStatusOptions = transformRecordToOption(projectStatusRecord)
/** 项目状态动作编码与中文标签映射 */
export const projectStatusActionRecord: Record<Api.Project.ProjectStatusActionCode, string> = {
auto_start: '自动开始',
auto_start: '开始推进',
pause: '暂停项目',
resume: '恢复项目',
complete: '完成项目',