feat(projects): 1、执行、任务、工作日志开发调试;2、增加富文本、附件等支撑

This commit is contained in:
2026-05-12 21:41:39 +08:00
parent 28c47b14a3
commit 5615399a68
59 changed files with 8046 additions and 919 deletions

View File

@@ -7,7 +7,13 @@
"WebFetch(domain:raw.githubusercontent.com)",
"Bash(Remove-Item *)",
"PowerShell(pnpm typecheck *)",
"WebFetch(domain:www.wangeditor.com)"
"WebFetch(domain:www.wangeditor.com)",
"Bash(node *)",
"Bash(dir \"rdms-project-boot-*\")",
"Bash(git stash *)",
"Bash(pnpm eslint *)",
"Bash(Select-String -Pattern \"business-rich-text-editor|task-operate-dialog\")",
"Bash(powershell *)"
]
}
}

View File

@@ -11,6 +11,8 @@
默认回答保持精简,优先给结论、改动点、验证方式和必要风险;如果用户只要求分析、审阅或方案,就停留在分析层,不主动扩展到实现层。
分析、解释、方案类回答优先用业务和逻辑语言把结构、差异与结论说清楚,不要大段贴源码、罗列 `file:line` 或把实现细节当解释;只有用户明确要求看代码、或某行确实是讨论焦点的关键佐证时,才贴最小必要的代码片段。
## 交互与执行原则
- 进入实施阶段前,先说明目标、涉及模块、预计改动点和验证方式。
@@ -173,6 +175,31 @@
- 涉及路由、菜单、权限的改动时,同时检查 `build/plugins/router.ts``src/router/routes/*``src/store/modules/route/*` 和相关文档。
- 对于可再生的路由产物,优先修改源配置并执行 `pnpm gen-route`,不要把手工修补生成文件当成常规方案。
## 防重复提交(两层联防)
用户快速双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮内置无 loading 等场景,都可能让同一写操作发出多次。仓库采用两层防御,新增写操作功能时按顺序检查:
### 第一层:业务按钮的 loading 锁(视觉防御)
- 新增、编辑入口优先使用 `src/components/custom/business-form-dialog.vue``src/components/custom/business-form-drawer.vue`,它们在 `submit` 流程内 await 接口期间会自动将"确认"按钮置为 `loading` + `disabled`
- 不要裸手写 `<ElButton @click="submit">` 直接调接口;若必须使用裸 `ElButton`,需要自行绑定 `:loading` 并在 await 接口期间锁住按钮。
- 删除二次确认使用 `ElMessageBox.confirm` 时,其内部"确定"按钮没有 loading 能力,必须依赖第二层兜底,不要尝试改造 confirm 的内部按钮。
### 第二层:请求层全局去重(逻辑兜底)
- 入口:`src/service/request/dedupe.ts` 提供 `withDedupe`,已在 `src/service/request/index.ts` 包住统一的 `request` 实例;`demoRequest` 未启用。
- 指纹:`method + 完整 URL + 排序后的 params + 稳定序列化的 body`body 内对象按 key 排序,数组保序。
- 行为:写操作(`POST` / `PUT` / `DELETE` / `PATCH`)在第一次请求 pending 期内,若再次发起指纹相同的请求,自动复用第一次的 Promise不发出第二次实际请求调用方两次拿到完全相同的返回对象。
- 跳过条件(即不去重,按原逻辑发出):`GET` / `HEAD` / `OPTIONS`,请求体为 `FormData``Blob`(上传场景),调用方显式传 `{ dedupe: false }`
- 业务调用方零感知:新增接口默认即享受兜底,不需要在 `src/service/api/*` 或页面层做任何改动。
- 极少数业务确实允许短时间内并发提交完全相同的写请求时,在调用处显式传 `request({ ..., dedupe: false })` 单接口关闭。
- 兜底超时 30 秒:极端情况下若某次 Promise 未 settlepending 条目过期后下一次相同请求视为新请求,避免内存泄漏。
### 设计责任划分
- 视觉层负责"按下立刻锁住按钮"的用户感知;逻辑层负责"即使锁失败也只发一次"的实际接口保护。
- 不要因为有第二层兜底就省略第一层 loading 锁:用户没有视觉反馈会再次点击;也不要试图在业务页面再造一套请求去重逻辑。
## 运行时字典使用口径
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。

View File

@@ -10,6 +10,7 @@
- **描述现状以代码、配置、文档可直接验证的事实为准**;不引入历史实现/过渡方案/猜测。
- **默认精简回答**:先给结论 → 改动点 → 验证方式 → 必要风险。**除非用户主动要求详细,否则不要展开**——不复述清单、不列每条改动的小理由、不堆"汇总"段。用户只让分析就停在分析层,不主动跳到实现。
- **分析/解释类回答不要堆代码层面描述**:默认用业务/逻辑语言说清楚结构、差异与结论;不要大段贴源码、不要罗列 `file:line`、不要把"实现细节"当解释。只有用户明确要求看代码、或非贴不可的关键佐证(如某行就是争议焦点),才贴最少代码片段。
- **进入实施阶段前,先说目标、涉及模块、预计改动点、验证方式**。
- **最小改动原则**:只改当前任务必需的范围,不顺手重构无关代码。
- **不主动执行 git 操作**status/diff/add/commit/restore/reset/checkout 全部不主动跑),除非用户明确要求。识别用户改动优先用 Read 直接看文件。
@@ -368,3 +369,40 @@ pnpm preview # preview server (9725)
- 业务模块写薄包装,例如 `getExecutionStatusTagType(code) = getStatusTagType('projectExecution', code)`
- 新增对象域:在 `StatusDomain` 加枚举 + `statusTagTypeRegistry` 加对应 map调用方写一个 wrapper 即可。
- 后端契约:未来若状态字典返颜色字段,调用方优先取后端值,缺失时回退 helper前端兜底
---
## 19. 防重复提交(两层联防,强约束)
> 用户双击、键盘连按 Enter、`ElMessageBox.confirm` 的"确定"按钮无内置 loading 都会让同一写操作发多次。两层防御缺一不可。
### 两层各自的职责
| 层 | 谁负责 | 行为 |
|---|---|---|
| 视觉层 | `business-form-dialog.vue` / `business-form-drawer.vue` | submit 触发后立即把"确认"按钮置 loading + disabled挡住二次点击 |
| 逻辑层(兜底) | `src/service/request/dedupe.ts`(已通过 `withDedupe` 包住 `request` 实例) | 写操作 pending 期内复用同一 Promise不真正发出第二次请求 |
### 业务侧关注点
- **不要裸手写** `<ElButton @click="submit">` 调接口;用 `business-form-dialog` / `business-form-drawer` 包;非要用裸 `ElButton` 时**必须**自行绑 `:loading` 并在 await 期间锁住。
- **`ElMessageBox.confirm` 的"确定"按钮没 loading 能力**——不要尝试改它,靠第二层兜底就够。
- **新接口默认享受去重**,调用方零改动;不要在 `src/service/api/*` 或页面层再造一套去重。
### 去重生效边界
- 自动去重:`POST` / `PUT` / `DELETE` / `PATCH`
- 不去重:`GET` / `HEAD` / `OPTIONS`(避免误伤分页 / 多 widget 并发查询);请求体为 `FormData` / `Blob`(上传场景)。
- 单接口逃生口:`request({ ..., dedupe: false })`——极少用,仅当业务真允许短时间内连发完全相同的写请求。
- 兜底超时 30s保险丝防止 Promise 永不 settle 时内存泄漏。
### 指纹算法
`method 大写 | URL + 排序后的 params 序列化 | 稳定序列化的 body`。body 内对象按 key 排序、数组保序——保证调用顺序不同但参数等价的两次请求拿到同一指纹。
### 何时回到本节查
- 新建写操作页面 → 视觉层用对组件、不裸 `ElButton` 调接口
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住

View File

