Compare commits
11 Commits
fe29fde564
...
2026-05
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d84b1aae0 | |||
| d3d0830820 | |||
| b2da882b31 | |||
| 4ed4b537ad | |||
| 3988eaf910 | |||
| e9214137c1 | |||
| 13b74cfe97 | |||
|
|
ab882e085b | ||
| 62859bfc38 | |||
| ba328e02bb | |||
|
|
28d597d91e |
14
CLAUDE.md
14
CLAUDE.md
@@ -415,3 +415,17 @@ pnpm preview # preview server (9725)
|
||||
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
|
||||
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
|
||||
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住
|
||||
|
||||
---
|
||||
|
||||
## 20. 我生成文档的输出格式(强约束)
|
||||
|
||||
- **superpowers 工作流(`docs/superpowers/plans/`、`docs/superpowers/specs/`)下输出的文档继续用 `.md`**——工作流以 markdown 为前提。
|
||||
- **其他**我生成的文档(设计方案、复盘、规约、技术经验沉淀等)**默认用 `.html`**,沿用 `docs/debt/` 现有 HTML 文档(参考 `token-刷新机制对齐分析.html`、`技术负债台账.html`)的样式骨架:
|
||||
- 单文件、内联 CSS
|
||||
- `max-width: 980px` 居中容器、`padding: 32px 28px 80px`
|
||||
- 14px / `line-height: 1.7`、`PingFang SC` / `Microsoft YaHei` 中文字体优先
|
||||
- 模块化区块:`section` + 编号 h2、`card`、`table.cmp`、`pre`、`tag-ok/warn/bad/crit`
|
||||
- 配色用 `--bg / --panel / --border / --text / --primary` 一套 CSS 变量
|
||||
- **`README.md`** 是目录索引约定文件,**保持 `.md`**(不强行 `.html`)。
|
||||
- **已有 `.md` 文档不主动改写**,等用户明确要求再转。
|
||||
|
||||
35
README.md
35
README.md
@@ -1,35 +0,0 @@
|
||||
# cn-rdms-web
|
||||
|
||||
这是当前项目的前端工程仓库。
|
||||
|
||||
原开源模板项目的介绍内容已移除,这个 README 现在只保留当前项目自身所需的信息。
|
||||
|
||||
## 项目说明
|
||||
|
||||
待补充。
|
||||
|
||||
建议后续在这里补充:
|
||||
|
||||
- 项目背景
|
||||
- 技术栈
|
||||
- 目录结构
|
||||
- 本地启动方式
|
||||
- 环境变量说明
|
||||
- 构建与发布流程
|
||||
|
||||
## 本地开发
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
## 常用命令
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
pnpm build
|
||||
pnpm build:dev
|
||||
pnpm typecheck
|
||||
pnpm lint
|
||||
```
|
||||
@@ -151,9 +151,14 @@ export function setupElegantRouter() {
|
||||
order: 5,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_overtime-application': {
|
||||
icon: 'mdi:clock-plus-outline',
|
||||
order: 6,
|
||||
keepAlive: true
|
||||
},
|
||||
'personal-center_pending-approval': {
|
||||
icon: 'mdi:check-decagram-outline',
|
||||
order: 6,
|
||||
order: 7,
|
||||
keepAlive: true
|
||||
},
|
||||
system: {
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"generatedAt": "2026-05-19T07:08:28.081Z",
|
||||
"generatedAt": "2026-06-01T01:55:51.875Z",
|
||||
"description": "Frontend visible page resource whitelist for backend route/menu configuration.",
|
||||
"rules": {
|
||||
"directoryComponent": "layout.base",
|
||||
"pageComponentPattern": "view.<routeName>",
|
||||
"singlePageComponentPattern": "layout.<layoutName>$view.<routeName>"
|
||||
},
|
||||
"total": 22,
|
||||
"total": 23,
|
||||
"items": [
|
||||
{
|
||||
"name": "product_list",
|
||||
@@ -470,6 +470,39 @@
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "personal-center_overtime-application",
|
||||
"path": "/personal-center/overtime-application",
|
||||
"component": "view.personal-center_overtime-application",
|
||||
"title": "加班申请",
|
||||
"routeTitle": "personal-center_overtime-application",
|
||||
"i18nKey": "route.personal-center_overtime-application",
|
||||
"icon": "mdi:clock-plus-outline",
|
||||
"localIcon": null,
|
||||
"order": 6,
|
||||
"hideInMenu": false,
|
||||
"keepAlive": true,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null,
|
||||
"redirect": null,
|
||||
"props": null,
|
||||
"meta": {
|
||||
"title": "加班申请",
|
||||
"i18nKey": "route.personal-center_overtime-application",
|
||||
"icon": "mdi:clock-plus-outline",
|
||||
"localIcon": null,
|
||||
"order": 6,
|
||||
"keepAlive": true,
|
||||
"hideInMenu": false,
|
||||
"activeMenu": null,
|
||||
"multiTab": false,
|
||||
"fixedIndexInTab": null
|
||||
},
|
||||
"parentName": "personal-center",
|
||||
"pageType": "leaf",
|
||||
"source": "generated"
|
||||
},
|
||||
{
|
||||
"name": "system_user",
|
||||
"path": "/system/user",
|
||||
|
||||
@@ -1,292 +0,0 @@
|
||||
# 产品对象首页改版设计说明
|
||||
|
||||
日期:2026-04-23
|
||||
|
||||
## 1. 目标
|
||||
|
||||
本设计用于收敛 RDMS 产品对象上下文默认首页的改版方向。
|
||||
|
||||
本轮目标不是继续做“说明型占位页”,而是明确把当前 `/product/dashboard?objectId=...` 改成一个真正可用的产品对象首页:
|
||||
|
||||
- 第一眼先让用户知道当前看的是什么产品
|
||||
- 第二眼能快速判断对象最近发生了什么
|
||||
- 第三眼能看出需求池现在的经营状态和最近变化
|
||||
- 底部为后续业务模块保留正式挂载位,而不是临时拼接入口
|
||||
|
||||
## 2. 已确认诉求
|
||||
|
||||
基于本轮对话,已确认以下用户诉求:
|
||||
|
||||
1. 首页顶部必须先展示产品基础概述,而不是先铺统计卡片
|
||||
2. 基础概述至少包含:名称、编号、团队、产品经理等对象基础信息
|
||||
3. 页面需要一块明显的时间线,用于承接产品对象与团队变更动态
|
||||
4. 页面需要承接需求池管理情况,重点看总量、状态、待处理等统计信息
|
||||
5. 需求相关事件不要混入对象时间线,应单独作为需求池最近变化区域
|
||||
6. 快捷入口不要保留
|
||||
7. 底部允许保留后续扩展区,重点预留给里程碑、风险点管理、产品资料等模块
|
||||
8. 能接真实接口就接真实接口,当前没有稳定接口的区域允许先用假数据,但结构必须按正式首页来设计
|
||||
|
||||
## 3. 首页定位结论
|
||||
|
||||
本页定位不是:
|
||||
|
||||
- 纯报表看板
|
||||
- 纯审计日志页
|
||||
- 设置页搬运版
|
||||
- 导航入口集合页
|
||||
|
||||
本页定位应当是:
|
||||
|
||||
- 产品对象首页
|
||||
- 偏统计,也带审计
|
||||
- 但页面主语始终是“当前产品对象”
|
||||
|
||||
换句话说,这个页面要同时回答三个问题:
|
||||
|
||||
1. 我现在看的是什么产品?
|
||||
2. 这个产品对象最近发生了什么?
|
||||
3. 这个产品的需求池现在处于什么状态?
|
||||
|
||||
## 4. 页面结构
|
||||
|
||||
### 4.1 桌面端结构
|
||||
|
||||
桌面端建议采用三层结构:
|
||||
|
||||
1. 顶部 `对象基础概述横幅`
|
||||
2. 中部 `左时间线 + 右需求池双模块`
|
||||
3. 底部 `扩展信息区`
|
||||
|
||||
推荐布局比例:
|
||||
|
||||
- 顶部横幅:`24 / 24`
|
||||
- 中部主区:左 `16 / 24`,右 `8 / 24`
|
||||
- 底部扩展区:`24 / 24`
|
||||
|
||||
中部左侧时间线高度应明显高于右侧任一单模块,形成首页主阅读区。
|
||||
|
||||
### 4.2 移动端结构
|
||||
|
||||
移动端统一退化为单列纵向布局,顺序为:
|
||||
|
||||
1. 对象基础概述横幅
|
||||
2. 对象 / 团队动态时间线
|
||||
3. 需求池管理概览
|
||||
4. 需求池最近变化
|
||||
5. 扩展信息区
|
||||
|
||||
移动端不强撑左右栏并排,不做卡片墙式压缩。
|
||||
|
||||
## 5. 模块设计
|
||||
|
||||
### 5.1 对象基础概述横幅
|
||||
|
||||
顶部采用“档案横幅型”,不采用纯指标卡片型。
|
||||
|
||||
横幅左侧承接对象身份信息:
|
||||
|
||||
- 产品名称
|
||||
- 产品编号
|
||||
- 当前状态标签
|
||||
- 产品经理
|
||||
- 团队规模
|
||||
- 团队角色摘要
|
||||
- 简短描述或备注
|
||||
|
||||
横幅右侧承接 4 个摘要指标:
|
||||
|
||||
- 团队人数
|
||||
- 需求总量
|
||||
- 待处理需求
|
||||
- 最近动态时间
|
||||
|
||||
设计原则:
|
||||
|
||||
- 左侧负责建立对象识别
|
||||
- 右侧负责快速判断当前概况
|
||||
- 右侧指标只保留 4 项,不堆成报表卡片墙
|
||||
|
||||
### 5.2 对象 / 团队动态时间线
|
||||
|
||||
该区域位于中部左侧,是首页的主阅读区。
|
||||
|
||||
这条时间线只承接对象与团队变化,不承接需求事件。
|
||||
|
||||
第一版事件范围收敛为:
|
||||
|
||||
- 产品创建
|
||||
- 产品状态变更
|
||||
- 产品经理变更
|
||||
- 成员加入
|
||||
- 成员移出
|
||||
- 成员角色调整
|
||||
|
||||
每条时间线建议展示:
|
||||
|
||||
- 事件标题
|
||||
- 事件类型标签
|
||||
- 发生时间
|
||||
- 操作摘要
|
||||
- 必要时展示原因或备注
|
||||
|
||||
表达目标是“业务时间线”,不是后台审计表格。
|
||||
|
||||
### 5.3 需求池管理概览
|
||||
|
||||
该区域位于中部右侧上半块,用于表达需求池的经营状态。
|
||||
|
||||
第一版首页需要优先看到的内容:
|
||||
|
||||
- 需求总量
|
||||
- 各状态数量
|
||||
- 待处理数量
|
||||
- 高优先级待处理数量
|
||||
|
||||
展示方式建议为“摘要指标 + 状态分布列表”,不直接在首页展开完整需求表格。
|
||||
|
||||
这一块回答的是:
|
||||
|
||||
- 需求池是否健康
|
||||
- 当前待处理压力大不大
|
||||
- 是否存在需要优先关注的积压
|
||||
|
||||
### 5.4 需求池最近变化
|
||||
|
||||
该区域位于中部右侧下半块,与需求池管理概览上下分层,但属于同一侧栏语义。
|
||||
|
||||
该区域不重复展示总量,而是展示需求池最近发生的变化。
|
||||
|
||||
第一版建议承接:
|
||||
|
||||
- 最近新增需求
|
||||
- 最近状态流转
|
||||
- 最近关闭或完成
|
||||
|
||||
每条记录建议至少展示:
|
||||
|
||||
- 需求标题
|
||||
- 动作类型
|
||||
- 时间
|
||||
- 当前状态或状态变更摘要
|
||||
|
||||
若当前没有真实数据,仍保留正式模块壳,不退化成“待开发”一句话。
|
||||
|
||||
### 5.5 扩展信息区
|
||||
|
||||
底部不再保留快捷入口,改为正式扩展信息区。
|
||||
|
||||
当前优先预留 3 类模块位:
|
||||
|
||||
- 里程碑
|
||||
- 风险点管理
|
||||
- 产品资料
|
||||
|
||||
这一层的作用是:
|
||||
|
||||
- 为后续对象级信息继续扩展留下稳定挂载位
|
||||
- 不把中部主结构挤成信息大杂烩
|
||||
- 避免为了未来模块提前做假导航入口
|
||||
|
||||
如果当前没有稳定接口,可先保留正式卡片结构与空态说明。
|
||||
|
||||
## 6. 数据策略
|
||||
|
||||
### 6.1 真实接口优先
|
||||
|
||||
当前首页优先消费现有真实接口:
|
||||
|
||||
- `fetchGetProduct`
|
||||
- `fetchGetProductSettings`
|
||||
- `fetchGetProductMembers`
|
||||
|
||||
这些接口足以支撑:
|
||||
|
||||
- 对象基础概述中的名称、编号、状态、产品经理、描述
|
||||
- 团队人数与角色摘要
|
||||
- 最近动态中的产品创建、状态变化、成员加入/移出
|
||||
|
||||
### 6.2 假数据使用边界
|
||||
|
||||
当前没有稳定真实接口的区域,允许先用假数据,但边界必须明确:
|
||||
|
||||
- 需求池管理概览
|
||||
- 需求池最近变化
|
||||
- 扩展信息区中的里程碑、风险点管理、产品资料摘要
|
||||
|
||||
假数据的使用原则:
|
||||
|
||||
1. 只补“当前没有稳定接口”的区域
|
||||
2. 不反向污染对象基础信息
|
||||
3. 不把假数据混入对象上下文 store
|
||||
4. 数据源要集中放在概览页自己的 mock 模块中,方便后续替换
|
||||
|
||||
### 6.3 不推荐的做法
|
||||
|
||||
以下做法应避免:
|
||||
|
||||
- 把需求假数据散落写进页面组件
|
||||
- 用对象 demo 数据冒充真实产品详情
|
||||
- 把对象时间线和需求时间线混成一条
|
||||
- 用快捷入口伪装成首页内容
|
||||
|
||||
## 7. 空态规则
|
||||
|
||||
首页至少要区分三种状态:
|
||||
|
||||
1. 能力未接入,只能先显示正式占位信息
|
||||
2. 能力已接入,但当前该产品暂无业务数据
|
||||
3. 当前用户无权限查看该模块
|
||||
|
||||
这三种状态不能共用一套模糊文案。
|
||||
|
||||
对需求池和扩展信息区,当前阶段更推荐“正式空态”而不是“待开发”。
|
||||
|
||||
## 8. 页面边界
|
||||
|
||||
首页明确不承接以下内容:
|
||||
|
||||
- 快捷入口导航区
|
||||
- 完整团队成员表格
|
||||
- 完整需求列表表格
|
||||
- 设置页重表单
|
||||
- 完整审计日志明细页
|
||||
|
||||
首页要做的是概述、判断与阅读,不是重操作页。
|
||||
|
||||
## 9. 实施建议
|
||||
|
||||
第一阶段建议先完成结构性改造:
|
||||
|
||||
1. 重做顶部横幅,建立对象档案感
|
||||
2. 保留中部左高右双块结构
|
||||
3. 用真实接口接通对象概述与对象 / 团队时间线
|
||||
4. 用局部 mock 数据先接通需求池两块和底部扩展区
|
||||
|
||||
第二阶段再逐步替换需求池与扩展区数据源:
|
||||
|
||||
- 接真实需求池统计接口
|
||||
- 接真实需求动态接口
|
||||
- 接里程碑、风险点、产品资料摘要接口
|
||||
|
||||
## 10. 验证标准
|
||||
|
||||
本设计是否成立,可按以下标准判断:
|
||||
|
||||
1. 进入首页后,第一眼能认出当前产品对象
|
||||
2. 用户能自然读到对象 / 团队最近发生了什么
|
||||
3. 右侧能快速判断需求池当前压力与最近变化
|
||||
4. 页面看起来像“对象首页”,而不是“普通后台卡片堆叠页”
|
||||
5. 当前没有真实接口的区域也保留正式结构,不显得像临时占位
|
||||
6. 后续新增里程碑、风险点管理、产品资料等能力时,不需要推翻整页结构
|
||||
|
||||
## 11. 本轮设计结论
|
||||
|
||||
本轮最终设计结论如下:
|
||||
|
||||
- 首页定位为“产品对象首页”,偏统计,也带审计,但不做纯报表页
|
||||
- 顶部采用档案横幅型,先立住对象身份信息
|
||||
- 中部左侧是高权重的对象 / 团队动态时间线
|
||||
- 中部右侧拆为“需求池管理概览 + 需求池最近变化”上下两块
|
||||
- 底部去掉快捷入口,改为正式扩展信息区
|
||||
- 当前有真实接口的模块优先接真实接口
|
||||
- 当前没有稳定接口的区域允许先用假数据,但必须隔离在概览页局部 mock 数据源中
|
||||
1018
src/components/custom/attendee-user-picker.vue
Normal file
1018
src/components/custom/attendee-user-picker.vue
Normal file
File diff suppressed because it is too large
Load Diff
@@ -470,7 +470,7 @@ onBeforeUnmount(() => {
|
||||
</ElIcon>
|
||||
<ElLink
|
||||
type="primary"
|
||||
:underline="false"
|
||||
underline="never"
|
||||
class="business-attachment-uploader__name"
|
||||
:title="item.name"
|
||||
@click="handleOpen(item)"
|
||||
@@ -478,7 +478,7 @@ onBeforeUnmount(() => {
|
||||
{{ 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>
|
||||
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||
</li>
|
||||
|
||||
@@ -509,7 +509,7 @@ onBeforeUnmount(() => {
|
||||
</ElIcon>
|
||||
<ElLink
|
||||
type="primary"
|
||||
:underline="false"
|
||||
underline="never"
|
||||
class="business-attachment-uploader__name"
|
||||
:title="item.name"
|
||||
@click="handleOpen(item)"
|
||||
@@ -517,7 +517,7 @@ onBeforeUnmount(() => {
|
||||
{{ 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>
|
||||
<ElLink type="primary" underline="never" @click="handleDownload(item)">下载</ElLink>
|
||||
<ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
@@ -21,6 +21,7 @@ const isEmpty = computed(() => !safeHtml.value || safeHtml.value.replace(/<[^>]+
|
||||
<template>
|
||||
<div class="business-rich-text-view">
|
||||
<span v-if="isEmpty" class="business-rich-text-view__empty">{{ props.emptyText }}</span>
|
||||
<!-- eslint-disable-next-line vue/no-v-html -->
|
||||
<div v-else class="business-rich-text-view__content" v-html="safeHtml" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import type { PropType } from 'vue';
|
||||
import { ElButton, ElPopover } from 'element-plus';
|
||||
import { computed, defineComponent, h, ref } from 'vue';
|
||||
import type { Component, PropType } from 'vue';
|
||||
import { ElButton, ElPopover, ElTooltip } from 'element-plus';
|
||||
import { $t } from '@/locales';
|
||||
|
||||
export type BusinessTableAction = {
|
||||
key: string;
|
||||
label: string;
|
||||
buttonType?: 'primary' | 'success' | 'warning' | 'danger' | 'info';
|
||||
icon?: Component;
|
||||
disabled?: boolean;
|
||||
onClick: () => void | Promise<void>;
|
||||
};
|
||||
@@ -17,12 +18,20 @@ export default defineComponent({
|
||||
actions: {
|
||||
type: Array as PropType<BusinessTableAction[]>,
|
||||
required: true
|
||||
},
|
||||
variant: {
|
||||
type: String as PropType<'button' | 'icon'>,
|
||||
default: 'button'
|
||||
}
|
||||
},
|
||||
setup(props) {
|
||||
const popoverVisible = ref(false);
|
||||
|
||||
const directActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return props.actions;
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return props.actions;
|
||||
}
|
||||
@@ -31,6 +40,10 @@ export default defineComponent({
|
||||
});
|
||||
|
||||
const moreActions = computed(() => {
|
||||
if (props.variant === 'icon') {
|
||||
return [];
|
||||
}
|
||||
|
||||
if (props.actions.length <= 2) {
|
||||
return [];
|
||||
}
|
||||
@@ -47,21 +60,86 @@ export default defineComponent({
|
||||
await action.onClick();
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action => (
|
||||
function renderIcon(action: BusinessTableAction) {
|
||||
if (!action.icon) return null;
|
||||
|
||||
return h(action.icon, { class: 'business-table-action-icon' });
|
||||
}
|
||||
|
||||
function renderButtonAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
function renderIconAction(action: BusinessTableAction) {
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-button"
|
||||
class="business-table-action-icon-button"
|
||||
aria-label={action.label}
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
{renderIcon(action)}
|
||||
</ElButton>
|
||||
))}
|
||||
</ElTooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function renderMenuButton(action: BusinessTableAction) {
|
||||
if (props.variant === 'icon') {
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
link
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__link"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
<span class="business-table-action-menu__item">
|
||||
{renderIcon(action)}
|
||||
<span>{action.label}</span>
|
||||
</span>
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
);
|
||||
}
|
||||
|
||||
return () => (
|
||||
<div class="business-table-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{directActions.value.map(action =>
|
||||
props.variant === 'icon' ? renderIconAction(action) : renderButtonAction(action)
|
||||
)}
|
||||
|
||||
{moreActions.value.length > 0 && (
|
||||
<ElPopover
|
||||
@@ -74,32 +152,28 @@ export default defineComponent({
|
||||
{{
|
||||
reference: () => (
|
||||
<ElButton
|
||||
plain
|
||||
link={props.variant === 'icon'}
|
||||
plain={props.variant !== 'icon'}
|
||||
size="small"
|
||||
class="business-table-action-button"
|
||||
class={
|
||||
props.variant === 'icon' ? 'business-table-action-icon-button' : 'business-table-action-button'
|
||||
}
|
||||
aria-label={$t('common.more')}
|
||||
onClick={event => event.stopPropagation()}
|
||||
>
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
{props.variant === 'icon' ? (
|
||||
<icon-mdi-dots-horizontal class="business-table-action-icon" />
|
||||
) : (
|
||||
<span class="inline-flex items-center gap-4px">
|
||||
{$t('common.more')}
|
||||
<icon-mdi-chevron-down class="text-14px" />
|
||||
</span>
|
||||
)}
|
||||
</ElButton>
|
||||
),
|
||||
default: () => (
|
||||
<div class="business-table-action-menu">
|
||||
{moreActions.value.map(action => (
|
||||
<ElButton
|
||||
key={action.key}
|
||||
plain
|
||||
size="small"
|
||||
type={action.buttonType}
|
||||
disabled={action.disabled}
|
||||
class="business-table-action-menu__button"
|
||||
onClick={() => handleAction(action)}
|
||||
>
|
||||
{action.label}
|
||||
</ElButton>
|
||||
))}
|
||||
{moreActions.value.map(action => renderMenuButton(action))}
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
|
||||
938
src/components/custom/business-user-picker.vue
Normal file
938
src/components/custom/business-user-picker.vue
Normal file
@@ -0,0 +1,938 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { useFormItem } from 'element-plus';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import { usePickerSelection } from './business-user-picker/composables/use-picker-selection';
|
||||
import { useDeptSource } from './business-user-picker/composables/use-dept-source';
|
||||
import { useChainSource } from './business-user-picker/composables/use-chain-source';
|
||||
import UserPickerTrigger from './business-user-picker/components/user-picker-trigger.vue';
|
||||
import IconEpOfficeBuilding from '~icons/ep/office-building';
|
||||
import IconEpUser from '~icons/ep/user';
|
||||
|
||||
defineOptions({ name: 'BusinessUserPicker' });
|
||||
|
||||
type Source = 'dept' | 'chain' | 'all';
|
||||
|
||||
interface Props {
|
||||
userOptions: Api.SystemManage.UserSimple[];
|
||||
sources?: Source[];
|
||||
multiple?: boolean;
|
||||
disabledUserIds?: readonly string[];
|
||||
excludeUserIds?: readonly string[];
|
||||
disabledLabel?: string;
|
||||
placeholder?: string;
|
||||
title?: string;
|
||||
dialogWidth?: string;
|
||||
confirmText?: string;
|
||||
triggerSize?: 'default' | 'small' | 'large';
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
sources: () => ['dept', 'chain', 'all'],
|
||||
multiple: false,
|
||||
disabledUserIds: () => [],
|
||||
excludeUserIds: () => [],
|
||||
disabledLabel: '',
|
||||
placeholder: '请选择用户',
|
||||
title: '选择用户',
|
||||
dialogWidth: '820px',
|
||||
confirmText: '',
|
||||
triggerSize: 'default',
|
||||
disabled: false
|
||||
});
|
||||
|
||||
interface Emits {
|
||||
(e: 'change', value: string | string[] | null): void;
|
||||
(e: 'confirm', payload: { userIds: string[] }): void;
|
||||
(e: 'cancel'): void;
|
||||
}
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const model = defineModel<string | string[] | null>({ default: null });
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const { formItem } = useFormItem();
|
||||
|
||||
const source = ref<Source>(props.sources[0] ?? 'all');
|
||||
const currentNodeId = ref<string | null>(null);
|
||||
const treeSearch = ref('');
|
||||
const userSearch = ref('');
|
||||
const hideAdded = ref(false);
|
||||
|
||||
const disabledUserIdSet = computed(() => new Set(props.disabledUserIds.map(String)));
|
||||
const excludeUserIdSet = computed(() => new Set(props.excludeUserIds.map(String)));
|
||||
|
||||
const selection = usePickerSelection(() => ({ multiple: props.multiple }));
|
||||
const deptSource = useDeptSource(
|
||||
() => props.userOptions,
|
||||
() => new Set(selection.selectedIds.value),
|
||||
() => disabledUserIdSet.value
|
||||
);
|
||||
const chainSource = useChainSource(
|
||||
() => new Set(selection.selectedIds.value),
|
||||
() => disabledUserIdSet.value
|
||||
);
|
||||
|
||||
const showTabs = computed(() => props.sources.length > 1);
|
||||
|
||||
const userByIdMap = computed(() => new Map(props.userOptions.map(u => [String(u.id), u])));
|
||||
|
||||
const committedIds = computed<string[]>(() => {
|
||||
const value = model.value;
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(String);
|
||||
}
|
||||
|
||||
if (typeof value === 'string' && value) {
|
||||
return [value];
|
||||
}
|
||||
|
||||
return [];
|
||||
});
|
||||
|
||||
const selectedUsers = computed(() =>
|
||||
committedIds.value.map(id => userByIdMap.value.get(id)).filter((u): u is Api.SystemManage.UserSimple => Boolean(u))
|
||||
);
|
||||
|
||||
const lockedSelectedIds = computed(() => selection.selectedIds.value.filter(id => disabledUserIdSet.value.has(id)));
|
||||
|
||||
const visibleSelectedIds = computed(() => selection.selectedIds.value.slice(0, 4));
|
||||
const overflowSelectedCount = computed(() => Math.max(0, selection.size.value - 4));
|
||||
const overflowSelectedIds = computed(() => selection.selectedIds.value.slice(4));
|
||||
const overflowPopoverVisible = ref(false);
|
||||
const overflowReferenceEl = ref<HTMLElement | null>(null);
|
||||
|
||||
function handleOverflowOutsideClick(e: MouseEvent) {
|
||||
if (!overflowPopoverVisible.value) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
if (target.closest('.user-picker__overflow-popper')) return;
|
||||
if (target.closest('.el-popper')) return;
|
||||
if (overflowReferenceEl.value?.contains(target)) return;
|
||||
overflowPopoverVisible.value = false;
|
||||
}
|
||||
|
||||
onMounted(() => document.addEventListener('mousedown', handleOverflowOutsideClick, true));
|
||||
onBeforeUnmount(() => document.removeEventListener('mousedown', handleOverflowOutsideClick, true));
|
||||
|
||||
function getUserById(uid: string) {
|
||||
return userByIdMap.value.get(uid);
|
||||
}
|
||||
|
||||
function visibleUserIds(): string[] {
|
||||
let pool: string[];
|
||||
if (source.value === 'all' || !currentNodeId.value) {
|
||||
pool = props.userOptions.map(u => String(u.id));
|
||||
} else if (source.value === 'dept') {
|
||||
const node = deptSource.findNode(deptSource.tree.value, currentNodeId.value);
|
||||
pool = node ? deptSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
|
||||
} else {
|
||||
const node = chainSource.findNode(chainSource.tree.value, currentNodeId.value);
|
||||
pool = node ? chainSource.getNodeUserIds(node) : props.userOptions.map(u => String(u.id));
|
||||
}
|
||||
return pool.filter(id => !excludeUserIdSet.value.has(id));
|
||||
}
|
||||
|
||||
const filteredUserIds = computed(() => {
|
||||
let ids = visibleUserIds();
|
||||
if (hideAdded.value) ids = ids.filter(id => !disabledUserIdSet.value.has(id));
|
||||
const kw = userSearch.value.trim().toLowerCase();
|
||||
if (kw) {
|
||||
ids = ids.filter(id => {
|
||||
const u = getUserById(id);
|
||||
if (!u) return false;
|
||||
return (
|
||||
u.nickname.toLowerCase().includes(kw) ||
|
||||
(u.username ?? '').toLowerCase().includes(kw) ||
|
||||
(u.deptName ?? '').toLowerCase().includes(kw)
|
||||
);
|
||||
});
|
||||
}
|
||||
return ids;
|
||||
});
|
||||
|
||||
async function switchSource(next: Source) {
|
||||
if (source.value === next) return;
|
||||
source.value = next;
|
||||
currentNodeId.value = null;
|
||||
treeSearch.value = '';
|
||||
if (next === 'dept') await deptSource.ensureLoaded();
|
||||
else if (next === 'chain') await chainSource.ensureLoaded();
|
||||
}
|
||||
|
||||
function handleDeptNodeClick(data: Api.SystemManage.DeptSimple) {
|
||||
currentNodeId.value = deptSource.nodeKey(data);
|
||||
}
|
||||
|
||||
function handleChainNodeClick(data: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
currentNodeId.value = chainSource.nodeKey(data);
|
||||
}
|
||||
|
||||
function toggleDeptCheck(node: Api.SystemManage.DeptSimple) {
|
||||
if (!props.multiple) return;
|
||||
const ids = deptSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
|
||||
const state = deptSource.getNodeCheckState(node);
|
||||
if (state === 'all') selection.removeMany(ids);
|
||||
else selection.addMany(ids);
|
||||
}
|
||||
|
||||
function toggleChainCheck(node: Api.SystemManage.UserManagementRelationTreeRespVO) {
|
||||
if (!props.multiple) return;
|
||||
const ids = chainSource.getNodeUserIds(node).filter(id => !disabledUserIdSet.value.has(id));
|
||||
const state = chainSource.getNodeCheckState(node);
|
||||
if (state === 'all') selection.removeMany(ids);
|
||||
else selection.addMany(ids);
|
||||
}
|
||||
|
||||
function toggleUser(uid: string) {
|
||||
if (disabledUserIdSet.value.has(uid)) return;
|
||||
selection.toggle(uid);
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
selection.clear(lockedSelectedIds.value);
|
||||
}
|
||||
|
||||
function clearUserFilter() {
|
||||
userSearch.value = '';
|
||||
hideAdded.value = false;
|
||||
}
|
||||
|
||||
const confirmDisabled = computed(() => {
|
||||
if (!props.multiple) return !selection.selectedIds.value.length;
|
||||
return selection.size.value === 0;
|
||||
});
|
||||
|
||||
const resolvedConfirmText = computed(() => {
|
||||
if (props.confirmText) return props.confirmText;
|
||||
if (!props.multiple) return '确定';
|
||||
return `确定(${selection.size.value})`;
|
||||
});
|
||||
|
||||
function handleConfirm() {
|
||||
if (confirmDisabled.value) return;
|
||||
const value = selection.commit();
|
||||
model.value = value;
|
||||
emit('change', value);
|
||||
emit('confirm', { userIds: selection.selectedIds.value });
|
||||
visible.value = false;
|
||||
nextTick(() => {
|
||||
formItem?.validate?.('change').catch(() => {});
|
||||
});
|
||||
}
|
||||
|
||||
function handleCancel() {
|
||||
emit('cancel');
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
function openDialog() {
|
||||
visible.value = true;
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (value) {
|
||||
treeSearch.value = '';
|
||||
userSearch.value = '';
|
||||
hideAdded.value = false;
|
||||
currentNodeId.value = null;
|
||||
source.value = props.sources[0] ?? 'all';
|
||||
selection.reset(model.value);
|
||||
if (source.value === 'dept') await deptSource.ensureLoaded();
|
||||
else if (source.value === 'chain') await chainSource.ensureLoaded();
|
||||
await nextTick();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="business-user-picker">
|
||||
<slot name="trigger" :open="openDialog" :selected-users="selectedUsers" :disabled="disabled">
|
||||
<UserPickerTrigger
|
||||
:selected-users="selectedUsers"
|
||||
:placeholder="placeholder"
|
||||
:multiple="multiple"
|
||||
:disabled="disabled"
|
||||
:size="triggerSize"
|
||||
@open="openDialog"
|
||||
/>
|
||||
</slot>
|
||||
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="lg"
|
||||
:width="dialogWidth"
|
||||
max-body-height="540px"
|
||||
:confirm-disabled="confirmDisabled"
|
||||
:confirm-text="resolvedConfirmText"
|
||||
@confirm="handleConfirm"
|
||||
@cancel="handleCancel"
|
||||
>
|
||||
<div class="user-picker">
|
||||
<div v-if="showTabs" class="user-picker__tabs">
|
||||
<button
|
||||
v-for="tab in sources"
|
||||
:key="tab"
|
||||
class="user-picker__tab"
|
||||
:class="{ 'is-active': source === tab }"
|
||||
type="button"
|
||||
@click="switchSource(tab)"
|
||||
>
|
||||
{{ tab === 'dept' ? '部门' : tab === 'chain' ? '团队' : '全部用户' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="user-picker__picker" :class="{ 'is-single': source === 'all' }">
|
||||
<div v-if="source !== 'all'" class="user-picker__col user-picker__col--tree">
|
||||
<div class="user-picker__col-head">{{ source === 'dept' ? '部门' : '团队' }}</div>
|
||||
<div class="user-picker__search">
|
||||
<ElInput
|
||||
v-model="treeSearch"
|
||||
size="small"
|
||||
clearable
|
||||
:placeholder="source === 'dept' ? '搜索部门…' : '搜索成员…'"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
v-loading="source === 'dept' ? deptSource.loading.value : chainSource.loading.value"
|
||||
class="user-picker__col-body"
|
||||
>
|
||||
<ElTree
|
||||
v-if="source === 'dept'"
|
||||
:data="deptSource.filterByKeyword(treeSearch)"
|
||||
:props="deptSource.treeProps.value"
|
||||
node-key="id"
|
||||
:expand-on-click-node="false"
|
||||
:default-expand-all="true"
|
||||
:indent="14"
|
||||
class="user-picker__tree"
|
||||
@node-click="handleDeptNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === String(data.id) }">
|
||||
<span
|
||||
v-if="multiple"
|
||||
class="user-picker__node-check"
|
||||
:class="{
|
||||
'is-checked': deptSource.getNodeCheckState(data) === 'all',
|
||||
'is-partial': deptSource.getNodeCheckState(data) === 'partial'
|
||||
}"
|
||||
@click.stop="toggleDeptCheck(data)"
|
||||
/>
|
||||
<IconEpOfficeBuilding class="user-picker__node-icon" />
|
||||
<span class="user-picker__node-label">{{ data.name }}</span>
|
||||
<span v-if="deptSource.getMetaText(data)" class="user-picker__node-meta">
|
||||
{{ deptSource.getMetaText(data) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
<ElTree
|
||||
v-else
|
||||
:data="chainSource.filterByKeyword(treeSearch)"
|
||||
:props="chainSource.treeProps.value"
|
||||
node-key="userId"
|
||||
:expand-on-click-node="false"
|
||||
:default-expand-all="true"
|
||||
:indent="14"
|
||||
class="user-picker__tree"
|
||||
@node-click="handleChainNodeClick"
|
||||
>
|
||||
<template #default="{ data }">
|
||||
<div class="user-picker__node" :class="{ 'is-active': currentNodeId === chainSource.nodeKey(data) }">
|
||||
<span
|
||||
v-if="multiple"
|
||||
class="user-picker__node-check"
|
||||
:class="{
|
||||
'is-checked': chainSource.getNodeCheckState(data) === 'all',
|
||||
'is-partial': chainSource.getNodeCheckState(data) === 'partial'
|
||||
}"
|
||||
@click.stop="toggleChainCheck(data)"
|
||||
/>
|
||||
<IconEpUser class="user-picker__node-icon" />
|
||||
<span class="user-picker__node-label">{{ data.userNickname }}</span>
|
||||
<span v-if="chainSource.getMetaText(data)" class="user-picker__node-meta">
|
||||
{{ chainSource.getMetaText(data) }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="user-picker__col user-picker__col--users">
|
||||
<div class="user-picker__col-head user-picker__col-head--user">
|
||||
<span>
|
||||
候选用户(
|
||||
<span>{{ filteredUserIds.length }}</span>
|
||||
人)
|
||||
</span>
|
||||
<label v-if="multiple" class="user-picker__hide-added">
|
||||
<ElCheckbox v-model="hideAdded">隐藏已添加</ElCheckbox>
|
||||
</label>
|
||||
</div>
|
||||
<div class="user-picker__search">
|
||||
<ElInput
|
||||
v-model="userSearch"
|
||||
size="small"
|
||||
clearable
|
||||
:placeholder="source === 'all' ? '搜索用户名 / 部门…' : '搜索用户名…'"
|
||||
/>
|
||||
</div>
|
||||
<div class="user-picker__col-body">
|
||||
<div v-if="!filteredUserIds.length" class="user-picker__empty">
|
||||
该节点下没有匹配用户
|
||||
<button
|
||||
v-if="userSearch || hideAdded"
|
||||
type="button"
|
||||
class="user-picker__link user-picker__empty-action"
|
||||
@click="clearUserFilter"
|
||||
>
|
||||
清除筛选条件
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-for="uid in filteredUserIds"
|
||||
:key="uid"
|
||||
class="user-picker__user-row"
|
||||
:class="{
|
||||
'is-disabled': disabledUserIdSet.has(uid),
|
||||
'is-selected': !multiple && selection.has(uid)
|
||||
}"
|
||||
@click="toggleUser(uid)"
|
||||
>
|
||||
<span v-if="multiple" class="user-picker__node-check" :class="{ 'is-checked': selection.has(uid) }" />
|
||||
<span class="user-picker__user-avatar">{{ (getUserById(uid)?.nickname ?? '?').slice(0, 1) }}</span>
|
||||
<div class="user-picker__user-main">
|
||||
<div class="user-picker__user-name">{{ getUserById(uid)?.nickname }}</div>
|
||||
</div>
|
||||
<span v-if="disabledUserIdSet.has(uid) && disabledLabel" class="user-picker__user-tag">
|
||||
{{ disabledLabel }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="multiple" class="user-picker__selected">
|
||||
<div class="user-picker__selected-head">
|
||||
<span>
|
||||
已选
|
||||
<strong>{{ selection.size.value }}</strong>
|
||||
人
|
||||
</span>
|
||||
<button
|
||||
v-if="selection.size.value > lockedSelectedIds.length"
|
||||
type="button"
|
||||
class="user-picker__link user-picker__link--danger"
|
||||
@click="clearAll"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="selection.size.value === 0" class="user-picker__selected-empty">从左侧勾选用户后会出现在这里</div>
|
||||
<div v-else class="user-picker__chips">
|
||||
<span v-for="uid in visibleSelectedIds" :key="uid" class="user-picker__chip">
|
||||
<span class="user-picker__chip-name">
|
||||
{{ getUserById(uid)?.nickname }}
|
||||
<ElTooltip v-if="disabledUserIdSet.has(uid) && disabledLabel" :content="disabledLabel" placement="top">
|
||||
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
<button
|
||||
v-if="!disabledUserIdSet.has(uid)"
|
||||
type="button"
|
||||
class="user-picker__chip-x"
|
||||
@click="toggleUser(uid)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
<ElPopover
|
||||
v-if="overflowSelectedCount > 0"
|
||||
:visible="overflowPopoverVisible"
|
||||
placement="top-end"
|
||||
:width="360"
|
||||
popper-class="user-picker__overflow-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<button
|
||||
ref="overflowReferenceEl"
|
||||
type="button"
|
||||
class="user-picker__chip-more"
|
||||
@click="overflowPopoverVisible = !overflowPopoverVisible"
|
||||
>
|
||||
+{{ overflowSelectedCount }} 更多
|
||||
</button>
|
||||
</template>
|
||||
<div class="user-picker__overflow-head">
|
||||
<span>
|
||||
另外
|
||||
<strong>{{ overflowSelectedCount }}</strong>
|
||||
人
|
||||
</span>
|
||||
</div>
|
||||
<div class="user-picker__overflow-chips">
|
||||
<span v-for="uid in overflowSelectedIds" :key="uid" class="user-picker__chip">
|
||||
<span class="user-picker__chip-name">
|
||||
{{ getUserById(uid)?.nickname }}
|
||||
<ElTooltip
|
||||
v-if="disabledUserIdSet.has(uid) && disabledLabel"
|
||||
:content="disabledLabel"
|
||||
placement="top"
|
||||
>
|
||||
<span class="user-picker__chip-lock">·{{ disabledLabel }}</span>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
<button
|
||||
v-if="!disabledUserIdSet.has(uid)"
|
||||
type="button"
|
||||
class="user-picker__chip-x"
|
||||
@click="toggleUser(uid)"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.business-user-picker {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* picker 内容上下贴满,标准 body padding 显得空——仅在含本组件的 dialog 上收紧 */
|
||||
:deep(.business-form-dialog__body:has(.user-picker)) {
|
||||
padding-top: 8px !important;
|
||||
padding-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.user-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.user-picker__tabs {
|
||||
display: flex;
|
||||
gap: 2px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.user-picker__tab {
|
||||
padding: 6px 14px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-size: 12.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
}
|
||||
|
||||
.user-picker__tab.is-active {
|
||||
color: var(--el-color-primary);
|
||||
border-bottom-color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.user-picker__picker {
|
||||
display: grid;
|
||||
grid-template-columns: 240px 1fr;
|
||||
gap: 12px;
|
||||
height: min(280px, 44vh);
|
||||
min-height: 260px;
|
||||
}
|
||||
|
||||
.user-picker__picker.is-single {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.user-picker__col {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.user-picker__col-head {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
background: #fafbfc;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.user-picker__col-head--user {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.user-picker__col-body {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-picker__search {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--el-border-color);
|
||||
}
|
||||
|
||||
.user-picker__tree {
|
||||
padding: 4px;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__content) {
|
||||
height: 32px;
|
||||
padding-right: 8px !important;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__content:hover) {
|
||||
background: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__expand-icon) {
|
||||
padding: 4px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-picker__tree :deep(.el-tree-node__expand-icon.is-leaf) {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.user-picker__node {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
font-size: 13px;
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node.is-active {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-picker__node-check {
|
||||
position: relative;
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 2px;
|
||||
background: var(--el-bg-color);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__node-check:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-checked {
|
||||
background: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-checked::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 1px;
|
||||
left: 4px;
|
||||
width: 3px;
|
||||
height: 7px;
|
||||
border: solid #fff;
|
||||
border-width: 0 1px 1px 0;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-partial {
|
||||
background: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-check.is-partial::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 8px;
|
||||
height: 2px;
|
||||
margin: -1px 0 0 -4px;
|
||||
background: #fff;
|
||||
border-radius: 1px;
|
||||
}
|
||||
|
||||
.user-picker__node-icon {
|
||||
flex-shrink: 0;
|
||||
font-size: 15px;
|
||||
color: var(--el-text-color-secondary);
|
||||
transition: color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__node.is-active .user-picker__node-icon {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker__node-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker__node-meta {
|
||||
flex-shrink: 0;
|
||||
padding-left: 6px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.user-picker__node.is-active .user-picker__node-meta {
|
||||
color: var(--el-color-primary);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.user-picker__user-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 0 10px;
|
||||
height: 36px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.user-picker__user-row:hover {
|
||||
background: var(--el-fill-color);
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-disabled {
|
||||
opacity: 0.55;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-disabled:hover {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-selected {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.user-picker__user-row.is-selected .user-picker__user-name {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.user-picker__user-avatar {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #c7d2fe, #93c5fd);
|
||||
color: #fff;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.user-picker__user-main {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.user-picker__user-name {
|
||||
font-size: 13px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker__user-tag {
|
||||
flex-shrink: 0;
|
||||
padding: 1px 7px;
|
||||
border-radius: 999px;
|
||||
font-size: 11px;
|
||||
background: var(--el-color-warning-light-7);
|
||||
color: var(--el-color-warning-dark-2);
|
||||
}
|
||||
|
||||
.user-picker__empty {
|
||||
padding: 40px 0;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-picker__hide-added {
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.user-picker__empty-action {
|
||||
display: block;
|
||||
margin: 6px auto 0;
|
||||
}
|
||||
|
||||
.user-picker__selected {
|
||||
padding: 8px 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.user-picker__selected-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 6px;
|
||||
font-size: 11.5px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.user-picker__selected-head strong {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 700;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.user-picker__selected-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 26px;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.user-picker__chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 26px;
|
||||
}
|
||||
|
||||
.user-picker__chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 2px 4px 2px 8px;
|
||||
background: #fff;
|
||||
border: 1px solid var(--el-border-color-darker);
|
||||
border-radius: 999px;
|
||||
font-size: 11.5px;
|
||||
}
|
||||
|
||||
.user-picker__chip-name {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.user-picker__chip-lock {
|
||||
color: var(--el-color-warning-dark-2);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-picker__chip-x {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-regular);
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.user-picker__chip-x:hover {
|
||||
background: var(--el-color-danger);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.user-picker__chip-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border-radius: 999px;
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 11.5px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker__chip-more:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.user-picker__overflow-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 8px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-regular);
|
||||
}
|
||||
|
||||
.user-picker__overflow-head strong {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.user-picker__overflow-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
max-height: 260px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.user-picker__link {
|
||||
background: transparent;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 11.5px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.user-picker__link--danger {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.user-picker__link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,127 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'UserPickerTrigger' });
|
||||
|
||||
interface Props {
|
||||
selectedUsers: Api.SystemManage.UserSimple[];
|
||||
placeholder: string;
|
||||
multiple: boolean;
|
||||
disabled: boolean;
|
||||
size: 'default' | 'small' | 'large';
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{ (e: 'open'): void }>();
|
||||
|
||||
const displayText = computed(() => {
|
||||
if (!props.selectedUsers.length) return '';
|
||||
if (!props.multiple) return props.selectedUsers[0]?.nickname ?? '';
|
||||
const head = props.selectedUsers
|
||||
.slice(0, 2)
|
||||
.map(u => u.nickname)
|
||||
.join('、');
|
||||
const rest = props.selectedUsers.length - 2;
|
||||
return rest > 0 ? `${head} +${rest}` : head;
|
||||
});
|
||||
|
||||
const sizeClass = computed(() => `is-${props.size}`);
|
||||
|
||||
function handleClick() {
|
||||
if (props.disabled) return;
|
||||
emit('open');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="user-picker-trigger"
|
||||
:class="[sizeClass, { 'is-disabled': disabled }]"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click="handleClick"
|
||||
@keydown.enter.prevent="handleClick"
|
||||
@keydown.space.prevent="handleClick"
|
||||
>
|
||||
<span v-if="displayText" class="user-picker-trigger__text">{{ displayText }}</span>
|
||||
<span v-else class="user-picker-trigger__placeholder">{{ placeholder }}</span>
|
||||
<span class="user-picker-trigger__suffix">
|
||||
<icon-ep:arrow-down />
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.user-picker-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 32px;
|
||||
padding: 0 30px 0 11px;
|
||||
background: var(--el-fill-color-blank);
|
||||
border: 1px solid var(--el-border-color);
|
||||
border-radius: var(--el-border-radius-base);
|
||||
font-size: var(--el-font-size-base);
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
box-sizing: border-box;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.user-picker-trigger.is-small {
|
||||
min-height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.user-picker-trigger.is-large {
|
||||
min-height: 40px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.user-picker-trigger:hover:not(.is-disabled) {
|
||||
border-color: var(--el-border-color-hover);
|
||||
}
|
||||
|
||||
.user-picker-trigger:focus-visible {
|
||||
outline: none;
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.user-picker-trigger.is-disabled {
|
||||
background: var(--el-disabled-bg-color);
|
||||
color: var(--el-disabled-text-color);
|
||||
cursor: not-allowed;
|
||||
border-color: var(--el-border-color);
|
||||
}
|
||||
|
||||
.user-picker-trigger__text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker-trigger__placeholder {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
color: var(--el-text-color-placeholder);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.user-picker-trigger__suffix {
|
||||
position: absolute;
|
||||
right: 8px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
display: inline-flex;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 14px;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,90 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { fetchGetUserManagementRelationTree } from '@/service/api';
|
||||
import type { TreeCheckState } from './use-dept-source';
|
||||
|
||||
type ChainNode = Api.SystemManage.UserManagementRelationTreeRespVO;
|
||||
|
||||
export function useChainSource(selectedIds: () => Set<string>, disabledUserIdSet: () => Set<string>) {
|
||||
const tree = ref<ChainNode[]>([]);
|
||||
const loading = ref(false);
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (loaded) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchGetUserManagementRelationTree({ fromUserIndex: false });
|
||||
tree.value = data ?? [];
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function nodeKey(node: ChainNode): string {
|
||||
return node.id ?? `chain_${node.userId}`;
|
||||
}
|
||||
|
||||
function getNodeUserIds(node: ChainNode): string[] {
|
||||
const ids = new Set<string>([String(node.userId)]);
|
||||
if (node.children) {
|
||||
for (const c of node.children) {
|
||||
for (const id of getNodeUserIds(c)) ids.add(id);
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function getNodeCheckState(node: ChainNode): TreeCheckState {
|
||||
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
|
||||
if (!ids.length) return 'none';
|
||||
const sel = ids.filter(id => selectedIds().has(id)).length;
|
||||
if (sel === 0) return 'none';
|
||||
if (sel === ids.length) return 'all';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function findNode(list: ChainNode[], key: string): ChainNode | null {
|
||||
for (const n of list) {
|
||||
if (nodeKey(n) === key) return n;
|
||||
if (n.children) {
|
||||
const r = findNode(n.children, key);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchKeyword(node: ChainNode, kw: string): boolean {
|
||||
if (!kw) return true;
|
||||
if (node.userNickname.toLowerCase().includes(kw)) return true;
|
||||
if (node.children) return node.children.some(c => matchKeyword(c, kw));
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterByKeyword(kw: string) {
|
||||
const lower = kw.trim().toLowerCase();
|
||||
if (!lower) return tree.value;
|
||||
return tree.value.filter(n => matchKeyword(n, lower));
|
||||
}
|
||||
|
||||
function getMetaText(node: ChainNode): string {
|
||||
const total = getNodeUserIds(node).length;
|
||||
return total > 1 ? `${total} 人` : '';
|
||||
}
|
||||
|
||||
const treeProps = computed(() => ({ children: 'children', label: 'userNickname' }) as const);
|
||||
|
||||
return {
|
||||
tree,
|
||||
loading,
|
||||
treeProps,
|
||||
ensureLoaded,
|
||||
getNodeUserIds,
|
||||
getNodeCheckState,
|
||||
findNode,
|
||||
filterByKeyword,
|
||||
getMetaText,
|
||||
nodeKey
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { fetchGetDeptSimpleList } from '@/service/api';
|
||||
import { buildMenuTree } from '@/views/system/shared/menu-tree';
|
||||
|
||||
export type TreeCheckState = 'none' | 'partial' | 'all';
|
||||
|
||||
export function useDeptSource(
|
||||
userOptions: () => Api.SystemManage.UserSimple[],
|
||||
selectedIds: () => Set<string>,
|
||||
disabledUserIdSet: () => Set<string>
|
||||
) {
|
||||
const tree = ref<Api.SystemManage.DeptSimple[]>([]);
|
||||
const loading = ref(false);
|
||||
let loaded = false;
|
||||
|
||||
async function ensureLoaded() {
|
||||
if (loaded) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data } = await fetchGetDeptSimpleList();
|
||||
tree.value = data ? buildMenuTree(data) : [];
|
||||
loaded = true;
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function collectDeptIds(node: Api.SystemManage.DeptSimple): string[] {
|
||||
const ids: string[] = [String(node.id)];
|
||||
if (node.children) {
|
||||
for (const c of node.children) ids.push(...collectDeptIds(c));
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
function getNodeUserIds(node: Api.SystemManage.DeptSimple): string[] {
|
||||
const deptIds = new Set(collectDeptIds(node));
|
||||
return userOptions()
|
||||
.filter(u => u.deptId !== null && u.deptId !== undefined && deptIds.has(String(u.deptId)))
|
||||
.map(u => String(u.id));
|
||||
}
|
||||
|
||||
function getNodeCheckState(node: Api.SystemManage.DeptSimple): TreeCheckState {
|
||||
const ids = getNodeUserIds(node).filter(id => !disabledUserIdSet().has(id));
|
||||
if (!ids.length) return 'none';
|
||||
const sel = ids.filter(id => selectedIds().has(id)).length;
|
||||
if (sel === 0) return 'none';
|
||||
if (sel === ids.length) return 'all';
|
||||
return 'partial';
|
||||
}
|
||||
|
||||
function findNode(list: Api.SystemManage.DeptSimple[], key: string): Api.SystemManage.DeptSimple | null {
|
||||
for (const n of list) {
|
||||
if (String(n.id) === key) return n;
|
||||
if (n.children) {
|
||||
const r = findNode(n.children, key);
|
||||
if (r) return r;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function matchKeyword(node: Api.SystemManage.DeptSimple, kw: string): boolean {
|
||||
if (!kw) return true;
|
||||
if (node.name.toLowerCase().includes(kw)) return true;
|
||||
if (node.children) return node.children.some(c => matchKeyword(c, kw));
|
||||
return false;
|
||||
}
|
||||
|
||||
function filterByKeyword(kw: string) {
|
||||
const lower = kw.trim().toLowerCase();
|
||||
if (!lower) return tree.value;
|
||||
return tree.value.filter(n => matchKeyword(n, lower));
|
||||
}
|
||||
|
||||
function getMetaText(node: Api.SystemManage.DeptSimple): string {
|
||||
const total = getNodeUserIds(node).length;
|
||||
return total > 0 ? `${total} 人` : '';
|
||||
}
|
||||
|
||||
function nodeKey(node: Api.SystemManage.DeptSimple): string {
|
||||
return String(node.id);
|
||||
}
|
||||
|
||||
const treeProps = computed(() => ({ children: 'children', label: 'name' }) as const);
|
||||
|
||||
return {
|
||||
tree,
|
||||
loading,
|
||||
treeProps,
|
||||
ensureLoaded,
|
||||
getNodeUserIds,
|
||||
getNodeCheckState,
|
||||
findNode,
|
||||
filterByKeyword,
|
||||
getMetaText,
|
||||
nodeKey
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
export interface PickerSelectionOptions {
|
||||
multiple: boolean;
|
||||
}
|
||||
|
||||
export function usePickerSelection(options: () => PickerSelectionOptions) {
|
||||
const multiSet = ref<Set<string>>(new Set());
|
||||
const singleId = ref<string | null>(null);
|
||||
|
||||
const multiple = computed(() => options().multiple);
|
||||
|
||||
function has(userId: string): boolean {
|
||||
if (multiple.value) return multiSet.value.has(userId);
|
||||
return singleId.value === userId;
|
||||
}
|
||||
|
||||
function toggle(userId: string) {
|
||||
if (multiple.value) {
|
||||
if (multiSet.value.has(userId)) multiSet.value.delete(userId);
|
||||
else multiSet.value.add(userId);
|
||||
multiSet.value = new Set(multiSet.value);
|
||||
} else {
|
||||
singleId.value = singleId.value === userId ? null : userId;
|
||||
}
|
||||
}
|
||||
|
||||
function addMany(userIds: readonly string[]) {
|
||||
if (!multiple.value) {
|
||||
singleId.value = userIds[0] ?? singleId.value;
|
||||
return;
|
||||
}
|
||||
for (const id of userIds) multiSet.value.add(id);
|
||||
multiSet.value = new Set(multiSet.value);
|
||||
}
|
||||
|
||||
function removeMany(userIds: readonly string[]) {
|
||||
if (!multiple.value) {
|
||||
if (singleId.value && userIds.includes(singleId.value)) singleId.value = null;
|
||||
return;
|
||||
}
|
||||
for (const id of userIds) multiSet.value.delete(id);
|
||||
multiSet.value = new Set(multiSet.value);
|
||||
}
|
||||
|
||||
function clear(preserveIds?: readonly string[]) {
|
||||
const keep = new Set((preserveIds ?? []).map(String));
|
||||
if (multiple.value) {
|
||||
const next = new Set<string>();
|
||||
for (const id of multiSet.value) {
|
||||
if (keep.has(id)) next.add(id);
|
||||
}
|
||||
multiSet.value = next;
|
||||
} else if (singleId.value && !keep.has(singleId.value)) singleId.value = null;
|
||||
}
|
||||
|
||||
function reset(initial: string | string[] | null | undefined) {
|
||||
if (multiple.value) {
|
||||
const ids = Array.isArray(initial) ? initial.map(String) : [];
|
||||
multiSet.value = new Set(ids);
|
||||
} else {
|
||||
singleId.value = typeof initial === 'string' ? initial : null;
|
||||
}
|
||||
}
|
||||
|
||||
const selectedIds = computed<string[]>(() => {
|
||||
if (multiple.value) return [...multiSet.value];
|
||||
return singleId.value ? [singleId.value] : [];
|
||||
});
|
||||
|
||||
const size = computed(() => selectedIds.value.length);
|
||||
|
||||
function commit(): string | string[] | null {
|
||||
if (multiple.value) return [...multiSet.value];
|
||||
return singleId.value;
|
||||
}
|
||||
|
||||
return {
|
||||
selectedIds,
|
||||
size,
|
||||
has,
|
||||
toggle,
|
||||
addMany,
|
||||
removeMany,
|
||||
clear,
|
||||
reset,
|
||||
commit
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, watch } from 'vue';
|
||||
import { useDictStore } from '@/store/modules/dict';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
|
||||
defineOptions({ name: 'DictSelect' });
|
||||
|
||||
const ensuredEmptyDictCodes = new Set<string>();
|
||||
|
||||
interface Props {
|
||||
dictCode: string;
|
||||
placeholder?: string;
|
||||
@@ -14,6 +17,8 @@ interface Props {
|
||||
multiple?: boolean;
|
||||
collapseTags?: boolean;
|
||||
collapseTagsTooltip?: boolean;
|
||||
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
|
||||
showRemark?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -24,29 +29,53 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
onlyEnabled: true,
|
||||
multiple: false,
|
||||
collapseTags: false,
|
||||
collapseTagsTooltip: false
|
||||
collapseTagsTooltip: false,
|
||||
showRemark: false
|
||||
});
|
||||
|
||||
const model = defineModel<string | number | Array<string | number> | null | undefined>({
|
||||
default: undefined
|
||||
});
|
||||
|
||||
const dictStore = useDictStore();
|
||||
const { enabledDictData, dictData } = useDict(() => props.dictCode);
|
||||
|
||||
const dictOptions = computed(() => {
|
||||
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
|
||||
|
||||
return source.map(item => ({
|
||||
label: item.label,
|
||||
value: item.value
|
||||
value: item.value,
|
||||
colorType: item.colorType ?? null,
|
||||
remark: item.remark ?? null
|
||||
}));
|
||||
});
|
||||
|
||||
// 单选时取当前选中项的 colorType,用于触发器 prefix 色块
|
||||
const selectedColorType = computed<string | null>(() => {
|
||||
if (props.multiple) return null;
|
||||
const value = model.value;
|
||||
if (value === null || value === undefined || value === '') return null;
|
||||
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
|
||||
});
|
||||
|
||||
watch(
|
||||
() => [props.dictCode, dictOptions.value.length, dictStore.initialized, dictStore.loading] as const,
|
||||
async ([dictCode, optionCount, initialized, loading]) => {
|
||||
if (!dictCode || optionCount > 0 || !initialized || loading || ensuredEmptyDictCodes.has(dictCode)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensuredEmptyDictCodes.add(dictCode);
|
||||
await dictStore.ensureDictData(dictCode, true);
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElSelect
|
||||
v-model="model"
|
||||
class="w-full"
|
||||
class="dict-select w-full"
|
||||
:placeholder="props.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:clearable="props.clearable"
|
||||
@@ -55,8 +84,51 @@ const dictOptions = computed(() => {
|
||||
:collapse-tags="props.collapseTags"
|
||||
:collapse-tags-tooltip="props.collapseTagsTooltip"
|
||||
>
|
||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
<template v-if="selectedColorType" #prefix>
|
||||
<span class="dict-select__color-dot" :style="{ background: selectedColorType }" />
|
||||
</template>
|
||||
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value">
|
||||
<span class="dict-select__option">
|
||||
<span
|
||||
v-if="item.colorType"
|
||||
class="dict-select__color-dot dict-select__color-dot--option"
|
||||
:style="{ background: item.colorType }"
|
||||
/>
|
||||
<span class="dict-select__option-label">{{ item.label }}</span>
|
||||
<span v-if="props.showRemark && item.remark" class="dict-select__option-remark">{{ item.remark }}</span>
|
||||
</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
<style scoped>
|
||||
.dict-select__color-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
vertical-align: middle;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
|
||||
.dict-select__color-dot--option {
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.dict-select__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.dict-select__option-label {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.dict-select__option-remark {
|
||||
margin-left: auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import DictText from './dict-text.vue';
|
||||
|
||||
defineOptions({ name: 'DictTag' });
|
||||
@@ -14,6 +16,7 @@ interface Props {
|
||||
fallback?: string;
|
||||
separator?: string;
|
||||
onlyEnabled?: boolean;
|
||||
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
|
||||
type?: DictTagType;
|
||||
effect?: DictTagEffect;
|
||||
size?: DictTagSize;
|
||||
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
|
||||
size: 'default',
|
||||
round: false
|
||||
});
|
||||
|
||||
const { getItem } = useDict(() => props.dictCode);
|
||||
|
||||
// 单值才支持自动取色;多值(数组)走默认渲染避免歧义
|
||||
const autoColorType = computed<string | null>(() => {
|
||||
if (Array.isArray(props.value)) return null;
|
||||
if (props.value === null || props.value === undefined || props.value === '') return null;
|
||||
return getItem(props.value, { onlyEnabled: props.onlyEnabled })?.colorType ?? null;
|
||||
});
|
||||
|
||||
// props.type 优先(向后兼容);其次字典 colorType(hex);都没有时回落到原生 ElTag 默认
|
||||
const hexColor = computed(() => (props.type ? null : autoColorType.value));
|
||||
|
||||
const tagStyle = computed<Record<string, string> | null>(() => {
|
||||
if (!hexColor.value) return null;
|
||||
// light 效果:浅底 + 主色字 + 中浅边;plain/dark 同样的色调思路,仅明度差异
|
||||
const fg = hexColor.value;
|
||||
if (props.effect === 'dark') {
|
||||
return {
|
||||
color: '#fff',
|
||||
background: fg,
|
||||
borderColor: fg
|
||||
};
|
||||
}
|
||||
if (props.effect === 'plain') {
|
||||
return {
|
||||
color: fg,
|
||||
background: 'transparent',
|
||||
borderColor: `color-mix(in srgb, ${fg} 50%, white)`
|
||||
};
|
||||
}
|
||||
// light(默认)
|
||||
return {
|
||||
color: fg,
|
||||
background: `color-mix(in srgb, ${fg} 12%, white)`,
|
||||
borderColor: `color-mix(in srgb, ${fg} 30%, white)`
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round">
|
||||
<ElTag
|
||||
:type="props.type"
|
||||
:effect="props.effect"
|
||||
:size="props.size"
|
||||
:round="props.round"
|
||||
:style="tagStyle ?? undefined"
|
||||
>
|
||||
<DictText
|
||||
:dict-code="props.dictCode"
|
||||
:value="props.value"
|
||||
|
||||
@@ -24,6 +24,8 @@ export interface SearchField {
|
||||
options?: Option[];
|
||||
/** dict 类型的字典编码 */
|
||||
dictCode?: string;
|
||||
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急") */
|
||||
showRemark?: boolean;
|
||||
/** 占位提示文本 */
|
||||
placeholder?: string;
|
||||
/** select 类型的自定义选项渲染函数 */
|
||||
@@ -179,6 +181,7 @@ function handleSearch() {
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
@@ -275,6 +278,7 @@ function handleSearch() {
|
||||
:dict-code="field.dictCode!"
|
||||
:placeholder="field.placeholder"
|
||||
:disabled="props.disabled"
|
||||
:show-remark="field.showRemark"
|
||||
@update:model-value="val => (props.modelValue[field.key] = val)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
@@ -45,10 +45,14 @@ export const SYSTEM_USER_COMPANY_DICT_CODE = 'system_user_company';
|
||||
export const RDMS_REQ_SOURCE_TYPE_DICT_CODE = 'rdms_req_source_type';
|
||||
|
||||
/**
|
||||
* 需求优先级字典编码
|
||||
* 优先级字典编码
|
||||
*
|
||||
* 对应业务字段:需求相关接口和页面中的 priority
|
||||
* 来源口径:产品需求文档中定义,标签包括P0、P1、P2、P3
|
||||
* 对应业务字段:
|
||||
* - 需求(产品需求 / 项目需求)的 priority(旧口径:Integer,数字大=高,0=低 / 3=紧急)
|
||||
* - 任务 / 执行的 priority(新口径:String "0"~"3",数字越小优先级越高,"1"=默认 P1)
|
||||
*
|
||||
* 来源口径:后端统一字典 rdms_req_priority,4 档标签 P0/P1/P2/P3。
|
||||
* 数值取值口径不同是已知遗留——前端用本字典的 label / colorType 渲染即可,不要硬编码 P0~P3。
|
||||
*/
|
||||
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority';
|
||||
|
||||
@@ -85,12 +89,12 @@ export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_typ
|
||||
export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_object_type';
|
||||
|
||||
/**
|
||||
* 工作日志完成难度字典编码
|
||||
* 任务/个人事项类型字典编码
|
||||
*
|
||||
* 对应业务字段:任务/个人事项工作日志中的 difficulty
|
||||
* 来源口径:后端工作日志表 `rdms_task_worklog.difficulty` 字段注释明确使用字典 `rdms_worklog_difficulty`
|
||||
* 对应业务字段:任务、个人事项中的 type
|
||||
* 来源口径:用户明确指定任务/个人事项类型下拉来自运行时字典 rdms_task_item_type
|
||||
*/
|
||||
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_worklog_difficulty';
|
||||
export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task_item_type';
|
||||
|
||||
/**
|
||||
* 需求允许删除的状态字典编码
|
||||
@@ -99,3 +103,27 @@ export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_worklog_difficulty';
|
||||
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
|
||||
*/
|
||||
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
|
||||
|
||||
/**
|
||||
* 工作日志难度字典编码
|
||||
*
|
||||
* 对应业务字段:任务/个人事项工作日志中的 difficulty
|
||||
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task_item_worklog_difficulty
|
||||
*/
|
||||
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task_item_worklog_difficulty';
|
||||
|
||||
/**
|
||||
* 加班申请状态字典编码
|
||||
*
|
||||
* 对应业务字段:加班申请中的 statusCode
|
||||
* 来源口径:`overtime-application-design.md` 明确状态字典为 rdms_overtime_application_status
|
||||
*/
|
||||
export const RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE = 'rdms_overtime_application_status';
|
||||
|
||||
/**
|
||||
* 加班时长快捷选项字典编码
|
||||
*
|
||||
* 对应业务字段:加班申请中的 overtimeDuration
|
||||
* 来源口径:`overtime-application-design.md` 明确时长下拉字典为 rdms_overtime_duration
|
||||
*/
|
||||
export const RDMS_OVERTIME_DURATION_DICT_CODE = 'rdms_overtime_duration';
|
||||
|
||||
@@ -16,7 +16,8 @@ export type StatusDomain =
|
||||
| 'product'
|
||||
| 'requirement'
|
||||
| 'workOrder'
|
||||
| 'personalItem';
|
||||
| 'personalItem'
|
||||
| 'overtimeApplication';
|
||||
|
||||
const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>> = {
|
||||
// 项目-执行
|
||||
@@ -61,6 +62,13 @@ const statusTagTypeRegistry: Record<StatusDomain, Record<string, StatusTagType>>
|
||||
active: 'primary',
|
||||
completed: 'success',
|
||||
cancelled: 'danger'
|
||||
},
|
||||
// 加班申请
|
||||
overtimeApplication: {
|
||||
pending: 'warning',
|
||||
approved: 'success',
|
||||
rejected: 'danger',
|
||||
cancelled: 'info'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,9 +77,13 @@ export function getStatusTagType(domain: StatusDomain, statusCode: string | null
|
||||
return 'info';
|
||||
}
|
||||
|
||||
return statusTagTypeRegistry[domain][statusCode] || 'info';
|
||||
return statusTagTypeRegistry[domain]?.[statusCode] || 'info';
|
||||
}
|
||||
|
||||
export function getPersonalItemStatusTagType(statusCode: string | null | undefined) {
|
||||
return getStatusTagType('personalItem', statusCode);
|
||||
}
|
||||
|
||||
export function getOvertimeApplicationStatusTagType(statusCode: string | null | undefined) {
|
||||
return getStatusTagType('overtimeApplication', statusCode);
|
||||
}
|
||||
|
||||
@@ -5,5 +5,6 @@ export enum SetupStoreId {
|
||||
Dict = 'dict-store',
|
||||
Route = 'route-store',
|
||||
Tab = 'tab-store',
|
||||
ObjectContext = 'object-context-store'
|
||||
ObjectContext = 'object-context-store',
|
||||
Workbench = 'workbench-store'
|
||||
}
|
||||
|
||||
@@ -0,0 +1,457 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useInfiniteScroll } from '@vueuse/core';
|
||||
|
||||
defineOptions({ name: 'NotificationBell' });
|
||||
|
||||
interface NotificationItem {
|
||||
id: string;
|
||||
title: string;
|
||||
timeLabel: string;
|
||||
unread: boolean;
|
||||
}
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
// 通知 mock:扩到 60 条以演示分页 / 搜索;等真接口落地后整体迁移
|
||||
function buildMockNotifications(): NotificationItem[] {
|
||||
const titles = [
|
||||
'你被指派为执行「迭代 24.06」负责人',
|
||||
'任务「SSO 改造」状态变更:开发中 → 待验收',
|
||||
'需求「多币种支持」评审通过',
|
||||
'工单 #1042 已分派给你',
|
||||
'需求「订单导出」被退回,请补充材料',
|
||||
'@ 你的评论已被回复',
|
||||
'项目「客户中心 2.0」周报已生成',
|
||||
'工单 #1098 客户回复待处理',
|
||||
'执行「迭代 24.05」已结束',
|
||||
'需求「批量审批」分配给你'
|
||||
];
|
||||
const times = ['10min 前', '30min 前', '1h 前', '2h 前', '4h 前', '昨日', '前天', '3 天前', '1 周前', '2 周前'];
|
||||
return Array.from({ length: 60 }, (_, i) => ({
|
||||
id: `m${i + 1}`,
|
||||
title: `${titles[i % titles.length]}(#${i + 1})`,
|
||||
timeLabel: times[Math.floor(i / 6) % times.length],
|
||||
unread: i < 14
|
||||
}));
|
||||
}
|
||||
|
||||
const notifications = ref<NotificationItem[]>(buildMockNotifications());
|
||||
|
||||
const unreadAll = computed(() => notifications.value.filter(n => n.unread));
|
||||
const readAll = computed(() => notifications.value.filter(n => !n.unread));
|
||||
const unreadCount = computed(() => unreadAll.value.length);
|
||||
const badgeLabel = computed(() => (unreadCount.value > 99 ? '99+' : String(unreadCount.value)));
|
||||
|
||||
const drawerOpen = ref(false);
|
||||
const activeTab = ref<'unread' | 'read'>('unread');
|
||||
const searchKeyword = ref('');
|
||||
|
||||
function matchesKeyword(item: NotificationItem) {
|
||||
const kw = searchKeyword.value.trim();
|
||||
if (!kw) return true;
|
||||
return item.title.toLowerCase().includes(kw.toLowerCase());
|
||||
}
|
||||
|
||||
const filteredUnread = computed(() => unreadAll.value.filter(matchesKeyword));
|
||||
const filteredRead = computed(() => readAll.value.filter(matchesKeyword));
|
||||
|
||||
const unreadPageSize = ref(PAGE_SIZE);
|
||||
const readPageSize = ref(PAGE_SIZE);
|
||||
|
||||
const visibleUnread = computed(() => filteredUnread.value.slice(0, unreadPageSize.value));
|
||||
const visibleRead = computed(() => filteredRead.value.slice(0, readPageSize.value));
|
||||
|
||||
const hasMoreUnread = computed(() => unreadPageSize.value < filteredUnread.value.length);
|
||||
const hasMoreRead = computed(() => readPageSize.value < filteredRead.value.length);
|
||||
|
||||
watch(searchKeyword, () => {
|
||||
unreadPageSize.value = PAGE_SIZE;
|
||||
readPageSize.value = PAGE_SIZE;
|
||||
});
|
||||
|
||||
// 已读列表数量会因"标已读"动态增长 / 未读会缩小;切换 tab 不重置已展示页数,体感更自然
|
||||
|
||||
type ScrollbarRefValue = { wrapRef?: HTMLElement } | null;
|
||||
const unreadScrollbar = ref<ScrollbarRefValue>(null);
|
||||
const readScrollbar = ref<ScrollbarRefValue>(null);
|
||||
|
||||
useInfiniteScroll(
|
||||
() => unreadScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreUnread.value) unreadPageSize.value += PAGE_SIZE;
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
|
||||
useInfiniteScroll(
|
||||
() => readScrollbar.value?.wrapRef,
|
||||
() => {
|
||||
if (hasMoreRead.value) readPageSize.value += PAGE_SIZE;
|
||||
},
|
||||
{ distance: 48 }
|
||||
);
|
||||
|
||||
function openDrawer() {
|
||||
drawerOpen.value = true;
|
||||
}
|
||||
|
||||
function closeDrawer() {
|
||||
drawerOpen.value = false;
|
||||
}
|
||||
|
||||
function markRead(item: NotificationItem) {
|
||||
if (!item.unread) return;
|
||||
item.unread = false;
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] mark-read', item.id);
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
notifications.value.forEach(item => {
|
||||
item.unread = false;
|
||||
});
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] mark-all-read');
|
||||
}
|
||||
|
||||
function openItem(item: NotificationItem) {
|
||||
markRead(item);
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('[notification] open', item.id);
|
||||
}
|
||||
|
||||
function onDrawerClosed() {
|
||||
searchKeyword.value = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="notification-bell__trigger"
|
||||
type="button"
|
||||
:aria-label="unreadCount > 0 ? `通知,${unreadCount} 条未读` : '通知'"
|
||||
@click="openDrawer"
|
||||
>
|
||||
<SvgIcon icon="mdi:bell-outline" class="notification-bell__icon" />
|
||||
<span v-if="unreadCount > 0" class="notification-bell__badge">{{ badgeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<ElDrawer v-model="drawerOpen" size="480px" :with-header="false" @closed="onDrawerClosed">
|
||||
<div class="notification-bell__panel">
|
||||
<header class="notification-bell__header">
|
||||
<span class="notification-bell__title">
|
||||
通知
|
||||
<span v-if="unreadCount > 0" class="notification-bell__title-count">未读 {{ unreadCount }}</span>
|
||||
</span>
|
||||
<span class="notification-bell__header-actions">
|
||||
<ElButton v-if="unreadCount > 0" link size="small" @click="markAllRead">全部已读</ElButton>
|
||||
<button class="notification-bell__close" type="button" aria-label="关闭" @click="closeDrawer">
|
||||
<SvgIcon icon="mdi:close" />
|
||||
</button>
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<div class="notification-bell__search">
|
||||
<ElInput v-model="searchKeyword" placeholder="搜索通知" clearable>
|
||||
<template #prefix>
|
||||
<SvgIcon icon="mdi:magnify" />
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
|
||||
<ElTabs v-model="activeTab" class="notification-bell__tabs">
|
||||
<ElTabPane name="unread">
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
未读
|
||||
<span class="notification-bell__tab-count">{{ filteredUnread.length }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElScrollbar ref="unreadScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleUnread.length > 0" class="notification-bell__list">
|
||||
<li
|
||||
v-for="row in visibleUnread"
|
||||
:key="row.id"
|
||||
class="notification-bell__row is-unread"
|
||||
@click="openItem(row)"
|
||||
>
|
||||
<span class="notification-bell__row-dot" />
|
||||
<div class="notification-bell__row-body">
|
||||
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无未读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleUnread.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreUnread ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
|
||||
<ElTabPane name="read">
|
||||
<template #label>
|
||||
<span class="notification-bell__tab-label">
|
||||
已读
|
||||
<span class="notification-bell__tab-count">{{ filteredRead.length }}</span>
|
||||
</span>
|
||||
</template>
|
||||
<ElScrollbar ref="readScrollbar" class="notification-bell__scroll">
|
||||
<ul v-if="visibleRead.length > 0" class="notification-bell__list">
|
||||
<li v-for="row in visibleRead" :key="row.id" class="notification-bell__row" @click="openItem(row)">
|
||||
<span class="notification-bell__row-dot" />
|
||||
<div class="notification-bell__row-body">
|
||||
<div class="notification-bell__row-title">{{ row.title }}</div>
|
||||
<div class="notification-bell__row-time">{{ row.timeLabel }}</div>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
<div v-else class="notification-bell__empty">
|
||||
{{ searchKeyword ? '没有匹配的通知' : '暂无已读通知' }}
|
||||
</div>
|
||||
<div v-if="visibleRead.length > 0" class="notification-bell__footer-hint">
|
||||
{{ hasMoreRead ? '滚动加载更多…' : '— 已经到底了 —' }}
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</div>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.notification-bell__trigger {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
padding: 0;
|
||||
margin: 0 4px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background-color: transparent;
|
||||
color: var(--el-text-color-regular);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 160ms ease,
|
||||
color 160ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__trigger:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__trigger:focus-visible {
|
||||
outline: 2px solid var(--el-color-primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.notification-bell__icon {
|
||||
font-size: 20px;
|
||||
}
|
||||
|
||||
.notification-bell__badge {
|
||||
position: absolute;
|
||||
top: 4px;
|
||||
right: 4px;
|
||||
min-width: 16px;
|
||||
height: 16px;
|
||||
padding: 0 4px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.notification-bell__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.notification-bell__title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-bell__title-count {
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.notification-bell__header-actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.notification-bell__close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background-color: transparent;
|
||||
color: var(--el-text-color-secondary);
|
||||
cursor: pointer;
|
||||
font-size: 18px;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__close:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__search {
|
||||
padding: 12px 0 4px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tabs__content) {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tab-pane) {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__tab-label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.notification-bell__tab-count {
|
||||
padding: 0 7px;
|
||||
border-radius: 999px;
|
||||
background-color: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
line-height: 16px;
|
||||
}
|
||||
|
||||
.notification-bell__tabs :deep(.el-tabs__item.is-active) .notification-bell__tab-count {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__scroll {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.notification-bell__list {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.notification-bell__row {
|
||||
position: relative;
|
||||
display: grid;
|
||||
grid-template-columns: 14px minmax(0, 1fr);
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 4px;
|
||||
cursor: pointer;
|
||||
border-radius: 8px;
|
||||
transition: background-color 120ms ease;
|
||||
}
|
||||
|
||||
.notification-bell__row + .notification-bell__row {
|
||||
border-top: 1px dashed var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.notification-bell__row:hover {
|
||||
background-color: var(--el-fill-color-light);
|
||||
}
|
||||
|
||||
.notification-bell__row-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
margin-top: 6px;
|
||||
border-radius: 50%;
|
||||
background-color: transparent;
|
||||
justify-self: center;
|
||||
}
|
||||
|
||||
.notification-bell__row.is-unread .notification-bell__row-dot {
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.notification-bell__row-body {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.notification-bell__row-title {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.notification-bell__row.is-unread .notification-bell__row-title {
|
||||
color: var(--el-text-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.notification-bell__row-time {
|
||||
margin-top: 4px;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.notification-bell__empty {
|
||||
padding: 48px 16px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.notification-bell__footer-hint {
|
||||
padding: 12px 0 4px;
|
||||
text-align: center;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
user-select: none;
|
||||
}
|
||||
</style>
|
||||
@@ -7,6 +7,7 @@ import GlobalLogo from '../global-logo/index.vue';
|
||||
import GlobalBreadcrumb from '../global-breadcrumb/index.vue';
|
||||
import GlobalSearch from '../global-search/index.vue';
|
||||
import ThemeButton from './components/theme-button.vue';
|
||||
import NotificationBell from './components/notification-bell.vue';
|
||||
import UserAvatar from './components/user-avatar.vue';
|
||||
|
||||
defineOptions({ name: 'GlobalHeader' });
|
||||
@@ -48,6 +49,7 @@ const { isFullscreen, toggle } = useFullscreen();
|
||||
<div>
|
||||
<ThemeButton />
|
||||
</div>
|
||||
<NotificationBell />
|
||||
<UserAvatar />
|
||||
</div>
|
||||
</DarkModeContainer>
|
||||
|
||||
@@ -0,0 +1,399 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { Search } from '@element-plus/icons-vue';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
import { fetchGetProductPage, fetchGetProjectPage } from '@/service/api';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
|
||||
defineOptions({ name: 'ObjectContextSwitcher' });
|
||||
|
||||
interface Props {
|
||||
domainConfig: App.ObjectContext.DomainConfig;
|
||||
}
|
||||
|
||||
type ObjectOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string | null;
|
||||
createTime?: string | null;
|
||||
};
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
|
||||
const visible = ref(false);
|
||||
const keyword = ref('');
|
||||
const expanded = ref(false);
|
||||
const loading = ref(false);
|
||||
const switchingId = ref('');
|
||||
const options = ref<ObjectOption[]>([]);
|
||||
let searchTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const OBJECT_SWITCHER_PAGE_SIZE = 100;
|
||||
|
||||
const isProductDomain = computed(() => props.domainConfig.domainKey === 'product');
|
||||
const domainLabel = computed(() => (isProductDomain.value ? '产品' : '项目'));
|
||||
const allLabel = computed(() => `全部${domainLabel.value}`);
|
||||
const placeholder = computed(() => `搜索${domainLabel.value}`);
|
||||
const previewOptions = computed(() => options.value.slice(0, 3));
|
||||
const displayOptions = computed(() => {
|
||||
if (keyword.value.trim() || expanded.value) {
|
||||
return options.value;
|
||||
}
|
||||
|
||||
return previewOptions.value;
|
||||
});
|
||||
const hiddenCount = computed(() => Math.max(options.value.length - previewOptions.value.length, 0));
|
||||
const showAllEntry = computed(() => !keyword.value.trim() && !expanded.value && hiddenCount.value > 0);
|
||||
|
||||
function sortByCreateTimeDesc(list: ObjectOption[]) {
|
||||
return list.slice().sort((left, right) => {
|
||||
const leftTime = left.createTime ? new Date(left.createTime).getTime() : 0;
|
||||
const rightTime = right.createTime ? new Date(right.createTime).getTime() : 0;
|
||||
|
||||
return rightTime - leftTime;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchObjectOptionsPage(pageNo: number, keywordValue?: string) {
|
||||
const result =
|
||||
props.domainConfig.domainKey === 'product'
|
||||
? await fetchGetProductPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue })
|
||||
: await fetchGetProjectPage({ pageNo, pageSize: OBJECT_SWITCHER_PAGE_SIZE, keyword: keywordValue });
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return {
|
||||
total: 0,
|
||||
list: []
|
||||
};
|
||||
}
|
||||
|
||||
const list = result.data.list.map(item => {
|
||||
if (props.domainConfig.domainKey === 'product') {
|
||||
const product = item as Api.Product.Product;
|
||||
|
||||
return {
|
||||
id: product.id,
|
||||
name: product.name,
|
||||
code: product.code,
|
||||
createTime: product.createTime
|
||||
};
|
||||
}
|
||||
|
||||
const project = item as Api.Project.Project;
|
||||
|
||||
return {
|
||||
id: project.id,
|
||||
name: project.projectName,
|
||||
code: project.projectCode,
|
||||
createTime: project.createTime
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
total: result.data.total,
|
||||
list
|
||||
};
|
||||
}
|
||||
|
||||
async function loadOptions() {
|
||||
loading.value = true;
|
||||
|
||||
const keywordValue = keyword.value.trim() || undefined;
|
||||
const firstPage = await fetchObjectOptionsPage(1, keywordValue);
|
||||
const pageCount = Math.ceil(firstPage.total / OBJECT_SWITCHER_PAGE_SIZE);
|
||||
const restPages =
|
||||
pageCount > 1
|
||||
? await Promise.all(
|
||||
Array.from({ length: pageCount - 1 }, (_, index) => fetchObjectOptionsPage(index + 2, keywordValue))
|
||||
)
|
||||
: [];
|
||||
const list = [firstPage, ...restPages].flatMap(page => page.list);
|
||||
|
||||
loading.value = false;
|
||||
options.value = sortByCreateTimeDesc(list);
|
||||
}
|
||||
|
||||
function handleVisibleChange(value: boolean) {
|
||||
visible.value = value;
|
||||
|
||||
if (value) {
|
||||
expanded.value = false;
|
||||
loadOptions();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSelect(option: ObjectOption) {
|
||||
if (option.id === objectContextStore.objectId) {
|
||||
visible.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
switchingId.value = option.id;
|
||||
const result = await objectContextStore.switchContext(props.domainConfig, option.id);
|
||||
switchingId.value = '';
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
visible.value = false;
|
||||
const query = {
|
||||
...route.query,
|
||||
[OBJECT_CONTEXT_QUERY_KEY]: option.id
|
||||
};
|
||||
const targetLocation = route.name ? { name: route.name, query } : { path: route.path, query };
|
||||
|
||||
await router.push(targetLocation);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => keyword.value,
|
||||
() => {
|
||||
if (!visible.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
expanded.value = Boolean(keyword.value.trim());
|
||||
|
||||
if (searchTimer) {
|
||||
clearTimeout(searchTimer);
|
||||
}
|
||||
|
||||
searchTimer = setTimeout(() => {
|
||||
loadOptions();
|
||||
}, 250);
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
:visible="visible"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="300"
|
||||
popper-class="object-context-switcher__popper"
|
||||
@update:visible="handleVisibleChange"
|
||||
>
|
||||
<template #reference>
|
||||
<button type="button" class="object-context-switcher__trigger" :class="{ 'is-open': visible }">
|
||||
<span class="object-context-switcher__trigger-label">{{ objectContextStore.objectName }}</span>
|
||||
<icon-ep:sort class="object-context-switcher__trigger-icon" />
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<div class="object-context-switcher__panel">
|
||||
<ElInput v-model="keyword" clearable :placeholder="placeholder" class="object-context-switcher__search">
|
||||
<template #suffix>
|
||||
<ElIcon>
|
||||
<Search />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<div v-loading="loading" class="object-context-switcher__list">
|
||||
<button
|
||||
v-for="item in displayOptions"
|
||||
:key="item.id"
|
||||
type="button"
|
||||
class="object-context-switcher__item"
|
||||
:class="{ 'is-active': item.id === objectContextStore.objectId }"
|
||||
:disabled="switchingId === item.id"
|
||||
@click="handleSelect(item)"
|
||||
>
|
||||
<span class="object-context-switcher__item-icon">
|
||||
<icon-ep:box v-if="isProductDomain" />
|
||||
<icon-ep:folder v-else />
|
||||
</span>
|
||||
<span class="object-context-switcher__item-main">
|
||||
<span class="object-context-switcher__item-name">{{ item.name }}</span>
|
||||
<span v-if="item.code" class="object-context-switcher__item-code">{{ item.code }}</span>
|
||||
</span>
|
||||
<icon-ep:check v-if="item.id === objectContextStore.objectId" class="object-context-switcher__check" />
|
||||
</button>
|
||||
|
||||
<ElEmpty v-if="!loading && !displayOptions.length" :description="`暂无可选${domainLabel}`" :image-size="54" />
|
||||
</div>
|
||||
|
||||
<button v-if="showAllEntry" type="button" class="object-context-switcher__all" @click="expanded = true">
|
||||
<span>{{ allLabel }}</span>
|
||||
<span class="object-context-switcher__all-meta">{{ hiddenCount }} 个更多</span>
|
||||
<icon-ep:arrow-right class="object-context-switcher__all-arrow" />
|
||||
</button>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.object-context-switcher__trigger {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 16rem;
|
||||
height: 32px;
|
||||
gap: 6px;
|
||||
padding: 0 10px 0 12px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger:hover,
|
||||
.object-context-switcher__trigger.is-open {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.object-context-switcher__trigger-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-context-switcher__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.object-context-switcher__search {
|
||||
padding: 4px 4px 0;
|
||||
}
|
||||
|
||||
.object-context-switcher__list {
|
||||
min-height: 84px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.object-context-switcher__item {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
gap: 10px;
|
||||
padding: 7px 10px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.object-context-switcher__item:hover,
|
||||
.object-context-switcher__item.is-active {
|
||||
background: rgb(59 130 246 / 10%);
|
||||
}
|
||||
|
||||
.object-context-switcher__item:disabled {
|
||||
cursor: wait;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 5px;
|
||||
background: var(--el-color-primary-light-8);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.object-context-switcher__item-main {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-name,
|
||||
.object-context-switcher__item-code {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-name {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.object-context-switcher__item-code {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.object-context-switcher__check {
|
||||
flex-shrink: 0;
|
||||
color: var(--el-color-primary);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.object-context-switcher__all {
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: calc(100% + 24px);
|
||||
height: 38px;
|
||||
gap: 8px;
|
||||
margin: 0 -12px -12px;
|
||||
padding: 0 14px;
|
||||
border: none;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
background: transparent;
|
||||
color: var(--el-text-color-primary);
|
||||
cursor: pointer;
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.object-context-switcher__all:hover {
|
||||
background: var(--el-fill-color-light);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.object-context-switcher__all-meta {
|
||||
flex: 1;
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.object-context-switcher__all-arrow {
|
||||
color: var(--el-text-color-placeholder);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
:global(.object-context-switcher__popper.el-popover) {
|
||||
padding: 12px;
|
||||
border: 1px solid rgb(226 232 240 / 90%);
|
||||
border-radius: 10px;
|
||||
box-shadow:
|
||||
0 12px 28px rgb(15 23 42 / 10%),
|
||||
0 2px 8px rgb(15 23 42 / 6%);
|
||||
}
|
||||
</style>
|
||||
@@ -8,6 +8,7 @@ import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import FirstLevelMenu from '../components/first-level-menu.vue';
|
||||
import ObjectContextSwitcher from '../components/object-context-switcher.vue';
|
||||
import { useMenu, useMixMenuContext } from '../../../context';
|
||||
|
||||
defineOptions({
|
||||
@@ -108,7 +109,7 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
class="mx-12px h-20px w-1px shrink-0 bg-[var(--el-border-color)]"
|
||||
></div>
|
||||
<div v-if="showObjectContextInfo" class="context-object-tag h-full flex-y-center">
|
||||
<span class="context-object-tag__label">{{ objectContextStore.objectName }}</span>
|
||||
<ObjectContextSwitcher v-if="currentObjectContextDomain" :domain-config="currentObjectContextDomain" />
|
||||
</div>
|
||||
<div
|
||||
v-if="showObjectContextInfo && headerMenus.length"
|
||||
@@ -208,28 +209,6 @@ function isMenuActive(menu: App.Global.Menu | App.ObjectContext.Menu): boolean {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.context-object-tag {
|
||||
flex-shrink: 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.context-object-tag__label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
max-width: 14rem;
|
||||
height: 32px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid rgb(148 163 184 / 26%);
|
||||
border-radius: 999px;
|
||||
background: linear-gradient(180deg, rgb(248 250 252 / 95%), rgb(241 245 249 / 92%));
|
||||
color: rgb(15 23 42 / 88%);
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.header-nav-list {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -173,6 +173,7 @@ const local: App.I18n.Schema = {
|
||||
'personal-center_my-monthly': 'My Monthly Report',
|
||||
'personal-center_my-performance': 'My Performance',
|
||||
'personal-center_my-application': 'My Application',
|
||||
'personal-center_overtime-application': 'Overtime Application',
|
||||
'personal-center_pending-approval': 'Pending Approval',
|
||||
infra: 'Infra',
|
||||
'infra_state-machine': 'State Machine',
|
||||
@@ -708,6 +709,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: 'Dictionary Status',
|
||||
dictLabel: 'Dictionary Label',
|
||||
dictValue: 'Dictionary Value',
|
||||
colorType: 'Color Type',
|
||||
sort: 'Sort',
|
||||
remark: 'Remark',
|
||||
form: {
|
||||
@@ -716,6 +718,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: 'Please select dictionary status',
|
||||
dictLabel: 'Please enter dictionary label',
|
||||
dictValue: 'Please enter dictionary value',
|
||||
colorType: 'Please enter color type',
|
||||
sort: 'Please enter sort',
|
||||
remark: 'Please enter remark'
|
||||
},
|
||||
|
||||
@@ -173,6 +173,7 @@ const local: App.I18n.Schema = {
|
||||
'personal-center_my-monthly': '我的月报',
|
||||
'personal-center_my-performance': '我的绩效',
|
||||
'personal-center_my-application': '我的申请',
|
||||
'personal-center_overtime-application': '加班申请',
|
||||
'personal-center_pending-approval': '待我审批',
|
||||
infra: '基础设施',
|
||||
'infra_state-machine': '状态机管理',
|
||||
@@ -696,6 +697,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: '字典状态',
|
||||
dictLabel: '字典标签',
|
||||
dictValue: '字典键值',
|
||||
colorType: '颜色类型',
|
||||
sort: '排序',
|
||||
remark: '备注',
|
||||
form: {
|
||||
@@ -704,6 +706,7 @@ const local: App.I18n.Schema = {
|
||||
dictStatus: '请选择字典状态',
|
||||
dictLabel: '请输入字典标签',
|
||||
dictValue: '请输入字典键值',
|
||||
colorType: '请输入颜色类型',
|
||||
sort: '请输入排序',
|
||||
remark: '请输入备注'
|
||||
},
|
||||
|
||||
@@ -39,6 +39,7 @@ export const views: Record<LastLevelRouteKey, RouteComponent | (() => Promise<Ro
|
||||
"personal-center_my-performance": () => import("@/views/personal-center/my-performance/index.vue"),
|
||||
"personal-center_my-profile": () => import("@/views/personal-center/my-profile/index.vue"),
|
||||
"personal-center_my-weekly": () => import("@/views/personal-center/my-weekly/index.vue"),
|
||||
"personal-center_overtime-application": () => import("@/views/personal-center/overtime-application/index.vue"),
|
||||
"personal-center_pending-approval": () => import("@/views/personal-center/pending-approval/index.vue"),
|
||||
plugin_barcode: () => import("@/views/plugin/barcode/index.vue"),
|
||||
plugin_charts_antv: () => import("@/views/plugin/charts/antv/index.vue"),
|
||||
|
||||
@@ -351,6 +351,18 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_overtime-application',
|
||||
path: '/personal-center/overtime-application',
|
||||
component: 'view.personal-center_overtime-application',
|
||||
meta: {
|
||||
title: 'personal-center_overtime-application',
|
||||
i18nKey: 'route.personal-center_overtime-application',
|
||||
icon: 'mdi:clock-plus-outline',
|
||||
order: 6,
|
||||
keepAlive: true
|
||||
}
|
||||
},
|
||||
{
|
||||
name: 'personal-center_pending-approval',
|
||||
path: '/personal-center/pending-approval',
|
||||
@@ -359,7 +371,7 @@ export const generatedRoutes: GeneratedRoute[] = [
|
||||
title: 'personal-center_pending-approval',
|
||||
i18nKey: 'route.personal-center_pending-approval',
|
||||
icon: 'mdi:check-decagram-outline',
|
||||
order: 5,
|
||||
order: 7,
|
||||
keepAlive: true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,6 +196,7 @@ const routeMap: RouteMap = {
|
||||
"personal-center_my-performance": "/personal-center/my-performance",
|
||||
"personal-center_my-profile": "/personal-center/my-profile",
|
||||
"personal-center_my-weekly": "/personal-center/my-weekly",
|
||||
"personal-center_overtime-application": "/personal-center/overtime-application",
|
||||
"personal-center_pending-approval": "/personal-center/pending-approval",
|
||||
"plugin": "/plugin",
|
||||
"plugin_barcode": "/plugin/barcode",
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ServiceRequestResult, mapServiceResult } from './shared';
|
||||
|
||||
const DICT_TYPE_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-type`;
|
||||
const DICT_DATA_PREFIX = `${SYSTEM_SERVICE_PREFIX}/dict-data`;
|
||||
@@ -15,6 +16,52 @@ function createBatchDeleteQuery(ids: number[]) {
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
type DictDataResponse = Omit<Api.Dict.DictData, 'colorType'> & {
|
||||
colorType?: string | null;
|
||||
color_type?: string | null;
|
||||
};
|
||||
|
||||
type DictDataPageResponse = Omit<Api.Dict.PageResult<Api.Dict.DictData>, 'list'> & {
|
||||
list: DictDataResponse[];
|
||||
};
|
||||
|
||||
type FrontendDictDataResponse = Omit<Api.Dict.FrontendDictData, 'colorType'> & {
|
||||
colorType?: string | null;
|
||||
color_type?: string | null;
|
||||
};
|
||||
|
||||
type FrontendDictCacheResponse = Record<string, FrontendDictDataResponse[]>;
|
||||
|
||||
function normalizeColorType(value?: string | null) {
|
||||
return value?.trim() || null;
|
||||
}
|
||||
|
||||
function normalizeDictData(data: DictDataResponse): Api.Dict.DictData {
|
||||
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrontendDictData(data: FrontendDictDataResponse): Api.Dict.FrontendDictData {
|
||||
const { color_type: colorTypeFromSnakeCase, ...rest } = data;
|
||||
|
||||
return {
|
||||
...rest,
|
||||
colorType: normalizeColorType(data.colorType ?? colorTypeFromSnakeCase)
|
||||
};
|
||||
}
|
||||
|
||||
function toSaveDictDataRequest(data: Api.Dict.SaveDictDataParams) {
|
||||
return {
|
||||
...data,
|
||||
colorType: normalizeColorType(data.colorType),
|
||||
remark: data.remark?.trim() || null
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取字典类型分页 */
|
||||
export function fetchGetDictTypePage(params?: Api.Dict.DictTypeSearchParams) {
|
||||
return request<Api.Dict.PageResult<Api.Dict.DictType>>({
|
||||
@@ -60,20 +107,40 @@ export function fetchBatchDeleteDictType(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 获取字典数据分页 */
|
||||
export function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
||||
export async function fetchGetDictDataPage(params: Api.Dict.DictDataSearchParams) {
|
||||
const result = await request<DictDataPageResponse>({
|
||||
url: `${DICT_DATA_PREFIX}/page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
return result as unknown as Awaited<ReturnType<typeof request<Api.Dict.PageResult<Api.Dict.DictData>>>>;
|
||||
}
|
||||
|
||||
return {
|
||||
...result,
|
||||
data: {
|
||||
...result.data,
|
||||
list: result.data.list.map(normalizeDictData)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取前端运行时字典缓存 */
|
||||
export function fetchGetFrontendDictCache() {
|
||||
return request<Api.Dict.FrontendDictCache>({
|
||||
export async function fetchGetFrontendDictCache() {
|
||||
const result = await request<FrontendDictCacheResponse>({
|
||||
url: `${DICT_DATA_PREFIX}/frontend-cache`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<FrontendDictCacheResponse>,
|
||||
data =>
|
||||
Object.fromEntries(
|
||||
Object.entries(data).map(([dictType, list]) => [dictType, list.map(normalizeFrontendDictData)])
|
||||
) as Api.Dict.FrontendDictCache
|
||||
);
|
||||
}
|
||||
|
||||
/** 创建字典数据 */
|
||||
@@ -81,7 +148,7 @@ export function fetchCreateDictData(data: Api.Dict.SaveDictDataParams) {
|
||||
return request<number>({
|
||||
url: `${DICT_DATA_PREFIX}/create`,
|
||||
method: 'post',
|
||||
data
|
||||
data: toSaveDictDataRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -90,7 +157,7 @@ export function fetchUpdateDictData(data: { id: number } & Api.Dict.SaveDictData
|
||||
return request<boolean>({
|
||||
url: `${DICT_DATA_PREFIX}/update`,
|
||||
method: 'put',
|
||||
data
|
||||
data: toSaveDictDataRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
@@ -112,9 +179,14 @@ export function fetchBatchDeleteDictData(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 通过岗位编码获取该字典的所有字典数据 */
|
||||
export function fetchGetDictDataByCode(code: string) {
|
||||
return request<Api.Dict.PageResult<Api.Dict.DictData>>({
|
||||
export async function fetchGetDictDataByCode(code: string) {
|
||||
const result = await request<DictDataPageResponse>({
|
||||
url: `${DICT_DATA_PREFIX}/code?code=${code}`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<DictDataPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeDictData)
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ export * from './dict';
|
||||
export * from './file';
|
||||
export * from './infra';
|
||||
export * from './object-context';
|
||||
export * from './overtime-application';
|
||||
export * from './personal-item';
|
||||
export * from './product';
|
||||
export * from './project';
|
||||
|
||||
280
src/service/api/overtime-application.ts
Normal file
280
src/service/api/overtime-application.ts
Normal file
@@ -0,0 +1,280 @@
|
||||
import { WEB_SERVICE_PREFIX } from '@/constants/service';
|
||||
import { request } from '../request';
|
||||
import { type ProjectLocalDateValue, normalizeProjectLocalDate } from './project-shared';
|
||||
import {
|
||||
type ServiceRequestResult,
|
||||
mapServiceResult,
|
||||
normalizeNullableStringId,
|
||||
normalizeStringId,
|
||||
safeJsonRequestConfig
|
||||
} from './shared';
|
||||
|
||||
const OVERTIME_APPLICATION_PREFIX = `${WEB_SERVICE_PREFIX}/project/overtime-applications`;
|
||||
|
||||
type StringIdResponse = string | number;
|
||||
|
||||
type OvertimeApplicationResponse = Omit<
|
||||
Api.OvertimeApplication.OvertimeApplication,
|
||||
'id' | 'applicantId' | 'approverId' | 'overtimeDate' | 'allowEdit' | 'terminal'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
applicantId: StringIdResponse;
|
||||
approverId: StringIdResponse;
|
||||
overtimeDate: ProjectLocalDateValue;
|
||||
allowEdit?: boolean | number | string | null;
|
||||
terminal?: boolean | number | string | null;
|
||||
};
|
||||
|
||||
type OvertimeApplicationPageResponse = Omit<Api.OvertimeApplication.OvertimeApplicationPageResult, 'total' | 'list'> & {
|
||||
total: number | string;
|
||||
list: OvertimeApplicationResponse[];
|
||||
};
|
||||
|
||||
type OvertimeApplicationStatusLogResponse = Omit<
|
||||
Api.OvertimeApplication.OvertimeApplicationStatusLog,
|
||||
'id' | 'applicationId' | 'operatorUserId' | 'overtimeDateSnapshot'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
applicationId: StringIdResponse;
|
||||
operatorUserId: StringIdResponse;
|
||||
overtimeDateSnapshot: ProjectLocalDateValue;
|
||||
};
|
||||
|
||||
function normalizeBooleanFlag(value: boolean | number | string | null | undefined) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
|
||||
if (typeof value === 'number') {
|
||||
return value === 1;
|
||||
}
|
||||
|
||||
if (typeof value === 'string') {
|
||||
const normalized = value.trim().toLowerCase();
|
||||
|
||||
return !['', '0', 'false', 'n', 'no'].includes(normalized);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function normalizeTotal(total: number | string) {
|
||||
const value = Number(total);
|
||||
|
||||
return Number.isFinite(value) ? Math.max(0, value) : 0;
|
||||
}
|
||||
|
||||
function normalizeOvertimeApplication(
|
||||
response: OvertimeApplicationResponse
|
||||
): Api.OvertimeApplication.OvertimeApplication {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
applicantId: normalizeStringId(response.applicantId),
|
||||
approverId: normalizeStringId(response.approverId),
|
||||
overtimeDate: normalizeProjectLocalDate(response.overtimeDate) ?? '',
|
||||
statusName: response.statusName || response.statusCode,
|
||||
allowEdit: normalizeBooleanFlag(response.allowEdit),
|
||||
terminal: normalizeBooleanFlag(response.terminal),
|
||||
approvalComment: response.approvalComment ?? null,
|
||||
approvalTime: response.approvalTime ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStatusLog(
|
||||
response: OvertimeApplicationStatusLogResponse
|
||||
): Api.OvertimeApplication.OvertimeApplicationStatusLog {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
applicationId: normalizeStringId(response.applicationId),
|
||||
operatorUserId: normalizeStringId(response.operatorUserId),
|
||||
overtimeDateSnapshot: normalizeProjectLocalDate(response.overtimeDateSnapshot) ?? '',
|
||||
fromStatus: normalizeNullableStringId(response.fromStatus),
|
||||
reason: response.reason ?? null,
|
||||
remark: response.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function createPageQuery(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
|
||||
const query = new URLSearchParams();
|
||||
|
||||
query.append('pageNo', String(params.pageNo ?? 1));
|
||||
query.append('pageSize', String(params.pageSize ?? 10));
|
||||
|
||||
if (params.keyword) {
|
||||
query.append('keyword', params.keyword);
|
||||
}
|
||||
|
||||
if (params.applicantName) {
|
||||
query.append('applicantName', params.applicantName);
|
||||
}
|
||||
|
||||
if (params.approverId) {
|
||||
query.append('approverId', params.approverId);
|
||||
}
|
||||
|
||||
if (params.approverName) {
|
||||
query.append('approverName', params.approverName);
|
||||
}
|
||||
|
||||
if (params.statusCode) {
|
||||
query.append('statusCode', params.statusCode);
|
||||
}
|
||||
|
||||
params.overtimeDate?.forEach(item => {
|
||||
if (item) {
|
||||
query.append('overtimeDate', item);
|
||||
}
|
||||
});
|
||||
|
||||
params.createTime?.forEach(item => {
|
||||
if (item) {
|
||||
query.append('createTime', item);
|
||||
}
|
||||
});
|
||||
|
||||
return query.toString();
|
||||
}
|
||||
|
||||
function toSaveRequest(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
|
||||
return {
|
||||
overtimeDate: data.overtimeDate,
|
||||
overtimeDuration: data.overtimeDuration,
|
||||
overtimeReason: data.overtimeReason.trim(),
|
||||
overtimeContent: data.overtimeContent.trim(),
|
||||
approverId: data.approverId
|
||||
};
|
||||
}
|
||||
|
||||
function toStatusActionRequest(data: Api.OvertimeApplication.StatusActionParams = {}) {
|
||||
return {
|
||||
reason: data.reason?.trim() || undefined
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchGetOvertimeApplicationPage(
|
||||
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
|
||||
) {
|
||||
const query = createPageQuery(params);
|
||||
|
||||
const result = await request<OvertimeApplicationPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query ? `${OVERTIME_APPLICATION_PREFIX}/page?${query}` : `${OVERTIME_APPLICATION_PREFIX}/page`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
|
||||
total: normalizeTotal(data.total),
|
||||
list: data.list.map(normalizeOvertimeApplication)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGetOvertimeApplicationApprovalPage(
|
||||
params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}
|
||||
) {
|
||||
const query = createPageQuery(params);
|
||||
|
||||
const result = await request<OvertimeApplicationPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: query
|
||||
? `${OVERTIME_APPLICATION_PREFIX}/approval-page?${query}`
|
||||
: `${OVERTIME_APPLICATION_PREFIX}/approval-page`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationPageResponse>, data => ({
|
||||
total: normalizeTotal(data.total),
|
||||
list: data.list.map(normalizeOvertimeApplication)
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchGetOvertimeApplicationDetail(id: string) {
|
||||
const result = await request<OvertimeApplicationResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationResponse>, normalizeOvertimeApplication);
|
||||
}
|
||||
|
||||
export async function fetchCreateOvertimeApplication(data: Api.OvertimeApplication.SaveOvertimeApplicationParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: OVERTIME_APPLICATION_PREFIX,
|
||||
method: 'post',
|
||||
data: toSaveRequest(data)
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
export function fetchUpdateRejectedOvertimeApplication(
|
||||
id: string,
|
||||
data: Api.OvertimeApplication.SaveOvertimeApplicationParams
|
||||
) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||
method: 'put',
|
||||
data: toSaveRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchApproveOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams = {}) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/approve`,
|
||||
method: 'post',
|
||||
data: toStatusActionRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchRejectOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/reject`,
|
||||
method: 'post',
|
||||
data: toStatusActionRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchCancelOvertimeApplication(id: string, data: Api.OvertimeApplication.StatusActionParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/cancel`,
|
||||
method: 'post',
|
||||
data: toStatusActionRequest(data)
|
||||
});
|
||||
}
|
||||
|
||||
export function fetchDeleteOvertimeApplication(id: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}`,
|
||||
method: 'delete'
|
||||
});
|
||||
}
|
||||
|
||||
export async function fetchGetOvertimeApplicationStatusLogs(id: string) {
|
||||
const result = await request<OvertimeApplicationStatusLogResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${OVERTIME_APPLICATION_PREFIX}/${id}/status-logs`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<OvertimeApplicationStatusLogResponse[]>, data =>
|
||||
data.map(normalizeStatusLog)
|
||||
);
|
||||
}
|
||||
|
||||
export function fetchExportOvertimeApplications(params: Api.OvertimeApplication.OvertimeApplicationSearchParams = {}) {
|
||||
const query = createPageQuery(params);
|
||||
|
||||
return request<Blob, 'blob'>({
|
||||
url: query ? `${OVERTIME_APPLICATION_PREFIX}/export?${query}` : `${OVERTIME_APPLICATION_PREFIX}/export`,
|
||||
method: 'get',
|
||||
responseType: 'blob'
|
||||
});
|
||||
}
|
||||
@@ -61,6 +61,7 @@ type PersonalItemExecutionOptionResponse = ProjectExecutionResponse & {
|
||||
type PersonalItemSaveRequest = {
|
||||
executionId?: string;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
progressRate?: number;
|
||||
plannedStartDate?: string;
|
||||
plannedEndDate?: string;
|
||||
@@ -86,7 +87,7 @@ type PersonalItemWorklogSaveRequest = {
|
||||
size?: number;
|
||||
contentType?: string;
|
||||
}>;
|
||||
difficulty?: string;
|
||||
difficulty: string;
|
||||
};
|
||||
|
||||
const PERSONAL_ITEM_PREFIX = `${WEB_SERVICE_PREFIX}/project/personal-items`;
|
||||
@@ -163,6 +164,7 @@ function normalizePersonalItem(response: PersonalItemResponse): Api.PersonalItem
|
||||
return {
|
||||
id: normalizeStringId(response.id),
|
||||
taskTitle: response.taskTitle ?? '',
|
||||
type: response.type ?? '',
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
statusCode: response.statusCode,
|
||||
terminal: normalizeBooleanFlag(response.terminal),
|
||||
@@ -214,6 +216,7 @@ function toPersonalItemSaveRequest(data: Api.PersonalItem.SavePersonalItemParams
|
||||
return {
|
||||
executionId: data.executionId ?? undefined,
|
||||
taskTitle: data.taskTitle.trim(),
|
||||
type: data.type,
|
||||
progressRate: typeof data.progressRate === 'number' ? data.progressRate : undefined,
|
||||
plannedStartDate: data.plannedStartDate ?? undefined,
|
||||
plannedEndDate: data.plannedEndDate ?? undefined,
|
||||
@@ -246,7 +249,7 @@ function toPersonalItemWorklogSaveRequest(
|
||||
size: item.size,
|
||||
contentType: item.contentType
|
||||
})) ?? undefined,
|
||||
difficulty: data.difficulty ?? undefined
|
||||
difficulty: data.difficulty
|
||||
};
|
||||
}
|
||||
|
||||
@@ -341,6 +344,7 @@ function createSeedItems(): PersonalItemRecord[] {
|
||||
{
|
||||
id: 'personal-item-1',
|
||||
taskTitle: '整理供应商沟通纪要',
|
||||
type: 'daily',
|
||||
ownerId: CURRENT_USER_ID,
|
||||
statusCode: 'active',
|
||||
progressRate: 45,
|
||||
@@ -362,6 +366,7 @@ function createSeedItems(): PersonalItemRecord[] {
|
||||
{
|
||||
id: 'personal-item-2',
|
||||
taskTitle: '清理浏览器收藏夹里的项目入口',
|
||||
type: 'daily',
|
||||
ownerId: CURRENT_USER_ID,
|
||||
statusCode: 'pending',
|
||||
progressRate: 0,
|
||||
@@ -383,6 +388,7 @@ function createSeedItems(): PersonalItemRecord[] {
|
||||
{
|
||||
id: 'personal-item-3',
|
||||
taskTitle: '补充账号开通说明截图',
|
||||
type: 'support',
|
||||
ownerId: CURRENT_USER_ID,
|
||||
statusCode: 'completed',
|
||||
progressRate: 100,
|
||||
@@ -587,6 +593,7 @@ function syncItemFromWorklogs(itemId: string) {
|
||||
|
||||
function applySaveFields(target: PersonalItemRecord, payload: Api.PersonalItem.SavePersonalItemParams) {
|
||||
target.taskTitle = payload.taskTitle.trim();
|
||||
target.type = payload.type;
|
||||
target.ownerId = payload.ownerId || target.ownerId;
|
||||
target.ownerName = CURRENT_USER_NAME;
|
||||
target.plannedStartDate = payload.plannedStartDate;
|
||||
@@ -661,6 +668,7 @@ export async function fetchCreatePersonalItem(data: Api.PersonalItem.SavePersona
|
||||
const createdItem: PersonalItemRecord = {
|
||||
id: mapped.data,
|
||||
taskTitle: data.taskTitle.trim(),
|
||||
type: data.type,
|
||||
ownerId: data.ownerId || CURRENT_USER_ID,
|
||||
statusCode: 'pending',
|
||||
progressRate: typeof data.progressRate === 'number' ? data.progressRate : 0,
|
||||
|
||||
@@ -205,6 +205,41 @@ type RequirementResponse = Omit<
|
||||
};
|
||||
|
||||
type RequirementPageResponse = Api.Product.PageResult<RequirementResponse>;
|
||||
type RequirementReviewResponse = Omit<
|
||||
Api.Product.RequirementReview,
|
||||
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId: string | number;
|
||||
operatorId: string | number;
|
||||
attendees?: Array<{
|
||||
userId: string | number;
|
||||
nickname: string;
|
||||
}>;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
};
|
||||
type ProductRequirementDashboardSummaryResponse = {
|
||||
total?: number | string | null;
|
||||
todo?: number | string | null;
|
||||
pendingClaim?: number | string | null;
|
||||
pendingReview?: number | string | null;
|
||||
pendingDispatch?: number | string | null;
|
||||
completed?: number | string | null;
|
||||
completionRate?: number | string | null;
|
||||
highPriorityTodo?: number | string | null;
|
||||
};
|
||||
type ProductRequirementDashboardRecentChangeResponse = Omit<
|
||||
Api.Product.ProductRequirementDashboardRecentChange,
|
||||
'id' | 'requirementId' | 'operatorUserId'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId?: string | number | null;
|
||||
operatorUserId?: string | number | null;
|
||||
};
|
||||
type ProductRequirementDashboardResponse = {
|
||||
summary?: ProductRequirementDashboardSummaryResponse | null;
|
||||
recentChanges?: ProductRequirementDashboardRecentChangeResponse[] | null;
|
||||
};
|
||||
|
||||
type AttachmentItemResponse = Omit<Api.Project.AttachmentItem, 'fileId'> & {
|
||||
fileId?: string | number;
|
||||
@@ -242,6 +277,51 @@ function normalizeRequirement(requirement: RequirementResponse): Api.Product.Req
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeRequirementReview(review: RequirementReviewResponse): Api.Product.RequirementReview {
|
||||
return {
|
||||
...review,
|
||||
id: normalizeStringId(review.id),
|
||||
requirementId: normalizeStringId(review.requirementId),
|
||||
operatorId: normalizeStringId(review.operatorId),
|
||||
attendees: review.attendees?.map(item => ({
|
||||
...item,
|
||||
userId: normalizeStringId(item.userId)
|
||||
})),
|
||||
attachments: normalizeAttachments(review.attachments)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDashboardCount(value: number | string | null | undefined) {
|
||||
const count = Number(value ?? 0);
|
||||
|
||||
return Number.isFinite(count) ? Math.max(0, count) : 0;
|
||||
}
|
||||
|
||||
function normalizeProductRequirementDashboard(
|
||||
data: ProductRequirementDashboardResponse
|
||||
): Api.Product.ProductRequirementDashboard {
|
||||
const summary = data.summary ?? {};
|
||||
|
||||
return {
|
||||
summary: {
|
||||
total: normalizeDashboardCount(summary.total),
|
||||
todo: normalizeDashboardCount(summary.todo),
|
||||
pendingClaim: normalizeDashboardCount(summary.pendingClaim),
|
||||
pendingReview: normalizeDashboardCount(summary.pendingReview),
|
||||
pendingDispatch: normalizeDashboardCount(summary.pendingDispatch),
|
||||
completed: normalizeDashboardCount(summary.completed),
|
||||
completionRate: Math.min(100, normalizeDashboardCount(summary.completionRate)),
|
||||
highPriorityTodo: normalizeDashboardCount(summary.highPriorityTodo)
|
||||
},
|
||||
recentChanges: (data.recentChanges ?? []).map(item => ({
|
||||
...item,
|
||||
id: normalizeStringId(item.id),
|
||||
requirementId: normalizeNullableStringId(item.requirementId),
|
||||
operatorUserId: normalizeNullableStringId(item.operatorUserId)
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
/** 获取需求分页列表 */
|
||||
export async function fetchGetRequirementPage(params?: Api.Product.RequirementSearchParams) {
|
||||
const result = await request<RequirementPageResponse>({
|
||||
@@ -337,17 +417,6 @@ export async function fetchSplitRequirement(data: Api.Product.SplitRequirementPa
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 关闭需求 */
|
||||
export function fetchCloseRequirement(data: Api.Product.CloseRequirementParams) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/close`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
}
|
||||
|
||||
/** 获取需求可执行的状态动作列表 */
|
||||
export async function fetchGetRequirementAllowedTransitions(requirementId: string, productId: string) {
|
||||
const result = await request<Api.Product.RequirementLifecycleAction[]>({
|
||||
@@ -379,16 +448,43 @@ export async function fetchGetRequirementAllowedTransitionsBatch(data: Api.Produ
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取需求生命周期信息 */
|
||||
export async function fetchGetRequirementLifecycle(requirementId: string, productId: string) {
|
||||
const result = await request<Api.Product.RequirementLifecycleInfo>({
|
||||
/** 提交产品需求评审 */
|
||||
export async function fetchSubmitProductRequirementReview(data: Api.Product.RequirementReviewSubmitParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/lifecycle`,
|
||||
method: 'get',
|
||||
params: { requirementId, productId }
|
||||
url: `${REQUIREMENT_PREFIX}/review/submit`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementLifecycleInfo>, data => data);
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 获取产品需求评审记录 */
|
||||
export async function fetchGetProductRequirementReview(productId: string, requirementId: string) {
|
||||
const result = await request<RequirementReviewResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/review/get`,
|
||||
method: 'get',
|
||||
params: { productId, requirementId }
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<RequirementReviewResponse>, normalizeRequirementReview);
|
||||
}
|
||||
|
||||
/** 获取产品概览需求池实时看板 */
|
||||
export async function fetchGetProductRequirementDashboard(productId: string) {
|
||||
const result = await request<ProductRequirementDashboardResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/dashboard`,
|
||||
method: 'get',
|
||||
params: { productId }
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<ProductRequirementDashboardResponse>,
|
||||
normalizeProductRequirementDashboard
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取需求所有状态字典 */
|
||||
@@ -402,18 +498,7 @@ export async function fetchGetRequirementStatusDict() {
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 获取需求终止态状态字典 */
|
||||
export async function fetchGetRequirementTerminalStatusDict() {
|
||||
const result = await request<Api.Product.RequirementStatusDict[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${REQUIREMENT_PREFIX}/status/dict/terminal`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Product.RequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 判断产品需求是否已分流生成项目需求 */
|
||||
/** 判断产品需求是否已指派并生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirement(requirementId: string, productId: string) {
|
||||
return request<boolean>({
|
||||
...safeJsonRequestConfig,
|
||||
@@ -423,7 +508,7 @@ export async function fetchHasDispatchedProjectRequirement(requirementId: string
|
||||
});
|
||||
}
|
||||
|
||||
/** 批量判断产品需求是否已分流生成项目需求 */
|
||||
/** 批量判断产品需求是否已指派并生成项目需求 */
|
||||
export async function fetchHasDispatchedProjectRequirementBatch(data: Api.Product.RequirementBatchReqVO) {
|
||||
const result = await request<Api.Product.RequirementHasDispatchedBatchRespVO[]>({
|
||||
...safeJsonRequestConfig,
|
||||
|
||||
@@ -23,6 +23,8 @@ export type ProjectExecutionResponse = Omit<
|
||||
| 'actualStartDate'
|
||||
| 'actualEndDate'
|
||||
| 'progressRate'
|
||||
| 'priority'
|
||||
| 'priorityName'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
projectId: StringIdResponse;
|
||||
@@ -34,6 +36,8 @@ export type ProjectExecutionResponse = Omit<
|
||||
actualStartDate?: ProjectLocalDateValue;
|
||||
actualEndDate?: ProjectLocalDateValue;
|
||||
progressRate?: number | null;
|
||||
priority?: string | number | null;
|
||||
priorityName?: string | null;
|
||||
};
|
||||
|
||||
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
|
||||
@@ -108,6 +112,8 @@ export type ProjectTaskResponse = Omit<
|
||||
| 'executionId'
|
||||
| 'parentTaskId'
|
||||
| 'ownerId'
|
||||
| 'executionOwnerId'
|
||||
| 'parentTaskOwnerId'
|
||||
| 'availableActions'
|
||||
| 'plannedStartDate'
|
||||
| 'plannedEndDate'
|
||||
@@ -116,12 +122,18 @@ export type ProjectTaskResponse = Omit<
|
||||
| 'progressRate'
|
||||
| 'assignees'
|
||||
| 'attachments'
|
||||
| 'priority'
|
||||
| 'priorityName'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
projectId: StringIdResponse;
|
||||
executionId: StringIdResponse;
|
||||
executionName?: string | null;
|
||||
executionStatusCode?: Api.Project.ProjectExecutionStatusCode | null;
|
||||
parentTaskId?: StringIdResponse | null;
|
||||
ownerId: StringIdResponse;
|
||||
executionOwnerId?: StringIdResponse | null;
|
||||
parentTaskOwnerId?: StringIdResponse | null;
|
||||
availableActions?: LifecycleActionResponse<Api.Project.ProjectTaskActionCode>[] | null;
|
||||
plannedStartDate?: ProjectLocalDateValue;
|
||||
plannedEndDate?: ProjectLocalDateValue;
|
||||
@@ -131,13 +143,21 @@ export type ProjectTaskResponse = Omit<
|
||||
assignees?: TaskAssigneeRefResponse[] | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
totalSpentHours?: number | null;
|
||||
priority?: string | number | null;
|
||||
priorityName?: string | null;
|
||||
};
|
||||
|
||||
export type TaskWorklogResponse = Omit<Api.Project.TaskWorklog, 'id' | 'taskId' | 'userId' | 'attachments'> & {
|
||||
export type TaskWorklogResponse = Omit<
|
||||
Api.Project.TaskWorklog,
|
||||
'id' | 'taskId' | 'userId' | 'difficulty' | 'attachments' | 'startDate' | 'endDate'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
taskId: StringIdResponse;
|
||||
userId: StringIdResponse;
|
||||
difficulty?: string | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
startDate?: ProjectLocalDateValue;
|
||||
endDate?: ProjectLocalDateValue;
|
||||
};
|
||||
|
||||
export interface ProjectMemberResponse {
|
||||
@@ -233,12 +253,21 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePriority(value: string | number | null | undefined): string {
|
||||
if (value === null || value === undefined || value === '') {
|
||||
return '1';
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
|
||||
return {
|
||||
...response,
|
||||
id: normalizeStringId(response.id),
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||
projectRequirementName: response.projectRequirementName ?? null,
|
||||
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
ownerNickname: response.ownerNickname ?? null,
|
||||
statusName: response.statusName ?? null,
|
||||
@@ -250,6 +279,8 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
|
||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||
priority: normalizePriority(response.priority),
|
||||
priorityName: response.priorityName ?? null,
|
||||
executionDesc: response.executionDesc ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null
|
||||
};
|
||||
@@ -289,9 +320,17 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
|
||||
id: normalizeStringId(response.id),
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
executionId: normalizeStringId(response.executionId),
|
||||
executionName: response.executionName ?? null,
|
||||
executionStatusCode: response.executionStatusCode ?? null,
|
||||
parentTaskId: normalizeNullableStringId(response.parentTaskId),
|
||||
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
|
||||
projectRequirementName: response.projectRequirementName ?? null,
|
||||
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
|
||||
type: response.type ?? '',
|
||||
ownerId: normalizeStringId(response.ownerId),
|
||||
ownerNickname: response.ownerNickname ?? null,
|
||||
executionOwnerId: normalizeNullableStringId(response.executionOwnerId),
|
||||
parentTaskOwnerId: normalizeNullableStringId(response.parentTaskOwnerId),
|
||||
statusName: response.statusName ?? null,
|
||||
terminal: Boolean(response.terminal),
|
||||
allowEdit: Boolean(response.allowEdit),
|
||||
@@ -301,6 +340,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
|
||||
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
|
||||
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
|
||||
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
|
||||
priority: normalizePriority(response.priority),
|
||||
priorityName: response.priorityName ?? null,
|
||||
taskDesc: response.taskDesc ?? null,
|
||||
lastStatusReason: response.lastStatusReason ?? null,
|
||||
assignees:
|
||||
@@ -323,7 +364,13 @@ export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project
|
||||
userNickname: response.userNickname ?? null,
|
||||
workContent: response.workContent ?? null,
|
||||
attachments: normalizeAttachments(response.attachments),
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||
// 后端 LocalDate 默认序列化为 [year, month, day] 数组,必须归一为 'YYYY-MM-DD' 字符串供 ElDatePicker 使用
|
||||
startDate: normalizeProjectLocalDate(response.startDate) ?? '',
|
||||
endDate: normalizeProjectLocalDate(response.endDate) ?? '',
|
||||
// 历史记录或异常缺失时兜底为字典默认档位 "2"
|
||||
difficulty: response.difficulty ?? '2',
|
||||
difficultyName: response.difficultyName ?? null
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -443,6 +443,14 @@ export function fetchDeleteProjectExecution(
|
||||
});
|
||||
}
|
||||
|
||||
/** 执行删除预检(spec §2.1:返回是否含下挂数据,用于前端弹层分流) */
|
||||
export function fetchPrecheckDeleteProjectExecution(projectId: string, executionId: string) {
|
||||
return request<Api.Project.ProjectExecutionDeletePrecheck>({
|
||||
url: `${getExecutionPrefix(projectId)}/${executionId}/delete-precheck`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** 变更项目执行状态 */
|
||||
export function fetchChangeProjectExecutionStatus(
|
||||
projectId: string,
|
||||
@@ -638,6 +646,14 @@ export function fetchDeleteProjectTask(
|
||||
});
|
||||
}
|
||||
|
||||
/** 任务删除预检(spec §2.1) */
|
||||
export function fetchPrecheckDeleteProjectTask(projectId: string, executionId: string, taskId: string) {
|
||||
return request<Api.Project.ProjectTaskDeletePrecheck>({
|
||||
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/delete-precheck`,
|
||||
method: 'get'
|
||||
});
|
||||
}
|
||||
|
||||
/** 变更项目任务状态 */
|
||||
export function fetchChangeProjectTaskStatus(
|
||||
projectId: string,
|
||||
@@ -652,6 +668,80 @@ export function fetchChangeProjectTaskStatus(
|
||||
});
|
||||
}
|
||||
|
||||
// ============= 项目级跨执行任务(不带 executionId 路径段) =============
|
||||
// 调试文档:所有接口挂在 /project/project/{projectId}/tasks/* 下;通过 involveUserId / ownerId / executionIds 等
|
||||
// 入参组合表达"我的任务 / 项目全部 / 指定执行"等视角。原有执行级 {eid}/tasks/page 等保留不动。
|
||||
|
||||
function getProjectTasksPrefix(projectId: string) {
|
||||
return `${PROJECT_PREFIX}/${projectId}/tasks`;
|
||||
}
|
||||
|
||||
/** 项目级跨执行任务分页 */
|
||||
export async function fetchGetProjectTaskPageCross(
|
||||
projectId: string,
|
||||
params?: Api.Project.ProjectTaskCrossSearchParams
|
||||
) {
|
||||
const result = await request<ProjectTaskPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${getProjectTasksPrefix(projectId)}/page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectTaskPageResponse>, data => ({
|
||||
...data,
|
||||
list: data.list.map(normalizeProjectTask)
|
||||
}));
|
||||
}
|
||||
|
||||
/** 项目级跨执行任务状态看板 */
|
||||
export function fetchGetProjectTaskStatusBoardCross(
|
||||
projectId: string,
|
||||
params?: Api.Project.ProjectTaskCrossStatusBoardParams
|
||||
) {
|
||||
return request<StatusBoardResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${getProjectTasksPrefix(projectId)}/status-board`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
/** 项目级跨执行任务看板分页(每列共用同一组 pageNo / pageSize;列内固定 plannedEndDate ASC, id DESC) */
|
||||
export async function fetchGetProjectTaskBoardPageCross(
|
||||
projectId: string,
|
||||
params?: Api.Project.ProjectTaskCrossBoardPageParams
|
||||
) {
|
||||
const result = await request<ProjectTaskBoardPageResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${getProjectTasksPrefix(projectId)}/board-page`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<ProjectTaskBoardPageResponse>, data => ({
|
||||
items: data.items.map(item => ({
|
||||
...item,
|
||||
list: item.list.map(normalizeProjectTask)
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目级"今日小条"汇总(4 个数字 + 服务器日期边界)。
|
||||
*
|
||||
* scope=all 必须有 project:task:query 权限,否则 403(PROJECT_OBJECT_PERMISSION_DENIED)。
|
||||
* 前端切到"项目全部"视角前应已基于权限码隐藏入口;如真被 403,UI 应自动切回"我的"。
|
||||
*/
|
||||
export function fetchGetProjectTaskSummary(projectId: string, params?: Api.Project.ProjectTaskSummaryParams) {
|
||||
return request<Api.Project.ProjectTaskSummary>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${getProjectTasksPrefix(projectId)}/summary`,
|
||||
method: 'get',
|
||||
params
|
||||
});
|
||||
}
|
||||
|
||||
type TaskWorklogPageResponse = Api.Project.PageResult<TaskWorklogResponse>;
|
||||
|
||||
function getWorklogPrefix(projectId: string, executionId: string, taskId: string) {
|
||||
@@ -816,6 +906,19 @@ type ProjectRequirementResponse = Omit<
|
||||
};
|
||||
|
||||
type ProjectRequirementPageResponse = Api.Project.PageResult<ProjectRequirementResponse>;
|
||||
type ProjectRequirementReviewResponse = Omit<
|
||||
Api.Project.ProjectRequirementReview,
|
||||
'id' | 'requirementId' | 'operatorId' | 'attendees' | 'attachments'
|
||||
> & {
|
||||
id: string | number;
|
||||
requirementId: string | number;
|
||||
operatorId: string | number;
|
||||
attendees?: Array<{
|
||||
userId: string | number;
|
||||
nickname: string;
|
||||
}>;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
};
|
||||
|
||||
type ProjectRequirementModuleResponse = Omit<Api.Project.ProjectRequirementModule, 'id' | 'parentId' | 'projectId'> & {
|
||||
id: string | number;
|
||||
@@ -855,10 +958,27 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
|
||||
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
|
||||
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
|
||||
attachments: normalizeAttachments(requirement.attachments),
|
||||
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
|
||||
children: requirement.children?.map(normalizeProjectRequirement)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectRequirementReview(
|
||||
review: ProjectRequirementReviewResponse
|
||||
): Api.Project.ProjectRequirementReview {
|
||||
return {
|
||||
...review,
|
||||
id: normalizeStringId(review.id),
|
||||
requirementId: normalizeStringId(review.requirementId),
|
||||
operatorId: normalizeStringId(review.operatorId),
|
||||
attendees: review.attendees?.map(item => ({
|
||||
...item,
|
||||
userId: normalizeStringId(item.userId)
|
||||
})),
|
||||
attachments: normalizeAttachments(review.attachments)
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeProjectRequirementModule(
|
||||
module: ProjectRequirementModuleResponse
|
||||
): Api.Project.ProjectRequirementModule {
|
||||
@@ -1013,16 +1133,31 @@ export async function fetchGetProjectRequirementAllowedTransitionsBatch(
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取项目需求生命周期信息 */
|
||||
export async function fetchGetProjectRequirementLifecycle(requirementId: string, projectId: string) {
|
||||
const result = await request<Api.Project.ProjectRequirementLifecycleInfo>({
|
||||
/** 提交项目需求评审 */
|
||||
export async function fetchSubmitProjectRequirementReview(data: Api.Project.ProjectRequirementReviewSubmitParams) {
|
||||
const result = await request<string | number>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/lifecycle`,
|
||||
method: 'get',
|
||||
params: { requirementId, projectId }
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/review/submit`,
|
||||
method: 'post',
|
||||
data
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementLifecycleInfo>, data => data);
|
||||
return mapServiceResult(result as ServiceRequestResult<string | number>, normalizeStringId);
|
||||
}
|
||||
|
||||
/** 获取项目需求评审记录 */
|
||||
export async function fetchGetProjectRequirementReview(projectId: string, requirementId: string) {
|
||||
const result = await request<ProjectRequirementReviewResponse>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/review/get`,
|
||||
method: 'get',
|
||||
params: { projectId, requirementId }
|
||||
});
|
||||
|
||||
return mapServiceResult(
|
||||
result as ServiceRequestResult<ProjectRequirementReviewResponse>,
|
||||
normalizeProjectRequirementReview
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取项目需求状态字典 */
|
||||
@@ -1036,17 +1171,6 @@ export async function fetchGetProjectRequirementStatusDict() {
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 获取项目需求终态状态字典 */
|
||||
export async function fetchGetProjectRequirementTerminalStatusDict() {
|
||||
const result = await request<Api.Project.ProjectRequirementStatusDict[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${PROJECT_REQUIREMENT_PREFIX}/status/dict/terminal`,
|
||||
method: 'get'
|
||||
});
|
||||
|
||||
return mapServiceResult(result as ServiceRequestResult<Api.Project.ProjectRequirementStatusDict[]>, data => data);
|
||||
}
|
||||
|
||||
/** 获取项目需求模块树 */
|
||||
export async function fetchGetProjectRequirementModuleTree(projectId: string) {
|
||||
const result = await request<ProjectRequirementModuleResponse[]>({
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { RouteMeta } from 'vue-router';
|
||||
import type { ElegantConstRoute, LastLevelRouteKey } from '@elegant-router/types';
|
||||
import { objectContextDomainConfigs } from '@/constants/object-context';
|
||||
import { SYSTEM_SERVICE_PREFIX } from '@/constants/service';
|
||||
|
||||
@@ -445,7 +445,7 @@ export function fetchBatchDeletePost(ids: number[]) {
|
||||
}
|
||||
|
||||
/** 获取用户简单列表(用于用户选择下拉框) */
|
||||
export function fetchGetUserSimpleList() {
|
||||
export async function fetchGetUserSimpleList() {
|
||||
return request<UserSimpleResponse[]>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_PREFIX}/simple-list`,
|
||||
@@ -455,6 +455,19 @@ export function fetchGetUserSimpleList() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取当前登录人的直属上级 */
|
||||
export async function fetchGetLoginUserDirectManager() {
|
||||
return request<UserSimpleResponse | null>({
|
||||
...safeJsonRequestConfig,
|
||||
url: `${USER_PREFIX}/profile/direct-manager`,
|
||||
method: 'get'
|
||||
}).then(result =>
|
||||
mapServiceResult(result as ServiceRequestResult<UserSimpleResponse | null>, data =>
|
||||
data ? normalizeUserSimple(data) : null
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/** 获取用户分页 */
|
||||
export function fetchGetUserPage(params?: Api.SystemManage.UserSearchParams) {
|
||||
return request<Api.SystemManage.UserList>({
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { ref } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_OBJECT_DIRECTION_LEGACY_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetFrontendDictCache } from '@/service/api';
|
||||
import { fetchGetDictDataByCode, fetchGetFrontendDictCache } from '@/service/api';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
|
||||
type DictValue = string | number | null | undefined;
|
||||
@@ -19,6 +19,15 @@ function sortDictData(list: Api.Dict.DictData[]) {
|
||||
return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
|
||||
}
|
||||
|
||||
// hex 色值兜底校验:仅接受 #RRGGBB(6 位);其他格式(含 #RGB 简写 / rgb())一律视为无效回落到默认渲染
|
||||
const HEX_COLOR_PATTERN = /^#[0-9a-f]{6}$/i;
|
||||
|
||||
function normalizeColorType(raw: unknown): string | null {
|
||||
if (typeof raw !== 'string') return null;
|
||||
const trimmed = raw.trim().toLowerCase();
|
||||
return HEX_COLOR_PATTERN.test(trimmed) ? trimmed : null;
|
||||
}
|
||||
|
||||
function normalizeFrontendDictData(
|
||||
dictType: string,
|
||||
list: Api.Dict.FrontendDictData[],
|
||||
@@ -31,13 +40,25 @@ function normalizeFrontendDictData(
|
||||
dictType: item.dictType || dictType,
|
||||
sort: item.sort,
|
||||
status: item.status ?? 0,
|
||||
remark: null,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
remark: item.remark ?? null,
|
||||
createTime: 0
|
||||
}));
|
||||
|
||||
return sortDictData(normalizedList);
|
||||
}
|
||||
|
||||
function normalizeDictDataItem(item: Api.Dict.DictData, dictType: string): Api.Dict.DictData {
|
||||
return {
|
||||
...item,
|
||||
value: String(item.value),
|
||||
dictType: item.dictType || dictType,
|
||||
status: item.status ?? 0,
|
||||
colorType: normalizeColorType(item.colorType),
|
||||
remark: item.remark ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeFrontendDictCache(cache: Api.Dict.FrontendDictCache) {
|
||||
const entries = Object.entries(cache);
|
||||
|
||||
@@ -89,6 +110,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
const loadedAt = ref<number | null>(null);
|
||||
|
||||
let initPromise: Promise<boolean> | null = null;
|
||||
const dictDataLoadPromises = new Map<string, Promise<boolean>>();
|
||||
|
||||
function resetDictCache() {
|
||||
dictTypes.value = [];
|
||||
@@ -96,6 +118,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
loadedAt.value = null;
|
||||
initialized.value = false;
|
||||
initPromise = null;
|
||||
dictDataLoadPromises.clear();
|
||||
}
|
||||
|
||||
async function initDictCache(force = false) {
|
||||
@@ -137,6 +160,51 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
async function ensureDictData(dictType: string, force = false) {
|
||||
if (!dictType) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!initialized.value) {
|
||||
await initDictCache();
|
||||
}
|
||||
|
||||
if (!force && getDictData(dictType).length > 0) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const pending = dictDataLoadPromises.get(dictType);
|
||||
if (pending && !force) {
|
||||
return pending;
|
||||
}
|
||||
|
||||
const promise = (async () => {
|
||||
const result = await fetchGetDictDataByCode(dictType);
|
||||
|
||||
if (result.error || !result.data?.list?.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
dictDataMap.value = {
|
||||
...dictDataMap.value,
|
||||
[dictType]: sortDictData(result.data.list.map(item => normalizeDictDataItem(item, dictType)))
|
||||
};
|
||||
dictTypes.value = createRuntimeDictTypes(dictDataMap.value);
|
||||
|
||||
return true;
|
||||
})();
|
||||
|
||||
dictDataLoadPromises.set(dictType, promise);
|
||||
|
||||
try {
|
||||
return await promise;
|
||||
} finally {
|
||||
if (dictDataLoadPromises.get(dictType) === promise) {
|
||||
dictDataLoadPromises.delete(dictType);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getDictData(dictType: string, onlyEnabled = false) {
|
||||
if (!dictType) {
|
||||
return [];
|
||||
@@ -199,6 +267,7 @@ export const useDictStore = defineStore(SetupStoreId.Dict, () => {
|
||||
dictDataMap,
|
||||
loadedAt,
|
||||
initDictCache,
|
||||
ensureDictData,
|
||||
resetDictCache,
|
||||
getDictData,
|
||||
getDictOptions,
|
||||
|
||||
11
src/store/modules/workbench/index.ts
Normal file
11
src/store/modules/workbench/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import { computed } from 'vue';
|
||||
import { defineStore } from 'pinia';
|
||||
import { useWorkbenchLayout } from '@/views/workbench/composables/use-workbench-layout';
|
||||
import { SetupStoreId } from '@/enum';
|
||||
import { useAuthStore } from '../auth';
|
||||
|
||||
export const useWorkbenchStore = defineStore(SetupStoreId.Workbench, () => {
|
||||
const authStore = useAuthStore();
|
||||
const userId = computed(() => String(authStore.userInfo?.userId ?? 'anonymous'));
|
||||
return useWorkbenchLayout({ userId: userId.value });
|
||||
});
|
||||
@@ -416,6 +416,20 @@ html .el-collapse {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.business-table-action-icon-button {
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
|
||||
&.el-button + .el-button {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.business-table-action-icon {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.business-table-action-menu {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -428,6 +442,19 @@ html .el-collapse {
|
||||
margin-left: 0 !important;
|
||||
}
|
||||
|
||||
.business-table-action-menu__link {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
margin-left: 0 !important;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
.business-table-action-menu__item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.business-table-card-body {
|
||||
display: flex;
|
||||
height: calc(100% - 56px);
|
||||
|
||||
12
src/typings/api/dict.d.ts
vendored
12
src/typings/api/dict.d.ts
vendored
@@ -47,8 +47,6 @@ declare namespace Api {
|
||||
id: number;
|
||||
/** dict label */
|
||||
label: string;
|
||||
/** sign */
|
||||
sign?: string | null;
|
||||
/** dict value */
|
||||
value: string;
|
||||
/** dict type code */
|
||||
@@ -57,6 +55,8 @@ declare namespace Api {
|
||||
sort: number;
|
||||
/** status: 0 enabled, 1 disabled */
|
||||
status: DictStatus;
|
||||
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
colorType?: string | null;
|
||||
/** remark */
|
||||
remark?: string | null;
|
||||
/** create time */
|
||||
@@ -67,8 +67,6 @@ declare namespace Api {
|
||||
interface FrontendDictData {
|
||||
/** dict label */
|
||||
label: string;
|
||||
/** sign */
|
||||
sign?: string | null;
|
||||
/** dict value */
|
||||
value: string;
|
||||
/** display order */
|
||||
@@ -77,6 +75,10 @@ declare namespace Api {
|
||||
dictType?: string;
|
||||
/** status: 0 enabled, 1 disabled */
|
||||
status?: DictStatus;
|
||||
/** 颜色(hex,#xxxxxx);nullable,无值时前端按默认渲染 */
|
||||
colorType?: string | null;
|
||||
/** 备注,可用于下拉中文释义展示 */
|
||||
remark?: string | null;
|
||||
}
|
||||
|
||||
/** frontend runtime dict cache map */
|
||||
@@ -86,7 +88,7 @@ declare namespace Api {
|
||||
type DictDataSearchParams = CommonType.RecordNullable<Pick<DictData, 'label' | 'dictType' | 'status'>> & PageParams;
|
||||
|
||||
/** dict data save params */
|
||||
type SaveDictDataParams = Pick<DictData, 'label' | 'sign' | 'value' | 'dictType' | 'sort' | 'status'> & {
|
||||
type SaveDictDataParams = Pick<DictData, 'label' | 'value' | 'dictType' | 'sort' | 'status' | 'colorType'> & {
|
||||
remark?: string | null;
|
||||
};
|
||||
}
|
||||
|
||||
78
src/typings/api/overtime-application.d.ts
vendored
Normal file
78
src/typings/api/overtime-application.d.ts
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
declare namespace Api {
|
||||
namespace OvertimeApplication {
|
||||
interface PageParams {
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
}
|
||||
|
||||
type OvertimeApplicationStatusCode = 'pending' | 'approved' | 'rejected' | 'cancelled';
|
||||
|
||||
type OvertimeApplicationActionType = 'submit' | 'resubmit' | 'approve' | 'reject' | 'cancel';
|
||||
|
||||
interface OvertimeApplication {
|
||||
id: string;
|
||||
applicantId: string;
|
||||
applicantName: string;
|
||||
overtimeDate: string;
|
||||
overtimeDuration: string;
|
||||
overtimeReason: string;
|
||||
overtimeContent: string;
|
||||
approverId: string;
|
||||
approverName: string;
|
||||
statusCode: OvertimeApplicationStatusCode;
|
||||
statusName: string;
|
||||
allowEdit: boolean;
|
||||
terminal: boolean;
|
||||
approvalComment?: string | null;
|
||||
submitTime: string;
|
||||
approvalTime?: string | null;
|
||||
createTime: string;
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
type OvertimeApplicationSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||
keyword: string;
|
||||
applicantName: string;
|
||||
approverId: string;
|
||||
approverName: string;
|
||||
statusCode: OvertimeApplicationStatusCode;
|
||||
overtimeDate: string[];
|
||||
createTime: string[];
|
||||
}
|
||||
>;
|
||||
|
||||
interface OvertimeApplicationPageResult {
|
||||
total: number;
|
||||
list: OvertimeApplication[];
|
||||
}
|
||||
|
||||
interface SaveOvertimeApplicationParams {
|
||||
overtimeDate: string;
|
||||
overtimeDuration: string;
|
||||
overtimeReason: string;
|
||||
overtimeContent: string;
|
||||
approverId: string;
|
||||
}
|
||||
|
||||
interface StatusActionParams {
|
||||
reason?: string | null;
|
||||
}
|
||||
|
||||
interface OvertimeApplicationStatusLog {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
actionType: OvertimeApplicationActionType;
|
||||
fromStatus?: string | null;
|
||||
toStatus: string;
|
||||
reason?: string | null;
|
||||
operatorUserId: string;
|
||||
operatorName: string;
|
||||
applicantNameSnapshot: string;
|
||||
overtimeDateSnapshot: string;
|
||||
overtimeDurationSnapshot: string;
|
||||
remark?: string | null;
|
||||
createTime: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
2
src/typings/api/personal-item.d.ts
vendored
2
src/typings/api/personal-item.d.ts
vendored
@@ -16,6 +16,7 @@ declare namespace Api {
|
||||
interface PersonalItem {
|
||||
id: string;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
ownerId: string;
|
||||
statusCode: PersonalItemStatusCode;
|
||||
terminal?: boolean;
|
||||
@@ -56,6 +57,7 @@ declare namespace Api {
|
||||
|
||||
interface SavePersonalItemParams {
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
ownerId?: string;
|
||||
executionId?: string | null;
|
||||
progressRate?: number | null;
|
||||
|
||||
105
src/typings/api/product.d.ts
vendored
105
src/typings/api/product.d.ts
vendored
@@ -256,15 +256,29 @@ declare namespace Api {
|
||||
// ========== 产品需求相关类型定义 ==========
|
||||
/** 需求状态编码 */
|
||||
type RequirementStatusCode =
|
||||
| 'pending_confirm'
|
||||
| 'pending_claim'
|
||||
| 'pending_review'
|
||||
| 'pending_dispatch'
|
||||
| 'reviewed'
|
||||
| 'review_rejected'
|
||||
| 'implementing'
|
||||
| 'accepted'
|
||||
| 'closed'
|
||||
| 'rejected'
|
||||
| 'cancelled';
|
||||
|
||||
/** 需求状态动作编码 */
|
||||
type RequirementStatusActionCode =
|
||||
| 'claim_to_review'
|
||||
| 'claim_to_dispatch'
|
||||
| 'pass_review'
|
||||
| 'reject_review'
|
||||
| 'dispatch'
|
||||
| 'cancel'
|
||||
| 'accept'
|
||||
| 'close'
|
||||
| 'reject';
|
||||
|
||||
/** 需求来源类型 */
|
||||
type RequirementSourceType = 'manual' | 'work_order';
|
||||
|
||||
@@ -333,8 +347,6 @@ declare namespace Api {
|
||||
updateTime: string;
|
||||
/** 子需求列表(树形结构) */
|
||||
children?: Requirement[];
|
||||
/** 是否为终态 */
|
||||
terminal?: boolean;
|
||||
}
|
||||
|
||||
// ========== 需求模块实体 ==========
|
||||
@@ -371,27 +383,18 @@ declare namespace Api {
|
||||
initialFlag: boolean;
|
||||
/** 是否终态 */
|
||||
terminalFlag: boolean;
|
||||
/** 是否允许编辑 */
|
||||
allowEdit: boolean;
|
||||
}
|
||||
|
||||
// ========== 需求生命周期 ==========
|
||||
|
||||
interface RequirementLifecycleAction {
|
||||
actionCode: string;
|
||||
actionCode: RequirementStatusActionCode;
|
||||
actionName: string;
|
||||
toStatusCode: string;
|
||||
toStatusName: string;
|
||||
needReason: boolean;
|
||||
}
|
||||
|
||||
interface RequirementLifecycleInfo {
|
||||
statusCode: RequirementStatusCode;
|
||||
statusName?: string | null;
|
||||
lastStatusReason?: string | null;
|
||||
terminal: boolean;
|
||||
allowEdit: boolean;
|
||||
availableActions: RequirementLifecycleAction[];
|
||||
}
|
||||
|
||||
interface RequirementBatchReqVO {
|
||||
productId: string;
|
||||
requirementIds: string[];
|
||||
@@ -407,6 +410,78 @@ declare namespace Api {
|
||||
hasDispatched: boolean;
|
||||
}
|
||||
|
||||
type ProductRequirementDashboardRecentChangeActionType = 'create' | 'delete' | 'status_terminal';
|
||||
|
||||
interface ProductRequirementDashboardSummary {
|
||||
/** 当前产品下所有未删除需求数,包括根需求和子需求 */
|
||||
total: number;
|
||||
/** 待认领、待评审、待指派的需求数 */
|
||||
todo: number;
|
||||
/** 待认领需求数 */
|
||||
pendingClaim: number;
|
||||
/** 待评审需求数 */
|
||||
pendingReview: number;
|
||||
/** 待指派需求数 */
|
||||
pendingDispatch: number;
|
||||
/** 已验收或已关闭需求数 */
|
||||
completed: number;
|
||||
/** 完成率,0-100 */
|
||||
completionRate: number;
|
||||
/** P0/P1 且待处理的需求数 */
|
||||
highPriorityTodo: number;
|
||||
}
|
||||
|
||||
interface ProductRequirementDashboardRecentChange {
|
||||
id: string;
|
||||
requirementId?: string | null;
|
||||
title: string;
|
||||
actionType: ProductRequirementDashboardRecentChangeActionType;
|
||||
actionLabel: string;
|
||||
content: string;
|
||||
occurredAt: string;
|
||||
operatorUserId?: string | null;
|
||||
operatorName?: string | null;
|
||||
}
|
||||
|
||||
interface ProductRequirementDashboard {
|
||||
summary: ProductRequirementDashboardSummary;
|
||||
recentChanges: ProductRequirementDashboardRecentChange[];
|
||||
}
|
||||
|
||||
type RequirementReviewConclusion = 0 | 1;
|
||||
|
||||
interface RequirementReviewAttendeeItem {
|
||||
userId: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface RequirementReview {
|
||||
id: string;
|
||||
objectType: 'product_requirement';
|
||||
requirementId: string;
|
||||
operatorId: string;
|
||||
conclusion: RequirementReviewConclusion;
|
||||
reviewContent?: string | null;
|
||||
requirementEstimatedHours?: number | string | null;
|
||||
attendees?: RequirementReviewAttendeeItem[];
|
||||
attachments?: Api.Project.AttachmentItem[] | null;
|
||||
reviewTime?: string | null;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
interface RequirementReviewSubmitParams {
|
||||
productId: string;
|
||||
requirementId: string;
|
||||
operatorId: string;
|
||||
conclusion: RequirementReviewConclusion;
|
||||
reviewContent?: string | null;
|
||||
requirementEstimatedHours?: number | string | null;
|
||||
attendees?: RequirementReviewAttendeeItem[];
|
||||
attachments?: Api.Project.AttachmentItem[] | null;
|
||||
reviewTime?: string | null;
|
||||
}
|
||||
|
||||
// ========== 请求参数类型 ==========
|
||||
|
||||
/** 需求分页查询参数 */
|
||||
|
||||
236
src/typings/api/project.d.ts
vendored
236
src/typings/api/project.d.ts
vendored
@@ -65,7 +65,7 @@ declare namespace Api {
|
||||
type ProjectExecutionStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||
|
||||
/** 执行动作编码 */
|
||||
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel';
|
||||
type ProjectExecutionActionCode = 'start' | 'pause' | 'resume' | 'cancel' | 'complete';
|
||||
|
||||
/** 任务状态编码 */
|
||||
type ProjectTaskStatusCode = 'pending' | 'active' | 'paused' | 'completed' | 'cancelled';
|
||||
@@ -96,6 +96,10 @@ declare namespace Api {
|
||||
id: string;
|
||||
projectId: string;
|
||||
projectRequirementId: string | null;
|
||||
/** 关联项目需求名称(service 层批量回填;未关联 = null) */
|
||||
projectRequirementName: string | null;
|
||||
/** 关联项目需求状态编码(pending_confirm/pending_review/implementing/accepted/closed/rejected/cancelled) */
|
||||
projectRequirementStatusCode: string | null;
|
||||
executionName: string;
|
||||
executionType: string | null;
|
||||
ownerId: string;
|
||||
@@ -110,6 +114,10 @@ declare namespace Api {
|
||||
actualStartDate: string | null;
|
||||
actualEndDate: string | null;
|
||||
progressRate: number;
|
||||
/** 优先级字典 value(rdms_req_priority):"0" P0 / "1" P1(默认)/ "2" P2 / "3" P3,数字越小越高 */
|
||||
priority: string;
|
||||
/** 优先级标签预留字段;当前后端不填、永远为 null,前端按 priority 自译 */
|
||||
priorityName: string | null;
|
||||
executionDesc: string | null;
|
||||
lastStatusReason: string | null;
|
||||
createTime: string;
|
||||
@@ -212,12 +220,23 @@ declare namespace Api {
|
||||
id: string;
|
||||
projectId: string;
|
||||
executionId: string;
|
||||
/** 所属执行名称;跨执行查询必有,单执行查询可缺省 */
|
||||
executionName?: string | null;
|
||||
/** 所属执行状态编码;跨执行查询必有,单执行查询可缺省(用于灰显已完成执行的任务行) */
|
||||
executionStatusCode?: ProjectExecutionStatusCode | null;
|
||||
parentTaskId: string | null;
|
||||
/** 所属执行关联的项目需求 ID(透传,未关联 = null) */
|
||||
projectRequirementId: string | null;
|
||||
/** 所属执行关联的项目需求名称(透传,未关联 = null;跨执行查询永远为 null,前端不在跨执行视角展示) */
|
||||
projectRequirementName: string | null;
|
||||
/** 所属执行关联的项目需求状态编码(同上) */
|
||||
projectRequirementStatusCode: string | null;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
ownerId: string;
|
||||
ownerNickname?: string | null;
|
||||
/** 所属执行的负责人 userId(按钮可见度公式用) */
|
||||
executionOwnerId: string;
|
||||
/** 所属执行的负责人 userId(按钮可见度公式用);跨执行查询永远为 null,按钮判定退化为只看权限码 */
|
||||
executionOwnerId: string | null;
|
||||
/** 父任务负责人 userId(一级任务为 null) */
|
||||
parentTaskOwnerId: string | null;
|
||||
statusCode: ProjectTaskStatusCode;
|
||||
@@ -230,6 +249,10 @@ declare namespace Api {
|
||||
plannedEndDate: string | null;
|
||||
actualStartDate: string | null;
|
||||
actualEndDate: string | null;
|
||||
/** 优先级字典 value(rdms_req_priority):"0" P0 / "1" P1(默认)/ "2" P2 / "3" P3,数字越小越高 */
|
||||
priority: string;
|
||||
/** 优先级标签预留字段;当前后端不填、永远为 null,前端按 priority 自译 */
|
||||
priorityName: string | null;
|
||||
taskDesc: string | null;
|
||||
lastStatusReason: string | null;
|
||||
assignees?: TaskAssigneeRef[] | null;
|
||||
@@ -240,12 +263,31 @@ declare namespace Api {
|
||||
updateTime: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行截止时间范围(基于 plannedEndDate):overdue 逾期 / today 今天到期 / thisWeek 本周到期。
|
||||
* 与任务侧 dueRange 同口径,后端三档均排除终态执行(已完成 / 已取消);未知值 = 不过滤。
|
||||
*/
|
||||
type ProjectExecutionDueRange = 'overdue' | 'today' | 'thisWeek';
|
||||
|
||||
/**
|
||||
* 项目执行分页入参(`GET /project/project/{projectId}/executions/page`)。
|
||||
*
|
||||
* - `involveUserId` / `ownerId` 互斥:同传后端不报错但语义变 AND,前端切视角时务必清另一字段。
|
||||
* - 不传 `involveUserId` 且不传 `ownerId` = 项目下全部执行。
|
||||
* - `dueRange` 按计划结束日期过滤,与其它参数 AND;详见 ProjectExecutionDueRange。
|
||||
*/
|
||||
type ProjectExecutionSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||
keyword: string;
|
||||
executionType: string;
|
||||
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
|
||||
involveUserId: string;
|
||||
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
|
||||
ownerId: string;
|
||||
statusCode: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
dueRange: ProjectExecutionDueRange;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -253,7 +295,12 @@ declare namespace Api {
|
||||
type ProjectExecutionStatusBoardParams = CommonType.RecordNullable<{
|
||||
keyword: string;
|
||||
executionType: string;
|
||||
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
|
||||
involveUserId: string;
|
||||
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
|
||||
ownerId: string;
|
||||
/** 截止时间范围过滤,传入后各状态分组计数均在该范围内统计(口径同 page) */
|
||||
dueRange: ProjectExecutionDueRange;
|
||||
updateTime: string[];
|
||||
}>;
|
||||
|
||||
@@ -265,6 +312,8 @@ declare namespace Api {
|
||||
projectRequirementId: string | null;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
executionDesc: string | null;
|
||||
assigneeUserIds?: string[];
|
||||
}
|
||||
@@ -279,6 +328,8 @@ declare namespace Api {
|
||||
projectRequirementId: string | null;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
executionDesc: string | null;
|
||||
}
|
||||
|
||||
@@ -306,6 +357,8 @@ declare namespace Api {
|
||||
parentTaskId: string;
|
||||
ownerId: string;
|
||||
statusCode: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -330,6 +383,8 @@ declare namespace Api {
|
||||
keyword: string;
|
||||
parentTaskId: string;
|
||||
ownerId: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -347,13 +402,93 @@ declare namespace Api {
|
||||
items: ProjectTaskBoardColumn[];
|
||||
}
|
||||
|
||||
/** 截止时间快速选项(跨执行接口专属) */
|
||||
type ProjectTaskDueRange = 'overdue' | 'today' | 'thisWeek';
|
||||
|
||||
/** 跨执行任务排序字段 */
|
||||
type ProjectTaskCrossSortBy = 'plannedEndDate' | 'priority' | 'updateTime' | 'createTime';
|
||||
|
||||
type ProjectTaskCrossSortOrder = 'asc' | 'desc';
|
||||
|
||||
/**
|
||||
* 项目级跨执行任务分页入参(`GET /project/project/{projectId}/tasks/page`)。
|
||||
*
|
||||
* - `involveUserId` / `ownerId` 互斥:同传只 `ownerId` 生效(后端 SQL 双重过滤)。
|
||||
* - `executionIds` 不传 = 项目内全部执行;空数组 `[]` = 明确返空。
|
||||
* - `executionInvolveUserId` = 限定到"该用户参与的执行"(owner 或活跃执行协办);未参与任何执行时返空;
|
||||
* 与 `executionIds` 同传为 AND。用它表达"我参与的执行"范围,无需前端先查执行 id 再回传。
|
||||
* - `executionStatusCodes` 在任务可见性之上叠加"任务所属执行状态 ∈ 白名单"过滤;多值 OR;
|
||||
* 与 `executionIds` 同传时为 AND。详见 `docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html`。
|
||||
* - 不传 `involveUserId / ownerId` 且无 `project:task:query` 权限时,后端静默降级为"自己有身份的范围",不抛 403。
|
||||
*/
|
||||
type ProjectTaskCrossSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> & {
|
||||
keyword: string;
|
||||
executionIds: string[];
|
||||
/**
|
||||
* 执行成员过滤:该用户作为执行 owner 或活跃执行协办人的执行 → 其下任务;未参与任何执行时返空。
|
||||
* 与 `involveUserId`(任务成员)正交,可同传取交集。
|
||||
*/
|
||||
executionInvolveUserId: string;
|
||||
/** 任务所属执行的状态白名单(用于左侧执行池按状态 chip 切换时的任务范围过滤) */
|
||||
executionStatusCodes: ProjectExecutionStatusCode[];
|
||||
/** "我参与"语义:当前用户作为 owner 或活跃协办;与 ownerId 二选一 */
|
||||
involveUserId: string;
|
||||
/** 仅作为 owner 匹配;与 involveUserId 二选一 */
|
||||
ownerId: string;
|
||||
statusCodes: ProjectTaskStatusCode[];
|
||||
/** 优先级字典 value("0"~"3") */
|
||||
priority: string;
|
||||
parentTaskId: string;
|
||||
dueRange: ProjectTaskDueRange;
|
||||
/** 更新时间范围 [start, end],格式 yyyy-MM-dd HH:mm:ss */
|
||||
updateTime: string[];
|
||||
sortBy: ProjectTaskCrossSortBy;
|
||||
sortOrder: ProjectTaskCrossSortOrder;
|
||||
}
|
||||
>;
|
||||
|
||||
/** 项目级跨执行任务状态看板入参(与 page 同口径但不含 pageNo/pageSize/statusCodes/sortBy/sortOrder) */
|
||||
type ProjectTaskCrossStatusBoardParams = Omit<
|
||||
ProjectTaskCrossSearchParams,
|
||||
'pageNo' | 'pageSize' | 'statusCodes' | 'sortBy' | 'sortOrder'
|
||||
>;
|
||||
|
||||
/** 项目级跨执行任务看板分页入参 */
|
||||
type ProjectTaskCrossBoardPageParams = Omit<ProjectTaskCrossSearchParams, 'sortBy' | 'sortOrder'>;
|
||||
|
||||
/** 项目级"今日小条"汇总入参 */
|
||||
interface ProjectTaskSummaryParams {
|
||||
/** 默认 mine(不传也走 mine);all 必须有 project:task:query 权限,否则 403 */
|
||||
scope?: 'mine' | 'all';
|
||||
}
|
||||
|
||||
/**
|
||||
* 项目级"今日小条"汇总响应(`GET /project/project/{projectId}/tasks/summary`)。
|
||||
*
|
||||
* 数字一致性:dueThisWeek 的范围与 page?dueRange=thisWeek 完全一致(本周一~本周日)。
|
||||
* today / weekStart / weekEnd 直接展示,不要前端再算"今天/本周一"(服务器时区为 Asia/Shanghai)。
|
||||
*/
|
||||
interface ProjectTaskSummary {
|
||||
overdue: number;
|
||||
dueToday: number;
|
||||
dueThisWeek: number;
|
||||
doneThisWeek: number;
|
||||
today: string;
|
||||
weekStart: string;
|
||||
weekEnd: string;
|
||||
}
|
||||
|
||||
interface SaveProjectTaskParams {
|
||||
parentTaskId: string | null;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
ownerId: string | null;
|
||||
progressRate?: number;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
taskDesc: string | null;
|
||||
/** 仅创建任务时生效,编辑接口静默忽略;userId 必须是当前有效执行协办人且不能等于 ownerId */
|
||||
assigneeUserIds?: string[];
|
||||
@@ -380,7 +515,10 @@ declare namespace Api {
|
||||
durationHours: number;
|
||||
/** 本次填报进度(0~100,scale=2) */
|
||||
progressRate: number;
|
||||
difficulty?: string | null;
|
||||
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||
difficulty: string;
|
||||
/** 后端预留字段,目前始终为 null,前端按 difficulty + 字典 cache 自译 */
|
||||
difficultyName?: string | null;
|
||||
workContent: string | null;
|
||||
attachments?: AttachmentItem[] | null;
|
||||
createTime: string;
|
||||
@@ -392,6 +530,8 @@ declare namespace Api {
|
||||
userId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
/** 完成难度筛选,等值匹配;不传 = 全部 */
|
||||
difficulty: string;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -404,7 +544,8 @@ declare namespace Api {
|
||||
durationHours: number;
|
||||
/** 本次填报进度(0~100,scale=2,必填) */
|
||||
progressRate: number;
|
||||
difficulty?: string | null;
|
||||
/** 难度,来自字典 rdms_task_item_worklog_difficulty */
|
||||
difficulty: string;
|
||||
workContent?: string | null;
|
||||
/** 编辑语义:null 保留原值 / [] 清空 / [...] 替换 */
|
||||
attachments?: AttachmentItem[] | null;
|
||||
@@ -599,6 +740,24 @@ declare namespace Api {
|
||||
reason: string;
|
||||
}
|
||||
|
||||
/** 执行删除预检(spec §2.1:判断是否需要走重型确认弹层) */
|
||||
interface ProjectExecutionDeletePrecheck {
|
||||
/** 该执行下任务总数(含子孙,含 completed),展示用 */
|
||||
taskCount: number;
|
||||
/** taskCount > 0 视为 true */
|
||||
hasDependentData: boolean;
|
||||
}
|
||||
|
||||
/** 任务删除预检(spec §2.1) */
|
||||
interface ProjectTaskDeletePrecheck {
|
||||
/** 直接子任务数 */
|
||||
childTaskCount: number;
|
||||
/** 工作日志条数 */
|
||||
worklogCount: number;
|
||||
/** childTaskCount + worklogCount > 0 视为 true */
|
||||
hasDependentData: boolean;
|
||||
}
|
||||
|
||||
/** 创建项目成员参数 */
|
||||
interface CreateProjectMemberParams {
|
||||
userId: string;
|
||||
@@ -657,14 +816,28 @@ declare namespace Api {
|
||||
// ========== 项目需求相关类型定义 ==========
|
||||
/** 项目需求状态编码 */
|
||||
type ProjectRequirementStatusCode =
|
||||
| 'pending_confirm'
|
||||
| 'pending_claim'
|
||||
| 'pending_review'
|
||||
| 'reviewed'
|
||||
| 'review_rejected'
|
||||
| 'implementing'
|
||||
| 'accepted'
|
||||
| 'closed'
|
||||
| 'rejected'
|
||||
| 'cancelled';
|
||||
|
||||
/** 项目需求状态动作编码 */
|
||||
type ProjectRequirementStatusActionCode =
|
||||
| 'claim_to_review'
|
||||
| 'claim_to_implement'
|
||||
| 'pass_review'
|
||||
| 'reject_review'
|
||||
| 'start_implement'
|
||||
| 'accept'
|
||||
| 'cancel'
|
||||
| 'close'
|
||||
| 'reject';
|
||||
|
||||
/** 项目需求来源类型 */
|
||||
type ProjectRequirementSourceType = 'manual' | 'work_order' | 'product_requirement';
|
||||
|
||||
@@ -721,14 +894,14 @@ declare namespace Api {
|
||||
expectedTime?: string | null;
|
||||
/** 排序值 */
|
||||
sort: number;
|
||||
/** 项目需求进度(BigDecimal,0.00 ~ 1.00;HALF_UP 两位小数)。读时聚合,后端不接受写入。 */
|
||||
progressRate: number;
|
||||
/** 创建时间 */
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
updateTime: string;
|
||||
/** 子需求列表 */
|
||||
children?: ProjectRequirement[];
|
||||
/** 是否终态 */
|
||||
terminal?: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementModule {
|
||||
@@ -761,25 +934,18 @@ declare namespace Api {
|
||||
initialFlag: boolean;
|
||||
/** 是否终态 */
|
||||
terminalFlag: boolean;
|
||||
/** 是否允许编辑 */
|
||||
allowEdit: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementLifecycleAction {
|
||||
actionCode: string;
|
||||
actionCode: ProjectRequirementStatusActionCode;
|
||||
actionName: string;
|
||||
toStatusCode: string;
|
||||
toStatusName: string;
|
||||
needReason: boolean;
|
||||
}
|
||||
|
||||
interface ProjectRequirementLifecycleInfo {
|
||||
statusCode: ProjectRequirementStatusCode;
|
||||
statusName?: string | null;
|
||||
lastStatusReason?: string | null;
|
||||
terminal: boolean;
|
||||
allowEdit: boolean;
|
||||
availableActions: ProjectRequirementLifecycleAction[];
|
||||
}
|
||||
|
||||
interface ProjectRequirementBatchReqVO {
|
||||
projectId: string;
|
||||
requirementIds: string[];
|
||||
@@ -790,6 +956,40 @@ declare namespace Api {
|
||||
transitions: ProjectRequirementLifecycleAction[];
|
||||
}
|
||||
|
||||
type ProjectRequirementReviewConclusion = 0 | 1;
|
||||
|
||||
interface ProjectRequirementReviewAttendeeItem {
|
||||
userId: string;
|
||||
nickname: string;
|
||||
}
|
||||
|
||||
interface ProjectRequirementReview {
|
||||
id: string;
|
||||
objectType: 'project_requirement';
|
||||
requirementId: string;
|
||||
operatorId: string;
|
||||
conclusion: ProjectRequirementReviewConclusion;
|
||||
reviewContent?: string | null;
|
||||
requirementEstimatedHours?: number | string | null;
|
||||
attendees?: ProjectRequirementReviewAttendeeItem[];
|
||||
attachments?: AttachmentItem[] | null;
|
||||
reviewTime?: string | null;
|
||||
createTime?: string;
|
||||
updateTime?: string;
|
||||
}
|
||||
|
||||
interface ProjectRequirementReviewSubmitParams {
|
||||
projectId: string;
|
||||
requirementId: string;
|
||||
operatorId: string;
|
||||
conclusion: ProjectRequirementReviewConclusion;
|
||||
reviewContent?: string | null;
|
||||
requirementEstimatedHours?: number | string | null;
|
||||
attendees?: ProjectRequirementReviewAttendeeItem[];
|
||||
attachments?: AttachmentItem[] | null;
|
||||
reviewTime?: string | null;
|
||||
}
|
||||
|
||||
/** 项目需求分页查询参数 */
|
||||
type ProjectRequirementSearchParams = CommonType.RecordNullable<
|
||||
Pick<PageParams, 'pageNo' | 'pageSize'> &
|
||||
|
||||
2
src/typings/app.d.ts
vendored
2
src/typings/app.d.ts
vendored
@@ -866,6 +866,7 @@ declare namespace App {
|
||||
dictStatus: string;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
colorType: string;
|
||||
sort: string;
|
||||
remark: string;
|
||||
form: {
|
||||
@@ -874,6 +875,7 @@ declare namespace App {
|
||||
dictStatus: string;
|
||||
dictLabel: string;
|
||||
dictValue: string;
|
||||
colorType: string;
|
||||
sort: string;
|
||||
remark: string;
|
||||
};
|
||||
|
||||
8
src/typings/components.d.ts
vendored
8
src/typings/components.d.ts
vendored
@@ -9,6 +9,7 @@ export {}
|
||||
declare module 'vue' {
|
||||
export interface GlobalComponents {
|
||||
AppProvider: typeof import('./../components/common/app-provider.vue')['default']
|
||||
AttendeeUserPicker: typeof import('./../components/custom/attendee-user-picker.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']
|
||||
@@ -18,6 +19,7 @@ declare module 'vue' {
|
||||
BusinessFormSimpleDialog: typeof import('./../components/custom/business-form-simple-dialog.vue')['default']
|
||||
BusinessRichTextEditor: typeof import('./../components/custom/business-rich-text-editor.vue')['default']
|
||||
BusinessRichTextView: typeof import('./../components/custom/business-rich-text-view.vue')['default']
|
||||
BusinessUserPicker: typeof import('./../components/custom/business-user-picker.vue')['default']
|
||||
BusinessUserSelect: typeof import('./../components/custom/business-user-select.vue')['default']
|
||||
ButtonIcon: typeof import('./../components/custom/button-icon.vue')['default']
|
||||
CountTo: typeof import('./../components/custom/count-to.vue')['default']
|
||||
@@ -100,10 +102,14 @@ declare module 'vue' {
|
||||
IconCarbonStop: typeof import('~icons/carbon/stop')['default']
|
||||
'IconCharm:download': typeof import('~icons/charm/download')['default']
|
||||
'IconEp:arrowDown': typeof import('~icons/ep/arrow-down')['default']
|
||||
'IconEp:arrowRight': typeof import('~icons/ep/arrow-right')['default']
|
||||
'IconEp:box': typeof import('~icons/ep/box')['default']
|
||||
'IconEp:check': typeof import('~icons/ep/check')['default']
|
||||
'IconEp:files': typeof import('~icons/ep/files')['default']
|
||||
'IconEp:folder': typeof import('~icons/ep/folder')['default']
|
||||
'IconEp:infoFilled': typeof import('~icons/ep/info-filled')['default']
|
||||
'IconEp:plus': typeof import('~icons/ep/plus')['default']
|
||||
'IconEp:sort': typeof import('~icons/ep/sort')['default']
|
||||
IconEpRemoveFilled: typeof import('~icons/ep/remove-filled')['default']
|
||||
IconEpSuccessFilled: typeof import('~icons/ep/success-filled')['default']
|
||||
'IconF7:circleFill': typeof import('~icons/f7/circle-fill')['default']
|
||||
@@ -144,6 +150,7 @@ declare module 'vue' {
|
||||
IconMdiCrown: typeof import('~icons/mdi/crown')['default']
|
||||
IconMdiDeleteOutline: typeof import('~icons/mdi/delete-outline')['default']
|
||||
IconMdiDotsHorizontal: typeof import('~icons/mdi/dots-horizontal')['default']
|
||||
IconMdiDownload: typeof import('~icons/mdi/download')['default']
|
||||
IconMdiDrag: typeof import('~icons/mdi/drag')['default']
|
||||
IconMdiFilterVariant: typeof import('~icons/mdi/filter-variant')['default']
|
||||
IconMdiFolderOpen: typeof import('~icons/mdi/folder-open')['default']
|
||||
@@ -176,6 +183,7 @@ declare module 'vue' {
|
||||
TableSearchFields: typeof import('./../components/custom/table-search-fields.vue')['default']
|
||||
TableSearchPanel: typeof import('./../components/custom/table-search-panel.vue')['default']
|
||||
ThemeSchemaSwitch: typeof import('./../components/common/theme-schema-switch.vue')['default']
|
||||
UserPickerTrigger: typeof import('./../components/custom/business-user-picker/components/user-picker-trigger.vue')['default']
|
||||
WaveBg: typeof import('./../components/custom/wave-bg.vue')['default']
|
||||
WebSiteLink: typeof import('./../components/custom/web-site-link.vue')['default']
|
||||
}
|
||||
|
||||
2
src/typings/elegant-router.d.ts
vendored
2
src/typings/elegant-router.d.ts
vendored
@@ -50,6 +50,7 @@ declare module "@elegant-router/types" {
|
||||
"personal-center_my-performance": "/personal-center/my-performance";
|
||||
"personal-center_my-profile": "/personal-center/my-profile";
|
||||
"personal-center_my-weekly": "/personal-center/my-weekly";
|
||||
"personal-center_overtime-application": "/personal-center/overtime-application";
|
||||
"personal-center_pending-approval": "/personal-center/pending-approval";
|
||||
"plugin": "/plugin";
|
||||
"plugin_barcode": "/plugin/barcode";
|
||||
@@ -187,6 +188,7 @@ declare module "@elegant-router/types" {
|
||||
| "personal-center_my-performance"
|
||||
| "personal-center_my-profile"
|
||||
| "personal-center_my-weekly"
|
||||
| "personal-center_overtime-application"
|
||||
| "personal-center_pending-approval"
|
||||
| "plugin_barcode"
|
||||
| "plugin_charts_antv"
|
||||
|
||||
@@ -17,6 +17,9 @@ import StateMachineOperateDialog from './modules/state-machine-operate-dialog.vu
|
||||
import StateMachineSearch from './modules/state-machine-search.vue';
|
||||
import StateTransitionDialog from './modules/state-transition-dialog.vue';
|
||||
import { formatDateTime, getBooleanLabel, getBooleanTagType, getStatusLabel, getStatusTagType } from './shared';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiSourceBranch from '~icons/mdi/source-branch';
|
||||
|
||||
defineOptions({ name: 'StateMachineManage' });
|
||||
|
||||
@@ -71,6 +74,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'transition',
|
||||
label: '状态流转',
|
||||
icon: IconMdiSourceBranch,
|
||||
buttonType: 'primary',
|
||||
onClick: () => openTransitionDialog(row)
|
||||
});
|
||||
@@ -80,6 +84,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: IconMdiPencilOutline,
|
||||
buttonType: 'primary',
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
@@ -89,6 +94,7 @@ function getStatusModelActions(row: Api.Infra.ObjectStatusModel): BusinessTableA
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
icon: IconMdiDeleteOutline,
|
||||
buttonType: 'danger',
|
||||
onClick: () => handleDeleteAction(row)
|
||||
});
|
||||
@@ -203,7 +209,7 @@ const { columns, columnChecks, data, loading, getData, getDataByPage, mobilePagi
|
||||
return <span>--</span>;
|
||||
}
|
||||
|
||||
return <BusinessTableActionCell actions={actions} />;
|
||||
return <BusinessTableActionCell actions={actions} variant="icon" />;
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
@@ -292,6 +292,13 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
openOperateDialog();
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
tooltip: '删除',
|
||||
icon: markRaw(IconMdiDeleteOutline),
|
||||
type: 'danger',
|
||||
onClick: async () => handleDelete(row)
|
||||
},
|
||||
{
|
||||
key: 'status-pause',
|
||||
tooltip: pauseAction?.actionName ?? '暂停',
|
||||
@@ -305,19 +312,19 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
needReason: pauseAction?.needReason ?? false
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'status-cancel',
|
||||
tooltip: cancelAction?.actionName ?? '取消',
|
||||
icon: markRaw(IconMdiCloseCircleOutline),
|
||||
type: 'danger',
|
||||
disabled: !cancelAction,
|
||||
onClick: async () =>
|
||||
handleStatusAction(row, {
|
||||
actionCode: cancelAction?.actionCode ?? 'cancel',
|
||||
actionName: cancelAction?.actionName ?? '取消',
|
||||
needReason: cancelAction?.needReason ?? false
|
||||
})
|
||||
},
|
||||
// {
|
||||
// key: 'status-cancel',
|
||||
// tooltip: cancelAction?.actionName ?? '取消',
|
||||
// icon: markRaw(IconMdiCloseCircleOutline),
|
||||
// type: 'danger',
|
||||
// disabled: !cancelAction,
|
||||
// onClick: async () =>
|
||||
// handleStatusAction(row, {
|
||||
// actionCode: cancelAction?.actionCode ?? 'cancel',
|
||||
// actionName: cancelAction?.actionName ?? '取消',
|
||||
// needReason: cancelAction?.needReason ?? false
|
||||
// })
|
||||
// },
|
||||
...lifecycleActions,
|
||||
{
|
||||
key: 'status-complete',
|
||||
@@ -331,13 +338,6 @@ function buildRowActions(row: Api.PersonalItem.PersonalItem): PersonalItemRowAct
|
||||
actionName: completeAction?.actionName ?? '完成',
|
||||
needReason: completeAction?.needReason ?? false
|
||||
})
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
tooltip: '删除',
|
||||
icon: markRaw(IconMdiDeleteOutline),
|
||||
type: 'danger',
|
||||
onClick: async () => handleDelete(row)
|
||||
}
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,8 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchGetPersonalItemDetail } from '@/service/api';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import {
|
||||
fetchCompletePersonalItem,
|
||||
fetchCreatePersonalItemWorklog,
|
||||
fetchDeletePersonalItemWorklog,
|
||||
fetchGetPersonalItemDetail,
|
||||
fetchGetPersonalItemWorklogPage,
|
||||
fetchUpdatePersonalItemWorklog
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import PersonalItemWorklogPanel from './personal-item-worklog-panel.vue';
|
||||
import TaskWorklogPanel from '@/views/project/project/execution/modules/task-worklog-panel.vue';
|
||||
import {
|
||||
formatPersonalItemDate,
|
||||
formatPersonalItemOwnerName,
|
||||
formatPersonalItemProgress,
|
||||
getPersonalItemStatusLabel,
|
||||
resolvePersonalItemStatusTagType
|
||||
} from './personal-item-shared';
|
||||
|
||||
defineOptions({ name: 'PersonalItemDetailDialog' });
|
||||
|
||||
@@ -14,6 +30,7 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
rowData: null,
|
||||
defaultTab: 'worklog'
|
||||
});
|
||||
|
||||
@@ -27,6 +44,49 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const activeTab = ref<TabName>('worklog');
|
||||
const detailData = ref<Api.PersonalItem.PersonalItem | null>(null);
|
||||
const authStore = useAuthStore();
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
const currentUserName = computed(
|
||||
() => authStore.userInfo.nickname?.trim() || authStore.userInfo.userName?.trim() || ''
|
||||
);
|
||||
|
||||
const COMPLETED_STATUS_CODE: Api.PersonalItem.PersonalItemStatusCode = 'completed';
|
||||
const COMPLETE_ACTION_CODE = 'complete';
|
||||
|
||||
const ownerName = computed(() => {
|
||||
if (!detailData.value) return '--';
|
||||
|
||||
const displayName = formatPersonalItemOwnerName(detailData.value);
|
||||
|
||||
if (displayName !== detailData.value.ownerId) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return detailData.value.ownerId === currentUserId.value && currentUserName.value
|
||||
? currentUserName.value
|
||||
: displayName;
|
||||
});
|
||||
const statusName = computed(() => (detailData.value ? getPersonalItemStatusLabel(detailData.value.statusCode) : '--'));
|
||||
const statusTagType = computed(() =>
|
||||
detailData.value ? resolvePersonalItemStatusTagType(detailData.value.statusCode) : 'info'
|
||||
);
|
||||
const progressText = computed(() => formatPersonalItemProgress(detailData.value?.progressRate));
|
||||
const plannedStartText = computed(() => formatPersonalItemDate(detailData.value?.plannedStartDate));
|
||||
const plannedEndText = computed(() => formatPersonalItemDate(detailData.value?.plannedEndDate));
|
||||
const actualStartText = computed(() => formatPersonalItemDate(detailData.value?.actualStartDate));
|
||||
const actualEndText = computed(() => formatPersonalItemDate(detailData.value?.actualEndDate));
|
||||
const totalHoursText = computed(() => {
|
||||
const total = detailData.value?.totalSpentHours;
|
||||
return `${typeof total === 'number' && Number.isFinite(total) ? total.toFixed(1) : '0.0'}h`;
|
||||
});
|
||||
const canSubmitWorklog = computed(() =>
|
||||
Boolean(
|
||||
detailData.value?.id &&
|
||||
(detailData.value.statusCode === 'pending' ||
|
||||
detailData.value.statusCode === 'active' ||
|
||||
detailData.value.statusCode === 'completed')
|
||||
)
|
||||
);
|
||||
|
||||
function syncDetailFromPageRow() {
|
||||
detailData.value = props.rowData ?? null;
|
||||
@@ -44,14 +104,64 @@ async function refreshDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
function canPromptCompleteItem(item: Api.PersonalItem.PersonalItem) {
|
||||
if (item.statusCode === COMPLETED_STATUS_CODE || item.terminal) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return (
|
||||
item.progressRate >= 100 && (item.availableActions ?? []).some(action => action.actionCode === COMPLETE_ACTION_CODE)
|
||||
);
|
||||
}
|
||||
|
||||
async function promptCompleteItemIfNeeded() {
|
||||
if (!detailData.value || !canPromptCompleteItem(detailData.value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await ElMessageBox.confirm('事项进度已达 100%,是否完成当前事项?', '完成确认', {
|
||||
confirmButtonText: '完成事项',
|
||||
cancelButtonText: '仅保留工时',
|
||||
type: 'info'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchCompletePersonalItem(detailData.value.id);
|
||||
|
||||
if (!error) {
|
||||
window.$message?.success('个人事项已完成');
|
||||
await refreshDetail();
|
||||
}
|
||||
}
|
||||
|
||||
async function handleWorklogChanged() {
|
||||
await refreshDetail();
|
||||
await promptCompleteItemIfNeeded();
|
||||
|
||||
if (detailData.value) {
|
||||
emit('changed', detailData.value);
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPersonalWorklogPage(params: Api.Project.TaskWorklogSearchParams) {
|
||||
return fetchGetPersonalItemWorklogPage(detailData.value!.id, params);
|
||||
}
|
||||
|
||||
function createPersonalWorklog(data: Api.Project.SaveTaskWorklogParams) {
|
||||
return fetchCreatePersonalItemWorklog(detailData.value!.id, data);
|
||||
}
|
||||
|
||||
function updatePersonalWorklog(payload: { worklogId: string; data: Api.Project.SaveTaskWorklogParams }) {
|
||||
return fetchUpdatePersonalItemWorklog(detailData.value!.id, payload);
|
||||
}
|
||||
|
||||
function deletePersonalWorklog(worklogId: string) {
|
||||
return fetchDeletePersonalItemWorklog(detailData.value!.id, worklogId);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
@@ -92,12 +202,66 @@ watch(
|
||||
>
|
||||
<ElTabs v-model="activeTab" class="personal-item-detail-dialog__tabs">
|
||||
<ElTabPane label="工作日志" name="worklog" lazy>
|
||||
<PersonalItemWorklogPanel
|
||||
v-if="detailData"
|
||||
:item="detailData"
|
||||
:active="activeTab === 'worklog' && visible"
|
||||
@changed="handleWorklogChanged"
|
||||
/>
|
||||
<div v-if="detailData" class="personal-item-worklog-content">
|
||||
<div class="personal-item-worklog-content__cards">
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">负责人</span>
|
||||
<span class="personal-item-worklog-content__card-value" :title="ownerName">{{ ownerName }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">当前状态</span>
|
||||
<ElTag :type="statusTagType" size="small" effect="light" class="personal-item-worklog-content__card-tag">
|
||||
{{ statusName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">计划开始</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ plannedStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">计划结束</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ plannedEndText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">当前进度</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ progressText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">累计工时</span>
|
||||
<span class="personal-item-worklog-content__card-value personal-item-worklog-content__card-value--accent">
|
||||
{{ totalHoursText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">实际开始</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ actualStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-content__card">
|
||||
<span class="personal-item-worklog-content__card-label">实际结束</span>
|
||||
<span class="personal-item-worklog-content__card-value">{{ actualEndText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TaskWorklogPanel
|
||||
project-id=""
|
||||
execution-id=""
|
||||
:task-id="detailData.id"
|
||||
:task-owner-id="currentUserId"
|
||||
:task-status-code="detailData.statusCode"
|
||||
:task-progress-rate="detailData.progressRate"
|
||||
:can-submit="canSubmitWorklog"
|
||||
:active="activeTab === 'worklog' && visible"
|
||||
:fetch-worklog-page="fetchPersonalWorklogPage"
|
||||
:create-worklog="createPersonalWorklog"
|
||||
:update-worklog="updatePersonalWorklog"
|
||||
:delete-worklog="deletePersonalWorklog"
|
||||
attachment-directory="personal-item-worklog"
|
||||
create-success-message="工作日志新增成功"
|
||||
update-success-message="工作日志修改成功"
|
||||
delete-success-message="工作日志删除成功"
|
||||
@changed="handleWorklogChanged"
|
||||
/>
|
||||
</div>
|
||||
</ElTabPane>
|
||||
</ElTabs>
|
||||
</BusinessFormDialog>
|
||||
@@ -112,4 +276,51 @@ watch(
|
||||
.personal-item-detail-dialog__tabs :deep(.el-tab-pane) {
|
||||
min-height: 640px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-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;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-value {
|
||||
overflow: hidden;
|
||||
color: var(--el-text-color-primary);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-value--accent {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.personal-item-worklog-content__card-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchCreatePersonalItem, fetchGetPersonalItemDetail, fetchUpdatePersonalItem } from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
@@ -9,6 +10,7 @@ import BusinessAttachmentUploader from '@/components/custom/business-attachment-
|
||||
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 DictSelect from '@/components/custom/dict-select.vue';
|
||||
import { isEmptyRichText } from './personal-item-shared';
|
||||
|
||||
defineOptions({ name: 'PersonalItemOperateDialog' });
|
||||
@@ -57,6 +59,7 @@ const submitting = ref(false);
|
||||
|
||||
interface Model {
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
taskDesc: string | null;
|
||||
@@ -76,6 +79,7 @@ const title = computed(() => {
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
taskTitle: '',
|
||||
type: '',
|
||||
plannedStartDate: null,
|
||||
plannedEndDate: null,
|
||||
taskDesc: null,
|
||||
@@ -108,6 +112,7 @@ const rules = computed(
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
type: [createRequiredRule('请选择事项类型')],
|
||||
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
|
||||
plannedEndDate: [
|
||||
createRequiredRule('请选择计划结束日期'),
|
||||
@@ -136,6 +141,7 @@ async function initModel() {
|
||||
|
||||
if (!error && data) {
|
||||
model.taskTitle = data.taskTitle;
|
||||
model.type = data.type;
|
||||
model.plannedStartDate = data.plannedStartDate;
|
||||
model.plannedEndDate = data.plannedEndDate;
|
||||
model.taskDesc = data.taskDesc;
|
||||
@@ -166,6 +172,7 @@ async function handleSubmit() {
|
||||
|
||||
const payload: Api.PersonalItem.SavePersonalItemParams = {
|
||||
taskTitle: model.taskTitle.trim(),
|
||||
type: model.type,
|
||||
ownerId: currentUserId.value,
|
||||
plannedStartDate: model.plannedStartDate,
|
||||
plannedEndDate: model.plannedEndDate,
|
||||
@@ -235,6 +242,16 @@ watch(
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="事项类型" prop="type">
|
||||
<DictSelect
|
||||
v-model="model.type"
|
||||
:dict-code="RDMS_TASK_ITEM_TYPE_DICT_CODE"
|
||||
:clearable="!isView"
|
||||
:disabled="isView"
|
||||
placeholder="请选择事项类型"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="计划开始日期" prop="plannedStartDate">
|
||||
<ElDatePicker
|
||||
v-model="model.plannedStartDate"
|
||||
|
||||
@@ -1,409 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
|
||||
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: 'PersonalItemWorklogFormDialog' });
|
||||
|
||||
type Mode = 'create' | 'edit' | 'view';
|
||||
type Granularity = 'day' | 'week';
|
||||
|
||||
interface Props {
|
||||
mode: Mode;
|
||||
rowData: Api.PersonalItem.PersonalItemWorklog | null;
|
||||
itemStatusCode: Api.PersonalItem.PersonalItemStatusCode;
|
||||
defaultProgressRate?: number;
|
||||
confirmLoading?: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'submit', payload: Api.PersonalItem.SavePersonalItemWorklogParams): void;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
defaultProgressRate: 0,
|
||||
confirmLoading: false
|
||||
});
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', { default: false });
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const isView = computed(() => props.mode === 'view');
|
||||
const isProgressReadonly = computed(() => isView.value || props.itemStatusCode === 'completed');
|
||||
|
||||
interface FormModel {
|
||||
granularity: Granularity;
|
||||
workDate: string | null;
|
||||
weekDate: Date | null;
|
||||
durationHours: number | null;
|
||||
progressRate: number;
|
||||
difficulty: string;
|
||||
workContent: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
}
|
||||
|
||||
const model = reactive<FormModel>({
|
||||
granularity: 'day',
|
||||
workDate: null,
|
||||
weekDate: null,
|
||||
durationHours: null,
|
||||
progressRate: 0,
|
||||
difficulty: '2',
|
||||
workContent: null,
|
||||
attachments: []
|
||||
});
|
||||
|
||||
const granularityOptions = [
|
||||
{ label: '按天', value: 'day' as const },
|
||||
{ label: '按周', value: 'week' as const }
|
||||
];
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (props.mode === 'create') return '填写工作日志';
|
||||
if (props.mode === 'view') return '查看工作日志';
|
||||
return '编辑工作日志';
|
||||
});
|
||||
const dateFieldLabel = computed(() => (model.granularity === 'day' ? '工作日期' : '工作周次'));
|
||||
|
||||
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() }
|
||||
];
|
||||
|
||||
const weekRangeTooltip = computed(() => {
|
||||
if (!model.weekDate) return '';
|
||||
const start = dayjs(model.weekDate);
|
||||
if (!start.isValid()) return '';
|
||||
return `${start.format('YYYY-MM-DD')} ~ ${start.add(6, 'day').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;
|
||||
}
|
||||
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'
|
||||
}
|
||||
],
|
||||
difficulty: [createRequiredRule('请选择完成难度')],
|
||||
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[]>
|
||||
);
|
||||
|
||||
function detectGranularityFromRow(row: Api.PersonalItem.PersonalItemWorklog): Granularity {
|
||||
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,
|
||||
() => {
|
||||
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.PersonalItem.SavePersonalItemWorklogParams = {
|
||||
startDate,
|
||||
endDate,
|
||||
durationHours: Number(model.durationHours!.toFixed(1)),
|
||||
progressRate: Number(model.progressRate.toFixed(2)),
|
||||
difficulty: model.difficulty,
|
||||
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 granularity = detectGranularityFromRow(row);
|
||||
model.granularity = granularity;
|
||||
model.workDate = granularity === 'day' ? row.startDate : null;
|
||||
model.weekDate = granularity === 'week' ? dayjs(row.startDate).toDate() : null;
|
||||
model.durationHours = row.durationHours;
|
||||
model.progressRate = row.progressRate;
|
||||
model.difficulty = row.difficulty || '2';
|
||||
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.progressRate = props.defaultProgressRate;
|
||||
model.difficulty = '2';
|
||||
model.workContent = null;
|
||||
model.attachments = [];
|
||||
}
|
||||
|
||||
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="personal-item-worklog-form-dialog__date-picker"
|
||||
/>
|
||||
<ElTooltip v-else :content="weekRangeTooltip" :disabled="!weekRangeTooltip" placement="top">
|
||||
<span class="personal-item-worklog-form-dialog__week-wrapper">
|
||||
<ElDatePicker
|
||||
v-model="model.weekDate"
|
||||
type="week"
|
||||
format="YYYY[年第]ww[周]"
|
||||
placeholder="选择工作周次"
|
||||
:shortcuts="isView ? undefined : weekDateShortcuts"
|
||||
:disabled="isView"
|
||||
class="personal-item-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"
|
||||
: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="isProgressReadonly"
|
||||
controls-position="right"
|
||||
class="w-full"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="完成难度" prop="difficulty">
|
||||
<DictSelect
|
||||
v-model="model.difficulty"
|
||||
:dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE"
|
||||
:disabled="isView"
|
||||
:clearable="false"
|
||||
/>
|
||||
</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="personal-item-worklog"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</ElForm>
|
||||
|
||||
<template v-if="isView" #footer="{ close }">
|
||||
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||
</template>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.personal-item-worklog-form-dialog__date-picker.el-date-editor.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.personal-item-worklog-form-dialog__week-wrapper {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -1,656 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElPopconfirm, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
|
||||
import {
|
||||
fetchCreatePersonalItemWorklog,
|
||||
fetchDeletePersonalItemWorklog,
|
||||
fetchGetPersonalItemWorklogPage,
|
||||
fetchUpdatePersonalItemWorklog
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import {
|
||||
formatPersonalItemDate,
|
||||
formatPersonalItemOwnerName,
|
||||
formatPersonalItemProgress,
|
||||
getPersonalItemStatusLabel,
|
||||
resolvePersonalItemStatusTagType
|
||||
} from './personal-item-shared';
|
||||
import PersonalItemWorklogFormDialog from './personal-item-worklog-form-dialog.vue';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiPaperclip from '~icons/mdi/paperclip';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'PersonalItemWorklogPanel' });
|
||||
|
||||
type WorklogGranularity = 'day' | 'week';
|
||||
|
||||
interface Props {
|
||||
item: Api.PersonalItem.PersonalItem;
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
active: true
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
changed: [];
|
||||
}>();
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { getLabel: getDifficultyLabel } = useDict(RDMS_WORKLOG_DIFFICULTY_DICT_CODE);
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
const currentUserName = computed(
|
||||
() => authStore.userInfo.nickname?.trim() || authStore.userInfo.userName?.trim() || ''
|
||||
);
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
const TABLE_HEIGHT = 390;
|
||||
|
||||
const pageNo = ref(1);
|
||||
const total = ref(0);
|
||||
const loading = ref(false);
|
||||
const records = ref<Api.PersonalItem.PersonalItemWorklog[]>([]);
|
||||
|
||||
const formVisible = ref(false);
|
||||
const formMode = ref<'create' | 'edit' | 'view'>('create');
|
||||
const submitting = ref(false);
|
||||
const editingWorklog = ref<Api.PersonalItem.PersonalItemWorklog | null>(null);
|
||||
const worklogFormDialogRef = ref<InstanceType<typeof PersonalItemWorklogFormDialog> | null>(null);
|
||||
|
||||
const totalHours = computed(() => {
|
||||
if (typeof props.item.totalSpentHours === 'number' && Number.isFinite(props.item.totalSpentHours)) {
|
||||
return props.item.totalSpentHours;
|
||||
}
|
||||
|
||||
return records.value.reduce((sum, item) => sum + (item.durationHours ?? 0), 0);
|
||||
});
|
||||
const totalHoursText = computed(() => `${totalHours.value.toFixed(1)}h`);
|
||||
const ownerName = computed(() => {
|
||||
const displayName = formatPersonalItemOwnerName(props.item);
|
||||
|
||||
if (displayName !== props.item.ownerId) {
|
||||
return displayName;
|
||||
}
|
||||
|
||||
return props.item.ownerId === currentUserId.value && currentUserName.value ? currentUserName.value : displayName;
|
||||
});
|
||||
const statusName = computed(() => getPersonalItemStatusLabel(props.item.statusCode));
|
||||
const progressText = computed(() => formatPersonalItemProgress(props.item.progressRate));
|
||||
const plannedStartText = computed(() => formatPersonalItemDate(props.item.plannedStartDate));
|
||||
const plannedEndText = computed(() => formatPersonalItemDate(props.item.plannedEndDate));
|
||||
const actualStartText = computed(() => formatPersonalItemDate(props.item.actualStartDate));
|
||||
const actualEndText = computed(() => formatPersonalItemDate(props.item.actualEndDate));
|
||||
|
||||
const list = computed(() => records.value);
|
||||
|
||||
const canCreate = computed(() =>
|
||||
Boolean(
|
||||
props.item.id &&
|
||||
(props.item.statusCode === 'pending' ||
|
||||
props.item.statusCode === 'active' ||
|
||||
props.item.statusCode === 'completed')
|
||||
)
|
||||
);
|
||||
|
||||
const isWorklogMutableStatus = computed(
|
||||
() => props.item.statusCode === 'active' || props.item.statusCode === 'completed'
|
||||
);
|
||||
|
||||
function getRowIndex(index: number) {
|
||||
return (pageNo.value - 1) * PAGE_SIZE + index + 1;
|
||||
}
|
||||
|
||||
function formatHours(hours: number | null | undefined) {
|
||||
if (typeof hours !== 'number' || !Number.isFinite(hours)) {
|
||||
return '0h';
|
||||
}
|
||||
|
||||
return `${hours.toFixed(1)}h`;
|
||||
}
|
||||
|
||||
function formatWorklogPeriod(startDate: string | null | undefined, endDate: string | null | undefined) {
|
||||
if (!startDate || !endDate) {
|
||||
return {
|
||||
granularity: null as WorklogGranularity | null,
|
||||
display: '--',
|
||||
tooltip: null as string | null
|
||||
};
|
||||
}
|
||||
|
||||
const startKey = formatPersonalItemDate(startDate);
|
||||
const endKey = formatPersonalItemDate(endDate);
|
||||
|
||||
if (startKey === endKey) {
|
||||
const current = dayjs(startDate);
|
||||
const weekSuffix = current.isValid() ? `(第${current.isoWeek()}周)` : '';
|
||||
|
||||
return {
|
||||
granularity: 'day' as const,
|
||||
display: `${startKey}${weekSuffix}`,
|
||||
tooltip: null
|
||||
};
|
||||
}
|
||||
|
||||
const start = dayjs(startDate);
|
||||
|
||||
return {
|
||||
granularity: 'week' as const,
|
||||
display: start.isValid() ? `${start.isoWeekYear()}年第${start.isoWeek()}周` : `${startKey} ~ ${endKey}`,
|
||||
tooltip: `${startKey} ~ ${endKey}`
|
||||
};
|
||||
}
|
||||
|
||||
function getWorklogGranularityName(granularity: WorklogGranularity | null) {
|
||||
if (granularity === 'day') {
|
||||
return '日';
|
||||
}
|
||||
|
||||
if (granularity === 'week') {
|
||||
return '周';
|
||||
}
|
||||
|
||||
return '--';
|
||||
}
|
||||
|
||||
function canEditRow(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
|
||||
}
|
||||
|
||||
function canDeleteRow(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
return Boolean(isWorklogMutableStatus.value && currentUserId.value && row.userId === currentUserId.value);
|
||||
}
|
||||
|
||||
async function loadRecords() {
|
||||
if (!props.item.id || !props.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchGetPersonalItemWorklogPage(props.item.id, {
|
||||
pageNo: pageNo.value,
|
||||
pageSize: PAGE_SIZE
|
||||
});
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
records.value = [];
|
||||
total.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
records.value = data.list;
|
||||
total.value = data.total;
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
pageNo.value = page;
|
||||
loadRecords();
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
formMode.value = 'create';
|
||||
editingWorklog.value = null;
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
function openView(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
formMode.value = 'view';
|
||||
editingWorklog.value = row;
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
formMode.value = 'edit';
|
||||
editingWorklog.value = row;
|
||||
formVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.PersonalItem.PersonalItemWorklog) {
|
||||
const shouldStepBack = records.value.length === 1 && pageNo.value > 1;
|
||||
const { error } = await fetchDeletePersonalItemWorklog(props.item.id, row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (shouldStepBack) {
|
||||
pageNo.value -= 1;
|
||||
}
|
||||
|
||||
window.$message?.success('工作日志删除成功');
|
||||
await loadRecords();
|
||||
emit('changed');
|
||||
}
|
||||
|
||||
async function handleSubmit(payload: Api.PersonalItem.SavePersonalItemWorklogParams) {
|
||||
submitting.value = true;
|
||||
|
||||
try {
|
||||
const result =
|
||||
formMode.value === 'edit' && editingWorklog.value
|
||||
? await fetchUpdatePersonalItemWorklog(props.item.id, {
|
||||
worklogId: editingWorklog.value.id,
|
||||
data: payload
|
||||
})
|
||||
: await fetchCreatePersonalItemWorklog(props.item.id, payload);
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await worklogFormDialogRef.value?.commit();
|
||||
|
||||
window.$message?.success(formMode.value === 'edit' ? '工作日志修改成功' : '工作日志新增成功');
|
||||
formVisible.value = false;
|
||||
await loadRecords();
|
||||
emit('changed');
|
||||
} finally {
|
||||
submitting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
watch(total, value => {
|
||||
const maxPage = Math.max(1, Math.ceil(value / PAGE_SIZE));
|
||||
if (pageNo.value > maxPage) {
|
||||
pageNo.value = maxPage;
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
[() => props.item.id, () => props.active],
|
||||
([itemId, active]) => {
|
||||
if (!itemId) {
|
||||
records.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
pageNo.value = 1;
|
||||
|
||||
if (active) {
|
||||
loadRecords();
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="personal-item-worklog-panel">
|
||||
<div class="personal-item-worklog-panel__cards">
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">负责人</span>
|
||||
<span class="personal-item-worklog-panel__card-value" :title="ownerName">{{ ownerName }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">当前状态</span>
|
||||
<ElTag
|
||||
:type="resolvePersonalItemStatusTagType(props.item.statusCode)"
|
||||
size="small"
|
||||
effect="light"
|
||||
class="personal-item-worklog-panel__card-tag"
|
||||
>
|
||||
{{ statusName }}
|
||||
</ElTag>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">计划开始</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ plannedStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">计划结束</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ plannedEndText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">当前进度</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ progressText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">累计工时</span>
|
||||
<span class="personal-item-worklog-panel__card-value personal-item-worklog-panel__card-value--accent">
|
||||
{{ totalHoursText }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">实际开始</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ actualStartText }}</span>
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__card">
|
||||
<span class="personal-item-worklog-panel__card-label">实际结束</span>
|
||||
<span class="personal-item-worklog-panel__card-value">{{ actualEndText }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<header v-if="canCreate" class="personal-item-worklog-panel__header">
|
||||
<ElButton type="primary" size="small" :icon="Plus" @click="openCreate">填报</ElButton>
|
||||
</header>
|
||||
|
||||
<ElTable
|
||||
v-loading="loading"
|
||||
:data="list"
|
||||
:height="TABLE_HEIGHT"
|
||||
border
|
||||
empty-text="暂无工作日志"
|
||||
class="personal-item-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 label="工作内容" min-width="320">
|
||||
<template #default="{ row }">
|
||||
<ElPopover
|
||||
v-if="row.workContent || (row.attachments && row.attachments.length)"
|
||||
trigger="hover"
|
||||
placement="top"
|
||||
:width="360"
|
||||
:show-after="200"
|
||||
popper-class="personal-item-worklog-panel__content-popover"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="personal-item-worklog-panel__content-cell">
|
||||
{{ row.workContent || `附件 ${row.attachments?.length ?? 0} 个` }}
|
||||
</span>
|
||||
</template>
|
||||
<div class="personal-item-worklog-panel__content-card">
|
||||
<div class="personal-item-worklog-panel__content-card-header">
|
||||
<span>{{ formatWorklogPeriod(row.startDate, row.endDate).display }}</span>
|
||||
<span class="personal-item-worklog-panel__content-card-meta">
|
||||
{{ formatHours(row.durationHours) }} / {{ formatPersonalItemProgress(row.progressRate) }} /
|
||||
{{ getDifficultyLabel(row.difficulty, { fallback: '--' }) }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="row.workContent" class="personal-item-worklog-panel__content-card-body">
|
||||
{{ row.workContent }}
|
||||
</div>
|
||||
<div class="personal-item-worklog-panel__content-card-attachments">
|
||||
<div class="personal-item-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="personal-item-worklog-panel__content-card-attachment-empty">无附件</span>
|
||||
</div>
|
||||
<div
|
||||
v-if="row.attachments && row.attachments.length"
|
||||
class="personal-item-worklog-panel__content-card-attachments-scroll"
|
||||
>
|
||||
<BusinessAttachmentUploader :model-value="row.attachments" disabled flat />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
<span v-else class="personal-item-worklog-panel__content-cell-empty">--</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="时长" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="personal-item-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="进度" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="personal-item-worklog-panel__progress">{{ formatPersonalItemProgress(row.progressRate) }}</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="操作" width="120" align="center" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<div class="personal-item-worklog-panel__actions" @click.stop>
|
||||
<ElTooltip content="查看">
|
||||
<ElButton link type="primary" class="personal-item-worklog-panel__action-btn" @click="openView(row)">
|
||||
<IconMdiEyeOutline class="text-15px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip :content="canEditRow(row) ? '编辑' : '仅可编辑本人填报'">
|
||||
<span class="inline-flex">
|
||||
<ElButton
|
||||
link
|
||||
type="primary"
|
||||
class="personal-item-worklog-panel__action-btn"
|
||||
:disabled="!canEditRow(row)"
|
||||
@click="openEdit(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="personal-item-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="personal-item-worklog-panel__action-btn" disabled>
|
||||
<IconMdiDeleteOutline class="text-15px" />
|
||||
</ElButton>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
</ElTable>
|
||||
|
||||
<div class="personal-item-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>
|
||||
|
||||
<PersonalItemWorklogFormDialog
|
||||
ref="worklogFormDialogRef"
|
||||
v-model:visible="formVisible"
|
||||
:mode="formMode"
|
||||
:row-data="editingWorklog"
|
||||
:item-status-code="props.item.statusCode"
|
||||
:default-progress-rate="props.item.progressRate"
|
||||
:confirm-loading="submitting"
|
||||
@submit="handleSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.personal-item-worklog-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__cards {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__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;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card-label {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__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;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card-value--accent {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__card-tag {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__duration {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__progress {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:deep(.personal-item-worklog-panel__action-btn) {
|
||||
padding: 3px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.personal-item-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;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-cell-empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 4px 2px;
|
||||
}
|
||||
|
||||
.personal-item-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);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-meta {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.personal-item-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;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-attachments-scroll {
|
||||
max-height: 144px;
|
||||
overflow-y: auto;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-section-title {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.personal-item-worklog-panel__content-card-attachment-empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
</style>
|
||||
401
src/views/personal-center/overtime-application/index.vue
Normal file
401
src/views/personal-center/overtime-application/index.vue
Normal file
@@ -0,0 +1,401 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, markRaw, reactive, ref } from 'vue';
|
||||
import { ElButton, ElMessageBox, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
fetchCancelOvertimeApplication,
|
||||
fetchDeleteOvertimeApplication,
|
||||
fetchExportOvertimeApplications,
|
||||
fetchGetOvertimeApplicationPage
|
||||
} from '@/service/api';
|
||||
import { useUIPaginatedTable } from '@/hooks/common/table';
|
||||
import BusinessTableActionCell, { type BusinessTableAction } from '@/components/custom/business-table-action-cell';
|
||||
import OvertimeApplicationActionDialog from './modules/overtime-application-action-dialog.vue';
|
||||
import OvertimeApplicationDetailDialog from './modules/overtime-application-detail-dialog.vue';
|
||||
import OvertimeApplicationOperateDialog from './modules/overtime-application-operate-dialog.vue';
|
||||
import OvertimeApplicationSearch from './modules/overtime-application-search.vue';
|
||||
import OvertimeApplicationStatusLogDialog from './modules/overtime-application-status-log-dialog.vue';
|
||||
import {
|
||||
downloadBlob,
|
||||
formatEmptyText,
|
||||
formatOvertimeDate,
|
||||
formatOvertimeDateTime,
|
||||
getOvertimeApplicationStatusLabel,
|
||||
resolveOvertimeApplicationStatusTagType
|
||||
} from './modules/overtime-application-shared';
|
||||
import IconMdiCloseCircleOutline from '~icons/mdi/close-circle-outline';
|
||||
import IconMdiDeleteOutline from '~icons/mdi/delete-outline';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiHistory from '~icons/mdi/history';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplication' });
|
||||
|
||||
type OvertimeApplicationPageResponse = Awaited<ReturnType<typeof fetchGetOvertimeApplicationPage>>;
|
||||
type ActionType = 'cancel';
|
||||
|
||||
function getInitSearchParams(): Api.OvertimeApplication.OvertimeApplicationSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
keyword: undefined,
|
||||
applicantName: undefined,
|
||||
approverId: undefined,
|
||||
approverName: undefined,
|
||||
statusCode: undefined,
|
||||
overtimeDate: undefined,
|
||||
createTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformPageResult(response: OvertimeApplicationPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: 1,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
const operateVisible = ref(false);
|
||||
const detailVisible = ref(false);
|
||||
const statusLogVisible = ref(false);
|
||||
const actionVisible = ref(false);
|
||||
const operateType = ref<'add' | 'edit'>('add');
|
||||
const currentRow = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
const currentActionType = ref<ActionType>('cancel');
|
||||
const actionSubmitting = ref(false);
|
||||
const exporting = ref(false);
|
||||
|
||||
const ACTION_ICON_MAP = {
|
||||
detail: markRaw(IconMdiEyeOutline),
|
||||
statusLog: markRaw(IconMdiHistory),
|
||||
edit: markRaw(IconMdiPencilOutline),
|
||||
cancel: markRaw(IconMdiCloseCircleOutline),
|
||||
delete: markRaw(IconMdiDeleteOutline)
|
||||
};
|
||||
|
||||
const { columns, columnChecks, data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
OvertimeApplicationPageResponse,
|
||||
Api.OvertimeApplication.OvertimeApplication
|
||||
>({
|
||||
paginationProps: {
|
||||
currentPage: searchParams.pageNo,
|
||||
pageSize: searchParams.pageSize
|
||||
},
|
||||
api: () => fetchGetOvertimeApplicationPage(searchParams),
|
||||
transform: response => transformPageResult(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
onPaginationParamsChange: params => {
|
||||
searchParams.pageNo = params.currentPage ?? 1;
|
||||
searchParams.pageSize = params.pageSize ?? 10;
|
||||
},
|
||||
columns: () => [
|
||||
{ prop: 'index', type: 'index', label: '序号', width: 64 },
|
||||
{ prop: 'applicantName', label: '申请人', minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'overtimeDate',
|
||||
label: '加班日期',
|
||||
width: 120,
|
||||
formatter: row => formatOvertimeDate(row.overtimeDate)
|
||||
},
|
||||
{ prop: 'overtimeDuration', label: '加班时长', width: 110, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'overtimeReason',
|
||||
label: '加班原因',
|
||||
minWidth: 180,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => formatEmptyText(row.overtimeReason)
|
||||
},
|
||||
{
|
||||
prop: 'overtimeContent',
|
||||
label: '加班内容',
|
||||
minWidth: 200,
|
||||
showOverflowTooltip: true,
|
||||
formatter: row => formatEmptyText(row.overtimeContent)
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
width: 110,
|
||||
align: 'center',
|
||||
formatter: row => (
|
||||
<ElTag type={resolveOvertimeApplicationStatusTagType(row.statusCode)}>
|
||||
{getOvertimeApplicationStatusLabel(row.statusCode, row.statusName)}
|
||||
</ElTag>
|
||||
)
|
||||
},
|
||||
{ prop: 'approverName', label: '审核人', minWidth: 120, showOverflowTooltip: true },
|
||||
{
|
||||
prop: 'submitTime',
|
||||
label: '提交时间',
|
||||
minWidth: 170,
|
||||
formatter: row => formatOvertimeDateTime(row.submitTime)
|
||||
},
|
||||
{
|
||||
prop: 'approvalTime',
|
||||
label: '审核时间',
|
||||
minWidth: 170,
|
||||
formatter: row => formatOvertimeDateTime(row.approvalTime)
|
||||
},
|
||||
{
|
||||
prop: 'operate',
|
||||
label: '操作',
|
||||
width: 170,
|
||||
align: 'center',
|
||||
fixed: 'right',
|
||||
formatter: row => <BusinessTableActionCell actions={getRowActions(row)} variant="icon" />
|
||||
}
|
||||
]
|
||||
});
|
||||
|
||||
const totalCount = computed(() => mobilePagination.value.total || data.value.length);
|
||||
|
||||
function getRowActions(row: Api.OvertimeApplication.OvertimeApplication): BusinessTableAction[] {
|
||||
const actions: BusinessTableAction[] = [
|
||||
{
|
||||
key: 'detail',
|
||||
label: '详情',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.detail,
|
||||
onClick: () => openDetail(row)
|
||||
}
|
||||
];
|
||||
|
||||
if ((row.statusCode === 'rejected' || row.statusCode === 'cancelled') && row.allowEdit) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '修改',
|
||||
buttonType: 'primary',
|
||||
icon: ACTION_ICON_MAP.edit,
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
}
|
||||
|
||||
actions.push({
|
||||
key: 'status-log',
|
||||
label: '状态日志',
|
||||
buttonType: 'info',
|
||||
icon: ACTION_ICON_MAP.statusLog,
|
||||
onClick: () => openStatusLog(row)
|
||||
});
|
||||
|
||||
if (row.statusCode === 'pending') {
|
||||
actions.push({
|
||||
key: 'cancel',
|
||||
label: '撤销',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.cancel,
|
||||
onClick: () => openCancel(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (row.statusCode === 'cancelled') {
|
||||
actions.push({
|
||||
key: 'delete',
|
||||
label: '删除',
|
||||
buttonType: 'danger',
|
||||
icon: ACTION_ICON_MAP.delete,
|
||||
onClick: () => handleDelete(row)
|
||||
});
|
||||
}
|
||||
|
||||
return actions;
|
||||
}
|
||||
|
||||
function openAdd() {
|
||||
operateType.value = 'add';
|
||||
currentRow.value = null;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openEdit(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
operateType.value = 'edit';
|
||||
currentRow.value = row;
|
||||
operateVisible.value = true;
|
||||
}
|
||||
|
||||
function openDetail(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
currentRow.value = row;
|
||||
detailVisible.value = true;
|
||||
}
|
||||
|
||||
function openStatusLog(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
currentRow.value = row;
|
||||
statusLogVisible.value = true;
|
||||
}
|
||||
|
||||
function openCancel(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
currentRow.value = row;
|
||||
currentActionType.value = 'cancel';
|
||||
actionVisible.value = true;
|
||||
}
|
||||
|
||||
async function reloadTable(page = searchParams.pageNo ?? 1) {
|
||||
await getDataByPage(page);
|
||||
}
|
||||
|
||||
function resetSearchParams() {
|
||||
const pageSize = searchParams.pageSize ?? 10;
|
||||
Object.assign(searchParams, getInitSearchParams(), { pageSize });
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
reloadTable(1);
|
||||
}
|
||||
|
||||
function handleSubmitted() {
|
||||
operateVisible.value = false;
|
||||
reloadTable(searchParams.pageNo ?? 1);
|
||||
}
|
||||
|
||||
async function handleActionSubmit(reason: string | null) {
|
||||
if (!currentRow.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionSubmitting.value = true;
|
||||
const { error } = await fetchCancelOvertimeApplication(currentRow.value.id, { reason });
|
||||
actionSubmitting.value = false;
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
actionVisible.value = false;
|
||||
window.$message?.success('加班申请已撤销');
|
||||
await reloadTable(searchParams.pageNo ?? 1);
|
||||
}
|
||||
|
||||
async function handleDelete(row: Api.OvertimeApplication.OvertimeApplication) {
|
||||
try {
|
||||
await ElMessageBox.confirm(
|
||||
`确定删除 ${row.applicantName} ${formatOvertimeDate(row.overtimeDate)} 的加班申请吗?`,
|
||||
'删除确认',
|
||||
{
|
||||
type: 'warning',
|
||||
confirmButtonText: '确定',
|
||||
cancelButtonText: '取消'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteOvertimeApplication(row.id);
|
||||
|
||||
if (error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success('加班申请已删除');
|
||||
await reloadTable(searchParams.pageNo ?? 1);
|
||||
}
|
||||
|
||||
async function handleExport() {
|
||||
exporting.value = true;
|
||||
const { error, data: blob } = await fetchExportOvertimeApplications(searchParams);
|
||||
exporting.value = false;
|
||||
|
||||
if (error || !blob) {
|
||||
return;
|
||||
}
|
||||
|
||||
downloadBlob(blob, `加班申请_${dayjs().format('YYYY-MM-DD')}.xls`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex-col-stretch gap-16px overflow-hidden">
|
||||
<OvertimeApplicationSearch v-model:model="searchParams" @reset="resetSearchParams" @search="handleSearch" />
|
||||
|
||||
<ElCard class="flex-1-hidden card-wrapper" body-class="business-table-card-body">
|
||||
<template #header>
|
||||
<div class="flex flex-wrap items-center justify-between gap-12px">
|
||||
<div class="flex items-center gap-10px">
|
||||
<p class="text-16px font-600">加班申请</p>
|
||||
<ElTag effect="plain">{{ totalCount }}</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
<ElButton plain :loading="exporting" @click="handleExport">
|
||||
<template #icon>
|
||||
<icon-mdi-download class="text-icon" />
|
||||
</template>
|
||||
导出
|
||||
</ElButton>
|
||||
<ElButton plain type="primary" @click="openAdd">
|
||||
<template #icon>
|
||||
<icon-ic-round-plus class="text-icon" />
|
||||
</template>
|
||||
新增
|
||||
</ElButton>
|
||||
</template>
|
||||
</TableHeaderOperation>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="flex-1">
|
||||
<ElTable v-loading="loading" height="100%" border row-key="id" :data="data">
|
||||
<template v-for="col in columns" :key="String(col.prop)">
|
||||
<ElTableColumn v-bind="col" />
|
||||
</template>
|
||||
</ElTable>
|
||||
</div>
|
||||
|
||||
<div class="mt-20px flex justify-end">
|
||||
<ElPagination
|
||||
v-if="mobilePagination.total"
|
||||
layout="total,prev,pager,next,sizes"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
/>
|
||||
</div>
|
||||
</ElCard>
|
||||
|
||||
<OvertimeApplicationOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:operate-type="operateType"
|
||||
:row-data="currentRow"
|
||||
@submitted="handleSubmitted"
|
||||
/>
|
||||
|
||||
<OvertimeApplicationDetailDialog v-model:visible="detailVisible" :row-data="currentRow" />
|
||||
|
||||
<OvertimeApplicationStatusLogDialog v-model:visible="statusLogVisible" :row-data="currentRow" />
|
||||
|
||||
<OvertimeApplicationActionDialog
|
||||
v-model:visible="actionVisible"
|
||||
:action-type="currentActionType"
|
||||
:loading="actionSubmitting"
|
||||
@submit="handleActionSubmit"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.overtime-application__reason-link) {
|
||||
max-width: 100%;
|
||||
padding: 0;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
:deep(.overtime-application__reason-link > span) {
|
||||
display: inline-block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,121 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, watch } from 'vue';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationActionDialog' });
|
||||
|
||||
type ActionType = 'approve' | 'reject' | 'cancel';
|
||||
|
||||
interface Props {
|
||||
actionType: ActionType;
|
||||
loading?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
loading: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
submit: [reason: string | null];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const model = reactive({
|
||||
reason: ''
|
||||
});
|
||||
|
||||
const title = computed(() => {
|
||||
const map: Record<ActionType, string> = {
|
||||
approve: '通过加班申请',
|
||||
reject: '退回加班申请',
|
||||
cancel: '撤销加班申请'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonLabel = computed(() => {
|
||||
const map: Record<ActionType, string> = {
|
||||
approve: '审核意见',
|
||||
reject: '退回原因',
|
||||
cancel: '撤销原因'
|
||||
};
|
||||
|
||||
return map[props.actionType];
|
||||
});
|
||||
|
||||
const reasonRequired = computed(() => props.actionType === 'reject');
|
||||
|
||||
const reasonPlaceholder = computed(() => {
|
||||
if (reasonRequired.value) {
|
||||
return `请输入${reasonLabel.value}`;
|
||||
}
|
||||
|
||||
return props.actionType === 'cancel' ? '可填写撤销原因' : '可填写审核意见';
|
||||
});
|
||||
|
||||
const rules = computed(() => ({
|
||||
reason: reasonRequired.value
|
||||
? [
|
||||
createRequiredRule(`请输入${reasonLabel.value}`),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error(`请输入${reasonLabel.value}`));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
]
|
||||
: []
|
||||
}));
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
emit('submit', model.reason.trim() || null);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (value) {
|
||||
model.reason = '';
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="sm"
|
||||
:confirm-loading="props.loading"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<ElFormItem :label="reasonLabel" prop="reason">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
:rows="5"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
:placeholder="reasonPlaceholder"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { fetchGetOvertimeApplicationDetail } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
formatEmptyText,
|
||||
formatOvertimeDate,
|
||||
formatOvertimeDateTime,
|
||||
getOvertimeApplicationStatusLabel,
|
||||
resolveOvertimeApplicationStatusTagType
|
||||
} from './overtime-application-shared';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationDetailDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const detailData = ref<Api.OvertimeApplication.OvertimeApplication | null>(null);
|
||||
|
||||
const statusTagType = computed(() => resolveOvertimeApplicationStatusTagType(detailData.value?.statusCode));
|
||||
const statusLabel = computed(() =>
|
||||
getOvertimeApplicationStatusLabel(detailData.value?.statusCode, detailData.value?.statusName)
|
||||
);
|
||||
|
||||
async function loadDetail() {
|
||||
if (!props.rowData?.id) {
|
||||
detailData.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
|
||||
loading.value = false;
|
||||
|
||||
detailData.value = error || !data ? props.rowData : data;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadDetail();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog v-model="visible" title="加班申请详情" preset="md" :loading="loading" :show-footer="false">
|
||||
<ElDescriptions v-if="detailData" :column="2" border>
|
||||
<ElDescriptionsItem label="状态">
|
||||
<ElTag :type="statusTagType">{{ statusLabel }}</ElTag>
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="申请人">
|
||||
{{ detailData.applicantName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班日期">{{ formatOvertimeDate(detailData.overtimeDate) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班时长">{{ detailData.overtimeDuration }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="审核人">
|
||||
{{ detailData.approverName }}
|
||||
</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="提交时间">{{ formatOvertimeDateTime(detailData.submitTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="审核时间">{{ formatOvertimeDateTime(detailData.approvalTime) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="审核意见">{{ formatEmptyText(detailData.approvalComment) }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班原因" :span="2">{{ detailData.overtimeReason }}</ElDescriptionsItem>
|
||||
<ElDescriptionsItem label="加班内容" :span="2">{{ detailData.overtimeContent }}</ElDescriptionsItem>
|
||||
</ElDescriptions>
|
||||
<ElEmpty v-else description="未获取到加班申请详情" />
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.overtime-application-detail-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(.overtime-application-detail-dialog__readonly-input .el-input__wrapper:hover),
|
||||
:deep(.overtime-application-detail-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-detail-dialog__readonly-input .el-input__inner) {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: default;
|
||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,273 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { RDMS_OVERTIME_DURATION_DICT_CODE } from '@/constants/dict';
|
||||
import {
|
||||
fetchCreateOvertimeApplication,
|
||||
fetchGetLoginUserDirectManager,
|
||||
fetchGetOvertimeApplicationDetail,
|
||||
fetchUpdateRejectedOvertimeApplication
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
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 DictSelect from '@/components/custom/dict-select.vue';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationOperateDialog' });
|
||||
|
||||
type OperateType = 'add' | 'edit';
|
||||
|
||||
interface Props {
|
||||
operateType: OperateType;
|
||||
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
submitted: [];
|
||||
}>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const detailLoading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const approverName = ref('');
|
||||
|
||||
const currentUserName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '--');
|
||||
const isEdit = computed(() => props.operateType === 'edit');
|
||||
const title = computed(() => (isEdit.value ? '修改并重新提交' : '新增加班申请'));
|
||||
|
||||
const model = reactive<Api.OvertimeApplication.SaveOvertimeApplicationParams>(createDefaultModel());
|
||||
|
||||
const rules = computed(
|
||||
() =>
|
||||
({
|
||||
overtimeDate: [createRequiredRule('请选择加班日期')],
|
||||
overtimeDuration: [createRequiredRule('请选择加班时长')],
|
||||
overtimeReason: [
|
||||
createRequiredRule('请输入加班原因'),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error('请输入加班原因'));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
overtimeContent: [
|
||||
createRequiredRule('请输入加班内容'),
|
||||
{
|
||||
validator: (_rule, value: string, callback) => {
|
||||
if (!value?.trim()) {
|
||||
callback(new Error('请输入加班内容'));
|
||||
return;
|
||||
}
|
||||
|
||||
callback();
|
||||
},
|
||||
trigger: 'blur'
|
||||
}
|
||||
],
|
||||
approverId: [createRequiredRule('请选择审核人')]
|
||||
}) satisfies Record<keyof Api.OvertimeApplication.SaveOvertimeApplicationParams, App.Global.FormRule[]>
|
||||
);
|
||||
|
||||
function createDefaultModel(): Api.OvertimeApplication.SaveOvertimeApplicationParams {
|
||||
return {
|
||||
overtimeDate: '',
|
||||
overtimeDuration: '',
|
||||
overtimeReason: '',
|
||||
overtimeContent: '',
|
||||
approverId: ''
|
||||
};
|
||||
}
|
||||
|
||||
async function loadDirectManagerAsDefaultApprover() {
|
||||
const { error, data } = await fetchGetLoginUserDirectManager();
|
||||
|
||||
if (!error && data?.id) {
|
||||
model.approverId = data.id;
|
||||
approverName.value = data.nickname;
|
||||
}
|
||||
}
|
||||
|
||||
async function initModel() {
|
||||
detailLoading.value = true;
|
||||
Object.assign(model, createDefaultModel());
|
||||
approverName.value = '';
|
||||
|
||||
if (isEdit.value && props.rowData) {
|
||||
const { error, data } = await fetchGetOvertimeApplicationDetail(props.rowData.id);
|
||||
const detail = error || !data ? props.rowData : data;
|
||||
|
||||
model.overtimeDate = detail.overtimeDate;
|
||||
model.overtimeDuration = detail.overtimeDuration;
|
||||
model.overtimeReason = detail.overtimeReason;
|
||||
model.overtimeContent = detail.overtimeContent;
|
||||
model.approverId = detail.approverId;
|
||||
approverName.value = detail.approverName;
|
||||
} else {
|
||||
await loadDirectManagerAsDefaultApprover();
|
||||
}
|
||||
|
||||
detailLoading.value = false;
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
const payload: Api.OvertimeApplication.SaveOvertimeApplicationParams = {
|
||||
overtimeDate: model.overtimeDate,
|
||||
overtimeDuration: model.overtimeDuration,
|
||||
overtimeReason: model.overtimeReason.trim(),
|
||||
overtimeContent: model.overtimeContent.trim(),
|
||||
approverId: model.approverId
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const result =
|
||||
isEdit.value && props.rowData
|
||||
? await fetchUpdateRejectedOvertimeApplication(props.rowData.id, payload)
|
||||
: await fetchCreateOvertimeApplication(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.$message?.success(isEdit.value ? '加班申请已重新提交' : '加班申请已提交');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
initModel();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
:title="title"
|
||||
preset="md"
|
||||
:loading="detailLoading"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<BusinessFormSection title="申请信息">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="申请人">
|
||||
<ElInput
|
||||
class="overtime-application-operate-dialog__readonly-input"
|
||||
:model-value="currentUserName"
|
||||
readonly
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="审核人" prop="approverId">
|
||||
<ElInput
|
||||
class="overtime-application-operate-dialog__readonly-input"
|
||||
:model-value="approverName"
|
||||
readonly
|
||||
placeholder="暂无直属上级"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="加班日期" prop="overtimeDate" style="width: 100%">
|
||||
<ElDatePicker
|
||||
v-model="model.overtimeDate"
|
||||
class="w-full"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 100%"
|
||||
placeholder="请选择加班日期"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="加班时长" prop="overtimeDuration">
|
||||
<DictSelect
|
||||
v-model="model.overtimeDuration"
|
||||
:dict-code="RDMS_OVERTIME_DURATION_DICT_CODE"
|
||||
placeholder="请选择加班时长"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="加班说明">
|
||||
<ElRow :gutter="16">
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="加班原因" prop="overtimeReason">
|
||||
<ElInput
|
||||
v-model="model.overtimeReason"
|
||||
type="textarea"
|
||||
:rows="3"
|
||||
maxlength="500"
|
||||
show-word-limit
|
||||
placeholder="请输入加班原因"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="加班内容" prop="overtimeContent">
|
||||
<ElInput
|
||||
v-model="model.overtimeContent"
|
||||
type="textarea"
|
||||
:rows="4"
|
||||
maxlength="1000"
|
||||
show-word-limit
|
||||
placeholder="请输入加班内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
</ElRow>
|
||||
</BusinessFormSection>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
:deep(.overtime-application-operate-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(.overtime-application-operate-dialog__readonly-input .el-input__wrapper:hover),
|
||||
:deep(.overtime-application-operate-dialog__readonly-input.is-focus .el-input__wrapper) {
|
||||
box-shadow: 0 0 0 1px rgb(203 213 225 / 96%) inset;
|
||||
}
|
||||
|
||||
:deep(.overtime-application-operate-dialog__readonly-input .el-input__inner) {
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: default;
|
||||
-webkit-text-fill-color: rgb(51 65 85 / 96%);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,95 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive, watch } from 'vue';
|
||||
import { RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE } from '@/constants/dict';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationSearch' });
|
||||
|
||||
const emit = defineEmits<{
|
||||
reset: [];
|
||||
search: [];
|
||||
}>();
|
||||
|
||||
const model = defineModel<Api.OvertimeApplication.OvertimeApplicationSearchParams>('model', {
|
||||
required: true
|
||||
});
|
||||
|
||||
const searchModel = reactive<Record<string, any>>({
|
||||
applicantName: '',
|
||||
overtimeDate: undefined,
|
||||
statusCode: undefined,
|
||||
approverName: ''
|
||||
});
|
||||
|
||||
let syncingFromSource = false;
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[model.value.applicantName, model.value.overtimeDate, model.value.statusCode, model.value.approverName] as const,
|
||||
([applicantName, overtimeDate, statusCode, approverName]) => {
|
||||
syncingFromSource = true;
|
||||
searchModel.applicantName = applicantName ?? '';
|
||||
searchModel.overtimeDate = overtimeDate;
|
||||
searchModel.statusCode = statusCode;
|
||||
searchModel.approverName = approverName ?? '';
|
||||
syncingFromSource = false;
|
||||
},
|
||||
{ immediate: true, flush: 'sync' }
|
||||
);
|
||||
|
||||
watch(
|
||||
() =>
|
||||
[searchModel.applicantName, searchModel.overtimeDate, searchModel.statusCode, searchModel.approverName] as const,
|
||||
([applicantName, overtimeDate, statusCode, approverName]) => {
|
||||
if (syncingFromSource) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value.applicantName = applicantName?.trim() || undefined;
|
||||
model.value.overtimeDate = overtimeDate;
|
||||
model.value.statusCode = statusCode;
|
||||
model.value.approverName = approverName?.trim() || undefined;
|
||||
},
|
||||
{ flush: 'sync' }
|
||||
);
|
||||
|
||||
const fields = computed<SearchField[]>(() => [
|
||||
{
|
||||
key: 'applicantName',
|
||||
label: '申请人',
|
||||
type: 'input',
|
||||
placeholder: '请输入申请人'
|
||||
},
|
||||
{
|
||||
key: 'overtimeDate',
|
||||
label: '加班日期',
|
||||
type: 'dateRange',
|
||||
placeholder: '请选择加班日期'
|
||||
},
|
||||
{
|
||||
key: 'statusCode',
|
||||
label: '状态',
|
||||
type: 'dict',
|
||||
dictCode: RDMS_OVERTIME_APPLICATION_STATUS_DICT_CODE,
|
||||
placeholder: '请选择状态'
|
||||
},
|
||||
{
|
||||
key: 'approverName',
|
||||
label: '审核人',
|
||||
type: 'input',
|
||||
placeholder: '请输入审核人'
|
||||
}
|
||||
]);
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="searchModel" :fields="fields" :columns="4" @reset="handleReset" @search="handleSearch" />
|
||||
</template>
|
||||
@@ -0,0 +1,77 @@
|
||||
import dayjs from 'dayjs';
|
||||
import { getStatusTagType } from '@/constants/status-tag';
|
||||
|
||||
export const overtimeApplicationStatusOptions: Array<{
|
||||
label: string;
|
||||
value: Api.OvertimeApplication.OvertimeApplicationStatusCode;
|
||||
}> = [
|
||||
{ label: '待审批', value: 'pending' },
|
||||
{ label: '已通过', value: 'approved' },
|
||||
{ label: '已退回', value: 'rejected' },
|
||||
{ label: '已撤销', value: 'cancelled' }
|
||||
];
|
||||
|
||||
export const overtimeApplicationActionNameMap: Record<Api.OvertimeApplication.OvertimeApplicationActionType, string> = {
|
||||
submit: '提交',
|
||||
resubmit: '重新提交',
|
||||
approve: '通过',
|
||||
reject: '退回',
|
||||
cancel: '撤销'
|
||||
};
|
||||
|
||||
export function getOvertimeApplicationStatusLabel(statusCode?: string | null, statusName?: string | null) {
|
||||
if (statusName) {
|
||||
return statusName;
|
||||
}
|
||||
|
||||
return overtimeApplicationStatusOptions.find(item => item.value === statusCode)?.label || statusCode || '--';
|
||||
}
|
||||
|
||||
export function resolveOvertimeApplicationStatusTagType(statusCode?: string | null) {
|
||||
return getStatusTagType('overtimeApplication', statusCode);
|
||||
}
|
||||
|
||||
export function getOvertimeApplicationActionLabel(actionType?: string | null) {
|
||||
if (!actionType) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return (
|
||||
overtimeApplicationActionNameMap[actionType as Api.OvertimeApplication.OvertimeApplicationActionType] || actionType
|
||||
);
|
||||
}
|
||||
|
||||
export function formatOvertimeDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD') : value;
|
||||
}
|
||||
|
||||
export function formatOvertimeDateTime(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
const target = dayjs(value);
|
||||
|
||||
return target.isValid() ? target.format('YYYY-MM-DD HH:mm:ss') : value;
|
||||
}
|
||||
|
||||
export function formatEmptyText(value?: string | null) {
|
||||
return value?.trim() || '--';
|
||||
}
|
||||
|
||||
export function downloadBlob(blob: Blob, fileName: string) {
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
|
||||
link.href = url;
|
||||
link.download = fileName;
|
||||
link.click();
|
||||
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
<script setup lang="tsx">
|
||||
import { ref, watch } from 'vue';
|
||||
import { ElTag } from 'element-plus';
|
||||
import { fetchGetOvertimeApplicationStatusLogs } from '@/service/api';
|
||||
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
formatEmptyText,
|
||||
formatOvertimeDate,
|
||||
formatOvertimeDateTime,
|
||||
getOvertimeApplicationActionLabel,
|
||||
getOvertimeApplicationStatusLabel,
|
||||
resolveOvertimeApplicationStatusTagType
|
||||
} from './overtime-application-shared';
|
||||
|
||||
defineOptions({ name: 'OvertimeApplicationStatusLogDialog' });
|
||||
|
||||
interface Props {
|
||||
rowData?: Api.OvertimeApplication.OvertimeApplication | null;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const logs = ref<Api.OvertimeApplication.OvertimeApplicationStatusLog[]>([]);
|
||||
|
||||
async function loadLogs() {
|
||||
if (!props.rowData?.id) {
|
||||
logs.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
const { error, data } = await fetchGetOvertimeApplicationStatusLogs(props.rowData.id);
|
||||
loading.value = false;
|
||||
|
||||
logs.value = error || !data ? [] : data;
|
||||
}
|
||||
|
||||
function renderStatus(code?: string | null) {
|
||||
if (!code) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return <ElTag type={resolveOvertimeApplicationStatusTagType(code)}>{getOvertimeApplicationStatusLabel(code)}</ElTag>;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadLogs();
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="状态日志"
|
||||
width="920px"
|
||||
:loading="loading"
|
||||
:show-footer="false"
|
||||
max-body-height="72vh"
|
||||
>
|
||||
<ElTable border :data="logs">
|
||||
<ElTableColumn prop="createTime" label="操作时间" width="170">
|
||||
<template #default="{ row }">{{ formatOvertimeDateTime(row.createTime) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="actionType" label="动作" width="110">
|
||||
<template #default="{ row }">{{ getOvertimeApplicationActionLabel(row.actionType) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="operatorName" label="操作人" width="120" show-overflow-tooltip />
|
||||
<ElTableColumn prop="fromStatus" label="原状态" width="110" :formatter="row => renderStatus(row.fromStatus)" />
|
||||
<ElTableColumn prop="toStatus" label="新状态" width="110" :formatter="row => renderStatus(row.toStatus)" />
|
||||
<ElTableColumn prop="reason" label="原因/意见" min-width="180" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ formatEmptyText(row.reason) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="overtimeDateSnapshot" label="加班日期" width="120">
|
||||
<template #default="{ row }">{{ formatOvertimeDate(row.overtimeDateSnapshot) }}</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="overtimeDurationSnapshot" label="时长" width="90" show-overflow-tooltip />
|
||||
</ElTable>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
@@ -49,15 +49,6 @@ export interface ProductHomepageTimelineItem {
|
||||
tone: 'sky' | 'emerald' | 'amber' | 'rose' | 'slate';
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolSummarySource {
|
||||
total: number;
|
||||
todo: number;
|
||||
analyzing: number;
|
||||
planned: number;
|
||||
done: number;
|
||||
highPriorityTodo: number;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolSummary {
|
||||
metrics: ProductHomepageMetric[];
|
||||
distribution: Array<{
|
||||
@@ -67,22 +58,16 @@ export interface ProductRequirementPoolSummary {
|
||||
total: number;
|
||||
todo: number;
|
||||
highPriorityTodo: number;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolRecentChangeSource {
|
||||
id: string;
|
||||
title: string;
|
||||
actionLabel: string;
|
||||
time: string;
|
||||
statusLabel: string;
|
||||
completionRate: number;
|
||||
}
|
||||
|
||||
export interface ProductRequirementPoolRecentChange {
|
||||
id: string;
|
||||
title: string;
|
||||
actionType: Api.Product.ProductRequirementDashboardRecentChangeActionType;
|
||||
actionLabel: string;
|
||||
time: string;
|
||||
statusLabel: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
export interface ProductHomepageExtensionModule {
|
||||
@@ -182,19 +167,20 @@ function resolveLatestTimelineTime(
|
||||
}
|
||||
|
||||
export function buildRequirementPoolSummary(
|
||||
source: ProductRequirementPoolSummarySource | null | undefined
|
||||
source: Api.Product.ProductRequirementDashboardSummary | null | undefined
|
||||
): ProductRequirementPoolSummary {
|
||||
const total = normalizeCount(source?.total);
|
||||
const todo = normalizeCount(source?.todo);
|
||||
const analyzing = normalizeCount(source?.analyzing);
|
||||
const planned = normalizeCount(source?.planned);
|
||||
const done = normalizeCount(source?.done);
|
||||
const pendingClaim = normalizeCount(source?.pendingClaim);
|
||||
const pendingReview = normalizeCount(source?.pendingReview);
|
||||
const pendingDispatch = normalizeCount(source?.pendingDispatch);
|
||||
const completionRate = Math.min(100, normalizeCount(source?.completionRate));
|
||||
const highPriorityTodo = normalizeCount(source?.highPriorityTodo);
|
||||
const distribution = [
|
||||
{ label: '待处理', value: String(todo) },
|
||||
{ label: '分析中', value: String(analyzing) },
|
||||
{ label: '已规划', value: String(planned) },
|
||||
{ label: '已完成', value: String(done) }
|
||||
{ label: '等待处理', value: String(todo) },
|
||||
{ label: '等待认领', value: String(pendingClaim) },
|
||||
{ label: '等待评审', value: String(pendingReview) },
|
||||
{ label: '等待指派', value: String(pendingDispatch) }
|
||||
];
|
||||
|
||||
return {
|
||||
@@ -212,30 +198,35 @@ export function buildRequirementPoolSummary(
|
||||
{
|
||||
label: '待处理',
|
||||
value: String(todo),
|
||||
hint: '等待进入分析或分派的需求数量'
|
||||
hint: '等待认领、评审、指派的需求,这些需求应该着重关注'
|
||||
},
|
||||
{
|
||||
label: '高优先级待处理',
|
||||
value: String(highPriorityTodo),
|
||||
hint: '需要优先推进的待处理需求数量'
|
||||
hint: '需要优先推进的待处理需求数量,P0、P1类型的需求'
|
||||
}
|
||||
],
|
||||
distribution,
|
||||
total,
|
||||
todo,
|
||||
highPriorityTodo
|
||||
highPriorityTodo,
|
||||
completionRate
|
||||
};
|
||||
}
|
||||
|
||||
export function buildRequirementPoolRecentChanges(
|
||||
source: readonly ProductRequirementPoolRecentChangeSource[] | null | undefined
|
||||
source: readonly Api.Product.ProductRequirementDashboardRecentChange[] | null | undefined
|
||||
) {
|
||||
return [...(source || [])]
|
||||
.filter(item => getTimeValue(item.time) > 0)
|
||||
.sort((left, right) => getTimeValue(right.time) - getTimeValue(left.time))
|
||||
.filter(item => getTimeValue(item.occurredAt) > 0)
|
||||
.sort((left, right) => getTimeValue(right.occurredAt) - getTimeValue(left.occurredAt))
|
||||
.map(item => ({
|
||||
...item,
|
||||
time: formatDateTime(item.time)
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
actionType: item.actionType,
|
||||
actionLabel: item.actionLabel,
|
||||
content: item.content,
|
||||
time: formatDateTime(item.occurredAt)
|
||||
})) satisfies ProductRequirementPoolRecentChange[];
|
||||
}
|
||||
|
||||
@@ -368,7 +359,7 @@ function buildProductHomepageBannerMetrics(source: ProductHomepageBannerSource)
|
||||
{
|
||||
label: '待处理需求',
|
||||
value: String(requirementSummary.todo),
|
||||
hint: '等待进入分析或分派的需求数量'
|
||||
hint: '需要进行认领、评审、指派的需求,这些需求应该着重关注'
|
||||
},
|
||||
{
|
||||
label: '最近动态时间',
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE } from '@/constants/dict';
|
||||
import { fetchGetProduct, fetchGetProductMembers, fetchGetProductSettings } from '@/service/api';
|
||||
import {
|
||||
fetchGetProduct,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProductRequirementDashboard,
|
||||
fetchGetProductSettings
|
||||
} from '@/service/api';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import { useCurrentProduct } from '../shared/use-current-product';
|
||||
import ProductActivityTimelinePanel from './modules/product-activity-timeline-panel.vue';
|
||||
@@ -11,7 +16,7 @@ import {
|
||||
buildRequirementPoolSummary,
|
||||
getProductHomepageExtensionModules
|
||||
} from './homepage';
|
||||
import { productHomepageExtensionMock, productRequirementPoolMock } from './mock';
|
||||
import { productHomepageExtensionMock } from './mock';
|
||||
|
||||
defineOptions({ name: 'ProductDashboard' });
|
||||
|
||||
@@ -22,11 +27,12 @@ const pageLoading = ref(false);
|
||||
const productDetail = ref<Api.Product.Product | null>(null);
|
||||
const settings = ref<Api.Product.ProductSettings | null>(null);
|
||||
const members = ref<Api.Product.ProductMember[]>([]);
|
||||
const requirementDashboard = ref<Api.Product.ProductRequirementDashboard | null>(null);
|
||||
const latestActivityTime = ref('');
|
||||
|
||||
const requirementPoolSummary = computed(() => buildRequirementPoolSummary(productRequirementPoolMock.summary));
|
||||
const requirementPoolSummary = computed(() => buildRequirementPoolSummary(requirementDashboard.value?.summary));
|
||||
const requirementPoolRecentChanges = computed(() =>
|
||||
buildRequirementPoolRecentChanges(productRequirementPoolMock.recentChanges)
|
||||
buildRequirementPoolRecentChanges(requirementDashboard.value?.recentChanges)
|
||||
);
|
||||
const homepageBanner = computed(() =>
|
||||
buildProductHomepageBanner({
|
||||
@@ -84,26 +90,33 @@ const kpiCards = computed<KpiCard[]>(() =>
|
||||
);
|
||||
|
||||
const distributionIcons: Record<string, string> = {
|
||||
待处理: 'mdi:timer-sand',
|
||||
分析中: 'mdi:magnify-scan',
|
||||
已规划: 'mdi:calendar-check-outline',
|
||||
已完成: 'mdi:check-circle-outline'
|
||||
等待处理: 'mdi:timer-sand',
|
||||
等待认领: 'mdi:magnify-scan',
|
||||
等待评审: 'mdi:calendar-check-outline',
|
||||
等待指派: 'mdi:check-circle-outline'
|
||||
};
|
||||
|
||||
const distributionHints: Record<string, string> = {
|
||||
等待处理: '需要执行认领、评审、指派动作的重要需求。',
|
||||
等待认领: '需要执行认领动作的需求,一般来自工单流转。',
|
||||
等待评审: '需要执行评审动作的需求。',
|
||||
等待指派: '需要执行指派动作的需求,包括“待指派”和“已评审”两种状态。'
|
||||
};
|
||||
|
||||
const distributionWithIcons = computed(() =>
|
||||
requirementPoolSummary.value.distribution.map((item, index) => ({
|
||||
...item,
|
||||
icon: distributionIcons[item.label] || 'mdi:circle-outline',
|
||||
hint: distributionHints[item.label] || '',
|
||||
tone: ['amber', 'sky', 'violet', 'emerald'][index] || 'slate'
|
||||
}))
|
||||
);
|
||||
|
||||
const poolCompletionRate = computed(() => {
|
||||
const { total, distribution } = requirementPoolSummary.value;
|
||||
const done = Number(distribution.find(item => item.label === '已完成')?.value || 0);
|
||||
if (!total) return 0;
|
||||
return Math.min(100, Math.max(0, Math.round((done / total) * 100)));
|
||||
});
|
||||
function getRecentChangeClass(actionType: Api.Product.ProductRequirementDashboardRecentChangeActionType) {
|
||||
return `product-overview__change--${actionType.replace(/_/g, '-')}`;
|
||||
}
|
||||
|
||||
const poolCompletionRate = computed(() => requirementPoolSummary.value.completionRate);
|
||||
|
||||
const poolProgressColor = computed(() => [
|
||||
{ color: '#f59e0b', percentage: 30 },
|
||||
@@ -129,15 +142,17 @@ async function loadDashboardData(objectId: string) {
|
||||
pageLoading.value = true;
|
||||
|
||||
try {
|
||||
const [productResult, settingsResult, membersResult] = await Promise.all([
|
||||
const [productResult, settingsResult, membersResult, requirementDashboardResult] = await Promise.all([
|
||||
fetchGetProduct(objectId),
|
||||
fetchGetProductSettings(objectId),
|
||||
fetchGetProductMembers(objectId)
|
||||
fetchGetProductMembers(objectId),
|
||||
fetchGetProductRequirementDashboard(objectId)
|
||||
]);
|
||||
|
||||
productDetail.value = productResult.error ? null : productResult.data || null;
|
||||
settings.value = settingsResult.error ? null : settingsResult.data || null;
|
||||
members.value = membersResult.error ? [] : membersResult.data || [];
|
||||
requirementDashboard.value = requirementDashboardResult.error ? null : requirementDashboardResult.data || null;
|
||||
} finally {
|
||||
pageLoading.value = false;
|
||||
}
|
||||
@@ -150,6 +165,7 @@ watch(
|
||||
productDetail.value = null;
|
||||
settings.value = null;
|
||||
members.value = [];
|
||||
requirementDashboard.value = null;
|
||||
latestActivityTime.value = '';
|
||||
return;
|
||||
}
|
||||
@@ -291,17 +307,15 @@ watch(
|
||||
</div>
|
||||
|
||||
<ul class="product-overview__pool-distribution">
|
||||
<li
|
||||
v-for="item in distributionWithIcons"
|
||||
:key="item.label"
|
||||
:class="`product-overview__pool-distribution-item--${item.tone}`"
|
||||
>
|
||||
<span class="product-overview__pool-distribution-icon">
|
||||
<SvgIcon :icon="item.icon" />
|
||||
</span>
|
||||
<span class="product-overview__pool-distribution-label">{{ item.label }}</span>
|
||||
<strong class="product-overview__pool-distribution-value">{{ item.value }}</strong>
|
||||
</li>
|
||||
<ElTooltip v-for="item in distributionWithIcons" :key="item.label" :content="item.hint" placement="top">
|
||||
<li :class="`product-overview__pool-distribution-item--${item.tone}`">
|
||||
<span class="product-overview__pool-distribution-icon">
|
||||
<SvgIcon :icon="item.icon" />
|
||||
</span>
|
||||
<span class="product-overview__pool-distribution-label">{{ item.label }}</span>
|
||||
<strong class="product-overview__pool-distribution-value">{{ item.value }}</strong>
|
||||
</li>
|
||||
</ElTooltip>
|
||||
</ul>
|
||||
</ElCard>
|
||||
</section>
|
||||
@@ -315,14 +329,19 @@ watch(
|
||||
<SvgIcon icon="mdi:swap-horizontal-circle-outline" />
|
||||
</span>
|
||||
<div class="product-overview__panel-head-text">
|
||||
<h3>需求池最近变化</h3>
|
||||
<p>需求新增、状态流转、关闭情况</p>
|
||||
<h3>需求池最新重要变化</h3>
|
||||
<p>需求新增、需求删除、状态流转</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div v-if="requirementPoolRecentChanges.length" class="product-overview__changes-list">
|
||||
<article v-for="item in requirementPoolRecentChanges" :key="item.id" class="product-overview__change">
|
||||
<article
|
||||
v-for="item in requirementPoolRecentChanges"
|
||||
:key="item.id"
|
||||
class="product-overview__change"
|
||||
:class="getRecentChangeClass(item.actionType)"
|
||||
>
|
||||
<div class="product-overview__change-meta">
|
||||
<span class="product-overview__change-action">{{ item.actionLabel }}</span>
|
||||
<time class="product-overview__change-time">{{ item.time }}</time>
|
||||
@@ -330,7 +349,7 @@ watch(
|
||||
<strong class="product-overview__change-title">{{ item.title }}</strong>
|
||||
<span class="product-overview__change-status">
|
||||
<SvgIcon icon="mdi:circle-medium" />
|
||||
当前状态 · {{ item.statusLabel }}
|
||||
{{ item.content }}
|
||||
</span>
|
||||
</article>
|
||||
</div>
|
||||
@@ -1107,12 +1126,30 @@ watch(
|
||||
background: linear-gradient(180deg, #14b8a6, #10b981);
|
||||
}
|
||||
|
||||
.product-overview__change--delete::before {
|
||||
background: linear-gradient(180deg, #b91c1c, #991b1b);
|
||||
}
|
||||
|
||||
.product-overview__change--status-terminal::before {
|
||||
background: linear-gradient(180deg, #1d4ed8, #1e40af);
|
||||
}
|
||||
|
||||
.product-overview__change:hover {
|
||||
transform: translateX(2px);
|
||||
border-color: rgb(20 184 166 / 40%);
|
||||
box-shadow: 0 10px 20px -16px rgb(15 118 110 / 35%);
|
||||
}
|
||||
|
||||
.product-overview__change--delete:hover {
|
||||
border-color: rgb(185 28 28 / 34%);
|
||||
box-shadow: 0 10px 20px -16px rgb(153 27 27 / 30%);
|
||||
}
|
||||
|
||||
.product-overview__change--status-terminal:hover {
|
||||
border-color: rgb(29 78 216 / 34%);
|
||||
box-shadow: 0 10px 20px -16px rgb(30 64 175 / 30%);
|
||||
}
|
||||
|
||||
.product-overview__change-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1131,6 +1168,16 @@ watch(
|
||||
color: #0f766e;
|
||||
}
|
||||
|
||||
.product-overview__change--delete .product-overview__change-action {
|
||||
background: rgb(185 28 28 / 10%);
|
||||
color: #991b1b;
|
||||
}
|
||||
|
||||
.product-overview__change--status-terminal .product-overview__change-action {
|
||||
background: rgb(29 78 216 / 10%);
|
||||
color: #1e40af;
|
||||
}
|
||||
|
||||
.product-overview__change-time {
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 12px;
|
||||
|
||||
@@ -1,42 +1,4 @@
|
||||
import type {
|
||||
ProductHomepageExtensionModule,
|
||||
ProductRequirementPoolRecentChangeSource,
|
||||
ProductRequirementPoolSummarySource
|
||||
} from './homepage';
|
||||
|
||||
export const productRequirementPoolMock = {
|
||||
summary: {
|
||||
total: 18,
|
||||
todo: 3,
|
||||
analyzing: 5,
|
||||
planned: 6,
|
||||
done: 4,
|
||||
highPriorityTodo: 2
|
||||
} satisfies ProductRequirementPoolSummarySource,
|
||||
recentChanges: [
|
||||
{
|
||||
id: 'req-1001',
|
||||
title: '支持产品资料标签归档',
|
||||
actionLabel: '新增需求',
|
||||
time: '2026-04-22 16:20:00',
|
||||
statusLabel: '待处理'
|
||||
},
|
||||
{
|
||||
id: 'req-1002',
|
||||
title: '统一需求池状态颜色',
|
||||
actionLabel: '状态流转',
|
||||
time: '2026-04-23 11:00:00',
|
||||
statusLabel: '分析中'
|
||||
},
|
||||
{
|
||||
id: 'req-1003',
|
||||
title: '补充对象首页需求池统计接口',
|
||||
actionLabel: '关闭需求',
|
||||
time: '2026-04-23 14:30:00',
|
||||
statusLabel: '已完成'
|
||||
}
|
||||
] satisfies ProductRequirementPoolRecentChangeSource[]
|
||||
};
|
||||
import type { ProductHomepageExtensionModule } from './homepage';
|
||||
|
||||
export const productHomepageExtensionMock = [
|
||||
{
|
||||
|
||||
@@ -22,6 +22,11 @@ export interface ProductActivityTextPart {
|
||||
strong?: boolean;
|
||||
}
|
||||
|
||||
interface ActivitySummaryResult {
|
||||
text: string;
|
||||
parts: ProductActivityTextPart[];
|
||||
}
|
||||
|
||||
export interface ProductActivityDisplayItem extends Api.Product.ProductActivityTimelineItem {
|
||||
tagLabel: string;
|
||||
timeText: string;
|
||||
@@ -265,34 +270,46 @@ function buildMemberChangeSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null,
|
||||
operatorText: string
|
||||
) {
|
||||
): ActivitySummaryResult | null {
|
||||
const memberName = getActivityTargetUserName(item, detailsRecord);
|
||||
const roleName = getActivityTargetRoleName(item, detailsRecord);
|
||||
|
||||
if (!memberName) {
|
||||
return '';
|
||||
return null;
|
||||
}
|
||||
|
||||
const memberDetail = roleName ? `${memberName}(${roleName})` : memberName;
|
||||
const prefix =
|
||||
operatorText === '--' ? `执行了【${item.actionName}】:` : `${operatorText}执行了【${item.actionName}】:`;
|
||||
const roleSuffix = roleName ? `(${roleName})` : '';
|
||||
const text = `${prefix}${memberName}${roleSuffix}`;
|
||||
const parts: ProductActivityTextPart[] = [{ text: prefix }, { text: memberName, strong: true }];
|
||||
|
||||
return operatorText === '--'
|
||||
? `执行了【${item.actionName}】:${memberDetail}`
|
||||
: `${operatorText}执行了【${item.actionName}】:${memberDetail}`;
|
||||
if (roleSuffix) {
|
||||
parts.push({ text: roleSuffix });
|
||||
}
|
||||
|
||||
return { text, parts };
|
||||
}
|
||||
|
||||
function buildMemberUpdateSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null,
|
||||
operatorText: string
|
||||
) {
|
||||
): ActivitySummaryResult {
|
||||
const memberName = getActivityTargetUserName(item, detailsRecord);
|
||||
const roleTransitionText = getRoleTransitionText(detailsRecord);
|
||||
const memberText = memberName || '成员';
|
||||
const roleText = roleTransitionText ? `,角色:${roleTransitionText}` : '';
|
||||
const prefix =
|
||||
operatorText === '--' ? `执行了【${item.actionName}】:` : `${operatorText}执行了【${item.actionName}】:`;
|
||||
const text = `${prefix}${memberText}${roleText}`;
|
||||
const parts: ProductActivityTextPart[] = [{ text: prefix }, { text: memberText, strong: Boolean(memberName) }];
|
||||
|
||||
return operatorText === '--'
|
||||
? `执行了【${item.actionName}】:${memberText}${roleText}`
|
||||
: `${operatorText}执行了【${item.actionName}】:${memberText}${roleText}`;
|
||||
if (roleText) {
|
||||
parts.push({ text: roleText });
|
||||
}
|
||||
|
||||
return { text, parts };
|
||||
}
|
||||
|
||||
function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, operatorText: string) {
|
||||
@@ -319,16 +336,20 @@ function buildManagerChangeSummary(detailsRecord: ActivityDetailRecord | null, o
|
||||
return operatorText === '--' ? `变更产品经理:${transitionText}` : `${operatorText}变更产品经理:${transitionText}`;
|
||||
}
|
||||
|
||||
function plainSummary(text: string): ActivitySummaryResult {
|
||||
return { text, parts: [{ text }] };
|
||||
}
|
||||
|
||||
function resolveDetailedSummary(
|
||||
item: Api.Product.ProductActivityTimelineItem,
|
||||
detailsRecord: ActivityDetailRecord | null,
|
||||
texts: { operatorText: string; actionText: string }
|
||||
) {
|
||||
): ActivitySummaryResult {
|
||||
const { operatorText, actionText } = texts;
|
||||
const summaryText = item.summary?.trim() || '';
|
||||
|
||||
if (item.actionType === 'add_member' || item.actionType === 'remove_member') {
|
||||
return buildMemberChangeSummary(item, detailsRecord, operatorText) || summaryText || actionText;
|
||||
return buildMemberChangeSummary(item, detailsRecord, operatorText) || plainSummary(summaryText || actionText);
|
||||
}
|
||||
|
||||
if (item.actionType === 'update_member') {
|
||||
@@ -336,29 +357,16 @@ function resolveDetailedSummary(
|
||||
}
|
||||
|
||||
if (!isGenericActivitySummary(summaryText, actionText)) {
|
||||
return summaryText;
|
||||
return plainSummary(summaryText);
|
||||
}
|
||||
|
||||
if (item.actionType === 'change_manager') {
|
||||
return buildManagerChangeSummary(detailsRecord, operatorText) || summaryText || actionText;
|
||||
const managerSummary = buildManagerChangeSummary(detailsRecord, operatorText);
|
||||
|
||||
return plainSummary(managerSummary || summaryText || actionText);
|
||||
}
|
||||
|
||||
return summaryText || actionText;
|
||||
}
|
||||
|
||||
function buildProductActivityTextParts(text: string, subjectText: string): ProductActivityTextPart[] {
|
||||
const normalizedSubject = subjectText.trim();
|
||||
const subjectIndex = normalizedSubject ? text.indexOf(normalizedSubject) : -1;
|
||||
|
||||
if (subjectIndex < 0) {
|
||||
return [{ text }];
|
||||
}
|
||||
|
||||
return [
|
||||
{ text: text.slice(0, subjectIndex) },
|
||||
{ text: normalizedSubject, strong: true },
|
||||
{ text: text.slice(subjectIndex + normalizedSubject.length) }
|
||||
].filter(part => part.text);
|
||||
return plainSummary(summaryText || actionText);
|
||||
}
|
||||
|
||||
export function buildProductActivityDisplayItem(
|
||||
@@ -369,18 +377,19 @@ export function buildProductActivityDisplayItem(
|
||||
operatorText === '--' ? `执行了【${item.actionName}】` : `${operatorText}执行了【${item.actionName}】`;
|
||||
const detailsRecord = parseActivityDetails(item.details);
|
||||
const subjectText = isMemberActivityAction(item.actionType) ? getActivityTargetUserName(item, detailsRecord) : '';
|
||||
const displaySummary =
|
||||
item.type === 'status' ? actionText : resolveDetailedSummary(item, detailsRecord, { operatorText, actionText });
|
||||
const compactText = displaySummary;
|
||||
const summary =
|
||||
item.type === 'status'
|
||||
? plainSummary(actionText)
|
||||
: resolveDetailedSummary(item, detailsRecord, { operatorText, actionText });
|
||||
|
||||
return {
|
||||
...item,
|
||||
tagLabel: activityTypeLabelMap[item.type],
|
||||
timeText: formatProductActivityTime(item.occurredAt) || '--',
|
||||
actionText,
|
||||
displaySummary,
|
||||
compactText,
|
||||
compactTextParts: buildProductActivityTextParts(compactText, subjectText),
|
||||
displaySummary: summary.text,
|
||||
compactText: summary.text,
|
||||
compactTextParts: summary.parts.filter(part => part.text),
|
||||
operatorText,
|
||||
subjectText,
|
||||
reasonText: item.reason?.trim() || '',
|
||||
|
||||
@@ -31,7 +31,7 @@ const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
|
||||
function getInitSearchParams(): Api.Product.ProductSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
directionCode: undefined,
|
||||
managerUserId: undefined,
|
||||
@@ -62,12 +62,12 @@ function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
|
||||
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const statusNavMetas: StatusNavMeta[] = [
|
||||
@@ -210,7 +210,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
|
||||
label: '最近更新',
|
||||
width: 170,
|
||||
align: 'center',
|
||||
formatter: row => formatDateTime(row.updateTime)
|
||||
formatter: row => formatDate(row.updateTime)
|
||||
}
|
||||
],
|
||||
immediate: false
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
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 BusinessUserPicker from '@/components/custom/business-user-picker.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
|
||||
defineOptions({ name: 'ProductCreateBaseForm' });
|
||||
@@ -72,9 +72,10 @@ defineExpose({ validate: runValidate });
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="产品经理" prop="managerUserId">
|
||||
<BusinessUserSelect
|
||||
<BusinessUserPicker
|
||||
v-model="model.managerUserId"
|
||||
:options="managerUserOptions"
|
||||
:user-options="managerUserOptions"
|
||||
title="选择产品经理"
|
||||
placeholder="请选择产品经理"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
@@ -56,7 +56,7 @@ function search() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="reset" @search="search" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -14,10 +14,10 @@ import {
|
||||
fetchChangeRequirementStatus,
|
||||
fetchDeleteRequirement,
|
||||
fetchGetProductMembers,
|
||||
fetchGetProductRequirementDashboard,
|
||||
fetchGetProjectListByProductId,
|
||||
fetchGetRequirementAllowedTransitionsBatch,
|
||||
fetchGetRequirementStatusDict,
|
||||
fetchGetRequirementTerminalStatusDict,
|
||||
fetchGetRequirementTree,
|
||||
fetchHasDispatchedProjectRequirementBatch
|
||||
} from '@/service/api';
|
||||
@@ -42,7 +42,11 @@ import RequirementCreateDialog from './modules/requirement-create-dialog.vue';
|
||||
import RequirementDetailDialog from './modules/requirement-detail-dialog.vue';
|
||||
import RequirementSplitDialog from './modules/requirement-split-dialog.vue';
|
||||
import RequirementActionDialog from './modules/requirement-action-dialog.vue';
|
||||
import RequirementReviewDialog from './modules/requirement-review-dialog.vue';
|
||||
import RequirementReviewRecordDialog from './modules/requirement-review-record-dialog.vue';
|
||||
import IconMdiSync from '~icons/mdi/sync';
|
||||
import IconMdiEyeOutline from '~icons/mdi/eye-outline';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
|
||||
defineOptions({ name: 'ProductRequirement' });
|
||||
|
||||
@@ -50,10 +54,20 @@ const router = useRouter();
|
||||
const { currentObjectId } = useCurrentProduct();
|
||||
const { hasObjectAuth } = useAuth();
|
||||
|
||||
const statusOptions = ref<Array<{ label: string; value: string }>>([]);
|
||||
const terminalStatusOptions = ref<string[]>([]);
|
||||
const statusDict = ref<Api.Product.RequirementStatusDict[]>([]);
|
||||
const projectOptions = ref<Api.Project.Project[]>([]);
|
||||
|
||||
const statusOptions = computed(() => {
|
||||
return statusDict.value.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
});
|
||||
|
||||
const statusMetaMap = computed(() => {
|
||||
return new Map(statusDict.value.map(item => [item.statusCode, item]));
|
||||
});
|
||||
|
||||
const projectNameMap = computed(() => {
|
||||
return new Map(projectOptions.value.map(item => [item.id, item.projectName]));
|
||||
});
|
||||
@@ -64,25 +78,11 @@ async function loadStatusOptions() {
|
||||
const { error, data } = await fetchGetRequirementStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
statusOptions.value = [];
|
||||
statusDict.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
statusOptions.value = data.map(item => ({
|
||||
label: item.statusName,
|
||||
value: item.statusCode
|
||||
}));
|
||||
}
|
||||
|
||||
async function loadTerminalStatusOptions() {
|
||||
const { error, data } = await fetchGetRequirementTerminalStatusDict();
|
||||
|
||||
if (error || !data) {
|
||||
terminalStatusOptions.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
terminalStatusOptions.value = data.map(item => item.statusCode);
|
||||
statusDict.value = data;
|
||||
}
|
||||
|
||||
async function loadProjectOptions() {
|
||||
@@ -106,12 +106,6 @@ function getStatusLabel(statusCode: string) {
|
||||
return item ? item.label : statusCode;
|
||||
}
|
||||
|
||||
const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
|
||||
0: 'danger',
|
||||
1: 'warning',
|
||||
2: 'primary',
|
||||
3: 'info'
|
||||
};
|
||||
const hasDispatchedMap = ref<Record<string, boolean>>({});
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
@@ -131,7 +125,23 @@ function formatDate(value?: string | null) {
|
||||
}
|
||||
|
||||
function isTerminalStatus(statusCode: string) {
|
||||
return terminalStatusOptions.value.includes(statusCode);
|
||||
return Boolean(statusMetaMap.value.get(statusCode)?.terminalFlag);
|
||||
}
|
||||
|
||||
function canEditRequirement(row: Api.Product.Requirement) {
|
||||
return Boolean(statusMetaMap.value.get(row.statusCode)?.allowEdit);
|
||||
}
|
||||
|
||||
function isReviewAction(row: Api.Product.Requirement, action: Api.Product.RequirementLifecycleAction) {
|
||||
return row.statusCode === 'pending_review' && ['pass_review', 'reject_review'].includes(action.actionCode);
|
||||
}
|
||||
|
||||
function isReviewTransitionAction(actionCode: string) {
|
||||
return ['pass_review', 'reject_review'].includes(actionCode);
|
||||
}
|
||||
|
||||
function canViewReviewRecord(row: Api.Product.Requirement) {
|
||||
return row.reviewRequired === 1 && !['pending_claim', 'pending_review'].includes(row.statusCode);
|
||||
}
|
||||
|
||||
function canSplitRequirement(row: Api.Product.Requirement) {
|
||||
@@ -142,7 +152,7 @@ function canSplitRequirement(row: Api.Product.Requirement) {
|
||||
if (hasDispatched) {
|
||||
return false;
|
||||
}
|
||||
return row.statusCode === 'pending_dispatch' || row.statusCode === 'implementing';
|
||||
return ['pending_dispatch', 'reviewed', 'implementing'].includes(row.statusCode);
|
||||
}
|
||||
|
||||
function canDeleteRequirement(row: Api.Product.Requirement) {
|
||||
@@ -156,6 +166,7 @@ const memberOptions = ref<Api.Product.ProductMember[]>([]);
|
||||
const requirementTableRef = ref<TableInstance>();
|
||||
const loading = ref(false);
|
||||
const treeData = ref<Api.Product.Requirement[]>([]);
|
||||
const requirementDisplayTotal = ref(0);
|
||||
const pagination = reactive({
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
@@ -183,6 +194,10 @@ const splitParentRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
const actionVisible = ref(false);
|
||||
const actionRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
const currentAction = ref<Api.Product.RequirementLifecycleAction | null>(null);
|
||||
const reviewVisible = ref(false);
|
||||
const reviewRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
const reviewRecordVisible = ref(false);
|
||||
const reviewRecordRequirement = ref<Api.Product.Requirement | null>(null);
|
||||
|
||||
interface MemberUserOption {
|
||||
id: string;
|
||||
@@ -212,14 +227,6 @@ function getMemberLabel(userId?: string | null) {
|
||||
return memberLabelMap.value.get(String(userId)) || String(userId);
|
||||
}
|
||||
|
||||
function getPriorityTagType(priority?: number | null): UI.ThemeColor {
|
||||
if (priority === null || priority === undefined) {
|
||||
return 'info';
|
||||
}
|
||||
|
||||
return priorityTagTypeMap[priority] || 'info';
|
||||
}
|
||||
|
||||
function flattenTree(nodes: Api.Product.Requirement[]): Api.Product.Requirement[] {
|
||||
const result: Api.Product.Requirement[] = [];
|
||||
for (const node of nodes) {
|
||||
@@ -360,14 +367,12 @@ const columns = computed(() => [
|
||||
label: '优先级',
|
||||
width: 75,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (
|
||||
<DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} type={getPriorityTagType(row.priority)} />
|
||||
)
|
||||
formatter: (row: Api.Product.Requirement) => <DictTag dictCode={RDMS_REQ_PRIORITY_DICT_CODE} value={row.priority} />
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
label: '状态',
|
||||
width: 90,
|
||||
width: 100,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (
|
||||
<ElTag type={getRequirementStatusTagType(row.statusCode)}>{getStatusLabel(row.statusCode)}</ElTag>
|
||||
@@ -376,13 +381,13 @@ const columns = computed(() => [
|
||||
{
|
||||
prop: 'category',
|
||||
label: '需求类型',
|
||||
minWidth: 100,
|
||||
minWidth: 80,
|
||||
formatter: (row: Api.Product.Requirement) => row.category
|
||||
},
|
||||
{
|
||||
prop: 'sourceType',
|
||||
label: '需求来源',
|
||||
minWidth: 100,
|
||||
minWidth: 80,
|
||||
align: 'center',
|
||||
formatter: (row: Api.Product.Requirement) => (
|
||||
<DictText dictCode={RDMS_REQ_SOURCE_TYPE_DICT_CODE} value={row.sourceType} />
|
||||
@@ -463,25 +468,32 @@ const columns = computed(() => [
|
||||
onClick: () => void;
|
||||
}[] = [];
|
||||
|
||||
if (hasObjectAuth('project:product:split')) {
|
||||
if (hasObjectAuth('project:product:query') && canViewReviewRecord(row)) {
|
||||
actions.push({
|
||||
key: 'reviewRecord',
|
||||
label: '查看评审记录',
|
||||
icon: markRaw(IconMdiEyeOutline),
|
||||
type: 'primary',
|
||||
onClick: () => handleViewReviewRecord(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasObjectAuth('project:product:split') && canSplitRequirement(row)) {
|
||||
actions.push({
|
||||
key: 'split',
|
||||
label: '拆分',
|
||||
icon: ACTION_ICON_MAP.split,
|
||||
type: ACTION_TYPE_MAP.split,
|
||||
disabled: !canSplitRequirement(row),
|
||||
onClick: () => openSplit(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasObjectAuth('project:product:update')) {
|
||||
const canEdit = !isTerminalStatus(row.statusCode) && row.statusCode !== 'accepted' && !row.implementProjectId;
|
||||
if (hasObjectAuth('project:product:update') && canEditRequirement(row)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
label: '编辑',
|
||||
icon: ACTION_ICON_MAP.edit,
|
||||
type: ACTION_TYPE_MAP.edit,
|
||||
disabled: !canEdit,
|
||||
onClick: () => openEdit(row)
|
||||
});
|
||||
}
|
||||
@@ -489,7 +501,7 @@ const columns = computed(() => [
|
||||
if (row.implementProjectId) {
|
||||
actions.push({
|
||||
key: 'forward',
|
||||
label: '前往项目侧',
|
||||
label: '跳转',
|
||||
icon: ACTION_ICON_MAP.forward,
|
||||
type: 'primary',
|
||||
onClick: () => handleForwardToProjectRequirement(row)
|
||||
@@ -497,13 +509,24 @@ const columns = computed(() => [
|
||||
}
|
||||
|
||||
const lifecycleActions = getRowActions(row);
|
||||
const hasReviewAuth = hasObjectAuth('project:product:review');
|
||||
const hasStatusAuth = hasObjectAuth('project:product:status');
|
||||
|
||||
if (hasReviewAuth && lifecycleActions.some(action => isReviewAction(row, action))) {
|
||||
actions.push({
|
||||
key: 'review',
|
||||
label: '评审',
|
||||
icon: ACTION_ICON_MAP.pass_review,
|
||||
type: 'primary',
|
||||
onClick: () => openReview(row)
|
||||
});
|
||||
}
|
||||
|
||||
if (hasStatusAuth) {
|
||||
const nonTerminalActions: Api.Product.RequirementLifecycleAction[] = [];
|
||||
const terminalActions: Api.Product.RequirementLifecycleAction[] = [];
|
||||
|
||||
for (const action of lifecycleActions) {
|
||||
for (const action of lifecycleActions.filter(item => !isReviewTransitionAction(item.actionCode))) {
|
||||
const code = action.actionCode as RequirementStatusActionCode;
|
||||
if (isRequirementActionTerminal(code)) {
|
||||
terminalActions.push(action);
|
||||
@@ -535,23 +558,29 @@ const columns = computed(() => [
|
||||
|
||||
return (
|
||||
<div class="requirement-action-cell" onClick={event => event.stopPropagation()}>
|
||||
{actions.map(action => {
|
||||
const IconComponent = action.icon as any;
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
class="requirement-action-icon-btn"
|
||||
type={action.type}
|
||||
disabled={action.disabled}
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
<IconComponent class="text-18px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
);
|
||||
})}
|
||||
{actions.length === 0 ? (
|
||||
<ElButton link size="small" class="requirement-action-icon-btn" type="primary" disabled>
|
||||
<IconMdiPencilOutline class="text-18px" />
|
||||
</ElButton>
|
||||
) : (
|
||||
actions.map(action => {
|
||||
const IconComponent = action.icon as any;
|
||||
return (
|
||||
<ElTooltip key={action.key} content={action.label} placement="top">
|
||||
<ElButton
|
||||
link
|
||||
size="small"
|
||||
class="requirement-action-icon-btn"
|
||||
type={action.type}
|
||||
disabled={action.disabled}
|
||||
onClick={() => action.onClick()}
|
||||
>
|
||||
<IconComponent class="text-18px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -633,11 +662,27 @@ async function loadTreeData() {
|
||||
pagination.total = data.total;
|
||||
}
|
||||
|
||||
async function loadRequirementDisplayTotal() {
|
||||
if (!currentObjectId.value) {
|
||||
requirementDisplayTotal.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProductRequirementDashboard(currentObjectId.value);
|
||||
|
||||
if (error || !data) {
|
||||
requirementDisplayTotal.value = pagination.total;
|
||||
return;
|
||||
}
|
||||
|
||||
requirementDisplayTotal.value = data.summary.total;
|
||||
}
|
||||
|
||||
async function reloadTable() {
|
||||
loading.value = true;
|
||||
try {
|
||||
await loadTreeData();
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll()]);
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll(), loadRequirementDisplayTotal()]);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -698,6 +743,20 @@ function openSplit(row: Api.Product.Requirement) {
|
||||
splitVisible.value = true;
|
||||
}
|
||||
|
||||
function openReview(row: Api.Product.Requirement) {
|
||||
reviewRequirement.value = row;
|
||||
reviewVisible.value = true;
|
||||
}
|
||||
|
||||
function handleViewReviewRecord(row: Api.Product.Requirement) {
|
||||
if (!canViewReviewRecord(row)) {
|
||||
return;
|
||||
}
|
||||
|
||||
reviewRecordRequirement.value = row;
|
||||
reviewRecordVisible.value = true;
|
||||
}
|
||||
|
||||
async function handleForwardToProjectRequirement(row: Api.Product.Requirement) {
|
||||
if (!row.implementProjectId) return;
|
||||
|
||||
@@ -715,7 +774,7 @@ function handleActionClick(row: Api.Product.Requirement, action: Api.Product.Req
|
||||
if (
|
||||
!isRequirementActionNeedReviewChoice(actionCode) &&
|
||||
!isRequirementActionNeedProject(actionCode) &&
|
||||
!isRequirementActionTerminal(actionCode)
|
||||
!action.needReason
|
||||
) {
|
||||
handleDirectAction(row, action);
|
||||
return;
|
||||
@@ -814,24 +873,30 @@ async function handleSplitSubmitted() {
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
async function handleReviewSubmitted() {
|
||||
reviewVisible.value = false;
|
||||
await reloadTable();
|
||||
}
|
||||
|
||||
watch(
|
||||
() => currentObjectId.value,
|
||||
async id => {
|
||||
if (id) {
|
||||
await Promise.all([loadMembers(), loadTreeData(), loadProjectOptions()]);
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll()]);
|
||||
await Promise.all([loadAllowedTransitionsForAll(), loadHasDispatchedForAll(), loadRequirementDisplayTotal()]);
|
||||
} else {
|
||||
memberOptions.value = [];
|
||||
treeData.value = [];
|
||||
projectOptions.value = [];
|
||||
allowedTransitionsMap.value = new Map();
|
||||
requirementDisplayTotal.value = 0;
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
await loadStatusOptions();
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -858,7 +923,7 @@ onMounted(async () => {
|
||||
<div class="flex items-center justify-between gap-12px">
|
||||
<div class="flex flex-wrap items-center gap-8px">
|
||||
<p>需求列表</p>
|
||||
<ElTag effect="plain">{{ pagination.total }} 条</ElTag>
|
||||
<ElTag effect="plain">{{ requirementDisplayTotal }} 条</ElTag>
|
||||
</div>
|
||||
<TableHeaderOperation v-model:columns="columnChecks" :loading="loading" @refresh="reloadTable">
|
||||
<template #default>
|
||||
@@ -950,6 +1015,21 @@ onMounted(async () => {
|
||||
:project-options="projectOptions"
|
||||
@submitted="handleActionSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementReviewDialog
|
||||
v-model:visible="reviewVisible"
|
||||
:product-id="currentObjectId || ''"
|
||||
:requirement="reviewRequirement"
|
||||
:member-options="memberOptions"
|
||||
@submitted="handleReviewSubmitted"
|
||||
/>
|
||||
|
||||
<RequirementReviewRecordDialog
|
||||
v-model:visible="reviewRecordVisible"
|
||||
:product-id="currentObjectId || ''"
|
||||
:requirement="reviewRecordRequirement"
|
||||
:member-options="memberOptions"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -967,6 +1047,8 @@ onMounted(async () => {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
line-height: 1.5;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
:deep(.requirement-title--terminal) {
|
||||
|
||||
@@ -17,7 +17,14 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
level: 0
|
||||
level: 0,
|
||||
selectedModuleId: undefined,
|
||||
editingNodeId: undefined,
|
||||
editingName: undefined,
|
||||
addingChildParentId: undefined,
|
||||
newChildModuleName: undefined,
|
||||
rootModuleId: undefined,
|
||||
moduleRequirementCountMap: undefined
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -156,37 +163,31 @@ function handleToggle() {
|
||||
</div>
|
||||
|
||||
<div v-if="!isEditing && hasAnyActionPermission" class="module-tree-item__actions" @click.stop>
|
||||
<ElDropdown trigger="click">
|
||||
<ElButton text size="small" class="module-tree-item__more-btn">
|
||||
<icon-mdi-dots-horizontal class="text-14px" />
|
||||
<ElTooltip v-if="hasObjectAuth('project:product:create')" content="新增子模块" placement="top">
|
||||
<ElButton link type="primary" class="module-tree-item__action-btn" @click="handleStartAddChild">
|
||||
<icon-mdi-plus class="text-14px" />
|
||||
</ElButton>
|
||||
<template #dropdown>
|
||||
<ElDropdownMenu>
|
||||
<ElDropdownItem v-if="hasObjectAuth('project:product:create')" @click="handleStartAddChild">
|
||||
<div class="flex items-center gap-6px">
|
||||
<icon-ic-round-plus class="text-14px" />
|
||||
<span>新增子模块</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem v-if="!isRootModule && hasObjectAuth('project:product:update')" @click="handleStartEdit">
|
||||
<div class="flex items-center gap-6px">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
<span>编辑</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
<ElDropdownItem
|
||||
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:product:delete')"
|
||||
divided
|
||||
@click="handleDelete"
|
||||
>
|
||||
<div class="flex items-center gap-6px text-error">
|
||||
</ElTooltip>
|
||||
<ElTooltip v-if="!isRootModule && hasObjectAuth('project:product:update')" content="编辑" placement="top">
|
||||
<ElButton link type="primary" class="module-tree-item__action-btn" @click="handleStartEdit">
|
||||
<icon-mdi-pencil-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElPopconfirm
|
||||
v-if="!isRootModule && canDeleteModule && hasObjectAuth('project:product:delete')"
|
||||
title="确定删除该模块吗?"
|
||||
@confirm="handleDelete"
|
||||
>
|
||||
<template #reference>
|
||||
<span class="inline-flex" @click.stop>
|
||||
<ElTooltip content="删除" placement="top">
|
||||
<ElButton link type="danger" class="module-tree-item__action-btn">
|
||||
<icon-mdi-delete-outline class="text-14px" />
|
||||
<span>删除</span>
|
||||
</div>
|
||||
</ElDropdownItem>
|
||||
</ElDropdownMenu>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
</template>
|
||||
</ElDropdown>
|
||||
</ElPopconfirm>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -390,12 +391,15 @@ function handleToggle() {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.module-tree-item__more-btn {
|
||||
padding: 4px;
|
||||
border-radius: 4px;
|
||||
.module-tree-item__action-btn {
|
||||
padding: 2px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
margin-left: 2px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.module-tree-item__more-btn:hover {
|
||||
background-color: #e2e8f0;
|
||||
.module-tree-item__action-btn:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -5,8 +5,7 @@ import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
|
||||
import {
|
||||
type RequirementStatusActionCode,
|
||||
isRequirementActionNeedProject,
|
||||
isRequirementActionNeedReviewChoice,
|
||||
isRequirementActionTerminal
|
||||
isRequirementActionNeedReviewChoice
|
||||
} from '../shared/requirement-master-data';
|
||||
|
||||
defineOptions({ name: 'RequirementActionDialog' });
|
||||
@@ -45,7 +44,7 @@ const isClaimAction = computed(() =>
|
||||
actionCode.value ? isRequirementActionNeedReviewChoice(actionCode.value) : false
|
||||
);
|
||||
const isDispatchAction = computed(() => (actionCode.value ? isRequirementActionNeedProject(actionCode.value) : false));
|
||||
const isTerminalAction = computed(() => (actionCode.value ? isRequirementActionTerminal(actionCode.value) : false));
|
||||
const needReason = computed(() => Boolean(props.action?.needReason));
|
||||
|
||||
const dialogTitle = computed(() => {
|
||||
if (!props.action) return '';
|
||||
@@ -55,7 +54,7 @@ const dialogTitle = computed(() => {
|
||||
|
||||
const reviewChoiceOptions = [
|
||||
{ label: '需要评审', value: 'claim_to_review', description: '认领后进入评审流程' },
|
||||
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入分流' }
|
||||
{ label: '不需要评审', value: 'claim_to_dispatch', description: '认领后直接进入指派' }
|
||||
];
|
||||
|
||||
const rules = computed(() => {
|
||||
@@ -69,7 +68,7 @@ const rules = computed(() => {
|
||||
baseRules.implementProjectId = [createRequiredRule('请选择关联项目')];
|
||||
}
|
||||
|
||||
if (isTerminalAction.value) {
|
||||
if (needReason.value) {
|
||||
baseRules.reason = [createRequiredRule('请输入状态变更原因')];
|
||||
}
|
||||
|
||||
@@ -98,7 +97,7 @@ async function handleSubmit() {
|
||||
payload.implementProjectId = model.value.implementProjectId;
|
||||
}
|
||||
|
||||
if (isTerminalAction.value) {
|
||||
if (needReason.value) {
|
||||
payload.reason = model.value.reason.trim();
|
||||
}
|
||||
|
||||
@@ -142,7 +141,7 @@ async function handleSubmit() {
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-if="isTerminalAction" label="变更原因" prop="reason">
|
||||
<ElFormItem v-if="needReason" label="变更原因" prop="reason">
|
||||
<ElInput
|
||||
v-model="model.reason"
|
||||
type="textarea"
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchCreateRequirement, fetchGetRequirementModuleTree } from '@/service/api';
|
||||
import { fetchCreateRequirement, fetchGetRequirementModuleTree, fetchGetUserSimpleList } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
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 DictSelect from '@/components/custom/dict-select.vue';
|
||||
import RequirementTreePicker, {
|
||||
type RequirementTreePickerNode
|
||||
} from '@/views/project/project/execution/components/requirement-tree-picker.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementCreateDialog' });
|
||||
@@ -36,18 +37,10 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
{ label: '需要', value: 1 }
|
||||
@@ -78,9 +71,9 @@ interface Model {
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
moduleId: string | null;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
proposerId: string;
|
||||
currentHandlerUserId: string;
|
||||
@@ -90,34 +83,14 @@ interface Model {
|
||||
const submitting = ref(false);
|
||||
const loading = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
const allUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const memberUserOptions = computed(() => {
|
||||
return props.memberOptions.filter(m => m.status === 0);
|
||||
});
|
||||
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
|
||||
for (const module of modules) {
|
||||
const currentPath = `${parentPath}/${module.moduleName}`;
|
||||
options.push({
|
||||
label: currentPath,
|
||||
value: module.id || ''
|
||||
});
|
||||
if (module.children?.length) {
|
||||
walk(module.children, currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleTree.value.length > 0) {
|
||||
walk(moduleTree.value, '');
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
const moduleTreeOptions = computed<RequirementTreePickerNode[]>(() => mapModuleTree(moduleTree.value));
|
||||
|
||||
const rules = {
|
||||
title: [createRequiredRule('请输入需求名称')],
|
||||
@@ -163,9 +136,9 @@ function createDefaultModel(): Model {
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: props.defaultModuleId || '0',
|
||||
moduleId: props.defaultModuleId || null,
|
||||
category: '功能需求',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
proposerId: '',
|
||||
currentHandlerUserId: '',
|
||||
@@ -173,6 +146,16 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function mapModuleTree(modules: Api.Product.RequirementModule[]): RequirementTreePickerNode[] {
|
||||
return modules
|
||||
.filter(item => Boolean(item.id))
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
title: item.moduleName,
|
||||
children: item.children?.length ? mapModuleTree(item.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
@@ -189,8 +172,8 @@ async function handleSubmit() {
|
||||
return;
|
||||
}
|
||||
|
||||
const proposer = memberUserOptions.value.find(m => m.userId === model.value.proposerId);
|
||||
const proposerNickname = proposer?.userNickname || '';
|
||||
const proposer = allUserOptions.value.find(u => u.id === model.value.proposerId);
|
||||
const proposerNickname = proposer?.nickname || '';
|
||||
const handler = memberUserOptions.value.find(m => m.userId === model.value.currentHandlerUserId);
|
||||
const currentHandlerUserNickname = handler?.userNickname || '';
|
||||
|
||||
@@ -249,6 +232,13 @@ async function loadModuleTree() {
|
||||
moduleTree.value = data;
|
||||
}
|
||||
|
||||
async function loadAllUsers() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
if (!error && data) {
|
||||
allUserOptions.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
@@ -257,7 +247,7 @@ watch(
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
await loadModuleTree();
|
||||
await Promise.all([loadModuleTree(), loadAllUsers()]);
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
@@ -286,9 +276,11 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="模块">
|
||||
<ElSelect v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
<RequirementTreePicker
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTreeOptions"
|
||||
placeholder="搜索或选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
@@ -306,9 +298,12 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
|
||||
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
@@ -322,14 +317,7 @@ watch(
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ElSelect v-model="model.proposerId" class="w-full" filterable placeholder="请选择提出人">
|
||||
<ElOption
|
||||
v-for="item in memberUserOptions"
|
||||
:key="item.userId"
|
||||
:label="item.userNickname"
|
||||
:value="item.userId"
|
||||
>
|
||||
<MemberSelectOption :nickname="item.userNickname" :role-name="item.roleName" />
|
||||
</ElOption>
|
||||
<ElOption v-for="item in allUserOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
fetchGetProjectListByProductId,
|
||||
fetchGetRequirement,
|
||||
fetchGetRequirementModuleTree,
|
||||
fetchGetUserSimpleList,
|
||||
fetchUpdateRequirement
|
||||
} from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
@@ -14,7 +15,11 @@ import BusinessAttachmentUploader from '@/components/custom/business-attachment-
|
||||
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 DictSelect from '@/components/custom/dict-select.vue';
|
||||
import ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import RequirementTreePicker, {
|
||||
type RequirementTreePickerNode
|
||||
} from '@/views/project/project/execution/components/requirement-tree-picker.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementDetailDialog' });
|
||||
@@ -46,26 +51,19 @@ const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const { getLabel: getCategoryLabel } = useDict(() => props.categoryDictCode);
|
||||
const { getLabel: getPriorityLabel, enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
const { getLabel: getPriorityLabel } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
interface Model {
|
||||
title: string;
|
||||
description: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
moduleId: string;
|
||||
moduleId: string | null;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
proposerId: string;
|
||||
proposerNickname: string;
|
||||
@@ -80,6 +78,7 @@ const loading = ref(false);
|
||||
const submitting = ref(false);
|
||||
const moduleTree = ref<Api.Product.RequirementModule[]>([]);
|
||||
const projectOptions = ref<Api.Project.Project[]>([]);
|
||||
const allUserOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
|
||||
const isViewMode = computed(() => props.mode === 'view');
|
||||
@@ -100,6 +99,10 @@ const memberLabelMap = computed(() => {
|
||||
return new Map(memberUserOptions.value.map(item => [String(item.userId), item.userNickname]));
|
||||
});
|
||||
|
||||
const allUserLabelMap = computed(() => {
|
||||
return new Map(allUserOptions.value.map(item => [String(item.id), item.nickname]));
|
||||
});
|
||||
|
||||
const moduleLabelMap = computed(() => {
|
||||
const map = new Map<string | undefined, string>();
|
||||
|
||||
@@ -120,28 +123,7 @@ const projectOptionsMap = computed(() => {
|
||||
return new Map(projectOptions.value.map(item => [String(item.id), item.projectName]));
|
||||
});
|
||||
|
||||
const flatModuleOptions = computed<Array<{ label: string; value: string }>>(() => {
|
||||
const options: Array<{ label: string; value: string }> = [];
|
||||
|
||||
function walk(modules: Api.Product.RequirementModule[], parentPath: string) {
|
||||
for (const module of modules) {
|
||||
const currentPath = `${parentPath}/${module.moduleName}`;
|
||||
options.push({
|
||||
label: currentPath,
|
||||
value: module.id || ''
|
||||
});
|
||||
if (module.children?.length) {
|
||||
walk(module.children, currentPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (moduleTree.value.length > 0) {
|
||||
walk(moduleTree.value, '');
|
||||
}
|
||||
|
||||
return options;
|
||||
});
|
||||
const moduleTreeOptions = computed<RequirementTreePickerNode[]>(() => mapModuleTree(moduleTree.value));
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
@@ -216,9 +198,9 @@ function createDefaultModel(): Model {
|
||||
description: null,
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
moduleId: '0',
|
||||
moduleId: null,
|
||||
category: '',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
proposerId: '',
|
||||
proposerNickname: '',
|
||||
@@ -230,6 +212,16 @@ function createDefaultModel(): Model {
|
||||
};
|
||||
}
|
||||
|
||||
function mapModuleTree(modules: Api.Product.RequirementModule[]): RequirementTreePickerNode[] {
|
||||
return modules
|
||||
.filter(item => Boolean(item.id))
|
||||
.map(item => ({
|
||||
id: item.id || '',
|
||||
title: item.moduleName,
|
||||
children: item.children?.length ? mapModuleTree(item.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
visible.value = false;
|
||||
}
|
||||
@@ -322,9 +314,9 @@ function transformRequirementData(data: Api.Product.Requirement): typeof model.v
|
||||
description: data.description || null,
|
||||
attachments: data.attachments ? [...data.attachments] : [],
|
||||
reviewRequired: data.reviewRequired ?? 0,
|
||||
moduleId: data.moduleId || '0',
|
||||
moduleId: data.moduleId || null,
|
||||
category: data.category || '',
|
||||
priority: data.priority ?? null,
|
||||
priority: data.priority === null || data.priority === undefined ? null : String(data.priority),
|
||||
expectedTime: formatExpectedTime(data.expectedTime),
|
||||
proposerId: data.proposerId || '',
|
||||
proposerNickname: data.proposerNickname || '',
|
||||
@@ -367,6 +359,13 @@ async function loadRequirementDetail() {
|
||||
model.value = transformRequirementData(data);
|
||||
}
|
||||
|
||||
async function loadAllUsers() {
|
||||
const { error, data } = await fetchGetUserSimpleList();
|
||||
if (!error && data) {
|
||||
allUserOptions.value = data;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
@@ -374,7 +373,7 @@ watch(
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([loadModuleTree(), loadProjectOptions()]);
|
||||
await Promise.all([loadModuleTree(), loadProjectOptions(), loadAllUsers()]);
|
||||
|
||||
if (props.requirement?.id) {
|
||||
await loadRequirementDetail();
|
||||
@@ -414,11 +413,14 @@ watch(
|
||||
|
||||
<ElFormItem label="模块">
|
||||
<template v-if="isViewMode">
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId) || '--'" />
|
||||
<ReadonlyField :value="moduleLabelMap.get(model.moduleId || undefined) || '--'" />
|
||||
</template>
|
||||
<ElSelect v-else v-model="model.moduleId" class="w-full" filterable placeholder="请选择所属模块">
|
||||
<ElOption v-for="item in flatModuleOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
<RequirementTreePicker
|
||||
v-else
|
||||
v-model="model.moduleId"
|
||||
:data="moduleTreeOptions"
|
||||
placeholder="搜索或选择所属模块"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="是否需要评审">
|
||||
@@ -431,9 +433,13 @@ watch(
|
||||
:value="model.priority !== null ? getPriorityLabel(String(model.priority)) || '--' : '--'"
|
||||
/>
|
||||
</template>
|
||||
<ElSelect v-else v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
|
||||
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
<DictSelect
|
||||
v-else
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求类型" prop="category">
|
||||
@@ -441,7 +447,7 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提出人" prop="proposerId">
|
||||
<ReadonlyField :value="memberLabelMap.get(model.proposerId) || '--'" />
|
||||
<ReadonlyField :value="allUserLabelMap.get(model.proposerId) || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
@@ -512,7 +518,7 @@ watch(
|
||||
:disabled="isViewMode"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement"
|
||||
placeholder="请输入需求内容"
|
||||
:placeholder="isViewMode && isEmptyRichText(model.description) ? '--' : '请输入需求内容'"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchSubmitProductRequirementReview } from '@/service/api';
|
||||
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';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
import ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
import AttendeeUserPicker from '@/components/custom/attendee-user-picker.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementReviewDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
requirement: Api.Product.Requirement | null;
|
||||
memberOptions: Api.Product.ProductMember[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'submitted'): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
interface Model {
|
||||
conclusion: Api.Product.RequirementReviewConclusion;
|
||||
attendees: Api.Product.RequirementReviewAttendeeItem[];
|
||||
requirementEstimatedHours: number | null;
|
||||
reviewTime: string | null;
|
||||
reviewContent: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
}
|
||||
|
||||
const model = ref<Model>(createDefaultModel());
|
||||
const submitting = ref(false);
|
||||
|
||||
const memberUserOptions = computed(() => props.memberOptions.filter(item => item.status === 0));
|
||||
|
||||
const reviewConclusionOptions = [
|
||||
{ label: '通过评审', value: 0 as Api.Product.RequirementReviewConclusion },
|
||||
{ label: '不通过评审', value: 1 as Api.Product.RequirementReviewConclusion }
|
||||
];
|
||||
|
||||
const rules = {
|
||||
conclusion: [createRequiredRule('请选择评审结论')],
|
||||
attendees: [createRequiredRule('请选择参会人')]
|
||||
} satisfies Record<string, App.Global.FormRule[]>;
|
||||
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('45vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
function createDefaultModel(): Model {
|
||||
return {
|
||||
conclusion: 0,
|
||||
attendees: [],
|
||||
requirementEstimatedHours: null,
|
||||
reviewTime: dayjs().format('YYYY-MM-DD'),
|
||||
reviewContent: null,
|
||||
attachments: []
|
||||
};
|
||||
}
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const text = html
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.replace(/ /g, '')
|
||||
.trim();
|
||||
|
||||
if (text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return !/<img\b/i.test(html);
|
||||
}
|
||||
|
||||
async function handleSubmit() {
|
||||
await validate();
|
||||
|
||||
if (!props.productId || !props.requirement?.id) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!authStore.userInfo.userId) {
|
||||
window.$message?.warning('未获取到当前登录用户信息');
|
||||
return;
|
||||
}
|
||||
|
||||
if (attachmentUploaderRef.value?.hasUploading) {
|
||||
window.$message?.warning('附件正在上传中,请稍候');
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Api.Product.RequirementReviewSubmitParams = {
|
||||
productId: props.productId,
|
||||
requirementId: props.requirement.id,
|
||||
operatorId: authStore.userInfo.userId,
|
||||
conclusion: model.value.conclusion,
|
||||
reviewContent: isEmptyRichText(model.value.reviewContent) ? null : (model.value.reviewContent ?? null),
|
||||
requirementEstimatedHours: model.value.requirementEstimatedHours,
|
||||
attendees: [...model.value.attendees],
|
||||
attachments: [...model.value.attachments],
|
||||
reviewTime: model.value.reviewTime
|
||||
};
|
||||
|
||||
submitting.value = true;
|
||||
|
||||
const result = await fetchSubmitProductRequirementReview(payload);
|
||||
|
||||
submitting.value = false;
|
||||
|
||||
if (result.error) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([attachmentUploaderRef.value?.commit(), richTextEditorRef.value?.commit()]);
|
||||
|
||||
window.$message?.success('评审提交成功');
|
||||
visible.value = false;
|
||||
emit('submitted');
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
async value => {
|
||||
if (!value) {
|
||||
return;
|
||||
}
|
||||
|
||||
model.value = createDefaultModel();
|
||||
|
||||
await nextTick();
|
||||
attachmentUploaderRef.value?.initSession();
|
||||
richTextEditorRef.value?.initSession();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="评审需求"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:confirm-loading="submitting"
|
||||
@confirm="handleSubmit"
|
||||
>
|
||||
<ElForm ref="formRef" :model="model" :rules="rules" label-position="top" :validate-on-rule-change="false">
|
||||
<div class="requirement-review-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-review-dialog__col-left">
|
||||
<BusinessFormSection title="评审信息">
|
||||
<ElFormItem label="需求名称">
|
||||
<ReadonlyField :value="requirement?.title || '--'" />
|
||||
<!-- <ElInput :model-value="requirement?.title || ''" readonly placeholder="--" />-->
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审结论" prop="conclusion">
|
||||
<ElRadioGroup v-model="model.conclusion">
|
||||
<ElRadio
|
||||
v-for="item in reviewConclusionOptions"
|
||||
:key="item.value"
|
||||
:value="item.value"
|
||||
border
|
||||
style="width: 165px"
|
||||
>
|
||||
{{ item.label }}
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="参会人" prop="attendees">
|
||||
<AttendeeUserPicker
|
||||
v-model="model.attendees"
|
||||
:team-options="memberUserOptions"
|
||||
team-tab-label="产品团队"
|
||||
:show-dept-tab="true"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求预估工时">
|
||||
<ElInputNumber
|
||||
v-model="model.requirementEstimatedHours"
|
||||
class="w-full"
|
||||
:min="0"
|
||||
:step="0.5"
|
||||
:precision="1"
|
||||
placeholder="请输入需求预估工时"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="实际评审日期">
|
||||
<ElDatePicker
|
||||
v-model="model.reviewTime"
|
||||
type="date"
|
||||
value-format="YYYY-MM-DD"
|
||||
placeholder="请选择实际评审日期"
|
||||
class="requirement-review-dialog__date-picker"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-review-dialog__col-right">
|
||||
<BusinessFormSection title="评审内容">
|
||||
<ElFormItem class="requirement-review-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
ref="richTextEditorRef"
|
||||
v-model="model.reviewContent"
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement-review"
|
||||
placeholder="请输入评审内容"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="会议资料">
|
||||
<ElFormItem class="requirement-review-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
directory="requirement-review"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</ElForm>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-review-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__col-left,
|
||||
.requirement-review-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-review-dialog__desc-item,
|
||||
.requirement-review-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-review-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.requirement-review-dialog__date-picker.el-date-editor.el-input) {
|
||||
width: 100%;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,312 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchGetProductRequirementReview } from '@/service/api';
|
||||
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 ReadonlyField from '@/components/custom/readonly-field.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementReviewRecordDialog' });
|
||||
|
||||
interface Props {
|
||||
productId: string;
|
||||
requirement: Api.Product.Requirement | null;
|
||||
memberOptions: Api.Product.ProductMember[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const visible = defineModel<boolean>('visible', {
|
||||
default: false
|
||||
});
|
||||
|
||||
const loading = ref(false);
|
||||
const reviewRecord = ref<Api.Product.RequirementReview | null>(null);
|
||||
const leftColRef = ref<HTMLElement>();
|
||||
const editorHeight = ref<string>('47vh');
|
||||
|
||||
const ATTACHMENT_SECTION_RESERVE_PX = 140;
|
||||
const ATTENDEE_VISIBLE_COUNT = 5;
|
||||
|
||||
useResizeObserver(leftColRef, entries => {
|
||||
const height = entries[0]?.contentRect.height;
|
||||
|
||||
if (height && height > 120) {
|
||||
editorHeight.value = `${Math.max(height - 60 - ATTACHMENT_SECTION_RESERVE_PX, 200)}px`;
|
||||
}
|
||||
});
|
||||
|
||||
const operatorLabelMap = computed(() => {
|
||||
return new Map(props.memberOptions.map(item => [item.userId, item.userNickname]));
|
||||
});
|
||||
|
||||
const conclusionText = computed(() => {
|
||||
if (!reviewRecord.value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return reviewRecord.value.conclusion === 0 ? '通过评审' : '不通过评审';
|
||||
});
|
||||
|
||||
const operatorText = computed(() => {
|
||||
if (!reviewRecord.value?.operatorId) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return operatorLabelMap.value.get(reviewRecord.value.operatorId) || reviewRecord.value.operatorId;
|
||||
});
|
||||
|
||||
const visibleAttendees = computed(() => reviewRecord.value?.attendees?.slice(0, ATTENDEE_VISIBLE_COUNT) ?? []);
|
||||
const overflowAttendees = computed(() => reviewRecord.value?.attendees?.slice(ATTENDEE_VISIBLE_COUNT) ?? []);
|
||||
|
||||
function formatExpectedTime(value?: string | number[] | null): string {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
const [year, month, day] = value;
|
||||
return `${year}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null): string {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
}
|
||||
|
||||
async function loadReviewRecord() {
|
||||
if (!props.productId || !props.requirement?.id) {
|
||||
reviewRecord.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
|
||||
const { error, data } = await fetchGetProductRequirementReview(props.productId, props.requirement.id);
|
||||
|
||||
loading.value = false;
|
||||
|
||||
if (error || !data) {
|
||||
reviewRecord.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
reviewRecord.value = data;
|
||||
}
|
||||
|
||||
watch(
|
||||
() => visible.value,
|
||||
value => {
|
||||
if (value) {
|
||||
loadReviewRecord();
|
||||
} else {
|
||||
reviewRecord.value = null;
|
||||
}
|
||||
}
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<BusinessFormDialog
|
||||
v-model="visible"
|
||||
title="查看评审记录"
|
||||
width="1100px"
|
||||
max-body-height="78vh"
|
||||
:loading="loading"
|
||||
:show-footer="true"
|
||||
>
|
||||
<template #footer="{ close }">
|
||||
<ElButton type="primary" @click="close">关闭</ElButton>
|
||||
</template>
|
||||
|
||||
<ElEmpty v-if="!loading && !reviewRecord" description="暂无评审记录" />
|
||||
|
||||
<div v-else class="requirement-review-record-dialog__grid">
|
||||
<div ref="leftColRef" class="requirement-review-record-dialog__col-left">
|
||||
<BusinessFormSection title="评审信息">
|
||||
<ElForm label-position="top">
|
||||
<ElFormItem label="需求名称">
|
||||
<ReadonlyField :value="requirement?.title || '--'" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审结论">
|
||||
<ReadonlyField :value="conclusionText" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="评审提交人">
|
||||
<ReadonlyField :value="operatorText" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="参会人">
|
||||
<div v-if="reviewRecord?.attendees?.length" class="requirement-review-record-dialog__tags">
|
||||
<ElTag v-for="item in visibleAttendees" :key="item.userId" effect="light">
|
||||
{{ item.nickname }}
|
||||
</ElTag>
|
||||
<ElPopover
|
||||
v-if="overflowAttendees.length"
|
||||
trigger="click"
|
||||
placement="bottom-start"
|
||||
:width="280"
|
||||
popper-class="requirement-review-record-dialog__attendee-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<button type="button" class="requirement-review-record-dialog__tag-more">
|
||||
+{{ overflowAttendees.length }} 更多
|
||||
</button>
|
||||
</template>
|
||||
<div class="requirement-review-record-dialog__attendee-overflow">
|
||||
<div class="requirement-review-record-dialog__attendee-overflow-head">
|
||||
另外
|
||||
<strong>{{ overflowAttendees.length }}</strong>
|
||||
人
|
||||
</div>
|
||||
<div class="requirement-review-record-dialog__attendee-overflow-tags">
|
||||
<ElTag v-for="item in overflowAttendees" :key="item.userId" effect="light">
|
||||
{{ item.nickname }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
<ReadonlyField v-else value="--" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="需求预估工时">
|
||||
<ReadonlyField
|
||||
:value="
|
||||
reviewRecord?.requirementEstimatedHours !== null &&
|
||||
reviewRecord?.requirementEstimatedHours !== undefined &&
|
||||
reviewRecord?.requirementEstimatedHours !== ''
|
||||
? String(reviewRecord.requirementEstimatedHours)
|
||||
: '--'
|
||||
"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="实际评审日期">
|
||||
<ReadonlyField :value="formatExpectedTime(reviewRecord?.reviewTime)" />
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="提交时间">
|
||||
<ReadonlyField :value="formatDateTime(reviewRecord?.createTime)" />
|
||||
</ElFormItem>
|
||||
</ElForm>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
|
||||
<div class="requirement-review-record-dialog__col-right">
|
||||
<BusinessFormSection title="评审内容">
|
||||
<ElFormItem class="requirement-review-record-dialog__desc-item">
|
||||
<BusinessRichTextEditor
|
||||
:model-value="reviewRecord?.reviewContent || ''"
|
||||
disabled
|
||||
:height="editorHeight"
|
||||
upload-directory="requirement-review"
|
||||
placeholder="--"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
|
||||
<BusinessFormSection title="会议资料">
|
||||
<ElFormItem class="requirement-review-record-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
:model-value="reviewRecord?.attachments || []"
|
||||
disabled
|
||||
directory="requirement-review"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</BusinessFormSection>
|
||||
</div>
|
||||
</div>
|
||||
</BusinessFormDialog>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.requirement-review-record-dialog__grid {
|
||||
display: grid;
|
||||
grid-template-columns: 360px 1fr;
|
||||
gap: 24px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__col-left,
|
||||
.requirement-review-record-dialog__col-right {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__col-right {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__desc-item,
|
||||
.requirement-review-record-dialog__attachment-item {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tag-more {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
height: 24px;
|
||||
padding: 0 10px;
|
||||
border: 1px dashed var(--el-border-color-darker);
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
font-size: 11.5px;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__tag-more:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-head {
|
||||
color: var(--el-text-color-regular);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-head strong {
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.requirement-review-record-dialog__attendee-overflow-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
max-height: 180px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (width <= 1024px) {
|
||||
.requirement-review-record-dialog__grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -124,7 +124,7 @@ const fields = computed(() => [
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="3" @search="emit('search')" @reset="emit('reset')" />
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @search="emit('search')" @reset="emit('reset')" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { fetchSplitRequirement } from '@/service/api';
|
||||
import { useForm, useFormRules } from '@/hooks/common/form';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
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 DictSelect from '@/components/custom/dict-select.vue';
|
||||
import MemberSelectOption from './member-select-option.vue';
|
||||
|
||||
defineOptions({ name: 'RequirementSplitDialog' });
|
||||
@@ -35,18 +34,10 @@ const visible = defineModel<boolean>('visible', {
|
||||
|
||||
const { formRef, validate } = useForm();
|
||||
const { createRequiredRule } = useFormRules();
|
||||
const { enabledDictData: priorityDictData } = useDict(() => props.priorityDictCode);
|
||||
|
||||
const attachmentUploaderRef = ref<InstanceType<typeof BusinessAttachmentUploader> | null>(null);
|
||||
const richTextEditorRef = ref<InstanceType<typeof BusinessRichTextEditor> | null>(null);
|
||||
|
||||
const priorityOptions = computed(() => {
|
||||
return priorityDictData.value.map(item => ({
|
||||
label: item.label,
|
||||
value: Number(item.value)
|
||||
}));
|
||||
});
|
||||
|
||||
const reviewRequiredOptions = [
|
||||
{ label: '不需要', value: 0 },
|
||||
{ label: '需要', value: 1 }
|
||||
@@ -78,7 +69,7 @@ interface Model {
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
reviewRequired: number;
|
||||
category: string;
|
||||
priority: number | null;
|
||||
priority: string | null;
|
||||
expectedTime: string | null;
|
||||
currentHandlerUserId: string;
|
||||
sort: number;
|
||||
@@ -135,7 +126,7 @@ function createDefaultModel(): Model {
|
||||
attachments: [],
|
||||
reviewRequired: 0,
|
||||
category: '',
|
||||
priority: 1,
|
||||
priority: '3',
|
||||
expectedTime: null,
|
||||
currentHandlerUserId: '',
|
||||
sort: 0
|
||||
@@ -265,9 +256,12 @@ watch(
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<ElSelect v-model="model.priority" class="w-full" filterable placeholder="请选择优先级">
|
||||
<ElOption v-for="item in priorityOptions" :key="item.value" :label="item.label" :value="item.value" />
|
||||
</ElSelect>
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="priorityDictCode"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="currentHandlerUserId">
|
||||
|
||||
@@ -16,35 +16,38 @@ import IconMingcuteForward2Line from '~icons/mingcute/forward-2-line';
|
||||
export type RequirementStatusActionCode =
|
||||
| 'claim_to_review'
|
||||
| 'claim_to_dispatch'
|
||||
| 'reject'
|
||||
| 'to_dispatch'
|
||||
| 'pass_review'
|
||||
| 'reject_review'
|
||||
| 'dispatch'
|
||||
| 'cancel'
|
||||
| 'accept'
|
||||
| 'close';
|
||||
| 'close'
|
||||
| 'reject';
|
||||
|
||||
export const requirementStatusRecord: Record<Api.Product.RequirementStatusCode, string> = {
|
||||
pending_confirm: '待确认',
|
||||
pending_claim: '待认领',
|
||||
pending_review: '待评审',
|
||||
pending_dispatch: '待分流',
|
||||
pending_dispatch: '待指派',
|
||||
reviewed: '已评审',
|
||||
review_rejected: '评审未过',
|
||||
implementing: '实施中',
|
||||
accepted: '已验收',
|
||||
closed: '已关闭',
|
||||
rejected: '已拒绝',
|
||||
cancelled: '已取消'
|
||||
};
|
||||
|
||||
export const requirementStatusOptions = transformRecordToOption(requirementStatusRecord);
|
||||
transformRecordToOption(requirementStatusRecord);
|
||||
|
||||
export const requirementStatusActionRecord: Record<RequirementStatusActionCode, string> = {
|
||||
claim_to_review: '认领',
|
||||
claim_to_dispatch: '认领',
|
||||
reject: '拒绝',
|
||||
to_dispatch: '评审通过',
|
||||
dispatch: '分流',
|
||||
pass_review: '评审通过',
|
||||
reject_review: '评审不通过',
|
||||
dispatch: '指派',
|
||||
cancel: '取消',
|
||||
accept: '验收通过',
|
||||
close: '关闭'
|
||||
close: '关闭',
|
||||
reject: '拒绝'
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -58,7 +61,8 @@ export const ACTION_ICON_MAP: Record<string, object> = {
|
||||
forward: markRaw(IconMingcuteForward2Line),
|
||||
claim_to_review: markRaw(IconMaterialSymbolsDescriptionOutline),
|
||||
claim_to_dispatch: markRaw(IconMdiCheckOutline),
|
||||
to_dispatch: markRaw(IconMdiGlasses),
|
||||
pass_review: markRaw(IconMdiGlasses),
|
||||
reject_review: markRaw(IconMdiGlasses),
|
||||
dispatch: markRaw(IconMdiShareVariant),
|
||||
accept: markRaw(IconMdiCheckCircleOutline),
|
||||
reject: markRaw(IconMdiClose),
|
||||
@@ -77,7 +81,8 @@ export const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> =
|
||||
edit: 'primary',
|
||||
claim_to_review: 'primary',
|
||||
claim_to_dispatch: 'primary',
|
||||
to_dispatch: 'primary',
|
||||
pass_review: 'primary',
|
||||
reject_review: 'danger',
|
||||
dispatch: 'primary',
|
||||
accept: 'primary',
|
||||
reject: 'danger',
|
||||
@@ -85,16 +90,13 @@ export const ACTION_TYPE_MAP: Record<string, 'primary' | 'success' | 'danger'> =
|
||||
close: 'danger',
|
||||
delete: 'danger'
|
||||
};
|
||||
|
||||
export function getRequirementStatusLabel(status: Api.Product.RequirementStatusCode) {
|
||||
return requirementStatusRecord[status];
|
||||
}
|
||||
|
||||
export function getRequirementStatusTagType(status: Api.Product.RequirementStatusCode): UI.ThemeColor {
|
||||
const statusTagTypeMap: Record<Api.Product.RequirementStatusCode, UI.ThemeColor> = {
|
||||
pending_confirm: 'info',
|
||||
pending_review: 'warning',
|
||||
pending_claim: 'info',
|
||||
pending_review: 'info',
|
||||
pending_dispatch: 'primary',
|
||||
reviewed: 'success',
|
||||
review_rejected: 'danger',
|
||||
implementing: 'primary',
|
||||
accepted: 'success',
|
||||
closed: 'danger',
|
||||
@@ -104,28 +106,6 @@ export function getRequirementStatusTagType(status: Api.Product.RequirementStatu
|
||||
|
||||
return statusTagTypeMap[status];
|
||||
}
|
||||
|
||||
export function getRequirementActionLabel(actionCode: RequirementStatusActionCode) {
|
||||
return requirementStatusActionRecord[actionCode];
|
||||
}
|
||||
|
||||
export function getRequirementActionTagType(
|
||||
actionCode: RequirementStatusActionCode
|
||||
): 'primary' | 'success' | 'warning' | 'danger' | 'info' {
|
||||
const actionTagTypeMap: Record<RequirementStatusActionCode, 'primary' | 'success' | 'warning' | 'danger' | 'info'> = {
|
||||
claim_to_review: 'primary',
|
||||
claim_to_dispatch: 'primary',
|
||||
reject: 'danger',
|
||||
to_dispatch: 'success',
|
||||
dispatch: 'primary',
|
||||
cancel: 'danger',
|
||||
accept: 'success',
|
||||
close: 'info'
|
||||
};
|
||||
|
||||
return actionTagTypeMap[actionCode];
|
||||
}
|
||||
|
||||
export function isRequirementActionTerminal(actionCode: RequirementStatusActionCode) {
|
||||
const terminalActions: RequirementStatusActionCode[] = ['reject', 'cancel', 'close'];
|
||||
return terminalActions.includes(actionCode);
|
||||
|
||||
@@ -17,7 +17,6 @@ import {
|
||||
fetchUpdateProductMember,
|
||||
fetchUpdateProductSettingBaseInfo
|
||||
} from '@/service/api';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useThemeStore } from '@/store/modules/theme';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
@@ -46,7 +45,6 @@ import {
|
||||
|
||||
defineOptions({ name: 'ProductSetting' });
|
||||
|
||||
const authStore = useAuthStore();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const themeStore = useThemeStore();
|
||||
const { routerPush } = useRouterPush();
|
||||
@@ -97,9 +95,7 @@ const baseInfo = computed(() => settings.value?.baseInfo || null);
|
||||
const lifecycle = computed(() => settings.value?.lifecycle || null);
|
||||
const canManageTeam = computed(() =>
|
||||
canManageProductTeam({
|
||||
buttonCodes: objectContextStore.buttonCodes,
|
||||
loginUserId: authStore.userInfo.userId,
|
||||
currentManagerUserId: currentManager.value?.userId
|
||||
buttonCodes: objectContextStore.buttonCodes
|
||||
})
|
||||
);
|
||||
const visibleSectionKeys = computed(() =>
|
||||
|
||||
@@ -6,8 +6,6 @@ export interface ProductManagerMemberLike {
|
||||
|
||||
interface ProductTeamManageContext {
|
||||
buttonCodes: readonly string[];
|
||||
loginUserId: string | null | undefined;
|
||||
currentManagerUserId: string | null | undefined;
|
||||
}
|
||||
|
||||
interface ProductLifecycleStatusSummary {
|
||||
@@ -203,13 +201,5 @@ export function getProductLifecycleActionCardMeta(actionCode: Api.Product.Produc
|
||||
}
|
||||
|
||||
export function canManageProductTeam(context: ProductTeamManageContext) {
|
||||
const hasUpdateAuth = context.buttonCodes.includes('project:product:update');
|
||||
const loginUserId = String(context.loginUserId || '');
|
||||
const currentManagerUserId = String(context.currentManagerUserId || '');
|
||||
|
||||
if (!hasUpdateAuth || !loginUserId || !currentManagerUserId) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return loginUserId === currentManagerUserId;
|
||||
return context.buttonCodes.includes('project:product:update');
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, onMounted, reactive, ref } from 'vue';
|
||||
import { ElButton, ElTag } from 'element-plus';
|
||||
import { ElButton, ElProgress, ElTag } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { OBJECT_CONTEXT_QUERY_KEY } from '@/constants/object-context';
|
||||
@@ -22,7 +22,7 @@ const PROJECT_ENTRY_ROUTE_PATH = '/project/list';
|
||||
function getInitSearchParams(): Api.Project.ProjectSearchParams {
|
||||
return {
|
||||
pageNo: 1,
|
||||
pageSize: 10,
|
||||
pageSize: 20,
|
||||
keyword: '',
|
||||
directionCode: undefined,
|
||||
projectType: undefined,
|
||||
@@ -55,12 +55,12 @@ function sortManagerOptions(list: Api.SystemManage.UserSimple[]) {
|
||||
return list.slice().sort((left, right) => left.nickname.localeCompare(right.nickname, 'zh-CN'));
|
||||
}
|
||||
|
||||
function formatDateTime(value?: string | null) {
|
||||
function formatDate(value?: string | null) {
|
||||
if (!value) {
|
||||
return '--';
|
||||
}
|
||||
|
||||
return dayjs(value).format('YYYY-MM-DD HH:mm:ss');
|
||||
return dayjs(value).format('YYYY-MM-DD');
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitSearchParams());
|
||||
@@ -170,9 +170,20 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
|
||||
{
|
||||
prop: 'progressRate',
|
||||
label: '进度',
|
||||
width: 100,
|
||||
align: 'center',
|
||||
formatter: () => '--'
|
||||
width: 160,
|
||||
formatter: row => {
|
||||
const percentage = row.progressRate ?? 0;
|
||||
return (
|
||||
<div style="padding: 0 8px;">
|
||||
<ElProgress
|
||||
percentage={percentage}
|
||||
status={percentage >= 100 ? 'success' : undefined}
|
||||
stroke-width={18}
|
||||
text-inside
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
prop: 'statusCode',
|
||||
@@ -188,7 +199,7 @@ const { columns, columnChecks, data, loading, getDataByPage, mobilePagination }
|
||||
label: '最近更新',
|
||||
width: 170,
|
||||
align: 'center',
|
||||
formatter: row => formatDateTime(row.updateTime)
|
||||
formatter: row => formatDate(row.updateTime)
|
||||
}
|
||||
],
|
||||
immediate: false
|
||||
|
||||
@@ -6,7 +6,7 @@ import { RDMS_OBJECT_DIRECTION_DICT_CODE, RDMS_PROJECT_TYPE_DICT_CODE } from '@/
|
||||
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 BusinessUserPicker from '@/components/custom/business-user-picker.vue';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectCreateBaseForm' });
|
||||
@@ -204,6 +204,19 @@ defineExpose({ validate: runValidate });
|
||||
<ElInput v-model="model.projectCode" clearable placeholder="不填则由后端自动生成" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="所属产品" prop="productId">
|
||||
<ElSelect
|
||||
v-model="model.productId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择所属产品(可选),选择后将锁定项目方向"
|
||||
@change="onProductChange"
|
||||
>
|
||||
<ElOption v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="项目方向" prop="directionCode">
|
||||
<DictSelect
|
||||
@@ -232,24 +245,12 @@ defineExpose({ validate: runValidate });
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="所属产品" prop="productId">
|
||||
<ElSelect
|
||||
v-model="model.productId"
|
||||
clearable
|
||||
filterable
|
||||
placeholder="选择所属产品(可选),选择后将锁定项目方向"
|
||||
@change="onProductChange"
|
||||
>
|
||||
<ElOption v-for="item in productOptions" :key="item.id" :label="item.name" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="项目经理" prop="managerUserId">
|
||||
<BusinessUserSelect
|
||||
<BusinessUserPicker
|
||||
v-model="model.managerUserId"
|
||||
:options="managerUserOptions"
|
||||
:user-options="managerUserOptions"
|
||||
title="选择项目经理"
|
||||
placeholder="请选择项目经理"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
@@ -439,6 +439,21 @@ watch(visible, async value => {
|
||||
<ElInput v-model="editModel.projectName" clearable maxlength="128" placeholder="请输入项目名称" />
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="所属产品">
|
||||
<ElInput
|
||||
:model-value="
|
||||
productOptions.find(p => p.id === editModel.productId)?.name ||
|
||||
props.rowData?.productName ||
|
||||
editModel.productId ||
|
||||
'未关联产品'
|
||||
"
|
||||
readonly
|
||||
class="project-operate-dialog__readonly-input"
|
||||
placeholder="未关联产品"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="项目方向" prop="directionCode">
|
||||
<DictSelect
|
||||
@@ -467,21 +482,6 @@ watch(visible, async value => {
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="所属产品">
|
||||
<ElInput
|
||||
:model-value="
|
||||
productOptions.find(p => p.id === editModel.productId)?.name ||
|
||||
props.rowData?.productName ||
|
||||
editModel.productId ||
|
||||
'未关联产品'
|
||||
"
|
||||
readonly
|
||||
class="project-operate-dialog__readonly-input"
|
||||
placeholder="未关联产品"
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem>
|
||||
<template #label>
|
||||
|
||||
@@ -63,7 +63,7 @@ function search() {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
|
||||
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="reset" @search="search" />
|
||||
</template>
|
||||
|
||||
<style scoped></style>
|
||||
|
||||
@@ -0,0 +1,340 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, ref, watch } from 'vue';
|
||||
import { useElementSize } from '@vueuse/core';
|
||||
import type { InputInstance, TreeInstance } from 'element-plus';
|
||||
import { ArrowDown, Close, Search } from '@element-plus/icons-vue';
|
||||
|
||||
defineOptions({ name: 'RequirementTreePicker' });
|
||||
|
||||
export interface RequirementTreePickerNode {
|
||||
id: string;
|
||||
title: string;
|
||||
statusCode?: string;
|
||||
children?: RequirementTreePickerNode[];
|
||||
}
|
||||
|
||||
interface Props {
|
||||
data: RequirementTreePickerNode[];
|
||||
/** 编辑模式回显:modelValue 在 data 中找不到(已删除/不在当前可见范围)时显示这个文本 */
|
||||
selectedName?: string | null;
|
||||
placeholder?: string;
|
||||
disabled?: boolean;
|
||||
clearable?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
selectedName: null,
|
||||
placeholder: '请选择',
|
||||
disabled: false,
|
||||
clearable: true
|
||||
});
|
||||
|
||||
const modelValue = defineModel<string | null>({ default: null });
|
||||
|
||||
const visible = ref(false);
|
||||
const searchText = ref('');
|
||||
const treeRef = ref<TreeInstance | null>(null);
|
||||
const triggerRef = ref<HTMLElement | null>(null);
|
||||
const searchInputRef = ref<InputInstance | null>(null);
|
||||
/** 选中时正在播放弹性动画的节点 id;让 ✓ scale 完整跑完再关闭弹层 */
|
||||
const animatingNodeId = ref<string | null>(null);
|
||||
|
||||
// popover 宽度跟随 trigger 实时变化,确保展开后下拉与上方输入框对齐
|
||||
const { width: triggerWidth } = useElementSize(triggerRef);
|
||||
const popoverWidth = computed(() => triggerWidth.value || 300);
|
||||
|
||||
const treeProps = { value: 'id', label: 'title', children: 'children' };
|
||||
|
||||
function findNodeTitle(tree: RequirementTreePickerNode[], id: string): string | null {
|
||||
for (const node of tree) {
|
||||
if (node.id === id) return node.title;
|
||||
if (node.children) {
|
||||
const found = findNodeTitle(node.children, id);
|
||||
if (found) return found;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
const displayLabel = computed(() => {
|
||||
if (!modelValue.value) return '';
|
||||
const fromTree = findNodeTitle(props.data, modelValue.value);
|
||||
if (fromTree) return fromTree;
|
||||
return props.selectedName || '';
|
||||
});
|
||||
|
||||
const showClear = computed(() => props.clearable && Boolean(modelValue.value) && !props.disabled);
|
||||
|
||||
watch(searchText, value => {
|
||||
treeRef.value?.filter(value);
|
||||
});
|
||||
|
||||
function filterNode(value: string, data: Record<string, unknown>) {
|
||||
if (!value) return true;
|
||||
const title = typeof data.title === 'string' ? data.title : '';
|
||||
return title.includes(value);
|
||||
}
|
||||
|
||||
function handleSelect(node: RequirementTreePickerNode) {
|
||||
// 先播 200ms 弹性动画再关弹层;中途又点别的节点会让动画 id 转到新节点上
|
||||
animatingNodeId.value = node.id;
|
||||
window.setTimeout(() => {
|
||||
modelValue.value = node.id;
|
||||
visible.value = false;
|
||||
animatingNodeId.value = null;
|
||||
}, 200);
|
||||
}
|
||||
|
||||
function handleClear() {
|
||||
modelValue.value = null;
|
||||
visible.value = false;
|
||||
}
|
||||
|
||||
watch(visible, async value => {
|
||||
if (value) {
|
||||
// 弹层打开后下一帧自动 focus 到搜索框,避免用户再点一下才能输入
|
||||
await nextTick();
|
||||
searchInputRef.value?.focus();
|
||||
} else {
|
||||
searchText.value = '';
|
||||
animatingNodeId.value = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElPopover
|
||||
v-model:visible="visible"
|
||||
placement="bottom-start"
|
||||
trigger="click"
|
||||
:width="popoverWidth"
|
||||
popper-class="requirement-tree-picker__popper"
|
||||
:disabled="props.disabled"
|
||||
:show-arrow="false"
|
||||
:hide-after="0"
|
||||
>
|
||||
<template #reference>
|
||||
<div ref="triggerRef" class="requirement-tree-picker__trigger" :class="{ 'is-disabled': props.disabled }">
|
||||
<ElInput
|
||||
:model-value="displayLabel"
|
||||
:placeholder="props.placeholder"
|
||||
:disabled="props.disabled"
|
||||
readonly
|
||||
class="requirement-tree-picker__input"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon v-if="showClear" class="requirement-tree-picker__suffix-icon is-clear" @click.stop="handleClear">
|
||||
<Close />
|
||||
</ElIcon>
|
||||
<ElIcon v-else class="requirement-tree-picker__suffix-icon" :class="{ 'is-open': visible }">
|
||||
<ArrowDown />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<div class="requirement-tree-picker__panel">
|
||||
<ElInput
|
||||
ref="searchInputRef"
|
||||
v-model="searchText"
|
||||
placeholder="搜索需求"
|
||||
clearable
|
||||
:prefix-icon="Search"
|
||||
size="small"
|
||||
class="requirement-tree-picker__search"
|
||||
/>
|
||||
<div class="requirement-tree-picker__tree-wrap">
|
||||
<ElTree
|
||||
ref="treeRef"
|
||||
:data="props.data"
|
||||
:props="treeProps"
|
||||
node-key="id"
|
||||
:expand-on-click-node="true"
|
||||
:default-expand-all="!searchText"
|
||||
:filter-node-method="filterNode"
|
||||
empty-text="暂无数据"
|
||||
class="requirement-tree-picker__tree"
|
||||
>
|
||||
<template #default="{ data: nodeData }">
|
||||
<div
|
||||
class="requirement-tree-picker__node"
|
||||
@dblclick.stop="handleSelect(nodeData as RequirementTreePickerNode)"
|
||||
>
|
||||
<span
|
||||
class="requirement-tree-picker__node-label"
|
||||
:class="{ 'is-active': nodeData.id === modelValue }"
|
||||
:title="nodeData.title"
|
||||
>
|
||||
{{ nodeData.title }}
|
||||
</span>
|
||||
<ElTooltip
|
||||
:content="nodeData.id === modelValue ? '当前已选' : '点击或双击节点选择'"
|
||||
placement="left"
|
||||
:show-after="300"
|
||||
>
|
||||
<span
|
||||
class="requirement-tree-picker__select-icon"
|
||||
:class="{
|
||||
'is-active': nodeData.id === modelValue,
|
||||
'is-animating': nodeData.id === animatingNodeId
|
||||
}"
|
||||
@click.stop="handleSelect(nodeData as RequirementTreePickerNode)"
|
||||
>
|
||||
<svg
|
||||
class="requirement-tree-picker__check-svg"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="3.5"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<polyline points="5 12 10 17 19 7" />
|
||||
</svg>
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</template>
|
||||
</ElTree>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.requirement-tree-picker__trigger {
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__input :deep(.el-input__inner) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__trigger.is-disabled .requirement-tree-picker__input :deep(.el-input__inner) {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__suffix-icon {
|
||||
transition: transform 0.2s;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__suffix-icon.is-open {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.requirement-tree-picker__suffix-icon.is-clear {
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
}
|
||||
|
||||
.requirement-tree-picker__panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__search {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__tree-wrap {
|
||||
flex: 1 1 auto;
|
||||
overflow: auto;
|
||||
max-height: 320px;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__node {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
width: 100%;
|
||||
padding-right: 8px;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__node-label {
|
||||
flex: 1 1 auto;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__node-label.is-active {
|
||||
color: var(--el-color-primary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__select-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-left: 8px;
|
||||
border: 1.5px solid var(--el-border-color);
|
||||
border-radius: 4px;
|
||||
background: #fff;
|
||||
color: var(--el-text-color-placeholder);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.15s ease,
|
||||
background 0.15s ease,
|
||||
color 0.15s ease,
|
||||
transform 0.15s ease;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__check-svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__select-icon:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
transform: scale(1.08);
|
||||
}
|
||||
|
||||
.requirement-tree-picker__select-icon.is-active {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.requirement-tree-picker__select-icon.is-active:hover {
|
||||
background: var(--el-color-primary-light-3);
|
||||
border-color: var(--el-color-primary-light-3);
|
||||
}
|
||||
|
||||
.requirement-tree-picker__select-icon.is-animating {
|
||||
border-color: var(--el-color-primary);
|
||||
background: var(--el-color-primary);
|
||||
color: #fff;
|
||||
animation: requirement-tree-picker-select-pop 200ms cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
}
|
||||
|
||||
@keyframes requirement-tree-picker-select-pop {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: scale(1.28);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="scss">
|
||||
.requirement-tree-picker__popper.el-popover {
|
||||
padding: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,100 @@
|
||||
import { ref, watch } from 'vue';
|
||||
import { fetchGetProjectRequirementPage } from '@/service/api/project';
|
||||
|
||||
export interface ProjectRequirementTreeNode {
|
||||
id: string;
|
||||
title: string;
|
||||
statusCode: string;
|
||||
children?: ProjectRequirementTreeNode[];
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取当前项目的项目需求并按 parentId 构造为树,供 ElTreeSelect 使用。
|
||||
*
|
||||
* 设计:
|
||||
* - 走 `/page` 接口拿扁平列表,前端自己按 `parentId` 关系构树。
|
||||
* 不用后端 `/tree` 接口是因为它有未明确的过滤条件(实测对当前项目返回空)。
|
||||
* - 不在前端做"终态过滤"——前端硬编码的终态集合与后端实际状态不一致,
|
||||
* 全显示,由用户挑选;选到后端拒绝的状态时会通过 1_008_003_025 等错误码反馈。
|
||||
* - 不在挂载时自动拉取——由调用方在"弹层打开/真正用到时"显式 reload(),
|
||||
* 避免页面挂载后到打开弹层期间数据陈旧。
|
||||
*/
|
||||
export function useProjectRequirementOptions(projectId: () => string | null) {
|
||||
const treeData = ref<ProjectRequirementTreeNode[]>([]);
|
||||
const loading = ref(false);
|
||||
|
||||
async function reload() {
|
||||
const id = projectId();
|
||||
if (!id) {
|
||||
treeData.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
loading.value = true;
|
||||
try {
|
||||
const { data, error } = await fetchGetProjectRequirementPage({
|
||||
projectId: id,
|
||||
pageNo: 1,
|
||||
pageSize: 100
|
||||
});
|
||||
|
||||
if (error || !data?.list) {
|
||||
treeData.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
treeData.value = buildTree(data.list);
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 将扁平 requirement 列表按 parentId 关系构造为树。
|
||||
* parentId === '0' 视为根节点(与后端约定,见 `ProjectRequirement.parentId` 注释)。
|
||||
* parentId 不在当前 list 中的节点(孤儿)也作为根节点展示,避免被"吞掉"。
|
||||
*/
|
||||
function buildTree(list: Api.Project.ProjectRequirement[]): ProjectRequirementTreeNode[] {
|
||||
const nodeMap = new Map<string, ProjectRequirementTreeNode>();
|
||||
list.forEach(item => {
|
||||
nodeMap.set(item.id, {
|
||||
id: item.id,
|
||||
title: item.title,
|
||||
statusCode: item.statusCode,
|
||||
children: []
|
||||
});
|
||||
});
|
||||
|
||||
const roots: ProjectRequirementTreeNode[] = [];
|
||||
list.forEach(item => {
|
||||
const node = nodeMap.get(item.id)!;
|
||||
const parent = item.parentId !== '0' ? nodeMap.get(item.parentId) : null;
|
||||
if (parent) {
|
||||
parent.children!.push(node);
|
||||
} else {
|
||||
roots.push(node);
|
||||
}
|
||||
});
|
||||
|
||||
// 清掉空的 children 数组,避免 ElTreeSelect 显示"叶子节点带展开箭头"
|
||||
function pruneEmptyChildren(nodes: ProjectRequirementTreeNode[]) {
|
||||
nodes.forEach(node => {
|
||||
if (node.children && node.children.length === 0) {
|
||||
delete node.children;
|
||||
} else if (node.children) {
|
||||
pruneEmptyChildren(node.children);
|
||||
}
|
||||
});
|
||||
}
|
||||
pruneEmptyChildren(roots);
|
||||
|
||||
return roots;
|
||||
}
|
||||
|
||||
// projectId 变化时清空旧数据(避免跨项目切换时短暂闪现上一项目的需求)
|
||||
watch(projectId, () => {
|
||||
treeData.value = [];
|
||||
});
|
||||
|
||||
return { treeData, loading, reload };
|
||||
}
|
||||
@@ -36,7 +36,7 @@ const STATUS_ACTION_ICON_MAP: Record<string, object> = {
|
||||
cancel: markRaw(IconMdiCloseCircleOutline)
|
||||
};
|
||||
|
||||
// 状态推进按钮 type 映射(对齐执行 execution-list-panel.vue 同源语义):
|
||||
// 状态推进按钮 type 映射(对齐执行 execution-section.vue 同源语义):
|
||||
// cancel 破坏性=红,pause 中断=橙,complete 完结=绿,resume 主动作=蓝
|
||||
const STATUS_ACTION_TYPE_MAP: Record<string, TaskAction['type']> = {
|
||||
cancel: 'danger',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { type Ref, ref, watch } from 'vue';
|
||||
import { fetchGetProjectTaskBoardPage } from '@/service/api/project';
|
||||
import type { ServiceRequestResult } from '@/service/api/shared';
|
||||
|
||||
export interface BoardColumnState {
|
||||
statusCode: string;
|
||||
@@ -15,16 +15,24 @@ export interface BoardColumnState {
|
||||
hasMore: boolean;
|
||||
}
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type BoardBaseParams = Pick<
|
||||
Api.Project.ProjectTaskSearchParams,
|
||||
'keyword' | 'parentTaskId' | 'ownerId' | 'updateTime'
|
||||
'keyword' | 'parentTaskId' | 'ownerId' | 'priority' | 'updateTime'
|
||||
>;
|
||||
|
||||
const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
/** 看板 fetcher 入参(每列的分页与 statusCode 由 composable 内部带入) */
|
||||
export interface BoardFetcherParams {
|
||||
pageNo: number;
|
||||
pageSize: number;
|
||||
/** 首屏不传 → 后端返全部列;列加载更多传 [statusCode] → 仅返该列 */
|
||||
statusCode?: string[];
|
||||
}
|
||||
|
||||
export interface UseTaskBoardColumnsOptions {
|
||||
projectId: Ref<string>;
|
||||
executionId: Ref<string>;
|
||||
/** 是否具备加载条件(projectId / executionId 等校验由调用方做完);false 时清空列表 */
|
||||
canLoad: Ref<boolean>;
|
||||
/**
|
||||
* 刷新触发器:workspace 的 statusBoard ref。
|
||||
*
|
||||
@@ -32,7 +40,23 @@ export interface UseTaskBoardColumnsOptions {
|
||||
* 触发本 composable 重新拉看板首屏。**composable 不读它的内容**,列结构以 board-page 响应为准。
|
||||
*/
|
||||
refreshSignal: Ref<unknown>;
|
||||
baseParams: () => BoardBaseParams;
|
||||
/**
|
||||
* 调用方提供的 fetcher,屏蔽"单执行 / 跨执行"两套接口的差异:
|
||||
* - 单执行视角 → 调 fetchGetProjectTaskBoardPage(projectId, executionId, { ...baseParams, ...params })
|
||||
* - 跨执行视角 → 调 fetchGetProjectTaskBoardPageCross(projectId, { ...baseParamsCross, ...params })
|
||||
*/
|
||||
fetcher: (params: BoardFetcherParams) => Promise<
|
||||
ServiceRequestResult<{
|
||||
items: Array<{
|
||||
statusCode: string;
|
||||
statusName: string;
|
||||
sort: number;
|
||||
terminal?: boolean;
|
||||
list: Api.Project.ProjectTask[];
|
||||
total: number;
|
||||
}>;
|
||||
}>
|
||||
>;
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
@@ -40,26 +64,20 @@ export interface UseTaskBoardColumnsOptions {
|
||||
* 看板按状态分列、每列独立分页(无限下拉)。
|
||||
*
|
||||
* 节奏:
|
||||
* - 进入看板 / 任何刷新事件:1 次 board-page(不带 statusCode)→ 拿到所有列骨架 + 各列首页 + 各列总数。
|
||||
* - 用户滚某列到底:1 次 board-page(`statusCode=[X]`, `pageNo=N+1`)→ 取 `items[0].list` 追加到本列。
|
||||
*
|
||||
* 不消费 searchParams.statusCode —— 每列总是查自己的 statusCode;顶部搜索栏的状态过滤在看板模式下天然失效。
|
||||
* - 进入看板 / 任何刷新事件:1 次 fetcher(不带 statusCode)→ 拿到所有列骨架 + 各列首页 + 各列总数。
|
||||
* - 用户滚某列到底:1 次 fetcher(`statusCode=[X]`, `pageNo=N+1`)→ 取 `items[0].list` 追加到本列。
|
||||
*/
|
||||
export function useTaskBoardColumns(options: UseTaskBoardColumnsOptions) {
|
||||
const pageSize = options.pageSize ?? DEFAULT_PAGE_SIZE;
|
||||
const columns = ref<BoardColumnState[]>([]);
|
||||
|
||||
async function refresh() {
|
||||
if (!options.projectId.value || !options.executionId.value) {
|
||||
if (!options.canLoad.value) {
|
||||
columns.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, {
|
||||
...options.baseParams(),
|
||||
pageNo: 1,
|
||||
pageSize
|
||||
});
|
||||
const result = await options.fetcher({ pageNo: 1, pageSize });
|
||||
|
||||
if (result.error || !result.data) {
|
||||
columns.value = [];
|
||||
@@ -81,18 +99,17 @@ export function useTaskBoardColumns(options: UseTaskBoardColumnsOptions) {
|
||||
}
|
||||
|
||||
async function loadMore(statusCode: string) {
|
||||
if (!options.projectId.value || !options.executionId.value) return;
|
||||
if (!options.canLoad.value) return;
|
||||
|
||||
const col = columns.value.find(item => item.statusCode === statusCode);
|
||||
if (!col || col.loading || !col.hasMore) return;
|
||||
|
||||
col.loading = true;
|
||||
const nextPage = col.pageNo + 1;
|
||||
const result = await fetchGetProjectTaskBoardPage(options.projectId.value, options.executionId.value, {
|
||||
...options.baseParams(),
|
||||
statusCode: [statusCode],
|
||||
const result = await options.fetcher({
|
||||
pageNo: nextPage,
|
||||
pageSize
|
||||
pageSize,
|
||||
statusCode: [statusCode]
|
||||
});
|
||||
|
||||
if (result.error || !result.data) {
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { taskStatusFallbackNameMap } from '../shared';
|
||||
|
||||
type ProjectTask = Api.Project.ProjectTask;
|
||||
type TaskActionCode = Api.Project.ProjectTaskActionCode;
|
||||
type TaskStatusCode = Api.Project.ProjectTaskStatusCode;
|
||||
type TaskAction = Api.Project.LifecycleAction<TaskActionCode>;
|
||||
|
||||
/**
|
||||
* 任务 actionCode → 目标 statusCode 映射。
|
||||
* 看板拖拽用此映射把"拖到哪一列"反查出"应该走哪个动作",再去 task.availableActions 里找匹配项。
|
||||
* auto_start 是后端在填工时自动触发,前端不暴露(useTaskActions 也做了同样过滤)。
|
||||
*/
|
||||
export const TASK_ACTION_TO_STATUS_CODE: Record<TaskActionCode, TaskStatusCode> = {
|
||||
auto_start: 'active',
|
||||
pause: 'paused',
|
||||
resume: 'active',
|
||||
complete: 'completed',
|
||||
cancel: 'cancelled'
|
||||
};
|
||||
|
||||
export type DragResolution = { ok: true; action: TaskAction } | { ok: false; reason: string | null };
|
||||
|
||||
/**
|
||||
* 判定"把 task 拖到 targetStatusCode 列"是否合法,合法时返回应触发的 action。
|
||||
*
|
||||
* - 同列(statusCode 相等):静默拒绝(看板不支持同列手动排序)
|
||||
* - 不在 task.availableActions 内的目标列:拒绝,带文案
|
||||
* - complete 动作下进度 < 100:拒绝,带文案(复刻按钮的兜底)
|
||||
* - auto_start:不走拖拽通道
|
||||
*/
|
||||
export function resolveTaskDragAction(task: ProjectTask, targetStatusCode: string): DragResolution {
|
||||
if (task.statusCode === targetStatusCode) {
|
||||
return { ok: false, reason: null };
|
||||
}
|
||||
|
||||
const candidate = task.availableActions.find(
|
||||
action => action.actionCode !== 'auto_start' && TASK_ACTION_TO_STATUS_CODE[action.actionCode] === targetStatusCode
|
||||
);
|
||||
|
||||
if (!candidate) {
|
||||
const targetName = taskStatusFallbackNameMap[targetStatusCode as TaskStatusCode] || targetStatusCode;
|
||||
return { ok: false, reason: `当前任务不能直接变更为「${targetName}」` };
|
||||
}
|
||||
|
||||
if (candidate.actionCode === 'complete' && task.progressRate < 100) {
|
||||
return { ok: false, reason: '完成任务前请先将进度调整为 100%' };
|
||||
}
|
||||
|
||||
return { ok: true, action: candidate };
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片"是否允许拖起"。
|
||||
*
|
||||
* - 执行已 completed/cancelled (跨执行视角下灰显卡片):禁拖
|
||||
* - availableActions 为空(后端按当前用户上下文已过滤):禁拖
|
||||
* → 自然覆盖"不是负责人 / 无权限 / 终态任务 / paused 等无出度"全部场景,口径与按钮完全一致
|
||||
* - 仅剩 auto_start 时也禁拖(无可暴露动作)
|
||||
*/
|
||||
export function isTaskDraggable(task: ProjectTask, options: { executionLocked?: boolean } = {}): boolean {
|
||||
if (options.executionLocked) return false;
|
||||
if (!task.availableActions.length) return false;
|
||||
return task.availableActions.some(action => action.actionCode !== 'auto_start');
|
||||
}
|
||||
@@ -50,7 +50,9 @@ export function useTaskPermissions() {
|
||||
}
|
||||
|
||||
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean {
|
||||
return execution.statusCode === 'pending' && hasPermission('project:execution:delete');
|
||||
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
|
||||
if (execution.statusCode === 'completed') return false;
|
||||
return hasPermission('project:execution:delete');
|
||||
}
|
||||
|
||||
function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean {
|
||||
@@ -77,7 +79,7 @@ export function useTaskPermissions() {
|
||||
);
|
||||
}
|
||||
|
||||
// —— 任务侧(按一级 / 子任务分流) ——
|
||||
// —— 任务侧(按一级 / 子任务指派) ——
|
||||
|
||||
function isTopLevelTask(task: Api.Project.ProjectTask): boolean {
|
||||
return task.parentTaskId === null || task.parentTaskId === undefined;
|
||||
@@ -92,7 +94,8 @@ export function useTaskPermissions() {
|
||||
}
|
||||
|
||||
function canDeleteTask(task: Api.Project.ProjectTask): boolean {
|
||||
if (task.statusCode !== 'pending') return false;
|
||||
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
|
||||
if (task.statusCode === 'completed') return false;
|
||||
if (hasPermission('project:task:delete')) return true;
|
||||
return isTopLevelTask(task)
|
||||
? currentUserId.value === task.executionOwnerId
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
import { reactive } from 'vue';
|
||||
|
||||
/**
|
||||
* 任务 tab 的身份维度视角。
|
||||
*
|
||||
* 范围维度(全部 / 某状态 / 某具体执行)由父组件的 selectedStatus + selectedExecution 单独维护,
|
||||
* 与本身份维度自由组合。
|
||||
*
|
||||
* - my: 我参与的(owner 或活跃协办)
|
||||
* - all: 所有任务(需 project:task:query 权限)
|
||||
*/
|
||||
export type ViewContextType = 'my' | 'all';
|
||||
|
||||
export interface ViewContext {
|
||||
type: ViewContextType;
|
||||
}
|
||||
|
||||
export function useTaskViewContext() {
|
||||
const state = reactive<ViewContext>({ type: 'my' });
|
||||
|
||||
function switchToMine() {
|
||||
state.type = 'my';
|
||||
}
|
||||
|
||||
function switchToAll() {
|
||||
state.type = 'all';
|
||||
}
|
||||
|
||||
return {
|
||||
context: state,
|
||||
switchToMine,
|
||||
switchToAll
|
||||
};
|
||||
}
|
||||
@@ -11,19 +11,23 @@ import {
|
||||
fetchGetProjectExecutionPage,
|
||||
fetchGetProjectExecutionStatusBoard,
|
||||
fetchGetProjectMembers,
|
||||
fetchGetProjectTaskStatusBoardCross,
|
||||
fetchInactiveProjectExecutionAssignee,
|
||||
fetchPrecheckDeleteProjectExecution,
|
||||
fetchUpdateProjectExecution
|
||||
} from '@/service/api';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
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 { useTaskViewContext } from './composables/use-task-view-context';
|
||||
import ExecutionAssigneeDialog from './modules/execution-assignee-dialog.vue';
|
||||
import ExecutionOperateDialog from './modules/execution-operate-dialog.vue';
|
||||
import ExecutionSection from './modules/execution-section.vue';
|
||||
import ObjectDeleteDialog from './modules/object-delete-dialog.vue';
|
||||
import StatusActionDialog from './modules/status-action-dialog.vue';
|
||||
import TaskWorkspace from './modules/task-workspace.vue';
|
||||
import TaskWorkspaceComp from './modules/task-workspace.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectExecution' });
|
||||
|
||||
@@ -39,35 +43,45 @@ function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParam
|
||||
executionType: undefined,
|
||||
ownerId: undefined,
|
||||
statusCode: undefined,
|
||||
priority: undefined,
|
||||
dueRange: undefined,
|
||||
updateTime: undefined
|
||||
};
|
||||
}
|
||||
|
||||
function transformExecutionPage(response: ExecutionPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
data: response.data.list,
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: response.data.total
|
||||
};
|
||||
return { data: response.data.list, pageNum: pageNo, pageSize, total: response.data.total };
|
||||
}
|
||||
|
||||
return {
|
||||
data: [],
|
||||
pageNum: pageNo,
|
||||
pageSize,
|
||||
total: 0
|
||||
};
|
||||
return { data: [], pageNum: pageNo, pageSize, total: 0 };
|
||||
}
|
||||
|
||||
const { currentObjectId } = useCurrentProject();
|
||||
const objectContextStore = useObjectContextStore();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const { context: viewContext, switchToMine, switchToAll } = useTaskViewContext();
|
||||
|
||||
// 执行域独立视角:跟任务域 viewContext 完全独立,切换互不影响。
|
||||
// 用 inline reactive 即可,不抽 composable(只在本页用,YAGNI)。
|
||||
const executionViewContext = reactive<{ type: 'my' | 'all' }>({ type: 'my' });
|
||||
|
||||
function switchExecutionToMine() {
|
||||
executionViewContext.type = 'my';
|
||||
}
|
||||
|
||||
function switchExecutionToAll() {
|
||||
executionViewContext.type = 'all';
|
||||
}
|
||||
|
||||
const searchParams = reactive(getInitExecutionSearchParams());
|
||||
const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = 'active';
|
||||
// 默认"全部":右侧任务列表也对应"项目全部执行下的我参与/所有任务",不预先把范围收窄
|
||||
const DEFAULT_EXECUTION_STATUS: ExecutionStatusFilter = null;
|
||||
const selectedStatus = ref<ExecutionStatusFilter>(DEFAULT_EXECUTION_STATUS);
|
||||
|
||||
/** 范围维度:选中的具体执行;为 null 表示"未锚定具体执行",数据来源由 selectedStatus 决定 */
|
||||
const selectedExecution = ref<Api.Project.ProjectExecution | null>(null);
|
||||
|
||||
const projectMembers = ref<Api.Project.ProjectMember[]>([]);
|
||||
const projectMemberOptions = ref<Api.SystemManage.UserSimple[]>([]);
|
||||
const operateVisible = ref(false);
|
||||
@@ -82,26 +96,95 @@ const executionAssignees = ref<Api.Project.ExecutionAssignee[]>([]);
|
||||
const assigneeLoading = ref(false);
|
||||
const executionStatusBoard = ref<Api.Project.StatusBoard | null>(null);
|
||||
|
||||
/**
|
||||
* 项目下"全部执行简明列表"。一次性拉取(pageSize=-1),用于:
|
||||
* 1. task-search 的「所属执行」下拉(executionOptionsForFilter)
|
||||
* 2. 左侧 chip 选择某状态时,前端 filter 出"该状态下的执行 ids"传给右侧任务列表
|
||||
*/
|
||||
const allProjectExecutions = ref<Api.Project.ProjectExecution[]>([]);
|
||||
|
||||
const allTasksCount = ref(0);
|
||||
const myTasksCount = ref(0);
|
||||
|
||||
// 执行视角 chip 计数:对应当前搜索条件下"我参与的执行" / "所有执行"总数
|
||||
const executionAllCount = ref(0);
|
||||
const executionMyCount = ref(0);
|
||||
|
||||
// "我参与的"执行视角下的快捷过滤计数(逾期 / 本周到期),仅 my 视角有意义
|
||||
const executionOverdueCount = ref(0);
|
||||
const executionThisWeekCount = ref(0);
|
||||
|
||||
const projectId = computed(() => currentObjectId.value || '');
|
||||
const currentUserId = computed(() => authStore.userInfo.userId || '');
|
||||
|
||||
const statusActionTitle = computed(() =>
|
||||
statusAction.value ? `执行状态变更:${statusAction.value.actionName}` : '执行状态变更'
|
||||
);
|
||||
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
|
||||
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
|
||||
/**
|
||||
* 「所有任务」视角按钮可见度:权限码 project:task:query(基础读权限)。
|
||||
* list-all 系列权限码已废弃,统一用 query 系列。常规链路恒为 true,
|
||||
* 留判断只是与权限模型对齐,不出现"按钮渲染却 403"的状态。
|
||||
*/
|
||||
const showAllPerspective = computed(() => buttonCodeSet.value.has('project:task:query'));
|
||||
|
||||
/**
|
||||
* 执行视角切换可见度:跟着 project:execution:query(基础读权限)。
|
||||
* 用户决策:执行没有独立的 list-all 权限码,有 query 就能在"我参与/所有"间切换。
|
||||
* 实际能进项目页的用户必然有 query 码,所以这个 computed 在常规链路里恒为 true,
|
||||
* 留判断只是与权限模型对齐,不出现"按钮渲染却 403"的状态。
|
||||
*/
|
||||
const showExecutionPerspectiveSwitch = computed(() => buttonCodeSet.value.has('project:execution:query'));
|
||||
|
||||
/**
|
||||
* 当前左侧锚定的"执行范围"——同时供 task-workspace 的任务列表/状态看板入参,以及视角按钮上的计数。
|
||||
*
|
||||
* 范围维度拆成三个独立字段下传,由后端组合:
|
||||
* - scopedExecutionIds:仅在"锚定到某具体执行"时为 [id];否则 undefined。
|
||||
* - scopedExecutionInvolveUserId:执行视角 = my 且未锚定具体执行时 = 当前用户,直接表达"我参与的执行"范围;
|
||||
* all 视角 / 锚定具体执行时 undefined。改用后端 executionInvolveUserId 后,无需再"先拉我参与的执行 ids 再 map 回传",
|
||||
* 用户未参与任何执行时后端直接返空(不会再退化成查全部,前端也不必空集合短路)。
|
||||
* - scopedExecutionStatusCodesForTasks:仅在左侧选了某状态 chip 时下发 [statusCode];其它场景 undefined。
|
||||
*
|
||||
* 历史上"按状态过滤"用前端 filter 出 ids 中转的方式实现,会把"对执行无成员权限但对其下任务有 owner/协办权限"
|
||||
* 的任务漏掉(详见 docs/debt/跨执行任务接口-按执行状态过滤-契约调整.html)。现改为把状态码直接下传给任务接口,
|
||||
* 由后端在任务可见性之上叠加"任务所属执行的状态 ∈ 白名单"过滤。
|
||||
*/
|
||||
const scopedExecutionIds = computed<string[] | undefined>(() => {
|
||||
if (selectedExecution.value) return [selectedExecution.value.id];
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const scopedExecutionInvolveUserId = computed<string | undefined>(() => {
|
||||
if (selectedExecution.value) return undefined;
|
||||
if (executionViewContext.type === 'my') return currentUserId.value || undefined;
|
||||
return undefined;
|
||||
});
|
||||
|
||||
const scopedExecutionStatusCodesForTasks = computed<Api.Project.ProjectExecutionStatusCode[] | undefined>(() => {
|
||||
if (selectedExecution.value) return undefined;
|
||||
if (!selectedStatus.value) return undefined;
|
||||
return [selectedStatus.value as Api.Project.ProjectExecutionStatusCode];
|
||||
});
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deleteExecutionDependentSummary = ref<string | null>(null);
|
||||
const { canCreateTopLevelTask } = useTaskPermissions();
|
||||
// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置
|
||||
// 选中的执行 = null 时按钮隐藏(无对象上下文可判)
|
||||
const canCreateTask = computed(() =>
|
||||
selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : false
|
||||
);
|
||||
|
||||
const canCreateTask = computed(() => (selectedExecution.value ? canCreateTopLevelTask(selectedExecution.value) : true));
|
||||
|
||||
const workspaceTitle = computed(() => {
|
||||
if (selectedExecution.value) return selectedExecution.value.executionName || '执行任务';
|
||||
return viewContext.type === 'my' ? '我参与的' : '所有任务';
|
||||
});
|
||||
|
||||
function createRequestParams(): Api.Project.ProjectExecutionSearchParams {
|
||||
return {
|
||||
...searchParams,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
statusCode: selectedStatus.value || undefined
|
||||
statusCode: selectedStatus.value || undefined,
|
||||
// 执行视角:my → 带当前用户 ID 走"我参与";all → 不传(项目全部)
|
||||
involveUserId: executionViewContext.type === 'my' ? currentUserId.value || undefined : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -110,7 +193,11 @@ function createStatusBoardParams(): Api.Project.ProjectExecutionStatusBoardParam
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
executionType: searchParams.executionType,
|
||||
ownerId: searchParams.ownerId,
|
||||
updateTime: searchParams.updateTime
|
||||
// dueRange 与列表同口径下传,状态 chip 数字随"逾期/本周到期"快捷过滤联动
|
||||
dueRange: searchParams.dueRange,
|
||||
updateTime: searchParams.updateTime,
|
||||
// 执行视角与列表保持同一身份维度,确保状态 chip 数字与列表对得上
|
||||
involveUserId: executionViewContext.type === 'my' ? currentUserId.value || undefined : undefined
|
||||
};
|
||||
}
|
||||
|
||||
@@ -129,7 +216,6 @@ const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
error: null
|
||||
} as unknown as ExecutionPageResponse);
|
||||
}
|
||||
|
||||
return fetchGetProjectExecutionPage(projectId.value, createRequestParams());
|
||||
},
|
||||
transform: response => transformExecutionPage(response, searchParams.pageNo ?? 1, searchParams.pageSize ?? 10),
|
||||
@@ -141,14 +227,9 @@ const { data, loading, getDataByPage, mobilePagination } = useUIPaginatedTable<
|
||||
immediate: false
|
||||
});
|
||||
|
||||
function syncSelectedExecution() {
|
||||
if (!data.value.length) {
|
||||
selectedExecution.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
selectedExecution.value = data.value.find(item => item.id === selectedExecution.value?.id) || data.value[0];
|
||||
}
|
||||
const executionOptionsForFilter = computed(() =>
|
||||
allProjectExecutions.value.map(item => ({ id: item.id, name: item.executionName }))
|
||||
);
|
||||
|
||||
async function loadProjectMemberOptions() {
|
||||
if (!projectId.value) {
|
||||
@@ -175,7 +256,6 @@ async function loadProjectMemberOptions() {
|
||||
|
||||
async function reloadExecutionData(page = searchParams.pageNo ?? 1) {
|
||||
await getDataByPage(page);
|
||||
syncSelectedExecution();
|
||||
}
|
||||
|
||||
async function loadExecutionStatusBoard() {
|
||||
@@ -183,41 +263,219 @@ async function loadExecutionStatusBoard() {
|
||||
executionStatusBoard.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
const { error, data: board } = await fetchGetProjectExecutionStatusBoard(projectId.value, createStatusBoardParams());
|
||||
|
||||
executionStatusBoard.value = error || !board ? null : board;
|
||||
}
|
||||
|
||||
/**
|
||||
* 拉取项目下执行简明列表(pageSize=-1,后端"不分页全量"约定见 CLAUDE.md §9)。
|
||||
* 按当前执行视角带 involveUserId:
|
||||
* - my 视角 → 只拉"我参与的执行",作为任务 scope 和"所属执行"下拉的来源
|
||||
* - all 视角 → 项目下全部执行
|
||||
*/
|
||||
async function loadAllProjectExecutions() {
|
||||
if (!projectId.value) {
|
||||
allProjectExecutions.value = [];
|
||||
return;
|
||||
}
|
||||
const { error, data: pageData } = await fetchGetProjectExecutionPage(projectId.value, {
|
||||
pageNo: 1,
|
||||
pageSize: -1,
|
||||
involveUserId: executionViewContext.type === 'my' ? currentUserId.value || undefined : undefined
|
||||
});
|
||||
allProjectExecutions.value = error || !pageData ? [] : pageData.list;
|
||||
}
|
||||
|
||||
async function loadCrossExecutionCounts() {
|
||||
if (!projectId.value || !currentUserId.value) {
|
||||
myTasksCount.value = 0;
|
||||
allTasksCount.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
// 视角按钮计数 = 当前左侧 chip / 执行 锚定范围内的"我参与/所有"总数。
|
||||
// my 执行视角下范围用 executionInvolveUserId 表达,我未参与任何执行时后端直接返空 → 计数自然为 0,无需空集合短路。
|
||||
const scopedIds = scopedExecutionIds.value;
|
||||
const scopedInvolveUserId = scopedExecutionInvolveUserId.value;
|
||||
const scopedStatusCodes = scopedExecutionStatusCodesForTasks.value;
|
||||
|
||||
const scopeParams: Pick<
|
||||
Api.Project.ProjectTaskCrossStatusBoardParams,
|
||||
'executionIds' | 'executionInvolveUserId' | 'executionStatusCodes'
|
||||
> = {};
|
||||
if (scopedIds !== undefined) scopeParams.executionIds = scopedIds;
|
||||
if (scopedInvolveUserId !== undefined) scopeParams.executionInvolveUserId = scopedInvolveUserId;
|
||||
if (scopedStatusCodes !== undefined) scopeParams.executionStatusCodes = scopedStatusCodes;
|
||||
|
||||
const [allRes, myRes] = await Promise.all([
|
||||
fetchGetProjectTaskStatusBoardCross(projectId.value, scopeParams),
|
||||
fetchGetProjectTaskStatusBoardCross(projectId.value, { ...scopeParams, involveUserId: currentUserId.value })
|
||||
]);
|
||||
|
||||
allTasksCount.value = allRes.error || !allRes.data ? 0 : allRes.data.total;
|
||||
myTasksCount.value = myRes.error || !myRes.data ? 0 : myRes.data.total;
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行视角 chip 计数:对当前搜索条件(关键字 / 类型 / 负责人 / 更新时间)叠加,并发拉两次:
|
||||
* - 不带 involveUserId → "所有"格子的总数
|
||||
* - 带 involveUserId = 当前用户 → "我参与的"格子的总数
|
||||
*
|
||||
* 跟 loadCrossExecutionCounts 对称,只是接口换成执行的 status-board。
|
||||
*/
|
||||
async function loadExecutionPerspectiveCounts() {
|
||||
if (!projectId.value || !currentUserId.value) {
|
||||
executionAllCount.value = 0;
|
||||
executionMyCount.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseParams: Api.Project.ProjectExecutionStatusBoardParams = {
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
executionType: searchParams.executionType,
|
||||
ownerId: searchParams.ownerId,
|
||||
updateTime: searchParams.updateTime
|
||||
};
|
||||
|
||||
const [allRes, myRes] = await Promise.all([
|
||||
fetchGetProjectExecutionStatusBoard(projectId.value, baseParams),
|
||||
fetchGetProjectExecutionStatusBoard(projectId.value, { ...baseParams, involveUserId: currentUserId.value })
|
||||
]);
|
||||
|
||||
executionAllCount.value = allRes.error || !allRes.data ? 0 : allRes.data.total;
|
||||
executionMyCount.value = myRes.error || !myRes.data ? 0 : myRes.data.total;
|
||||
}
|
||||
|
||||
/**
|
||||
* "逾期 / 本周到期"快捷过滤计数:仅"我参与的"执行视角有意义,非 my 视角直接清 0。
|
||||
* 复用 page 接口 pageSize=1 读 total(口径同任务侧 loadQuickFilterCounts),
|
||||
* 用 involveUserId=me + 基础搜索条件(关键字/类型/更新时间),不叠加 statusCode 或已选中的 dueRange——
|
||||
* 计数表达的是该范围内的总量指示,不随当前选中的快捷过滤变化。
|
||||
*/
|
||||
async function loadExecutionDueCounts() {
|
||||
if (!projectId.value || !currentUserId.value || executionViewContext.type !== 'my') {
|
||||
executionOverdueCount.value = 0;
|
||||
executionThisWeekCount.value = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const baseParams: Api.Project.ProjectExecutionSearchParams = {
|
||||
pageNo: 1,
|
||||
pageSize: 1,
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
executionType: searchParams.executionType,
|
||||
updateTime: searchParams.updateTime,
|
||||
involveUserId: currentUserId.value
|
||||
};
|
||||
|
||||
const [overdueRes, thisWeekRes] = await Promise.all([
|
||||
fetchGetProjectExecutionPage(projectId.value, { ...baseParams, dueRange: 'overdue' }),
|
||||
fetchGetProjectExecutionPage(projectId.value, { ...baseParams, dueRange: 'thisWeek' })
|
||||
]);
|
||||
|
||||
executionOverdueCount.value = overdueRes.error || !overdueRes.data ? 0 : overdueRes.data.total;
|
||||
executionThisWeekCount.value = thisWeekRes.error || !thisWeekRes.data ? 0 : thisWeekRes.data.total;
|
||||
}
|
||||
|
||||
/** 执行视角 chip 计数 + 快捷过滤计数统一刷新入口(各操作后调它,避免两处计数散落) */
|
||||
async function refreshExecutionCounts() {
|
||||
await Promise.all([loadExecutionPerspectiveCounts(), loadExecutionDueCounts()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 刷新"我参与的执行"集合(供「所属执行」下拉 / 左侧执行列表来源) + 跨执行任务计数。
|
||||
*
|
||||
* 任务 scope 已改用 executionInvolveUserId 直接表达"我参与的执行",不再依赖 allProjectExecutions,
|
||||
* 故两者无顺序依赖,可并行。
|
||||
*/
|
||||
async function refreshTaskScopeAndCounts() {
|
||||
await Promise.all([loadAllProjectExecutions(), loadCrossExecutionCounts()]);
|
||||
}
|
||||
|
||||
async function refreshPageData() {
|
||||
await Promise.all([loadProjectMemberOptions(), reloadExecutionData(), loadExecutionStatusBoard()]);
|
||||
await Promise.all([
|
||||
loadProjectMemberOptions(),
|
||||
reloadExecutionData(),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleSearch() {
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
Object.assign(searchParams, getInitExecutionSearchParams());
|
||||
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
}
|
||||
|
||||
async function handleStatusChange(status: ExecutionStatusFilter) {
|
||||
async function handleExecutionStatusFilter(status: ExecutionStatusFilter) {
|
||||
selectedStatus.value = status;
|
||||
// 状态 chip 是"按状态范围浏览":意味着不再锚定具体执行,清掉 selectedExecution
|
||||
if (selectedExecution.value) selectedExecution.value = null;
|
||||
// 左侧范围切换默认回「我参与的」视角,符合"在该范围内看自己的"主用法
|
||||
switchToMine();
|
||||
await reloadExecutionData(1);
|
||||
}
|
||||
|
||||
async function handleExecutionDueRangeChange(range: Api.Project.ProjectExecutionDueRange | null) {
|
||||
// 再点已选中的 chip → 取消(回到不限截止时间)
|
||||
searchParams.dueRange = range ?? undefined;
|
||||
// dueRange 影响列表与状态看板(状态 chip 数字随之联动);快捷过滤计数是范围总量,不随选中变,无需重算
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
}
|
||||
|
||||
async function handleExecutionSearch() {
|
||||
// 视角 chip 数字依赖搜索条件(keyword/executionType/ownerId/updateTime),搜索后需同步刷新
|
||||
await Promise.all([reloadExecutionData(1), refreshExecutionCounts()]);
|
||||
}
|
||||
|
||||
async function handleExecutionResetSearch() {
|
||||
Object.assign(searchParams, getInitExecutionSearchParams());
|
||||
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
|
||||
if (selectedExecution.value) selectedExecution.value = null;
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard(), refreshExecutionCounts()]);
|
||||
}
|
||||
|
||||
async function getExecutionDetail(row: Api.Project.ProjectExecution) {
|
||||
if (!projectId.value) {
|
||||
return row;
|
||||
}
|
||||
|
||||
if (!projectId.value) return row;
|
||||
const result = await fetchGetProjectExecution(projectId.value, row.id);
|
||||
|
||||
return result.error || !result.data ? row : result.data;
|
||||
}
|
||||
|
||||
function handleSelectExecution(row: Api.Project.ProjectExecution) {
|
||||
// 锚定具体执行,默认回「我参与的」视角
|
||||
selectedExecution.value = row;
|
||||
switchToMine();
|
||||
}
|
||||
|
||||
function handleSelectPerspective(type: 'my' | 'all') {
|
||||
// 只切身份维度,保留范围(具体执行 / 状态 chip)
|
||||
if (type === 'my') switchToMine();
|
||||
else switchToAll();
|
||||
}
|
||||
|
||||
async function handleSelectExecutionPerspective(type: 'my' | 'all') {
|
||||
if (executionViewContext.type === type) return;
|
||||
|
||||
if (type === 'my') switchExecutionToMine();
|
||||
else switchExecutionToAll();
|
||||
|
||||
// 切视角时:清掉锚定执行(避免"我参与"下锚定执行不在新范围内的空高亮);
|
||||
// 状态 chip 回"全部"(回到该视角下的项目级总览);
|
||||
// 然后重拉所有跟执行视角相关的数据。
|
||||
// 同步清空"我参与的执行"旧集合(供「所属执行」下拉),避免切视角瞬间下拉仍显示上一视角的执行,
|
||||
// 随后 loadAllProjectExecutions 按新视角重填。任务 scope 已改用 executionInvolveUserId(同步随
|
||||
// executionViewContext.type 变),不再有"读到旧执行 ids 算出陈旧 scope"的竞态。
|
||||
allProjectExecutions.value = [];
|
||||
selectedExecution.value = null;
|
||||
selectedStatus.value = DEFAULT_EXECUTION_STATUS;
|
||||
// 快捷过滤(逾期/本周到期)仅"我参与的"视角可见,切视角时一并清掉,避免残留过滤被带到下一视角
|
||||
searchParams.dueRange = undefined;
|
||||
searchParams.pageNo = 1;
|
||||
|
||||
// 视角切换 → "我参与的执行"集合本身变了 → 任务 scope/cross counts 都要重算
|
||||
await Promise.all([
|
||||
reloadExecutionData(1),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
}
|
||||
|
||||
function openCreateExecution() {
|
||||
editingExecution.value = null;
|
||||
editingExecutionAssignees.value = [];
|
||||
@@ -227,12 +485,10 @@ function openCreateExecution() {
|
||||
|
||||
async function openEditExecution(row: Api.Project.ProjectExecution) {
|
||||
const detail = await getExecutionDetail(row);
|
||||
|
||||
if (!detail.allowEdit) {
|
||||
window.$message?.warning('当前执行状态不允许编辑');
|
||||
return;
|
||||
}
|
||||
|
||||
editingExecution.value = detail;
|
||||
const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
|
||||
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
|
||||
@@ -242,7 +498,6 @@ async function openEditExecution(row: Api.Project.ProjectExecution) {
|
||||
|
||||
async function openViewExecution(row: Api.Project.ProjectExecution) {
|
||||
const detail = await getExecutionDetail(row);
|
||||
|
||||
editingExecution.value = detail;
|
||||
const assigneeResult = await fetchGetProjectExecutionAssignees(projectId.value, detail.id);
|
||||
editingExecutionAssignees.value = assigneeResult.error || !assigneeResult.data ? [] : assigneeResult.data;
|
||||
@@ -259,14 +514,34 @@ async function openMemberDialog(row: Api.Project.ProjectExecution) {
|
||||
async function openExecutionStatus(row: Api.Project.ProjectExecution, action: ExecutionAction | null) {
|
||||
const detail = await getExecutionDetail(row);
|
||||
const targetAction = action || detail.availableActions[0] || null;
|
||||
|
||||
if (!targetAction) {
|
||||
window.$message?.warning('当前执行暂无可用状态操作');
|
||||
return;
|
||||
}
|
||||
|
||||
statusExecution.value = detail;
|
||||
statusAction.value = targetAction;
|
||||
|
||||
// 完成动作:二次确认后直接提交(完成无需填原因,但需让用户确认这一状态变更)
|
||||
if (targetAction.actionCode === 'complete') {
|
||||
try {
|
||||
await window.$messageBox?.confirm(`确定要完成执行“${detail.executionName}”吗?`, '完成确认', {
|
||||
confirmButtonText: '完成执行',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
});
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
await handleExecutionStatusSubmit(null);
|
||||
return;
|
||||
}
|
||||
|
||||
// 其他非必填原因的动作(开始/暂停/恢复)直接提交,不弹原因弹层
|
||||
if (!targetAction.needReason) {
|
||||
await handleExecutionStatusSubmit(null);
|
||||
return;
|
||||
}
|
||||
|
||||
statusVisible.value = true;
|
||||
}
|
||||
|
||||
@@ -275,9 +550,7 @@ async function loadExecutionAssignees(executionId: string) {
|
||||
executionAssignees.value = [];
|
||||
return;
|
||||
}
|
||||
|
||||
assigneeLoading.value = true;
|
||||
|
||||
try {
|
||||
const { error, data: assignees } = await fetchGetProjectExecutionAssignees(projectId.value, executionId);
|
||||
executionAssignees.value = error || !assignees ? [] : assignees;
|
||||
@@ -287,10 +560,7 @@ async function loadExecutionAssignees(executionId: string) {
|
||||
}
|
||||
|
||||
async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionParams) {
|
||||
if (!projectId.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId.value) return;
|
||||
const result = editingExecution.value
|
||||
? await fetchUpdateProjectExecution(projectId.value, editingExecution.value.id, {
|
||||
executionName: payload.executionName,
|
||||
@@ -298,42 +568,44 @@ async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionPa
|
||||
projectRequirementId: payload.projectRequirementId,
|
||||
plannedStartDate: payload.plannedStartDate,
|
||||
plannedEndDate: payload.plannedEndDate,
|
||||
priority: payload.priority,
|
||||
executionDesc: payload.executionDesc
|
||||
})
|
||||
: await fetchCreateProjectExecution(projectId.value, payload);
|
||||
|
||||
if (!result.error) {
|
||||
operateVisible.value = false;
|
||||
// 执行集合变化 → 视角 chip 数字 + 任务 scope/cross counts 都要刷
|
||||
await Promise.all([
|
||||
reloadExecutionData(editingExecution.value ? (searchParams.pageNo ?? 1) : 1),
|
||||
loadExecutionStatusBoard()
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleChangeOwner(payload: Api.Project.ChangeExecutionOwnerParams) {
|
||||
if (!projectId.value || !selectedExecution.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId.value || !selectedExecution.value) return;
|
||||
const result = await fetchChangeProjectExecutionOwner(projectId.value, selectedExecution.value.id, payload);
|
||||
|
||||
if (!result.error) {
|
||||
selectedExecution.value = await getExecutionDetail(selectedExecution.value);
|
||||
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
|
||||
// 改 owner 会影响"我参与的"身份判定,视角 chip + 任务 scope/cross counts 都要刷
|
||||
await Promise.all([
|
||||
reloadExecutionData(searchParams.pageNo ?? 1),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleExecutionStatusSubmit(reason: string | null) {
|
||||
if (!projectId.value || !statusExecution.value || !statusAction.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId.value || !statusExecution.value || !statusAction.value) return;
|
||||
const result = await fetchChangeProjectExecutionStatus(projectId.value, statusExecution.value.id, {
|
||||
actionCode: statusAction.value.actionCode,
|
||||
reason
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
statusVisible.value = false;
|
||||
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
|
||||
@@ -341,12 +613,8 @@ async function handleExecutionStatusSubmit(reason: string | null) {
|
||||
}
|
||||
|
||||
async function handleAddExecutionAssignee(payload: Api.Project.CreateExecutionAssigneeParams) {
|
||||
if (!projectId.value || !selectedExecution.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId.value || !selectedExecution.value) return;
|
||||
const result = await fetchCreateProjectExecutionAssignee(projectId.value, selectedExecution.value.id, payload);
|
||||
|
||||
if (!result.error) {
|
||||
await loadExecutionAssignees(selectedExecution.value.id);
|
||||
}
|
||||
@@ -356,23 +624,57 @@ async function handleInactiveExecutionAssignee(
|
||||
assignee: Api.Project.ExecutionAssignee,
|
||||
payload: Api.Project.InactiveExecutionAssigneeParams
|
||||
) {
|
||||
if (!projectId.value || !selectedExecution.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!projectId.value || !selectedExecution.value) return;
|
||||
const result = await fetchInactiveProjectExecutionAssignee(projectId.value, selectedExecution.value.id, {
|
||||
assigneeId: assignee.id,
|
||||
data: payload
|
||||
});
|
||||
|
||||
if (!result.error) {
|
||||
await loadExecutionAssignees(selectedExecution.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
function handleDeleteExecution(row: Api.Project.ProjectExecution) {
|
||||
selectedExecution.value = row;
|
||||
deleteDialogVisible.value = true;
|
||||
async function handleDeleteExecution(row: Api.Project.ProjectExecution) {
|
||||
if (!projectId.value) return;
|
||||
const precheck = await fetchPrecheckDeleteProjectExecution(projectId.value, row.id);
|
||||
const canDirectDelete = !precheck.error && precheck.data && !precheck.data.hasDependentData;
|
||||
|
||||
if (!canDirectDelete) {
|
||||
selectedExecution.value = row;
|
||||
deleteExecutionDependentSummary.value =
|
||||
precheck.data && precheck.data.taskCount > 0 ? `下含 ${precheck.data.taskCount} 个任务` : null;
|
||||
deleteDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.$messageBox?.confirm(
|
||||
`确定要删除执行“${row.executionName}”吗?删除后将不可见,如需恢复请联系管理员。`,
|
||||
'删除确认',
|
||||
{ confirmButtonText: '确认删除', cancelButtonText: '取消', type: 'warning' }
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteProjectExecution(projectId.value, row.id, {
|
||||
executionName: row.executionName,
|
||||
confirmText: 'DELETE',
|
||||
reason: '无下挂数据,用户已二次确认'
|
||||
});
|
||||
if (error) {
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
return;
|
||||
}
|
||||
window.$message?.success('删除成功');
|
||||
selectedExecution.value = null;
|
||||
// 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷
|
||||
await Promise.all([
|
||||
reloadExecutionData(1),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
}
|
||||
|
||||
async function confirmDeleteExecution(payload: { name: string; confirmText: string; reason: string }) {
|
||||
@@ -386,12 +688,23 @@ async function confirmDeleteExecution(payload: { name: string; confirmText: stri
|
||||
window.$message?.success('删除成功');
|
||||
deleteDialogVisible.value = false;
|
||||
selectedExecution.value = null;
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
// 删执行 → 执行集合 -1,视角 chip + 任务 scope/cross counts 都要刷
|
||||
await Promise.all([
|
||||
reloadExecutionData(1),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
}
|
||||
|
||||
async function handleExecutionChangedByTask() {
|
||||
if (!selectedExecution.value) {
|
||||
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
|
||||
await Promise.all([
|
||||
reloadExecutionData(searchParams.pageNo ?? 1),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -400,13 +713,27 @@ async function handleExecutionChangedByTask() {
|
||||
|
||||
if (selectedStatus.value && latestExecution.statusCode !== selectedStatus.value) {
|
||||
selectedStatus.value = latestExecution.statusCode;
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
await Promise.all([
|
||||
reloadExecutionData(1),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all([reloadExecutionData(searchParams.pageNo ?? 1), loadExecutionStatusBoard()]);
|
||||
await Promise.all([
|
||||
reloadExecutionData(searchParams.pageNo ?? 1),
|
||||
loadExecutionStatusBoard(),
|
||||
refreshExecutionCounts(),
|
||||
refreshTaskScopeAndCounts()
|
||||
]);
|
||||
}
|
||||
|
||||
// 左侧 chip / 执行 选择变化 → 视角按钮上的「我参与的 / 所有任务」计数跟着刷,保持与 task-workspace 任务列表口径一致
|
||||
watch([scopedExecutionIds, scopedExecutionInvolveUserId, scopedExecutionStatusCodesForTasks], () => {
|
||||
loadCrossExecutionCounts();
|
||||
});
|
||||
|
||||
watch(
|
||||
() => projectId.value,
|
||||
async () => {
|
||||
@@ -421,41 +748,64 @@ watch(
|
||||
|
||||
<template>
|
||||
<div v-if="projectId" class="project-execution-page">
|
||||
<ExecutionListPanel
|
||||
v-model:search-model="searchParams"
|
||||
class="project-execution-page__aside"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:pagination="mobilePagination"
|
||||
:selected-id="selectedExecution?.id || null"
|
||||
:status-board="executionStatusBoard"
|
||||
:selected-status="selectedStatus"
|
||||
:owner-options="projectMemberOptions"
|
||||
:can-create="canCreateExecution"
|
||||
@select="selectedExecution = $event"
|
||||
@status-change="handleStatusChange"
|
||||
@search="handleSearch"
|
||||
@reset="handleReset"
|
||||
@create="openCreateExecution"
|
||||
@edit="openEditExecution"
|
||||
@view="openViewExecution"
|
||||
@members="openMemberDialog"
|
||||
@status-action="openExecutionStatus"
|
||||
@delete="handleDeleteExecution"
|
||||
/>
|
||||
<div class="project-execution-page__content">
|
||||
<aside class="project-execution-page__aside">
|
||||
<ExecutionSection
|
||||
v-model:search-model="searchParams"
|
||||
class="project-execution-page__execution-section"
|
||||
:data="data"
|
||||
:loading="loading"
|
||||
:pagination="mobilePagination"
|
||||
:active-execution-id="selectedExecution?.id ?? null"
|
||||
:status-board="executionStatusBoard"
|
||||
:selected-status="selectedStatus"
|
||||
:can-create="canCreateExecution"
|
||||
:owner-options="projectMemberOptions"
|
||||
:view-context-type="executionViewContext.type"
|
||||
:my-count="executionMyCount"
|
||||
:all-count="executionAllCount"
|
||||
:overdue-count="executionOverdueCount"
|
||||
:this-week-count="executionThisWeekCount"
|
||||
:show-perspective-switch="showExecutionPerspectiveSwitch"
|
||||
@select="handleSelectExecution"
|
||||
@status-change="handleExecutionStatusFilter"
|
||||
@search="handleExecutionSearch"
|
||||
@reset="handleExecutionResetSearch"
|
||||
@create="openCreateExecution"
|
||||
@edit="openEditExecution"
|
||||
@view="openViewExecution"
|
||||
@members="openMemberDialog"
|
||||
@status-action="openExecutionStatus"
|
||||
@delete="handleDeleteExecution"
|
||||
@select-perspective="handleSelectExecutionPerspective"
|
||||
@due-range-change="handleExecutionDueRangeChange"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<TaskWorkspace
|
||||
class="project-execution-page__main"
|
||||
:project-id="projectId"
|
||||
:execution="selectedExecution"
|
||||
:can-create="canCreateTask"
|
||||
@execution-changed="handleExecutionChangedByTask"
|
||||
/>
|
||||
<TaskWorkspaceComp
|
||||
class="project-execution-page__main"
|
||||
:project-id="projectId"
|
||||
:view-context="viewContext"
|
||||
:execution="selectedExecution"
|
||||
:execution-options="executionOptionsForFilter"
|
||||
:scoped-execution-ids="scopedExecutionIds"
|
||||
:scoped-execution-involve-user-id="scopedExecutionInvolveUserId"
|
||||
:scoped-execution-status-codes="scopedExecutionStatusCodesForTasks"
|
||||
:can-create="canCreateTask"
|
||||
:title="workspaceTitle"
|
||||
:my-count="myTasksCount"
|
||||
:all-count="allTasksCount"
|
||||
:show-all="showAllPerspective"
|
||||
@execution-changed="handleExecutionChangedByTask"
|
||||
@select-perspective="handleSelectPerspective"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<ExecutionOperateDialog
|
||||
v-model:visible="operateVisible"
|
||||
:mode="operateMode"
|
||||
:row-data="editingExecution"
|
||||
:project-id="projectId"
|
||||
:user-options="projectMemberOptions"
|
||||
:current-assignees="editingExecutionAssignees"
|
||||
@submit="handleExecutionSubmit"
|
||||
@@ -483,6 +833,7 @@ watch(
|
||||
v-model:visible="deleteDialogVisible"
|
||||
object-type="execution"
|
||||
:object-name="selectedExecution?.executionName ?? ''"
|
||||
:dependent-summary="deleteExecutionDependentSummary"
|
||||
:on-confirm="confirmDeleteExecution"
|
||||
/>
|
||||
</div>
|
||||
@@ -492,24 +843,52 @@ watch(
|
||||
|
||||
<style scoped>
|
||||
.project-execution-page {
|
||||
display: grid;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 560px;
|
||||
gap: 16px;
|
||||
gap: 12px;
|
||||
overflow: hidden;
|
||||
grid-template-columns: 396px minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.project-execution-page__aside,
|
||||
.project-execution-page__content {
|
||||
display: grid;
|
||||
grid-template-columns: 340px minmax(0, 1fr);
|
||||
gap: 14px;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.project-execution-page__aside {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.project-execution-page__execution-section {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.project-execution-page__main {
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.project-execution-page {
|
||||
.project-execution-page__content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.project-execution-page__aside {
|
||||
max-height: 480px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import {
|
||||
EXECUTION_STATUS_ORDER,
|
||||
TASK_STATUS_ORDER,
|
||||
executionStatusFallbackNameMap,
|
||||
taskStatusFallbackNameMap
|
||||
} from './shared';
|
||||
|
||||
export const mockExecutionStatusCounts: Record<Api.Project.ProjectExecutionStatusCode, number> =
|
||||
EXECUTION_STATUS_ORDER.reduce(
|
||||
(counts, statusCode) => ({
|
||||
...counts,
|
||||
[statusCode]: 0
|
||||
}),
|
||||
{} as Record<Api.Project.ProjectExecutionStatusCode, number>
|
||||
);
|
||||
|
||||
export const mockTaskStatusColumns = TASK_STATUS_ORDER.map(statusCode => ({
|
||||
statusCode,
|
||||
statusName: taskStatusFallbackNameMap[statusCode],
|
||||
tasks: [] as Api.Project.ProjectTask[]
|
||||
}));
|
||||
|
||||
export function createEmptyExecution(projectId: string): Api.Project.ProjectExecution {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
return {
|
||||
id: '',
|
||||
projectId,
|
||||
projectRequirementId: null,
|
||||
executionName: '',
|
||||
executionType: null,
|
||||
ownerId: '',
|
||||
ownerNickname: null,
|
||||
statusCode: 'pending',
|
||||
statusName: executionStatusFallbackNameMap.pending,
|
||||
terminal: false,
|
||||
allowEdit: true,
|
||||
availableActions: [],
|
||||
plannedStartDate: null,
|
||||
plannedEndDate: null,
|
||||
actualStartDate: null,
|
||||
actualEndDate: null,
|
||||
progressRate: 0,
|
||||
executionDesc: null,
|
||||
lastStatusReason: null,
|
||||
createTime: now,
|
||||
updateTime: now
|
||||
};
|
||||
}
|
||||
@@ -260,7 +260,7 @@ defineExpose({ reset });
|
||||
:total="displayAssignees.length"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
small
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -211,7 +211,7 @@ defineExpose({ refresh });
|
||||
v-if="mobilePagination.total"
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
small
|
||||
size="small"
|
||||
v-bind="mobilePagination"
|
||||
@current-change="mobilePagination['current-change']"
|
||||
@size-change="mobilePagination['size-change']"
|
||||
|
||||
@@ -1,599 +0,0 @@
|
||||
<script setup lang="ts">
|
||||
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 } 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 IconMdiPause from '~icons/mdi/pause';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiPlay from '~icons/mdi/play';
|
||||
import IconMdiRestart from '~icons/mdi/restart';
|
||||
import IconMdiSync from '~icons/mdi/sync';
|
||||
|
||||
defineOptions({ name: 'ProjectExecutionListPanel' });
|
||||
|
||||
type ExecutionStatusFilter = string | null;
|
||||
|
||||
interface Props {
|
||||
data: Api.Project.ProjectExecution[];
|
||||
loading: boolean;
|
||||
pagination: Partial<PaginationProps & Record<string, any>>;
|
||||
selectedId: string | null;
|
||||
statusBoard: Api.Project.StatusBoard | null;
|
||||
selectedStatus: ExecutionStatusFilter;
|
||||
ownerOptions: Api.SystemManage.UserSimple[];
|
||||
canCreate: boolean;
|
||||
}
|
||||
|
||||
const { canEditExecution, canDeleteExecution, canSeeExecutionAssigneeEntry } = useTaskPermissions();
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'status-change', status: ExecutionStatusFilter): void;
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'view', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'members', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'delete', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'search'): void;
|
||||
(e: 'reset'): void;
|
||||
(
|
||||
e: 'status-action',
|
||||
row: Api.Project.ProjectExecution,
|
||||
action: Api.Project.LifecycleAction<Api.Project.ProjectExecutionActionCode> | null
|
||||
): void;
|
||||
}
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function handleKeywordInput(value: string) {
|
||||
searchModel.value.keyword = value.trim() || undefined;
|
||||
}
|
||||
|
||||
function handleKeywordClear() {
|
||||
searchModel.value.keyword = undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleOwnerSelect(id: string | null | undefined) {
|
||||
searchModel.value.ownerId = id || undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
const paginationVisible = computed(() => {
|
||||
const total = Number(props.pagination.total || 0);
|
||||
|
||||
return total > 0;
|
||||
});
|
||||
|
||||
const totalCount = computed(() => props.statusBoard?.total ?? 0);
|
||||
|
||||
const statusItems = computed(() => [
|
||||
{
|
||||
key: null,
|
||||
label: '全部',
|
||||
count: totalCount.value
|
||||
},
|
||||
...(props.statusBoard?.items ?? []).map(item => ({
|
||||
key: item.statusCode,
|
||||
label: item.statusName,
|
||||
count: item.count
|
||||
}))
|
||||
]);
|
||||
|
||||
function handleStatusClick(status: ExecutionStatusFilter) {
|
||||
emit('status-change', status);
|
||||
}
|
||||
|
||||
function handleSelect(row: Api.Project.ProjectExecution) {
|
||||
emit('select', row);
|
||||
}
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
props.pagination['current-change']?.(page);
|
||||
}
|
||||
|
||||
function handleSizeChange(pageSize: number) {
|
||||
props.pagination['size-change']?.(pageSize);
|
||||
}
|
||||
|
||||
interface ExecutionAction {
|
||||
key: string;
|
||||
tooltip: string;
|
||||
icon: object;
|
||||
type: 'primary' | 'success' | 'danger' | 'warning';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
|
||||
start: markRaw(IconMdiPlay),
|
||||
pause: markRaw(IconMdiPause),
|
||||
resume: markRaw(IconMdiRestart),
|
||||
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[] = [];
|
||||
|
||||
// 查看入口已收到执行名称(点击名称触发 view);操作区不再放眼睛按钮。
|
||||
|
||||
// 编辑执行:pending/active + (权限码 OR 字段身份)
|
||||
if (canEditExecution(row)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
tooltip: '编辑',
|
||||
icon: markRaw(IconMdiPencilOutline),
|
||||
type: 'primary',
|
||||
onClick: () => emit('edit', row)
|
||||
});
|
||||
}
|
||||
|
||||
// 协办人入口:仅项目负责人 / 项目创建人 / 执行负责人可见,无状态前置
|
||||
// 普通登录用户通过"查看"对话框看团队信息;dialog 内"加 / 移 / 换 owner"再自判 isMutable
|
||||
if (canSeeExecutionAssigneeEntry(row)) {
|
||||
actions.push({
|
||||
key: 'members',
|
||||
tooltip: '协办人',
|
||||
icon: markRaw(IconMdiAccountMultipleOutline),
|
||||
type: 'primary',
|
||||
onClick: () => emit('members', row)
|
||||
});
|
||||
}
|
||||
|
||||
// 状态推进按钮完全依赖 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: STATUS_ACTION_TYPE_MAP[action.actionCode] ?? 'primary',
|
||||
onClick: () => emit('status-action', row, action)
|
||||
});
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="execution-list-panel">
|
||||
<header class="execution-list-panel__header">
|
||||
<h3 class="execution-list-panel__title">执行池</h3>
|
||||
<ElButton v-if="canCreate" type="primary" :icon="Plus" @click="emit('create')">新增</ElButton>
|
||||
</header>
|
||||
|
||||
<div class="execution-list-panel__search">
|
||||
<ElInput
|
||||
:model-value="searchModel.keyword ?? ''"
|
||||
class="execution-search-input"
|
||||
placeholder="搜索执行名称"
|
||||
@update:model-value="handleKeywordInput"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon v-if="searchModel.keyword" class="execution-search-input__clear" @click="handleKeywordClear">
|
||||
<icon-mdi-close-circle />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<ElSelect
|
||||
:model-value="searchModel.ownerId ?? null"
|
||||
class="execution-owner-select"
|
||||
placeholder="负责人"
|
||||
clearable
|
||||
filterable
|
||||
@change="handleOwnerSelect"
|
||||
>
|
||||
<ElOption v-for="item in ownerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
|
||||
<div class="execution-search__icons">
|
||||
<ElTooltip content="重置" placement="top">
|
||||
<ElButton link class="execution-search-input__btn" @click="handleReset">
|
||||
<icon-mdi-refresh class="text-15px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip content="搜索" placement="top">
|
||||
<ElButton link class="execution-search-input__btn" type="primary" @click="handleSearch">
|
||||
<icon-ic-round-search class="text-15px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="execution-status-grid" aria-label="执行状态筛选">
|
||||
<button
|
||||
v-for="item in statusItems"
|
||||
:key="item.key || 'all'"
|
||||
type="button"
|
||||
class="execution-status-grid__item"
|
||||
:class="{ 'is-active': selectedStatus === item.key }"
|
||||
:aria-pressed="selectedStatus === item.key"
|
||||
@click="handleStatusClick(item.key)"
|
||||
>
|
||||
<span>{{ item.label }}</span>
|
||||
<strong>{{ item.count }}</strong>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="execution-list-panel__scrollbar">
|
||||
<ElSkeleton v-if="loading" :rows="5" animated />
|
||||
<ElEmpty v-else-if="data.length === 0" class="execution-list-panel__empty" description="暂无执行项" />
|
||||
<div v-else class="execution-list-panel__list">
|
||||
<article
|
||||
v-for="row in data"
|
||||
:key="row.id"
|
||||
class="execution-item"
|
||||
:class="{ 'is-active': selectedId === row.id }"
|
||||
@click="handleSelect(row)"
|
||||
>
|
||||
<div class="execution-item__main">
|
||||
<div class="execution-item__top">
|
||||
<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)"
|
||||
effect="light"
|
||||
size="small"
|
||||
>
|
||||
{{ 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">
|
||||
<span>
|
||||
<ElIcon><User /></ElIcon>
|
||||
{{ row.ownerNickname || '未设置负责人' }}
|
||||
</span>
|
||||
<span>
|
||||
<ElIcon><Flag /></ElIcon>
|
||||
计划 {{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}
|
||||
</span>
|
||||
<span>
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
|
||||
</span>
|
||||
<span class="execution-item__progress-row">
|
||||
<ElIcon><TrendCharts /></ElIcon>
|
||||
<ElProgress class="execution-item__progress" :percentage="row.progressRate" :stroke-width="6" />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<div v-if="paginationVisible" class="execution-list-panel__pagination">
|
||||
<ElPagination
|
||||
small
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
v-bind="pagination"
|
||||
@current-change="handlePageChange"
|
||||
@size-change="handleSizeChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.execution-list-panel {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
min-height: 0;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
height: 100%;
|
||||
padding: 12px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 8px;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.execution-list-panel__header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
min-height: 40px;
|
||||
}
|
||||
|
||||
.execution-list-panel__title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.execution-list-panel__search {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-search-input {
|
||||
width: 140px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.execution-search-input__clear {
|
||||
color: rgb(192 196 204);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.execution-search-input__clear:hover {
|
||||
color: rgb(144 147 153);
|
||||
}
|
||||
|
||||
.execution-search__icons {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.execution-search-input__btn {
|
||||
width: 24px;
|
||||
min-width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.execution-owner-select {
|
||||
width: 140px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.execution-search__icons :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.execution-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-status-grid__item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 6px;
|
||||
background-color: rgb(248 250 252 / 80%);
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.execution-status-grid__item:hover {
|
||||
border-color: rgb(148 163 184 / 80%);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.execution-status-grid__item.is-active {
|
||||
border-color: rgb(64 158 255 / 68%);
|
||||
background-color: rgb(236 245 255 / 92%);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.execution-status-grid__item span {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.execution-status-grid__item strong {
|
||||
flex: 0 0 auto;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.execution-list-panel__scrollbar {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-list-panel__empty {
|
||||
padding: 32px 0;
|
||||
}
|
||||
|
||||
.execution-list-panel__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.execution-list-panel__pagination {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
padding-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.execution-item {
|
||||
min-width: 0;
|
||||
padding: 10px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 6px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease;
|
||||
}
|
||||
|
||||
.execution-item:hover {
|
||||
border-color: rgb(148 163 184 / 76%);
|
||||
background-color: rgb(248 250 252 / 68%);
|
||||
}
|
||||
|
||||
.execution-item.is-active {
|
||||
border-color: rgb(64 158 255 / 68%);
|
||||
background-color: rgb(236 245 255 / 78%);
|
||||
}
|
||||
|
||||
.execution-item__main {
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 8px;
|
||||
min-height: 24px;
|
||||
}
|
||||
|
||||
.execution-item__status-tag {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.execution-item__name {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 14px;
|
||||
font-weight: 700;
|
||||
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 {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
margin-top: 8px;
|
||||
color: rgb(100 116 139 / 94%);
|
||||
font-size: 12px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.execution-item__meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.execution-item__progress-row {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.execution-item__progress {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.execution-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.execution-item__actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:deep(.execution-action-btn) {
|
||||
padding: 3px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.execution-item__top {
|
||||
align-items: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.execution-item__actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -2,14 +2,16 @@
|
||||
import { computed, nextTick, reactive, ref, watch } from 'vue';
|
||||
import { useResizeObserver } from '@vueuse/core';
|
||||
import dayjs from 'dayjs';
|
||||
import { RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE, RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/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 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 RequirementTreePicker from '../components/requirement-tree-picker.vue';
|
||||
import { isActiveExecutionAssignee, withVirtualOwnerAssignee } from '../shared';
|
||||
import { useProjectRequirementOptions } from '../composables/use-project-requirement-options';
|
||||
|
||||
function isEmptyRichText(html: string | null | undefined) {
|
||||
if (!html) {
|
||||
@@ -36,6 +38,8 @@ interface Props {
|
||||
rowData: Api.Project.ProjectExecution | null;
|
||||
userOptions: Api.SystemManage.UserSimple[];
|
||||
currentAssignees?: Api.Project.ExecutionAssignee[];
|
||||
/** 当前项目 ID(父组件 useCurrentProject 取) */
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
@@ -112,10 +116,21 @@ const model = reactive<Api.Project.SaveProjectExecutionParams>({
|
||||
projectRequirementId: null,
|
||||
plannedStartDate: null,
|
||||
plannedEndDate: null,
|
||||
priority: '3',
|
||||
executionDesc: null,
|
||||
assigneeUserIds: []
|
||||
});
|
||||
|
||||
const { treeData: requirementTreeData, reload: reloadRequirementOptions } = useProjectRequirementOptions(
|
||||
() => props.projectId || null
|
||||
);
|
||||
|
||||
// 编辑模式下若已关联的需求不在新拉到的树里(已删除/移出当前项目),用 rowData 回显其名称
|
||||
const requirementFallbackName = computed(() => {
|
||||
const name = props.rowData?.projectRequirementName;
|
||||
return name ? `${name}(不可用)` : null;
|
||||
});
|
||||
|
||||
function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
|
||||
return {
|
||||
text,
|
||||
@@ -161,6 +176,7 @@ const rules = computed(
|
||||
({
|
||||
executionName: [createRequiredRule('请输入执行名称')],
|
||||
executionType: [createRequiredRule('请选择执行类型')],
|
||||
priority: [createRequiredRule('请选择优先级')],
|
||||
ownerId: props.mode === 'create' ? [createRequiredRule('请选择执行负责人')] : [],
|
||||
assigneeUserIds: props.mode === 'create' ? [createRequiredRule('请选择执行协办人')] : [],
|
||||
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
|
||||
@@ -219,9 +235,10 @@ async function handleConfirm() {
|
||||
executionName: model.executionName.trim(),
|
||||
executionType: model.executionType.trim(),
|
||||
ownerId: model.ownerId,
|
||||
projectRequirementId: null,
|
||||
projectRequirementId: model.projectRequirementId,
|
||||
plannedStartDate: model.plannedStartDate,
|
||||
plannedEndDate: model.plannedEndDate,
|
||||
priority: model.priority,
|
||||
executionDesc: isEmptyRichText(model.executionDesc) ? null : (model.executionDesc ?? null),
|
||||
assigneeUserIds: props.mode === 'create' ? normalizeAssigneeUserIds(model.assigneeUserIds) : undefined
|
||||
});
|
||||
@@ -245,13 +262,17 @@ watch(
|
||||
model.executionName = props.rowData?.executionName || '';
|
||||
model.executionType = props.rowData?.executionType || '';
|
||||
model.ownerId = props.rowData?.ownerId || '';
|
||||
model.projectRequirementId = null;
|
||||
model.projectRequirementId = props.rowData?.projectRequirementId ?? null;
|
||||
model.plannedStartDate = props.rowData?.plannedStartDate || null;
|
||||
model.plannedEndDate = props.rowData?.plannedEndDate || null;
|
||||
model.priority = props.rowData?.priority || '3';
|
||||
model.executionDesc = props.rowData?.executionDesc || null;
|
||||
model.assigneeUserIds = [];
|
||||
autoOwnerAssigneeId.value = null;
|
||||
|
||||
// 每次打开弹层都重拉一次非终态项目需求,保证下拉新鲜(避免页面挂载后到打开期间需求被关掉)
|
||||
reloadRequirementOptions();
|
||||
|
||||
await nextTick();
|
||||
formRef.value?.clearValidate();
|
||||
}
|
||||
@@ -302,6 +323,26 @@ watch(
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="关联项目需求" prop="projectRequirementId">
|
||||
<RequirementTreePicker
|
||||
v-model="model.projectRequirementId"
|
||||
:data="requirementTreeData"
|
||||
:selected-name="requirementFallbackName"
|
||||
:disabled="isView"
|
||||
placeholder="搜索或选择关联的项目需求(可不选)"
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
:disabled="isView"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem v-if="mode === 'create'" label="负责人" prop="ownerId">
|
||||
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
|
||||
</ElFormItem>
|
||||
|
||||
@@ -0,0 +1,838 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { PaginationProps } from 'element-plus';
|
||||
import { Calendar, Flag, Link, List, Plus, Star, User } from '@element-plus/icons-vue';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
||||
import DictTag from '@/components/custom/dict-tag.vue';
|
||||
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType } from '../shared';
|
||||
import { projectRequirementStatusRecord } from '../../requirement/shared/requirement-master-data';
|
||||
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 IconMdiPause from '~icons/mdi/pause';
|
||||
import IconMdiPencilOutline from '~icons/mdi/pencil-outline';
|
||||
import IconMdiPlay from '~icons/mdi/play';
|
||||
import IconMdiRestart from '~icons/mdi/restart';
|
||||
import IconMdiSync from '~icons/mdi/sync';
|
||||
|
||||
defineOptions({ name: 'ProjectTaskExecutionSection' });
|
||||
|
||||
type ExecutionStatusFilter = string | null;
|
||||
|
||||
type ExecutionViewContextType = 'my' | 'all';
|
||||
|
||||
interface Props {
|
||||
data: Api.Project.ProjectExecution[];
|
||||
loading: boolean;
|
||||
pagination: Partial<PaginationProps & Record<string, any>>;
|
||||
/** 当前激活的执行 id;跨执行视角时为 null,对应没有任何卡片高亮 */
|
||||
activeExecutionId: string | null;
|
||||
statusBoard: Api.Project.StatusBoard | null;
|
||||
selectedStatus: ExecutionStatusFilter;
|
||||
canCreate: boolean;
|
||||
ownerOptions: Api.SystemManage.UserSimple[];
|
||||
/** 当前执行视角(身份维度):my=我参与的 / all=所有 */
|
||||
viewContextType: ExecutionViewContextType;
|
||||
/** "我参与的"chip 数字 */
|
||||
myCount: number;
|
||||
/** "所有"chip 数字 */
|
||||
allCount: number;
|
||||
/** 快捷过滤计数:逾期执行数(仅"我参与的"视角展示 chip) */
|
||||
overdueCount: number;
|
||||
/** 快捷过滤计数:本周到期执行数 */
|
||||
thisWeekCount: number;
|
||||
/** 是否展示视角切换 chip 行;无 project:execution:query 权限时为 false */
|
||||
showPerspectiveSwitch: boolean;
|
||||
}
|
||||
|
||||
interface Emits {
|
||||
(e: 'select', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'status-change', status: ExecutionStatusFilter): void;
|
||||
(e: 'create'): void;
|
||||
(e: 'edit', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'view', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'members', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'delete', row: Api.Project.ProjectExecution): void;
|
||||
(e: 'search'): void;
|
||||
(e: 'reset'): void;
|
||||
(
|
||||
e: 'status-action',
|
||||
row: Api.Project.ProjectExecution,
|
||||
action: Api.Project.LifecycleAction<Api.Project.ProjectExecutionActionCode> | null
|
||||
): void;
|
||||
/** 切换执行视角(身份维度) */
|
||||
(e: 'select-perspective', type: ExecutionViewContextType): void;
|
||||
/** 切换"逾期/本周到期"快捷过滤(再点已选中 → 传 null 取消) */
|
||||
(e: 'due-range-change', range: Api.Project.ProjectExecutionDueRange | null): void;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
|
||||
|
||||
function handleSearch() {
|
||||
emit('search');
|
||||
}
|
||||
|
||||
function handleKeywordInput(value: string) {
|
||||
searchModel.value.keyword = value.trim() || undefined;
|
||||
}
|
||||
|
||||
function handleKeywordClear() {
|
||||
searchModel.value.keyword = undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleOwnerSelect(id: string | null | undefined) {
|
||||
searchModel.value.ownerId = id || undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
|
||||
const router = useRouter();
|
||||
const { canEditExecution, canDeleteExecution, canSeeExecutionAssigneeEntry } = useTaskPermissions();
|
||||
|
||||
const statusChips = computed(() =>
|
||||
(props.statusBoard?.items ?? []).map(item => ({
|
||||
key: item.statusCode as ExecutionStatusFilter,
|
||||
label: item.statusName,
|
||||
count: item.count,
|
||||
// 状态色调 → 复用业务对象状态色注册表(src/constants/status-tag.ts),保证与右侧执行卡内的 ElTag 同源
|
||||
tone: getExecutionStatusTagType(item.statusCode)
|
||||
}))
|
||||
);
|
||||
|
||||
// 状态下拉:选中某状态 → 该状态;clearable 清空(value 为 '' / undefined) → null,回到"不限状态"
|
||||
function handleStatusSelect(value: ExecutionStatusFilter) {
|
||||
emit('status-change', value || null);
|
||||
}
|
||||
|
||||
// 跟右侧任务侧 perspectiveCards 保持顺序一致:所有(List) 在前、我参与的(Star) 在后,icon 一致
|
||||
const perspectiveChips = computed(() => [
|
||||
{ key: 'all' as ExecutionViewContextType, label: '所有', count: props.allCount, icon: markRaw(List) },
|
||||
{ key: 'my' as ExecutionViewContextType, label: '我参与的', count: props.myCount, icon: markRaw(Star) }
|
||||
]);
|
||||
|
||||
// "我参与的"视角下的快捷过滤:逾期(标红) / 本周到期。"已完成"复用下方状态 chip,不在此重复
|
||||
const dueRangeChips = computed(() => [
|
||||
{ key: 'overdue' as Api.Project.ProjectExecutionDueRange, label: '逾期', count: props.overdueCount, danger: true },
|
||||
{
|
||||
key: 'thisWeek' as Api.Project.ProjectExecutionDueRange,
|
||||
label: '本周到期',
|
||||
count: props.thisWeekCount,
|
||||
danger: false
|
||||
}
|
||||
]);
|
||||
|
||||
// 再点已选中的 chip → 传 null 取消(回到不限截止时间)
|
||||
function handleDueRangeChipClick(key: Api.Project.ProjectExecutionDueRange) {
|
||||
emit('due-range-change', searchModel.value.dueRange === key ? null : key);
|
||||
}
|
||||
|
||||
const totalCount = computed(() => Number(props.pagination.total || 0));
|
||||
const paginationVisible = computed(() => totalCount.value > 0);
|
||||
|
||||
function handlePageChange(page: number) {
|
||||
props.pagination['current-change']?.(page);
|
||||
}
|
||||
|
||||
function getProjectRequirementStatusName(code: string | null) {
|
||||
if (!code) return '';
|
||||
return projectRequirementStatusRecord[code as keyof typeof projectRequirementStatusRecord] ?? code;
|
||||
}
|
||||
|
||||
function handleRequirementClick(row: Api.Project.ProjectExecution) {
|
||||
if (!row.projectRequirementId) return;
|
||||
router.push({
|
||||
path: '/project/project/requirement',
|
||||
query: {
|
||||
objectId: row.projectId,
|
||||
requirementId: row.projectRequirementId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
interface ExecutionAction {
|
||||
key: string;
|
||||
tooltip: string;
|
||||
icon: object;
|
||||
type: 'primary' | 'success' | 'danger' | 'warning';
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
const STATUS_ACTION_ICON_MAP: Record<string, object> = {
|
||||
start: markRaw(IconMdiPlay),
|
||||
pause: markRaw(IconMdiPause),
|
||||
resume: markRaw(IconMdiRestart),
|
||||
cancel: markRaw(IconMdiCloseCircleOutline),
|
||||
complete: markRaw(IconMdiCheckCircleOutline)
|
||||
};
|
||||
|
||||
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[] = [];
|
||||
|
||||
if (canEditExecution(row)) {
|
||||
actions.push({
|
||||
key: 'edit',
|
||||
tooltip: '编辑',
|
||||
icon: markRaw(IconMdiPencilOutline),
|
||||
type: 'primary',
|
||||
onClick: () => emit('edit', row)
|
||||
});
|
||||
}
|
||||
|
||||
if (canSeeExecutionAssigneeEntry(row)) {
|
||||
actions.push({
|
||||
key: 'members',
|
||||
tooltip: '协办人',
|
||||
icon: markRaw(IconMdiAccountMultipleOutline),
|
||||
type: 'primary',
|
||||
onClick: () => emit('members', row)
|
||||
});
|
||||
}
|
||||
|
||||
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: STATUS_ACTION_TYPE_MAP[action.actionCode] ?? 'primary',
|
||||
onClick: () => emit('status-action', row, action)
|
||||
});
|
||||
});
|
||||
|
||||
return actions;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="execution-section">
|
||||
<header class="execution-section__header">
|
||||
<h3 class="execution-section__title">执行池</h3>
|
||||
<div
|
||||
v-if="showPerspectiveSwitch"
|
||||
class="execution-section__perspective"
|
||||
role="tablist"
|
||||
aria-label="执行视角与快捷过滤切换"
|
||||
>
|
||||
<ElTooltip
|
||||
v-for="item in perspectiveChips"
|
||||
:key="item.key"
|
||||
:content="item.label"
|
||||
placement="top"
|
||||
:show-after="120"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
role="tab"
|
||||
class="perspective-compact"
|
||||
:class="{ 'is-active': viewContextType === item.key }"
|
||||
:aria-label="`${item.label}:${item.count}`"
|
||||
:aria-pressed="viewContextType === item.key"
|
||||
@click="emit('select-perspective', item.key)"
|
||||
>
|
||||
<ElIcon><component :is="item.icon" /></ElIcon>
|
||||
<span class="perspective-compact__value">{{ item.count }}</span>
|
||||
</button>
|
||||
</ElTooltip>
|
||||
|
||||
<button
|
||||
v-for="chip in viewContextType === 'my' ? dueRangeChips : []"
|
||||
:key="chip.key"
|
||||
type="button"
|
||||
class="perspective-compact"
|
||||
:class="{
|
||||
'is-active': searchModel.dueRange === chip.key,
|
||||
'perspective-compact--danger': chip.danger
|
||||
}"
|
||||
:aria-pressed="searchModel.dueRange === chip.key"
|
||||
@click="handleDueRangeChipClick(chip.key)"
|
||||
>
|
||||
<span class="perspective-compact__label">{{ chip.label }}</span>
|
||||
<span class="perspective-compact__value">{{ chip.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div class="execution-section__search-row">
|
||||
<ElInput
|
||||
:model-value="searchModel.keyword ?? ''"
|
||||
class="execution-section__keyword-input"
|
||||
placeholder="执行名称"
|
||||
@update:model-value="handleKeywordInput"
|
||||
@keyup.enter="handleSearch"
|
||||
>
|
||||
<template #suffix>
|
||||
<ElIcon v-if="searchModel.keyword" class="execution-section__search-clear" @click="handleKeywordClear">
|
||||
<icon-mdi-close-circle />
|
||||
</ElIcon>
|
||||
</template>
|
||||
</ElInput>
|
||||
|
||||
<ElSelect
|
||||
:model-value="searchModel.ownerId ?? null"
|
||||
class="execution-section__owner-select"
|
||||
placeholder="负责人"
|
||||
clearable
|
||||
filterable
|
||||
@change="handleOwnerSelect"
|
||||
>
|
||||
<ElOption v-for="item in ownerOptions" :key="item.id" :label="item.nickname" :value="item.id" />
|
||||
</ElSelect>
|
||||
</div>
|
||||
|
||||
<div class="execution-section__filter-row">
|
||||
<ElSelect
|
||||
:model-value="selectedStatus"
|
||||
class="execution-section__status-select"
|
||||
placeholder="状态"
|
||||
clearable
|
||||
@change="handleStatusSelect"
|
||||
>
|
||||
<ElOption v-for="item in statusChips" :key="item.key ?? ''" :label="item.label" :value="item.key ?? ''">
|
||||
<span class="execution-section__status-option">
|
||||
<span class="execution-section__status-option-dot" :data-tone="item.tone" aria-hidden="true" />
|
||||
<span class="execution-section__status-option-label">{{ item.label }}</span>
|
||||
<span class="execution-section__status-option-count">{{ item.count }}</span>
|
||||
</span>
|
||||
</ElOption>
|
||||
</ElSelect>
|
||||
|
||||
<div class="execution-section__filter-actions">
|
||||
<ElTooltip content="重置" placement="top">
|
||||
<ElButton link class="execution-section__action-btn" @click="handleReset">
|
||||
<icon-mdi-refresh class="text-16px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip content="查询" placement="top">
|
||||
<ElButton link type="primary" class="execution-section__action-btn" @click="handleSearch">
|
||||
<icon-ic-round-search class="text-16px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
<ElTooltip v-if="canCreate" content="新增" placement="top">
|
||||
<ElButton link type="primary" class="execution-section__action-btn" @click="emit('create')">
|
||||
<ElIcon><Plus /></ElIcon>
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ElScrollbar class="execution-section__scrollbar">
|
||||
<ElSkeleton v-if="loading" :rows="5" animated />
|
||||
<ElEmpty v-else-if="data.length === 0" class="execution-section__empty" description="暂无执行项" />
|
||||
<div v-else class="execution-section__list">
|
||||
<article
|
||||
v-for="row in data"
|
||||
:key="row.id"
|
||||
class="exec-item"
|
||||
:class="{ 'is-active': activeExecutionId === row.id }"
|
||||
@click="emit('select', row)"
|
||||
>
|
||||
<div class="exec-item__top">
|
||||
<strong
|
||||
class="exec-item__name"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="emit('view', row)"
|
||||
@keydown.enter.stop.prevent="emit('view', row)"
|
||||
>
|
||||
{{ row.executionName || '未命名执行' }}
|
||||
</strong>
|
||||
<DictTag
|
||||
class="exec-item__tag"
|
||||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
:value="row.priority"
|
||||
effect="light"
|
||||
size="small"
|
||||
/>
|
||||
<ElTag class="exec-item__tag" :type="getExecutionStatusTagType(row.statusCode)" effect="light" size="small">
|
||||
{{ getExecutionStatusName(row) }}
|
||||
</ElTag>
|
||||
<div class="exec-item__actions" @click.stop>
|
||||
<ElTooltip v-for="action in createActions(row)" :key="action.key" :content="action.tooltip">
|
||||
<ElButton link :type="action.type" class="exec-item__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="exec-item__action-btn" @click="emit('delete', row)">
|
||||
<icon-mdi-delete-outline class="text-14px" />
|
||||
</ElButton>
|
||||
</ElTooltip>
|
||||
</div>
|
||||
</div>
|
||||
<div class="exec-item__meta">
|
||||
<span>
|
||||
<ElIcon><User /></ElIcon>
|
||||
{{ row.ownerNickname || '未设置负责人' }}
|
||||
</span>
|
||||
<span>
|
||||
<ElIcon><Flag /></ElIcon>
|
||||
计划 {{ formatDateRange(row.plannedStartDate, row.plannedEndDate) }}
|
||||
</span>
|
||||
<span>
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
|
||||
</span>
|
||||
<span v-if="row.projectRequirementId && row.projectRequirementName" class="exec-item__requirement">
|
||||
<ElIcon><Link /></ElIcon>
|
||||
<ElTooltip
|
||||
:content="getProjectRequirementStatusName(row.projectRequirementStatusCode)"
|
||||
placement="top"
|
||||
:show-after="200"
|
||||
:disabled="!row.projectRequirementStatusCode"
|
||||
>
|
||||
<span
|
||||
class="exec-item__requirement-name"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="handleRequirementClick(row)"
|
||||
@keydown.enter.stop.prevent="handleRequirementClick(row)"
|
||||
>
|
||||
{{ row.projectRequirementName }}
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
<span class="exec-item__progress-row">
|
||||
<ElProgress class="exec-item__progress" :percentage="row.progressRate" :stroke-width="6" />
|
||||
</span>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</ElScrollbar>
|
||||
|
||||
<div v-if="paginationVisible" class="execution-section__pagination">
|
||||
<ElPagination
|
||||
size="small"
|
||||
background
|
||||
layout="prev, pager, next"
|
||||
v-bind="pagination"
|
||||
@current-change="handlePageChange"
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.execution-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.execution-section__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-section__title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.execution-section__count {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.execution-section__search-row,
|
||||
.execution-section__filter-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-section__keyword-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.execution-section__search-icon {
|
||||
color: var(--el-text-color-placeholder);
|
||||
}
|
||||
|
||||
.execution-section__search-clear {
|
||||
color: rgb(192 196 204);
|
||||
cursor: pointer;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.execution-section__search-clear:hover {
|
||||
color: rgb(144 147 153);
|
||||
}
|
||||
|
||||
.execution-section__owner-select,
|
||||
.execution-section__status-select {
|
||||
flex: 1;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.execution-section__filter-actions {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 6px;
|
||||
min-width: 96px;
|
||||
}
|
||||
|
||||
.execution-section__filter-actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:deep(.execution-section__action-btn) {
|
||||
width: 28px;
|
||||
min-width: 28px;
|
||||
height: 28px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* 状态下拉项:色点 + 状态名 + 数量 */
|
||||
.execution-section__status-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-section__status-option-dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
flex: 0 0 auto;
|
||||
border-radius: 50%;
|
||||
background-color: var(--el-color-info);
|
||||
}
|
||||
|
||||
.execution-section__status-option-dot[data-tone='primary'] {
|
||||
background-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.execution-section__status-option-dot[data-tone='success'] {
|
||||
background-color: var(--el-color-success);
|
||||
}
|
||||
|
||||
.execution-section__status-option-dot[data-tone='warning'] {
|
||||
background-color: var(--el-color-warning);
|
||||
}
|
||||
|
||||
.execution-section__status-option-dot[data-tone='danger'] {
|
||||
background-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.execution-section__status-option-dot[data-tone='info'] {
|
||||
background-color: var(--el-color-info);
|
||||
}
|
||||
|
||||
.execution-section__status-option-label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-section__status-option-count {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
/*
|
||||
* 视角切换 + 快捷过滤:横排 pill,与右侧任务区视角行同一控件语言。
|
||||
* 视角(所有 / 我参与的)始终在,图标 + 文字 + 数字;逾期 / 本周到期仅"我参与的"视角追加,同一行,窄屏自动折行。
|
||||
*/
|
||||
.execution-section__perspective {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.perspective-compact {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
height: 26px;
|
||||
padding: 0 8px;
|
||||
border: 1px solid rgb(226 232 240);
|
||||
border-radius: 6px;
|
||||
background: #fff;
|
||||
color: rgb(100 116 139);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.perspective-compact__label {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.perspective-compact:not(.is-active):hover {
|
||||
color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.perspective-compact.is-active {
|
||||
background-color: var(--el-color-primary);
|
||||
border-color: var(--el-color-primary);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(64 158 255 / 28%);
|
||||
}
|
||||
|
||||
.perspective-compact__value {
|
||||
padding: 1px 5px;
|
||||
border-radius: 6px;
|
||||
background-color: rgb(255 255 255 / 70%);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.perspective-compact.is-active .perspective-compact__value {
|
||||
background-color: #fff;
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
/* 逾期 chip:未选中红字红边,选中红底(与任务区快捷过滤的 danger 款一致) */
|
||||
.perspective-compact--danger:not(.is-active) {
|
||||
color: var(--el-color-danger);
|
||||
border-color: rgb(252 165 165 / 70%);
|
||||
}
|
||||
|
||||
.perspective-compact--danger:not(.is-active):hover {
|
||||
color: var(--el-color-danger);
|
||||
border-color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.perspective-compact--danger.is-active {
|
||||
background-color: var(--el-color-danger);
|
||||
border-color: var(--el-color-danger);
|
||||
color: #fff;
|
||||
box-shadow: 0 2px 6px rgb(245 108 108 / 28%);
|
||||
}
|
||||
|
||||
.perspective-compact--danger.is-active .perspective-compact__value {
|
||||
background-color: #fff;
|
||||
color: var(--el-color-danger);
|
||||
}
|
||||
|
||||
.execution-status-cell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
min-width: 0;
|
||||
min-height: 40px;
|
||||
padding: 8px 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 6px;
|
||||
background-color: rgb(248 250 252 / 80%);
|
||||
color: rgb(51 65 85 / 96%);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
border-color 0.16s ease,
|
||||
background-color 0.16s ease,
|
||||
color 0.16s ease;
|
||||
}
|
||||
|
||||
.execution-status-cell:hover {
|
||||
border-color: rgb(148 163 184 / 80%);
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.execution-status-cell.is-active {
|
||||
border-color: var(--el-color-primary);
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.execution-status-cell span {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
font-size: 13px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.execution-status-cell strong {
|
||||
flex: 0 0 auto;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.execution-section__scrollbar {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding-top: 8px;
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
}
|
||||
|
||||
.execution-section__empty {
|
||||
padding: 24px 0;
|
||||
}
|
||||
|
||||
.execution-section__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-right: 4px;
|
||||
}
|
||||
|
||||
.exec-item {
|
||||
padding: 9px 10px;
|
||||
border: 1px solid rgb(226 232 240 / 92%);
|
||||
border-radius: 6px;
|
||||
background-color: #fff;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.16s ease,
|
||||
border-color 0.16s ease;
|
||||
}
|
||||
|
||||
.exec-item:hover {
|
||||
background-color: rgb(243 244 246 / 60%);
|
||||
border-color: rgb(148 163 184 / 76%);
|
||||
}
|
||||
|
||||
.exec-item.is-active {
|
||||
background-color: var(--el-color-primary-light-9);
|
||||
border-color: var(--el-color-primary);
|
||||
border-left-width: 3px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.exec-item__top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
min-height: 22px;
|
||||
}
|
||||
|
||||
.exec-item__name {
|
||||
overflow: hidden;
|
||||
min-width: 0;
|
||||
color: rgb(15 23 42 / 94%);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
line-height: 1.45;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: color 0.16s ease;
|
||||
}
|
||||
|
||||
.exec-item__name:hover,
|
||||
.exec-item__name:focus-visible {
|
||||
color: var(--el-color-primary);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.exec-item__tag {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.exec-item__actions {
|
||||
display: none;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.exec-item:hover .exec-item__actions {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.exec-item__actions :deep(.el-button + .el-button) {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
:deep(.exec-item__action-btn) {
|
||||
padding: 2px;
|
||||
min-width: auto;
|
||||
height: auto;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.exec-item__meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
margin-top: 6px;
|
||||
color: rgb(100 116 139);
|
||||
font-size: 11.5px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.exec-item__meta span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exec-item__progress-row {
|
||||
width: 100%;
|
||||
}
|
||||
.exec-item__progress {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.exec-item__requirement {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.exec-item__requirement-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.exec-item__requirement-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.execution-section__pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding-top: 4px;
|
||||
}
|
||||
</style>
|
||||
@@ -11,6 +11,8 @@ interface Props {
|
||||
objectType: 'execution' | 'task';
|
||||
/** 当前对象的名称,用作输入框 placeholder 参照;提交时校验完全一致 */
|
||||
objectName: string;
|
||||
/** 下挂数据摘要,由 precheck 计算后传入(如 "下含 3 个子任务 + 5 条工作日志");null/空则走兜底文案 */
|
||||
dependentSummary?: string | null;
|
||||
/** 删除确认回调,async;接收三个字段;resolve 后由调用方决定刷新/关闭 */
|
||||
onConfirm: (payload: { name: string; confirmText: string; reason: string }) => Promise<void>;
|
||||
}
|
||||
@@ -79,7 +81,10 @@ async function handleConfirm() {
|
||||
@confirm="handleConfirm"
|
||||
>
|
||||
<ElAlert type="error" :closable="false" show-icon>
|
||||
此操作不可撤销,删除后{{ objectTypeLabel }}下挂数据将不可见
|
||||
<template v-if="dependentSummary">
|
||||
此操作不可撤销,{{ objectTypeLabel }}{{ dependentSummary }},删除后将一并不可见
|
||||
</template>
|
||||
<template v-else>此操作不可撤销,删除后{{ objectTypeLabel }}下挂数据将不可见</template>
|
||||
</ElAlert>
|
||||
|
||||
<ElForm label-position="top" class="mt-3">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user