refactor(projects): 1、新增执行任务,表单优化;2、删除逻辑丰富。3、修改已知问题

This commit is contained in:
2026-05-21 21:42:23 +08:00
parent 28d597d91e
commit ba328e02bb
68 changed files with 3329 additions and 644 deletions

View File

@@ -415,3 +415,17 @@ pnpm preview # preview server (9725)
- 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false` - 新建写接口 → 不用管,默认兜底;只有明确"允许短时间连发"才传 `dedupe: false`
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可 - 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住 - 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `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` 文档不主动改写**,等用户明确要求再转。

View File

@@ -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
```

View File

@@ -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 数据源中

View File

@@ -470,7 +470,7 @@ onBeforeUnmount(() => {
</ElIcon> </ElIcon>
<ElLink <ElLink
type="primary" type="primary"
:underline="false" underline="never"
class="business-attachment-uploader__name" class="business-attachment-uploader__name"
:title="item.name" :title="item.name"
@click="handleOpen(item)" @click="handleOpen(item)"
@@ -478,7 +478,7 @@ onBeforeUnmount(() => {
{{ item.name }} {{ item.name }}
</ElLink> </ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span> <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)" /> <ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li> </li>
@@ -509,7 +509,7 @@ onBeforeUnmount(() => {
</ElIcon> </ElIcon>
<ElLink <ElLink
type="primary" type="primary"
:underline="false" underline="never"
class="business-attachment-uploader__name" class="business-attachment-uploader__name"
:title="item.name" :title="item.name"
@click="handleOpen(item)" @click="handleOpen(item)"
@@ -517,7 +517,7 @@ onBeforeUnmount(() => {
{{ item.name }} {{ item.name }}
</ElLink> </ElLink>
<span v-if="item.size" class="business-attachment-uploader__size">{{ formatSize(item.size) }}</span> <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)" /> <ElButton v-if="!disabled" link type="danger" :icon="Delete" @click="handleRemove(item)" />
</li> </li>
</ul> </ul>

View File

@@ -14,6 +14,8 @@ interface Props {
multiple?: boolean; multiple?: boolean;
collapseTags?: boolean; collapseTags?: boolean;
collapseTagsTooltip?: boolean; collapseTagsTooltip?: boolean;
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
showRemark?: boolean;
} }
const props = withDefaults(defineProps<Props>(), { const props = withDefaults(defineProps<Props>(), {
@@ -24,7 +26,8 @@ const props = withDefaults(defineProps<Props>(), {
onlyEnabled: true, onlyEnabled: true,
multiple: false, multiple: false,
collapseTags: false, collapseTags: false,
collapseTagsTooltip: false collapseTagsTooltip: false,
showRemark: false
}); });
const model = defineModel<string | number | Array<string | number> | null | undefined>({ const model = defineModel<string | number | Array<string | number> | null | undefined>({
@@ -35,18 +38,27 @@ const { enabledDictData, dictData } = useDict(() => props.dictCode);
const dictOptions = computed(() => { const dictOptions = computed(() => {
const source = props.onlyEnabled ? enabledDictData.value : dictData.value; const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
return source.map(item => ({ return source.map(item => ({
label: item.label, 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> </script>
<template> <template>
<ElSelect <ElSelect
v-model="model" v-model="model"
class="w-full" class="dict-select w-full"
:placeholder="props.placeholder" :placeholder="props.placeholder"
:disabled="props.disabled" :disabled="props.disabled"
:clearable="props.clearable" :clearable="props.clearable"
@@ -55,8 +67,51 @@ const dictOptions = computed(() => {
:collapse-tags="props.collapseTags" :collapse-tags="props.collapseTags"
:collapse-tags-tooltip="props.collapseTagsTooltip" :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> </ElSelect>
</template> </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>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useDict } from '@/hooks/business/dict';
import DictText from './dict-text.vue'; import DictText from './dict-text.vue';
defineOptions({ name: 'DictTag' }); defineOptions({ name: 'DictTag' });
@@ -14,6 +16,7 @@ interface Props {
fallback?: string; fallback?: string;
separator?: string; separator?: string;
onlyEnabled?: boolean; onlyEnabled?: boolean;
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
type?: DictTagType; type?: DictTagType;
effect?: DictTagEffect; effect?: DictTagEffect;
size?: DictTagSize; size?: DictTagSize;
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
size: 'default', size: 'default',
round: false 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 优先(向后兼容);其次字典 colorTypehex都没有时回落到原生 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> </script>
<template> <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 <DictText
:dict-code="props.dictCode" :dict-code="props.dictCode"
:value="props.value" :value="props.value"

View File

@@ -24,6 +24,8 @@ export interface SearchField {
options?: Option[]; options?: Option[];
/** dict 类型的字典编码 */ /** dict 类型的字典编码 */
dictCode?: string; dictCode?: string;
/** dict 类型下拉项右侧追加字典 remark 释义(如优先级 "P0 → 紧急" */
showRemark?: boolean;
/** 占位提示文本 */ /** 占位提示文本 */
placeholder?: string; placeholder?: string;
/** select 类型的自定义选项渲染函数 */ /** select 类型的自定义选项渲染函数 */
@@ -179,6 +181,7 @@ function handleSearch() {
:dict-code="field.dictCode!" :dict-code="field.dictCode!"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:disabled="props.disabled" :disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
</ElFormItem> </ElFormItem>
@@ -275,6 +278,7 @@ function handleSearch() {
:dict-code="field.dictCode!" :dict-code="field.dictCode!"
:placeholder="field.placeholder" :placeholder="field.placeholder"
:disabled="props.disabled" :disabled="props.disabled"
:show-remark="field.showRemark"
@update:model-value="val => (props.modelValue[field.key] = val)" @update:model-value="val => (props.modelValue[field.key] = val)"
/> />
</ElFormItem> </ElFormItem>

View File

@@ -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'; 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_priority4 档标签 P0/P1/P2/P3。
* 数值取值口径不同是已知遗留——前端用本字典的 label / colorType 渲染即可,不要硬编码 P0~P3。
*/ */
export const RDMS_REQ_PRIORITY_DICT_CODE = 'rdms_req_priority'; 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'; 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 * 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
*/ */
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = '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';

View File

@@ -5,5 +5,6 @@ export enum SetupStoreId {
Dict = 'dict-store', Dict = 'dict-store',
Route = 'route-store', Route = 'route-store',
Tab = 'tab-store', Tab = 'tab-store',
ObjectContext = 'object-context-store' ObjectContext = 'object-context-store',
Workbench = 'workbench-store'
} }

View File

@@ -23,6 +23,8 @@ export type ProjectExecutionResponse = Omit<
| 'actualStartDate' | 'actualStartDate'
| 'actualEndDate' | 'actualEndDate'
| 'progressRate' | 'progressRate'
| 'priority'
| 'priorityName'
> & { > & {
id: StringIdResponse; id: StringIdResponse;
projectId: StringIdResponse; projectId: StringIdResponse;
@@ -34,6 +36,8 @@ export type ProjectExecutionResponse = Omit<
actualStartDate?: ProjectLocalDateValue; actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue; actualEndDate?: ProjectLocalDateValue;
progressRate?: number | null; progressRate?: number | null;
priority?: string | number | null;
priorityName?: string | null;
}; };
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & { export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
@@ -116,6 +120,8 @@ export type ProjectTaskResponse = Omit<
| 'progressRate' | 'progressRate'
| 'assignees' | 'assignees'
| 'attachments' | 'attachments'
| 'priority'
| 'priorityName'
> & { > & {
id: StringIdResponse; id: StringIdResponse;
projectId: StringIdResponse; projectId: StringIdResponse;
@@ -131,6 +137,8 @@ export type ProjectTaskResponse = Omit<
assignees?: TaskAssigneeRefResponse[] | null; assignees?: TaskAssigneeRefResponse[] | null;
attachments?: AttachmentItemResponse[] | null; attachments?: AttachmentItemResponse[] | null;
totalSpentHours?: number | null; totalSpentHours?: number | null;
priority?: string | number | null;
priorityName?: string | null;
}; };
export type TaskWorklogResponse = Omit< 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 { export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
return { return {
...response, ...response,
id: normalizeStringId(response.id), id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId), projectId: normalizeStringId(response.projectId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId), projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
ownerId: normalizeStringId(response.ownerId), ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null, ownerNickname: response.ownerNickname ?? null,
statusName: response.statusName ?? null, statusName: response.statusName ?? null,
@@ -254,6 +271,8 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
actualStartDate: normalizeProjectLocalDate(response.actualStartDate), actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate), actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0, progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? null,
executionDesc: response.executionDesc ?? null, executionDesc: response.executionDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null lastStatusReason: response.lastStatusReason ?? null
}; };
@@ -294,6 +313,9 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
projectId: normalizeStringId(response.projectId), projectId: normalizeStringId(response.projectId),
executionId: normalizeStringId(response.executionId), executionId: normalizeStringId(response.executionId),
parentTaskId: normalizeNullableStringId(response.parentTaskId), parentTaskId: normalizeNullableStringId(response.parentTaskId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
type: response.type ?? '', type: response.type ?? '',
ownerId: normalizeStringId(response.ownerId), ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null, ownerNickname: response.ownerNickname ?? null,
@@ -306,6 +328,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate), plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate), actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate), actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? null,
taskDesc: response.taskDesc ?? null, taskDesc: response.taskDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null, lastStatusReason: response.lastStatusReason ?? null,
assignees: assignees:
@@ -327,9 +351,11 @@ export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project
userId: normalizeStringId(response.userId), userId: normalizeStringId(response.userId),
userNickname: response.userNickname ?? null, userNickname: response.userNickname ?? null,
workContent: response.workContent ?? null, workContent: response.workContent ?? null,
difficulty: response.difficulty ?? '',
attachments: normalizeAttachments(response.attachments), 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
}; };
} }

View File

@@ -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( export function fetchChangeProjectExecutionStatus(
projectId: string, 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( export function fetchChangeProjectTaskStatus(
projectId: string, projectId: string,
@@ -855,6 +871,7 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId), currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId), sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments), attachments: normalizeAttachments(requirement.attachments),
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
children: requirement.children?.map(normalizeProjectRequirement) children: requirement.children?.map(normalizeProjectRequirement)
}; };
} }

View File

@@ -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')); return list.slice().sort((left, right) => left.sort - right.sort || left.label.localeCompare(right.label, 'zh-CN'));
} }
// hex 色值兜底校验:仅接受 #RRGGBB6 位);其他格式(含 #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( function normalizeFrontendDictData(
dictType: string, dictType: string,
list: Api.Dict.FrontendDictData[], list: Api.Dict.FrontendDictData[],
@@ -31,7 +40,8 @@ function normalizeFrontendDictData(
dictType: item.dictType || dictType, dictType: item.dictType || dictType,
sort: item.sort, sort: item.sort,
status: item.status ?? 0, status: item.status ?? 0,
remark: null, colorType: normalizeColorType(item.colorType),
remark: item.remark ?? null,
createTime: 0 createTime: 0
})); }));

View 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 });
});

View File

@@ -57,6 +57,8 @@ declare namespace Api {
sort: number; sort: number;
/** status: 0 enabled, 1 disabled */ /** status: 0 enabled, 1 disabled */
status: DictStatus; status: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** remark */ /** remark */
remark?: string | null; remark?: string | null;
/** create time */ /** create time */
@@ -77,6 +79,10 @@ declare namespace Api {
dictType?: string; dictType?: string;
/** status: 0 enabled, 1 disabled */ /** status: 0 enabled, 1 disabled */
status?: DictStatus; status?: DictStatus;
/** 颜色hex#xxxxxxnullable无值时前端按默认渲染 */
colorType?: string | null;
/** 备注,可用于下拉中文释义展示 */
remark?: string | null;
} }
/** frontend runtime dict cache map */ /** frontend runtime dict cache map */

View File

@@ -96,6 +96,10 @@ declare namespace Api {
id: string; id: string;
projectId: string; projectId: string;
projectRequirementId: string | null; projectRequirementId: string | null;
/** 关联项目需求名称service 层批量回填;未关联 = null */
projectRequirementName: string | null;
/** 关联项目需求状态编码pending_confirm/pending_review/implementing/accepted/closed/rejected/cancelled */
projectRequirementStatusCode: string | null;
executionName: string; executionName: string;
executionType: string | null; executionType: string | null;
ownerId: string; ownerId: string;
@@ -110,6 +114,10 @@ declare namespace Api {
actualStartDate: string | null; actualStartDate: string | null;
actualEndDate: string | null; actualEndDate: string | null;
progressRate: number; progressRate: number;
/** 优先级字典 valuerdms_req_priority"0" P0 / "1" P1默认/ "2" P2 / "3" P3数字越小越高 */
priority: string;
/** 优先级标签预留字段;当前后端不填、永远为 null前端按 priority 自译 */
priorityName: string | null;
executionDesc: string | null; executionDesc: string | null;
lastStatusReason: string | null; lastStatusReason: string | null;
createTime: string; createTime: string;
@@ -213,6 +221,12 @@ declare namespace Api {
projectId: string; projectId: string;
executionId: string; executionId: string;
parentTaskId: string | null; parentTaskId: string | null;
/** 所属执行关联的项目需求 ID透传未关联 = null */
projectRequirementId: string | null;
/** 所属执行关联的项目需求名称(透传,未关联 = null */
projectRequirementName: string | null;
/** 所属执行关联的项目需求状态编码(透传,未关联 = null */
projectRequirementStatusCode: string | null;
taskTitle: string; taskTitle: string;
type: string; type: string;
ownerId: string; ownerId: string;
@@ -231,6 +245,10 @@ declare namespace Api {
plannedEndDate: string | null; plannedEndDate: string | null;
actualStartDate: string | null; actualStartDate: string | null;
actualEndDate: string | null; actualEndDate: string | null;
/** 优先级字典 valuerdms_req_priority"0" P0 / "1" P1默认/ "2" P2 / "3" P3数字越小越高 */
priority: string;
/** 优先级标签预留字段;当前后端不填、永远为 null前端按 priority 自译 */
priorityName: string | null;
taskDesc: string | null; taskDesc: string | null;
lastStatusReason: string | null; lastStatusReason: string | null;
assignees?: TaskAssigneeRef[] | null; assignees?: TaskAssigneeRef[] | null;
@@ -247,6 +265,8 @@ declare namespace Api {
executionType: string; executionType: string;
ownerId: string; ownerId: string;
statusCode: string; statusCode: string;
/** 优先级筛选(字典 valueString "0"~"3"),不传 = 全部档位 */
priority: string;
updateTime: string[]; updateTime: string[];
} }
>; >;
@@ -266,6 +286,8 @@ declare namespace Api {
projectRequirementId: string | null; projectRequirementId: string | null;
plannedStartDate: string | null; plannedStartDate: string | null;
plannedEndDate: string | null; plannedEndDate: string | null;
/** 优先级字典 value必填String "0"~"3" */
priority: string;
executionDesc: string | null; executionDesc: string | null;
assigneeUserIds?: string[]; assigneeUserIds?: string[];
} }
@@ -280,6 +302,8 @@ declare namespace Api {
projectRequirementId: string | null; projectRequirementId: string | null;
plannedStartDate: string | null; plannedStartDate: string | null;
plannedEndDate: string | null; plannedEndDate: string | null;
/** 优先级字典 value必填String "0"~"3" */
priority: string;
executionDesc: string | null; executionDesc: string | null;
} }
@@ -307,6 +331,8 @@ declare namespace Api {
parentTaskId: string; parentTaskId: string;
ownerId: string; ownerId: string;
statusCode: string; statusCode: string;
/** 优先级筛选(字典 valueString "0"~"3"),不传 = 全部档位 */
priority: string;
updateTime: string[]; updateTime: string[];
} }
>; >;
@@ -331,6 +357,8 @@ declare namespace Api {
keyword: string; keyword: string;
parentTaskId: string; parentTaskId: string;
ownerId: string; ownerId: string;
/** 优先级筛选(字典 valueString "0"~"3"),不传 = 全部档位 */
priority: string;
updateTime: string[]; updateTime: string[];
} }
>; >;
@@ -356,6 +384,8 @@ declare namespace Api {
progressRate?: number; progressRate?: number;
plannedStartDate: string | null; plannedStartDate: string | null;
plannedEndDate: string | null; plannedEndDate: string | null;
/** 优先级字典 value必填String "0"~"3" */
priority: string;
taskDesc: string | null; taskDesc: string | null;
/** 仅创建任务时生效编辑接口静默忽略userId 必须是当前有效执行协办人且不能等于 ownerId */ /** 仅创建任务时生效编辑接口静默忽略userId 必须是当前有效执行协办人且不能等于 ownerId */
assigneeUserIds?: string[]; assigneeUserIds?: string[];
@@ -384,6 +414,8 @@ declare namespace Api {
progressRate: number; progressRate: number;
/** 难度,来自字典 rdms_task&item_worklog_difficulty */ /** 难度,来自字典 rdms_task&item_worklog_difficulty */
difficulty: string; difficulty: string;
/** 后端预留字段,目前始终为 null前端按 difficulty + 字典 cache 自译 */
difficultyName?: string | null;
workContent: string | null; workContent: string | null;
attachments?: AttachmentItem[] | null; attachments?: AttachmentItem[] | null;
createTime: string; createTime: string;
@@ -395,6 +427,8 @@ declare namespace Api {
userId: string; userId: string;
startDate: string; startDate: string;
endDate: string; endDate: string;
/** 完成难度筛选,等值匹配;不传 = 全部 */
difficulty: string;
} }
>; >;
@@ -603,6 +637,24 @@ declare namespace Api {
reason: string; 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 { interface CreateProjectMemberParams {
userId: string; userId: string;
@@ -725,6 +777,8 @@ declare namespace Api {
expectedTime?: string | null; expectedTime?: string | null;
/** 排序值 */ /** 排序值 */
sort: number; sort: number;
/** 项目需求进度BigDecimal0.00 ~ 1.00HALF_UP 两位小数)。读时聚合,后端不接受写入。 */
progressRate: number;
/** 创建时间 */ /** 创建时间 */
createTime: string; createTime: string;
/** 更新时间 */ /** 更新时间 */

View File

@@ -31,7 +31,7 @@ const PRODUCT_ENTRY_ROUTE_PATH = '/product/list';
function getInitSearchParams(): Api.Product.ProductSearchParams { function getInitSearchParams(): Api.Product.ProductSearchParams {
return { return {
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 20,
keyword: '', keyword: '',
directionCode: undefined, directionCode: undefined,
managerUserId: undefined, managerUserId: undefined,

View File

@@ -56,7 +56,7 @@ function search() {
</script> </script>
<template> <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> </template>
<style scoped></style> <style scoped></style>

View File

@@ -22,7 +22,7 @@ const PROJECT_ENTRY_ROUTE_PATH = '/project/list';
function getInitSearchParams(): Api.Project.ProjectSearchParams { function getInitSearchParams(): Api.Project.ProjectSearchParams {
return { return {
pageNo: 1, pageNo: 1,
pageSize: 10, pageSize: 20,
keyword: '', keyword: '',
directionCode: undefined, directionCode: undefined,
projectType: undefined, projectType: undefined,

View File

@@ -63,7 +63,7 @@ function search() {
</script> </script>
<template> <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> </template>
<style scoped></style> <style scoped></style>

View File

@@ -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>

View File

@@ -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 };
}

View File

@@ -19,7 +19,7 @@ const DEFAULT_PAGE_SIZE = 20;
export type BoardBaseParams = Pick< export type BoardBaseParams = Pick<
Api.Project.ProjectTaskSearchParams, Api.Project.ProjectTaskSearchParams,
'keyword' | 'parentTaskId' | 'ownerId' | 'updateTime' 'keyword' | 'parentTaskId' | 'ownerId' | 'priority' | 'updateTime'
>; >;
export interface UseTaskBoardColumnsOptions { export interface UseTaskBoardColumnsOptions {

View File

@@ -50,7 +50,9 @@ export function useTaskPermissions() {
} }
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean { 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 { function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean {
@@ -92,7 +94,8 @@ export function useTaskPermissions() {
} }
function canDeleteTask(task: Api.Project.ProjectTask): boolean { 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; if (hasPermission('project:task:delete')) return true;
return isTopLevelTask(task) return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId ? currentUserId.value === task.executionOwnerId

View File

@@ -12,6 +12,7 @@ import {
fetchGetProjectExecutionStatusBoard, fetchGetProjectExecutionStatusBoard,
fetchGetProjectMembers, fetchGetProjectMembers,
fetchInactiveProjectExecutionAssignee, fetchInactiveProjectExecutionAssignee,
fetchPrecheckDeleteProjectExecution,
fetchUpdateProjectExecution fetchUpdateProjectExecution
} from '@/service/api'; } from '@/service/api';
import { useObjectContextStore } from '@/store/modules/object-context'; import { useObjectContextStore } from '@/store/modules/object-context';
@@ -39,6 +40,7 @@ function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParam
executionType: undefined, executionType: undefined,
ownerId: undefined, ownerId: undefined,
statusCode: undefined, statusCode: undefined,
priority: undefined,
updateTime: undefined updateTime: undefined
}; };
} }
@@ -90,6 +92,7 @@ const statusActionTitle = computed(() =>
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes)); const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create')); const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
const deleteDialogVisible = ref(false); const deleteDialogVisible = ref(false);
const deleteExecutionDependentSummary = ref<string | null>(null);
const { canCreateTopLevelTask } = useTaskPermissions(); const { canCreateTopLevelTask } = useTaskPermissions();
// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置 // 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置
// 选中的执行 = null 时按钮隐藏(无对象上下文可判) // 选中的执行 = null 时按钮隐藏(无对象上下文可判)
@@ -298,6 +301,7 @@ async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionPa
projectRequirementId: payload.projectRequirementId, projectRequirementId: payload.projectRequirementId,
plannedStartDate: payload.plannedStartDate, plannedStartDate: payload.plannedStartDate,
plannedEndDate: payload.plannedEndDate, plannedEndDate: payload.plannedEndDate,
priority: payload.priority,
executionDesc: payload.executionDesc executionDesc: payload.executionDesc
}) })
: await fetchCreateProjectExecution(projectId.value, payload); : await fetchCreateProjectExecution(projectId.value, payload);
@@ -370,9 +374,48 @@ async function handleInactiveExecutionAssignee(
} }
} }
function handleDeleteExecution(row: Api.Project.ProjectExecution) { async function handleDeleteExecution(row: Api.Project.ProjectExecution) {
selectedExecution.value = row; if (!projectId.value) return;
deleteDialogVisible.value = true;
// 无下挂走简单二次确认;有/查询异常走原重型弹层
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 }) { async function confirmDeleteExecution(payload: { name: string; confirmText: string; reason: string }) {
@@ -456,6 +499,7 @@ watch(
v-model:visible="operateVisible" v-model:visible="operateVisible"
:mode="operateMode" :mode="operateMode"
:row-data="editingExecution" :row-data="editingExecution"
:project-id="projectId"
:user-options="projectMemberOptions" :user-options="projectMemberOptions"
:current-assignees="editingExecutionAssignees" :current-assignees="editingExecutionAssignees"
@submit="handleExecutionSubmit" @submit="handleExecutionSubmit"
@@ -483,6 +527,7 @@ watch(
v-model:visible="deleteDialogVisible" v-model:visible="deleteDialogVisible"
object-type="execution" object-type="execution"
:object-name="selectedExecution?.executionName ?? ''" :object-name="selectedExecution?.executionName ?? ''"
:dependent-summary="deleteExecutionDependentSummary"
:on-confirm="confirmDeleteExecution" :on-confirm="confirmDeleteExecution"
/> />
</div> </div>

View File

@@ -27,6 +27,8 @@ export function createEmptyExecution(projectId: string): Api.Project.ProjectExec
id: '', id: '',
projectId, projectId,
projectRequirementId: null, projectRequirementId: null,
projectRequirementName: null,
projectRequirementStatusCode: null,
executionName: '', executionName: '',
executionType: null, executionType: null,
ownerId: '', ownerId: '',
@@ -41,6 +43,8 @@ export function createEmptyExecution(projectId: string): Api.Project.ProjectExec
actualStartDate: null, actualStartDate: null,
actualEndDate: null, actualEndDate: null,
progressRate: 0, progressRate: 0,
priority: '1',
priorityName: null,
executionDesc: null, executionDesc: null,
lastStatusReason: null, lastStatusReason: null,
createTime: now, createTime: now,

View File

@@ -260,7 +260,7 @@ defineExpose({ reset });
:total="displayAssignees.length" :total="displayAssignees.length"
layout="total, prev, pager, next" layout="total, prev, pager, next"
background background
small size="small"
/> />
</div> </div>

View File

@@ -211,7 +211,7 @@ defineExpose({ refresh });
v-if="mobilePagination.total" v-if="mobilePagination.total"
background background
layout="total, prev, pager, next" layout="total, prev, pager, next"
small size="small"
v-bind="mobilePagination" v-bind="mobilePagination"
@current-change="mobilePagination['current-change']" @current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']" @size-change="mobilePagination['size-change']"

View File

@@ -1,8 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, markRaw } from 'vue'; import { computed, markRaw } from 'vue';
import { useRouter } from 'vue-router';
import type { PaginationProps } from 'element-plus'; 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 { formatDateRange, getExecutionStatusName, getExecutionStatusTagType } from '../shared';
import { projectRequirementStatusRecord } from '../../requirement/shared/requirement-master-data';
import { useTaskPermissions } from '../composables/use-task-permissions'; import { useTaskPermissions } from '../composables/use-task-permissions';
import IconMdiAccountMultipleOutline from '~icons/mdi/account-multiple-outline'; import IconMdiAccountMultipleOutline from '~icons/mdi/account-multiple-outline';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline'; import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
@@ -51,6 +56,19 @@ interface Emits {
const emit = defineEmits<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 }); const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
function handleSearch() { function handleSearch() {
@@ -71,6 +89,11 @@ function handleOwnerSelect(id: string | null | undefined) {
handleSearch(); handleSearch();
} }
function handlePrioritySelect(value: string | number | null | undefined) {
searchModel.value.priority = value ? String(value) : undefined;
handleSearch();
}
function handleReset() { function handleReset() {
emit('reset'); emit('reset');
} }
@@ -100,6 +123,11 @@ function handleStatusClick(status: ExecutionStatusFilter) {
emit('status-change', status); 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) { function handleSelect(row: Api.Project.ProjectExecution) {
emit('select', row); emit('select', row);
} }
@@ -240,6 +268,18 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
</div> </div>
</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="执行状态筛选"> <div class="execution-status-grid" aria-label="执行状态筛选">
<button <button
v-for="item in statusItems" v-for="item in statusItems"
@@ -277,6 +317,13 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
> >
{{ row.executionName || '未命名执行' }} {{ row.executionName || '未命名执行' }}
</strong> </strong>
<DictTag
class="execution-item__status-tag"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:value="row.priority"
effect="light"
size="small"
/>
<ElTag <ElTag
class="execution-item__status-tag" class="execution-item__status-tag"
:type="getExecutionStatusTagType(row.statusCode)" :type="getExecutionStatusTagType(row.statusCode)"
@@ -313,6 +360,25 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
<ElIcon><Calendar /></ElIcon> <ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }} 实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
</span> </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"> <span class="execution-item__progress-row">
<ElIcon><TrendCharts /></ElIcon> <ElIcon><TrendCharts /></ElIcon>
<ElProgress class="execution-item__progress" :percentage="row.progressRate" :stroke-width="6" /> <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"> <div v-if="paginationVisible" class="execution-list-panel__pagination">
<ElPagination <ElPagination
small size="small"
background background
layout="total, prev, pager, next" layout="total, prev, pager, next"
v-bind="pagination" v-bind="pagination"
@@ -372,6 +438,16 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
gap: 8px; gap: 8px;
} }
.execution-list-panel__filter {
display: flex;
align-items: center;
gap: 8px;
}
.execution-priority-select {
flex: 1;
}
.execution-search-input { .execution-search-input {
width: 140px; width: 140px;
flex: 0 0 auto; flex: 0 0 auto;
@@ -567,6 +643,26 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
min-width: 0; 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 { .execution-item__actions {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -2,14 +2,16 @@
import { computed, nextTick, reactive, ref, watch } from 'vue'; import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core'; import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs'; 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 { useForm, useFormRules } from '@/hooks/common/form';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue'; import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue'; import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.vue';
import BusinessUserSelect from '@/components/custom/business-user-select.vue'; import BusinessUserSelect from '@/components/custom/business-user-select.vue';
import DictSelect from '@/components/custom/dict-select.vue'; import DictSelect from '@/components/custom/dict-select.vue';
import RequirementTreePicker from '../components/requirement-tree-picker.vue';
import { isActiveExecutionAssignee, withVirtualOwnerAssignee } from '../shared'; import { isActiveExecutionAssignee, withVirtualOwnerAssignee } from '../shared';
import { useProjectRequirementOptions } from '../composables/use-project-requirement-options';
function isEmptyRichText(html: string | null | undefined) { function isEmptyRichText(html: string | null | undefined) {
if (!html) { if (!html) {
@@ -36,6 +38,8 @@ interface Props {
rowData: Api.Project.ProjectExecution | null; rowData: Api.Project.ProjectExecution | null;
userOptions: Api.SystemManage.UserSimple[]; userOptions: Api.SystemManage.UserSimple[];
currentAssignees?: Api.Project.ExecutionAssignee[]; currentAssignees?: Api.Project.ExecutionAssignee[];
/** 当前项目 ID父组件 useCurrentProject 取) */
projectId: string;
} }
interface Emits { interface Emits {
@@ -112,10 +116,21 @@ const model = reactive<Api.Project.SaveProjectExecutionParams>({
projectRequirementId: null, projectRequirementId: null,
plannedStartDate: null, plannedStartDate: null,
plannedEndDate: null, plannedEndDate: null,
priority: '3',
executionDesc: null, executionDesc: null,
assigneeUserIds: [] 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) { function buildEndDateShortcut(text: string, mutator: (date: Date) => void) {
return { return {
text, text,
@@ -161,6 +176,7 @@ const rules = computed(
({ ({
executionName: [createRequiredRule('请输入执行名称')], executionName: [createRequiredRule('请输入执行名称')],
executionType: [createRequiredRule('请选择执行类型')], executionType: [createRequiredRule('请选择执行类型')],
priority: [createRequiredRule('请选择优先级')],
ownerId: props.mode === 'create' ? [createRequiredRule('请选择执行负责人')] : [], ownerId: props.mode === 'create' ? [createRequiredRule('请选择执行负责人')] : [],
assigneeUserIds: props.mode === 'create' ? [createRequiredRule('请选择执行协办人')] : [], assigneeUserIds: props.mode === 'create' ? [createRequiredRule('请选择执行协办人')] : [],
plannedStartDate: [createRequiredRule('请选择计划开始日期')], plannedStartDate: [createRequiredRule('请选择计划开始日期')],
@@ -219,9 +235,10 @@ async function handleConfirm() {
executionName: model.executionName.trim(), executionName: model.executionName.trim(),
executionType: model.executionType.trim(), executionType: model.executionType.trim(),
ownerId: model.ownerId, ownerId: model.ownerId,
projectRequirementId: null, projectRequirementId: model.projectRequirementId,
plannedStartDate: model.plannedStartDate, plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate, plannedEndDate: model.plannedEndDate,
priority: model.priority,
executionDesc: isEmptyRichText(model.executionDesc) ? null : (model.executionDesc ?? null), executionDesc: isEmptyRichText(model.executionDesc) ? null : (model.executionDesc ?? null),
assigneeUserIds: props.mode === 'create' ? normalizeAssigneeUserIds(model.assigneeUserIds) : undefined assigneeUserIds: props.mode === 'create' ? normalizeAssigneeUserIds(model.assigneeUserIds) : undefined
}); });
@@ -245,13 +262,17 @@ watch(
model.executionName = props.rowData?.executionName || ''; model.executionName = props.rowData?.executionName || '';
model.executionType = props.rowData?.executionType || ''; model.executionType = props.rowData?.executionType || '';
model.ownerId = props.rowData?.ownerId || ''; model.ownerId = props.rowData?.ownerId || '';
model.projectRequirementId = null; model.projectRequirementId = props.rowData?.projectRequirementId ?? null;
model.plannedStartDate = props.rowData?.plannedStartDate || null; model.plannedStartDate = props.rowData?.plannedStartDate || null;
model.plannedEndDate = props.rowData?.plannedEndDate || null; model.plannedEndDate = props.rowData?.plannedEndDate || null;
model.priority = props.rowData?.priority || '3';
model.executionDesc = props.rowData?.executionDesc || null; model.executionDesc = props.rowData?.executionDesc || null;
model.assigneeUserIds = []; model.assigneeUserIds = [];
autoOwnerAssigneeId.value = null; autoOwnerAssigneeId.value = null;
// 每次打开弹层都重拉一次非终态项目需求,保证下拉新鲜(避免页面挂载后到打开期间需求被关掉)
reloadRequirementOptions();
await nextTick(); await nextTick();
formRef.value?.clearValidate(); formRef.value?.clearValidate();
} }
@@ -302,6 +323,26 @@ watch(
/> />
</ElFormItem> </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"> <ElFormItem v-if="mode === 'create'" label="负责人" prop="ownerId">
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" /> <BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
</ElFormItem> </ElFormItem>

View File

@@ -11,6 +11,8 @@ interface Props {
objectType: 'execution' | 'task'; objectType: 'execution' | 'task';
/** 当前对象的名称,用作输入框 placeholder 参照;提交时校验完全一致 */ /** 当前对象的名称,用作输入框 placeholder 参照;提交时校验完全一致 */
objectName: string; objectName: string;
/** 下挂数据摘要,由 precheck 计算后传入(如 "下含 3 个子任务 + 5 条工作日志"null/空则走兜底文案 */
dependentSummary?: string | null;
/** 删除确认回调async接收三个字段resolve 后由调用方决定刷新/关闭 */ /** 删除确认回调async接收三个字段resolve 后由调用方决定刷新/关闭 */
onConfirm: (payload: { name: string; confirmText: string; reason: string }) => Promise<void>; onConfirm: (payload: { name: string; confirmText: string; reason: string }) => Promise<void>;
} }
@@ -79,7 +81,10 @@ async function handleConfirm() {
@confirm="handleConfirm" @confirm="handleConfirm"
> >
<ElAlert type="error" :closable="false" show-icon> <ElAlert type="error" :closable="false" show-icon>
此操作不可撤销删除后{{ objectTypeLabel }}下挂数据将不可见 <template v-if="dependentSummary">
此操作不可撤销{{ objectTypeLabel }}{{ dependentSummary }}删除后将一并不可见
</template>
<template v-else>此操作不可撤销删除后{{ objectTypeLabel }}下挂数据将不可见</template>
</ElAlert> </ElAlert>
<ElForm label-position="top" class="mt-3"> <ElForm label-position="top" class="mt-3">

View File

@@ -188,7 +188,7 @@ defineExpose({ reset });
:total="assignees.length" :total="assignees.length"
layout="total, prev, pager, next" layout="total, prev, pager, next"
background background
small size="small"
/> />
</div> </div>

View File

@@ -196,7 +196,7 @@ function getOperatorDisplay(row: Api.Project.TaskAssigneeLog) {
v-if="mobilePagination.total" v-if="mobilePagination.total"
background background
layout="total, prev, pager, next" layout="total, prev, pager, next"
small size="small"
v-bind="mobilePagination" v-bind="mobilePagination"
@current-change="mobilePagination['current-change']" @current-change="mobilePagination['current-change']"
@size-change="mobilePagination['size-change']" @size-change="mobilePagination['size-change']"

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onBeforeUnmount, onMounted, toRef } from 'vue'; import { computed, onBeforeUnmount, onMounted, toRef } from 'vue';
import { Calendar, Flag, User } from '@element-plus/icons-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 { formatDateRange, getProgressText, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useTaskActions } from '../composables/use-task-actions'; import { useTaskActions } from '../composables/use-task-actions';
import { type BoardBaseParams, useTaskBoardColumns } from '../composables/use-task-board-columns'; import { type BoardBaseParams, useTaskBoardColumns } from '../composables/use-task-board-columns';
@@ -116,9 +118,12 @@ onBeforeUnmount(() => {
> >
<div class="task-board-card-item__top"> <div class="task-board-card-item__top">
<strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong> <strong class="task-board-card-item__title">{{ task.taskTitle || '未命名任务' }}</strong>
<ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small"> <div class="task-board-card-item__top-tags">
{{ getTaskStatusName(task) }} <DictTag :dict-code="RDMS_REQ_PRIORITY_DICT_CODE" :value="task.priority" effect="light" size="small" />
</ElTag> <ElTag :type="getTaskStatusTagType(task.statusCode)" effect="light" size="small">
{{ getTaskStatusName(task) }}
</ElTag>
</div>
</div> </div>
<div class="task-board-card-item__meta"> <div class="task-board-card-item__meta">
@@ -237,6 +242,13 @@ onBeforeUnmount(() => {
gap: 8px; 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 { .task-board-card-item__title {
min-width: 0; min-width: 0;
color: rgb(15 23 42 / 94%); color: rgb(15 23 42 / 94%);

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; 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 BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormSection from '@/components/custom/business-form-section.vue'; import BusinessFormSection from '@/components/custom/business-form-section.vue';
import BusinessRichTextEditor from '@/components/custom/business-rich-text-editor.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 parentTaskId = computed(() => props.task?.parentTaskId ?? null);
const plannedStartDate = computed(() => props.task?.plannedStartDate ?? null); const plannedStartDate = computed(() => props.task?.plannedStartDate ?? null);
const plannedEndDate = computed(() => props.task?.plannedEndDate ?? null); const plannedEndDate = computed(() => props.task?.plannedEndDate ?? null);
const priority = computed(() => props.task?.priority ?? null);
const attachments = computed(() => props.task?.attachments ?? []); const attachments = computed(() => props.task?.attachments ?? []);
const assigneeIds = computed(() => props.task?.assignees?.map(a => a.userId) ?? []); 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" /> <ElOption v-for="item in parentTaskOptions" :key="item.id" :label="item.taskTitle" :value="item.id" />
</ElSelect> </ElSelect>
</ElFormItem> </ElFormItem>
<ElFormItem label="优先级">
<DictSelect
:model-value="priority"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
disabled
placeholder="--"
show-remark
/>
</ElFormItem>
<ElFormItem label="负责人"> <ElFormItem label="负责人">
<BusinessUserSelect :model-value="ownerId" :options="userOptions" disabled placeholder="--" /> <BusinessUserSelect :model-value="ownerId" :options="userOptions" disabled placeholder="--" />
</ElFormItem> </ElFormItem>

View File

@@ -2,7 +2,7 @@
import { computed, nextTick, reactive, ref, watch } from 'vue'; import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useResizeObserver } from '@vueuse/core'; import { useResizeObserver } from '@vueuse/core';
import dayjs from 'dayjs'; 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 { useForm, useFormRules } from '@/hooks/common/form';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue'; import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import BusinessFormDialog from '@/components/custom/business-form-dialog.vue'; import BusinessFormDialog from '@/components/custom/business-form-dialog.vue';
@@ -64,6 +64,7 @@ interface FormModel {
ownerId: string | null; ownerId: string | null;
plannedStartDate: string | null; plannedStartDate: string | null;
plannedEndDate: string | null; plannedEndDate: string | null;
priority: string;
taskDesc: string | null; taskDesc: string | null;
assigneeUserIds: string[]; assigneeUserIds: string[];
attachments: Api.Project.AttachmentItem[]; attachments: Api.Project.AttachmentItem[];
@@ -76,6 +77,7 @@ const model = reactive<FormModel>({
ownerId: null, ownerId: null,
plannedStartDate: null, plannedStartDate: null,
plannedEndDate: null, plannedEndDate: null,
priority: '3',
taskDesc: null, taskDesc: null,
assigneeUserIds: [], assigneeUserIds: [],
attachments: [] attachments: []
@@ -125,6 +127,7 @@ const rules = computed(
({ ({
taskTitle: [createRequiredRule('请输入任务名称')], taskTitle: [createRequiredRule('请输入任务名称')],
type: [createRequiredRule('请选择任务类型')], type: [createRequiredRule('请选择任务类型')],
priority: [createRequiredRule('请选择优先级')],
ownerId: model.parentTaskId ? [] : [createRequiredRule('请选择负责人')], ownerId: model.parentTaskId ? [] : [createRequiredRule('请选择负责人')],
plannedStartDate: [createRequiredRule('请选择计划开始日期')], plannedStartDate: [createRequiredRule('请选择计划开始日期')],
plannedEndDate: [ plannedEndDate: [
@@ -236,6 +239,7 @@ async function handleConfirm() {
ownerId: model.ownerId || null, ownerId: model.ownerId || null,
plannedStartDate: model.plannedStartDate, plannedStartDate: model.plannedStartDate,
plannedEndDate: model.plannedEndDate, plannedEndDate: model.plannedEndDate,
priority: model.priority,
taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null), taskDesc: isEmptyRichText(model.taskDesc) ? null : (model.taskDesc ?? null),
attachments: [...model.attachments] attachments: [...model.attachments]
}; };
@@ -256,17 +260,22 @@ function handleAssigneeChange(value: string[]) {
model.assigneeUserIds = cleaned; 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() { function applyRowDataToModel() {
model.parentTaskId = const row = props.rowData;
props.mode === 'create' ? (props.defaultParentTaskId ?? null) : props.rowData?.parentTaskId || null; model.parentTaskId = props.mode === 'create' ? (props.defaultParentTaskId ?? null) : row?.parentTaskId || null;
model.taskTitle = props.rowData?.taskTitle || ''; applyBasicFieldsFromRow(row);
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;
model.assigneeUserIds = []; model.assigneeUserIds = [];
model.attachments = props.rowData?.attachments ? [...props.rowData.attachments] : []; model.attachments = row?.attachments ? [...row.attachments] : [];
} }
watch( watch(
@@ -344,6 +353,15 @@ defineExpose({
</ElSelect> </ElSelect>
</ElFormItem> </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"> <ElFormItem label="负责人" prop="ownerId">
<BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" /> <BusinessUserSelect v-model="model.ownerId" :options="userOptions" placeholder="请选择负责人" />
</ElFormItem> </ElFormItem>

View File

@@ -1,5 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue'; import TableSearchFields, { type SearchField } from '@/components/custom/table-search-fields.vue';
defineOptions({ name: 'ProjectExecutionTaskSearch' }); defineOptions({ name: 'ProjectExecutionTaskSearch' });
@@ -36,6 +37,14 @@ const fields = computed<SearchField[]>(() => [
type: 'input', type: 'input',
placeholder: '任务名称/说明' placeholder: '任务名称/说明'
}, },
{
key: 'priority',
label: '优先级',
type: 'dict',
dictCode: RDMS_REQ_PRIORITY_DICT_CODE,
placeholder: '全部优先级',
showRemark: true
},
{ {
key: 'statusCode', key: 'statusCode',
label: '状态', label: '状态',

View File

@@ -1,6 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, toRef } from 'vue'; import { computed, toRef } from 'vue';
import type { PaginationProps } from 'element-plus'; 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 { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useTaskActions } from '../composables/use-task-actions'; 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> <ElTag effect="plain" :type="getTaskStatusTagType(row.statusCode)">{{ getTaskStatusName(row) }}</ElTag>
</template> </template>
</ElTableColumn> </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> <ElTableColumn label="负责人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template> <template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
</ElTableColumn> </ElTableColumn>

View File

@@ -62,7 +62,8 @@ interface FormModel {
/** 0.5 颗粒小时数 */ /** 0.5 颗粒小时数 */
durationHours: number | null; durationHours: number | null;
progressRate: number; progressRate: number;
difficulty: string; /** 完成难度,字典 rdms_worklog_difficulty 的 value默认 "2";用户清空后为 null由 required 校验拦截 */
difficulty: string | null;
workContent: string | null; workContent: string | null;
attachments: Api.Project.AttachmentItem[]; attachments: Api.Project.AttachmentItem[];
} }
@@ -102,11 +103,20 @@ const weekDateShortcuts = [
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() } { 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周" // 选中后鼠标悬浮 input 显示该周的起止日期input 里默认只显示 "YYYY年第W周"
const weekRangeTooltip = computed(() => { const weekRangeTooltip = computed(() => {
if (!model.weekDate) return ''; const start = resolveIsoWeekStart(model.weekDate);
const start = dayjs(model.weekDate); if (!start) return '';
if (!start.isValid()) return '';
const end = start.add(6, 'day'); const end = start.add(6, 'day');
return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`; return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`;
}); });
@@ -184,7 +194,7 @@ const rules = computed(
trigger: 'change' trigger: 'change'
} }
], ],
difficulty: [createRequiredRule('请选择难度')], difficulty: [createRequiredRule('请选择完成难度')],
workContent: [ workContent: [
{ {
required: true, required: true,
@@ -251,7 +261,7 @@ function getStartEndFromModel(): { startDate: string; endDate: string } {
if (model.granularity === 'day') { if (model.granularity === 'day') {
return { startDate: model.workDate!, endDate: model.workDate! }; return { startDate: model.workDate!, endDate: model.workDate! };
} }
const weekStart = dayjs(model.weekDate!).startOf('isoWeek'); const weekStart = resolveIsoWeekStart(model.weekDate)!;
return { return {
startDate: weekStart.format('YYYY-MM-DD'), startDate: weekStart.format('YYYY-MM-DD'),
endDate: weekStart.add(6, 'day').format('YYYY-MM-DD') endDate: weekStart.add(6, 'day').format('YYYY-MM-DD')
@@ -287,7 +297,7 @@ async function handleConfirm() {
endDate, endDate,
durationHours: Number(model.durationHours!.toFixed(1)), durationHours: Number(model.durationHours!.toFixed(1)),
progressRate: Number(model.progressRate.toFixed(2)), progressRate: Number(model.progressRate.toFixed(2)),
difficulty: model.difficulty, difficulty: model.difficulty!,
workContent: model.workContent?.trim() || null, workContent: model.workContent?.trim() || null,
attachments: [...model.attachments] attachments: [...model.attachments]
}; };
@@ -315,6 +325,7 @@ watch(
model.weekDate = null; model.weekDate = null;
} }
model.durationHours = typeof row.durationHours === 'number' ? row.durationHours : null; model.durationHours = typeof row.durationHours === 'number' ? row.durationHours : null;
// PUT 需全字段回传,回显时按 row 原值row.difficulty 兜底已在 normalize 层
model.difficulty = row.difficulty || '2'; model.difficulty = row.difficulty || '2';
model.workContent = row.workContent || null; model.workContent = row.workContent || null;
model.attachments = row.attachments ? [...row.attachments] : []; model.attachments = row.attachments ? [...row.attachments] : [];
@@ -389,7 +400,7 @@ defineExpose({
</ElTooltip> </ElTooltip>
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="8">
<ElFormItem label="时长(小时)" prop="durationHours"> <ElFormItem label="时长(小时)" prop="durationHours">
<ElInputNumber <ElInputNumber
v-model="model.durationHours" v-model="model.durationHours"
@@ -403,7 +414,7 @@ defineExpose({
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="8">
<ElFormItem label="进度(%" prop="progressRate"> <ElFormItem label="进度(%" prop="progressRate">
<ElInputNumber <ElInputNumber
v-model="model.progressRate" v-model="model.progressRate"
@@ -417,13 +428,14 @@ defineExpose({
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="12"> <ElCol :span="8">
<ElFormItem label="难度" prop="difficulty"> <ElFormItem label="完成难度" prop="difficulty">
<DictSelect <DictSelect
v-model="model.difficulty" v-model="model.difficulty"
:dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE" :dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE"
placeholder="请选择完成难度"
:disabled="isView" :disabled="isView"
:clearable="false" show-remark
/> />
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
@@ -441,7 +453,7 @@ defineExpose({
</ElFormItem> </ElFormItem>
</ElCol> </ElCol>
<ElCol :span="24"> <ElCol :span="24">
<ElFormItem label="附件"> <ElFormItem label="附件" class="task-worklog-form-dialog__attachment-item">
<BusinessAttachmentUploader <BusinessAttachmentUploader
ref="attachmentUploaderRef" ref="attachmentUploaderRef"
v-model="model.attachments" v-model="model.attachments"
@@ -468,4 +480,10 @@ defineExpose({
display: block; display: block;
width: 100%; 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> </style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue'; import { Plus } from '@element-plus/icons-vue';
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
import { import {
fetchCreateProjectTaskWorklog, fetchCreateProjectTaskWorklog,
fetchDeleteProjectTaskWorklog, fetchDeleteProjectTaskWorklog,
@@ -8,7 +9,9 @@ import {
fetchUpdateProjectTaskWorklog fetchUpdateProjectTaskWorklog
} from '@/service/api/project'; } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue'; import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatWorklogPeriod, getWorklogGranularityName } from '../shared'; import { formatWorklogPeriod, getWorklogGranularityName } from '../shared';
import type { WorklogChangedPayload } from '../shared'; import type { WorklogChangedPayload } from '../shared';
import TaskWorklogFormDialog from './task-worklog-form-dialog.vue'; import TaskWorklogFormDialog from './task-worklog-form-dialog.vue';
@@ -70,6 +73,28 @@ const userFilter = ref<string[]>([]);
const userFilterPopoverVisible = ref(false); const userFilterPopoverVisible = ref(false);
const pendingUserFilter = ref<string[]>([]); 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 { interface UserFilterRichOption {
value: string; value: string;
name: string; name: string;
@@ -155,11 +180,14 @@ watch(userFilterPopoverVisible, value => {
}); });
const filteredExternalList = computed<Api.Project.TaskWorklog[]>(() => { const filteredExternalList = computed<Api.Project.TaskWorklog[]>(() => {
const all = props.externalList ?? []; let result = props.externalList ?? [];
if (!props.showAssigneeColumn || userFilter.value.length === 0) { if (props.showAssigneeColumn && userFilter.value.length > 0) {
return all; 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)); const total = computed(() => (usingExternal.value ? filteredExternalList.value.length : internalTotal.value));
@@ -237,6 +265,10 @@ async function loadList() {
if (!isOwner.value && props.canSubmit && currentUserId.value) { if (!isOwner.value && props.canSubmit && currentUserId.value) {
params.userId = currentUserId.value; params.userId = currentUserId.value;
} }
// 难度筛选:未选时不传参(避免 eqIfPresent 把空字符串当筛选条件漏掉全部)
if (difficultyFilter.value) {
params.difficulty = difficultyFilter.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage( const { error, data } = await fetchGetProjectTaskWorklogPage(
props.projectId, props.projectId,
@@ -351,12 +383,21 @@ watch(userFilter, () => {
pageNo.value = 1; pageNo.value = 1;
}); });
watch(difficultyFilter, () => {
pageNo.value = 1;
if (!usingExternal.value) {
loadList();
}
});
watch( watch(
() => props.taskId, () => props.taskId,
() => { () => {
pageNo.value = 1; pageNo.value = 1;
userFilter.value = []; userFilter.value = [];
userFilterPopoverVisible.value = false; userFilterPopoverVisible.value = false;
difficultyFilter.value = '';
difficultyFilterPopoverVisible.value = false;
loadList(); loadList();
}, },
{ immediate: true } { immediate: true }
@@ -515,6 +556,59 @@ watch(
<span v-else class="task-worklog-panel__content-cell-empty">--</span> <span v-else class="task-worklog-panel__content-cell-empty">--</span>
</template> </template>
</ElTableColumn> </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"> <ElTableColumn label="时长" width="100" align="center">
<template #default="{ row }"> <template #default="{ row }">
<span class="task-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span> <span class="task-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
@@ -579,7 +673,7 @@ watch(
<div class="task-worklog-panel__pagination"> <div class="task-worklog-panel__pagination">
<ElPagination <ElPagination
v-if="total > 0" v-if="total > 0"
small size="small"
background background
layout="total, prev, pager, next" layout="total, prev, pager, next"
:current-page="pageNo" :current-page="pageNo"
@@ -865,4 +959,48 @@ watch(
border-top: 1px solid var(--el-border-color-lighter); border-top: 1px solid var(--el-border-color-lighter);
margin-top: 4px; 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> </style>

View File

@@ -12,6 +12,7 @@ import {
fetchGetProjectTaskPage, fetchGetProjectTaskPage,
fetchGetProjectTaskStatusBoard, fetchGetProjectTaskStatusBoard,
fetchInactiveProjectTaskAssignee, fetchInactiveProjectTaskAssignee,
fetchPrecheckDeleteProjectTask,
fetchUpdateProjectTask fetchUpdateProjectTask
} from '@/service/api/project'; } from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
@@ -90,6 +91,7 @@ const detailDialogDefaultTab = ref<'info' | 'worklog'>('info');
const deleteTaskDialogVisible = ref(false); const deleteTaskDialogVisible = ref(false);
const deleteTaskTarget = ref<Api.Project.ProjectTask | null>(null); const deleteTaskTarget = ref<Api.Project.ProjectTask | null>(null);
const deleteTaskDependentSummary = ref<string | null>(null);
const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({ const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
pageNo: 1, pageNo: 1,
@@ -98,6 +100,7 @@ const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
parentTaskId: undefined, parentTaskId: undefined,
ownerId: undefined, ownerId: undefined,
statusCode: undefined, statusCode: undefined,
priority: undefined,
updateTime: 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) { function transformTaskPage(response: TaskPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) { if (!response.error && response.data) {
return { return {
@@ -189,6 +202,7 @@ function resetSearchParams() {
searchParams.parentTaskId = undefined; searchParams.parentTaskId = undefined;
searchParams.ownerId = undefined; searchParams.ownerId = undefined;
searchParams.statusCode = undefined; searchParams.statusCode = undefined;
searchParams.priority = undefined;
searchParams.updateTime = undefined; searchParams.updateTime = undefined;
} }
@@ -211,12 +225,15 @@ async function handleReset() {
await Promise.all([refreshTableData(true), loadTaskStatusBoard()]); await Promise.all([refreshTableData(true), loadTaskStatusBoard()]);
} }
function handleCreate() { async function handleCreate() {
if (!props.execution) { if (!props.execution) {
window.$message?.warning('请先选择执行项'); window.$message?.warning('请先选择执行项');
return; return;
} }
// 打开新增弹层前实时拉一次协办人列表,避免在执行侧改完成员(加/换负责人)后仍看到旧候选
await loadExecutionAssigneeOptions();
operateMode.value = 'create'; operateMode.value = 'create';
currentTask.value = null; currentTask.value = null;
presetParentTaskId.value = null; presetParentTaskId.value = null;
@@ -234,7 +251,8 @@ async function getTaskDetail(row: Api.Project.ProjectTask) {
} }
async function handleEdit(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) { if (!detail.allowEdit) {
window.$message?.warning('当前任务状态不允许编辑'); window.$message?.warning('当前任务状态不允许编辑');
@@ -476,9 +494,51 @@ async function loadExecutionAssigneeOptions() {
})); }));
} }
function openDeleteTaskDialog(task: Api.Project.ProjectTask) { async function openDeleteTaskDialog(task: Api.Project.ProjectTask) {
deleteTaskTarget.value = task; // 无下挂走简单二次确认;有/查询异常走原重型弹层。precheck 含子任务 + 工作日志双口径
deleteTaskDialogVisible.value = true; 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 }) { async function confirmDeleteTask(payload: { name: string; confirmText: string; reason: string }) {
@@ -582,7 +642,7 @@ watch(viewMode, async mode => {
:project-id="projectId" :project-id="projectId"
:execution-id="executionId" :execution-id="executionId"
:status-board="taskStatusBoard" :status-board="taskStatusBoard"
:base-params="createStatusBoardParams()" :base-params="createBoardBaseParams()"
@detail="handleDetail" @detail="handleDetail"
@edit="handleEdit" @edit="handleEdit"
@report="handleReport" @report="handleReport"
@@ -644,6 +704,7 @@ watch(viewMode, async mode => {
v-model:visible="deleteTaskDialogVisible" v-model:visible="deleteTaskDialogVisible"
object-type="task" object-type="task"
:object-name="deleteTaskTarget?.taskTitle ?? ''" :object-name="deleteTaskTarget?.taskTitle ?? ''"
:dependent-summary="deleteTaskDependentSummary"
:on-confirm="confirmDeleteTask" :on-confirm="confirmDeleteTask"
/> />
</section> </section>

View File

@@ -1,6 +1,6 @@
<script setup lang="tsx"> <script setup lang="tsx">
import { computed, reactive, ref, watch } from 'vue'; 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 { ElButton, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { import {
@@ -86,6 +86,7 @@ const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
3: 'danger' 3: 'danger'
}; };
const route = useRoute();
const router = useRouter(); const router = useRouter();
const { currentObjectId, currentProject } = useCurrentProject(); const { currentObjectId, currentProject } = useCurrentProject();
const { hasObjectAuth } = useAuth(); const { hasObjectAuth } = useAuth();
@@ -758,6 +759,24 @@ watch(
{ immediate: true } { 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()]); Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
</script> </script>

View 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));
}
}

View 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>;
}

View 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
};
}

View 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 };
}

View 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: {}
};
}

View File

@@ -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 };
}

View 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;
}

View File

@@ -345,3 +345,138 @@ export function getTodayLabel() {
const weekdayMap = ['日', '一', '二', '三', '四', '五', '六']; const weekdayMap = ['日', '一', '二', '三', '四', '五', '六'];
return `今天 ${today.format('YYYY-MM-DD')} 星期${weekdayMap[today.day()]}`; 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 }));
}

View File

@@ -1,46 +1,151 @@
<script setup lang="ts"> <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 { import {
buildWorkbenchActivityItems, type WorkbenchColumnId,
buildWorkbenchBannerSummary, type WorkbenchModuleKey,
buildWorkbenchKpiCards, useWorkbenchModules
buildWorkbenchProjectItems, } from './composables/use-workbench-modules';
buildWorkbenchTodoItems
} from './homepage';
import {
workbenchActivityMock,
workbenchBannerSummaryMock,
workbenchKpiMock,
workbenchProjectMock,
workbenchTodoMock
} from './mock';
import WorkbenchBanner from './modules/workbench-banner.vue'; 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 WorkbenchKpi from './modules/workbench-kpi.vue';
import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue'; import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue';
import WorkbenchActivityPanel from './modules/workbench-activity-panel.vue'; import WorkbenchActivityPanel from './modules/workbench-activity-panel.vue';
import WorkbenchProjectGrid from './modules/workbench-project-grid.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' }); 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 bannerSummary = computed(() => buildWorkbenchBannerSummary(workbenchBannerSummaryMock));
const kpiCards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
const todoItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock)); function onColumnUpdate(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) {
const activityItems = computed(() => buildWorkbenchActivityItems(workbenchActivityMock)); workbench.setColumnModules(columnId, modules);
const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock)); }
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> </script>
<template> <template>
<div class="workbench"> <div class="workbench">
<WorkbenchBanner :summary="bannerSummary" /> <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"> <WorkbenchEditOverlay
<WorkbenchTodoPanel :items="todoItems" /> v-if="workbench.mode === 'editing'"
<WorkbenchActivityPanel :items="activityItems" /> :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> </section>
<WorkbenchProjectGrid :items="projectItems" /> <WorkbenchModuleLibrary
v-model="libraryOpen"
:hidden-metas="workbench.hiddenMetas"
@add-module="
(key, col) => {
workbench.showModule(key, col);
libraryOpen = false;
}
"
/>
</div> </div>
</template> </template>
@@ -50,13 +155,15 @@ const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectM
flex-direction: column; flex-direction: column;
gap: 16px; gap: 16px;
} }
.workbench__toolbar {
display: flex;
justify-content: flex-end;
}
.workbench__main { .workbench__main {
display: grid; display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr); grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 16px; gap: 16px;
} }
@media (width <= 1280px) { @media (width <= 1280px) {
.workbench__main { .workbench__main {
grid-template-columns: 1fr; grid-template-columns: 1fr;

View File

@@ -2,8 +2,14 @@ import dayjs from 'dayjs';
import type { import type {
WorkbenchActivityItemSource, WorkbenchActivityItemSource,
WorkbenchBannerSummarySource, WorkbenchBannerSummarySource,
WorkbenchFavoriteItemSource,
WorkbenchKpiSource, WorkbenchKpiSource,
WorkbenchMyRequirementGroupSource,
WorkbenchMyTaskItemSource,
WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource,
WorkbenchProjectItemSource, WorkbenchProjectItemSource,
WorkbenchTeamTodoRowSource,
WorkbenchTodoItemSource WorkbenchTodoItemSource
} from './homepage'; } from './homepage';
@@ -192,3 +198,145 @@ export const workbenchProjectMock = [
lastActiveTime: iso(now.subtract(2, 'day').hour(10)) lastActiveTime: iso(now.subtract(2, 'day').hour(10))
} }
] satisfies WorkbenchProjectItemSource[]; ] 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[];

View File

@@ -1,24 +1,35 @@
<script setup lang="ts"> <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' }); defineOptions({ name: 'WorkbenchActivityPanel' });
interface Props { 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> </script>
<template> <template>
<ElCard class="workbench-activity card-wrapper" shadow="never"> <WorkbenchModuleCard
<template #header> title="最近动态"
<div> icon="mdi:timeline-outline"
<h3 class="workbench-activity__title">最近动态</h3> :editing="editing"
<p class="workbench-activity__desc">关注与我相关的需求任务工单变化与 @ 提醒</p> :collapsed="collapsed"
</div> @hide="$emit('hide')"
</template> @toggle-collapse="$emit('toggle-collapse')"
>
<div v-if="items.length" class="workbench-activity__list"> <div v-if="items.length" class="workbench-activity__list">
<article v-for="item in items" :key="item.id" class="workbench-activity__item"> <article v-for="item in items" :key="item.id" class="workbench-activity__item">
<div class="workbench-activity__rail"> <div class="workbench-activity__rail">
@@ -40,33 +51,10 @@ defineProps<Props>();
</article> </article>
</div> </div>
<ElEmpty v-else description="暂无动态" :image-size="72" /> <ElEmpty v-else description="暂无动态" :image-size="72" />
</ElCard> </WorkbenchModuleCard>
</template> </template>
<style scoped> <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 { .workbench-activity__list {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useAuthStore } from '@/store/modules/auth'; import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { getGreeting, getTodayLabel } from '../homepage'; import { getGreeting, getTodayLabel } from '../homepage';
import type { WorkbenchBannerSummary } from '../homepage'; import type { WorkbenchBannerSummary } from '../homepage';
@@ -13,7 +12,6 @@ interface Props {
const props = defineProps<Props>(); const props = defineProps<Props>();
const { routerPushByKey } = useRouterPush();
const authStore = useAuthStore(); const authStore = useAuthStore();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '同学'); 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.weekInProgress), tone: 'sky' as const },
{ label: '逾期', value: String(props.summary.weekOverdue), tone: 'rose' as const } { label: '逾期', value: String(props.summary.weekOverdue), tone: 'rose' as const }
]); ]);
function handleCreateRequirement() {
routerPushByKey('product_list');
}
function handleCreateTask() {
routerPushByKey('project_list');
}
</script> </script>
<template> <template>
@@ -40,7 +30,6 @@ function handleCreateTask() {
<div class="workbench-banner__identity"> <div class="workbench-banner__identity">
<div class="workbench-banner__title-group"> <div class="workbench-banner__title-group">
<h1 class="workbench-banner__title">{{ greeting }}{{ displayName }}</h1> <h1 class="workbench-banner__title">{{ greeting }}{{ displayName }}</h1>
<span class="workbench-banner__decor-word">RDMS</span>
</div> </div>
<p class="workbench-banner__subtitle">{{ todayLabel }}</p> <p class="workbench-banner__subtitle">{{ todayLabel }}</p>
@@ -59,17 +48,6 @@ function handleCreateTask() {
<span class="workbench-banner__digest-unit"></span> <span class="workbench-banner__digest-unit"></span>
</div> </div>
</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>
<div class="workbench-banner__rhythm"> <div class="workbench-banner__rhythm">
@@ -131,18 +109,6 @@ function handleCreateTask() {
letter-spacing: -0.02em; 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 { .workbench-banner__subtitle {
margin: 0; margin: 0;
color: rgb(100 116 139 / 92%); color: rgb(100 116 139 / 92%);
@@ -191,17 +157,6 @@ function handleCreateTask() {
user-select: none; 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 { .workbench-banner__rhythm {
display: flex; display: flex;
flex-direction: column; flex-direction: column;

View 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>

View 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>

View 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>

View File

@@ -1,13 +1,24 @@
<script setup lang="ts"> <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' }); defineOptions({ name: 'WorkbenchKpi' });
interface Props { 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']) { function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
if (trend === 'up') return 'mdi:arrow-top-right-thin'; if (trend === 'up') return 'mdi:arrow-top-right-thin';
@@ -17,27 +28,36 @@ function getTrendIcon(trend: WorkbenchKpiCard['trend']) {
</script> </script>
<template> <template>
<section class="workbench-kpi"> <WorkbenchModuleCard
<article title="KPI 速览"
v-for="card in cards" icon="mdi:view-dashboard-outline"
:key="card.key" :editing="editing"
class="workbench-kpi__card" :collapsed="collapsed"
:class="`workbench-kpi__card--${card.tone}`" @hide="$emit('hide')"
> @toggle-collapse="$emit('toggle-collapse')"
<div class="workbench-kpi__card-header"> >
<span class="workbench-kpi__card-label">{{ card.label }}</span> <section class="workbench-kpi">
<span class="workbench-kpi__card-icon"> <article
<SvgIcon :icon="card.icon" /> v-for="card in cards"
</span> :key="card.key"
</div> class="workbench-kpi__card"
<strong class="workbench-kpi__card-value">{{ card.value }}</strong> :class="`workbench-kpi__card--${card.tone}`"
<div class="workbench-kpi__card-trend" :class="`workbench-kpi__card-trend--${card.trend}`"> >
<SvgIcon :icon="getTrendIcon(card.trend)" class="workbench-kpi__card-trend-icon" /> <div class="workbench-kpi__card-header">
<span>{{ card.trendText }}</span> <span class="workbench-kpi__card-label">{{ card.label }}</span>
</div> <span class="workbench-kpi__card-icon">
<p class="workbench-kpi__card-hint">{{ card.hint }}</p> <SvgIcon :icon="card.icon" />
</article> </span>
</section> </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> </template>
<style scoped> <style scoped>

View 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>

View 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>

View 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>

View 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>

View 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>

View File

@@ -1,36 +1,49 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { useRouterPush } from '@/hooks/common/router'; 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' }); defineOptions({ name: 'WorkbenchProjectGrid' });
interface Props { 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 { routerPushByKey } = useRouterPush();
const items = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
function handleEnterProjectList() { function handleEnterProjectList() {
routerPushByKey('project_list'); routerPushByKey('project_list');
} }
</script> </script>
<template> <template>
<ElCard class="workbench-project card-wrapper" shadow="never"> <WorkbenchModuleCard
<template #header> title="我参与的项目"
<div class="workbench-project__header"> icon="mdi:briefcase-outline"
<div> :editing="editing"
<h3 class="workbench-project__title">我参与的项目</h3> :collapsed="collapsed"
<p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p> @hide="$emit('hide')"
</div> @toggle-collapse="$emit('toggle-collapse')"
<ElButton type="primary" link @click="handleEnterProjectList"> >
<span>进入项目列表</span> <div class="workbench-project__subheader">
<SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" /> <p class="workbench-project__desc">直接看每个项目的当前进度我的角色与未完成任务</p>
</ElButton> <ElButton type="primary" link @click="handleEnterProjectList">
</div> <span>进入项目列表</span>
</template> <SvgIcon icon="mdi:arrow-right-thin" class="workbench-project__more-icon" />
</ElButton>
</div>
<div v-if="items.length" class="workbench-project__grid"> <div v-if="items.length" class="workbench-project__grid">
<article v-for="item in items" :key="item.id" class="workbench-project__card"> <article v-for="item in items" :key="item.id" class="workbench-project__card">
@@ -81,35 +94,20 @@ function handleEnterProjectList() {
</article> </article>
</div> </div>
<ElEmpty v-else description="暂未参与任何项目" :image-size="72" /> <ElEmpty v-else description="暂未参与任何项目" :image-size="72" />
</ElCard> </WorkbenchModuleCard>
</template> </template>
<style scoped> <style scoped>
.workbench-project { .workbench-project__subheader {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-project__header {
display: flex; display: flex;
align-items: flex-start; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 12px; gap: 12px;
} margin-bottom: 14px;
.workbench-project__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
} }
.workbench-project__desc { .workbench-project__desc {
margin: 4px 0 0; margin: 0;
color: rgb(100 116 139 / 92%); color: rgb(100 116 139 / 92%);
font-size: 13px; font-size: 13px;
line-height: 1.6; line-height: 1.6;

View 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>

View 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>

View 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>

View 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>

View File

@@ -2,16 +2,28 @@
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import type { RouteKey } from '@elegant-router/types'; import type { RouteKey } from '@elegant-router/types';
import { useRouterPush } from '@/hooks/common/router'; import { useRouterPush } from '@/hooks/common/router';
import { filterWorkbenchTodoItems } from '../homepage'; import {
import type { WorkbenchTodoItem, WorkbenchTodoTimeBucket } from '../homepage'; type WorkbenchTodoItem,
type WorkbenchTodoTimeBucket,
buildWorkbenchTodoItems,
filterWorkbenchTodoItems
} from '../homepage';
import { workbenchTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTodoPanel' }); defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props { 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(); const { routerPushByKey } = useRouterPush();
@@ -24,14 +36,16 @@ const buckets: Array<{ key: WorkbenchTodoTimeBucket; label: string }> = [
{ key: 'overdue', label: '逾期' } { key: 'overdue', label: '逾期' }
]; ];
const items = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const bucketCounts = computed(() => ({ const bucketCounts = computed(() => ({
all: props.items.length, all: items.value.length,
today: filterWorkbenchTodoItems(props.items, 'today').length, today: filterWorkbenchTodoItems(items.value, 'today').length,
week: filterWorkbenchTodoItems(props.items, 'week').length, week: filterWorkbenchTodoItems(items.value, 'week').length,
overdue: filterWorkbenchTodoItems(props.items, 'overdue').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) { function handleClickItem(item: WorkbenchTodoItem) {
if (!item.routeKey) return; if (!item.routeKey) return;
@@ -48,28 +62,27 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</script> </script>
<template> <template>
<ElCard class="workbench-todo card-wrapper" shadow="never"> <WorkbenchModuleCard
<template #header> title="我的待办"
<div class="workbench-todo__header"> icon="mdi:clipboard-text-clock-outline"
<div class="workbench-todo__title-group"> :editing="editing"
<h3 class="workbench-todo__title">我的待办</h3> :collapsed="collapsed"
<p class="workbench-todo__desc">需要我处理的需求评审任务工单与 @ 提醒</p> @hide="$emit('hide')"
</div> @toggle-collapse="$emit('toggle-collapse')"
<div class="workbench-todo__tabs"> >
<button <div class="workbench-todo__tabs">
v-for="bucket in buckets" <button
:key="bucket.key" v-for="bucket in buckets"
type="button" :key="bucket.key"
class="workbench-todo__tab" type="button"
:class="{ 'workbench-todo__tab--active': activeBucket === bucket.key }" class="workbench-todo__tab"
@click="activeBucket = bucket.key" :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> <span>{{ bucket.label }}</span>
</button> <span class="workbench-todo__tab-count">{{ bucketCounts[bucket.key] }}</span>
</div> </button>
</div> </div>
</template>
<div v-if="filteredItems.length" class="workbench-todo__list"> <div v-if="filteredItems.length" class="workbench-todo__list">
<article <article
@@ -101,49 +114,15 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</article> </article>
</div> </div>
<ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" /> <ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" />
</ElCard> </WorkbenchModuleCard>
</template> </template>
<style scoped> <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 { .workbench-todo__tabs {
display: flex; display: flex;
gap: 6px; gap: 6px;
flex-wrap: wrap; flex-wrap: wrap;
margin-bottom: 14px;
} }
.workbench-todo__tab { .workbench-todo__tab {