feat(projects): 1、执行、任务、工作日志开发调试;2、增加富文本、附件等支撑
This commit is contained in:
@@ -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 *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
27
AGENTS.md
27
AGENTS.md
@@ -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 未 settle,pending 条目过期后下一次相同请求视为新请求,避免内存泄漏。
|
||||
|
||||
### 设计责任划分
|
||||
|
||||
- 视觉层负责"按下立刻锁住按钮"的用户感知;逻辑层负责"即使锁失败也只发一次"的实际接口保护。
|
||||
- 不要因为有第二层兜底就省略第一层 loading 锁:用户没有视觉反馈会再次点击;也不要试图在业务页面再造一套请求去重逻辑。
|
||||
|
||||
## 运行时字典使用口径
|
||||
|
||||
- 运行时字典统一由 `src/store/modules/dict/index.ts` 管理,登录后通过 `/system/dict-data/frontend-cache` 初始化;不要在业务页面重复直调字典接口。
|
||||
|
||||
38
CLAUDE.md
38
CLAUDE.md
@@ -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`,靠第二层就该挡住
|
||||
|
||||
718
src/components/custom/business-attachment-uploader.vue
Normal file
718
src/components/custom/business-attachment-uploader.vue
Normal 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() 删 pendingDelete;rollback() 删 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: 需要在循环里 await,suppress 即可
|
||||
// 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(保留 original);committed=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 单项失败,这里不再 await,fire-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">
|
||||
// 浮层非 scoped:popper 渲染到 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>
|
||||
@@ -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 并重置 committed;HTML 里已有的图(编辑模式回显的)不进 uploadedMap,
|
||||
* 因此 commit/rollback 不会动它们——只动本次会话上传的图。
|
||||
*/
|
||||
initSession() {
|
||||
session.uploadedMap.clear();
|
||||
session.committed = false;
|
||||
},
|
||||
|
||||
/**
|
||||
* 父组件在【业务保存成功后】调用。
|
||||
* 扫当前 model HTML:uploadedMap 里 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 遮挡 */
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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: {},
|
||||
// 产品(待补全)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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 ?? []
|
||||
};
|
||||
|
||||
@@ -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'
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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>({
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}));
|
||||
}
|
||||
|
||||
79
src/service/request/dedupe.ts
Normal file
79
src/service/request/dedupe.ts
Normal 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;
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -28,6 +28,7 @@ export const useAuthStore = defineStore(SetupStoreId.Auth, () => {
|
||||
const userInfo: Api.Auth.UserInfo = reactive({
|
||||
userId: '',
|
||||
userName: '',
|
||||
nickname: '',
|
||||
roles: [],
|
||||
buttons: []
|
||||
});
|
||||
|
||||
1
src/typings/api/auth.d.ts
vendored
1
src/typings/api/auth.d.ts
vendored
@@ -13,6 +13,7 @@ declare namespace Api {
|
||||
interface UserInfo {
|
||||
userId: string;
|
||||
userName: string;
|
||||
nickname: string;
|
||||
roles: string[];
|
||||
buttons: string[];
|
||||
}
|
||||
|
||||
10
src/typings/api/product.d.ts
vendored
10
src/typings/api/product.d.ts
vendored
@@ -210,6 +210,16 @@ declare namespace Api {
|
||||
previousManagerRoleId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 产品创建(含初始团队)原子接口参数
|
||||
*
|
||||
* 新增产品两步向导提交的载荷。经理成员也由前端聚合到 members 数组中。
|
||||
*/
|
||||
interface CreateProductWithTeamParams {
|
||||
product: SaveProductParams;
|
||||
members: CreateProductMemberParams[];
|
||||
}
|
||||
|
||||
interface UpdateProductMemberParams {
|
||||
roleId: string;
|
||||
remark?: string | null;
|
||||
|
||||
183
src/typings/api/project.d.ts
vendored
183
src/typings/api/project.d.ts
vendored
@@ -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 {
|
||||
/**
|
||||
* 文件 ID(infra_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;
|
||||
/** 本次填报小时数(BigDecimal,0.5 颗粒,> 0) */
|
||||
durationHours: number;
|
||||
/** 本次填报进度(0~100,scale=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~100,scale=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[];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
5
src/typings/components.d.ts
vendored
5
src/typings/components.d.ts
vendored
@@ -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']
|
||||
|
||||
@@ -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,
|
||||
|
||||
96
src/views/product/list/modules/product-create-base-form.vue
Normal file
96
src/views/product/list/modules/product-create-base-form.vue
Normal 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>
|
||||
@@ -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>
|
||||
298
src/views/product/list/modules/product-create-team-step.vue
Normal file
298
src/views/product/list/modules/product-create-team-step.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
317
src/views/project/list/modules/project-create-base-form.vue
Normal file
317
src/views/project/list/modules/project-create-base-form.vue
Normal 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>
|
||||
@@ -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>
|
||||
298
src/views/project/list/modules/project-create-team-step.vue
Normal file
298
src/views/project/list/modules/project-create-team-step.vue
Normal 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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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
|
||||
};
|
||||
}
|
||||
@@ -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="未获取到当前项目上下文,请返回项目列表重新选择项目" />
|
||||
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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 => {
|
||||
// 状态推进按钮完全依赖 availableActions(owner-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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
// 协办人视角:只看自己的 worklog;owner 视角:全量加载
|
||||
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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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 表示没有任何 worklog;UI 上隐藏工时/进度,只显示"未填报"灰字 */
|
||||
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 位小数,去掉尾部 0:35 → "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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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-DD,tooltip 为空
|
||||
* - 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 链路向上透传的 payload;workspace 据此判定是否触发完成级联 */
|
||||
export interface WorklogChangedPayload {
|
||||
/** 本次操作类型:create / edit / delete */
|
||||
mode: 'create' | 'edit' | 'delete';
|
||||
/** 任务 id(worklog 所属 task) */
|
||||
taskId: string;
|
||||
/** 本次填报的进度(0~100);delete 模式不传 */
|
||||
progressRate?: number;
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ export const projectStatusOptions = transformRecordToOption(projectStatusRecord)
|
||||
|
||||
/** 项目状态动作编码与中文标签映射 */
|
||||
export const projectStatusActionRecord: Record<Api.Project.ProjectStatusActionCode, string> = {
|
||||
auto_start: '自动开始',
|
||||
auto_start: '开始推进',
|
||||
pause: '暂停项目',
|
||||
resume: '恢复项目',
|
||||
complete: '完成项目',
|
||||
|
||||
222
tests/request-dedupe.test.ts
Normal file
222
tests/request-dedupe.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user