@@ -0,0 +1,718 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { ArrowDown, Delete, Document, Loading, Picture, QuestionFilled, Upload } from '@element-plus/icons-vue';
import { deleteFile, downloadFile, uploadFile } from '@/service/api/file';
defineOptions({ name: 'BusinessAttachmentUploader' });
interface Props {
/** 上传目录,传给后端 directory 字段 */
directory?: string;
/** 数量上限,默认 20与后端 AttachmentValidator 一致) */
max?: number;
/** 单文件大小上限 MB前端兜底最终由 /system/file/upload 拦截) */
maxFileSizeMB?: number;
disabled?: boolean;
/**
* 平铺模式:所有附件直接逐项渲染,不再做"首项 + 折叠浮层"。
* 用于本身已经在 popover / 详情卡片里展示,避免嵌套浮层。
*/
flat?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
directory: undefined,
max: 20,
maxFileSizeMB: 50,
disabled: false,
flat: false
});
const model = defineModel<Api.Project.AttachmentItem[]>({ default: () => [] });
/** 给用户看的简短分类hint 行展示) */
const ALLOWED_EXTENSIONS_HINT = '支持 PDF、Word、Excel、PPT、TXT/MD/CSV、图片、ZIP/RAR/7Z、MP3/MP4';
// 与后端 AttachmentValidator 白/黑名单保持一致5.16
const ALLOWED_EXTENSIONS = new Set([
'pdf',
'doc',
'docx',
'xls',
'xlsx',
'ppt',
'pptx',
'txt',
'md',
'csv',
'jpg',
'jpeg',
'png',
'gif',
'webp',
'bmp',
'zip',
'rar',
'7z',
'mp4',
'mp3'
]);
const FORBIDDEN_EXTENSIONS = new Set([
'exe',
'bat',
'cmd',
'sh',
'ps1',
'msi',
'dll',
'jar',
'war',
'php',
'jsp',
'asp',
'aspx',
'py',
'rb',
'pl',
'com',
'scr',
'vbs',
'js'
]);
interface PendingItem {
id: string;
name: string;
}
const pending = ref<PendingItem[]>([]);
const inputRef = ref<HTMLInputElement>();
const isUnmounting = ref(false);
/**
* 会话级清理账本:
* - originalIds: 弹层打开时已存在的 fileId编辑模式下来自 rowData.attachments
* 当前未在 commit/rollback 中直接读取(清理逻辑靠 addedIds 自己判定);
* 保留是为了让会话模型完整、便于后续扩展(如"撤销删除""仅删原有附件"等差异行为)。
* - addedIds: 本次会话内上传成功的 fileId
* - pendingDeleteIds: 用户在 UI 上点过"删除"的 fileId含 original 和 added 两类)
* - committed: commit() 调用后置 true阻止后续 rollback 误删
*
* UI 显示 = model已减去 pendingDelete 项)
* 真删时机commit() 删 pendingDeleterollback() 删 addedIds除非 committed
*/
interface UploadSession {
originalIds: Set<string>;
addedIds: Set<string>;
pendingDeleteIds: Set<string>;
committed: boolean;
}
const session = reactive<UploadSession>({
originalIds: new Set<string>(),
addedIds: new Set<string>(),
pendingDeleteIds: new Set<string>(),
committed: false
});
const totalCount = computed(() => model.value.length + pending.value.length);
const isFull = computed(() => totalCount.value >= props.max);
const hasUploading = computed(() => pending.value.length > 0);
const acceptExtensionsList = computed(() => Array.from(ALLOWED_EXTENSIONS).join(', '));
/**
* 列表区拆成"直接展示"和"折叠浮层"两组:
* - flat全部直接展示适合本身已在 popover 里)
* - 默认:首项直接展示,>1 时其余进入悬浮浮层
*/
const displayedAttachments = computed(() => (props.flat ? model.value : model.value.slice(0, 1)));
const popoverAttachments = computed(() => (props.flat || model.value.length <= 1 ? [] : model.value.slice(1)));
const IMAGE_EXTENSIONS = new Set(['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg']);
function isImage(item: Api.Project.AttachmentItem) {
if (item.contentType?.startsWith('image/')) {
return true;
}
return IMAGE_EXTENSIONS.has(getExtension(item.name));
}
interface ImagePreviewState {
visible: boolean;
urls: string[];
}
const imagePreview = reactive<ImagePreviewState>({
visible: false,
urls: []
});
function getExtension(name: string) {
const idx = name.lastIndexOf('.');
return idx > 0 ? name.slice(idx + 1).toLowerCase() : '';
}
function validateFile(file: File): string | null {
if (!file.name) {
return '文件名为空';
}
if (file.name.length > 255) {
return '文件名超过 255 字符';
}
const ext = getExtension(file.name);
if (!ext) {
return '文件缺少扩展名';
}
if (FORBIDDEN_EXTENSIONS.has(ext)) {
return `不允许上传 .${ext} 文件`;
}
if (!ALLOWED_EXTENSIONS.has(ext)) {
return `暂不支持 .${ext} 文件`;
}
if (file.size > props.maxFileSizeMB * 1024 * 1024) {
return `单文件不能超过 ${props.maxFileSizeMB}MB`;
}
return null;
}
function triggerSelect() {
if (props.disabled || isFull.value) {
return;
}
inputRef.value?.click();
}
async function handleFileChange(event: Event) {
const input = event.target as HTMLInputElement;
const files = Array.from(input.files || []);
input.value = '';
if (files.length === 0) {
return;
}
const remaining = props.max - totalCount.value;
if (files.length > remaining) {
window.$message?.warning(`最多还能上传 ${remaining} 个附件`);
return;
}
const validFiles: File[] = [];
files.forEach(file => {
const err = validateFile(file);
if (err) {
window.$message?.error(`${file.name}${err}`);
return;
}
validFiles.push(file);
});
if (validFiles.length === 0) {
return;
}
await Promise.all(validFiles.map(uploadOne));
}
async function uploadOne(file: File) {
const tempId = `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
pending.value = [...pending.value, { id: tempId, name: file.name }];
try {
const result = await uploadFile(file, props.directory);
if (result.error || !result.data) {
window.$message?.error(`${file.name}:上传失败`);
return;
}
const { id, url } = result.data;
// 组件已卸载用户上传过程中关弹层onBeforeUnmount 已跑过且看不到这个 id
// 这里立刻调删除,避免孤儿文件
if (isUnmounting.value) {
deleteFile(id).catch(() => {
// 已卸载场景下 console.warn 也访问不到 component scope这里静默吞掉
});
return;
}
model.value = [
...model.value,
{
fileId: id,
url,
name: file.name,
size: file.size,
contentType: file.type || undefined
}
];
session.addedIds.add(id);
} finally {
pending.value = pending.value.filter(item => item.id !== tempId);
}
}
function handleRemove(item: Api.Project.AttachmentItem) {
removeAttachmentByFileId(item.fileId);
}
async function fetchAsBlobUrl(item: Api.Project.AttachmentItem) {
const { data, error } = await downloadFile(item.fileId);
if (error || !data) {
window.$message?.error(`${item.name}:加载失败`);
return null;
}
return URL.createObjectURL(data);
}
async function handleDownload(item: Api.Project.AttachmentItem) {
const blobUrl = await fetchAsBlobUrl(item);
if (!blobUrl) {
return;
}
const link = document.createElement('a');
link.href = blobUrl;
link.download = item.name;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(blobUrl);
}
async function handlePreviewImage(item: Api.Project.AttachmentItem) {
const blobUrl = await fetchAsBlobUrl(item);
if (!blobUrl) {
return;
}
imagePreview.urls = [blobUrl];
imagePreview.visible = true;
}
function handleClosePreview() {
imagePreview.urls.forEach(url => URL.revokeObjectURL(url));
imagePreview.urls = [];
imagePreview.visible = false;
}
/** 文件名点击的统一入口:图片走预览,其余走下载 */
function handleOpen(item: Api.Project.AttachmentItem) {
if (isImage(item)) {
handlePreviewImage(item);
} else {
handleDownload(item);
}
}
/** 把 model 里的某项移除(折叠浮层里也用,不依赖索引) */
function removeAttachmentByFileId(fileId: string) {
if (props.disabled) {
return;
}
const idx = model.value.findIndex(item => item.fileId === fileId);
if (idx === -1) {
return;
}
session.pendingDeleteIds.add(fileId);
model.value = model.value.filter((_, i) => i !== idx);
}
function formatSize(size?: number) {
if (!size && size !== 0) {
return '';
}
if (size < 1024) {
return `${size}B`;
}
if (size < 1024 * 1024) {
return `${(size / 1024).toFixed(1)}KB`;
}
if (size < 1024 * 1024 * 1024) {
return `${(size / 1024 / 1024).toFixed(1)}MB`;
}
return `${(size / 1024 / 1024 / 1024).toFixed(2)}GB`;
}
/**
* 删除一批 fileId。fire-and-forget
* - 不阻塞 UI任何失败仅 console.warn
* - 后端返回 1001003001文件不存在视为成功
*/
async function deleteMany(ids: string[]) {
if (ids.length === 0) {
return;
}
await Promise.allSettled(
ids.map(async id => {
const { error } = await deleteFile(id);
if (error) {
// eslint-disable-next-line no-console
console.warn('[BusinessAttachmentUploader] 删除失败(已忽略)', id, error);
}
})
);
}
/** 等关闭弹层时先等再清理。设上限 5s避免极端网络下 commit/rollback 永久挂起。 */
async function waitForPending(maxWaitMs = 5000) {
const start = Date.now();
while (pending.value.length > 0) {
if (Date.now() - start >= maxWaitMs) {
// eslint-disable-next-line no-console
console.warn('[BusinessAttachmentUploader] 等待 pending 上传超时,继续后续清理');
return;
}
// polling: 需要在循环里 awaitsuppress 即可
// eslint-disable-next-line no-await-in-loop
await new Promise<void>(resolve => {
setTimeout(resolve, 50);
});
}
}
defineExpose({
/**
* 父组件在【打开弹层并填充 model 之后】调用。
* 把当前 model 视为 original清空 added / pendingDelete重置 committed。
*/
initSession() {
session.originalIds = new Set(model.value.map(item => item.fileId));
session.addedIds.clear();
session.pendingDeleteIds.clear();
session.committed = false;
},
/**
* 父组件在【业务保存成功后】调用。
* 真删 pendingDelete含 original 和 added 两类);置 committed 阻止后续 rollback。
*/
async commit() {
await waitForPending();
const ids = Array.from(session.pendingDeleteIds);
session.pendingDeleteIds.clear();
session.addedIds.clear();
session.committed = true;
await deleteMany(ids);
},
/**
* 父组件取消/关闭时调用onBeforeUnmount 也会兜底调一次。
* 真删 addedIds保留 originalcommitted=true 时跳过。
*/
async rollback() {
if (session.committed) {
return;
}
await waitForPending();
const ids = Array.from(session.addedIds);
session.addedIds.clear();
session.pendingDeleteIds.clear();
session.committed = true;
await deleteMany(ids);
},
/** 父组件在提交前可读此值判断是否还有 pending 上传 */
get hasUploading() {
return hasUploading.value;
}
});
onBeforeUnmount(() => {
// 标记卸载中:让正在 flight 的 uploadOne 完成时知道要立刻删除自己
isUnmounting.value = true;
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
// deleteMany 内部已 swallow 单项失败,这里不再 awaitfire-and-forget
if (!session.committed) {
// eslint-disable-next-line @typescript-eslint/no-floating-promises
deleteMany(Array.from(session.addedIds));
}
});
</script>
<template>
<div class="business-attachment-uploader">
<div v-if="!disabled" class="business-attachment-uploader__trigger">
<ElButton :icon="Upload" :disabled="isFull" :loading="hasUploading" @click="triggerSelect">点击上传</ElButton>
<span class="business-attachment-uploader__hint">
最多 {{ max }} 已选 {{ totalCount }} 单文件 {{ maxFileSizeMB }}MB
<ElTooltip placement="top">
<template #content>
<div class="business-attachment-uploader__hint-tooltip">
<div>{{ ALLOWED_EXTENSIONS_HINT }}</div>
<div class="business-attachment-uploader__hint-tooltip-ext">允许扩展名{{ acceptExtensionsList }}</div>
</div>
</template>
<ElIcon class="business-attachment-uploader__hint-icon"><QuestionFilled /></ElIcon>
</ElTooltip>
</span>
<input
ref="inputRef"
type="file"
multiple
class="business-attachment-uploader__input"
@change="handleFileChange"
/>
</div>
<div v-else-if="totalCount === 0" class="business-attachment-uploader__empty">暂无附件</div>
<ul v-if="totalCount > 0" class="business-attachment-uploader__list">
<!-- 直接展示默认仅首项flat 模式全部 -->
<li v-for="item in displayedAttachments" :key="`done-${item.fileId}`" class="business-attachment-uploader__item">
<ElIcon class="business-attachment-uploader__icon">
<Picture v-if="isImage(item)" />
<Document v-else />
</ElIcon>
<ElLink
type="primary"
:underline="false"
class="business-attachment-uploader__name"
:title="item.name"
@click="handleOpen(item)"
>
{{ item.name }}
</ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li>
<!-- 折叠提示>1 个时显示hover 弹完整列表flat 模式下永不出现 -->
<li v-if="popoverAttachments.length > 0" class="business-attachment-uploader__more-row">
<ElPopover
trigger="hover"
placement="bottom-start"
:width="380"
:show-after="200"
popper-class="business-attachment-uploader__popover"
>
<template #reference>
<span class="business-attachment-uploader__more">
还有 {{ popoverAttachments.length }} 个附件
<ElIcon><ArrowDown /></ElIcon>
</span>
</template>
<ul class="business-attachment-uploader__popover-list">
<li
v-for="item in popoverAttachments"
:key="`popover-${item.fileId}`"
class="business-attachment-uploader__item"
>
<ElIcon class="business-attachment-uploader__icon">
<Picture v-if="isImage(item)" />
<Document v-else />
</ElIcon>
<ElLink
type="primary"
:underline="false"
class="business-attachment-uploader__name"
:title="item.name"
@click="handleOpen(item)"
>
{{ item.name }}
</ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span>
<ElLink type="primary" :underline="false" @click="handleDownload(item)">下载</ElLink>
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li>
</ul>
</ElPopover>
</li>
<!-- pending 项不折叠让用户能持续看到上传进度 -->
<li
v-for="item in pending"
:key="`pending-${item.id}`"
class="business-attachment-uploader__item business-attachment-uploader__item--pending"
>
<ElIcon class="business-attachment-uploader__icon is-loading"><Loading /></ElIcon>
<span class="business-attachment-uploader__name" :title="item.name">{{ item.name }}</span>
<span class="business-attachment-uploader__status">上传中</span>
</li>
</ul>
<ElImageViewer
v-if="imagePreview.visible"
:url-list="imagePreview.urls"
hide-on-click-modal
@close="handleClosePreview"
/>
</div>
</template>
<style scoped lang="scss">
.business-attachment-uploader {
display: flex;
flex-direction: column;
gap: 8px;
}
.business-attachment-uploader__trigger {
display: flex;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.business-attachment-uploader__hint {
display: inline-flex;
align-items: center;
gap: 4px;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__hint-icon {
color: rgb(100 116 139 / 88%);
cursor: help;
font-size: 14px;
}
.business-attachment-uploader__hint-tooltip {
display: flex;
flex-direction: column;
gap: 4px;
max-width: 320px;
line-height: 1.5;
}
.business-attachment-uploader__hint-tooltip-ext {
word-break: break-all;
opacity: 0.85;
}
.business-attachment-uploader__empty {
color: rgb(100 116 139 / 88%);
font-size: 13px;
}
.business-attachment-uploader__input {
display: none;
}
.business-attachment-uploader__list {
display: flex;
flex-direction: column;
gap: 4px;
margin: 0;
padding: 0;
list-style: none;
}
.business-attachment-uploader__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
border: 1px solid var(--el-border-color-lighter);
border-radius: 4px;
background: var(--el-fill-color-blank);
font-size: 13px;
&--pending {
background: var(--el-fill-color-light);
color: rgb(100 116 139 / 88%);
}
}
.business-attachment-uploader__icon {
flex: 0 0 auto;
color: var(--el-color-primary);
}
.business-attachment-uploader__name {
flex: 1 1 auto;
min-width: 0;
justify-content: flex-start;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.business-attachment-uploader__size {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__status {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
.business-attachment-uploader__more-row {
display: flex;
align-items: center;
padding: 2px 0;
}
.business-attachment-uploader__more {
display: inline-flex;
align-items: center;
gap: 2px;
color: var(--el-color-primary);
font-size: 13px;
cursor: pointer;
user-select: none;
&:hover {
text-decoration: underline;
}
}
</style>
<style lang="scss">
// 浮层非 scopedpopper 渲染到 body
.business-attachment-uploader__popover {
padding: 8px 4px !important;
.business-attachment-uploader__popover-list {
display: flex;
flex-direction: column;
gap: 4px;
max-height: 280px;
margin: 0;
padding: 0;
list-style: none;
overflow-y: auto;
}
.business-attachment-uploader__item {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 8px;
border-radius: 4px;
font-size: 13px;
&:hover {
background: var(--el-fill-color-light);
}
}
.business-attachment-uploader__icon {
flex: 0 0 auto;
color: var(--el-color-primary);
}
.business-attachment-uploader__name {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.business-attachment-uploader__size {
flex: 0 0 auto;
color: rgb(100 116 139 / 88%);
font-size: 12px;
}
}
</style>

View File

@@ -1,9 +1,10 @@
<script setup lang="ts">
import { computed, onBeforeUnmount, shallowRef, watch } from 'vue';
import { computed, onBeforeUnmount, reactive, ref, shallowRef, watch } from 'vue';
import '@wangeditor/editor/dist/css/style.css';
import { ElImageViewer } from 'element-plus';
import { Editor, Toolbar } from '@wangeditor/editor-for-vue';
import type { IDomEditor, IEditorConfig, IToolbarConfig } from '@wangeditor/editor';
import { uploadFile } from '@/service/api/file';
import { deleteFile, uploadFile } from '@/service/api/file';
defineOptions({ name: 'BusinessRichTextEditor' });
@@ -28,6 +29,140 @@ const props = withDefaults(defineProps<Props>(), {
const model = defineModel<string | null | undefined>({ default: '' });
const editorRef = shallowRef<IDomEditor>();
const containerRef = ref<HTMLElement>();
/**
* 图片预览:
* - hover 富文本里的 <img> → 在图片右上角浮一个放大镜按钮
* - 点按钮 → ElImageViewer 多图模式url-list = 当前 HTML 里所有 img src按出现顺序去重
* - 编辑态与 disabled 只读态共用
*/
const zoomBtnVisible = ref(false);
const zoomBtnStyle = ref<Record<string, string>>({});
const hoveredImageSrc = ref('');
const viewerVisible = ref(false);
const viewerUrlList = ref<string[]>([]);
const viewerIndex = ref(0);
let hideZoomBtnTimer: number | undefined;
function cancelHideZoomBtn() {
if (hideZoomBtnTimer !== undefined) {
window.clearTimeout(hideZoomBtnTimer);
hideZoomBtnTimer = undefined;
}
}
function scheduleHideZoomBtn() {
cancelHideZoomBtn();
hideZoomBtnTimer = window.setTimeout(() => {
zoomBtnVisible.value = false;
}, 150);
}
function positionZoomBtn(img: HTMLImageElement) {
const container = containerRef.value;
if (!container) {
return;
}
const containerRect = container.getBoundingClientRect();
const imgRect = img.getBoundingClientRect();
const btnSize = 28;
const gap = 8;
zoomBtnStyle.value = {
top: `${imgRect.top - containerRect.top + gap}px`,
left: `${imgRect.right - containerRect.left - btnSize - gap}px`
};
hoveredImageSrc.value = img.getAttribute('src') ?? '';
zoomBtnVisible.value = true;
}
function isZoomBtn(el: EventTarget | null): boolean {
return el instanceof HTMLElement && Boolean(el.closest('.business-rich-text-editor__zoom-btn'));
}
function findImageAtPoint(e: MouseEvent): HTMLImageElement | null {
const container = containerRef.value;
if (!container) {
return null;
}
const target = e.target as HTMLElement | null;
// 1) target 本身或祖先链上是 img
const direct =
target?.tagName === 'IMG' ? (target as HTMLImageElement) : (target?.closest('img') as HTMLImageElement | null);
if (direct && container.contains(direct)) {
return direct;
}
// 2) 兜底wangeditor 可能在图片上层叠了 resize/selection 遮罩target 不是 img用坐标穿透找
if (typeof document.elementsFromPoint === 'function') {
const stack = document.elementsFromPoint(e.clientX, e.clientY);
for (const el of stack) {
if (el.tagName === 'IMG' && container.contains(el)) {
return el as HTMLImageElement;
}
}
}
return null;
}
function onContainerMouseOver(e: MouseEvent) {
if (isZoomBtn(e.target)) {
cancelHideZoomBtn();
return;
}
const img = findImageAtPoint(e);
if (img) {
cancelHideZoomBtn();
positionZoomBtn(img);
} else {
scheduleHideZoomBtn();
}
}
function onContainerMouseLeave() {
scheduleHideZoomBtn();
}
function onTextScroll() {
// wangeditor 内部滚动后按钮坐标会和图片错位,直接隐藏由下次 hover 重算
zoomBtnVisible.value = false;
}
function openImageViewer() {
if (!hoveredImageSrc.value) {
return;
}
const urls = listImageSrcs(model.value);
const idx = urls.indexOf(hoveredImageSrc.value);
viewerUrlList.value = urls.length > 0 ? urls : [hoveredImageSrc.value];
viewerIndex.value = idx >= 0 ? idx : 0;
viewerVisible.value = true;
}
function closeImageViewer() {
viewerVisible.value = false;
}
/**
* 会话级清理账本(富文本图片治标):
* - uploadedMap: 本次会话内通过 customUpload 上传成功的图片 url -> fileId
* - committed: commit() 调用后置 true阻止后续 rollback / 卸载兜底重复删
*
* 真删时机:
* - commit(): 扫当前 model HTML删 uploadedMap 里"url 已不在 HTML"的项(被用户删掉的图)
* - rollback(): 删 uploadedMap 里所有项(整个会话不要了)
* - onBeforeUnmount: 兜底走 rollback 等价逻辑
*/
interface RichTextSession {
uploadedMap: Map<string, string>;
committed: boolean;
}
const session = reactive<RichTextSession>({
uploadedMap: new Map(),
committed: false
});
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: [
@@ -63,7 +198,9 @@ const editorConfig: Partial<IEditorConfig> = {
return;
}
const url = result.data;
const { id, url } = result.data;
// 记录 url -> fileId后续 commit/rollback 才知道删哪个
session.uploadedMap.set(url, id);
insertFn(url, file.name, url);
}
}
@@ -88,9 +225,116 @@ watch(
function handleCreated(editor: IDomEditor) {
editorRef.value = editor;
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
textContainer?.addEventListener('scroll', onTextScroll, { passive: true });
}
/**
* 从 HTML 字符串里抓所有 <img src="...">,返回 url 集合。
* 用 regex 而不是 DOMParser 是为了避免对 SSR / 测试环境的依赖。
*/
function extractImageUrls(html: string | null | undefined): Set<string> {
const urls = new Set<string>();
if (!html) {
return urls;
}
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null = re.exec(html);
while (match !== null) {
urls.add(match[1]);
match = re.exec(html);
}
return urls;
}
/** 按出现顺序去重列出当前 HTML 内所有 img src给 ElImageViewer 用。 */
function listImageSrcs(html: string | null | undefined): string[] {
const list: string[] = [];
if (!html) {
return list;
}
const re = /<img\b[^>]*\bsrc=["']([^"']+)["'][^>]*>/gi;
let match: RegExpExecArray | null = re.exec(html);
while (match !== null) {
if (!list.includes(match[1])) {
list.push(match[1]);
}
match = re.exec(html);
}
return list;
}
/** 删除一批 fileId。fire-and-forget单项失败仅 console.warn。 */
async function deleteMany(ids: string[]) {
if (ids.length === 0) {
return;
}
await Promise.allSettled(
ids.map(async id => {
const { error } = await deleteFile(id);
if (error) {
// eslint-disable-next-line no-console
console.warn('[BusinessRichTextEditor] 删除失败(已忽略)', id, error);
}
})
);
}
defineExpose({
/**
* 父组件在【打开弹层并填充 model 之后】调用。
* 清空 uploadedMap 并重置 committedHTML 里已有的图(编辑模式回显的)不进 uploadedMap
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
*/
initSession() {
session.uploadedMap.clear();
session.committed = false;
},
/**
* 父组件在【业务保存成功后】调用。
* 扫当前 model HTMLuploadedMap 里 url 不在 HTML 的图 = 用户已删除 = 真删。
*/
async commit() {
const currentUrls = extractImageUrls(model.value);
const toDelete: string[] = [];
session.uploadedMap.forEach((fileId, url) => {
if (!currentUrls.has(url)) {
toDelete.push(fileId);
}
});
session.uploadedMap.clear();
session.committed = true;
await deleteMany(toDelete);
},
/**
* 父组件取消/关闭时调用onBeforeUnmount 也会兜底调一次。
* 删 uploadedMap 里所有项(整个会话回滚)。
*/
async rollback() {
if (session.committed) {
return;
}
const toDelete = Array.from(session.uploadedMap.values());
session.uploadedMap.clear();
session.committed = true;
await deleteMany(toDelete);
}
});
onBeforeUnmount(() => {
cancelHideZoomBtn();
const textContainer = containerRef.value?.querySelector('.w-e-text-container');
textContainer?.removeEventListener('scroll', onTextScroll);
// 兜底:用户没显式 rollback 就直接关弹层 / 切路由 / unmount
if (!session.committed) {
const toDelete = Array.from(session.uploadedMap.values());
session.uploadedMap.clear();
// eslint-disable-next-line @typescript-eslint/no-floating-promises
deleteMany(toDelete);
}
editorRef.value?.destroy();
editorRef.value = undefined;
});
@@ -116,7 +360,7 @@ const editorStyle = computed(() => {
</script>
<template>
<div :class="containerClass">
<div ref="containerRef" :class="containerClass" @mouseover="onContainerMouseOver" @mouseleave="onContainerMouseLeave">
<Toolbar
class="business-rich-text-editor__toolbar"
:editor="editorRef"
@@ -131,11 +375,36 @@ const editorStyle = computed(() => {
mode="default"
@on-created="handleCreated"
/>
<button
v-show="zoomBtnVisible"
type="button"
class="business-rich-text-editor__zoom-btn"
:style="zoomBtnStyle"
title="预览图片"
aria-label="预览图片"
@click.stop="openImageViewer"
>
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor" aria-hidden="true">
<path
d="M10 2a8 8 0 1 1-5.29 14.04L1.4 19.36a1 1 0 1 1-1.4-1.4l3.32-3.32A8 8 0 0 1 10 2zm0 2a6 6 0 1 0 0 12 6 6 0 0 0 0-12zm1 3v2h2v2h-2v2H9v-2H7V9h2V7h2z"
/>
</svg>
</button>
<ElImageViewer
v-if="viewerVisible"
:url-list="viewerUrlList"
:initial-index="viewerIndex"
:z-index="3100"
teleported
hide-on-click-modal
@close="closeImageViewer"
/>
</div>
</template>
<style scoped lang="scss">
.business-rich-text-editor {
position: relative;
width: 100%;
border: 1px solid var(--el-border-color);
border-radius: var(--el-border-radius-base);
@@ -157,6 +426,27 @@ const editorStyle = computed(() => {
height: 100%;
min-height: 0;
}
&__zoom-btn {
position: absolute;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
border: none;
border-radius: 4px;
background: rgba(0, 0, 0, 0.55);
color: #fff;
cursor: pointer;
transition: background 0.15s;
z-index: 10;
&:hover {
background: rgba(0, 0, 0, 0.75);
}
}
}
/* wangeditor 弹层(链接、图片菜单等)默认 z-index 偏低,提高一档避免被 ElDialog 遮挡 */

View File

@@ -89,3 +89,25 @@ export const postTypeRecord: Record<Api.SystemManage.PostType, string> = {
};
export const postTypeOptions = transformRecordToOption(postTypeRecord);
/**
* 产品对象域角色编码:产品经理
*
* 用途:
* 产品创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
* 返回的角色列表中反查产品经理角色 ID作为默认经理成员行的 roleId 提交。
*
* 来源口径:后端约定的产品对象域内置角色稳定 code。code 变更需同步前端常量。
*/
export const PRODUCT_MANAGER_ROLE_CODE = 'product_manager';
/**
* 项目对象域角色编码:项目经理
*
* 用途:
* 项目创建两步向导第 2 步初始化团队时,前端按本 code 在 fetchGetRoleSimpleList
* 返回的角色列表中反查项目经理角色 ID作为默认经理成员行的 roleId 提交。
*
* 来源口径:后端约定的项目对象域内置角色稳定 code。code 变更需同步前端常量。
*/
export const PROJECT_MANAGER_ROLE_CODE = 'project_manager';

View File

@@ -10,7 +10,8 @@ export type StatusTagType = 'primary' | 'success' | 'warning' | 'info' | 'danger
export type StatusDomain =
| 'projectExecution'
| 'projectTask'
| 'executionMember'
| 'executionAssignee'
| 'taskAssigneeMember'
| 'project'
| 'product'
| 'requirement'
@@ -29,17 +30,22 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
projectTask: {
pending: 'info',
active: 'primary',
blocked: 'warning',
paused: 'warning',
completed: 'success',
cancelled: 'danger'
},
// 执行成员变更事件
executionMember: {
// 执行协办人变更事件
executionAssignee: {
join: 'success',
inactive: 'danger',
owner_transfer_in: 'warning',
owner_transfer_out: 'warning'
},
// 任务协办人变更事件
taskAssigneeMember: {
join: 'success',
inactive: 'danger'
},
// 项目(待补全)
project: {},
// 产品(待补全)

View File

@@ -12,6 +12,8 @@ const authStore = useAuthStore();
const { routerPushByKey, toLogin } = useRouterPush();
const { SvgIconVNode } = useSvgIcon();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName);
function loginOrRegister() {
toLogin();
}
@@ -84,7 +86,7 @@ function handleDropdown(key: DropdownKey) {
</template>
<div class="flex items-center">
<SvgIcon icon="ph:user-circle" class="mr-5px text-icon-large" />
<span class="text-16px font-medium">{{ authStore.userInfo.userName }}</span>
<span class="text-16px font-medium">{{ displayName }}</span>
</div>
</ElDropdown>
</template>

View File

@@ -1,9 +1,11 @@
import { extend } from 'dayjs';
import isoWeek from 'dayjs/plugin/isoWeek';
import localeData from 'dayjs/plugin/localeData';
import { setDayjsLocale } from '../locales/dayjs';
export function setupDayjs() {
extend(localeData);
extend(isoWeek);
setDayjsLocale();
}

View File

@@ -14,6 +14,7 @@ interface BackendLoginToken {
interface BackendUserInfoDTO {
userId: string | number;
userName?: string | null;
nickname?: string | null;
roles?: string[] | null;
buttons?: string[] | null;
}
@@ -32,6 +33,7 @@ function mapUserInfo(data: BackendUserInfoDTO): Api.Auth.UserInfo {
return {
userId: String(data.userId ?? ''),
userName: data.userName ?? '',
nickname: data.nickname ?? '',
roles: data.roles ?? [],
buttons: data.buttons ?? []
};

View File

@@ -3,6 +3,13 @@ import { request } from '../request';
const FILE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/file`;
export interface UploadFileResult {
/** infra_file.id 的字符串形式(避免 Long 精度丢失) */
id: string;
/** 文件访问 URL私有桶带签名、公开桶裸 URL */
url: string;
}
/** 上传文件(模式一:后端中转) */
export function uploadFile(file: File, directory?: string) {
const formData = new FormData();
@@ -11,9 +18,38 @@ export function uploadFile(file: File, directory?: string) {
formData.append('directory', directory);
}
return request<string>({
return request<UploadFileResult>({
url: `${FILE_PREFIX}/upload`,
method: 'post',
data: formData
});
}
/**
* 删除文件
*
* 业务表单"取消/关闭/标记删除"场景调用本接口清理孤儿文件。
* 删除已不存在的文件(后端返回错误码 `1001003001`)应由调用方视为成功并吞掉。
*/
export function deleteFile(id: string) {
return request<boolean>({
url: `${FILE_PREFIX}/delete`,
method: 'delete',
params: { id }
});
}
/**
* 下载文件(流)
*
* 走后端代理接口 `/system/file/download?id=xxx`,由后端读取对象存储并以字节流返回。
* 私有桶下不要直接打开 `infra_file.url`,签名地址会过期。
*/
export function downloadFile(id: string) {
return request<Blob, 'blob'>({
url: `${FILE_PREFIX}/download`,
method: 'get',
params: { id },
responseType: 'blob'
});
}

View File

@@ -139,6 +139,18 @@ export async function fetchCreateProduct(data: Api.Product.SaveProductParams) {
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 创建产品(含初始团队,原子接口) */
export async function fetchCreateProductWithTeam(data: Api.Product.CreateProductWithTeamParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PRODUCT_PREFIX}/create-with-team`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 鏇存柊浜у搧 */
export function fetchUpdateProduct(data: Api.Product.UpdateProductParams) {
return request<boolean>({

View File

@@ -36,14 +36,14 @@ export type ProjectExecutionResponse = Omit<
progressRate?: number | null;
};
export type ExecutionMemberResponse = Omit<Api.Project.ExecutionMember, 'id' | 'executionId' | 'userId'> & {
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
id: StringIdResponse;
executionId: StringIdResponse;
userId: StringIdResponse;
};
export type ExecutionMemberLogResponse = Omit<
Api.Project.ExecutionMemberLog,
export type ExecutionAssigneeLogResponse = Omit<
Api.Project.ExecutionAssigneeLog,
'id' | 'executionId' | 'userId' | 'operatorUserId'
> & {
id: StringIdResponse;
@@ -52,6 +52,55 @@ export type ExecutionMemberLogResponse = Omit<
operatorUserId: StringIdResponse;
};
type TaskAssigneeRefResponse = Omit<Api.Project.TaskAssigneeRef, 'id' | 'userId'> & {
id: StringIdResponse;
userId: StringIdResponse;
};
/**
* 后端 attachments 项的兼容形态:历史/当前响应字段名是 `id`,前端类型统一用 `fileId`。
* normalizeAttachments 负责把两者归一成 `fileId`。
*/
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
fileId?: StringIdResponse;
id?: StringIdResponse;
};
function normalizeAttachments(list?: AttachmentItemResponse[] | null): Api.Project.AttachmentItem[] | null {
if (!list) {
return null;
}
return list.map(item => {
const rawId = item.fileId ?? item.id;
return {
...item,
fileId: rawId === null || rawId === undefined ? '' : String(rawId)
};
});
}
/**
* 5.6 单独接口返的协办人字段(与 5.3 嵌入字段命名口径不一致:返 userNickname 而非 nickname
* 经 normalizeTaskAssignee 归一化后对外统一为 Api.Project.TaskAssigneeRef。
*/
export type TaskAssigneeFromApiResponse = {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
userNickname?: string | null;
joinedAt?: string | null;
};
export type TaskAssigneeLogResponse = Omit<
Api.Project.TaskAssigneeLog,
'id' | 'taskId' | 'userId' | 'operatorUserId'
> & {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
operatorUserId: StringIdResponse;
};
export type ProjectTaskResponse = Omit<
Api.Project.ProjectTask,
| 'id'
@@ -65,6 +114,8 @@ export type ProjectTaskResponse = Omit<
| 'actualStartDate'
| 'actualEndDate'
| 'progressRate'
| 'assignees'
| 'attachments'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
@@ -77,6 +128,16 @@ export type ProjectTaskResponse = Omit<
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
progressRate?: number | null;
assignees?: TaskAssigneeRefResponse[] | null;
attachments?: AttachmentItemResponse[] | null;
totalSpentHours?: number | null;
};
export type TaskWorklogResponse = Omit<Api.Project.TaskWorklog, 'id' | 'taskId' | 'userId' | 'attachments'> & {
id: StringIdResponse;
taskId: StringIdResponse;
userId: StringIdResponse;
attachments?: AttachmentItemResponse[] | null;
};
export interface ProjectMemberResponse {
@@ -194,7 +255,7 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
};
}
export function normalizeExecutionMember(response: ExecutionMemberResponse): Api.Project.ExecutionMember {
export function normalizeExecutionAssignee(response: ExecutionAssigneeResponse): Api.Project.ExecutionAssignee {
return {
...response,
id: normalizeStringId(response.id),
@@ -207,7 +268,9 @@ export function normalizeExecutionMember(response: ExecutionMemberResponse): Api
};
}
export function normalizeExecutionMemberLog(response: ExecutionMemberLogResponse): Api.Project.ExecutionMemberLog {
export function normalizeExecutionAssigneeLog(
response: ExecutionAssigneeLogResponse
): Api.Project.ExecutionAssigneeLog {
return {
...response,
id: normalizeStringId(response.id),
@@ -239,6 +302,49 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
taskDesc: response.taskDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null
lastStatusReason: response.lastStatusReason ?? null,
assignees:
response.assignees?.map(item => ({
id: normalizeStringId(item.id),
userId: normalizeStringId(item.userId),
nickname: item.nickname ?? ''
})) ?? null,
attachments: normalizeAttachments(response.attachments),
totalSpentHours: response.totalSpentHours ?? null
};
}
export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project.TaskWorklog {
return {
...response,
id: normalizeStringId(response.id),
taskId: normalizeStringId(response.taskId),
userId: normalizeStringId(response.userId),
userNickname: response.userNickname ?? null,
workContent: response.workContent ?? null,
attachments: normalizeAttachments(response.attachments),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0
};
}
export function normalizeTaskAssignee(response: TaskAssigneeFromApiResponse): Api.Project.TaskAssigneeRef {
return {
id: normalizeStringId(response.id),
userId: normalizeStringId(response.userId),
nickname: response.userNickname ?? '',
joinedAt: response.joinedAt ?? null
};
}
export function normalizeTaskAssigneeLog(response: TaskAssigneeLogResponse): Api.Project.TaskAssigneeLog {
return {
...response,
id: normalizeStringId(response.id),
taskId: normalizeStringId(response.taskId),
userId: normalizeStringId(response.userId),
operatorUserId: normalizeStringId(response.operatorUserId),
userNicknameSnapshot: response.userNicknameSnapshot ?? null,
operatorNicknameSnapshot: response.operatorNicknameSnapshot ?? null,
reason: response.reason ?? null
};
}

View File

@@ -8,19 +8,25 @@ import {
safeJsonRequestConfig
} from './shared';
import {
type ExecutionMemberLogResponse,
type ExecutionMemberResponse,
type ExecutionAssigneeLogResponse,
type ExecutionAssigneeResponse,
type ProjectExecutionResponse,
type ProjectLocalDateValue,
type ProjectMemberResponse,
type ProjectTaskResponse,
type TaskAssigneeFromApiResponse,
type TaskAssigneeLogResponse,
type TaskWorklogResponse,
getProjectLifecycleActions,
normalizeExecutionMember,
normalizeExecutionMemberLog,
normalizeExecutionAssignee,
normalizeExecutionAssigneeLog,
normalizeProjectExecution,
normalizeProjectLocalDate,
normalizeProjectMember,
normalizeProjectTask
normalizeProjectTask,
normalizeTaskAssignee,
normalizeTaskAssigneeLog,
normalizeTaskWorklog
} from './project-shared';
const PROJECT_PREFIX = `${WEB_SERVICE_PREFIX}/project/project`;
@@ -159,6 +165,18 @@ export async function fetchCreateProject(data: Api.Project.SaveProjectParams) {
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 创建项目(含初始团队,原子接口) */
export async function fetchCreateProjectWithTeam(data: Api.Project.CreateProjectWithTeamParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${PROJECT_PREFIX}/create-with-team`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 更新项目 */
export function fetchUpdateProject(data: Api.Project.UpdateProjectParams) {
return request<boolean>({
@@ -340,7 +358,7 @@ export async function fetchGetProjectExecution(projectId: string, executionId: s
}
/** 创建项目执行 */
export async function fetchCreateProjectExecution(projectId: string, data: Api.Project.SaveProjectExecutionParams) {
export async function fetchCreateProjectExecution(projectId: string, data: Api.Project.CreateProjectExecutionParams) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: getExecutionPrefix(projectId),
@@ -355,7 +373,7 @@ export async function fetchCreateProjectExecution(projectId: string, data: Api.P
export function fetchUpdateProjectExecution(
projectId: string,
executionId: string,
data: Api.Project.SaveProjectExecutionParams
data: Api.Project.UpdateProjectExecutionParams
) {
return request<boolean>({
...safeJsonRequestConfig,
@@ -379,6 +397,20 @@ export function fetchChangeProjectExecutionOwner(
});
}
/** 删除项目执行 */
export function fetchDeleteProjectExecution(
projectId: string,
executionId: string,
data: Api.Project.DeleteProjectExecutionParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}`,
method: 'delete',
data
});
}
/** 变更项目执行状态 */
export function fetchChangeProjectExecutionStatus(
projectId: string,
@@ -393,28 +425,28 @@ export function fetchChangeProjectExecutionStatus(
});
}
/** 获取项目执行成员 */
export async function fetchGetProjectExecutionMembers(projectId: string, executionId: string) {
const result = await request<ExecutionMemberResponse[]>({
/** 获取项目执行协办人 */
export async function fetchGetProjectExecutionAssignees(projectId: string, executionId: string) {
const result = await request<ExecutionAssigneeResponse[]>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/members`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignees`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<ExecutionMemberResponse[]>, data =>
data.map(normalizeExecutionMember)
return mapServiceResult(result as ServiceRequestResult<ExecutionAssigneeResponse[]>, data =>
data.map(normalizeExecutionAssignee)
);
}
/** 创建项目执行成员 */
export async function fetchCreateProjectExecutionMember(
/** 创建项目执行协办人 */
export async function fetchCreateProjectExecutionAssignee(
projectId: string,
executionId: string,
data: Api.Project.CreateExecutionMemberParams
data: Api.Project.CreateExecutionAssigneeParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/members`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignees`,
method: 'post',
data
});
@@ -422,37 +454,40 @@ export async function fetchCreateProjectExecutionMember(
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 移除项目执行成员 */
export function fetchInactiveProjectExecutionMember(
/** 移除项目执行协办人 */
export function fetchInactiveProjectExecutionAssignee(
projectId: string,
executionId: string,
payload: { memberId: string; data: Api.Project.InactiveExecutionMemberParams }
payload: { assigneeId: string; data: Api.Project.InactiveExecutionAssigneeParams }
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/members/${payload.memberId}/inactive`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignees/${payload.assigneeId}/inactive`,
method: 'post',
data: payload.data
});
}
/** 获取项目执行成员变更历史分页 */
export async function fetchGetProjectExecutionMemberLogPage(
/** 获取项目执行协办人变更历史分页 */
export async function fetchGetProjectExecutionAssigneeLogPage(
projectId: string,
executionId: string,
params?: Api.Project.ExecutionMemberLogSearchParams
params?: Api.Project.ExecutionAssigneeLogSearchParams
) {
const result = await request<Api.Project.PageResult<ExecutionMemberLogResponse>>({
const result = await request<Api.Project.PageResult<ExecutionAssigneeLogResponse>>({
...safeJsonRequestConfig,
url: `${getExecutionPrefix(projectId)}/${executionId}/member-logs`,
url: `${getExecutionPrefix(projectId)}/${executionId}/assignee-logs`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<Api.Project.PageResult<ExecutionMemberLogResponse>>, data => ({
return mapServiceResult(
result as ServiceRequestResult<Api.Project.PageResult<ExecutionAssigneeLogResponse>>,
data => ({
...data,
list: data.list.map(normalizeExecutionMemberLog)
}));
list: data.list.map(normalizeExecutionAssigneeLog)
})
);
}
/** 获取项目任务分页 */
@@ -529,6 +564,22 @@ export function fetchUpdateProjectTask(
});
}
/** 删除项目任务 */
// eslint-disable-next-line max-params
export function fetchDeleteProjectTask(
projectId: string,
executionId: string,
taskId: string,
data: Api.Project.DeleteProjectTaskParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}`,
method: 'delete',
data
});
}
/** 变更项目任务状态 */
export function fetchChangeProjectTaskStatus(
projectId: string,
@@ -542,3 +593,148 @@ export function fetchChangeProjectTaskStatus(
data: payload.data
});
}
type TaskWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
function getWorklogPrefix(projectId: string, executionId: string, taskId: string) {
return `${getTaskPrefix(projectId, executionId)}/${taskId}/worklogs`;
}
/** 获取任务工时分页 */
// eslint-disable-next-line max-params
export async function fetchGetProjectTaskWorklogPage(
projectId: string,
executionId: string,
taskId: string,
params?: Api.Project.TaskWorklogSearchParams
) {
const result = await request<TaskWorklogPageResponse>({
...safeJsonRequestConfig,
url: getWorklogPrefix(projectId, executionId, taskId),
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<TaskWorklogPageResponse>, data => ({
...data,
list: data.list.map(normalizeTaskWorklog)
}));
}
/** 新增任务工时 */
// eslint-disable-next-line max-params
export async function fetchCreateProjectTaskWorklog(
projectId: string,
executionId: string,
taskId: string,
data: Api.Project.SaveTaskWorklogParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: getWorklogPrefix(projectId, executionId, taskId),
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 修改任务工时 */
// eslint-disable-next-line max-params
export function fetchUpdateProjectTaskWorklog(
projectId: string,
executionId: string,
taskId: string,
payload: { worklogId: string; data: Api.Project.SaveTaskWorklogParams }
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getWorklogPrefix(projectId, executionId, taskId)}/${payload.worklogId}`,
method: 'put',
data: payload.data
});
}
/** 删除任务工时 */
// eslint-disable-next-line max-params
export function fetchDeleteProjectTaskWorklog(
projectId: string,
executionId: string,
taskId: string,
worklogId: string
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getWorklogPrefix(projectId, executionId, taskId)}/${worklogId}`,
method: 'delete'
});
}
/** 5.6 获取任务协办人列表(仅当前活跃) */
export async function fetchGetProjectTaskAssignees(projectId: string, executionId: string, taskId: string) {
const result = await request<TaskAssigneeFromApiResponse[]>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignees`,
method: 'get'
});
return mapServiceResult(result as ServiceRequestResult<TaskAssigneeFromApiResponse[]>, data =>
data.map(normalizeTaskAssignee)
);
}
/** 5.7 加入任务协办人 */
// eslint-disable-next-line max-params
export async function fetchCreateProjectTaskAssignee(
projectId: string,
executionId: string,
taskId: string,
data: Api.Project.CreateTaskAssigneeParams
) {
const result = await request<string | number>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignees`,
method: 'post',
data
});
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
}
/** 5.8 退出任务协办人 */
// eslint-disable-next-line max-params
export function fetchInactiveProjectTaskAssignee(
projectId: string,
executionId: string,
taskId: string,
assigneeId: string,
data: Api.Project.InactiveTaskAssigneeParams
) {
return request<boolean>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignees/${assigneeId}/inactive`,
method: 'post',
data
});
}
/** 5.9 任务协办人变更历史分页 */
// eslint-disable-next-line max-params
export async function fetchGetProjectTaskAssigneeLogPage(
projectId: string,
executionId: string,
taskId: string,
params?: Api.Project.TaskAssigneeLogSearchParams
) {
const result = await request<Api.Project.PageResult<TaskAssigneeLogResponse>>({
...safeJsonRequestConfig,
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/assignee-logs`,
method: 'get',
params
});
return mapServiceResult(result as ServiceRequestResult<Api.Project.PageResult<TaskAssigneeLogResponse>>, data => ({
...data,
list: data.list.map(normalizeTaskAssigneeLog)
}));
}

View File

@@ -0,0 +1,79 @@
import type { InternalAxiosRequestConfig } from 'axios';
declare module 'axios' {
interface AxiosRequestConfig {
dedupe?: boolean;
}
}
const WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH']);
type DedupableConfig = Pick<InternalAxiosRequestConfig, 'method' | 'url' | 'data' | 'params'> & {
dedupe?: boolean;
};
function isFormDataLike(value: unknown): boolean {
if (typeof FormData !== 'undefined' && value instanceof FormData) return true;
if (typeof Blob !== 'undefined' && value instanceof Blob) return true;
return false;
}
function stableJson(value: unknown): string {
if (value === null || value === undefined) return '';
if (typeof value !== 'object') return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(stableJson).join(',')}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map(k => `${JSON.stringify(k)}:${stableJson(obj[k])}`).join(',')}}`;
}
export function computeDedupeKey(config: DedupableConfig): string | null {
const method = (config.method ?? 'GET').toUpperCase();
if (!WRITE_METHODS.has(method)) return null;
if (config.dedupe === false) return null;
if (isFormDataLike(config.data)) return null;
const url = config.url ?? '';
const paramsPart = stableJson(config.params);
const bodyPart = stableJson(config.data);
return `${method}|${url}?${paramsPart}|${bodyPart}`;
}
const DEFAULT_TTL_MS = 30_000;
export interface WithDedupeOptions {
ttlMs?: number;
now?: () => number;
}
type AnyRequestFn = (...args: any[]) => Promise<unknown>;
export function withDedupe<TFn extends AnyRequestFn>(request: TFn, options: WithDedupeOptions = {}): TFn {
const ttl = options.ttlMs ?? DEFAULT_TTL_MS;
const now = options.now ?? Date.now;
const pending = new Map<string, { promise: Promise<unknown>; expiresAt: number }>();
return new Proxy(request, {
apply(target, thisArg, args: Parameters<TFn>) {
const [config] = args;
const key = computeDedupeKey(config as DedupableConfig);
if (key === null) return Reflect.apply(target, thisArg, args);
const cached = pending.get(key);
if (cached && cached.expiresAt > now()) return cached.promise;
if (cached) pending.delete(key);
const promise = Promise.resolve()
.then(() => Reflect.apply(target, thisArg, args))
.finally(() => {
const current = pending.get(key);
if (current && current.promise === promise) {
pending.delete(key);
}
});
pending.set(key, { promise, expiresAt: now() + ttl });
return promise;
}
}) as TFn;
}

View File

@@ -6,12 +6,14 @@ import { getServiceBaseURL } from '@/utils/service';
import { $t } from '@/locales';
import { applyApiEncrypt } from './api-encrypt';
import { getAuthorization, handleExpiredRequest, showErrorMsg } from './shared';
import { withDedupe } from './dedupe';
import type { RequestInstanceState } from './type';
const isHttpProxy = import.meta.env.DEV && import.meta.env.VITE_HTTP_PROXY === 'Y';
const { baseURL, otherBaseURL } = getServiceBaseURL(import.meta.env, isHttpProxy);
export const request = createFlatRequest(
export const request = withDedupe(
createFlatRequest(
{
baseURL,
headers: {
@@ -125,6 +127,7 @@ export const request = createFlatRequest(
showErrorMsg(request.state, message);
}
}
)
);
export const demoRequest = createRequest(

View File

@@ -28,6 +28,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
const userInfo: Api.Auth.UserInfo = reactive({
userId: '',
userName: '',
nickname: '',
roles: [],
buttons: []
});

View File

@@ -13,6 +13,7 @@ declare namespace Api {
interface UserInfo {
userId: string;
userName: string;
nickname: string;
roles: string[];
buttons: string[];
}

View File

@@ -210,6 +210,16 @@ declare namespace Api {
previousManagerRoleId?: string | null;
}
/**
* 产品创建(含初始团队)原子接口参数
*
* 新增产品两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
*/
interface CreateProductWithTeamParams {
product: SaveProductParams;
members: CreateProductMemberParams[];
}
interface UpdateProductMemberParams {
roleId: string;
remark?: string | null;

View File

@@ -68,10 +68,10 @@ declare namespace Api {
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel';
/** 任务状态编码 */
type ProjectTaskStatusCode = 'pending' | 'active' | 'blocked' | 'completed' | 'cancelled';
type ProjectTaskStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
/** 任务动作编码 */
type ProjectTaskActionCode = 'start' | 'block' | 'resume' | 'complete' | 'cancel';
type ProjectTaskActionCode = 'auto_start' | 'pause' | 'resume' | 'complete' | 'cancel';
interface LifecycleAction<ActionCode extends string = string> {
actionCode: ActionCode;
@@ -116,7 +116,7 @@ declare namespace Api {
updateTime: string;
}
interface ExecutionMember {
interface ExecutionAssignee {
id: string;
executionId: string;
userId: string;
@@ -126,14 +126,14 @@ declare namespace Api {
removedReason: string | null;
}
/** 执行成员变更事件类型 */
type ExecutionMemberActionType = 'join' | 'inactive' | 'owner_transfer_in' | 'owner_transfer_out';
/** 执行协办人变更事件类型 */
type ExecutionAssigneeActionType = 'join' | 'inactive' | 'owner_transfer_in' | 'owner_transfer_out';
/** 执行成员变更历史 */
interface ExecutionMemberLog {
/** 执行协办人变更历史 */
interface ExecutionAssigneeLog {
id: string;
executionId: string;
actionType: ExecutionMemberActionType;
actionType: ExecutionAssigneeActionType;
userId: string;
userNicknameSnapshot: string | null;
operatorUserId: string;
@@ -142,15 +142,72 @@ declare namespace Api {
reason: string | null;
}
type ExecutionMemberLogSearchParams = CommonType.RecordNullable<
type ExecutionAssigneeLogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
actionTypes: ExecutionMemberActionType[];
actionTypes: ExecutionAssigneeActionType[];
userId: string;
startTime: string;
endTime: string;
}
>;
/** 通用附件元数据(任务 / 工时等域共用,规则见 AttachmentValidator */
interface AttachmentItem {
/**
* 文件 IDinfra_file.id 字符串形式)。
* 用于会话级清理时调用 DELETE /system/file/delete?id=xxx 删除孤儿文件。
*/
fileId: string;
url: string;
name: string;
size?: number;
contentType?: string;
}
/** 任务详情 / 分页响应里嵌入的活跃协办人引用(按加入时间正序) */
interface TaskAssigneeRef {
id: string;
userId: string;
nickname: string;
/** 加入时间5.6 路径返5.3 嵌入路径不返,留 undefined */
joinedAt?: string | null;
}
/** 协办人变更事件类型5.9 actionType */
type TaskAssigneeActionType = 'join' | 'inactive';
/** 协办人变更日志 */
interface TaskAssigneeLog {
id: string;
taskId: string;
actionType: TaskAssigneeActionType;
userId: string;
userNicknameSnapshot: string | null;
operatorUserId: string;
operatorNicknameSnapshot: string | null;
actionTime: string;
reason: string | null;
}
type TaskAssigneeLogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
actionTypes: TaskAssigneeActionType[];
userId: string;
startTime: string;
endTime: string;
}
>;
/** 5.7 加入协办人入参 */
interface CreateTaskAssigneeParams {
userId: string;
}
/** 5.8 退出协办人入参 */
interface InactiveTaskAssigneeParams {
reason: string;
}
interface ProjectTask {
id: string;
projectId: string;
@@ -159,6 +216,10 @@ declare namespace Api {
taskTitle: string;
ownerId: string;
ownerNickname?: string | null;
/** 所属执行的负责人 userId按钮可见度公式用 */
executionOwnerId: string;
/** 父任务负责人 userId一级任务为 null */
parentTaskOwnerId: string | null;
statusCode: ProjectTaskStatusCode;
statusName: string | null;
terminal: boolean;
@@ -171,6 +232,10 @@ declare namespace Api {
actualEndDate: string | null;
taskDesc: string | null;
lastStatusReason: string | null;
assignees?: TaskAssigneeRef[] | null;
attachments?: AttachmentItem[] | null;
/** 已填报工时合计单位小时0.5 颗粒BigDecimal。逻辑删除的工时不计入。 */
totalSpentHours?: number | null;
createTime: string;
updateTime: string;
}
@@ -192,7 +257,8 @@ declare namespace Api {
updateTime: string[];
}>;
interface SaveProjectExecutionParams {
/** 创建执行入参(含 ownerId + assigneeUserIds */
interface CreateProjectExecutionParams {
executionName: string;
executionType: string;
ownerId: string;
@@ -200,7 +266,20 @@ declare namespace Api {
plannedStartDate: string | null;
plannedEndDate: string | null;
executionDesc: string | null;
memberUserIds?: string[];
assigneeUserIds?: string[];
}
/** 执行创建/编辑弹层 emit 的统一 payload创建时含 ownerId + assigneeUserIds编辑时不含 */
type SaveProjectExecutionParams = CreateProjectExecutionParams;
/** 编辑执行入参(不含 ownerId / assigneeUserIds */
interface UpdateProjectExecutionParams {
executionName: string;
executionType: string;
projectRequirementId: string | null;
plannedStartDate: string | null;
plannedEndDate: string | null;
executionDesc: string | null;
}
interface ChangeExecutionOwnerParams {
@@ -213,11 +292,11 @@ declare namespace Api {
reason: string | null;
}
interface CreateExecutionMemberParams {
interface CreateExecutionAssigneeParams {
userId: string;
}
interface InactiveExecutionMemberParams {
interface InactiveExecutionAssigneeParams {
reason: string;
}
@@ -246,8 +325,10 @@ declare namespace Api {
plannedStartDate: string | null;
plannedEndDate: string | null;
taskDesc: string | null;
/** 仅创建任务时生效编辑接口静默忽略userId 必须是当前有效执行成员且不能等于 ownerId */
/** 仅创建任务时生效编辑接口静默忽略userId 必须是当前有效执行协办人且不能等于 ownerId */
assigneeUserIds?: string[];
/** 编辑语义null 保留原值 / [] 清空 / [...] 整体替换 */
attachments?: AttachmentItem[] | null;
}
interface ChangeTaskStatusParams {
@@ -255,6 +336,48 @@ declare namespace Api {
reason: string | null;
}
/** 任务工时记录 */
interface TaskWorklog {
id: string;
taskId: string;
userId: string;
userNickname: string | null;
/** 段起始日期YYYY-MM-DD单天=与 endDate 相等 */
startDate: string;
/** 段结束日期YYYY-MM-DD单天=与 startDate 相等 */
endDate: string;
/** 本次填报小时数BigDecimal0.5 颗粒,> 0 */
durationHours: number;
/** 本次填报进度0~100scale=2 */
progressRate: number;
workContent: string | null;
attachments?: AttachmentItem[] | null;
createTime: string;
updateTime: string;
}
type TaskWorklogSearchParams = CommonType.RecordNullable<
Pick<PageParams, 'pageNo' | 'pageSize'> & {
userId: string;
startDate: string;
endDate: string;
}
>;
interface SaveTaskWorklogParams {
/** 段起始日期YYYY-MM-DD */
startDate: string;
/** 段结束日期YYYY-MM-DD不得早于 startDate */
endDate: string;
/** 本次填报小时数,> 0 且 0.5 整数倍 */
durationHours: number;
/** 本次填报进度0~100scale=2必填 */
progressRate: number;
workContent?: string | null;
/** 编辑语义null 保留原值 / [] 清空 / [...] 替换 */
attachments?: AttachmentItem[] | null;
}
/** 项目设置参数 */
interface ProjectSettings {
baseInfo: ProjectSettingBaseInfo;
@@ -424,6 +547,26 @@ declare namespace Api {
reason: string;
}
/** 删除执行入参 */
interface DeleteProjectExecutionParams {
/** 二次确认:必须与当前执行名称完全一致 */
executionName: string;
/** 删除确认口令:接受 "删除" 或 "DELETE" */
confirmText: string;
/** 删除原因,写入审计日志 */
reason: string;
}
/** 删除任务入参 */
interface DeleteProjectTaskParams {
/** 二次确认:必须与当前任务名称完全一致 */
taskName: string;
/** 删除确认口令:接受 "删除" 或 "DELETE" */
confirmText: string;
/** 删除原因,写入审计日志 */
reason: string;
}
/** 创建项目成员参数 */
interface CreateProjectMemberParams {
userId: string;
@@ -446,5 +589,15 @@ declare namespace Api {
interface InactiveProjectMemberParams {
reason: string | null;
}
/**
* 项目创建(含初始团队)原子接口参数
*
* 新增项目两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
*/
interface CreateProjectWithTeamParams {
project: SaveProjectParams;
members: CreateProjectMemberParams[];
}
}
}

View File

@@ -10,6 +10,7 @@ declare module 'vue' {
export interface GlobalComponents {
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
BetterScroll: typeof import('./../components/custom/better-scroll.vue')['default']
BusinessAttachmentUploader: typeof import('./../components/custom/business-attachment-uploader.vue')['default']
BusinessDateRangePicker: typeof import('./../components/custom/business-date-range-picker.vue')['default']
BusinessFormDialog: typeof import('./../components/custom/business-form-dialog.vue')['default']
BusinessFormDrawer: typeof import('./../components/custom/business-form-drawer.vue')['default']
@@ -54,8 +55,10 @@ declare module 'vue' {
ElForm: typeof import('element-plus/es')['ElForm']
ElFormItem: typeof import('element-plus/es')['ElFormItem']
ElIcon: typeof import('element-plus/es')['ElIcon']
ElImageViewer: typeof import('element-plus/es')['ElImageViewer']
ElInput: typeof import('element-plus/es')['ElInput']
ElInputNumber: typeof import('element-plus/es')['ElInputNumber']
ElLink: typeof import('element-plus/es')['ElLink']
ElMenu: typeof import('element-plus/es')['ElMenu']
ElMenuItem: typeof import('element-plus/es')['ElMenuItem']
ElOption: typeof import('element-plus/es')['ElOption']
@@ -131,7 +134,9 @@ declare module 'vue' {
IconMdiChevronDoubleUp: typeof import('~icons/mdi/chevron-double-up')['default']
IconMdiChevronDown: typeof import('~icons/mdi/chevron-down')['default']
IconMdiChevronRight: typeof import('~icons/mdi/chevron-right')['default']
IconMdiClose: typeof import('~icons/mdi/close')['default']
IconMdiCloseCircle: typeof import('~icons/mdi/close-circle')['default']
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
IconMdiDrag: typeof import('~icons/mdi/drag')['default']

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({
const { error } = await fetchUpdateProduct({
id: props.rowData.id,
...payload
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;
}
const result = await fetchCreateProduct(payload);
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 (result.error) {
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 (!props.rowData?.id) {
return;
}
if (newProductId && newProductId !== oldProductId) {
// 选择了产品,自动填充方向
const product = productOptions.value.find(p => p.id === newProductId);
if (product) {
model.value.directionCode = product.directionCode;
}
}
// 取消选择产品时directionCode 保留,用户可重新选择
}
);
const managerUserId = editModel.value.managerUserId;
async function handleSubmit() {
await validate();
if (!managerUserId) {
return;
}
// 提交时,如果关联了产品,使用产品方向
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)
};
? selectedProductDirection.value || editModel.value.directionCode
: editModel.value.directionCode;
submitting.value = true;
if (isEditMode.value && props.rowData?.id) {
const updateParams: Api.Project.UpdateProjectParams = {
const { error } = await fetchUpdateProject({
id: props.rowData.id,
...payload
};
const result = await fetchUpdateProject(updateParams);
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;
}
const result = await fetchCreateProject(payload);
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 (result.error) {
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: '完成项目',

View File

@@ -0,0 +1,222 @@
import assert from 'node:assert/strict';
import { describe, it } from 'node:test';
import { computeDedupeKey, withDedupe } from '../src/service/request/dedupe';
describe('computeDedupeKey', () => {
it('returns null for GET method', () => {
assert.equal(computeDedupeKey({ method: 'GET', url: '/api/list' }), null);
});
it('returns null for HEAD/OPTIONS method', () => {
assert.equal(computeDedupeKey({ method: 'HEAD', url: '/api/x' }), null);
assert.equal(computeDedupeKey({ method: 'OPTIONS', url: '/api/x' }), null);
});
it('returns null when dedupe is explicitly false', () => {
assert.equal(computeDedupeKey({ method: 'POST', url: '/api/x', dedupe: false }), null);
});
it('returns null when data is FormData', () => {
const fd = new FormData();
fd.append('file', new Blob(['x']));
assert.equal(computeDedupeKey({ method: 'POST', url: '/api/upload', data: fd }), null);
});
it('returns null when data is Blob', () => {
assert.equal(computeDedupeKey({ method: 'POST', url: '/api/blob', data: new Blob(['x']) }), null);
});
it('returns a string fingerprint for write methods', () => {
const key = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } });
assert.equal(typeof key, 'string');
assert.ok((key as string).length > 0);
});
it('uppercases method for canonical fingerprint', () => {
const a = computeDedupeKey({ method: 'post', url: '/api/x', data: { a: 1 } });
const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } });
assert.equal(a, b);
});
it('produces the same fingerprint regardless of params key order', () => {
const a = computeDedupeKey({ method: 'POST', url: '/api/x', params: { b: 2, a: 1 } });
const b = computeDedupeKey({ method: 'POST', url: '/api/x', params: { a: 1, b: 2 } });
assert.equal(a, b);
});
it('produces the same fingerprint regardless of body key order', () => {
const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: { b: 2, a: 1 } });
const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1, b: 2 } });
assert.equal(a, b);
});
it('produces different fingerprint when body differs', () => {
const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 1 } });
const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: { a: 2 } });
assert.notEqual(a, b);
});
it('handles undefined body (e.g. DELETE) without throwing', () => {
const key = computeDedupeKey({ method: 'DELETE', url: '/api/x/1' });
assert.equal(typeof key, 'string');
});
it('handles primitive body (string)', () => {
const key = computeDedupeKey({ method: 'POST', url: '/api/x', data: 'raw=text' });
assert.equal(typeof key, 'string');
});
it('handles array body without key sorting (arrays keep order)', () => {
const a = computeDedupeKey({ method: 'POST', url: '/api/x', data: [1, 2, 3] });
const b = computeDedupeKey({ method: 'POST', url: '/api/x', data: [3, 2, 1] });
assert.notEqual(a, b);
});
});
describe('withDedupe', () => {
const buildPending = () => {
let resolve!: (value: unknown) => void;
let reject!: (reason?: unknown) => void;
const promise = new Promise((res, rej) => {
resolve = res;
reject = rej;
});
return { promise, resolve, reject };
};
it('returns the cached Promise for concurrent identical write requests', async () => {
let callCount = 0;
const pending = buildPending();
const inner = (_config: unknown) => {
callCount += 1;
return pending.promise;
};
const wrapped = withDedupe(inner);
const config = { method: 'POST', url: '/api/x', data: { a: 1 } };
const p1 = wrapped(config);
const p2 = wrapped(config);
assert.equal(callCount, 1);
assert.equal(p1, p2);
pending.resolve({ data: 'ok', error: null });
const [r1, r2] = await Promise.all([p1, p2]);
assert.deepEqual(r1, { data: 'ok', error: null });
assert.equal(r1, r2);
});
it('does not dedupe different fingerprints', async () => {
let callCount = 0;
const inner = async (config: any) => {
callCount += 1;
return { data: config.data, error: null };
};
const wrapped = withDedupe(inner);
await wrapped({ method: 'POST', url: '/api/x', data: { a: 1 } });
await wrapped({ method: 'POST', url: '/api/x', data: { a: 2 } });
assert.equal(callCount, 2);
});
it('does not dedupe GET requests', async () => {
let callCount = 0;
const pending = buildPending();
const inner = (_config: unknown) => {
callCount += 1;
return pending.promise;
};
const wrapped = withDedupe(inner);
const config = { method: 'GET', url: '/api/list', params: { page: 1 } };
wrapped(config);
wrapped(config);
assert.equal(callCount, 2);
pending.resolve({ data: [], error: null });
});
it('does not dedupe FormData uploads', async () => {
let callCount = 0;
const inner = async (_config: unknown) => {
callCount += 1;
return { data: 'ok', error: null };
};
const wrapped = withDedupe(inner);
const fd = new FormData();
fd.append('file', new Blob(['x']));
await wrapped({ method: 'POST', url: '/api/upload', data: fd });
await wrapped({ method: 'POST', url: '/api/upload', data: fd });
assert.equal(callCount, 2);
});
it('cleans pending entry after success, so next identical request hits the network again', async () => {
let callCount = 0;
const inner = async (_config: unknown) => {
callCount += 1;
return { data: 'ok', error: null };
};
const wrapped = withDedupe(inner);
const config = { method: 'POST', url: '/api/x', data: { a: 1 } };
await wrapped(config);
await wrapped(config);
assert.equal(callCount, 2);
});
it('cleans pending entry after failure (FlatRequest never throws, but raw rejection also clears)', async () => {
let callCount = 0;
const inner = async (_config: unknown) => {
callCount += 1;
throw new Error('boom');
};
const wrapped = withDedupe(inner);
const config = { method: 'POST', url: '/api/x', data: { a: 1 } };
await assert.rejects(wrapped(config), /boom/);
await assert.rejects(wrapped(config), /boom/);
assert.equal(callCount, 2);
});
it('treats entries past TTL as stale and re-enters', async () => {
let callCount = 0;
const pending = buildPending();
const inner = (_config: unknown) => {
callCount += 1;
return pending.promise;
};
let fakeNow = 1_000_000;
const wrapped = withDedupe(inner, { ttlMs: 30_000, now: () => fakeNow });
const config = { method: 'POST', url: '/api/x', data: { a: 1 } };
wrapped(config);
assert.equal(callCount, 1);
fakeNow += 31_000;
wrapped(config);
assert.equal(callCount, 2);
pending.resolve({ data: 'ok', error: null });
});
it('preserves extension properties on the wrapped function (state / cancelAllRequest)', () => {
const inner = (() => Promise.resolve('x')) as unknown as ((c: unknown) => Promise<string>) & {
state: { foo: number };
cancelAllRequest: () => string;
};
inner.state = { foo: 1 };
inner.cancelAllRequest = () => 'cancelled';
const wrapped = withDedupe(inner);
assert.equal(wrapped.state.foo, 1);
assert.equal(wrapped.cancelAllRequest(), 'cancelled');
wrapped.state.foo = 2;
assert.equal(inner.state.foo, 2);
});
});