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`
- 新建上传 / 下载流程 → FormData / Blob 天然不在去重范围,按原方式写即可
- 用户报"双击双发"复现不出来 → 检查目标按钮是否走了 `business-form-dialog`;若用的是 `ElMessageBox.confirm`,靠第二层就该挡住
---
## 20. 我生成文档的输出格式(强约束)
- **superpowers 工作流(`docs/superpowers/plans/``docs/superpowers/specs/`)下输出的文档继续用 `.md`**——工作流以 markdown 为前提。
- **其他**我生成的文档(设计方案、复盘、规约、技术经验沉淀等)**默认用 `.html`**,沿用 `docs/debt/` 现有 HTML 文档(参考 `token-刷新机制对齐分析.html``技术负债台账.html`)的样式骨架:
- 单文件、内联 CSS
- `max-width: 980px` 居中容器、`padding: 32px 28px 80px`
- 14px / `line-height: 1.7``PingFang SC` / `Microsoft YaHei` 中文字体优先
- 模块化区块:`section` + 编号 h2、`card``table.cmp``pre``tag-ok/warn/bad/crit`
- 配色用 `--bg / --panel / --border / --text / --primary` 一套 CSS 变量
- **`README.md`** 是目录索引约定文件,**保持 `.md`**(不强行 `.html`)。
- **已有 `.md` 文档不主动改写**,等用户明确要求再转。

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

View File

@@ -14,6 +14,8 @@ interface Props {
multiple?: boolean;
collapseTags?: boolean;
collapseTagsTooltip?: boolean;
/** 下拉项右侧追加字典 remark 中文释义(优先级等需要"P0 → 紧急"对照的场景) */
showRemark?: boolean;
}
const props = withDefaults(defineProps<Props>(), {
@@ -24,7 +26,8 @@ const props = withDefaults(defineProps<Props>(), {
onlyEnabled: true,
multiple: false,
collapseTags: false,
collapseTagsTooltip: false
collapseTagsTooltip: false,
showRemark: false
});
const model = defineModel<string | number | Array<string | number> | null | undefined>({
@@ -35,18 +38,27 @@ const { enabledDictData, dictData } = useDict(() => props.dictCode);
const dictOptions = computed(() => {
const source = props.onlyEnabled ? enabledDictData.value : dictData.value;
return source.map(item => ({
label: item.label,
value: item.value
value: item.value,
colorType: item.colorType ?? null,
remark: item.remark ?? null
}));
});
// 单选时取当前选中项的 colorType用于触发器 prefix 色块
const selectedColorType = computed<string | null>(() => {
if (props.multiple) return null;
const value = model.value;
if (value === null || value === undefined || value === '') return null;
return dictOptions.value.find(opt => opt.value === value)?.colorType ?? null;
});
</script>
<template>
<ElSelect
v-model="model"
class="w-full"
class="dict-select w-full"
:placeholder="props.placeholder"
:disabled="props.disabled"
:clearable="props.clearable"
@@ -55,8 +67,51 @@ const dictOptions = computed(() => {
:collapse-tags="props.collapseTags"
:collapse-tags-tooltip="props.collapseTagsTooltip"
>
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value" />
<template v-if="selectedColorType" #prefix>
<span class="dict-select__color-dot" :style="{ background: selectedColorType }" />
</template>
<ElOption v-for="item in dictOptions" :key="item.value" :label="item.label" :value="item.value">
<span class="dict-select__option">
<span
v-if="item.colorType"
class="dict-select__color-dot dict-select__color-dot--option"
:style="{ background: item.colorType }"
/>
<span class="dict-select__option-label">{{ item.label }}</span>
<span v-if="props.showRemark && item.remark" class="dict-select__option-remark">{{ item.remark }}</span>
</span>
</ElOption>
</ElSelect>
</template>
<style scoped></style>
<style scoped>
.dict-select__color-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
vertical-align: middle;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
}
.dict-select__color-dot--option {
margin-right: 8px;
}
.dict-select__option {
display: inline-flex;
align-items: center;
width: 100%;
gap: 8px;
}
.dict-select__option-label {
flex: 0 0 auto;
}
.dict-select__option-remark {
margin-left: auto;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>

View File

@@ -1,4 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useDict } from '@/hooks/business/dict';
import DictText from './dict-text.vue';
defineOptions({ name: 'DictTag' });
@@ -14,6 +16,7 @@ interface Props {
fallback?: string;
separator?: string;
onlyEnabled?: boolean;
/** 显式传入时优先;不传则按字典 item.colorType 自动取色 */
type?: DictTagType;
effect?: DictTagEffect;
size?: DictTagSize;
@@ -30,10 +33,54 @@ const props = withDefaults(defineProps<Props>(), {
size: 'default',
round: false
});
const { getItem } = useDict(() => props.dictCode);
// 单值才支持自动取色;多值(数组)走默认渲染避免歧义
const autoColorType = computed<string | null>(() => {
if (Array.isArray(props.value)) return null;
if (props.value === null || props.value === undefined || props.value === '') return null;
return getItem(props.value, { onlyEnabled: props.onlyEnabled })?.colorType ?? null;
});
// props.type 优先(向后兼容);其次字典 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>
<template>
<ElTag :type="props.type" :effect="props.effect" :size="props.size" :round="props.round">
<ElTag
:type="props.type"
:effect="props.effect"
:size="props.size"
:round="props.round"
:style="tagStyle ?? undefined"
>
<DictText
:dict-code="props.dictCode"
:value="props.value"

View File

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

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';
/**
* 需求优先级字典编码
* 优先级字典编码
*
* 对应业务字段:需求相关接口和页面中的 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';
@@ -84,14 +88,6 @@ export const RDMS_PROJECT_EXECUTION_TYPE_DICT_CODE = 'rdms_project_execution_typ
*/
export const OBJECT_STATUS_MODEL_OBJECT_TYPE_DICT_CODE = 'object_status_model_object_type';
/**
* 工作日志难度字典编码
*
* 对应业务字段:任务/个人事项工作日志中的 difficulty
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task&item_worklog_difficulty
*/
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task&item_worklog_difficulty';
/**
* 任务/个人事项类型字典编码
*
@@ -107,3 +103,11 @@ export const RDMS_TASK_ITEM_TYPE_DICT_CODE = 'rdms_task&item_type';
* 来源口径:用户在系统字典管理页中创建的字典 rdms_req_can_delete_status
*/
export const RDMS_REQ_CAN_DELETE_STATUS_DICT_CODE = 'rdms_req_can_delete_status';
/**
* 工作日志难度字典编码
*
* 对应业务字段:任务/个人事项工作日志中的 difficulty
* 来源口径:用户明确指定任务/个人事项工作日志难度下拉来自运行时字典 rdms_task&item_worklog_difficulty
*/
export const RDMS_WORKLOG_DIFFICULTY_DICT_CODE = 'rdms_task&item_worklog_difficulty';

View File

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

View File

@@ -23,6 +23,8 @@ export type ProjectExecutionResponse = Omit<
| 'actualStartDate'
| 'actualEndDate'
| 'progressRate'
| 'priority'
| 'priorityName'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
@@ -34,6 +36,8 @@ export type ProjectExecutionResponse = Omit<
actualStartDate?: ProjectLocalDateValue;
actualEndDate?: ProjectLocalDateValue;
progressRate?: number | null;
priority?: string | number | null;
priorityName?: string | null;
};
export type ExecutionAssigneeResponse = Omit<Api.Project.ExecutionAssignee, 'id' | 'executionId' | 'userId'> & {
@@ -116,6 +120,8 @@ export type ProjectTaskResponse = Omit<
| 'progressRate'
| 'assignees'
| 'attachments'
| 'priority'
| 'priorityName'
> & {
id: StringIdResponse;
projectId: StringIdResponse;
@@ -131,6 +137,8 @@ export type ProjectTaskResponse = Omit<
assignees?: TaskAssigneeRefResponse[] | null;
attachments?: AttachmentItemResponse[] | null;
totalSpentHours?: number | null;
priority?: string | number | null;
priorityName?: string | null;
};
export type TaskWorklogResponse = Omit<
@@ -237,12 +245,21 @@ export function normalizeProjectMember(response: ProjectMemberResponse): Api.Pro
};
}
function normalizePriority(value: string | number | null | undefined): string {
if (value === null || value === undefined || value === '') {
return '1';
}
return String(value);
}
export function normalizeProjectExecution(response: ProjectExecutionResponse): Api.Project.ProjectExecution {
return {
...response,
id: normalizeStringId(response.id),
projectId: normalizeStringId(response.projectId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null,
statusName: response.statusName ?? null,
@@ -254,6 +271,8 @@ export function normalizeProjectExecution(response: ProjectExecutionResponse): A
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? null,
executionDesc: response.executionDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null
};
@@ -294,6 +313,9 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
projectId: normalizeStringId(response.projectId),
executionId: normalizeStringId(response.executionId),
parentTaskId: normalizeNullableStringId(response.parentTaskId),
projectRequirementId: normalizeNullableStringId(response.projectRequirementId),
projectRequirementName: response.projectRequirementName ?? null,
projectRequirementStatusCode: response.projectRequirementStatusCode ?? null,
type: response.type ?? '',
ownerId: normalizeStringId(response.ownerId),
ownerNickname: response.ownerNickname ?? null,
@@ -306,6 +328,8 @@ export function normalizeProjectTask(response: ProjectTaskResponse): Api.Project
plannedEndDate: normalizeProjectLocalDate(response.plannedEndDate),
actualStartDate: normalizeProjectLocalDate(response.actualStartDate),
actualEndDate: normalizeProjectLocalDate(response.actualEndDate),
priority: normalizePriority(response.priority),
priorityName: response.priorityName ?? null,
taskDesc: response.taskDesc ?? null,
lastStatusReason: response.lastStatusReason ?? null,
assignees:
@@ -327,9 +351,11 @@ export function normalizeTaskWorklog(response: TaskWorklogResponse): Api.Project
userId: normalizeStringId(response.userId),
userNickname: response.userNickname ?? null,
workContent: response.workContent ?? null,
difficulty: response.difficulty ?? '',
attachments: normalizeAttachments(response.attachments),
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0
progressRate: typeof response.progressRate === 'number' ? response.progressRate : 0,
// 历史记录或异常缺失时兜底为字典默认档位 "2"
difficulty: response.difficulty ?? '2',
difficultyName: response.difficultyName ?? null
};
}

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(
projectId: string,
@@ -638,6 +646,14 @@ export function fetchDeleteProjectTask(
});
}
/** 任务删除预检spec §2.1 */
export function fetchPrecheckDeleteProjectTask(projectId: string, executionId: string, taskId: string) {
return request<Api.Project.ProjectTaskDeletePrecheck>({
url: `${getTaskPrefix(projectId, executionId)}/${taskId}/delete-precheck`,
method: 'get'
});
}
/** 变更项目任务状态 */
export function fetchChangeProjectTaskStatus(
projectId: string,
@@ -855,6 +871,7 @@ function normalizeProjectRequirement(requirement: ProjectRequirementResponse): A
currentHandlerUserId: normalizeNullableStringId(requirement.currentHandlerUserId),
sourceBizId: normalizeNullableStringId(requirement.sourceBizId),
attachments: normalizeAttachments(requirement.attachments),
progressRate: typeof requirement.progressRate === 'number' ? requirement.progressRate : 0,
children: requirement.children?.map(normalizeProjectRequirement)
};
}

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'));
}
// 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(
dictType: string,
list: Api.Dict.FrontendDictData[],
@@ -31,7 +40,8 @@ function normalizeFrontendDictData(
dictType: item.dictType || dictType,
sort: item.sort,
status: item.status ?? 0,
remark: null,
colorType: normalizeColorType(item.colorType),
remark: item.remark ?? null,
createTime: 0
}));

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

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ function search() {
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="reset" @search="search" />
</template>
<style scoped></style>

View File

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

View File

@@ -63,7 +63,7 @@ function search() {
</script>
<template>
<TableSearchFields v-model="model" :fields="fields" :columns="3" @reset="reset" @search="search" />
<TableSearchFields v-model="model" :fields="fields" :columns="4" @reset="reset" @search="search" />
</template>
<style scoped></style>

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<
Api.Project.ProjectTaskSearchParams,
'keyword' | 'parentTaskId' | 'ownerId' | 'updateTime'
'keyword' | 'parentTaskId' | 'ownerId' | 'priority' | 'updateTime'
>;
export interface UseTaskBoardColumnsOptions {

View File

@@ -50,7 +50,9 @@ export function useTaskPermissions() {
}
function canDeleteExecution(execution: Api.Project.ProjectExecution): boolean {
return execution.statusCode === 'pending' && hasPermission('project:execution:delete');
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
if (execution.statusCode === 'completed') return false;
return hasPermission('project:execution:delete');
}
function canChangeExecutionOwner(execution: Api.Project.ProjectExecution): boolean {
@@ -92,7 +94,8 @@ export function useTaskPermissions() {
}
function canDeleteTask(task: Api.Project.ProjectTask): boolean {
if (task.statusCode !== 'pending') return false;
// completed 终态后端硬卡(不允许主动删,级联删除时由后端兜底),前端按钮也拦掉
if (task.statusCode === 'completed') return false;
if (hasPermission('project:task:delete')) return true;
return isTopLevelTask(task)
? currentUserId.value === task.executionOwnerId

View File

@@ -12,6 +12,7 @@ import {
fetchGetProjectExecutionStatusBoard,
fetchGetProjectMembers,
fetchInactiveProjectExecutionAssignee,
fetchPrecheckDeleteProjectExecution,
fetchUpdateProjectExecution
} from '@/service/api';
import { useObjectContextStore } from '@/store/modules/object-context';
@@ -39,6 +40,7 @@ function getInitExecutionSearchParams(): Api.Project.ProjectExecutionSearchParam
executionType: undefined,
ownerId: undefined,
statusCode: undefined,
priority: undefined,
updateTime: undefined
};
}
@@ -90,6 +92,7 @@ const statusActionTitle = computed(() =>
const buttonCodeSet = computed(() => new Set(objectContextStore.buttonCodes));
const canCreateExecution = computed(() => buttonCodeSet.value.has('project:execution:create'));
const deleteDialogVisible = ref(false);
const deleteExecutionDependentSummary = ref<string | null>(null);
const { canCreateTopLevelTask } = useTaskPermissions();
// 第 2 类:项目内 RBAC 权限码 OR 执行 owner 字段身份;含 isMutable 状态前置
// 选中的执行 = null 时按钮隐藏(无对象上下文可判)
@@ -298,6 +301,7 @@ async function handleExecutionSubmit(payload: Api.Project.SaveProjectExecutionPa
projectRequirementId: payload.projectRequirementId,
plannedStartDate: payload.plannedStartDate,
plannedEndDate: payload.plannedEndDate,
priority: payload.priority,
executionDesc: payload.executionDesc
})
: await fetchCreateProjectExecution(projectId.value, payload);
@@ -370,9 +374,48 @@ async function handleInactiveExecutionAssignee(
}
}
function handleDeleteExecution(row: Api.Project.ProjectExecution) {
async function handleDeleteExecution(row: Api.Project.ProjectExecution) {
if (!projectId.value) return;
// 无下挂走简单二次确认;有/查询异常走原重型弹层
const precheck = await fetchPrecheckDeleteProjectExecution(projectId.value, row.id);
const canDirectDelete = !precheck.error && precheck.data && !precheck.data.hasDependentData;
if (!canDirectDelete) {
selectedExecution.value = row;
deleteExecutionDependentSummary.value =
precheck.data && precheck.data.taskCount > 0 ? `下含 ${precheck.data.taskCount} 个任务` : null;
deleteDialogVisible.value = true;
return;
}
try {
await window.$messageBox?.confirm(
`确定要删除执行“${row.executionName}”吗?删除后将不可见,如需恢复请联系管理员。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
);
} catch {
return;
}
const { error } = await fetchDeleteProjectExecution(projectId.value, row.id, {
executionName: row.executionName,
confirmText: 'DELETE',
reason: '无下挂数据,用户已二次确认'
});
if (error) {
// 简化路径出错多为缓存陈旧completed 被改/并发删),兜底刷新让用户看到最新状态
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
return;
}
window.$message?.success('删除成功');
selectedExecution.value = null;
await Promise.all([reloadExecutionData(1), loadExecutionStatusBoard()]);
}
async function confirmDeleteExecution(payload: { name: string; confirmText: string; reason: string }) {
@@ -456,6 +499,7 @@ watch(
v-model:visible="operateVisible"
:mode="operateMode"
:row-data="editingExecution"
:project-id="projectId"
:user-options="projectMemberOptions"
:current-assignees="editingExecutionAssignees"
@submit="handleExecutionSubmit"
@@ -483,6 +527,7 @@ watch(
v-model:visible="deleteDialogVisible"
object-type="execution"
:object-name="selectedExecution?.executionName ?? ''"
:dependent-summary="deleteExecutionDependentSummary"
:on-confirm="confirmDeleteExecution"
/>
</div>

View File

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

View File

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

View File

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

View File

@@ -1,8 +1,13 @@
<script setup lang="ts">
import { computed, markRaw } from 'vue';
import { useRouter } from 'vue-router';
import type { PaginationProps } from 'element-plus';
import { Calendar, Flag, Plus, TrendCharts, User } from '@element-plus/icons-vue';
import { Calendar, Flag, Link, Plus, TrendCharts, User } from '@element-plus/icons-vue';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import DictSelect from '@/components/custom/dict-select.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, getExecutionStatusName, getExecutionStatusTagType } from '../shared';
import { projectRequirementStatusRecord } from '../../requirement/shared/requirement-master-data';
import { useTaskPermissions } from '../composables/use-task-permissions';
import IconMdiAccountMultipleOutline from '~icons/mdi/account-multiple-outline';
import IconMdiCheckCircleOutline from '~icons/mdi/check-circle-outline';
@@ -51,6 +56,19 @@ interface Emits {
const emit = defineEmits<Emits>();
const router = useRouter();
function handleRequirementClick(row: Api.Project.ProjectExecution) {
if (!row.projectRequirementId) return;
router.push({
path: '/project/project/requirement',
query: {
objectId: row.projectId,
requirementId: row.projectRequirementId
}
});
}
const searchModel = defineModel<Api.Project.ProjectExecutionSearchParams>('searchModel', { required: true });
function handleSearch() {
@@ -71,6 +89,11 @@ function handleOwnerSelect(id: string | null | undefined) {
handleSearch();
}
function handlePrioritySelect(value: string | number | null | undefined) {
searchModel.value.priority = value ? String(value) : undefined;
handleSearch();
}
function handleReset() {
emit('reset');
}
@@ -100,6 +123,11 @@ function handleStatusClick(status: ExecutionStatusFilter) {
emit('status-change', status);
}
function getProjectRequirementStatusName(code: string | null) {
if (!code) return '';
return projectRequirementStatusRecord[code as keyof typeof projectRequirementStatusRecord] ?? code;
}
function handleSelect(row: Api.Project.ProjectExecution) {
emit('select', row);
}
@@ -240,6 +268,18 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
</div>
</div>
<div class="execution-list-panel__filter">
<DictSelect
:model-value="searchModel.priority ?? null"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
class="execution-priority-select"
placeholder="筛选优先级"
clearable
show-remark
@update:model-value="handlePrioritySelect"
/>
</div>
<div class="execution-status-grid" aria-label="执行状态筛选">
<button
v-for="item in statusItems"
@@ -277,6 +317,13 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
>
{{ row.executionName || '未命名执行' }}
</strong>
<DictTag
class="execution-item__status-tag"
:dict-code="RDMS_REQ_PRIORITY_DICT_CODE"
:value="row.priority"
effect="light"
size="small"
/>
<ElTag
class="execution-item__status-tag"
:type="getExecutionStatusTagType(row.statusCode)"
@@ -313,6 +360,25 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
<ElIcon><Calendar /></ElIcon>
实际 {{ formatDateRange(row.actualStartDate, row.actualEndDate) }}
</span>
<span v-if="row.projectRequirementId && row.projectRequirementName" class="execution-item__requirement">
<ElIcon><Link /></ElIcon>
<ElTooltip
:content="getProjectRequirementStatusName(row.projectRequirementStatusCode)"
placement="top"
:show-after="200"
:disabled="!row.projectRequirementStatusCode"
>
<span
class="execution-item__requirement-name"
role="button"
tabindex="0"
@click.stop="handleRequirementClick(row)"
@keydown.enter.stop.prevent="handleRequirementClick(row)"
>
{{ row.projectRequirementName }}
</span>
</ElTooltip>
</span>
<span class="execution-item__progress-row">
<ElIcon><TrendCharts /></ElIcon>
<ElProgress class="execution-item__progress" :percentage="row.progressRate" :stroke-width="6" />
@@ -325,7 +391,7 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
<div v-if="paginationVisible" class="execution-list-panel__pagination">
<ElPagination
small
size="small"
background
layout="total, prev, pager, next"
v-bind="pagination"
@@ -372,6 +438,16 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
gap: 8px;
}
.execution-list-panel__filter {
display: flex;
align-items: center;
gap: 8px;
}
.execution-priority-select {
flex: 1;
}
.execution-search-input {
width: 140px;
flex: 0 0 auto;
@@ -567,6 +643,26 @@ function createActions(row: Api.Project.ProjectExecution): ExecutionAction[] {
min-width: 0;
}
.execution-item__requirement {
display: inline-flex;
align-items: center;
gap: 4px;
max-width: 320px;
overflow: hidden;
}
.execution-item__requirement-name {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--el-color-primary);
cursor: pointer;
}
.execution-item__requirement-name:hover {
text-decoration: underline;
}
.execution-item__actions {
display: flex;
align-items: center;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,8 @@
<script setup lang="ts">
import { computed, toRef } from 'vue';
import type { PaginationProps } from 'element-plus';
import { RDMS_REQ_PRIORITY_DICT_CODE } from '@/constants/dict';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatDateRange, formatDateTime, getTaskStatusName, getTaskStatusTagType } from '../shared';
import { useTaskActions } from '../composables/use-task-actions';
@@ -87,6 +89,11 @@ function handleSizeChange(pageSize: number) {
<ElTag effect="plain" :type="getTaskStatusTagType(row.statusCode)">{{ getTaskStatusName(row) }}</ElTag>
</template>
</ElTableColumn>
<ElTableColumn label="优先级" width="90" align="center">
<template #default="{ row }">
<DictTag :dict-code="RDMS_REQ_PRIORITY_DICT_CODE" :value="row.priority" />
</template>
</ElTableColumn>
<ElTableColumn label="负责人" min-width="120" show-overflow-tooltip>
<template #default="{ row }">{{ row.ownerNickname || row.ownerId || '--' }}</template>
</ElTableColumn>

View File

@@ -62,7 +62,8 @@ interface FormModel {
/** 0.5 颗粒小时数 */
durationHours: number | null;
progressRate: number;
difficulty: string;
/** 完成难度,字典 rdms_worklog_difficulty 的 value默认 "2";用户清空后为 null由 required 校验拦截 */
difficulty: string | null;
workContent: string | null;
attachments: Api.Project.AttachmentItem[];
}
@@ -102,11 +103,20 @@ const weekDateShortcuts = [
{ text: '上周', value: () => dayjs().subtract(1, 'week').startOf('isoWeek').toDate() }
];
// EP type='week' 默认 firstDayOfWeek=7从日历点选时返回当周"周日"作为周首日。
// 我们按 ISO 周(周一-周日)存储 / 展示,遇到周日时先 +1 天,避免 startOf('isoWeek') 回退到上一周。
function resolveIsoWeekStart(weekDate: Date | null) {
if (!weekDate) return null;
const picked = dayjs(weekDate);
if (!picked.isValid()) return null;
const aligned = picked.isoWeekday() === 7 ? picked.add(1, 'day') : picked;
return aligned.startOf('isoWeek');
}
// 选中后鼠标悬浮 input 显示该周的起止日期input 里默认只显示 "YYYY年第W周"
const weekRangeTooltip = computed(() => {
if (!model.weekDate) return '';
const start = dayjs(model.weekDate);
if (!start.isValid()) return '';
const start = resolveIsoWeekStart(model.weekDate);
if (!start) return '';
const end = start.add(6, 'day');
return `${start.format('YYYY-MM-DD')} ~ ${end.format('YYYY-MM-DD')}`;
});
@@ -184,7 +194,7 @@ const rules = computed(
trigger: 'change'
}
],
difficulty: [createRequiredRule('请选择难度')],
difficulty: [createRequiredRule('请选择完成难度')],
workContent: [
{
required: true,
@@ -251,7 +261,7 @@ function getStartEndFromModel(): { startDate: string; endDate: string } {
if (model.granularity === 'day') {
return { startDate: model.workDate!, endDate: model.workDate! };
}
const weekStart = dayjs(model.weekDate!).startOf('isoWeek');
const weekStart = resolveIsoWeekStart(model.weekDate)!;
return {
startDate: weekStart.format('YYYY-MM-DD'),
endDate: weekStart.add(6, 'day').format('YYYY-MM-DD')
@@ -287,7 +297,7 @@ async function handleConfirm() {
endDate,
durationHours: Number(model.durationHours!.toFixed(1)),
progressRate: Number(model.progressRate.toFixed(2)),
difficulty: model.difficulty,
difficulty: model.difficulty!,
workContent: model.workContent?.trim() || null,
attachments: [...model.attachments]
};
@@ -315,6 +325,7 @@ watch(
model.weekDate = null;
}
model.durationHours = typeof row.durationHours === 'number' ? row.durationHours : null;
// PUT 需全字段回传,回显时按 row 原值row.difficulty 兜底已在 normalize 层
model.difficulty = row.difficulty || '2';
model.workContent = row.workContent || null;
model.attachments = row.attachments ? [...row.attachments] : [];
@@ -389,7 +400,7 @@ defineExpose({
</ElTooltip>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol :span="8">
<ElFormItem label="时长(小时)" prop="durationHours">
<ElInputNumber
v-model="model.durationHours"
@@ -403,7 +414,7 @@ defineExpose({
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElCol :span="8">
<ElFormItem label="进度(%" prop="progressRate">
<ElInputNumber
v-model="model.progressRate"
@@ -417,13 +428,14 @@ defineExpose({
/>
</ElFormItem>
</ElCol>
<ElCol :span="12">
<ElFormItem label="难度" prop="difficulty">
<ElCol :span="8">
<ElFormItem label="完成难度" prop="difficulty">
<DictSelect
v-model="model.difficulty"
:dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE"
placeholder="请选择完成难度"
:disabled="isView"
:clearable="false"
show-remark
/>
</ElFormItem>
</ElCol>
@@ -441,7 +453,7 @@ defineExpose({
</ElFormItem>
</ElCol>
<ElCol :span="24">
<ElFormItem label="附件">
<ElFormItem label="附件" class="task-worklog-form-dialog__attachment-item">
<BusinessAttachmentUploader
ref="attachmentUploaderRef"
v-model="model.attachments"
@@ -468,4 +480,10 @@ defineExpose({
display: block;
width: 100%;
}
/* 预留附件区域最小高度(触发器一行 + 2 条附件占位),避免前两条附件加进去时 align-center dialog 重新居中产生抖动 */
.task-worklog-form-dialog__attachment-item :deep(.el-form-item__content) {
min-height: 120px;
align-content: flex-start;
}
</style>

View File

@@ -1,6 +1,7 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue';
import { Plus } from '@element-plus/icons-vue';
import { RDMS_WORKLOG_DIFFICULTY_DICT_CODE } from '@/constants/dict';
import {
fetchCreateProjectTaskWorklog,
fetchDeleteProjectTaskWorklog,
@@ -8,7 +9,9 @@ import {
fetchUpdateProjectTaskWorklog
} from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
import { useDict } from '@/hooks/business/dict';
import BusinessAttachmentUploader from '@/components/custom/business-attachment-uploader.vue';
import DictTag from '@/components/custom/dict-tag.vue';
import { formatWorklogPeriod, getWorklogGranularityName } from '../shared';
import type { WorklogChangedPayload } from '../shared';
import TaskWorklogFormDialog from './task-worklog-form-dialog.vue';
@@ -70,6 +73,28 @@ const userFilter = ref<string[]>([]);
const userFilterPopoverVisible = ref(false);
const pendingUserFilter = ref<string[]>([]);
// 完成难度筛选(单选,对齐后端 eqIfPresent。'' = 未筛选
const difficultyFilter = ref<string>('');
const difficultyFilterPopoverVisible = ref(false);
const pendingDifficultyFilter = ref<string>('');
const { enabledDictData: difficultyDictItems } = useDict(RDMS_WORKLOG_DIFFICULTY_DICT_CODE);
function handleDifficultyFilterConfirm() {
difficultyFilter.value = pendingDifficultyFilter.value;
difficultyFilterPopoverVisible.value = false;
}
function handleDifficultyFilterReset() {
pendingDifficultyFilter.value = '';
}
watch(difficultyFilterPopoverVisible, value => {
if (value) {
pendingDifficultyFilter.value = difficultyFilter.value;
}
});
interface UserFilterRichOption {
value: string;
name: string;
@@ -155,11 +180,14 @@ watch(userFilterPopoverVisible, value => {
});
const filteredExternalList = computed<Api.Project.TaskWorklog[]>(() => {
const all = props.externalList ?? [];
if (!props.showAssigneeColumn || userFilter.value.length === 0) {
return all;
let result = props.externalList ?? [];
if (props.showAssigneeColumn && userFilter.value.length > 0) {
result = result.filter(item => userFilter.value.includes(item.userId));
}
return all.filter(item => userFilter.value.includes(item.userId));
if (difficultyFilter.value) {
result = result.filter(item => item.difficulty === difficultyFilter.value);
}
return result;
});
const total = computed(() => (usingExternal.value ? filteredExternalList.value.length : internalTotal.value));
@@ -237,6 +265,10 @@ async function loadList() {
if (!isOwner.value && props.canSubmit && currentUserId.value) {
params.userId = currentUserId.value;
}
// 难度筛选:未选时不传参(避免 eqIfPresent 把空字符串当筛选条件漏掉全部)
if (difficultyFilter.value) {
params.difficulty = difficultyFilter.value;
}
const { error, data } = await fetchGetProjectTaskWorklogPage(
props.projectId,
@@ -351,12 +383,21 @@ watch(userFilter, () => {
pageNo.value = 1;
});
watch(difficultyFilter, () => {
pageNo.value = 1;
if (!usingExternal.value) {
loadList();
}
});
watch(
() => props.taskId,
() => {
pageNo.value = 1;
userFilter.value = [];
userFilterPopoverVisible.value = false;
difficultyFilter.value = '';
difficultyFilterPopoverVisible.value = false;
loadList();
},
{ immediate: true }
@@ -515,6 +556,59 @@ watch(
<span v-else class="task-worklog-panel__content-cell-empty">--</span>
</template>
</ElTableColumn>
<ElTableColumn label="难度" width="110" align="center">
<template #header>
<div class="task-worklog-panel__user-header">
<span>难度</span>
<ElPopover
v-model:visible="difficultyFilterPopoverVisible"
trigger="click"
placement="bottom"
:width="180"
popper-class="task-worklog-panel__user-filter-popper"
>
<template #reference>
<span
class="task-worklog-panel__user-filter-trigger"
:class="{ 'is-active': !!difficultyFilter }"
@click.stop
>
<IconMdiFilterVariant />
</span>
</template>
<div class="task-worklog-panel__user-filter">
<ElRadioGroup v-model="pendingDifficultyFilter" class="task-worklog-panel__difficulty-filter-list">
<ElRadio
v-for="option in difficultyDictItems"
:key="option.value"
:value="option.value"
class="task-worklog-panel__difficulty-filter-row"
>
<span class="task-worklog-panel__difficulty-filter-item">
<span
v-if="option.colorType"
class="task-worklog-panel__difficulty-filter-dot"
:style="{ background: option.colorType }"
/>
<span class="task-worklog-panel__difficulty-filter-label">{{ option.label }}</span>
<span v-if="option.remark" class="task-worklog-panel__difficulty-filter-remark">
{{ option.remark }}
</span>
</span>
</ElRadio>
</ElRadioGroup>
<div class="task-worklog-panel__user-filter-footer">
<ElButton size="small" link @click="handleDifficultyFilterReset">重置</ElButton>
<ElButton size="small" type="primary" @click="handleDifficultyFilterConfirm">确定</ElButton>
</div>
</div>
</ElPopover>
</div>
</template>
<template #default="{ row }">
<DictTag :dict-code="RDMS_WORKLOG_DIFFICULTY_DICT_CODE" :value="row.difficulty" size="small" effect="light" />
</template>
</ElTableColumn>
<ElTableColumn label="时长" width="100" align="center">
<template #default="{ row }">
<span class="task-worklog-panel__duration">{{ formatHours(row.durationHours) }}</span>
@@ -579,7 +673,7 @@ watch(
<div class="task-worklog-panel__pagination">
<ElPagination
v-if="total > 0"
small
size="small"
background
layout="total, prev, pager, next"
:current-page="pageNo"
@@ -865,4 +959,48 @@ watch(
border-top: 1px solid var(--el-border-color-lighter);
margin-top: 4px;
}
.task-worklog-panel__difficulty-filter-list {
display: flex;
flex-direction: column;
padding: 0 12px;
}
.task-worklog-panel__difficulty-filter-row.el-radio {
height: auto;
margin: 0;
padding: 4px 0;
}
.task-worklog-panel__difficulty-filter-row .el-radio__label {
padding-left: 6px;
flex: 1;
min-width: 0;
}
.task-worklog-panel__difficulty-filter-item {
display: inline-flex;
align-items: center;
gap: 6px;
width: 100%;
}
.task-worklog-panel__difficulty-filter-dot {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 50%;
box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.08);
flex: 0 0 auto;
}
.task-worklog-panel__difficulty-filter-label {
color: var(--el-text-color-primary);
}
.task-worklog-panel__difficulty-filter-remark {
margin-left: auto;
color: var(--el-text-color-secondary);
font-size: 12px;
}
</style>

View File

@@ -12,6 +12,7 @@ import {
fetchGetProjectTaskPage,
fetchGetProjectTaskStatusBoard,
fetchInactiveProjectTaskAssignee,
fetchPrecheckDeleteProjectTask,
fetchUpdateProjectTask
} from '@/service/api/project';
import { useAuthStore } from '@/store/modules/auth';
@@ -90,6 +91,7 @@ const detailDialogDefaultTab = ref<'info' | 'worklog'>('info');
const deleteTaskDialogVisible = ref(false);
const deleteTaskTarget = ref<Api.Project.ProjectTask | null>(null);
const deleteTaskDependentSummary = ref<string | null>(null);
const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
pageNo: 1,
@@ -98,6 +100,7 @@ const searchParams = reactive<Api.Project.ProjectTaskSearchParams>({
parentTaskId: undefined,
ownerId: undefined,
statusCode: undefined,
priority: undefined,
updateTime: undefined
});
@@ -137,6 +140,16 @@ function createStatusBoardParams(): Api.Project.ProjectTaskStatusBoardParams {
};
}
function createBoardBaseParams() {
return {
keyword: searchParams.keyword?.trim() || undefined,
parentTaskId: searchParams.parentTaskId,
ownerId: searchParams.ownerId,
priority: searchParams.priority,
updateTime: searchParams.updateTime
};
}
function transformTaskPage(response: TaskPageResponse, pageNo: number, pageSize: number) {
if (!response.error && response.data) {
return {
@@ -189,6 +202,7 @@ function resetSearchParams() {
searchParams.parentTaskId = undefined;
searchParams.ownerId = undefined;
searchParams.statusCode = undefined;
searchParams.priority = undefined;
searchParams.updateTime = undefined;
}
@@ -211,12 +225,15 @@ async function handleReset() {
await Promise.all([refreshTableData(true), loadTaskStatusBoard()]);
}
function handleCreate() {
async function handleCreate() {
if (!props.execution) {
window.$message?.warning('请先选择执行项');
return;
}
// 打开新增弹层前实时拉一次协办人列表,避免在执行侧改完成员(加/换负责人)后仍看到旧候选
await loadExecutionAssigneeOptions();
operateMode.value = 'create';
currentTask.value = null;
presetParentTaskId.value = null;
@@ -234,7 +251,8 @@ async function getTaskDetail(row: Api.Project.ProjectTask) {
}
async function handleEdit(row: Api.Project.ProjectTask) {
const detail = await getTaskDetail(row);
// 同 handleCreate编辑前一并刷新协办人免得改过执行成员后这里仍是缓存
const [detail] = await Promise.all([getTaskDetail(row), loadExecutionAssigneeOptions()]);
if (!detail.allowEdit) {
window.$message?.warning('当前任务状态不允许编辑');
@@ -476,9 +494,51 @@ async function loadExecutionAssigneeOptions() {
}));
}
function openDeleteTaskDialog(task: Api.Project.ProjectTask) {
async function openDeleteTaskDialog(task: Api.Project.ProjectTask) {
// 无下挂走简单二次确认;有/查询异常走原重型弹层。precheck 含子任务 + 工作日志双口径
const precheck = await fetchPrecheckDeleteProjectTask(task.projectId, task.executionId, task.id);
const canDirectDelete = !precheck.error && precheck.data && !precheck.data.hasDependentData;
if (!canDirectDelete) {
deleteTaskTarget.value = task;
if (precheck.data) {
const parts: string[] = [];
if (precheck.data.childTaskCount > 0) parts.push(`${precheck.data.childTaskCount} 个子任务`);
if (precheck.data.worklogCount > 0) parts.push(`${precheck.data.worklogCount} 条工作日志`);
deleteTaskDependentSummary.value = parts.length ? `下含 ${parts.join(' + ')}` : null;
} else {
deleteTaskDependentSummary.value = null;
}
deleteTaskDialogVisible.value = true;
return;
}
try {
await window.$messageBox?.confirm(
`确定要删除任务“${task.taskTitle}”吗?删除后将不可见,如需恢复请联系管理员。`,
'删除确认',
{
confirmButtonText: '确认删除',
cancelButtonText: '取消',
type: 'warning'
}
);
} catch {
return;
}
const { error } = await fetchDeleteProjectTask(task.projectId, task.executionId, task.id, {
taskName: task.taskTitle,
confirmText: 'DELETE',
reason: '无下挂数据,用户已二次确认'
});
if (error) {
// 简化路径出错多为缓存陈旧completed 被改/并发删),兜底刷新让用户看到最新状态
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
return;
}
window.$message?.success('删除成功');
await Promise.all([refreshTableData(), loadTaskStatusBoard()]);
}
async function confirmDeleteTask(payload: { name: string; confirmText: string; reason: string }) {
@@ -582,7 +642,7 @@ watch(viewMode, async mode => {
:project-id="projectId"
:execution-id="executionId"
:status-board="taskStatusBoard"
:base-params="createStatusBoardParams()"
:base-params="createBoardBaseParams()"
@detail="handleDetail"
@edit="handleEdit"
@report="handleReport"
@@ -644,6 +704,7 @@ watch(viewMode, async mode => {
v-model:visible="deleteTaskDialogVisible"
object-type="task"
:object-name="deleteTaskTarget?.taskTitle ?? ''"
:dependent-summary="deleteTaskDependentSummary"
:on-confirm="confirmDeleteTask"
/>
</section>

View File

@@ -1,6 +1,6 @@
<script setup lang="tsx">
import { computed, reactive, ref, watch } from 'vue';
import { useRouter } from 'vue-router';
import { useRoute, useRouter } from 'vue-router';
import { ElButton, ElTag, ElTooltip } from 'element-plus';
import dayjs from 'dayjs';
import {
@@ -86,6 +86,7 @@ const priorityTagTypeMap: Record<number, UI.ThemeColor> = {
3: 'danger'
};
const route = useRoute();
const router = useRouter();
const { currentObjectId, currentProject } = useCurrentProject();
const { hasObjectAuth } = useAuth();
@@ -758,6 +759,24 @@ watch(
{ immediate: true }
);
watch(
() => [route.query.requirementId, treeData.value] as const,
([targetId]) => {
if (!targetId) return;
const idStr = String(targetId);
const flat = flattenTree(treeData.value);
const found = flat.find(item => item.id === idStr);
if (found) {
openView(found);
// 清掉 requirementId query保留其它参数
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { requirementId: _rid, ...restQuery } = route.query;
router.replace({ query: restQuery });
}
},
{ immediate: true }
);
Promise.all([loadStatusOptions(), loadTerminalStatusOptions()]);
</script>

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 = ['日', '一', '二', '三', '四', '五', '六'];
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">
import { computed } from 'vue';
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { onBeforeRouteLeave } from 'vue-router';
import { ElMessageBox } from 'element-plus';
import { useWorkbenchStore } from '@/store/modules/workbench';
import { buildWorkbenchBannerSummary } from './homepage';
import { workbenchBannerSummaryMock } from './mock';
import {
buildWorkbenchActivityItems,
buildWorkbenchBannerSummary,
buildWorkbenchKpiCards,
buildWorkbenchProjectItems,
buildWorkbenchTodoItems
} from './homepage';
import {
workbenchActivityMock,
workbenchBannerSummaryMock,
workbenchKpiMock,
workbenchProjectMock,
workbenchTodoMock
} from './mock';
type WorkbenchColumnId,
type WorkbenchModuleKey,
useWorkbenchModules
} from './composables/use-workbench-modules';
import WorkbenchBanner from './modules/workbench-banner.vue';
import WorkbenchColumn from './modules/workbench-column.vue';
import WorkbenchEditOverlay from './modules/workbench-edit-overlay.vue';
import WorkbenchModuleLibrary from './modules/workbench-module-library.vue';
import WorkbenchKpi from './modules/workbench-kpi.vue';
import WorkbenchTodoPanel from './modules/workbench-todo-panel.vue';
import WorkbenchActivityPanel from './modules/workbench-activity-panel.vue';
import WorkbenchProjectGrid from './modules/workbench-project-grid.vue';
import WorkbenchMyTask from './modules/workbench-my-task.vue';
import WorkbenchMyRequirement from './modules/workbench-my-requirement.vue';
import WorkbenchTeamTodo from './modules/workbench-team-todo.vue';
import WorkbenchProjectHealth from './modules/workbench-project-health.vue';
import WorkbenchProgressChart from './modules/workbench-progress-chart.vue';
import WorkbenchFavorite from './modules/workbench-favorite.vue';
import WorkbenchShortcut from './modules/workbench-shortcut.vue';
defineOptions({ name: 'Workbench' });
const { registerModuleComponent } = useWorkbenchModules();
registerModuleComponent('kpi', WorkbenchKpi);
registerModuleComponent('myTodo', WorkbenchTodoPanel);
registerModuleComponent('myProject', WorkbenchProjectGrid);
registerModuleComponent('activity', WorkbenchActivityPanel);
registerModuleComponent('myTask', WorkbenchMyTask);
registerModuleComponent('myRequirement', WorkbenchMyRequirement);
registerModuleComponent('teamTodo', WorkbenchTeamTodo);
registerModuleComponent('projectHealth', WorkbenchProjectHealth);
registerModuleComponent('progressChart', WorkbenchProgressChart);
registerModuleComponent('favorite', WorkbenchFavorite);
registerModuleComponent('shortcut', WorkbenchShortcut);
const workbench = useWorkbenchStore();
const libraryOpen = ref(false);
onMounted(() => {
workbench.load();
});
function onBeforeUnload(e: BeforeUnloadEvent) {
if (workbench.mode === 'editing' && workbench.dirty) {
e.preventDefault();
e.returnValue = '';
}
}
onMounted(() => window.addEventListener('beforeunload', onBeforeUnload));
onBeforeUnmount(() => window.removeEventListener('beforeunload', onBeforeUnload));
watch(
() => workbench.error,
err => {
if (err) window.$message?.error(`布局保存失败:${err.message}`);
}
);
const bannerSummary = computed(() => buildWorkbenchBannerSummary(workbenchBannerSummaryMock));
const kpiCards = computed(() => buildWorkbenchKpiCards(workbenchKpiMock));
const todoItems = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const activityItems = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectMock));
function onColumnUpdate(columnId: WorkbenchColumnId, modules: WorkbenchModuleKey[]) {
workbench.setColumnModules(columnId, modules);
}
async function handleReset() {
try {
await ElMessageBox.confirm('重置后将恢复默认布局,确认继续?', '重置默认布局', { type: 'warning' });
await workbench.resetToDefault();
} catch {
/* cancelled */
}
}
onBeforeRouteLeave(async (_to, _from, next) => {
if (workbench.mode === 'editing' && workbench.dirty) {
try {
await ElMessageBox.confirm('编辑布局未保存,确认离开?', '确认离开', { type: 'warning' });
workbench.cancelEditing();
next();
} catch {
next(false);
}
} else {
next();
}
});
</script>
<template>
<div class="workbench">
<WorkbenchBanner :summary="bannerSummary" />
<WorkbenchKpi :cards="kpiCards" />
<div class="workbench__toolbar">
<ElButton v-if="workbench.mode === 'normal'" type="primary" link @click="workbench.enterEditing">
<SvgIcon icon="mdi:pencil-outline" />
自定义布局
</ElButton>
<ElButton v-else type="primary" link @click="libraryOpen = true">
<SvgIcon icon="mdi:view-grid-plus-outline" />
模块库
</ElButton>
</div>
<section class="workbench__main">
<WorkbenchTodoPanel :items="todoItems" />
<WorkbenchActivityPanel :items="activityItems" />
<WorkbenchEditOverlay
v-if="workbench.mode === 'editing'"
:dirty="workbench.dirty"
:saving="workbench.saving"
@save="workbench.saveEditing"
@cancel="workbench.cancelEditing"
@reset="handleReset"
/>
<ElEmpty v-if="workbench.layout.columns.every(c => c.modules.length === 0)" description="还没有可见模块">
<ElButton type="primary" @click="workbench.enterEditing">添加模块</ElButton>
</ElEmpty>
<section v-else class="workbench__main">
<WorkbenchColumn
v-for="col in workbench.layout.columns"
:key="col.id"
:column-id="col.id"
:modules="col.modules"
:editing="workbench.mode === 'editing'"
:collapsed="workbench.layout.collapsed"
@update:modules="onColumnUpdate(col.id, $event)"
@hide="workbench.hideModule"
@toggle-collapse="workbench.toggleCollapse"
/>
</section>
<WorkbenchProjectGrid :items="projectItems" />
<WorkbenchModuleLibrary
v-model="libraryOpen"
:hidden-metas="workbench.hiddenMetas"
@add-module="
(key, col) => {
workbench.showModule(key, col);
libraryOpen = false;
}
"
/>
</div>
</template>
@@ -50,13 +155,15 @@ const projectItems = computed(() => buildWorkbenchProjectItems(workbenchProjectM
flex-direction: column;
gap: 16px;
}
.workbench__toolbar {
display: flex;
justify-content: flex-end;
}
.workbench__main {
display: grid;
grid-template-columns: minmax(0, 1.35fr) minmax(0, 1fr);
gap: 16px;
}
@media (width <= 1280px) {
.workbench__main {
grid-template-columns: 1fr;

View File

@@ -2,8 +2,14 @@ import dayjs from 'dayjs';
import type {
WorkbenchActivityItemSource,
WorkbenchBannerSummarySource,
WorkbenchFavoriteItemSource,
WorkbenchKpiSource,
WorkbenchMyRequirementGroupSource,
WorkbenchMyTaskItemSource,
WorkbenchProgressBarSource,
WorkbenchProjectHealthCardSource,
WorkbenchProjectItemSource,
WorkbenchTeamTodoRowSource,
WorkbenchTodoItemSource
} from './homepage';
@@ -192,3 +198,145 @@ export const workbenchProjectMock = [
lastActiveTime: iso(now.subtract(2, 'day').hour(10))
}
] satisfies WorkbenchProjectItemSource[];
export const workbenchMyTaskMock = [
{
id: 't-1',
title: '支付回调接口联调',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '收银台 V3 · 后端联调',
projectName: '收银台 V3',
priority: 'high',
deadline: iso(now.add(1, 'day').hour(17))
},
{
id: 't-2',
title: '订单导出 V2 文档编写',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '订单中心 · 文档',
projectName: '订单中心',
priority: 'mid',
deadline: iso(now.add(3, 'day').hour(12))
},
{
id: 't-3',
title: 'API 返回结构调整',
statusCode: 'pending',
statusLabel: '待开始',
executionName: '收银台 V3 · 后端联调',
projectName: '收银台 V3',
priority: 'mid',
deadline: iso(now.subtract(1, 'day').hour(18))
},
{
id: 't-4',
title: '会员等级文案校对',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '会员中心 · 文案',
projectName: '会员中心',
priority: 'low',
deadline: iso(now.add(2, 'day').hour(15))
},
{
id: 't-5',
title: '收银台 H5 适配',
statusCode: 'inProgress',
statusLabel: '进行中',
executionName: '收银台 V3 · 前端',
projectName: '收银台 V3',
priority: 'high',
deadline: iso(now.hour(20))
}
] satisfies WorkbenchMyTaskItemSource[];
export const workbenchMyRequirementMock = [
{ statusCode: 'pendingReview', statusLabel: '待评审', count: 3, tone: 'amber' },
{ statusCode: 'reviewing', statusLabel: '评审中', count: 2, tone: 'sky' },
{ statusCode: 'developing', statusLabel: '开发中', count: 5, tone: 'emerald' },
{ statusCode: 'paused', statusLabel: '已暂停', count: 1, tone: 'rose' }
] satisfies WorkbenchMyRequirementGroupSource[];
export const workbenchTeamTodoMock = [
{
projectId: 'prj-1',
projectName: '收银台 V3',
memberId: 'm-1',
memberName: '张三',
inProgress: 5,
overdue: 2,
weekDone: 3
},
{
projectId: 'prj-1',
projectName: '收银台 V3',
memberId: 'm-2',
memberName: '李四',
inProgress: 3,
overdue: 0,
weekDone: 4
},
{
projectId: 'prj-2',
projectName: '会员中心',
memberId: 'm-3',
memberName: '王五',
inProgress: 2,
overdue: 1,
weekDone: 2
},
{
projectId: 'prj-3',
projectName: '订单中心',
memberId: 'm-4',
memberName: '赵六',
inProgress: 4,
overdue: 0,
weekDone: 5
}
] satisfies WorkbenchTeamTodoRowSource[];
export const workbenchProjectHealthMock = [
{
projectId: 'prj-1',
projectName: '收银台 V3',
code: 'CASHIER-V3',
health: 'yellow',
riskCount: 2,
overdueTasks: 3,
backlogRequirements: 2
},
{
projectId: 'prj-2',
projectName: '会员中心',
code: 'MEMBER',
health: 'green',
riskCount: 0,
overdueTasks: 0,
backlogRequirements: 1
},
{
projectId: 'prj-3',
projectName: '订单中心',
code: 'ORDER',
health: 'red',
riskCount: 4,
overdueTasks: 5,
backlogRequirements: 6
}
] satisfies WorkbenchProjectHealthCardSource[];
export const workbenchProgressChartMock = [
{ projectId: 'prj-1', projectName: '收银台 V3', weekCompletionRate: 78 },
{ projectId: 'prj-2', projectName: '会员中心', weekCompletionRate: 62 },
{ projectId: 'prj-3', projectName: '订单中心', weekCompletionRate: 45 }
] satisfies WorkbenchProgressBarSource[];
export const workbenchFavoriteMock = [
{ id: 'fav-1', kind: 'product', title: '收银台 V3', source: '产品库' },
{ id: 'fav-2', kind: 'project', title: '会员中心 · 一期', source: '项目库' },
{ id: 'fav-3', kind: 'requirement', title: '订单导出 V2', source: '收银台 V3' },
{ id: 'fav-4', kind: 'task', title: '支付回调接口联调', source: '收银台 V3 · 后端联调' }
] satisfies WorkbenchFavoriteItemSource[];

View File

@@ -1,24 +1,35 @@
<script setup lang="ts">
import type { WorkbenchActivityItem } from '../homepage';
import { computed } from 'vue';
import { buildWorkbenchActivityItems } from '../homepage';
import { workbenchActivityMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchActivityPanel' });
interface Props {
items: WorkbenchActivityItem[];
editing?: boolean;
collapsed?: boolean;
}
defineProps<Props>();
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const items = computed(() => buildWorkbenchActivityItems(workbenchActivityMock));
</script>
<template>
<ElCard class="workbench-activity card-wrapper" shadow="never">
<template #header>
<div>
<h3 class="workbench-activity__title">最近动态</h3>
<p class="workbench-activity__desc">关注与我相关的需求任务工单变化与 @ 提醒</p>
</div>
</template>
<WorkbenchModuleCard
title="最近动态"
icon="mdi:timeline-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div v-if="items.length" class="workbench-activity__list">
<article v-for="item in items" :key="item.id" class="workbench-activity__item">
<div class="workbench-activity__rail">
@@ -40,33 +51,10 @@ defineProps<Props>();
</article>
</div>
<ElEmpty v-else description="暂无动态" :image-size="72" />
</ElCard>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-activity {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-activity__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.workbench-activity__desc {
margin: 4px 0 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
}
.workbench-activity__list {
display: flex;
flex-direction: column;

View File

@@ -1,7 +1,6 @@
<script setup lang="ts">
import { computed } from 'vue';
import { useAuthStore } from '@/store/modules/auth';
import { useRouterPush } from '@/hooks/common/router';
import { getGreeting, getTodayLabel } from '../homepage';
import type { WorkbenchBannerSummary } from '../homepage';
@@ -13,7 +12,6 @@ interface Props {
const props = defineProps<Props>();
const { routerPushByKey } = useRouterPush();
const authStore = useAuthStore();
const displayName = computed(() => authStore.userInfo.nickname || authStore.userInfo.userName || '同学');
@@ -25,14 +23,6 @@ const rhythmItems = computed(() => [
{ label: '进行中', value: String(props.summary.weekInProgress), tone: 'sky' as const },
{ label: '逾期', value: String(props.summary.weekOverdue), tone: 'rose' as const }
]);
function handleCreateRequirement() {
routerPushByKey('product_list');
}
function handleCreateTask() {
routerPushByKey('project_list');
}
</script>
<template>
@@ -40,7 +30,6 @@ function handleCreateTask() {
<div class="workbench-banner__identity">
<div class="workbench-banner__title-group">
<h1 class="workbench-banner__title">{{ greeting }}{{ displayName }}</h1>
<span class="workbench-banner__decor-word">RDMS</span>
</div>
<p class="workbench-banner__subtitle">{{ todayLabel }}</p>
@@ -59,17 +48,6 @@ function handleCreateTask() {
<span class="workbench-banner__digest-unit"></span>
</div>
</div>
<div class="workbench-banner__actions">
<ElButton type="primary" @click="handleCreateRequirement">
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
<span>新建需求</span>
</ElButton>
<ElButton @click="handleCreateTask">
<SvgIcon icon="mdi:plus" class="workbench-banner__btn-icon" />
<span>新建任务</span>
</ElButton>
</div>
</div>
<div class="workbench-banner__rhythm">
@@ -131,18 +109,6 @@ function handleCreateTask() {
letter-spacing: -0.02em;
}
.workbench-banner__decor-word {
color: transparent;
background: linear-gradient(180deg, rgb(14 116 144 / 92%), rgb(13 148 136 / 60%));
background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 22px;
font-weight: 800;
letter-spacing: 0.32em;
text-shadow: 0 10px 24px rgb(14 116 144 / 14%);
user-select: none;
}
.workbench-banner__subtitle {
margin: 0;
color: rgb(100 116 139 / 92%);
@@ -191,17 +157,6 @@ function handleCreateTask() {
user-select: none;
}
.workbench-banner__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.workbench-banner__btn-icon {
margin-right: 4px;
font-size: 16px;
}
.workbench-banner__rhythm {
display: flex;
flex-direction: column;

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

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 type { RouteKey } from '@elegant-router/types';
import { useRouterPush } from '@/hooks/common/router';
import { filterWorkbenchTodoItems } from '../homepage';
import type { WorkbenchTodoItem, WorkbenchTodoTimeBucket } from '../homepage';
import {
type WorkbenchTodoItem,
type WorkbenchTodoTimeBucket,
buildWorkbenchTodoItems,
filterWorkbenchTodoItems
} from '../homepage';
import { workbenchTodoMock } from '../mock';
import WorkbenchModuleCard from './workbench-module-card.vue';
defineOptions({ name: 'WorkbenchTodoPanel' });
interface Props {
items: WorkbenchTodoItem[];
editing?: boolean;
collapsed?: boolean;
}
const props = defineProps<Props>();
withDefaults(defineProps<Props>(), { editing: false, collapsed: false });
defineEmits<{
(e: 'hide'): void;
(e: 'toggle-collapse'): void;
}>();
const { routerPushByKey } = useRouterPush();
@@ -24,14 +36,16 @@ const buckets: Array<{ key: WorkbenchTodoTimeBucket; label: string }> = [
{ key: 'overdue', label: '逾期' }
];
const items = computed(() => buildWorkbenchTodoItems(workbenchTodoMock));
const bucketCounts = computed(() => ({
all: props.items.length,
today: filterWorkbenchTodoItems(props.items, 'today').length,
week: filterWorkbenchTodoItems(props.items, 'week').length,
overdue: filterWorkbenchTodoItems(props.items, 'overdue').length
all: items.value.length,
today: filterWorkbenchTodoItems(items.value, 'today').length,
week: filterWorkbenchTodoItems(items.value, 'week').length,
overdue: filterWorkbenchTodoItems(items.value, 'overdue').length
}));
const filteredItems = computed(() => filterWorkbenchTodoItems(props.items, activeBucket.value));
const filteredItems = computed(() => filterWorkbenchTodoItems(items.value, activeBucket.value));
function handleClickItem(item: WorkbenchTodoItem) {
if (!item.routeKey) return;
@@ -48,13 +62,14 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</script>
<template>
<ElCard class="workbench-todo card-wrapper" shadow="never">
<template #header>
<div class="workbench-todo__header">
<div class="workbench-todo__title-group">
<h3 class="workbench-todo__title">我的待办</h3>
<p class="workbench-todo__desc">需要我处理的需求评审任务工单与 @ 提醒</p>
</div>
<WorkbenchModuleCard
title="我的待办"
icon="mdi:clipboard-text-clock-outline"
:editing="editing"
:collapsed="collapsed"
@hide="$emit('hide')"
@toggle-collapse="$emit('toggle-collapse')"
>
<div class="workbench-todo__tabs">
<button
v-for="bucket in buckets"
@@ -68,8 +83,6 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
<span class="workbench-todo__tab-count">{{ bucketCounts[bucket.key] }}</span>
</button>
</div>
</div>
</template>
<div v-if="filteredItems.length" class="workbench-todo__list">
<article
@@ -101,49 +114,15 @@ function getDeadlineToneClass(item: WorkbenchTodoItem) {
</article>
</div>
<ElEmpty v-else description="当前筛选下暂无待办" :image-size="72" />
</ElCard>
</WorkbenchModuleCard>
</template>
<style scoped>
.workbench-todo {
overflow: hidden;
}
:deep(.el-card__header) {
padding: 16px 18px;
border-bottom: 1px solid rgb(226 232 240 / 80%);
}
.workbench-todo__header {
display: flex;
flex-direction: column;
gap: 14px;
}
.workbench-todo__title-group {
display: flex;
flex-direction: column;
gap: 4px;
}
.workbench-todo__title {
margin: 0;
color: rgb(15 23 42 / 98%);
font-size: 16px;
font-weight: 700;
}
.workbench-todo__desc {
margin: 0;
color: rgb(100 116 139 / 92%);
font-size: 13px;
line-height: 1.6;
}
.workbench-todo__tabs {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin-bottom: 14px;
}
.workbench-todo__tab {