refactor(projects): 1、新增执行任务,表单优化;2、删除逻辑丰富。3、修改已知问题
This commit is contained in:
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
|
||||
```
|
||||
@@ -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 数据源中
|
||||
@@ -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>
|
||||
|
||||
@@ -14,6 +14,8 @@ interface Props {
|
||||
multiple?: boolean;
|
||||
collapseTags?: boolean;
|
||||
collapseTagsTooltip?: boolean;
|
||||
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
|
||||
showRemark?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
@@ -24,7 +26,8 @@ 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>({
|
||||
@@ -35,18 +38,27 @@ 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;
|
||||
});
|
||||
</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 +67,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';
|
||||
|
||||
@@ -84,14 +88,6 @@ 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&item_worklog_difficulty
|
||||
*/
|
||||
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task&item_worklog_difficulty';
|
||||
|
||||
/**
|
||||
* 任务/个人事项类型字典编码
|
||||
*
|
||||
@@ -107,3 +103,11 @@ export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task&item_type';
|
||||
* 来源口径:用户在系统字典管理页中创建的字典 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';
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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'> & {
|
||||
@@ -116,6 +120,8 @@ export type ProjectTaskResponse = Omit<
|
||||
| 'progressRate'
|
||||
| 'assignees'
|
||||
| 'attachments'
|
||||
| 'priority'
|
||||
| 'priorityName'
|
||||
> & {
|
||||
id: StringIdResponse;
|
||||
projectId: StringIdResponse;
|
||||
@@ -131,6 +137,8 @@ export type ProjectTaskResponse = Omit<
|
||||
assignees?: TaskAssigneeRefResponse[] | null;
|
||||
attachments?: AttachmentItemResponse[] | null;
|
||||
totalSpentHours?: number | null;
|
||||
priority?: string | number | null;
|
||||
priorityName?: string | null;
|
||||
};
|
||||
|
||||
export type TaskWorklogResponse = Omit<
|
||||
@@ -237,12 +245,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,
|
||||
@@ -254,6 +271,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
|
||||
};
|
||||
@@ -294,6 +313,9 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
|
||||
projectId: normalizeStringId(response.projectId),
|
||||
executionId: normalizeStringId(response.executionId),
|
||||
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,
|
||||
@@ -306,6 +328,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:
|
||||
@@ -327,9 +351,11 @@ export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project
|
||||
userId: normalizeStringId(response.userId),
|
||||
userNickname: response.userNickname ?? null,
|
||||
workContent: response.workContent ?? null,
|
||||
difficulty: response.difficulty ?? '',
|
||||
attachments: normalizeAttachments(response.attachments),
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0
|
||||
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
|
||||
// 历史记录或异常缺失时兜底为字典默认档位 "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,
|
||||
@@ -855,6 +871,7 @@ 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)
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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,7 +40,8 @@ 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
|
||||
}));
|
||||
|
||||
|
||||
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 });
|
||||
});
|
||||
6
src/typings/api/dict.d.ts
vendored
6
src/typings/api/dict.d.ts
vendored
@@ -57,6 +57,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 */
|
||||
@@ -77,6 +79,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 */
|
||||
|
||||
54
src/typings/api/project.d.ts
vendored
54
src/typings/api/project.d.ts
vendored
@@ -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;
|
||||
@@ -213,6 +221,12 @@ declare namespace Api {
|
||||
projectId: string;
|
||||
executionId: string;
|
||||
parentTaskId: string | null;
|
||||
/** 所属执行关联的项目需求 ID(透传,未关联 = null) */
|
||||
projectRequirementId: string | null;
|
||||
/** 所属执行关联的项目需求名称(透传,未关联 = null) */
|
||||
projectRequirementName: string | null;
|
||||
/** 所属执行关联的项目需求状态编码(透传,未关联 = null) */
|
||||
projectRequirementStatusCode: string | null;
|
||||
taskTitle: string;
|
||||
type: string;
|
||||
ownerId: string;
|
||||
@@ -231,6 +245,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;
|
||||
@@ -247,6 +265,8 @@ declare namespace Api {
|
||||
executionType: string;
|
||||
ownerId: string;
|
||||
statusCode: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -266,6 +286,8 @@ declare namespace Api {
|
||||
projectRequirementId: string | null;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
executionDesc: string | null;
|
||||
assigneeUserIds?: string[];
|
||||
}
|
||||
@@ -280,6 +302,8 @@ declare namespace Api {
|
||||
projectRequirementId: string | null;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
executionDesc: string | null;
|
||||
}
|
||||
|
||||
@@ -307,6 +331,8 @@ declare namespace Api {
|
||||
parentTaskId: string;
|
||||
ownerId: string;
|
||||
statusCode: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -331,6 +357,8 @@ declare namespace Api {
|
||||
keyword: string;
|
||||
parentTaskId: string;
|
||||
ownerId: string;
|
||||
/** 优先级筛选(字典 value:String "0"~"3"),不传 = 全部档位 */
|
||||
priority: string;
|
||||
updateTime: string[];
|
||||
}
|
||||
>;
|
||||
@@ -356,6 +384,8 @@ declare namespace Api {
|
||||
progressRate?: number;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
/** 优先级字典 value,必填,String "0"~"3" */
|
||||
priority: string;
|
||||
taskDesc: string | null;
|
||||
/** 仅创建任务时生效,编辑接口静默忽略;userId 必须是当前有效执行协办人且不能等于 ownerId */
|
||||
assigneeUserIds?: string[];
|
||||
@@ -384,6 +414,8 @@ declare namespace Api {
|
||||
progressRate: number;
|
||||
/** 难度,来自字典 rdms_task&item_worklog_difficulty */
|
||||
difficulty: string;
|
||||
/** 后端预留字段,目前始终为 null,前端按 difficulty + 字典 cache 自译 */
|
||||
difficultyName?: string | null;
|
||||
workContent: string | null;
|
||||
attachments?: AttachmentItem[] | null;
|
||||
createTime: string;
|
||||
@@ -395,6 +427,8 @@ declare namespace Api {
|
||||
userId: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
/** 完成难度筛选,等值匹配;不传 = 全部 */
|
||||
difficulty: string;
|
||||
}
|
||||
>;
|
||||
|
||||
@@ -603,6 +637,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;
|
||||
@@ -725,6 +777,8 @@ declare namespace Api {
|
||||
expectedTime?: string | null;
|
||||
/** 排序值 */
|
||||
sort: number;
|
||||
/** 项目需求进度(BigDecimal,0.00 ~ 1.00;HALF_UP 两位小数)。读时聚合,后端不接受写入。 */
|
||||
progressRate: number;
|
||||
/** 创建时间 */
|
||||
createTime: string;
|
||||
/** 更新时间 */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,334 @@
|
||||
<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';
|
||||
import type { ProjectRequirementTreeNode } from '../composables/use-project-requirement-options';
|
||||
|
||||
defineOptions({ name: 'RequirementTreePicker' });
|
||||
|
||||
interface Props {
|
||||
data: ProjectRequirementTreeNode[];
|
||||
/** 编辑模式回显: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: ProjectRequirementTreeNode[], 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: ProjectRequirementTreeNode) {
|
||||
// 先播 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 ProjectRequirementTreeNode)"
|
||||
>
|
||||
<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 ProjectRequirementTreeNode)"
|
||||
>
|
||||
<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 };
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const DEFAULT_PAGE_SIZE = 20;
|
||||
|
||||
export type BoardBaseParams = Pick<
|
||||
Api.Project.ProjectTaskSearchParams,
|
||||
'keyword' | 'parentTaskId' | 'ownerId' | 'updateTime'
|
||||
'keyword' | 'parentTaskId' | 'ownerId' | 'priority' | 'updateTime'
|
||||
>;
|
||||
|
||||
export interface UseTaskBoardColumnsOptions {
|
||||
|
||||
@@ -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 {
|
||||
@@ -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
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
fetchGetProjectExecutionStatusBoard,
|
||||
fetchGetProjectMembers,
|
||||
fetchInactiveProjectExecutionAssignee,
|
||||
fetchPrecheckDeleteProjectExecution,
|
||||
fetchUpdateProjectExecution
|
||||
} from '@/service/api';
|
||||
import { useObjectContextStore } from '@/store/modules/object-context';
|
||||
@@ -39,6 +40,7 @@ function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParam
|
||||
executionType: undefined,
|
||||
ownerId: undefined,
|
||||
statusCode: undefined,
|
||||
priority: undefined,
|
||||
updateTime: undefined
|
||||
};
|
||||
}
|
||||
@@ -90,6 +92,7 @@ const statusActionTitle = computed(() =>
|
||||
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
|
||||
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
|
||||
const deleteDialogVisible = ref(false);
|
||||
const deleteExecutionDependentSummary = ref<string | null>(null);
|
||||
const { canCreateTopLevelTask } = useTaskPermissions();
|
||||
// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置
|
||||
// 选中的执行 = null 时按钮隐藏(无对象上下文可判)
|
||||
@@ -298,6 +301,7 @@ 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);
|
||||
@@ -370,9 +374,48 @@ async function handleInactiveExecutionAssignee(
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
// 简化路径出错多为缓存陈旧(completed 被改/并发删),兜底刷新让用户看到最新状态
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
return;
|
||||
}
|
||||
window.$message?.success('删除成功');
|
||||
selectedExecution.value = null;
|
||||
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
|
||||
}
|
||||
|
||||
async function confirmDeleteExecution(payload: { name: string; confirmText: string; reason: string }) {
|
||||
@@ -456,6 +499,7 @@ watch(
|
||||
v-model:visible="operateVisible"
|
||||
:mode="operateMode"
|
||||
:row-data="editingExecution"
|
||||
:project-id="projectId"
|
||||
:user-options="projectMemberOptions"
|
||||
:current-assignees="editingExecutionAssignees"
|
||||
@submit="handleExecutionSubmit"
|
||||
@@ -483,6 +527,7 @@ watch(
|
||||
v-model:visible="deleteDialogVisible"
|
||||
object-type="execution"
|
||||
:object-name="selectedExecution?.executionName ?? ''"
|
||||
:dependent-summary="deleteExecutionDependentSummary"
|
||||
:on-confirm="confirmDeleteExecution"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -27,6 +27,8 @@ export function createEmptyExecution(projectId: string): Api.Project.ProjectExec
|
||||
id: '',
|
||||
projectId,
|
||||
projectRequirementId: null,
|
||||
projectRequirementName: null,
|
||||
projectRequirementStatusCode: null,
|
||||
executionName: '',
|
||||
executionType: null,
|
||||
ownerId: '',
|
||||
@@ -41,6 +43,8 @@ export function createEmptyExecution(projectId: string): Api.Project.ProjectExec
|
||||
actualStartDate: null,
|
||||
actualEndDate: null,
|
||||
progressRate: 0,
|
||||
priority: '1',
|
||||
priorityName: null,
|
||||
executionDesc: null,
|
||||
lastStatusReason: null,
|
||||
createTime: 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,8 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, markRaw } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import type { PaginationProps } from 'element-plus';
|
||||
import { Calendar, Flag, Plus, TrendCharts, User } from '@element-plus/icons-vue';
|
||||
import { Calendar, Flag, Link, Plus, TrendCharts, User } from '@element-plus/icons-vue';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
||||
import DictSelect from '@/components/custom/dict-select.vue';
|
||||
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';
|
||||
@@ -51,6 +56,19 @@ interface Emits {
|
||||
|
||||
const emit = defineEmits<Emits>();
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
function handleRequirementClick(row: Api.Project.ProjectExecution) {
|
||||
if (!row.projectRequirementId) return;
|
||||
router.push({
|
||||
path: '/project/project/requirement',
|
||||
query: {
|
||||
objectId: row.projectId,
|
||||
requirementId: row.projectRequirementId
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
|
||||
|
||||
function handleSearch() {
|
||||
@@ -71,6 +89,11 @@ function handleOwnerSelect(id: string | null | undefined) {
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handlePrioritySelect(value: string | number | null | undefined) {
|
||||
searchModel.value.priority = value ? String(value) : undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
function handleReset() {
|
||||
emit('reset');
|
||||
}
|
||||
@@ -100,6 +123,11 @@ function handleStatusClick(status: ExecutionStatusFilter) {
|
||||
emit('status-change', status);
|
||||
}
|
||||
|
||||
function getProjectRequirementStatusName(code: string | null) {
|
||||
if (!code) return '';
|
||||
return projectRequirementStatusRecord[code as keyof typeof projectRequirementStatusRecord] ?? code;
|
||||
}
|
||||
|
||||
function handleSelect(row: Api.Project.ProjectExecution) {
|
||||
emit('select', row);
|
||||
}
|
||||
@@ -240,6 +268,18 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="execution-list-panel__filter">
|
||||
<DictSelect
|
||||
:model-value="searchModel.priority ?? null"
|
||||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
class="execution-priority-select"
|
||||
placeholder="筛选优先级"
|
||||
clearable
|
||||
show-remark
|
||||
@update:model-value="handlePrioritySelect"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="execution-status-grid" aria-label="执行状态筛选">
|
||||
<button
|
||||
v-for="item in statusItems"
|
||||
@@ -277,6 +317,13 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
|
||||
>
|
||||
{{ row.executionName || '未命名执行' }}
|
||||
</strong>
|
||||
<DictTag
|
||||
class="execution-item__status-tag"
|
||||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
:value="row.priority"
|
||||
effect="light"
|
||||
size="small"
|
||||
/>
|
||||
<ElTag
|
||||
class="execution-item__status-tag"
|
||||
:type="getExecutionStatusTagType(row.statusCode)"
|
||||
@@ -313,6 +360,25 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
|
||||
<ElIcon><Calendar /></ElIcon>
|
||||
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
|
||||
</span>
|
||||
<span v-if="row.projectRequirementId && row.projectRequirementName" class="execution-item__requirement">
|
||||
<ElIcon><Link /></ElIcon>
|
||||
<ElTooltip
|
||||
:content="getProjectRequirementStatusName(row.projectRequirementStatusCode)"
|
||||
placement="top"
|
||||
:show-after="200"
|
||||
:disabled="!row.projectRequirementStatusCode"
|
||||
>
|
||||
<span
|
||||
class="execution-item__requirement-name"
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@click.stop="handleRequirementClick(row)"
|
||||
@keydown.enter.stop.prevent="handleRequirementClick(row)"
|
||||
>
|
||||
{{ row.projectRequirementName }}
|
||||
</span>
|
||||
</ElTooltip>
|
||||
</span>
|
||||
<span class="execution-item__progress-row">
|
||||
<ElIcon><TrendCharts /></ElIcon>
|
||||
<ElProgress class="execution-item__progress" :percentage="row.progressRate" :stroke-width="6" />
|
||||
@@ -325,7 +391,7 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
|
||||
|
||||
<div v-if="paginationVisible" class="execution-list-panel__pagination">
|
||||
<ElPagination
|
||||
small
|
||||
size="small"
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
v-bind="pagination"
|
||||
@@ -372,6 +438,16 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-list-panel__filter {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.execution-priority-select {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.execution-search-input {
|
||||
width: 140px;
|
||||
flex: 0 0 auto;
|
||||
@@ -567,6 +643,26 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.execution-item__requirement {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 320px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.execution-item__requirement-name {
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--el-color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.execution-item__requirement-name:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.execution-item__actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -188,7 +188,7 @@ defineExpose({ reset });
|
||||
:total="assignees.length"
|
||||
layout="total, prev, pager, next"
|
||||
background
|
||||
small
|
||||
size="small"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ function getOperatorDisplay(row: Api.Project.TaskAssigneeLog) {
|
||||
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,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
|
||||
import { Calendar, Flag, 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, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
|
||||
import { useTaskActions } from '../composables/use-task-actions';
|
||||
import { type BoardBaseParams, useTaskBoardColumns } from '../composables/use-task-board-columns';
|
||||
@@ -116,9 +118,12 @@ onBeforeUnmount(() => {
|
||||
>
|
||||
<div class="task-board-card-item__top">
|
||||
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
|
||||
<ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small">
|
||||
{{ getTaskStatusName(task) }}
|
||||
</ElTag>
|
||||
<div class="task-board-card-item__top-tags">
|
||||
<DictTag :dict-code="RDMS_REQ_PRIORITY_DICT_CODE" :value="task.priority" effect="light" size="small" />
|
||||
<ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small">
|
||||
{{ getTaskStatusName(task) }}
|
||||
</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="task-board-card-item__meta">
|
||||
@@ -237,6 +242,13 @@ onBeforeUnmount(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.task-board-card-item__top-tags {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.task-board-card-item__title {
|
||||
min-width: 0;
|
||||
color: rgb(15 23 42 / 94%);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_DICT_CODE } from '@/constants/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import BusinessFormSection from '@/components/custom/business-form-section.vue';
|
||||
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
|
||||
@@ -27,6 +27,7 @@ const ownerId = computed(() => props.task?.ownerId ?? null);
|
||||
const parentTaskId = computed(() => props.task?.parentTaskId ?? null);
|
||||
const plannedStartDate = computed(() => props.task?.plannedStartDate ?? null);
|
||||
const plannedEndDate = computed(() => props.task?.plannedEndDate ?? null);
|
||||
const priority = computed(() => props.task?.priority ?? null);
|
||||
const attachments = computed(() => props.task?.attachments ?? []);
|
||||
|
||||
const assigneeIds = computed(() => props.task?.assignees?.map(a => a.userId) ?? []);
|
||||
@@ -57,6 +58,15 @@ const parentTaskOptions = computed(() => {
|
||||
<ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" />
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="优先级">
|
||||
<DictSelect
|
||||
:model-value="priority"
|
||||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
disabled
|
||||
placeholder="--"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
<ElFormItem label="负责人">
|
||||
<BusinessUserSelect :model-value="ownerId" :options="userOptions" disabled placeholder="--" />
|
||||
</ElFormItem>
|
||||
|
||||
@@ -2,7 +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 { RDMS_REQ_PRIORITY_DICT_CODE, RDMS_TASK_ITEM_TYPE_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';
|
||||
@@ -64,6 +64,7 @@ interface FormModel {
|
||||
ownerId: string | null;
|
||||
plannedStartDate: string | null;
|
||||
plannedEndDate: string | null;
|
||||
priority: string;
|
||||
taskDesc: string | null;
|
||||
assigneeUserIds: string[];
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
@@ -76,6 +77,7 @@ const model = reactive<FormModel>({
|
||||
ownerId: null,
|
||||
plannedStartDate: null,
|
||||
plannedEndDate: null,
|
||||
priority: '3',
|
||||
taskDesc: null,
|
||||
assigneeUserIds: [],
|
||||
attachments: []
|
||||
@@ -125,6 +127,7 @@ const rules = computed(
|
||||
({
|
||||
taskTitle: [createRequiredRule('请输入任务名称')],
|
||||
type: [createRequiredRule('请选择任务类型')],
|
||||
priority: [createRequiredRule('请选择优先级')],
|
||||
ownerId: model.parentTaskId ? [] : [createRequiredRule('请选择负责人')],
|
||||
plannedStartDate: [createRequiredRule('请选择计划开始日期')],
|
||||
plannedEndDate: [
|
||||
@@ -236,6 +239,7 @@ async function handleConfirm() {
|
||||
ownerId: model.ownerId || null,
|
||||
plannedStartDate: model.plannedStartDate,
|
||||
plannedEndDate: model.plannedEndDate,
|
||||
priority: model.priority,
|
||||
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null),
|
||||
attachments: [...model.attachments]
|
||||
};
|
||||
@@ -256,17 +260,22 @@ function handleAssigneeChange(value: string[]) {
|
||||
model.assigneeUserIds = cleaned;
|
||||
}
|
||||
|
||||
function applyBasicFieldsFromRow(row: Api.Project.ProjectTask | null) {
|
||||
model.taskTitle = row?.taskTitle || '';
|
||||
model.type = row?.type || '';
|
||||
model.ownerId = row?.ownerId || null;
|
||||
model.plannedStartDate = row?.plannedStartDate || null;
|
||||
model.plannedEndDate = row?.plannedEndDate || null;
|
||||
model.priority = row?.priority || '3';
|
||||
model.taskDesc = row?.taskDesc || null;
|
||||
}
|
||||
|
||||
function applyRowDataToModel() {
|
||||
model.parentTaskId =
|
||||
props.mode === 'create' ? (props.defaultParentTaskId ?? null) : props.rowData?.parentTaskId || null;
|
||||
model.taskTitle = props.rowData?.taskTitle || '';
|
||||
model.type = props.rowData?.type || '';
|
||||
model.ownerId = props.rowData?.ownerId || null;
|
||||
model.plannedStartDate = props.rowData?.plannedStartDate || null;
|
||||
model.plannedEndDate = props.rowData?.plannedEndDate || null;
|
||||
model.taskDesc = props.rowData?.taskDesc || null;
|
||||
const row = props.rowData;
|
||||
model.parentTaskId = props.mode === 'create' ? (props.defaultParentTaskId ?? null) : row?.parentTaskId || null;
|
||||
applyBasicFieldsFromRow(row);
|
||||
model.assigneeUserIds = [];
|
||||
model.attachments = props.rowData?.attachments ? [...props.rowData.attachments] : [];
|
||||
model.attachments = row?.attachments ? [...row.attachments] : [];
|
||||
}
|
||||
|
||||
watch(
|
||||
@@ -344,6 +353,15 @@ defineExpose({
|
||||
</ElSelect>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="优先级" prop="priority">
|
||||
<DictSelect
|
||||
v-model="model.priority"
|
||||
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
|
||||
placeholder="请选择优先级"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
|
||||
<ElFormItem label="负责人" prop="ownerId">
|
||||
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
|
||||
</ElFormItem>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
||||
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
|
||||
|
||||
defineOptions({ name: 'ProjectExecutionTaskSearch' });
|
||||
@@ -36,6 +37,14 @@ const fields = computed<SearchField[]>(() => [
|
||||
type: 'input',
|
||||
placeholder: '任务名称/说明'
|
||||
},
|
||||
{
|
||||
key: 'priority',
|
||||
label: '优先级',
|
||||
type: 'dict',
|
||||
dictCode: RDMS_REQ_PRIORITY_DICT_CODE,
|
||||
placeholder: '全部优先级',
|
||||
showRemark: true
|
||||
},
|
||||
{
|
||||
key: 'statusCode',
|
||||
label: '状态',
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, toRef } from 'vue';
|
||||
import type { PaginationProps } from 'element-plus';
|
||||
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
|
||||
import DictTag from '@/components/custom/dict-tag.vue';
|
||||
import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
|
||||
import { useTaskActions } from '../composables/use-task-actions';
|
||||
|
||||
@@ -87,6 +89,11 @@ function handleSizeChange(pageSize: number) {
|
||||
<ElTag effect="plain" :type="getTaskStatusTagType(row.statusCode)">{{ getTaskStatusName(row) }}</ElTag>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="优先级" width="90" align="center">
|
||||
<template #default="{ row }">
|
||||
<DictTag :dict-code="RDMS_REQ_PRIORITY_DICT_CODE" :value="row.priority" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="负责人" min-width="120" show-overflow-tooltip>
|
||||
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
|
||||
</ElTableColumn>
|
||||
|
||||
@@ -62,7 +62,8 @@ interface FormModel {
|
||||
/** 0.5 颗粒小时数 */
|
||||
durationHours: number | null;
|
||||
progressRate: number;
|
||||
difficulty: string;
|
||||
/** 完成难度,字典 rdms_worklog_difficulty 的 value,默认 "2";用户清空后为 null,由 required 校验拦截 */
|
||||
difficulty: string | null;
|
||||
workContent: string | null;
|
||||
attachments: Api.Project.AttachmentItem[];
|
||||
}
|
||||
@@ -102,11 +103,20 @@ const weekDateShortcuts = [
|
||||
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() }
|
||||
];
|
||||
|
||||
// EP type='week' 默认 firstDayOfWeek=7,从日历点选时返回当周"周日"作为周首日。
|
||||
// 我们按 ISO 周(周一-周日)存储 / 展示,遇到周日时先 +1 天,避免 startOf('isoWeek') 回退到上一周。
|
||||
function resolveIsoWeekStart(weekDate: Date | null) {
|
||||
if (!weekDate) return null;
|
||||
const picked = dayjs(weekDate);
|
||||
if (!picked.isValid()) return null;
|
||||
const aligned = picked.isoWeekday() === 7 ? picked.add(1, 'day') : picked;
|
||||
return aligned.startOf('isoWeek');
|
||||
}
|
||||
|
||||
// 选中后鼠标悬浮 input 显示该周的起止日期(input 里默认只显示 "YYYY年第W周")
|
||||
const weekRangeTooltip = computed(() => {
|
||||
if (!model.weekDate) return '';
|
||||
const start = dayjs(model.weekDate);
|
||||
if (!start.isValid()) return '';
|
||||
const start = resolveIsoWeekStart(model.weekDate);
|
||||
if (!start) return '';
|
||||
const end = start.add(6, 'day');
|
||||
return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`;
|
||||
});
|
||||
@@ -184,7 +194,7 @@ const rules = computed(
|
||||
trigger: 'change'
|
||||
}
|
||||
],
|
||||
difficulty: [createRequiredRule('请选择难度')],
|
||||
difficulty: [createRequiredRule('请选择完成难度')],
|
||||
workContent: [
|
||||
{
|
||||
required: true,
|
||||
@@ -251,7 +261,7 @@ function getStartEndFromModel(): { startDate: string; endDate: string } {
|
||||
if (model.granularity === 'day') {
|
||||
return { startDate: model.workDate!, endDate: model.workDate! };
|
||||
}
|
||||
const weekStart = dayjs(model.weekDate!).startOf('isoWeek');
|
||||
const weekStart = resolveIsoWeekStart(model.weekDate)!;
|
||||
return {
|
||||
startDate: weekStart.format('YYYY-MM-DD'),
|
||||
endDate: weekStart.add(6, 'day').format('YYYY-MM-DD')
|
||||
@@ -287,7 +297,7 @@ async function handleConfirm() {
|
||||
endDate,
|
||||
durationHours: Number(model.durationHours!.toFixed(1)),
|
||||
progressRate: Number(model.progressRate.toFixed(2)),
|
||||
difficulty: model.difficulty,
|
||||
difficulty: model.difficulty!,
|
||||
workContent: model.workContent?.trim() || null,
|
||||
attachments: [...model.attachments]
|
||||
};
|
||||
@@ -315,6 +325,7 @@ watch(
|
||||
model.weekDate = null;
|
||||
}
|
||||
model.durationHours = typeof row.durationHours === 'number' ? row.durationHours : null;
|
||||
// PUT 需全字段回传,回显时按 row 原值;row.difficulty 兜底已在 normalize 层
|
||||
model.difficulty = row.difficulty || '2';
|
||||
model.workContent = row.workContent || null;
|
||||
model.attachments = row.attachments ? [...row.attachments] : [];
|
||||
@@ -389,7 +400,7 @@ defineExpose({
|
||||
</ElTooltip>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElCol :span="8">
|
||||
<ElFormItem label="时长(小时)" prop="durationHours">
|
||||
<ElInputNumber
|
||||
v-model="model.durationHours"
|
||||
@@ -403,7 +414,7 @@ defineExpose({
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElCol :span="8">
|
||||
<ElFormItem label="进度(%)" prop="progressRate">
|
||||
<ElInputNumber
|
||||
v-model="model.progressRate"
|
||||
@@ -417,13 +428,14 @@ defineExpose({
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="12">
|
||||
<ElFormItem label="难度" prop="difficulty">
|
||||
<ElCol :span="8">
|
||||
<ElFormItem label="完成难度" prop="difficulty">
|
||||
<DictSelect
|
||||
v-model="model.difficulty"
|
||||
:dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE"
|
||||
placeholder="请选择完成难度"
|
||||
:disabled="isView"
|
||||
:clearable="false"
|
||||
show-remark
|
||||
/>
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
@@ -441,7 +453,7 @@ defineExpose({
|
||||
</ElFormItem>
|
||||
</ElCol>
|
||||
<ElCol :span="24">
|
||||
<ElFormItem label="附件">
|
||||
<ElFormItem label="附件" class="task-worklog-form-dialog__attachment-item">
|
||||
<BusinessAttachmentUploader
|
||||
ref="attachmentUploaderRef"
|
||||
v-model="model.attachments"
|
||||
@@ -468,4 +480,10 @@ defineExpose({
|
||||
display: block;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 预留附件区域最小高度(触发器一行 + 2 条附件占位),避免前两条附件加进去时 align-center dialog 重新居中产生抖动 */
|
||||
.task-worklog-form-dialog__attachment-item :deep(.el-form-item__content) {
|
||||
min-height: 120px;
|
||||
align-content: flex-start;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { Plus } from '@element-plus/icons-vue';
|
||||
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
|
||||
import {
|
||||
fetchCreateProjectTaskWorklog,
|
||||
fetchDeleteProjectTaskWorklog,
|
||||
@@ -8,7 +9,9 @@ import {
|
||||
fetchUpdateProjectTaskWorklog
|
||||
} from '@/service/api/project';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useDict } from '@/hooks/business/dict';
|
||||
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
|
||||
import DictTag from '@/components/custom/dict-tag.vue';
|
||||
import { formatWorklogPeriod, getWorklogGranularityName } from '../shared';
|
||||
import type { WorklogChangedPayload } from '../shared';
|
||||
import TaskWorklogFormDialog from './task-worklog-form-dialog.vue';
|
||||
@@ -70,6 +73,28 @@ const userFilter = ref<string[]>([]);
|
||||
const userFilterPopoverVisible = ref(false);
|
||||
const pendingUserFilter = ref<string[]>([]);
|
||||
|
||||
// 完成难度筛选(单选,对齐后端 eqIfPresent)。'' = 未筛选
|
||||
const difficultyFilter = ref<string>('');
|
||||
const difficultyFilterPopoverVisible = ref(false);
|
||||
const pendingDifficultyFilter = ref<string>('');
|
||||
|
||||
const { enabledDictData: difficultyDictItems } = useDict(RDMS_WORKLOG_DIFFICULTY_DICT_CODE);
|
||||
|
||||
function handleDifficultyFilterConfirm() {
|
||||
difficultyFilter.value = pendingDifficultyFilter.value;
|
||||
difficultyFilterPopoverVisible.value = false;
|
||||
}
|
||||
|
||||
function handleDifficultyFilterReset() {
|
||||
pendingDifficultyFilter.value = '';
|
||||
}
|
||||
|
||||
watch(difficultyFilterPopoverVisible, value => {
|
||||
if (value) {
|
||||
pendingDifficultyFilter.value = difficultyFilter.value;
|
||||
}
|
||||
});
|
||||
|
||||
interface UserFilterRichOption {
|
||||
value: string;
|
||||
name: string;
|
||||
@@ -155,11 +180,14 @@ watch(userFilterPopoverVisible, value => {
|
||||
});
|
||||
|
||||
const filteredExternalList = computed<Api.Project.TaskWorklog[]>(() => {
|
||||
const all = props.externalList ?? [];
|
||||
if (!props.showAssigneeColumn || userFilter.value.length === 0) {
|
||||
return all;
|
||||
let result = props.externalList ?? [];
|
||||
if (props.showAssigneeColumn && userFilter.value.length > 0) {
|
||||
result = result.filter(item => userFilter.value.includes(item.userId));
|
||||
}
|
||||
return all.filter(item => userFilter.value.includes(item.userId));
|
||||
if (difficultyFilter.value) {
|
||||
result = result.filter(item => item.difficulty === difficultyFilter.value);
|
||||
}
|
||||
return result;
|
||||
});
|
||||
|
||||
const total = computed(() => (usingExternal.value ? filteredExternalList.value.length : internalTotal.value));
|
||||
@@ -237,6 +265,10 @@ async function loadList() {
|
||||
if (!isOwner.value && props.canSubmit && currentUserId.value) {
|
||||
params.userId = currentUserId.value;
|
||||
}
|
||||
// 难度筛选:未选时不传参(避免 eqIfPresent 把空字符串当筛选条件漏掉全部)
|
||||
if (difficultyFilter.value) {
|
||||
params.difficulty = difficultyFilter.value;
|
||||
}
|
||||
|
||||
const { error, data } = await fetchGetProjectTaskWorklogPage(
|
||||
props.projectId,
|
||||
@@ -351,12 +383,21 @@ watch(userFilter, () => {
|
||||
pageNo.value = 1;
|
||||
});
|
||||
|
||||
watch(difficultyFilter, () => {
|
||||
pageNo.value = 1;
|
||||
if (!usingExternal.value) {
|
||||
loadList();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.taskId,
|
||||
() => {
|
||||
pageNo.value = 1;
|
||||
userFilter.value = [];
|
||||
userFilterPopoverVisible.value = false;
|
||||
difficultyFilter.value = '';
|
||||
difficultyFilterPopoverVisible.value = false;
|
||||
loadList();
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -515,6 +556,59 @@ watch(
|
||||
<span v-else class="task-worklog-panel__content-cell-empty">--</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="难度" width="110" align="center">
|
||||
<template #header>
|
||||
<div class="task-worklog-panel__user-header">
|
||||
<span>难度</span>
|
||||
<ElPopover
|
||||
v-model:visible="difficultyFilterPopoverVisible"
|
||||
trigger="click"
|
||||
placement="bottom"
|
||||
:width="180"
|
||||
popper-class="task-worklog-panel__user-filter-popper"
|
||||
>
|
||||
<template #reference>
|
||||
<span
|
||||
class="task-worklog-panel__user-filter-trigger"
|
||||
:class="{ 'is-active': !!difficultyFilter }"
|
||||
@click.stop
|
||||
>
|
||||
<IconMdiFilterVariant />
|
||||
</span>
|
||||
</template>
|
||||
<div class="task-worklog-panel__user-filter">
|
||||
<ElRadioGroup v-model="pendingDifficultyFilter" class="task-worklog-panel__difficulty-filter-list">
|
||||
<ElRadio
|
||||
v-for="option in difficultyDictItems"
|
||||
:key="option.value"
|
||||
:value="option.value"
|
||||
class="task-worklog-panel__difficulty-filter-row"
|
||||
>
|
||||
<span class="task-worklog-panel__difficulty-filter-item">
|
||||
<span
|
||||
v-if="option.colorType"
|
||||
class="task-worklog-panel__difficulty-filter-dot"
|
||||
:style="{ background: option.colorType }"
|
||||
/>
|
||||
<span class="task-worklog-panel__difficulty-filter-label">{{ option.label }}</span>
|
||||
<span v-if="option.remark" class="task-worklog-panel__difficulty-filter-remark">
|
||||
{{ option.remark }}
|
||||
</span>
|
||||
</span>
|
||||
</ElRadio>
|
||||
</ElRadioGroup>
|
||||
<div class="task-worklog-panel__user-filter-footer">
|
||||
<ElButton size="small" link @click="handleDifficultyFilterReset">重置</ElButton>
|
||||
<ElButton size="small" type="primary" @click="handleDifficultyFilterConfirm">确定</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</ElPopover>
|
||||
</div>
|
||||
</template>
|
||||
<template #default="{ row }">
|
||||
<DictTag :dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE" :value="row.difficulty" size="small" effect="light" />
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn label="时长" width="100" align="center">
|
||||
<template #default="{ row }">
|
||||
<span class="task-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
|
||||
@@ -579,7 +673,7 @@ watch(
|
||||
<div class="task-worklog-panel__pagination">
|
||||
<ElPagination
|
||||
v-if="total > 0"
|
||||
small
|
||||
size="small"
|
||||
background
|
||||
layout="total, prev, pager, next"
|
||||
:current-page="pageNo"
|
||||
@@ -865,4 +959,48 @@ watch(
|
||||
border-top: 1px solid var(--el-border-color-lighter);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.task-worklog-panel__difficulty-filter-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.task-worklog-panel__difficulty-filter-row.el-radio {
|
||||
height: auto;
|
||||
margin: 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.task-worklog-panel__difficulty-filter-row .el-radio__label {
|
||||
padding-left: 6px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.task-worklog-panel__difficulty-filter-item {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.task-worklog-panel__difficulty-filter-dot {
|
||||
display: inline-block;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.task-worklog-panel__difficulty-filter-label {
|
||||
color: var(--el-text-color-primary);
|
||||
}
|
||||
|
||||
.task-worklog-panel__difficulty-filter-remark {
|
||||
margin-left: auto;
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
fetchGetProjectTaskPage,
|
||||
fetchGetProjectTaskStatusBoard,
|
||||
fetchInactiveProjectTaskAssignee,
|
||||
fetchPrecheckDeleteProjectTask,
|
||||
fetchUpdateProjectTask
|
||||
} from '@/service/api/project';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
@@ -90,6 +91,7 @@ const detailDialogDefaultTab = ref<'info' | 'worklog'>('info');
|
||||
|
||||
const deleteTaskDialogVisible = ref(false);
|
||||
const deleteTaskTarget = ref<Api.Project.ProjectTask | null>(null);
|
||||
const deleteTaskDependentSummary = ref<string | null>(null);
|
||||
|
||||
const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
|
||||
pageNo: 1,
|
||||
@@ -98,6 +100,7 @@ const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
|
||||
parentTaskId: undefined,
|
||||
ownerId: undefined,
|
||||
statusCode: undefined,
|
||||
priority: undefined,
|
||||
updateTime: undefined
|
||||
});
|
||||
|
||||
@@ -137,6 +140,16 @@ function createStatusBoardParams(): Api.Project.ProjectTaskStatusBoardParams {
|
||||
};
|
||||
}
|
||||
|
||||
function createBoardBaseParams() {
|
||||
return {
|
||||
keyword: searchParams.keyword?.trim() || undefined,
|
||||
parentTaskId: searchParams.parentTaskId,
|
||||
ownerId: searchParams.ownerId,
|
||||
priority: searchParams.priority,
|
||||
updateTime: searchParams.updateTime
|
||||
};
|
||||
}
|
||||
|
||||
function transformTaskPage(response: TaskPageResponse, pageNo: number, pageSize: number) {
|
||||
if (!response.error && response.data) {
|
||||
return {
|
||||
@@ -189,6 +202,7 @@ function resetSearchParams() {
|
||||
searchParams.parentTaskId = undefined;
|
||||
searchParams.ownerId = undefined;
|
||||
searchParams.statusCode = undefined;
|
||||
searchParams.priority = undefined;
|
||||
searchParams.updateTime = undefined;
|
||||
}
|
||||
|
||||
@@ -211,12 +225,15 @@ async function handleReset() {
|
||||
await Promise.all([refreshTableData(true), loadTaskStatusBoard()]);
|
||||
}
|
||||
|
||||
function handleCreate() {
|
||||
async function handleCreate() {
|
||||
if (!props.execution) {
|
||||
window.$message?.warning('请先选择执行项');
|
||||
return;
|
||||
}
|
||||
|
||||
// 打开新增弹层前实时拉一次协办人列表,避免在执行侧改完成员(加/换负责人)后仍看到旧候选
|
||||
await loadExecutionAssigneeOptions();
|
||||
|
||||
operateMode.value = 'create';
|
||||
currentTask.value = null;
|
||||
presetParentTaskId.value = null;
|
||||
@@ -234,7 +251,8 @@ async function getTaskDetail(row: Api.Project.ProjectTask) {
|
||||
}
|
||||
|
||||
async function handleEdit(row: Api.Project.ProjectTask) {
|
||||
const detail = await getTaskDetail(row);
|
||||
// 同 handleCreate:编辑前一并刷新协办人,免得改过执行成员后这里仍是缓存
|
||||
const [detail] = await Promise.all([getTaskDetail(row), loadExecutionAssigneeOptions()]);
|
||||
|
||||
if (!detail.allowEdit) {
|
||||
window.$message?.warning('当前任务状态不允许编辑');
|
||||
@@ -476,9 +494,51 @@ async function loadExecutionAssigneeOptions() {
|
||||
}));
|
||||
}
|
||||
|
||||
function openDeleteTaskDialog(task: Api.Project.ProjectTask) {
|
||||
deleteTaskTarget.value = task;
|
||||
deleteTaskDialogVisible.value = true;
|
||||
async function openDeleteTaskDialog(task: Api.Project.ProjectTask) {
|
||||
// 无下挂走简单二次确认;有/查询异常走原重型弹层。precheck 含子任务 + 工作日志双口径
|
||||
const precheck = await fetchPrecheckDeleteProjectTask(task.projectId, task.executionId, task.id);
|
||||
const canDirectDelete = !precheck.error && precheck.data && !precheck.data.hasDependentData;
|
||||
|
||||
if (!canDirectDelete) {
|
||||
deleteTaskTarget.value = task;
|
||||
if (precheck.data) {
|
||||
const parts: string[] = [];
|
||||
if (precheck.data.childTaskCount > 0) parts.push(`${precheck.data.childTaskCount} 个子任务`);
|
||||
if (precheck.data.worklogCount > 0) parts.push(`${precheck.data.worklogCount} 条工作日志`);
|
||||
deleteTaskDependentSummary.value = parts.length ? `下含 ${parts.join(' + ')}` : null;
|
||||
} else {
|
||||
deleteTaskDependentSummary.value = null;
|
||||
}
|
||||
deleteTaskDialogVisible.value = true;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await window.$messageBox?.confirm(
|
||||
`确定要删除任务“${task.taskTitle}”吗?删除后将不可见,如需恢复请联系管理员。`,
|
||||
'删除确认',
|
||||
{
|
||||
confirmButtonText: '确认删除',
|
||||
cancelButtonText: '取消',
|
||||
type: 'warning'
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const { error } = await fetchDeleteProjectTask(task.projectId, task.executionId, task.id, {
|
||||
taskName: task.taskTitle,
|
||||
confirmText: 'DELETE',
|
||||
reason: '无下挂数据,用户已二次确认'
|
||||
});
|
||||
if (error) {
|
||||
// 简化路径出错多为缓存陈旧(completed 被改/并发删),兜底刷新让用户看到最新状态
|
||||
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
|
||||
return;
|
||||
}
|
||||
window.$message?.success('删除成功');
|
||||
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
|
||||
}
|
||||
|
||||
async function confirmDeleteTask(payload: { name: string; confirmText: string; reason: string }) {
|
||||
@@ -582,7 +642,7 @@ watch(viewMode, async mode => {
|
||||
:project-id="projectId"
|
||||
:execution-id="executionId"
|
||||
:status-board="taskStatusBoard"
|
||||
:base-params="createStatusBoardParams()"
|
||||
:base-params="createBoardBaseParams()"
|
||||
@detail="handleDetail"
|
||||
@edit="handleEdit"
|
||||
@report="handleReport"
|
||||
@@ -644,6 +704,7 @@ watch(viewMode, async mode => {
|
||||
v-model:visible="deleteTaskDialogVisible"
|
||||
object-type="task"
|
||||
:object-name="deleteTaskTarget?.taskTitle ?? ''"
|
||||
:dependent-summary="deleteTaskDependentSummary"
|
||||
:on-confirm="confirmDeleteTask"
|
||||
/>
|
||||
</section>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup lang="tsx">
|
||||
import { computed, reactive, ref, watch } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
import { ElButton, ElTag, ElTooltip } from 'element-plus';
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
@@ -86,6 +86,7 @@ const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
|
||||
3: 'danger'
|
||||
};
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
const { currentObjectId, currentProject } = useCurrentProject();
|
||||
const { hasObjectAuth } = useAuth();
|
||||
@@ -758,6 +759,24 @@ watch(
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
watch(
|
||||
() => [route.query.requirementId, treeData.value] as const,
|
||||
([targetId]) => {
|
||||
if (!targetId) return;
|
||||
const idStr = String(targetId);
|
||||
const flat = flattenTree(treeData.value);
|
||||
const found = flat.find(item => item.id === idStr);
|
||||
if (found) {
|
||||
openView(found);
|
||||
// 清掉 requirementId query,保留其它参数
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const { requirementId: _rid, ...restQuery } = route.query;
|
||||
router.replace({ query: restQuery });
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
);
|
||||
|
||||
Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
|
||||
</script>
|
||||
|
||||
|
||||
28
src/views/workbench/composables/layout-storage-local.ts
Normal file
28
src/views/workbench/composables/layout-storage-local.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import type { LayoutStorage } from './layout-storage';
|
||||
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
|
||||
|
||||
const KEY_PREFIX = 'rdms-workbench-layout';
|
||||
|
||||
function buildKey(userId: string) {
|
||||
return `${KEY_PREFIX}-${userId}`;
|
||||
}
|
||||
|
||||
export class LocalStorageAdapter implements LayoutStorage {
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
async load(userId: string): Promise<WorkbenchLayout | null> {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(buildKey(userId));
|
||||
if (!raw) return null;
|
||||
const parsed = JSON.parse(raw) as WorkbenchLayout;
|
||||
if (parsed?.version !== WORKBENCH_LAYOUT_VERSION) return null;
|
||||
return parsed;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// eslint-disable-next-line class-methods-use-this
|
||||
async save(userId: string, layout: WorkbenchLayout): Promise<void> {
|
||||
window.localStorage.setItem(buildKey(userId), JSON.stringify(layout));
|
||||
}
|
||||
}
|
||||
6
src/views/workbench/composables/layout-storage.ts
Normal file
6
src/views/workbench/composables/layout-storage.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import type { WorkbenchLayout } from './workbench-layout-types';
|
||||
|
||||
export interface LayoutStorage {
|
||||
load(userId: string): Promise<WorkbenchLayout | null>;
|
||||
save(userId: string, layout: WorkbenchLayout): Promise<void>;
|
||||
}
|
||||
158
src/views/workbench/composables/use-workbench-layout.ts
Normal file
158
src/views/workbench/composables/use-workbench-layout.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { type WorkbenchColumnId, type WorkbenchModuleKey, useWorkbenchModules } from './use-workbench-modules';
|
||||
import { buildDefaultLayout } from './workbench-layout-default';
|
||||
import type { LayoutStorage } from './layout-storage';
|
||||
import { LocalStorageAdapter } from './layout-storage-local';
|
||||
import { reconcileLayout } from './workbench-layout-reconcile';
|
||||
import type { WorkbenchLayout } from './workbench-layout-types';
|
||||
|
||||
export type WorkbenchMode = 'normal' | 'editing';
|
||||
|
||||
interface UseWorkbenchLayoutOptions {
|
||||
userId: string;
|
||||
storage?: LayoutStorage;
|
||||
}
|
||||
|
||||
export function useWorkbenchLayout(options: UseWorkbenchLayoutOptions) {
|
||||
const { getAllModules } = useWorkbenchModules();
|
||||
const storage = options.storage ?? new LocalStorageAdapter();
|
||||
|
||||
const layout = ref<WorkbenchLayout>(buildDefaultLayout(getAllModules()));
|
||||
const mode = ref<WorkbenchMode>('normal');
|
||||
const dirty = ref(false);
|
||||
const saving = ref(false);
|
||||
const error = ref<Error | null>(null);
|
||||
|
||||
let snapshotBeforeEdit: WorkbenchLayout | null = null;
|
||||
|
||||
async function load() {
|
||||
const fromStorage = await storage.load(options.userId);
|
||||
layout.value = reconcileLayout(fromStorage ?? buildDefaultLayout(getAllModules()), getAllModules());
|
||||
}
|
||||
|
||||
const persist = useDebounceFn(async () => {
|
||||
saving.value = true;
|
||||
error.value = null;
|
||||
try {
|
||||
await storage.save(options.userId, layout.value);
|
||||
} catch (err) {
|
||||
error.value = err as Error;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}, 500);
|
||||
|
||||
function markDirty() {
|
||||
if (mode.value === 'editing') {
|
||||
dirty.value = true;
|
||||
} else {
|
||||
// 非编辑态写(如折叠)直接落盘
|
||||
persist();
|
||||
}
|
||||
}
|
||||
|
||||
function enterEditing() {
|
||||
snapshotBeforeEdit = JSON.parse(JSON.stringify(layout.value));
|
||||
mode.value = 'editing';
|
||||
dirty.value = false;
|
||||
}
|
||||
|
||||
async function saveEditing() {
|
||||
saving.value = true;
|
||||
try {
|
||||
await storage.save(options.userId, layout.value);
|
||||
mode.value = 'normal';
|
||||
dirty.value = false;
|
||||
snapshotBeforeEdit = null;
|
||||
} catch (err) {
|
||||
error.value = err as Error;
|
||||
} finally {
|
||||
saving.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function cancelEditing() {
|
||||
if (snapshotBeforeEdit) {
|
||||
layout.value = snapshotBeforeEdit;
|
||||
}
|
||||
mode.value = 'normal';
|
||||
dirty.value = false;
|
||||
snapshotBeforeEdit = null;
|
||||
}
|
||||
|
||||
function hideModule(key: WorkbenchModuleKey) {
|
||||
for (const col of layout.value.columns) {
|
||||
col.modules = col.modules.filter(k => k !== key);
|
||||
}
|
||||
if (!layout.value.hidden.includes(key)) layout.value.hidden.push(key);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function showModule(key: WorkbenchModuleKey, columnId: WorkbenchColumnId = 'left') {
|
||||
layout.value.hidden = layout.value.hidden.filter(k => k !== key);
|
||||
const target = layout.value.columns.find(c => c.id === columnId);
|
||||
if (target && !target.modules.includes(key)) target.modules.push(key);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function setColumnModules(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) {
|
||||
const target = layout.value.columns.find(c => c.id === columnId);
|
||||
if (target) target.modules = modules;
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function toggleCollapse(key: WorkbenchModuleKey) {
|
||||
if (layout.value.collapsed.includes(key)) {
|
||||
layout.value.collapsed = layout.value.collapsed.filter(k => k !== key);
|
||||
} else {
|
||||
layout.value.collapsed.push(key);
|
||||
}
|
||||
markDirty();
|
||||
}
|
||||
|
||||
function updateModuleSettings<K extends keyof WorkbenchLayout['settings']>(
|
||||
key: K,
|
||||
value: WorkbenchLayout['settings'][K]
|
||||
) {
|
||||
layout.value.settings = { ...layout.value.settings, [key]: value };
|
||||
markDirty();
|
||||
}
|
||||
|
||||
async function resetToDefault() {
|
||||
layout.value = buildDefaultLayout(getAllModules());
|
||||
mode.value = 'normal';
|
||||
dirty.value = false;
|
||||
snapshotBeforeEdit = null;
|
||||
await storage.save(options.userId, layout.value);
|
||||
}
|
||||
|
||||
const isCollapsed = (key: WorkbenchModuleKey) => layout.value.collapsed.includes(key);
|
||||
|
||||
const hiddenMetas = computed(() => {
|
||||
const allMeta = getAllModules();
|
||||
return layout.value.hidden
|
||||
.map(k => allMeta.find(m => m.key === k))
|
||||
.filter((m): m is NonNullable<typeof m> => Boolean(m));
|
||||
});
|
||||
|
||||
return {
|
||||
layout,
|
||||
mode,
|
||||
dirty,
|
||||
saving,
|
||||
error,
|
||||
hiddenMetas,
|
||||
isCollapsed,
|
||||
load,
|
||||
enterEditing,
|
||||
saveEditing,
|
||||
cancelEditing,
|
||||
hideModule,
|
||||
showModule,
|
||||
setColumnModules,
|
||||
toggleCollapse,
|
||||
updateModuleSettings,
|
||||
resetToDefault
|
||||
};
|
||||
}
|
||||
160
src/views/workbench/composables/use-workbench-modules.ts
Normal file
160
src/views/workbench/composables/use-workbench-modules.ts
Normal file
@@ -0,0 +1,160 @@
|
||||
import type { Component } from 'vue';
|
||||
import { markRaw, shallowRef } from 'vue';
|
||||
|
||||
export type WorkbenchModuleKey =
|
||||
| 'kpi'
|
||||
| 'myTodo'
|
||||
| 'myTask'
|
||||
| 'myRequirement'
|
||||
| 'myProject'
|
||||
| 'activity'
|
||||
| 'shortcut'
|
||||
| 'teamTodo'
|
||||
| 'projectHealth'
|
||||
| 'progressChart'
|
||||
| 'favorite';
|
||||
|
||||
export type WorkbenchModuleCategory = 'personal' | 'manager' | 'tool';
|
||||
export type WorkbenchColumnId = 'left' | 'right';
|
||||
|
||||
export interface WorkbenchModuleMeta {
|
||||
key: WorkbenchModuleKey;
|
||||
component: Component;
|
||||
displayName: string;
|
||||
icon: string;
|
||||
category: WorkbenchModuleCategory;
|
||||
defaultVisible: boolean;
|
||||
defaultColumn: WorkbenchColumnId;
|
||||
defaultOrder: number;
|
||||
}
|
||||
|
||||
const placeholder = markRaw({ render: () => null });
|
||||
|
||||
const registry: WorkbenchModuleMeta[] = [
|
||||
{
|
||||
key: 'kpi',
|
||||
component: placeholder,
|
||||
displayName: 'KPI 速览',
|
||||
icon: 'mdi:view-dashboard-outline',
|
||||
category: 'personal',
|
||||
defaultVisible: true,
|
||||
defaultColumn: 'left',
|
||||
defaultOrder: 1
|
||||
},
|
||||
{
|
||||
key: 'myTodo',
|
||||
component: placeholder,
|
||||
displayName: '我的待办',
|
||||
icon: 'mdi:clipboard-text-clock-outline',
|
||||
category: 'personal',
|
||||
defaultVisible: true,
|
||||
defaultColumn: 'left',
|
||||
defaultOrder: 2
|
||||
},
|
||||
{
|
||||
key: 'myTask',
|
||||
component: placeholder,
|
||||
displayName: '我的任务',
|
||||
icon: 'mdi:checkbox-marked-circle-outline',
|
||||
category: 'personal',
|
||||
defaultVisible: true,
|
||||
defaultColumn: 'left',
|
||||
defaultOrder: 3
|
||||
},
|
||||
{
|
||||
key: 'myRequirement',
|
||||
component: placeholder,
|
||||
displayName: '我的需求',
|
||||
icon: 'mdi:file-document-multiple-outline',
|
||||
category: 'personal',
|
||||
defaultVisible: true,
|
||||
defaultColumn: 'left',
|
||||
defaultOrder: 4
|
||||
},
|
||||
{
|
||||
key: 'myProject',
|
||||
component: placeholder,
|
||||
displayName: '我参与的项目',
|
||||
icon: 'mdi:briefcase-outline',
|
||||
category: 'personal',
|
||||
defaultVisible: true,
|
||||
defaultColumn: 'right',
|
||||
defaultOrder: 1
|
||||
},
|
||||
{
|
||||
key: 'activity',
|
||||
component: placeholder,
|
||||
displayName: '最近动态',
|
||||
icon: 'mdi:timeline-outline',
|
||||
category: 'personal',
|
||||
defaultVisible: true,
|
||||
defaultColumn: 'right',
|
||||
defaultOrder: 2
|
||||
},
|
||||
{
|
||||
key: 'shortcut',
|
||||
component: placeholder,
|
||||
displayName: '快捷入口',
|
||||
icon: 'mdi:rocket-launch-outline',
|
||||
category: 'tool',
|
||||
defaultVisible: true,
|
||||
defaultColumn: 'right',
|
||||
defaultOrder: 3
|
||||
},
|
||||
{
|
||||
key: 'teamTodo',
|
||||
component: placeholder,
|
||||
displayName: '团队待办汇总',
|
||||
icon: 'mdi:account-group-outline',
|
||||
category: 'manager',
|
||||
defaultVisible: false,
|
||||
defaultColumn: 'right',
|
||||
defaultOrder: 4
|
||||
},
|
||||
{
|
||||
key: 'projectHealth',
|
||||
component: placeholder,
|
||||
displayName: '项目健康度',
|
||||
icon: 'mdi:heart-pulse',
|
||||
category: 'manager',
|
||||
defaultVisible: false,
|
||||
defaultColumn: 'right',
|
||||
defaultOrder: 5
|
||||
},
|
||||
{
|
||||
key: 'progressChart',
|
||||
component: placeholder,
|
||||
displayName: '跨项目进度图',
|
||||
icon: 'mdi:chart-bar',
|
||||
category: 'manager',
|
||||
defaultVisible: false,
|
||||
defaultColumn: 'right',
|
||||
defaultOrder: 6
|
||||
},
|
||||
{
|
||||
key: 'favorite',
|
||||
component: placeholder,
|
||||
displayName: '我的收藏',
|
||||
icon: 'mdi:star-outline',
|
||||
category: 'tool',
|
||||
defaultVisible: false,
|
||||
defaultColumn: 'right',
|
||||
defaultOrder: 7
|
||||
}
|
||||
];
|
||||
|
||||
const registryRef = shallowRef(registry);
|
||||
|
||||
export function useWorkbenchModules() {
|
||||
function getAllModules() {
|
||||
return registryRef.value;
|
||||
}
|
||||
function getModuleMeta(key: WorkbenchModuleKey) {
|
||||
return registryRef.value.find(m => m.key === key);
|
||||
}
|
||||
function registerModuleComponent(key: WorkbenchModuleKey, component: Component) {
|
||||
const target = registryRef.value.find(m => m.key === key);
|
||||
if (target) target.component = markRaw(component);
|
||||
}
|
||||
return { getAllModules, getModuleMeta, registerModuleComponent };
|
||||
}
|
||||
30
src/views/workbench/composables/workbench-layout-default.ts
Normal file
30
src/views/workbench/composables/workbench-layout-default.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import type { WorkbenchModuleMeta } from './use-workbench-modules';
|
||||
import { WORKBENCH_LAYOUT_VERSION, type WorkbenchLayout } from './workbench-layout-types';
|
||||
|
||||
export function buildDefaultLayout(modules: WorkbenchModuleMeta[]): WorkbenchLayout {
|
||||
const left = modules
|
||||
.filter(m => m.defaultVisible && m.defaultColumn === 'left')
|
||||
.sort((a, b) => a.defaultOrder - b.defaultOrder)
|
||||
.map(m => m.key);
|
||||
|
||||
const right = modules
|
||||
.filter(m => m.defaultVisible && m.defaultColumn === 'right')
|
||||
.sort((a, b) => a.defaultOrder - b.defaultOrder)
|
||||
.map(m => m.key);
|
||||
|
||||
const hidden = modules
|
||||
.filter(m => !m.defaultVisible)
|
||||
.sort((a, b) => a.defaultOrder - b.defaultOrder)
|
||||
.map(m => m.key);
|
||||
|
||||
return {
|
||||
version: WORKBENCH_LAYOUT_VERSION,
|
||||
columns: [
|
||||
{ id: 'left', modules: left },
|
||||
{ id: 'right', modules: right }
|
||||
],
|
||||
hidden,
|
||||
collapsed: [],
|
||||
settings: {}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { WorkbenchModuleKey, WorkbenchModuleMeta } from './use-workbench-modules';
|
||||
import type { WorkbenchLayout } from './workbench-layout-types';
|
||||
|
||||
/**
|
||||
* 把存量布局与当前模块注册中心对齐。
|
||||
* - 注册中心存在但布局未含的 key:按 defaultVisible 进 columns 或 hidden
|
||||
* - 布局含但注册中心已删除的 key:丢弃
|
||||
*/
|
||||
export function reconcileLayout(layout: WorkbenchLayout, modules: WorkbenchModuleMeta[]): WorkbenchLayout {
|
||||
const knownKeys = new Set<WorkbenchModuleKey>(modules.map(m => m.key));
|
||||
const filterKnown = (list: WorkbenchModuleKey[]) => list.filter(k => knownKeys.has(k));
|
||||
|
||||
const columns = layout.columns.map(c => ({ id: c.id, modules: filterKnown(c.modules) }));
|
||||
const hidden = filterKnown(layout.hidden);
|
||||
const collapsed = filterKnown(layout.collapsed);
|
||||
|
||||
const appearKeys = new Set<WorkbenchModuleKey>([...columns.flatMap(c => c.modules), ...hidden]);
|
||||
|
||||
for (const m of modules) {
|
||||
if (!appearKeys.has(m.key)) {
|
||||
if (m.defaultVisible) {
|
||||
const target = columns.find(c => c.id === m.defaultColumn) ?? columns[0];
|
||||
target.modules.push(m.key);
|
||||
} else {
|
||||
hidden.push(m.key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { ...layout, columns, hidden, collapsed };
|
||||
}
|
||||
22
src/views/workbench/composables/workbench-layout-types.ts
Normal file
22
src/views/workbench/composables/workbench-layout-types.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import type { WorkbenchColumnId, WorkbenchModuleKey } from './use-workbench-modules';
|
||||
|
||||
export const WORKBENCH_LAYOUT_VERSION = 1;
|
||||
|
||||
export interface WorkbenchShortcutSettings {
|
||||
/** 用户在快捷入口里选了哪些菜单 key */
|
||||
menuKeys: string[];
|
||||
}
|
||||
|
||||
export interface WorkbenchModuleSettings {
|
||||
shortcut?: WorkbenchShortcutSettings;
|
||||
/** 后续每模块可加自定义设置 */
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WorkbenchLayout {
|
||||
version: typeof WORKBENCH_LAYOUT_VERSION;
|
||||
columns: Array<{ id: WorkbenchColumnId; modules: WorkbenchModuleKey[] }>;
|
||||
hidden: WorkbenchModuleKey[];
|
||||
collapsed: WorkbenchModuleKey[];
|
||||
settings: WorkbenchModuleSettings;
|
||||
}
|
||||
@@ -345,3 +345,138 @@ export function getTodayLabel() {
|
||||
const weekdayMap = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
return `今天 ${today.format('YYYY-MM-DD')} 星期${weekdayMap[today.day()]}`;
|
||||
}
|
||||
|
||||
export type WorkbenchMyTaskBucket = 'today' | 'week' | 'overdue' | 'all';
|
||||
|
||||
export interface WorkbenchMyTaskItemSource {
|
||||
id: string;
|
||||
title: string;
|
||||
statusCode: string;
|
||||
statusLabel: string;
|
||||
executionName: string;
|
||||
projectName: string;
|
||||
priority: 'high' | 'mid' | 'low';
|
||||
deadline: string | null;
|
||||
}
|
||||
|
||||
export interface WorkbenchMyTaskItem extends Omit<WorkbenchMyTaskItemSource, 'deadline'> {
|
||||
deadlineLabel: string;
|
||||
remainingDays: number | null;
|
||||
overdue: boolean;
|
||||
}
|
||||
|
||||
export function buildWorkbenchMyTaskItems(source: readonly WorkbenchMyTaskItemSource[]): WorkbenchMyTaskItem[] {
|
||||
return [...source]
|
||||
.sort((a, b) => {
|
||||
const av = a.deadline ? dayjs(a.deadline).valueOf() : Number.POSITIVE_INFINITY;
|
||||
const bv = b.deadline ? dayjs(b.deadline).valueOf() : Number.POSITIVE_INFINITY;
|
||||
return av - bv;
|
||||
})
|
||||
.map(item => {
|
||||
const remaining = getRemainingDays(item.deadline);
|
||||
return {
|
||||
...item,
|
||||
deadlineLabel: formatDeadline(item.deadline),
|
||||
remainingDays: remaining,
|
||||
overdue: remaining !== null && remaining < 0
|
||||
} satisfies WorkbenchMyTaskItem;
|
||||
});
|
||||
}
|
||||
|
||||
export function filterWorkbenchMyTaskItems(items: readonly WorkbenchMyTaskItem[], bucket: WorkbenchMyTaskBucket) {
|
||||
if (bucket === 'all') return [...items];
|
||||
if (bucket === 'overdue') return items.filter(i => i.overdue);
|
||||
if (bucket === 'today') return items.filter(i => i.remainingDays === 0);
|
||||
return items.filter(i => i.remainingDays !== null && i.remainingDays >= 0 && i.remainingDays <= 7);
|
||||
}
|
||||
|
||||
export interface WorkbenchMyRequirementGroupSource {
|
||||
statusCode: string;
|
||||
statusLabel: string;
|
||||
count: number;
|
||||
tone: 'sky' | 'amber' | 'emerald' | 'rose';
|
||||
}
|
||||
|
||||
export type WorkbenchMyRequirementGroup = WorkbenchMyRequirementGroupSource;
|
||||
|
||||
export function buildWorkbenchMyRequirementGroups(
|
||||
source: readonly WorkbenchMyRequirementGroupSource[]
|
||||
): WorkbenchMyRequirementGroup[] {
|
||||
return [...source];
|
||||
}
|
||||
|
||||
export interface WorkbenchTeamTodoRowSource {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
memberId: string;
|
||||
memberName: string;
|
||||
inProgress: number;
|
||||
overdue: number;
|
||||
weekDone: number;
|
||||
}
|
||||
|
||||
export type WorkbenchTeamTodoRow = WorkbenchTeamTodoRowSource;
|
||||
|
||||
export function buildWorkbenchTeamTodoRows(source: readonly WorkbenchTeamTodoRowSource[]): WorkbenchTeamTodoRow[] {
|
||||
return [...source].sort((a, b) => b.overdue - a.overdue);
|
||||
}
|
||||
|
||||
export type WorkbenchHealthLevel = 'green' | 'yellow' | 'red';
|
||||
|
||||
export interface WorkbenchProjectHealthCardSource {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
code: string;
|
||||
health: WorkbenchHealthLevel;
|
||||
riskCount: number;
|
||||
overdueTasks: number;
|
||||
backlogRequirements: number;
|
||||
}
|
||||
|
||||
export interface WorkbenchProjectHealthCard extends WorkbenchProjectHealthCardSource {
|
||||
healthLabel: string;
|
||||
}
|
||||
|
||||
export function buildWorkbenchProjectHealthCards(
|
||||
source: readonly WorkbenchProjectHealthCardSource[]
|
||||
): WorkbenchProjectHealthCard[] {
|
||||
const labelMap: Record<WorkbenchHealthLevel, string> = { green: '健康', yellow: '关注', red: '风险' };
|
||||
return source.map(s => ({ ...s, healthLabel: labelMap[s.health] }));
|
||||
}
|
||||
|
||||
export interface WorkbenchProgressBarSource {
|
||||
projectId: string;
|
||||
projectName: string;
|
||||
/** 完成率 0-100 */
|
||||
weekCompletionRate: number;
|
||||
}
|
||||
|
||||
export type WorkbenchProgressBar = WorkbenchProgressBarSource;
|
||||
|
||||
export function buildWorkbenchProgressBars(source: readonly WorkbenchProgressBarSource[]): WorkbenchProgressBar[] {
|
||||
return source.map(s => ({ ...s, weekCompletionRate: Math.min(100, Math.max(0, Math.round(s.weekCompletionRate))) }));
|
||||
}
|
||||
|
||||
export type WorkbenchFavoriteKind = 'product' | 'project' | 'requirement' | 'task';
|
||||
|
||||
export interface WorkbenchFavoriteItemSource {
|
||||
id: string;
|
||||
kind: WorkbenchFavoriteKind;
|
||||
title: string;
|
||||
source: string;
|
||||
}
|
||||
|
||||
export interface WorkbenchFavoriteItem extends WorkbenchFavoriteItemSource {
|
||||
kindLabel: string;
|
||||
kindTone: 'sky' | 'emerald' | 'amber' | 'rose';
|
||||
}
|
||||
|
||||
export function buildWorkbenchFavoriteItems(source: readonly WorkbenchFavoriteItemSource[]): WorkbenchFavoriteItem[] {
|
||||
const meta: Record<WorkbenchFavoriteKind, { label: string; tone: 'sky' | 'emerald' | 'amber' | 'rose' }> = {
|
||||
product: { label: '产品', tone: 'sky' },
|
||||
project: { label: '项目', tone: 'emerald' },
|
||||
requirement: { label: '需求', tone: 'amber' },
|
||||
task: { label: '任务', tone: 'rose' }
|
||||
};
|
||||
return source.map(s => ({ ...s, kindLabel: meta[s.kind].label, kindTone: meta[s.kind].tone }));
|
||||
}
|
||||
|
||||
@@ -1,46 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
|
||||
import { onBeforeRouteLeave } from 'vue-router';
|
||||
import { ElMessageBox } from 'element-plus';
|
||||
import { useWorkbenchStore } from '@/store/modules/workbench';
|
||||
import { buildWorkbenchBannerSummary } from './homepage';
|
||||
import { workbenchBannerSummaryMock } from './mock';
|
||||
import {
|
||||
buildWorkbenchActivityItems,
|
||||
buildWorkbenchBannerSummary,
|
||||
buildWorkbenchKpiCards,
|
||||
buildWorkbenchProjectItems,
|
||||
buildWorkbenchTodoItems
|
||||
} from './homepage';
|
||||
import {
|
||||
workbenchActivityMock,
|
||||
workbenchBannerSummaryMock,
|
||||
workbenchKpiMock,
|
||||
workbenchProjectMock,
|
||||
workbenchTodoMock
|
||||
} from './mock';
|
||||
type WorkbenchColumnId,
|
||||
type WorkbenchModuleKey,
|
||||
useWorkbenchModules
|
||||
} from './composables/use-workbench-modules';
|
||||
import WorkbenchBanner from './modules/workbench-banner.vue';
|
||||
import WorkbenchColumn from './modules/workbench-column.vue';
|
||||
import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue';
|
||||
import WorkbenchModuleLibrary from './modules/workbench-module-library.vue';
|
||||
import WorkbenchKpi from './modules/workbench-kpi.vue';
|
||||
import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue';
|
||||
import WorkbenchActivityPanel from './modules/workbench-activity-panel.vue';
|
||||
import WorkbenchProjectGrid from './modules/workbench-project-grid.vue';
|
||||
import WorkbenchMyTask from './modules/workbench-my-task.vue';
|
||||
import WorkbenchMyRequirement from './modules/workbench-my-requirement.vue';
|
||||
import WorkbenchTeamTodo from './modules/workbench-team-todo.vue';
|
||||
import WorkbenchProjectHealth from './modules/workbench-project-health.vue';
|
||||
import WorkbenchProgressChart from './modules/workbench-progress-chart.vue';
|
||||
import WorkbenchFavorite from './modules/workbench-favorite.vue';
|
||||
import WorkbenchShortcut from './modules/workbench-shortcut.vue';
|
||||
|
||||
defineOptions({ name: 'Workbench' });
|
||||
|
||||
const { registerModuleComponent } = useWorkbenchModules();
|
||||
registerModuleComponent('kpi', WorkbenchKpi);
|
||||
registerModuleComponent('myTodo', WorkbenchTodoPanel);
|
||||
registerModuleComponent('myProject', WorkbenchProjectGrid);
|
||||
registerModuleComponent('activity', WorkbenchActivityPanel);
|
||||
registerModuleComponent('myTask', WorkbenchMyTask);
|
||||
registerModuleComponent('myRequirement', WorkbenchMyRequirement);
|
||||
registerModuleComponent('teamTodo', WorkbenchTeamTodo);
|
||||
registerModuleComponent('projectHealth', WorkbenchProjectHealth);
|
||||
registerModuleComponent('progressChart', WorkbenchProgressChart);
|
||||
registerModuleComponent('favorite', WorkbenchFavorite);
|
||||
registerModuleComponent('shortcut', WorkbenchShortcut);
|
||||
|
||||
const workbench = useWorkbenchStore();
|
||||
const libraryOpen = ref(false);
|
||||
|
||||
onMounted(() => {
|
||||
workbench.load();
|
||||
});
|
||||
|
||||
function onBeforeUnload(e: BeforeUnloadEvent) {
|
||||
if (workbench.mode === 'editing' && workbench.dirty) {
|
||||
e.preventDefault();
|
||||
e.returnValue = '';
|
||||
}
|
||||
}
|
||||
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload));
|
||||
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload));
|
||||
|
||||
watch(
|
||||
() => workbench.error,
|
||||
err => {
|
||||
if (err) window.$message?.error(`布局保存失败:${err.message}`);
|
||||
}
|
||||
);
|
||||
|
||||
const bannerSummary = computed(() => buildWorkbenchBannerSummary(workbenchBannerSummaryMock));
|
||||
const kpiCards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
|
||||
const todoItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
|
||||
const activityItems = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
|
||||
const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
|
||||
|
||||
function onColumnUpdate(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) {
|
||||
workbench.setColumnModules(columnId, modules);
|
||||
}
|
||||
|
||||
async function handleReset() {
|
||||
try {
|
||||
await ElMessageBox.confirm('重置后将恢复默认布局,确认继续?', '重置默认布局', { type: 'warning' });
|
||||
await workbench.resetToDefault();
|
||||
} catch {
|
||||
/* cancelled */
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(async (_to, _from, next) => {
|
||||
if (workbench.mode === 'editing' && workbench.dirty) {
|
||||
try {
|
||||
await ElMessageBox.confirm('编辑布局未保存,确认离开?', '确认离开', { type: 'warning' });
|
||||
workbench.cancelEditing();
|
||||
next();
|
||||
} catch {
|
||||
next(false);
|
||||
}
|
||||
} else {
|
||||
next();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workbench">
|
||||
<WorkbenchBanner :summary="bannerSummary" />
|
||||
|
||||
<WorkbenchKpi :cards="kpiCards" />
|
||||
<div class="workbench__toolbar">
|
||||
<ElButton v-if="workbench.mode === 'normal'" type="primary" link @click="workbench.enterEditing">
|
||||
<SvgIcon icon="mdi:pencil-outline" />
|
||||
自定义布局
|
||||
</ElButton>
|
||||
<ElButton v-else type="primary" link @click="libraryOpen = true">
|
||||
<SvgIcon icon="mdi:view-grid-plus-outline" />
|
||||
模块库
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<section class="workbench__main">
|
||||
<WorkbenchTodoPanel :items="todoItems" />
|
||||
<WorkbenchActivityPanel :items="activityItems" />
|
||||
<WorkbenchEditOverlay
|
||||
v-if="workbench.mode === 'editing'"
|
||||
:dirty="workbench.dirty"
|
||||
:saving="workbench.saving"
|
||||
@save="workbench.saveEditing"
|
||||
@cancel="workbench.cancelEditing"
|
||||
@reset="handleReset"
|
||||
/>
|
||||
|
||||
<ElEmpty v-if="workbench.layout.columns.every(c => c.modules.length === 0)" description="还没有可见模块">
|
||||
<ElButton type="primary" @click="workbench.enterEditing">添加模块</ElButton>
|
||||
</ElEmpty>
|
||||
|
||||
<section v-else class="workbench__main">
|
||||
<WorkbenchColumn
|
||||
v-for="col in workbench.layout.columns"
|
||||
:key="col.id"
|
||||
:column-id="col.id"
|
||||
:modules="col.modules"
|
||||
:editing="workbench.mode === 'editing'"
|
||||
:collapsed="workbench.layout.collapsed"
|
||||
@update:modules="onColumnUpdate(col.id, $event)"
|
||||
@hide="workbench.hideModule"
|
||||
@toggle-collapse="workbench.toggleCollapse"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<WorkbenchProjectGrid :items="projectItems" />
|
||||
<WorkbenchModuleLibrary
|
||||
v-model="libraryOpen"
|
||||
:hidden-metas="workbench.hiddenMetas"
|
||||
@add-module="
|
||||
(key, col) => {
|
||||
workbench.showModule(key, col);
|
||||
libraryOpen = false;
|
||||
}
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -50,13 +155,15 @@ const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectM
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.workbench__toolbar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.workbench__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
@media (width <= 1280px) {
|
||||
.workbench__main {
|
||||
grid-template-columns: 1fr;
|
||||
|
||||
@@ -2,8 +2,14 @@ import dayjs from 'dayjs';
|
||||
import type {
|
||||
WorkbenchActivityItemSource,
|
||||
WorkbenchBannerSummarySource,
|
||||
WorkbenchFavoriteItemSource,
|
||||
WorkbenchKpiSource,
|
||||
WorkbenchMyRequirementGroupSource,
|
||||
WorkbenchMyTaskItemSource,
|
||||
WorkbenchProgressBarSource,
|
||||
WorkbenchProjectHealthCardSource,
|
||||
WorkbenchProjectItemSource,
|
||||
WorkbenchTeamTodoRowSource,
|
||||
WorkbenchTodoItemSource
|
||||
} from './homepage';
|
||||
|
||||
@@ -192,3 +198,145 @@ export const workbenchProjectMock = [
|
||||
lastActiveTime: iso(now.subtract(2, 'day').hour(10))
|
||||
}
|
||||
] satisfies WorkbenchProjectItemSource[];
|
||||
|
||||
export const workbenchMyTaskMock = [
|
||||
{
|
||||
id: 't-1',
|
||||
title: '支付回调接口联调',
|
||||
statusCode: 'inProgress',
|
||||
statusLabel: '进行中',
|
||||
executionName: '收银台 V3 · 后端联调',
|
||||
projectName: '收银台 V3',
|
||||
priority: 'high',
|
||||
deadline: iso(now.add(1, 'day').hour(17))
|
||||
},
|
||||
{
|
||||
id: 't-2',
|
||||
title: '订单导出 V2 文档编写',
|
||||
statusCode: 'inProgress',
|
||||
statusLabel: '进行中',
|
||||
executionName: '订单中心 · 文档',
|
||||
projectName: '订单中心',
|
||||
priority: 'mid',
|
||||
deadline: iso(now.add(3, 'day').hour(12))
|
||||
},
|
||||
{
|
||||
id: 't-3',
|
||||
title: 'API 返回结构调整',
|
||||
statusCode: 'pending',
|
||||
statusLabel: '待开始',
|
||||
executionName: '收银台 V3 · 后端联调',
|
||||
projectName: '收银台 V3',
|
||||
priority: 'mid',
|
||||
deadline: iso(now.subtract(1, 'day').hour(18))
|
||||
},
|
||||
{
|
||||
id: 't-4',
|
||||
title: '会员等级文案校对',
|
||||
statusCode: 'inProgress',
|
||||
statusLabel: '进行中',
|
||||
executionName: '会员中心 · 文案',
|
||||
projectName: '会员中心',
|
||||
priority: 'low',
|
||||
deadline: iso(now.add(2, 'day').hour(15))
|
||||
},
|
||||
{
|
||||
id: 't-5',
|
||||
title: '收银台 H5 适配',
|
||||
statusCode: 'inProgress',
|
||||
statusLabel: '进行中',
|
||||
executionName: '收银台 V3 · 前端',
|
||||
projectName: '收银台 V3',
|
||||
priority: 'high',
|
||||
deadline: iso(now.hour(20))
|
||||
}
|
||||
] satisfies WorkbenchMyTaskItemSource[];
|
||||
|
||||
export const workbenchMyRequirementMock = [
|
||||
{ statusCode: 'pendingReview', statusLabel: '待评审', count: 3, tone: 'amber' },
|
||||
{ statusCode: 'reviewing', statusLabel: '评审中', count: 2, tone: 'sky' },
|
||||
{ statusCode: 'developing', statusLabel: '开发中', count: 5, tone: 'emerald' },
|
||||
{ statusCode: 'paused', statusLabel: '已暂停', count: 1, tone: 'rose' }
|
||||
] satisfies WorkbenchMyRequirementGroupSource[];
|
||||
|
||||
export const workbenchTeamTodoMock = [
|
||||
{
|
||||
projectId: 'prj-1',
|
||||
projectName: '收银台 V3',
|
||||
memberId: 'm-1',
|
||||
memberName: '张三',
|
||||
inProgress: 5,
|
||||
overdue: 2,
|
||||
weekDone: 3
|
||||
},
|
||||
{
|
||||
projectId: 'prj-1',
|
||||
projectName: '收银台 V3',
|
||||
memberId: 'm-2',
|
||||
memberName: '李四',
|
||||
inProgress: 3,
|
||||
overdue: 0,
|
||||
weekDone: 4
|
||||
},
|
||||
{
|
||||
projectId: 'prj-2',
|
||||
projectName: '会员中心',
|
||||
memberId: 'm-3',
|
||||
memberName: '王五',
|
||||
inProgress: 2,
|
||||
overdue: 1,
|
||||
weekDone: 2
|
||||
},
|
||||
{
|
||||
projectId: 'prj-3',
|
||||
projectName: '订单中心',
|
||||
memberId: 'm-4',
|
||||
memberName: '赵六',
|
||||
inProgress: 4,
|
||||
overdue: 0,
|
||||
weekDone: 5
|
||||
}
|
||||
] satisfies WorkbenchTeamTodoRowSource[];
|
||||
|
||||
export const workbenchProjectHealthMock = [
|
||||
{
|
||||
projectId: 'prj-1',
|
||||
projectName: '收银台 V3',
|
||||
code: 'CASHIER-V3',
|
||||
health: 'yellow',
|
||||
riskCount: 2,
|
||||
overdueTasks: 3,
|
||||
backlogRequirements: 2
|
||||
},
|
||||
{
|
||||
projectId: 'prj-2',
|
||||
projectName: '会员中心',
|
||||
code: 'MEMBER',
|
||||
health: 'green',
|
||||
riskCount: 0,
|
||||
overdueTasks: 0,
|
||||
backlogRequirements: 1
|
||||
},
|
||||
{
|
||||
projectId: 'prj-3',
|
||||
projectName: '订单中心',
|
||||
code: 'ORDER',
|
||||
health: 'red',
|
||||
riskCount: 4,
|
||||
overdueTasks: 5,
|
||||
backlogRequirements: 6
|
||||
}
|
||||
] satisfies WorkbenchProjectHealthCardSource[];
|
||||
|
||||
export const workbenchProgressChartMock = [
|
||||
{ projectId: 'prj-1', projectName: '收银台 V3', weekCompletionRate: 78 },
|
||||
{ projectId: 'prj-2', projectName: '会员中心', weekCompletionRate: 62 },
|
||||
{ projectId: 'prj-3', projectName: '订单中心', weekCompletionRate: 45 }
|
||||
] satisfies WorkbenchProgressBarSource[];
|
||||
|
||||
export const workbenchFavoriteMock = [
|
||||
{ id: 'fav-1', kind: 'product', title: '收银台 V3', source: '产品库' },
|
||||
{ id: 'fav-2', kind: 'project', title: '会员中心 · 一期', source: '项目库' },
|
||||
{ id: 'fav-3', kind: 'requirement', title: '订单导出 V2', source: '收银台 V3' },
|
||||
{ id: 'fav-4', kind: 'task', title: '支付回调接口联调', source: '收银台 V3 · 后端联调' }
|
||||
] satisfies WorkbenchFavoriteItemSource[];
|
||||
|
||||
@@ -1,24 +1,35 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorkbenchActivityItem } from '../homepage';
|
||||
import { computed } from 'vue';
|
||||
import { buildWorkbenchActivityItems } from '../homepage';
|
||||
import { workbenchActivityMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
defineOptions({ name: 'WorkbenchActivityPanel' });
|
||||
|
||||
interface Props {
|
||||
items: WorkbenchActivityItem[];
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void;
|
||||
(e: 'toggle-collapse'): void;
|
||||
}>();
|
||||
|
||||
const items = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="workbench-activity card-wrapper" shadow="never">
|
||||
<template #header>
|
||||
<div>
|
||||
<h3 class="workbench-activity__title">最近动态</h3>
|
||||
<p class="workbench-activity__desc">关注与我相关的需求、任务、工单变化与 @ 提醒</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<WorkbenchModuleCard
|
||||
title="最近动态"
|
||||
icon="mdi:timeline-outline"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<div v-if="items.length" class="workbench-activity__list">
|
||||
<article v-for="item in items" :key="item.id" class="workbench-activity__item">
|
||||
<div class="workbench-activity__rail">
|
||||
@@ -40,33 +51,10 @@ defineProps<Props>();
|
||||
</article>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂无动态" :image-size="72" />
|
||||
</ElCard>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-activity {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid rgb(226 232 240 / 80%);
|
||||
}
|
||||
|
||||
.workbench-activity__title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workbench-activity__desc {
|
||||
margin: 4px 0 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.workbench-activity__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useAuthStore } from '@/store/modules/auth';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { getGreeting, getTodayLabel } from '../homepage';
|
||||
import type { WorkbenchBannerSummary } from '../homepage';
|
||||
|
||||
@@ -13,7 +12,6 @@ interface Props {
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const authStore = useAuthStore();
|
||||
|
||||
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '同学');
|
||||
@@ -25,14 +23,6 @@ const rhythmItems = computed(() => [
|
||||
{ label: '进行中', value: String(props.summary.weekInProgress), tone: 'sky' as const },
|
||||
{ label: '逾期', value: String(props.summary.weekOverdue), tone: 'rose' as const }
|
||||
]);
|
||||
|
||||
function handleCreateRequirement() {
|
||||
routerPushByKey('product_list');
|
||||
}
|
||||
|
||||
function handleCreateTask() {
|
||||
routerPushByKey('project_list');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -40,7 +30,6 @@ function handleCreateTask() {
|
||||
<div class="workbench-banner__identity">
|
||||
<div class="workbench-banner__title-group">
|
||||
<h1 class="workbench-banner__title">{{ greeting }},{{ displayName }}</h1>
|
||||
<span class="workbench-banner__decor-word">RDMS</span>
|
||||
</div>
|
||||
<p class="workbench-banner__subtitle">{{ todayLabel }}</p>
|
||||
|
||||
@@ -59,17 +48,6 @@ function handleCreateTask() {
|
||||
<span class="workbench-banner__digest-unit">项</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workbench-banner__actions">
|
||||
<ElButton type="primary" @click="handleCreateRequirement">
|
||||
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
|
||||
<span>新建需求</span>
|
||||
</ElButton>
|
||||
<ElButton @click="handleCreateTask">
|
||||
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
|
||||
<span>新建任务</span>
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workbench-banner__rhythm">
|
||||
@@ -131,18 +109,6 @@ function handleCreateTask() {
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.workbench-banner__decor-word {
|
||||
color: transparent;
|
||||
background: linear-gradient(180deg, rgb(14 116 144 / 92%), rgb(13 148 136 / 60%));
|
||||
background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
font-size: 22px;
|
||||
font-weight: 800;
|
||||
letter-spacing: 0.32em;
|
||||
text-shadow: 0 10px 24px rgb(14 116 144 / 14%);
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.workbench-banner__subtitle {
|
||||
margin: 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
@@ -191,17 +157,6 @@ function handleCreateTask() {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.workbench-banner__actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workbench-banner__btn-icon {
|
||||
margin-right: 4px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.workbench-banner__rhythm {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
61
src/views/workbench/modules/workbench-column.vue
Normal file
61
src/views/workbench/modules/workbench-column.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { VueDraggable } from 'vue-draggable-plus';
|
||||
import type { WorkbenchColumnId, WorkbenchModuleKey } from '../composables/use-workbench-modules';
|
||||
import { useWorkbenchModules } from '../composables/use-workbench-modules';
|
||||
|
||||
interface Props {
|
||||
columnId: WorkbenchColumnId;
|
||||
modules: WorkbenchModuleKey[];
|
||||
editing: boolean;
|
||||
collapsed: WorkbenchModuleKey[];
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modules', modules: WorkbenchModuleKey[]): void;
|
||||
(e: 'hide', key: WorkbenchModuleKey): void;
|
||||
(e: 'toggle-collapse', key: WorkbenchModuleKey): void;
|
||||
(e: 'open-settings', key: WorkbenchModuleKey): void;
|
||||
}>();
|
||||
|
||||
const { getModuleMeta } = useWorkbenchModules();
|
||||
|
||||
const modelValue = computed({
|
||||
get: () => props.modules,
|
||||
set: (val: WorkbenchModuleKey[]) => emit('update:modules', val)
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<VueDraggable
|
||||
v-model="modelValue"
|
||||
group="workbench-modules"
|
||||
:animation="180"
|
||||
handle=".module-drag-handle"
|
||||
:disabled="!editing"
|
||||
class="workbench-column"
|
||||
>
|
||||
<template v-for="key in modelValue" :key="key">
|
||||
<component
|
||||
:is="getModuleMeta(key)?.component"
|
||||
:module-key="key"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed.includes(key)"
|
||||
@hide="emit('hide', key)"
|
||||
@toggle-collapse="emit('toggle-collapse', key)"
|
||||
@open-settings="emit('open-settings', key)"
|
||||
/>
|
||||
</template>
|
||||
</VueDraggable>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
min-height: 200px;
|
||||
}
|
||||
</style>
|
||||
53
src/views/workbench/modules/workbench-edit-overlay.vue
Normal file
53
src/views/workbench/modules/workbench-edit-overlay.vue
Normal file
@@ -0,0 +1,53 @@
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
dirty: boolean;
|
||||
saving: boolean;
|
||||
}
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'save'): void;
|
||||
(e: 'cancel'): void;
|
||||
(e: 'reset'): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="edit-overlay">
|
||||
<span class="edit-overlay__hint">
|
||||
<SvgIcon icon="mdi:cursor-move" />
|
||||
正在编辑布局——拖动模块换位 / 抽屉里把隐藏模块拖回来
|
||||
</span>
|
||||
<div class="edit-overlay__actions">
|
||||
<ElButton @click="emit('reset')">重置默认布局</ElButton>
|
||||
<ElButton @click="emit('cancel')">取消</ElButton>
|
||||
<ElButton type="primary" :loading="saving" :disabled="!dirty && !saving" @click="emit('save')">
|
||||
保存{{ dirty ? '*' : '' }}
|
||||
</ElButton>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.edit-overlay {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 10px 16px;
|
||||
background: var(--el-color-primary-light-9);
|
||||
border: 1px solid var(--el-color-primary-light-5);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.edit-overlay__hint {
|
||||
color: var(--el-color-primary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
.edit-overlay__actions {
|
||||
display: inline-flex;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
72
src/views/workbench/modules/workbench-favorite.vue
Normal file
72
src/views/workbench/modules/workbench-favorite.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { type WorkbenchFavoriteItem, buildWorkbenchFavoriteItems } from '../homepage';
|
||||
import { workbenchFavoriteMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
interface Props {
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
|
||||
|
||||
const items = computed(() => buildWorkbenchFavoriteItems(workbenchFavoriteMock));
|
||||
|
||||
function toneType(it: WorkbenchFavoriteItem): 'primary' | 'success' | 'warning' | 'danger' {
|
||||
return ({ sky: 'primary', emerald: 'success', amber: 'warning', rose: 'danger' } as const)[it.kindTone];
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="我的收藏"
|
||||
icon="mdi:star-outline"
|
||||
:badge-count="items.length"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<ElAlert type="info" :closable="false" class="favorite-hint">
|
||||
v1 仅展示 mock;业务页"收藏"入口将在 v2 加入。
|
||||
</ElAlert>
|
||||
<ul class="favorite-list">
|
||||
<li v-for="item in items" :key="item.id" class="favorite-item">
|
||||
<ElTag size="small" :type="toneType(item)">{{ item.kindLabel }}</ElTag>
|
||||
<span class="favorite-title">{{ item.title }}</span>
|
||||
<span class="favorite-source">{{ item.source }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.favorite-hint {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.favorite-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.favorite-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
padding: 8px 10px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 6px;
|
||||
}
|
||||
.favorite-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.favorite-source {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
</style>
|
||||
@@ -1,13 +1,24 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorkbenchKpiCard } from '../homepage';
|
||||
import { computed } from 'vue';
|
||||
import { type WorkbenchKpiCard, buildWorkbenchKpiCards } from '../homepage';
|
||||
import { workbenchKpiMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
defineOptions({ name: 'WorkbenchKpi' });
|
||||
|
||||
interface Props {
|
||||
cards: WorkbenchKpiCard[];
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void;
|
||||
(e: 'toggle-collapse'): void;
|
||||
}>();
|
||||
|
||||
const cards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
|
||||
|
||||
function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
|
||||
if (trend === 'up') return 'mdi:arrow-top-right-thin';
|
||||
@@ -17,27 +28,36 @@ function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="workbench-kpi">
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="workbench-kpi__card"
|
||||
:class="`workbench-kpi__card--${card.tone}`"
|
||||
>
|
||||
<div class="workbench-kpi__card-header">
|
||||
<span class="workbench-kpi__card-label">{{ card.label }}</span>
|
||||
<span class="workbench-kpi__card-icon">
|
||||
<SvgIcon :icon="card.icon" />
|
||||
</span>
|
||||
</div>
|
||||
<strong class="workbench-kpi__card-value">{{ card.value }}</strong>
|
||||
<div class="workbench-kpi__card-trend" :class="`workbench-kpi__card-trend--${card.trend}`">
|
||||
<SvgIcon :icon="getTrendIcon(card.trend)" class="workbench-kpi__card-trend-icon" />
|
||||
<span>{{ card.trendText }}</span>
|
||||
</div>
|
||||
<p class="workbench-kpi__card-hint">{{ card.hint }}</p>
|
||||
</article>
|
||||
</section>
|
||||
<WorkbenchModuleCard
|
||||
title="KPI 速览"
|
||||
icon="mdi:view-dashboard-outline"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<section class="workbench-kpi">
|
||||
<article
|
||||
v-for="card in cards"
|
||||
:key="card.key"
|
||||
class="workbench-kpi__card"
|
||||
:class="`workbench-kpi__card--${card.tone}`"
|
||||
>
|
||||
<div class="workbench-kpi__card-header">
|
||||
<span class="workbench-kpi__card-label">{{ card.label }}</span>
|
||||
<span class="workbench-kpi__card-icon">
|
||||
<SvgIcon :icon="card.icon" />
|
||||
</span>
|
||||
</div>
|
||||
<strong class="workbench-kpi__card-value">{{ card.value }}</strong>
|
||||
<div class="workbench-kpi__card-trend" :class="`workbench-kpi__card-trend--${card.trend}`">
|
||||
<SvgIcon :icon="getTrendIcon(card.trend)" class="workbench-kpi__card-trend-icon" />
|
||||
<span>{{ card.trendText }}</span>
|
||||
</div>
|
||||
<p class="workbench-kpi__card-hint">{{ card.hint }}</p>
|
||||
</article>
|
||||
</section>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
150
src/views/workbench/modules/workbench-module-card.vue
Normal file
150
src/views/workbench/modules/workbench-module-card.vue
Normal file
@@ -0,0 +1,150 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
|
||||
defineOptions({ name: 'WorkbenchModuleCard' });
|
||||
|
||||
interface Props {
|
||||
title: string;
|
||||
icon?: string;
|
||||
badgeCount?: number;
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
hasSettings?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
editing: false,
|
||||
collapsed: false,
|
||||
hasSettings: false
|
||||
});
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-collapse'): void;
|
||||
(e: 'hide'): void;
|
||||
(e: 'open-settings'): void;
|
||||
(e: 'refresh'): void;
|
||||
(e: 'navigate'): void;
|
||||
}>();
|
||||
|
||||
const showBody = computed(() => !props.collapsed);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<section class="module-card" :class="{ 'is-editing': editing, 'is-collapsed': collapsed }">
|
||||
<header class="module-card__head">
|
||||
<span v-if="editing" class="module-drag-handle" title="拖动调整位置">
|
||||
<SvgIcon icon="mdi:drag-vertical" />
|
||||
</span>
|
||||
<SvgIcon v-if="icon" class="module-card__icon" :icon="icon" />
|
||||
<span class="module-card__title">{{ title }}</span>
|
||||
<span v-if="badgeCount != null" class="module-card__badge">{{ badgeCount }}</span>
|
||||
|
||||
<div class="module-card__actions">
|
||||
<ElButton v-if="editing && hasSettings" link size="small" title="模块设置" @click="emit('open-settings')">
|
||||
<SvgIcon icon="mdi:cog-outline" />
|
||||
</ElButton>
|
||||
<ElButton
|
||||
v-if="!editing"
|
||||
link
|
||||
size="small"
|
||||
:title="collapsed ? '展开' : '折叠'"
|
||||
@click="emit('toggle-collapse')"
|
||||
>
|
||||
<SvgIcon :icon="collapsed ? 'mdi:chevron-down' : 'mdi:chevron-up'" />
|
||||
</ElButton>
|
||||
<ElButton v-if="!editing" link size="small" title="刷新" @click="emit('refresh')">
|
||||
<SvgIcon icon="mdi:refresh" />
|
||||
</ElButton>
|
||||
<ElButton v-if="!editing" link size="small" title="跳详情" @click="emit('navigate')">
|
||||
<SvgIcon icon="mdi:open-in-new" />
|
||||
</ElButton>
|
||||
<ElButton v-if="editing" link size="small" title="隐藏此模块" type="danger" @click="emit('hide')">
|
||||
<SvgIcon icon="mdi:close" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-show="showBody" class="module-card__body">
|
||||
<slot />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.module-card {
|
||||
background: var(--el-bg-color);
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 10px;
|
||||
min-height: 180px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
border-color 120ms,
|
||||
box-shadow 120ms;
|
||||
}
|
||||
|
||||
.module-card.is-editing {
|
||||
border-style: dashed;
|
||||
border-color: var(--el-color-primary-light-5);
|
||||
}
|
||||
|
||||
.module-card.is-collapsed {
|
||||
min-height: auto;
|
||||
}
|
||||
|
||||
.module-card__head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-bottom: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-blank);
|
||||
}
|
||||
|
||||
.module-card.is-collapsed .module-card__head {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.module-drag-handle {
|
||||
cursor: grab;
|
||||
color: var(--el-text-color-secondary);
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.module-drag-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.module-card__icon {
|
||||
color: var(--el-color-primary);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.module-card__title {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.module-card__badge {
|
||||
background: var(--el-fill-color);
|
||||
color: var(--el-text-color-secondary);
|
||||
padding: 1px 8px;
|
||||
border-radius: 999px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.module-card__actions {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.module-card__body {
|
||||
flex: 1;
|
||||
padding: 14px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
80
src/views/workbench/modules/workbench-module-library.vue
Normal file
80
src/views/workbench/modules/workbench-module-library.vue
Normal file
@@ -0,0 +1,80 @@
|
||||
<script setup lang="ts">
|
||||
import type { WorkbenchColumnId, WorkbenchModuleMeta } from '../composables/use-workbench-modules';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
hiddenMetas: WorkbenchModuleMeta[];
|
||||
}
|
||||
defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void;
|
||||
(e: 'add-module', key: WorkbenchModuleMeta['key'], column: WorkbenchColumnId): void;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="modelValue"
|
||||
direction="rtl"
|
||||
size="320px"
|
||||
title="模块库"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #default>
|
||||
<p class="hint">点击下方模块加入工作台(默认进左栏)。</p>
|
||||
<ul class="library">
|
||||
<li v-if="hiddenMetas.length === 0" class="empty">所有模块都已显示</li>
|
||||
<li
|
||||
v-for="meta in hiddenMetas"
|
||||
:key="meta.key"
|
||||
class="library-item"
|
||||
@click="emit('add-module', meta.key, 'left')"
|
||||
>
|
||||
<SvgIcon :icon="meta.icon" />
|
||||
<span class="library-item__name">{{ meta.displayName }}</span>
|
||||
<ElTag size="small" :type="meta.category === 'manager' ? 'warning' : 'info'">
|
||||
{{ meta.category === 'manager' ? '管理者' : meta.category === 'tool' ? '工具' : '个人' }}
|
||||
</ElTag>
|
||||
</li>
|
||||
</ul>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.library {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
.library-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 10px 12px;
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 120ms;
|
||||
}
|
||||
.library-item:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.library-item__name {
|
||||
flex: 1;
|
||||
font-weight: 500;
|
||||
}
|
||||
.empty {
|
||||
color: var(--el-text-color-placeholder);
|
||||
text-align: center;
|
||||
padding: 40px 0;
|
||||
}
|
||||
</style>
|
||||
101
src/views/workbench/modules/workbench-my-requirement.vue
Normal file
101
src/views/workbench/modules/workbench-my-requirement.vue
Normal file
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { buildWorkbenchMyRequirementGroups } from '../homepage';
|
||||
import { workbenchMyRequirementMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
interface Props {
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
const groups = computed(() => buildWorkbenchMyRequirementGroups(workbenchMyRequirementMock));
|
||||
const total = computed(() => groups.value.reduce((s, g) => s + g.count, 0));
|
||||
|
||||
function handleClickGroup() {
|
||||
routerPushByKey('product_list');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="我的需求"
|
||||
icon="mdi:file-document-multiple-outline"
|
||||
:badge-count="total"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
@navigate="handleClickGroup"
|
||||
>
|
||||
<div class="req-grid">
|
||||
<button
|
||||
v-for="group in groups"
|
||||
:key="group.statusCode"
|
||||
class="req-card"
|
||||
:class="`tone-${group.tone}`"
|
||||
@click="handleClickGroup"
|
||||
>
|
||||
<span class="req-card__label">{{ group.statusLabel }}</span>
|
||||
<span class="req-card__count">{{ group.count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.req-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.req-card {
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-lighter);
|
||||
padding: 14px 16px;
|
||||
border-radius: 8px;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
transition: all 120ms;
|
||||
}
|
||||
|
||||
.req-card:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
box-shadow: 0 2px 8px var(--el-color-primary-light-9);
|
||||
}
|
||||
|
||||
.req-card__label {
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
|
||||
.req-card__count {
|
||||
font-size: 24px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.tone-sky .req-card__count {
|
||||
color: #0284c7;
|
||||
}
|
||||
|
||||
.tone-amber .req-card__count {
|
||||
color: #d97706;
|
||||
}
|
||||
|
||||
.tone-emerald .req-card__count {
|
||||
color: #047857;
|
||||
}
|
||||
|
||||
.tone-rose .req-card__count {
|
||||
color: #be123c;
|
||||
}
|
||||
</style>
|
||||
105
src/views/workbench/modules/workbench-my-task.vue
Normal file
105
src/views/workbench/modules/workbench-my-task.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { type WorkbenchMyTaskBucket, buildWorkbenchMyTaskItems, filterWorkbenchMyTaskItems } from '../homepage';
|
||||
import { workbenchMyTaskMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
interface Props {
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void; (e: 'refresh'): void }>();
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const allItems = computed(() => buildWorkbenchMyTaskItems(workbenchMyTaskMock));
|
||||
const bucket = ref<WorkbenchMyTaskBucket>('today');
|
||||
const visibleItems = computed(() => filterWorkbenchMyTaskItems(allItems.value, bucket.value));
|
||||
|
||||
function handleClickItem() {
|
||||
routerPushByKey('project_list');
|
||||
}
|
||||
|
||||
function handleRefresh() {
|
||||
window.$message?.success('已刷新(v1 mock)');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="我的任务"
|
||||
icon="mdi:checkbox-marked-circle-outline"
|
||||
:badge-count="visibleItems.length"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
@navigate="handleClickItem"
|
||||
@refresh="handleRefresh"
|
||||
>
|
||||
<ElTabs v-model="bucket" class="my-task-tabs">
|
||||
<ElTabPane label="今日" name="today" />
|
||||
<ElTabPane label="本周" name="week" />
|
||||
<ElTabPane label="逾期" name="overdue" />
|
||||
<ElTabPane label="全部" name="all" />
|
||||
</ElTabs>
|
||||
<ul v-if="visibleItems.length" class="my-task-list">
|
||||
<li v-for="item in visibleItems" :key="item.id" class="my-task-item" @click="handleClickItem">
|
||||
<ElTag size="small" :type="item.overdue ? 'danger' : 'info'">{{ item.statusLabel }}</ElTag>
|
||||
<span class="my-task-title">{{ item.title }}</span>
|
||||
<span class="my-task-meta">{{ item.projectName }} · {{ item.executionName }}</span>
|
||||
<span class="my-task-deadline" :class="{ overdue: item.overdue }">{{ item.deadlineLabel }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<ElEmpty v-else description="暂无任务" :image-size="60" />
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.my-task-tabs {
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.my-task-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
.my-task-item {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr auto;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 2px 8px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
background: var(--el-fill-color-lighter);
|
||||
transition: background 120ms;
|
||||
}
|
||||
.my-task-item:hover {
|
||||
background: var(--el-color-primary-light-9);
|
||||
}
|
||||
.my-task-title {
|
||||
font-weight: 500;
|
||||
}
|
||||
.my-task-meta {
|
||||
grid-column: 2 / 3;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.my-task-deadline {
|
||||
grid-column: 3 / 4;
|
||||
grid-row: 1 / 3;
|
||||
align-self: center;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-secondary);
|
||||
}
|
||||
.my-task-deadline.overdue {
|
||||
color: var(--el-color-danger);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
78
src/views/workbench/modules/workbench-progress-chart.vue
Normal file
78
src/views/workbench/modules/workbench-progress-chart.vue
Normal file
@@ -0,0 +1,78 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue';
|
||||
import * as echarts from 'echarts';
|
||||
import { buildWorkbenchProgressBars } from '../homepage';
|
||||
import { workbenchProgressChartMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
interface Props {
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void;
|
||||
(e: 'toggle-collapse'): void;
|
||||
}>();
|
||||
|
||||
defineOptions({ name: 'WorkbenchProgressChart' });
|
||||
|
||||
const bars = computed(() => buildWorkbenchProgressBars(workbenchProgressChartMock));
|
||||
const chartEl = ref<HTMLDivElement | null>(null);
|
||||
let chart: echarts.ECharts | null = null;
|
||||
|
||||
function render() {
|
||||
if (!chartEl.value) return;
|
||||
if (!chart) chart = echarts.init(chartEl.value);
|
||||
chart.setOption({
|
||||
tooltip: { trigger: 'axis' },
|
||||
grid: { left: 20, right: 20, top: 20, bottom: 40, containLabel: true },
|
||||
xAxis: { type: 'category', data: bars.value.map(b => b.projectName), axisLabel: { interval: 0 } },
|
||||
yAxis: { type: 'value', max: 100, axisLabel: { formatter: '{value}%' } },
|
||||
series: [
|
||||
{
|
||||
type: 'bar',
|
||||
data: bars.value.map(b => b.weekCompletionRate),
|
||||
itemStyle: { color: '#2563eb', borderRadius: [4, 4, 0, 0] },
|
||||
label: { show: true, position: 'top', formatter: '{c}%' }
|
||||
}
|
||||
]
|
||||
});
|
||||
}
|
||||
|
||||
onMounted(render);
|
||||
watch(() => bars.value, render, { deep: true });
|
||||
watch(
|
||||
() => props.collapsed,
|
||||
v => {
|
||||
if (!v) setTimeout(render, 0);
|
||||
}
|
||||
);
|
||||
|
||||
onUnmounted(() => {
|
||||
chart?.dispose();
|
||||
chart = null;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="跨项目进度图"
|
||||
icon="mdi:chart-bar"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<div ref="chartEl" class="progress-chart" />
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.progress-chart {
|
||||
width: 100%;
|
||||
height: 220px;
|
||||
}
|
||||
</style>
|
||||
@@ -1,36 +1,49 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import type { WorkbenchProjectItem } from '../homepage';
|
||||
import { buildWorkbenchProjectItems } from '../homepage';
|
||||
import { workbenchProjectMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
defineOptions({ name: 'WorkbenchProjectGrid' });
|
||||
|
||||
interface Props {
|
||||
items: WorkbenchProjectItem[];
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
defineProps<Props>();
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void;
|
||||
(e: 'toggle-collapse'): void;
|
||||
}>();
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
const items = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
|
||||
|
||||
function handleEnterProjectList() {
|
||||
routerPushByKey('project_list');
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="workbench-project card-wrapper" shadow="never">
|
||||
<template #header>
|
||||
<div class="workbench-project__header">
|
||||
<div>
|
||||
<h3 class="workbench-project__title">我参与的项目</h3>
|
||||
<p class="workbench-project__desc">直接看每个项目的当前进度、我的角色与未完成任务</p>
|
||||
</div>
|
||||
<ElButton type="primary" link @click="handleEnterProjectList">
|
||||
<span>进入项目列表</span>
|
||||
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
|
||||
</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
<WorkbenchModuleCard
|
||||
title="我参与的项目"
|
||||
icon="mdi:briefcase-outline"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<div class="workbench-project__subheader">
|
||||
<p class="workbench-project__desc">直接看每个项目的当前进度、我的角色与未完成任务</p>
|
||||
<ElButton type="primary" link @click="handleEnterProjectList">
|
||||
<span>进入项目列表</span>
|
||||
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
|
||||
</ElButton>
|
||||
</div>
|
||||
|
||||
<div v-if="items.length" class="workbench-project__grid">
|
||||
<article v-for="item in items" :key="item.id" class="workbench-project__card">
|
||||
@@ -81,35 +94,20 @@ function handleEnterProjectList() {
|
||||
</article>
|
||||
</div>
|
||||
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
|
||||
</ElCard>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-project {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid rgb(226 232 240 / 80%);
|
||||
}
|
||||
|
||||
.workbench-project__header {
|
||||
.workbench-project__subheader {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workbench-project__title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.workbench-project__desc {
|
||||
margin: 4px 0 0;
|
||||
margin: 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
|
||||
97
src/views/workbench/modules/workbench-project-health.vue
Normal file
97
src/views/workbench/modules/workbench-project-health.vue
Normal file
@@ -0,0 +1,97 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { buildWorkbenchProjectHealthCards } from '../homepage';
|
||||
import { workbenchProjectHealthMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
interface Props {
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
|
||||
|
||||
const cards = computed(() => buildWorkbenchProjectHealthCards(workbenchProjectHealthMock));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="项目健康度"
|
||||
icon="mdi:heart-pulse"
|
||||
:badge-count="cards.length"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<div class="health-list">
|
||||
<div v-for="card in cards" :key="card.projectId" class="health-card">
|
||||
<div class="health-card__ring" :class="`is-${card.health}`">
|
||||
<span>{{ card.healthLabel }}</span>
|
||||
</div>
|
||||
<div class="health-card__body">
|
||||
<div class="health-card__name">{{ card.projectName }}</div>
|
||||
<div class="health-card__meta">
|
||||
<ElTag v-if="card.riskCount > 0" size="small" type="danger">风险 {{ card.riskCount }}</ElTag>
|
||||
<ElTag size="small" type="warning">逾期任务 {{ card.overdueTasks }}</ElTag>
|
||||
<ElTag size="small">需求积压 {{ card.backlogRequirements }}</ElTag>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.health-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.health-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.health-card__ring {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #fff;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.health-card__ring.is-green {
|
||||
background: #10b981;
|
||||
}
|
||||
|
||||
.health-card__ring.is-yellow {
|
||||
background: #f59e0b;
|
||||
}
|
||||
|
||||
.health-card__ring.is-red {
|
||||
background: #ef4444;
|
||||
}
|
||||
|
||||
.health-card__name {
|
||||
font-weight: 600;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
|
||||
.health-card__meta {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
102
src/views/workbench/modules/workbench-shortcut-picker.vue
Normal file
102
src/views/workbench/modules/workbench-shortcut-picker.vue
Normal file
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { ElTree } from 'element-plus';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
|
||||
interface Props {
|
||||
modelValue: boolean;
|
||||
/** 已选菜单 key */
|
||||
initialSelected: string[];
|
||||
}
|
||||
const props = defineProps<Props>();
|
||||
const emit = defineEmits<{
|
||||
(e: 'update:modelValue', v: boolean): void;
|
||||
(e: 'confirm', keys: string[]): void;
|
||||
}>();
|
||||
|
||||
const routeStore = useRouteStore();
|
||||
|
||||
interface TreeNode {
|
||||
key: string;
|
||||
label: string;
|
||||
children?: TreeNode[];
|
||||
isLeaf: boolean;
|
||||
}
|
||||
|
||||
function toTreeNodes(menus: any[]): TreeNode[] {
|
||||
return menus.map(m => ({
|
||||
key: m.key,
|
||||
label: m.label as string,
|
||||
isLeaf: !m.children || m.children.length === 0,
|
||||
children: m.children ? toTreeNodes(m.children) : undefined
|
||||
}));
|
||||
}
|
||||
|
||||
const treeData = computed(() => toTreeNodes(routeStore.menus));
|
||||
const treeRef = ref<InstanceType<typeof ElTree> | null>(null);
|
||||
|
||||
const checkedKeys = ref<string[]>([...props.initialSelected]);
|
||||
|
||||
watch(
|
||||
() => props.modelValue,
|
||||
open => {
|
||||
if (open) {
|
||||
checkedKeys.value = [...props.initialSelected];
|
||||
// 等抽屉 transition 后设置 checked
|
||||
setTimeout(() => treeRef.value?.setCheckedKeys(props.initialSelected, true), 100);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
function handleConfirm() {
|
||||
const allChecked = treeRef.value?.getCheckedKeys(true) ?? []; // leafOnly = true
|
||||
emit(
|
||||
'confirm',
|
||||
allChecked.map(k => String(k))
|
||||
);
|
||||
emit('update:modelValue', false);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElDrawer
|
||||
:model-value="modelValue"
|
||||
direction="rtl"
|
||||
size="380px"
|
||||
title="选择快捷入口菜单"
|
||||
@update:model-value="emit('update:modelValue', $event)"
|
||||
>
|
||||
<template #default>
|
||||
<p class="hint">勾选你想加入快捷入口的菜单(仅叶子节点可勾选)。</p>
|
||||
<ElTree
|
||||
ref="treeRef"
|
||||
:data="treeData"
|
||||
node-key="key"
|
||||
:props="{ label: 'label', children: 'children' }"
|
||||
show-checkbox
|
||||
check-strictly
|
||||
default-expand-all
|
||||
:check-on-click-node="false"
|
||||
/>
|
||||
</template>
|
||||
<template #footer>
|
||||
<div class="footer">
|
||||
<ElButton @click="emit('update:modelValue', false)">取消</ElButton>
|
||||
<ElButton type="primary" @click="handleConfirm">保存</ElButton>
|
||||
</div>
|
||||
</template>
|
||||
</ElDrawer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.hint {
|
||||
color: var(--el-text-color-secondary);
|
||||
font-size: 13px;
|
||||
margin: 0 0 12px;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
132
src/views/workbench/modules/workbench-shortcut.vue
Normal file
132
src/views/workbench/modules/workbench-shortcut.vue
Normal file
@@ -0,0 +1,132 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue';
|
||||
import { useRouteStore } from '@/store/modules/route';
|
||||
import { useWorkbenchStore } from '@/store/modules/workbench';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
import WorkbenchShortcutPicker from './workbench-shortcut-picker.vue';
|
||||
|
||||
interface Props {
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), {
|
||||
editing: false,
|
||||
collapsed: false
|
||||
});
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void;
|
||||
(e: 'toggle-collapse'): void;
|
||||
}>();
|
||||
|
||||
const routeStore = useRouteStore();
|
||||
const workbench = useWorkbenchStore();
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
interface FlatMenu {
|
||||
key: string;
|
||||
label: string;
|
||||
icon?: string;
|
||||
}
|
||||
|
||||
function flatten(menus: typeof routeStore.menus): FlatMenu[] {
|
||||
const out: FlatMenu[] = [];
|
||||
function walk(list: typeof menus) {
|
||||
list.forEach((m: any) => {
|
||||
if (m.children && m.children.length) {
|
||||
walk(m.children);
|
||||
} else {
|
||||
out.push({
|
||||
key: m.key,
|
||||
label: m.label as string,
|
||||
icon: m.i18nKey || m.icon || ''
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
walk(menus);
|
||||
return out;
|
||||
}
|
||||
|
||||
const flatMenus = computed(() => flatten(routeStore.menus));
|
||||
const selectedKeys = computed(() => workbench.layout.settings.shortcut?.menuKeys ?? []);
|
||||
const selected = computed(() =>
|
||||
selectedKeys.value.map(k => flatMenus.value.find(m => m.key === k)).filter((x): x is FlatMenu => Boolean(x))
|
||||
);
|
||||
|
||||
const pickerOpen = ref(false);
|
||||
|
||||
function openPicker() {
|
||||
pickerOpen.value = true;
|
||||
}
|
||||
|
||||
function handleClick(key: string) {
|
||||
routerPushByKey(key as any);
|
||||
}
|
||||
|
||||
function handleConfirm(keys: string[]) {
|
||||
workbench.updateModuleSettings('shortcut', { menuKeys: keys });
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="快捷入口"
|
||||
icon="mdi:rocket-launch-outline"
|
||||
:badge-count="selected.length || undefined"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
has-settings
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
@open-settings="openPicker"
|
||||
>
|
||||
<div v-if="selected.length === 0" class="shortcut-empty">
|
||||
<ElEmpty description="还未选择菜单" :image-size="60">
|
||||
<ElButton type="primary" size="small" @click="openPicker">+ 选择菜单</ElButton>
|
||||
</ElEmpty>
|
||||
</div>
|
||||
<div v-else class="shortcut-grid">
|
||||
<button v-for="item in selected" :key="item.key" class="shortcut-item" @click="handleClick(item.key)">
|
||||
<SvgIcon icon="mdi:link-variant" />
|
||||
<span>{{ item.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<WorkbenchShortcutPicker v-model="pickerOpen" :initial-selected="selectedKeys" @confirm="handleConfirm" />
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.shortcut-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.shortcut-item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 12px 8px;
|
||||
border: 1px solid var(--el-border-color-lighter);
|
||||
background: var(--el-fill-color-lighter);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
color: var(--el-text-color-primary);
|
||||
transition: all 120ms;
|
||||
}
|
||||
|
||||
.shortcut-item:hover {
|
||||
border-color: var(--el-color-primary);
|
||||
color: var(--el-color-primary);
|
||||
}
|
||||
|
||||
.shortcut-empty {
|
||||
padding: 20px 0;
|
||||
}
|
||||
</style>
|
||||
47
src/views/workbench/modules/workbench-team-todo.vue
Normal file
47
src/views/workbench/modules/workbench-team-todo.vue
Normal file
@@ -0,0 +1,47 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue';
|
||||
import { buildWorkbenchTeamTodoRows } from '../homepage';
|
||||
import { workbenchTeamTodoMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
interface Props {
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
defineEmits<{ (e: 'hide'): void; (e: 'toggle-collapse'): void }>();
|
||||
|
||||
const rows = computed(() => buildWorkbenchTeamTodoRows(workbenchTeamTodoMock));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<WorkbenchModuleCard
|
||||
title="团队待办汇总"
|
||||
icon="mdi:account-group-outline"
|
||||
:badge-count="rows.length"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<ElTable :data="rows" stripe size="small">
|
||||
<ElTableColumn prop="projectName" label="项目" min-width="120" />
|
||||
<ElTableColumn prop="memberName" label="成员" width="80" />
|
||||
<ElTableColumn prop="inProgress" label="进行中" width="80" align="right" />
|
||||
<ElTableColumn label="逾期" width="80" align="right">
|
||||
<template #default="{ row }">
|
||||
<span
|
||||
:style="{
|
||||
color: row.overdue > 0 ? 'var(--el-color-danger)' : 'inherit',
|
||||
fontWeight: row.overdue > 0 ? 600 : 'normal'
|
||||
}"
|
||||
>
|
||||
{{ row.overdue }}
|
||||
</span>
|
||||
</template>
|
||||
</ElTableColumn>
|
||||
<ElTableColumn prop="weekDone" label="本周完成" width="100" align="right" />
|
||||
</ElTable>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
@@ -2,16 +2,28 @@
|
||||
import { computed, ref } from 'vue';
|
||||
import type { RouteKey } from '@elegant-router/types';
|
||||
import { useRouterPush } from '@/hooks/common/router';
|
||||
import { filterWorkbenchTodoItems } from '../homepage';
|
||||
import type { WorkbenchTodoItem, WorkbenchTodoTimeBucket } from '../homepage';
|
||||
import {
|
||||
type WorkbenchTodoItem,
|
||||
type WorkbenchTodoTimeBucket,
|
||||
buildWorkbenchTodoItems,
|
||||
filterWorkbenchTodoItems
|
||||
} from '../homepage';
|
||||
import { workbenchTodoMock } from '../mock';
|
||||
import WorkbenchModuleCard from './workbench-module-card.vue';
|
||||
|
||||
defineOptions({ name: 'WorkbenchTodoPanel' });
|
||||
|
||||
interface Props {
|
||||
items: WorkbenchTodoItem[];
|
||||
editing?: boolean;
|
||||
collapsed?: boolean;
|
||||
}
|
||||
|
||||
const props = defineProps<Props>();
|
||||
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
|
||||
|
||||
defineEmits<{
|
||||
(e: 'hide'): void;
|
||||
(e: 'toggle-collapse'): void;
|
||||
}>();
|
||||
|
||||
const { routerPushByKey } = useRouterPush();
|
||||
|
||||
@@ -24,14 +36,16 @@ const buckets: Array<{ key: WorkbenchTodoTimeBucket; label: string }> = [
|
||||
{ key: 'overdue', label: '逾期' }
|
||||
];
|
||||
|
||||
const items = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
|
||||
|
||||
const bucketCounts = computed(() => ({
|
||||
all: props.items.length,
|
||||
today: filterWorkbenchTodoItems(props.items, 'today').length,
|
||||
week: filterWorkbenchTodoItems(props.items, 'week').length,
|
||||
overdue: filterWorkbenchTodoItems(props.items, 'overdue').length
|
||||
all: items.value.length,
|
||||
today: filterWorkbenchTodoItems(items.value, 'today').length,
|
||||
week: filterWorkbenchTodoItems(items.value, 'week').length,
|
||||
overdue: filterWorkbenchTodoItems(items.value, 'overdue').length
|
||||
}));
|
||||
|
||||
const filteredItems = computed(() => filterWorkbenchTodoItems(props.items, activeBucket.value));
|
||||
const filteredItems = computed(() => filterWorkbenchTodoItems(items.value, activeBucket.value));
|
||||
|
||||
function handleClickItem(item: WorkbenchTodoItem) {
|
||||
if (!item.routeKey) return;
|
||||
@@ -48,28 +62,27 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ElCard class="workbench-todo card-wrapper" shadow="never">
|
||||
<template #header>
|
||||
<div class="workbench-todo__header">
|
||||
<div class="workbench-todo__title-group">
|
||||
<h3 class="workbench-todo__title">我的待办</h3>
|
||||
<p class="workbench-todo__desc">需要我处理的需求评审、任务、工单与 @ 提醒</p>
|
||||
</div>
|
||||
<div class="workbench-todo__tabs">
|
||||
<button
|
||||
v-for="bucket in buckets"
|
||||
:key="bucket.key"
|
||||
type="button"
|
||||
class="workbench-todo__tab"
|
||||
:class="{ 'workbench-todo__tab--active': activeBucket === bucket.key }"
|
||||
@click="activeBucket = bucket.key"
|
||||
>
|
||||
<span>{{ bucket.label }}</span>
|
||||
<span class="workbench-todo__tab-count">{{ bucketCounts[bucket.key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<WorkbenchModuleCard
|
||||
title="我的待办"
|
||||
icon="mdi:clipboard-text-clock-outline"
|
||||
:editing="editing"
|
||||
:collapsed="collapsed"
|
||||
@hide="$emit('hide')"
|
||||
@toggle-collapse="$emit('toggle-collapse')"
|
||||
>
|
||||
<div class="workbench-todo__tabs">
|
||||
<button
|
||||
v-for="bucket in buckets"
|
||||
:key="bucket.key"
|
||||
type="button"
|
||||
class="workbench-todo__tab"
|
||||
:class="{ 'workbench-todo__tab--active': activeBucket === bucket.key }"
|
||||
@click="activeBucket = bucket.key"
|
||||
>
|
||||
<span>{{ bucket.label }}</span>
|
||||
<span class="workbench-todo__tab-count">{{ bucketCounts[bucket.key] }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="filteredItems.length" class="workbench-todo__list">
|
||||
<article
|
||||
@@ -101,49 +114,15 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
|
||||
</article>
|
||||
</div>
|
||||
<ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" />
|
||||
</ElCard>
|
||||
</WorkbenchModuleCard>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workbench-todo {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
:deep(.el-card__header) {
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid rgb(226 232 240 / 80%);
|
||||
}
|
||||
|
||||
.workbench-todo__header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.workbench-todo__title-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.workbench-todo__title {
|
||||
margin: 0;
|
||||
color: rgb(15 23 42 / 98%);
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.workbench-todo__desc {
|
||||
margin: 0;
|
||||
color: rgb(100 116 139 / 92%);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.workbench-todo__tabs {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.workbench-todo__tab {
|
||||
|
||||
Reference in New Issue
Block a user